mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
Merge branch 'master' into feat/fix-8306-capture-wedge-30c2b0
Resolve operation-log.effects.ts conflict from master's #8307 fix (#8382). Master fixed the #8307 quota-retry double-dequeue by forcing skipDequeue=true on the retry within the positional FIFO model. This PR removes that FIFO queue entirely (pending counter + idempotent extractEntityChanges), so the double-dequeue is structurally impossible — the retry just re-extracts the same action. Took this PR's isDeferredWrite-based signature for all three hunks and updated the comment to reflect the structural fix. Replaced the now-obsolete dequeue-based #8307 regression test (it spied on the removed dequeue()) with one asserting the new model: the quota retry re-extracts the SAME action via extractEntityChanges and never consumes a second op.
This commit is contained in:
commit
2737cd68f7
53 changed files with 974 additions and 137 deletions
|
|
@ -227,6 +227,22 @@ An import is an explicit user action to restore **all clients** to a specific st
|
|||
|
||||
In `detectConflict()`, operations with `opType` of `SYNC_IMPORT`, `BACKUP_IMPORT`, or `REPAIR` return `{ hasConflict: false }` immediately. These operations replace entire state and don't operate on individual entities.
|
||||
|
||||
### Multi-Entity Ops and Server-Side Conflict Detection (issue #8334)
|
||||
|
||||
An op may carry `entityIds: string[]` (batch actions: `deleteTasks`, `moveToArchive`, `__updateMultipleTaskSimple`, round-time-spent, task-repeat-cfg/board/issue-provider batches). `detectConflict()` checks the **incoming** op against **all** of its `entityIds`. The op must also be **looked up** by all of its entities once stored — otherwise a later stale write to a non-first entity would find no prior writer and be wrongly accepted as non-conflicting.
|
||||
|
||||
To make that symmetric, the `operations` row stores:
|
||||
|
||||
- `entity_id` — the client-supplied scalar. For batch ops the client sets it to `entityIds[0]` (`operation-log.effects.ts`), but the **server does not enforce** `entity_id === entityIds[0]`. It is the lookup key for single-entity ops and the first entity of multi-entity ops, and is also used by duplicate detection.
|
||||
- `entity_ids` — the entity set for **multi-entity ops only** (a `text[]` column; populated via `getStoredEntityIds(op)`, which returns `[]` for single-entity ops). Keeping single-entity rows out of this column keeps the `GIN(entity_ids)` index small and the array-branch lookup cheap.
|
||||
|
||||
The lookups in `conflict.ts` match a requested entity as the scalar `entity_id` **or** a member of `entity_ids`:
|
||||
|
||||
- `detectConflictForEntity` (single) — Prisma `where: { OR: [{ entityId }, { entityIds: { has: entityId } }] }`, ordered by `server_seq`. The `OR` spans the `entity_id` btree and the `entity_ids` GIN, so the planner uses a `BitmapOr` + sort bounded by the entity's stored version depth (op-log pruning keeps that small). If a real-Postgres `EXPLAIN` on a deep-history entity ever shows this hot path is a problem, the escalation is two ordered `LIMIT 1` lookups (scalar btree + `entity_ids` GIN) taking the higher `server_seq` — the array side stays small because the column is multi-entity-only.
|
||||
- `detectConflictForEntities` / `prefetchLatestEntityOpsForBatch` (batch) — raw SQL unnesting `CASE WHEN cardinality(entity_ids) > 0 THEN entity_ids ELSE ARRAY[entity_id] END`, with a `entity_ids && ... OR entity_id = ANY(...)` prefilter so the `GIN(entity_ids)` index (migration `20260613000001`) and the existing `entity_id` btree stay usable.
|
||||
|
||||
**Forward-only by design:** rows written before migration `20260613000000` have an empty `entity_ids` array and fall back to the scalar `entity_id` (= first entity) in the `CASE` expression / scalar branch above — there is no `UPDATE` backfill. Entities 2..n of already-stored multi-entity ops were never persisted and are unrecoverable, so they remain invisible to conflict detection until that entity gets a fresh write. This residual is bounded: client-side LWW is unaffected (the client persists the full op and `VectorClockService.getEntityFrontier()` fans each op out to **every** entity), and the server only builds an authoritative snapshot from non-encrypted ops (`replayOpsToState()` throws on encrypted ops), so the pre-fix gap could only surface a stale value to a fresh client on non-encrypted self-hosted servers.
|
||||
|
||||
### The `SyncImportFilterService` Algorithm
|
||||
|
||||
Implemented in `src/app/op-log/sync/sync-import-filter.service.ts`:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
-- Add the entity_ids array column backing #8334 multi-entity conflict detection.
|
||||
--
|
||||
-- Ops can touch multiple entities (deleteTasks, moveToArchive, __updateMultipleTaskSimple,
|
||||
-- round-time-spent, batch board/issue-provider actions). Until now only the scalar
|
||||
-- entity_id (= entityIds[0]) was persisted, so once a multi-entity op was stored only its
|
||||
-- first entity participated in future conflict lookups; a later stale write to entity 2..n
|
||||
-- found no prior writer and was wrongly accepted. entity_ids carries the full set so the
|
||||
-- conflict lookups (src/sync/conflict.ts) can match any touched entity across uploads.
|
||||
--
|
||||
-- ADD COLUMN ... NOT NULL DEFAULT <constant> is a metadata-only operation on PostgreSQL 11+
|
||||
-- (the default is stored in the catalog, no table rewrite, no long lock). There is no data
|
||||
-- backfill on purpose: pre-migration rows keep an empty array and fall back to entity_id in
|
||||
-- the conflict lookups, so the fix is forward-only (entities 2..n of already-stored ops were
|
||||
-- never persisted and are unrecoverable).
|
||||
ALTER TABLE "operations" ADD COLUMN "entity_ids" TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
-- GIN index for #8334 multi-entity conflict detection.
|
||||
--
|
||||
-- The latest-writer lookups match a requested entity id against entity_ids
|
||||
-- (entity_ids @> ARRAY[id] / entity_ids && ARRAY[...]). A GIN index on the array
|
||||
-- lets those match without scanning a user's operation history.
|
||||
--
|
||||
-- Single-statement CREATE INDEX CONCURRENTLY: `prisma migrate deploy` applies it
|
||||
-- natively because PostgreSQL does not wrap a single statement in an implicit
|
||||
-- transaction (CONCURRENTLY is forbidden inside a transaction). See
|
||||
-- prisma/migrations/README.md.
|
||||
--
|
||||
-- Avoid name-only idempotency: an interrupted concurrent build leaves an INVALID
|
||||
-- index with this name, which must fail loudly and be rebuilt rather than be
|
||||
-- silently skipped (matching the 20260511000000 precedent). Pre-migration rows
|
||||
-- have an empty entity_ids array; the conflict lookups fall back to the scalar
|
||||
-- entity_id (served by the existing
|
||||
-- operations_user_id_entity_type_entity_id_server_seq index), so no data backfill
|
||||
-- is needed for this index to be correct.
|
||||
CREATE INDEX CONCURRENTLY "operations_entity_ids_gin"
|
||||
ON "operations" USING GIN ("entity_ids");
|
||||
|
|
@ -69,6 +69,14 @@ model Operation {
|
|||
opType String @map("op_type")
|
||||
entityType String @map("entity_type")
|
||||
entityId String? @map("entity_id")
|
||||
// Entity set for multi-entity (batch) ops only — single-entity ops store [] and
|
||||
// are matched via the scalar `entityId` (the client sets `entityId` to entityIds[0]
|
||||
// but the server does not enforce that). Conflict detection consults this array so
|
||||
// writes to a non-first entity are not invisible across uploads (#8334).
|
||||
// Pre-migration rows have an empty array and fall back to `entityId` in the
|
||||
// conflict lookups, so the backfill is forward-only (older entities 2..n are
|
||||
// unrecoverable — they were never persisted).
|
||||
entityIds String[] @default([]) @map("entity_ids")
|
||||
payload Json
|
||||
payloadBytes BigInt @default(0) @map("payload_bytes")
|
||||
vectorClock Json @map("vector_clock")
|
||||
|
|
@ -85,6 +93,8 @@ model Operation {
|
|||
@@index([userId, clientId])
|
||||
@@index([userId, receivedAt])
|
||||
// Restore-point opType lookups use a raw partial index in the 20260512000000 migration.
|
||||
// Multi-entity conflict lookups (#8334) use a raw GIN index on entity_ids in the
|
||||
// 20260613000001 migration (Prisma does not model GIN array indexes here).
|
||||
@@map("operations")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,16 +68,28 @@ export const detectConflictForEntities = async (
|
|||
start,
|
||||
start + CONFLICT_DETECTION_ENTITY_BATCH_SIZE,
|
||||
);
|
||||
// Match each requested id against the op's full entity set: entity_ids when
|
||||
// present, else the scalar entity_id (pre-migration rows). The `&&`/`= ANY`
|
||||
// prefilter keeps the GIN(entity_ids) + entity_id indexes usable; the unnest
|
||||
// + `eid = ANY` keeps only the requested entities, then DISTINCT ON picks the
|
||||
// latest op per entity. Kept inline (not a shared fragment) so the positional
|
||||
// params stay stable for the conflict-detection.spec mock; the same shape is
|
||||
// duplicated in prefetchLatestEntityOpsForBatch — keep them in sync. (#8334)
|
||||
const idArray = Prisma.sql`ARRAY[${Prisma.join(batchEntityIds)}]::text[]`;
|
||||
const latestOps = await tx.$queryRaw<LatestEntityOperationRow[]>`
|
||||
SELECT DISTINCT ON (entity_id)
|
||||
entity_id AS "entityId",
|
||||
client_id AS "clientId",
|
||||
vector_clock AS "vectorClock"
|
||||
FROM operations
|
||||
WHERE user_id = ${userId}
|
||||
AND entity_type = ${op.entityType}
|
||||
AND entity_id IN (${Prisma.join(batchEntityIds)})
|
||||
ORDER BY entity_id, server_seq DESC
|
||||
SELECT DISTINCT ON (eid)
|
||||
eid AS "entityId",
|
||||
o.client_id AS "clientId",
|
||||
o.vector_clock AS "vectorClock"
|
||||
FROM operations o
|
||||
CROSS JOIN LATERAL unnest(
|
||||
CASE WHEN cardinality(o.entity_ids) > 0 THEN o.entity_ids ELSE ARRAY[o.entity_id] END
|
||||
) AS eid
|
||||
WHERE o.user_id = ${userId}
|
||||
AND o.entity_type = ${op.entityType}
|
||||
AND (o.entity_ids && ${idArray} OR o.entity_id = ANY(${idArray}))
|
||||
AND eid = ANY(${idArray})
|
||||
ORDER BY eid, o.server_seq DESC
|
||||
`;
|
||||
|
||||
const latestOpByEntityId = new Map<string, LatestEntityOperationRow>();
|
||||
|
|
@ -173,20 +185,28 @@ export const detectConflictForEntity = async (
|
|||
entityId: string,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<ConflictResult> => {
|
||||
// Get the latest operation for this entity
|
||||
// Get the latest op that touched this entity. A multi-entity (batch) op stores
|
||||
// its full set in entity_ids, so match the entity as the scalar entity_id OR a
|
||||
// member of entity_ids. Single-entity ops store an empty array and are matched
|
||||
// via the scalar; pre-migration rows likewise fall back to the scalar (#8334).
|
||||
//
|
||||
// PERF: the OR spans the entity_id btree + the entity_ids GIN, so the planner
|
||||
// uses a BitmapOr + sort rather than an ordered LIMIT-1 walk. Bounded by one
|
||||
// entity's stored version depth (op-log pruning keeps that small, so the sort is
|
||||
// sub-ms in practice). If a real-Postgres EXPLAIN on a deep-history entity ever
|
||||
// shows this hot path (run per single-entity upload) is a problem, split into two
|
||||
// ordered LIMIT-1 lookups (scalar btree + entity_ids GIN) and take the higher
|
||||
// server_seq — the array branch stays small because entity_ids is multi-entity-only.
|
||||
// The batch unnest paths (detectConflictForEntities / prefetchLatestEntityOpsForBatch)
|
||||
// carry the larger sort, so EXPLAIN those first under heavy-user latency.
|
||||
const existingOp = await tx.operation.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
entityType: op.entityType,
|
||||
entityId,
|
||||
},
|
||||
select: {
|
||||
clientId: true,
|
||||
vectorClock: true,
|
||||
},
|
||||
orderBy: {
|
||||
serverSeq: 'desc',
|
||||
OR: [{ entityId }, { entityIds: { has: entityId } }],
|
||||
},
|
||||
select: { clientId: true, vectorClock: true },
|
||||
orderBy: { serverSeq: 'desc' },
|
||||
});
|
||||
|
||||
// No existing operation = no conflict
|
||||
|
|
@ -287,6 +307,11 @@ export const toStableJsonValue = (value: unknown): unknown => {
|
|||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* All entities an op touches (full set, including single-entity ops). Use for the
|
||||
* incoming-op conflict check and the in-memory in-batch map — NOT for the persisted
|
||||
* `entity_ids` column, which uses {@link getStoredEntityIds} (multi-entity only).
|
||||
*/
|
||||
export const getConflictEntityIds = (op: Operation): string[] => {
|
||||
const rawEntityIds = op.entityIds?.length
|
||||
? op.entityIds
|
||||
|
|
@ -296,6 +321,26 @@ export const getConflictEntityIds = (op: Operation): string[] => {
|
|||
return Array.from(new Set(rawEntityIds));
|
||||
};
|
||||
|
||||
/**
|
||||
* The entity_ids array to persist with an op. Ops whose touched-entity set is
|
||||
* already covered by the scalar entity_id store an empty array, so single-entity
|
||||
* ops (the vast majority) stay out of the entity_ids GIN index — keeping it small
|
||||
* and off their insert write path (Postgres GIN indexes no keys for an empty
|
||||
* array). Any other set is stored in full.
|
||||
*
|
||||
* The gate is "is the set exactly [entity_id]?", NOT "length > 1": a batch op
|
||||
* whose ids dedup to a single value that differs from entity_id (the server does
|
||||
* not enforce entity_id === entityIds[0]) must still be stored, or that entity
|
||||
* would be invisible to conflict lookups (#8334).
|
||||
*/
|
||||
export const getStoredEntityIds = (op: Operation): string[] => {
|
||||
const ids = getConflictEntityIds(op);
|
||||
if (ids.length <= 1 && ids[0] === op.entityId) {
|
||||
return [];
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
export const getEntityConflictKey = (entityType: string, entityId: string): string => {
|
||||
return `${entityType}\u0000${entityId}`;
|
||||
};
|
||||
|
|
@ -332,22 +377,36 @@ export const prefetchLatestEntityOpsForBatch = async (
|
|||
start < entityPairs.length;
|
||||
start += CONFLICT_DETECTION_ENTITY_BATCH_SIZE
|
||||
) {
|
||||
const touchedRows = entityPairs
|
||||
.slice(start, start + CONFLICT_DETECTION_ENTITY_BATCH_SIZE)
|
||||
.map(({ entityType, entityId }) => Prisma.sql`(${entityType}, ${entityId})`);
|
||||
const batchPairs = entityPairs.slice(
|
||||
start,
|
||||
start + CONFLICT_DETECTION_ENTITY_BATCH_SIZE,
|
||||
);
|
||||
const touchedRows = batchPairs.map(
|
||||
({ entityType, entityId }) => Prisma.sql`(${entityType}, ${entityId})`,
|
||||
);
|
||||
// Prefilter array (all requested ids) so the JOIN below can match a requested
|
||||
// id inside a stored op's entity_ids set, not just its scalar entity_id, while
|
||||
// keeping the GIN(entity_ids) + entity_id indexes usable. (#8334)
|
||||
const idArray = Prisma.sql`ARRAY[${Prisma.join(
|
||||
batchPairs.map((pair) => pair.entityId),
|
||||
)}]::text[]`;
|
||||
|
||||
const latestOps = await tx.$queryRaw<LatestBatchEntityOperationRow[]>`
|
||||
SELECT DISTINCT ON (o.entity_type, o.entity_id)
|
||||
SELECT DISTINCT ON (o.entity_type, eid)
|
||||
o.entity_type AS "entityType",
|
||||
o.entity_id AS "entityId",
|
||||
eid AS "entityId",
|
||||
o.client_id AS "clientId",
|
||||
o.vector_clock AS "vectorClock"
|
||||
FROM operations o
|
||||
CROSS JOIN LATERAL unnest(
|
||||
CASE WHEN cardinality(o.entity_ids) > 0 THEN o.entity_ids ELSE ARRAY[o.entity_id] END
|
||||
) AS eid
|
||||
JOIN (VALUES ${Prisma.join(touchedRows)}) AS touched(entity_type, entity_id)
|
||||
ON touched.entity_type = o.entity_type
|
||||
AND touched.entity_id = o.entity_id
|
||||
AND touched.entity_id = eid
|
||||
WHERE o.user_id = ${userId}
|
||||
ORDER BY o.entity_type, o.entity_id, o.server_seq DESC
|
||||
AND (o.entity_ids && ${idArray} OR o.entity_id = ANY(${idArray}))
|
||||
ORDER BY o.entity_type, eid, o.server_seq DESC
|
||||
`;
|
||||
|
||||
for (const latestOp of latestOps) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
detectConflict,
|
||||
getBatchConflictEntityPairs,
|
||||
getConflictEntityIds,
|
||||
getStoredEntityIds,
|
||||
getEntityConflictKey,
|
||||
isSameDuplicateOperation,
|
||||
prefetchLatestEntityOpsForBatch,
|
||||
|
|
@ -544,6 +545,10 @@ export class OperationUploadService {
|
|||
opType: candidate.op.opType,
|
||||
entityType: candidate.op.entityType,
|
||||
entityId: candidate.op.entityId ?? null,
|
||||
// Persist the full entity set for multi-entity ops so conflict detection
|
||||
// can match a write to any touched entity across uploads, not just
|
||||
// entityIds[0]; single-entity ops store [] and use the scalar (#8334).
|
||||
entityIds: getStoredEntityIds(candidate.op),
|
||||
payload: candidate.op.payload as Prisma.InputJsonValue,
|
||||
payloadBytes: BigInt(candidate.storageBytes),
|
||||
vectorClock: candidate.op.vectorClock as Prisma.InputJsonValue,
|
||||
|
|
@ -800,6 +805,10 @@ export class OperationUploadService {
|
|||
opType: op.opType,
|
||||
entityType: op.entityType,
|
||||
entityId: op.entityId ?? null,
|
||||
// Persist the full entity set for multi-entity ops so conflict detection
|
||||
// can match a write to any touched entity across uploads, not just
|
||||
// entityIds[0]; single-entity ops store [] and use the scalar (#8334).
|
||||
entityIds: getStoredEntityIds(op),
|
||||
payload: op.payload as Prisma.InputJsonValue,
|
||||
payloadBytes: BigInt(sized.bytes),
|
||||
vectorClock: op.vectorClock as Prisma.InputJsonValue,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
sanitizeVectorClock,
|
||||
validatePayload,
|
||||
} from '../sync.types';
|
||||
import { ENTITY_TYPES } from '@sp/shared-schema';
|
||||
import { ENTITY_TYPES, SUPER_SYNC_MAX_ENTITY_IDS_PER_OP } from '@sp/shared-schema';
|
||||
import { Logger } from '../../logger';
|
||||
|
||||
/**
|
||||
|
|
@ -106,6 +106,32 @@ export class ValidationService {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate the multi-entity set the same way as the scalar entityId. The HTTP
|
||||
// contract Zod schema also bounds this, but enforcing it here keeps the invariant
|
||||
// with the op (defense-in-depth for any non-HTTP caller) since entityIds is now
|
||||
// persisted and consulted by conflict detection (#8334).
|
||||
if (op.entityIds !== undefined && op.entityIds !== null) {
|
||||
if (
|
||||
!Array.isArray(op.entityIds) ||
|
||||
op.entityIds.length > SUPER_SYNC_MAX_ENTITY_IDS_PER_OP
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid entityIds: not an array or too many entries',
|
||||
errorCode: SYNC_ERROR_CODES.INVALID_ENTITY_ID,
|
||||
};
|
||||
}
|
||||
for (const id of op.entityIds) {
|
||||
if (typeof id !== 'string' || id.length > 255 || id.trim().length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid entityIds element: must be a non-empty string <= 255 chars',
|
||||
errorCode: SYNC_ERROR_CODES.INVALID_ENTITY_ID,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Require entityId for regular entity operations.
|
||||
// Full-state operations (SYNC_IMPORT, BACKUP_IMPORT, REPAIR) and bulk entity types
|
||||
// (ALL, RECOVERY) legitimately don't have entityId.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,28 @@ vi.mock('../src/db', async () => {
|
|||
applyOperationSelect(state.operations.get(args.where.id), args.select) || null
|
||||
);
|
||||
}
|
||||
// Single-entity conflict lookup: where { userId, entityType,
|
||||
// OR: [{ entityId: X }, { entityIds: { has: X } }] } — match X as the
|
||||
// scalar entity_id OR a member of the entity_ids array (#8334).
|
||||
if (Array.isArray(args.where?.OR) && args.where?.entityType) {
|
||||
state.entityConflictFindFirstCount++;
|
||||
const scalarClause = args.where.OR.find((c: any) => 'entityId' in c);
|
||||
const hasClause = args.where.OR.find(
|
||||
(c: any) => c.entityIds?.has !== undefined,
|
||||
);
|
||||
const targetId = scalarClause?.entityId ?? hasClause?.entityIds?.has;
|
||||
const ops = Array.from(state.operations.values())
|
||||
.filter(
|
||||
(op: any) =>
|
||||
op.userId === args.where.userId &&
|
||||
op.entityType === args.where.entityType &&
|
||||
(op.entityId === targetId ||
|
||||
(Array.isArray(op.entityIds) && op.entityIds.includes(targetId))),
|
||||
)
|
||||
.sort((a: any, b: any) => b.serverSeq - a.serverSeq);
|
||||
return applyOperationSelect(ops[0], args.select) || null;
|
||||
}
|
||||
// Scalar-only lookup (other callers): where { userId, entityType, entityId }.
|
||||
if (args.where?.entityId && args.where?.entityType) {
|
||||
state.entityConflictFindFirstCount++;
|
||||
const ops = Array.from(state.operations.values())
|
||||
|
|
@ -200,23 +222,29 @@ vi.mock('../src/db', async () => {
|
|||
(entityId): entityId is string => typeof entityId === 'string',
|
||||
),
|
||||
);
|
||||
// An op covers an entity via its full entity_ids set (multi-entity ops),
|
||||
// falling back to the scalar entity_id for pre-migration rows (#8334).
|
||||
const coveredEntityIds = (op: any): string[] =>
|
||||
Array.isArray(op.entityIds) && op.entityIds.length > 0
|
||||
? op.entityIds
|
||||
: op.entityId != null
|
||||
? [op.entityId]
|
||||
: [];
|
||||
const latestByEntityId = new Map<string, any>();
|
||||
const ops = Array.from(state.operations.values())
|
||||
.filter(
|
||||
(op: any) =>
|
||||
op.userId === userId &&
|
||||
op.entityType === entityType &&
|
||||
batchEntityIds.has(op.entityId),
|
||||
)
|
||||
.filter((op: any) => op.userId === userId && op.entityType === entityType)
|
||||
.sort((a: any, b: any) => b.serverSeq - a.serverSeq);
|
||||
|
||||
for (const op of ops) {
|
||||
if (!op.entityId || latestByEntityId.has(op.entityId)) continue;
|
||||
latestByEntityId.set(op.entityId, op);
|
||||
for (const eid of coveredEntityIds(op)) {
|
||||
if (batchEntityIds.has(eid) && !latestByEntityId.has(eid)) {
|
||||
latestByEntityId.set(eid, op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(latestByEntityId.values()).map((op: any) => ({
|
||||
entityId: op.entityId,
|
||||
return Array.from(latestByEntityId.entries()).map(([eid, op]) => ({
|
||||
entityId: eid,
|
||||
clientId: op.clientId,
|
||||
vectorClock: op.vectorClock,
|
||||
}));
|
||||
|
|
@ -969,5 +997,75 @@ describe('Conflict Detection', () => {
|
|||
});
|
||||
expect(result[0].error).toContain('TASK:task-2');
|
||||
});
|
||||
|
||||
// Regression for #8334. The test above proves an *incoming* multi-entity op
|
||||
// is checked against all its ids. These two prove the reverse — a *stored*
|
||||
// multi-entity op exposes every entity to later conflict lookups, not just
|
||||
// entityIds[0] — across both the single-entity and batch lookup paths.
|
||||
it('#8334 single path: stale write to a non-first stored entity conflicts', async () => {
|
||||
const service = getSyncService();
|
||||
|
||||
// clientA stores a multi-entity op over [task-1, task-2]
|
||||
// (persisted scalar entity_id = task-1, entity_ids = both).
|
||||
const stored = await service.uploadOps(userId, clientA, [
|
||||
createOp({
|
||||
entityId: 'task-1',
|
||||
entityIds: ['task-1', 'task-2'],
|
||||
clientId: clientA,
|
||||
vectorClock: { [clientA]: 1 },
|
||||
}),
|
||||
]);
|
||||
expect(stored[0].accepted).toBe(true);
|
||||
|
||||
// clientB sends a stale single-entity op for task-2 (the SECOND entity).
|
||||
// {clientB:1} is CONCURRENT with {clientA:1}.
|
||||
const result = await service.uploadOps(userId, clientB, [
|
||||
createOp({
|
||||
entityId: 'task-2',
|
||||
clientId: clientB,
|
||||
vectorClock: { [clientB]: 1 },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
accepted: false,
|
||||
errorCode: SYNC_ERROR_CODES.CONFLICT_CONCURRENT,
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE: with the default (non-batch) upload config this exercises
|
||||
// `detectConflictForEntities`. The `prefetchLatestEntityOpsForBatch` variant
|
||||
// (batchUpload=true) shares the same entity_ids matching SQL but is not driven
|
||||
// here; its raw query is validated separately against real Postgres.
|
||||
it('#8334 batch path: incoming multi-entity op hits a non-first stored entity', async () => {
|
||||
const service = getSyncService();
|
||||
|
||||
const stored = await service.uploadOps(userId, clientA, [
|
||||
createOp({
|
||||
entityId: 'task-1',
|
||||
entityIds: ['task-1', 'task-2'],
|
||||
clientId: clientA,
|
||||
vectorClock: { [clientA]: 1 },
|
||||
}),
|
||||
]);
|
||||
expect(stored[0].accepted).toBe(true);
|
||||
|
||||
// Incoming *multi-entity* op (→ batch lookup path) touching task-2.
|
||||
const result = await service.uploadOps(userId, clientB, [
|
||||
createOp({
|
||||
entityId: 'task-2',
|
||||
entityIds: ['task-2', 'task-9'],
|
||||
clientId: clientB,
|
||||
vectorClock: { [clientB]: 1 },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(testState.batchConflictQueryCount).toBeGreaterThan(0);
|
||||
expect(result[0]).toMatchObject({
|
||||
accepted: false,
|
||||
errorCode: SYNC_ERROR_CODES.CONFLICT_CONCURRENT,
|
||||
});
|
||||
expect(result[0].error).toContain('TASK:task-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { detectConflict, getStoredEntityIds } from '../src/sync/conflict';
|
||||
import type { Operation } from '../src/sync/sync.types';
|
||||
|
||||
/**
|
||||
* Unit-level regression for #8334's single-entity lookup path
|
||||
* (detectConflict → detectConflictForEntity). It exercises the REAL function
|
||||
* against a tx mock modelling how production persists ops: multi-entity ops carry
|
||||
* the full entity_ids array, single-entity ops store []. detectConflictForEntity
|
||||
* matches an entity via a Prisma `OR: [{entityId}, {entityIds:{has}}]` filter,
|
||||
* which the mock's findFirst reproduces.
|
||||
*
|
||||
* The batch lookup paths (raw unnest SQL) are validated separately against real
|
||||
* Postgres semantics and in conflict-detection.spec.ts.
|
||||
*/
|
||||
type StoredRow = {
|
||||
userId: number;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
entityIds: string[];
|
||||
clientId: string;
|
||||
serverSeq: number;
|
||||
vectorClock: Record<string, number>;
|
||||
};
|
||||
|
||||
const makeTx = (rows: StoredRow[]): any => ({
|
||||
operation: {
|
||||
// Mirrors: where { userId, entityType, OR: [{entityId:X}, {entityIds:{has:X}}] }
|
||||
findFirst: async ({ where }: any) => {
|
||||
const target =
|
||||
where.OR.find((c: any) => 'entityId' in c)?.entityId ??
|
||||
where.OR.find((c: any) => c.entityIds?.has !== undefined)?.entityIds?.has;
|
||||
return (
|
||||
rows
|
||||
.filter(
|
||||
(r) =>
|
||||
r.userId === where.userId &&
|
||||
r.entityType === where.entityType &&
|
||||
(r.entityId === target || r.entityIds.includes(target)),
|
||||
)
|
||||
.sort((a, b) => b.serverSeq - a.serverSeq)[0] ?? null
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const staleOp = (entityId: string): Operation =>
|
||||
({
|
||||
id: 'op-b',
|
||||
clientId: 'B',
|
||||
actionType: 'UPDATE_TASK',
|
||||
opType: 'UPD',
|
||||
entityType: 'TASK',
|
||||
entityId,
|
||||
vectorClock: { B: 1 },
|
||||
timestamp: 1,
|
||||
schemaVersion: 1,
|
||||
}) as unknown as Operation;
|
||||
|
||||
const multiEntityRow: StoredRow = {
|
||||
userId: 1,
|
||||
entityType: 'TASK',
|
||||
entityId: 'task-1',
|
||||
entityIds: ['task-1', 'task-2'],
|
||||
clientId: 'A',
|
||||
serverSeq: 1,
|
||||
vectorClock: { A: 1 },
|
||||
};
|
||||
|
||||
describe('#8334 detectConflict single-entity path', () => {
|
||||
it('rejects a stale write to a NON-FIRST entity of a stored multi-entity op', async () => {
|
||||
const result = await detectConflict(1, staleOp('task-2'), makeTx([multiEntityRow]));
|
||||
expect(result.hasConflict).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects a stale write to the first/scalar entity', async () => {
|
||||
const result = await detectConflict(1, staleOp('task-1'), makeTx([multiEntityRow]));
|
||||
expect(result.hasConflict).toBe(true);
|
||||
});
|
||||
|
||||
it('finds a pre-migration single-entity row via the scalar fallback', async () => {
|
||||
const oldRow: StoredRow = {
|
||||
userId: 1,
|
||||
entityType: 'TASK',
|
||||
entityId: 'task-3',
|
||||
entityIds: [], // pre-migration: empty array, only scalar persisted
|
||||
clientId: 'A',
|
||||
serverSeq: 1,
|
||||
vectorClock: { A: 1 },
|
||||
};
|
||||
const result = await detectConflict(1, staleOp('task-3'), makeTx([oldRow]));
|
||||
expect(result.hasConflict).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag an unrelated entity', async () => {
|
||||
const result = await detectConflict(1, staleOp('task-9'), makeTx([multiEntityRow]));
|
||||
expect(result.hasConflict).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#8334 getStoredEntityIds', () => {
|
||||
const op = (over: Partial<Operation>): Operation => over as unknown as Operation;
|
||||
|
||||
it('stores [] for a single-entity op (covered by the scalar)', () => {
|
||||
expect(getStoredEntityIds(op({ entityId: 'task-1' }))).toEqual([]);
|
||||
expect(getStoredEntityIds(op({ entityId: 'task-1', entityIds: ['task-1'] }))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('stores the full set for a multi-entity op', () => {
|
||||
expect(
|
||||
getStoredEntityIds(op({ entityId: 'task-1', entityIds: ['task-1', 'task-2'] })),
|
||||
).toEqual(['task-1', 'task-2']);
|
||||
});
|
||||
|
||||
it('stores [] for an entity-less op', () => {
|
||||
expect(getStoredEntityIds(op({}))).toEqual([]);
|
||||
});
|
||||
|
||||
it('stores the id when a batch op dedups to one value that differs from entityId', () => {
|
||||
// The server does not enforce entityId === entityIds[0]; without this the
|
||||
// entity would be invisible to conflict lookups (#8334).
|
||||
expect(
|
||||
getStoredEntityIds(op({ entityId: 'task-Z', entityIds: ['task-A', 'task-A'] })),
|
||||
).toEqual(['task-A']);
|
||||
});
|
||||
});
|
||||
|
|
@ -133,6 +133,38 @@ describe('performance migrations', () => {
|
|||
expect(migrationSql).not.toMatch(/\bBEGIN\b|\bCOMMIT\b/i);
|
||||
});
|
||||
|
||||
it('adds the operation entity_ids column as a metadata-only column (no table rewrite)', () => {
|
||||
const migrationSql = readMigration('20260613000000_add_operation_entity_ids');
|
||||
|
||||
// Same fast-path guards as payload_bytes: ADD COLUMN with a constant default is
|
||||
// metadata-only on PG 11+. A future edit to an expression default or a separate
|
||||
// UPDATE backfill would rewrite/lock a 100M-row table — #8334 is forward-only by
|
||||
// design (pre-migration rows fall back to the scalar entity_id), so no backfill.
|
||||
expect(migrationSql).toMatch(
|
||||
/ALTER TABLE "operations"\s+ADD COLUMN "entity_ids" TEXT\[\] NOT NULL DEFAULT '\{\}'/i,
|
||||
);
|
||||
expect(migrationSql).not.toMatch(/\bUPDATE\b/i);
|
||||
expect(migrationSql).not.toMatch(/\bUSING\b/i);
|
||||
expect(migrationSql).not.toMatch(/\bDROP\s+TABLE\b/i);
|
||||
expect(migrationSql).not.toMatch(/\bBEGIN\b|\bCOMMIT\b/i);
|
||||
});
|
||||
|
||||
it('adds the entity_ids GIN index concurrently as a single native-apply statement', () => {
|
||||
const migrationSql = readMigration(
|
||||
'20260613000001_add_operation_entity_ids_gin_index',
|
||||
);
|
||||
|
||||
expect(migrationSql).toContain('CREATE INDEX CONCURRENTLY');
|
||||
expect(migrationSql).toContain('"operations_entity_ids_gin"');
|
||||
expect(migrationSql).toContain('USING GIN ("entity_ids")');
|
||||
// Bare CREATE (no IF NOT EXISTS / no drop-then-create): an interrupted concurrent
|
||||
// build must fail loudly, matching the 20260511000000 precedent.
|
||||
expect(migrationSql).not.toMatch(/\bIF\s+NOT\s+EXISTS\b/i);
|
||||
expect(migrationSql).not.toMatch(/\bALTER\s+TABLE\b/i);
|
||||
expect(migrationSql).not.toMatch(/\bDROP\s+TABLE\b/i);
|
||||
expect(migrationSql).not.toMatch(/\bBEGIN\b|\bCOMMIT\b/i);
|
||||
});
|
||||
|
||||
it('runs migrations before replacing the app during compose deploys', () => {
|
||||
const deployScript = readFileSync(join(currentDir, '../scripts/deploy.sh'), 'utf8');
|
||||
const runtimeMigrateScript = readFileSync(
|
||||
|
|
|
|||
|
|
@ -325,17 +325,19 @@ vi.mock('../src/db', async () => {
|
|||
return [{ lastSeq }];
|
||||
}
|
||||
if (sql.includes('JOIN (VALUES')) {
|
||||
const txUserId = params[params.length - 1] as number;
|
||||
const touchedParams = params.slice(0, -1).flatMap((param: unknown): unknown[] => {
|
||||
if (
|
||||
param &&
|
||||
typeof param === 'object' &&
|
||||
Array.isArray((param as { values?: unknown[] }).values)
|
||||
) {
|
||||
return (param as { values: unknown[] }).values;
|
||||
}
|
||||
return [param];
|
||||
});
|
||||
// Params are [touchedRows (VALUES join), userId, idArray, idArray]: userId
|
||||
// is the only number; the VALUES join is the first Sql fragment, whose
|
||||
// .values hold the flattened (entity_type, entity_id) touched pairs. The
|
||||
// idArray prefilter params (#8334) are not needed by the mock. (Previously
|
||||
// userId was the last param and the join the only fragment.)
|
||||
const txUserId = params.find((p: unknown) => typeof p === 'number') as number;
|
||||
const valuesParam = params.find(
|
||||
(p: unknown) =>
|
||||
!!p &&
|
||||
typeof p === 'object' &&
|
||||
Array.isArray((p as { values?: unknown[] }).values),
|
||||
) as { values: unknown[] } | undefined;
|
||||
const touchedParams = valuesParam?.values ?? [];
|
||||
const touchedPairs = new Set<string>();
|
||||
for (let i = 0; i < touchedParams.length; i += 2) {
|
||||
touchedPairs.add(`${touchedParams[i]}\u0000${touchedParams[i + 1]}`);
|
||||
|
|
|
|||
|
|
@ -81,6 +81,24 @@ vi.mock('../src/db', async () => {
|
|||
applyOperationSelect(state.operations.get(args.where.id), args.select) || null
|
||||
);
|
||||
}
|
||||
// Single-entity conflict lookup: where { userId, entityType,
|
||||
// OR: [{ entityId: X }, { entityIds: { has: X } }] } (#8334).
|
||||
if (Array.isArray(args.where?.OR) && args.where?.entityType) {
|
||||
const targetId =
|
||||
args.where.OR.find((c: any) => 'entityId' in c)?.entityId ??
|
||||
args.where.OR.find((c: any) => c.entityIds?.has !== undefined)?.entityIds
|
||||
?.has;
|
||||
const ops = Array.from(state.operations.values())
|
||||
.filter(
|
||||
(op: any) =>
|
||||
op.userId === args.where.userId &&
|
||||
op.entityType === args.where.entityType &&
|
||||
(op.entityId === targetId ||
|
||||
(Array.isArray(op.entityIds) && op.entityIds.includes(targetId))),
|
||||
)
|
||||
.sort((a: any, b: any) => b.serverSeq - a.serverSeq);
|
||||
return applyOperationSelect(ops[0], args.select) || null;
|
||||
}
|
||||
if (args.where?.entityId && args.where?.entityType) {
|
||||
const ops = Array.from(state.operations.values())
|
||||
.filter(
|
||||
|
|
|
|||
|
|
@ -114,6 +114,37 @@ describe('ValidationService', () => {
|
|||
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
|
||||
});
|
||||
|
||||
// === entityIds (multi-entity batch ops) validation (#8334) ===
|
||||
|
||||
it('should accept a valid entityIds array', () => {
|
||||
const op = createValidOp({ entityIds: ['task-1', 'task-2'] });
|
||||
expect(validationService.validateOp(op, clientId).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject an entityIds element longer than 255 characters', () => {
|
||||
const op = createValidOp({ entityIds: ['ok', 'x'.repeat(256)] });
|
||||
const result = validationService.validateOp(op, clientId);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
|
||||
});
|
||||
|
||||
it('should reject a non-string / empty entityIds element', () => {
|
||||
expect(
|
||||
validationService.validateOp(createValidOp({ entityIds: [123] }), clientId).valid,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validationService.validateOp(createValidOp({ entityIds: [' '] }), clientId)
|
||||
.valid,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject more than SUPER_SYNC_MAX_ENTITY_IDS_PER_OP entries', () => {
|
||||
const op = createValidOp({ entityIds: new Array(1001).fill('id') });
|
||||
const result = validationService.validateOp(op, clientId);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
|
||||
});
|
||||
|
||||
// === entityId validation for regular entity types ===
|
||||
|
||||
it('should reject null entityId for regular entity type (TASK)', () => {
|
||||
|
|
|
|||
|
|
@ -73,8 +73,10 @@
|
|||
<mat-form-field>
|
||||
<mat-label>{{ T.F.SIMPLE_COUNTER.D_EDIT.L_COUNTER | translate }}</mat-label>
|
||||
<input
|
||||
#countInput
|
||||
matInput
|
||||
type="number"
|
||||
min="0"
|
||||
name="count"
|
||||
[ngModel]="selectedValue()"
|
||||
(ngModelChange)="updateValue($event)"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import {
|
|||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
|
|
@ -88,6 +90,8 @@ export class DialogSimpleCounterEditComponent {
|
|||
'countdownDuration',
|
||||
];
|
||||
|
||||
readonly countInput = viewChild<ElementRef<HTMLInputElement>>('countInput');
|
||||
|
||||
// State
|
||||
readonly localCounter = signal<SimpleCounterCopy>({
|
||||
...this.dialogData.simpleCounter,
|
||||
|
|
@ -188,11 +192,21 @@ export class DialogSimpleCounterEditComponent {
|
|||
|
||||
updateValue(value: number): void {
|
||||
const date = this.selectedDateStr();
|
||||
// counts and durations can never be negative; clamp invalid/empty input to 0
|
||||
const safeValue = Math.max(0, value || 0);
|
||||
// A type="number" field still accepts typed/pasted negatives, and the
|
||||
// one-way [ngModel] binding won't repaint the field when the clamped value
|
||||
// equals the previously bound value (e.g. editing from 0) — so reset the
|
||||
// element directly to reject a negative visibly.
|
||||
const inputEl = this.countInput()?.nativeElement;
|
||||
if (inputEl && value < 0) {
|
||||
inputEl.value = String(safeValue);
|
||||
}
|
||||
this.localCounter.update((counter) => ({
|
||||
...counter,
|
||||
countOnDay: {
|
||||
...counter.countOnDay,
|
||||
[date]: value,
|
||||
[date]: safeValue,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,6 +264,45 @@ describe('SimpleCounterReducer', () => {
|
|||
expect(result.entities['counter1']!.countOnDay['2024-01-14']).toBe(5);
|
||||
expect(result.entities['counter1']!.countOnDay['2024-01-15']).toBe(10);
|
||||
});
|
||||
|
||||
it('should clamp negative values to 0', () => {
|
||||
const counter = createCounter('counter1', { countOnDay: {} });
|
||||
const existingState = createStateWithCounters([counter]);
|
||||
|
||||
const action = SimpleCounterActions.setSimpleCounterCounterToday({
|
||||
id: 'counter1',
|
||||
newVal: -5,
|
||||
today: '2024-01-15',
|
||||
});
|
||||
const result = simpleCounterReducer(existingState, action);
|
||||
|
||||
expect(result.entities['counter1']!.countOnDay['2024-01-15']).toBe(0);
|
||||
});
|
||||
|
||||
it('should leave 0 and positive values unchanged', () => {
|
||||
const counter = createCounter('counter1', { countOnDay: {} });
|
||||
const existingState = createStateWithCounters([counter]);
|
||||
|
||||
const zeroResult = simpleCounterReducer(
|
||||
existingState,
|
||||
SimpleCounterActions.setSimpleCounterCounterToday({
|
||||
id: 'counter1',
|
||||
newVal: 0,
|
||||
today: '2024-01-15',
|
||||
}),
|
||||
);
|
||||
expect(zeroResult.entities['counter1']!.countOnDay['2024-01-15']).toBe(0);
|
||||
|
||||
const posResult = simpleCounterReducer(
|
||||
existingState,
|
||||
SimpleCounterActions.setSimpleCounterCounterToday({
|
||||
id: 'counter1',
|
||||
newVal: 42,
|
||||
today: '2024-01-15',
|
||||
}),
|
||||
);
|
||||
expect(posResult.entities['counter1']!.countOnDay['2024-01-15']).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSimpleCounterCounterForDate', () => {
|
||||
|
|
@ -280,6 +319,20 @@ describe('SimpleCounterReducer', () => {
|
|||
|
||||
expect(result.entities['counter1']!.countOnDay['2024-01-10']).toBe(7);
|
||||
});
|
||||
|
||||
it('should clamp negative values to 0', () => {
|
||||
const counter = createCounter('counter1', { countOnDay: {} });
|
||||
const existingState = createStateWithCounters([counter]);
|
||||
|
||||
const action = SimpleCounterActions.setSimpleCounterCounterForDate({
|
||||
id: 'counter1',
|
||||
date: '2024-01-10',
|
||||
newVal: -7,
|
||||
});
|
||||
const result = simpleCounterReducer(existingState, action);
|
||||
|
||||
expect(result.entities['counter1']!.countOnDay['2024-01-10']).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -154,7 +154,8 @@ const _reducer = createReducer<SimpleCounterState>(
|
|||
changes: {
|
||||
countOnDay: {
|
||||
...entity.countOnDay,
|
||||
[today]: newVal,
|
||||
// counts and durations can never be negative
|
||||
[today]: Math.max(0, newVal),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -173,7 +174,8 @@ const _reducer = createReducer<SimpleCounterState>(
|
|||
changes: {
|
||||
countOnDay: {
|
||||
...entity.countOnDay,
|
||||
[date]: newVal,
|
||||
// counts and durations can never be negative
|
||||
[date]: Math.max(0, newVal),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ import { IS_ELECTRON } from '../../app.constants';
|
|||
import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service';
|
||||
import { OperationLogSyncService } from '../../op-log/sync/operation-log-sync.service';
|
||||
import { SyncSessionValidationService } from '../../op-log/sync/sync-session-validation.service';
|
||||
import { SyncCycleGuardService } from '../../op-log/sync/sync-cycle-guard.service';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { isSuperSyncWebSocketAccess } from '@sp/sync-providers/super-sync';
|
||||
import { isTransientNetworkError } from '@sp/sync-providers/http';
|
||||
|
|
@ -116,6 +117,7 @@ export class SyncWrapperService {
|
|||
private _opLogStore = inject(OperationLogStoreService);
|
||||
private _opLogSyncService = inject(OperationLogSyncService);
|
||||
private _sessionValidation = inject(SyncSessionValidationService);
|
||||
private _syncCycleGuard = inject(SyncCycleGuardService);
|
||||
private _wrappedProvider = inject(WrappedProviderService);
|
||||
private _hydrationState = inject(HydrationStateService);
|
||||
|
||||
|
|
@ -280,6 +282,19 @@ export class SyncWrapperService {
|
|||
return 'HANDLED_ERROR';
|
||||
}
|
||||
this._isSyncInProgress$.next(true);
|
||||
|
||||
// #8309: claim the in-tab sync cycle so the conflict-gate decision,
|
||||
// setLastServerSeq, and the session-validation latch are serialized against
|
||||
// the side channels (immediate upload / WS download). Both this claim and
|
||||
// the re-entry check above are synchronous (no await between), so the
|
||||
// check-and-set stays atomic. If a (short-lived) side channel is mid-cycle
|
||||
// we skip rather than block — the next trigger retries. Released in the
|
||||
// finally below.
|
||||
if (!this._syncCycleGuard.tryBegin()) {
|
||||
this._isSyncInProgress$.next(false);
|
||||
SyncLog.log('Sync skipped: another sync cycle is in progress');
|
||||
return 'HANDLED_ERROR';
|
||||
}
|
||||
// Open before any async work — see `HydrationStateService.isInSyncWindow`.
|
||||
// Pass 0 to disable the failsafe: the `finally` block below is the
|
||||
// authoritative close, and a slow sync (provider I/O > 2s) would
|
||||
|
|
@ -290,6 +305,7 @@ export class SyncWrapperService {
|
|||
const result = await this._sync(isUserTriggered).finally(() => {
|
||||
this._isSyncInProgress$.next(false);
|
||||
this._hydrationState.closeSyncWindow();
|
||||
this._syncCycleGuard.end();
|
||||
// Safeguard: if _sync() threw or completed without setting a final status,
|
||||
// reset from SYNCING to UNKNOWN_OR_CHANGED to avoid getting stuck in SYNCING state
|
||||
if (this._providerManager.isSyncInProgress) {
|
||||
|
|
@ -976,42 +992,60 @@ export class SyncWrapperService {
|
|||
private async _forceDownload(): Promise<void> {
|
||||
SyncLog.log('SyncWrapperService: forceDownload called - downloading remote state');
|
||||
|
||||
await this.runWithSyncBlocked(async () => {
|
||||
// #8309: claim the sync cycle so this flow's conflict-gate / session
|
||||
// latch are isolated from the immediate-upload / WS-download side
|
||||
// channels. runWithSyncBlocked has already drained any main sync and set
|
||||
// isEncryptionOperationInProgress, so the only thing that could hold the
|
||||
// guard here is a short-lived side channel; skip-and-let-the-user-retry
|
||||
// rather than block.
|
||||
if (!this._syncCycleGuard.tryBegin()) {
|
||||
SyncLog.log('Force download skipped: another sync cycle is in progress');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._forceDownloadInner();
|
||||
} finally {
|
||||
this._syncCycleGuard.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _forceDownloadInner(): Promise<void> {
|
||||
// Open a session-validation scope — read after forceDownloadRemoteState
|
||||
// returns so a corrupt downloaded state is reported as ERROR. (#7330)
|
||||
await this.runWithSyncBlocked(() =>
|
||||
this._sessionValidation.withSession(async () => {
|
||||
try {
|
||||
const rawProvider = this._providerManager.getActiveProvider();
|
||||
const syncCapableProvider =
|
||||
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
|
||||
await this._sessionValidation.withSession(async () => {
|
||||
try {
|
||||
const rawProvider = this._providerManager.getActiveProvider();
|
||||
const syncCapableProvider =
|
||||
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
|
||||
|
||||
if (!syncCapableProvider) {
|
||||
SyncLog.warn(
|
||||
'SyncWrapperService: Cannot force download - provider not available',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider);
|
||||
if (this._sessionValidation.hasFailed()) {
|
||||
SyncLog.err(
|
||||
'SyncWrapperService: Force download applied but post-sync validation failed; reporting ERROR',
|
||||
);
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
} else {
|
||||
this._providerManager.setSyncStatus('IN_SYNC');
|
||||
}
|
||||
SyncLog.log('SyncWrapperService: Force download complete');
|
||||
} catch (error) {
|
||||
SyncLog.err('SyncWrapperService: Force download failed:', error);
|
||||
const errStr = getSyncErrorStr(error);
|
||||
this._snackService.open({
|
||||
msg: errStr,
|
||||
type: 'ERROR',
|
||||
});
|
||||
if (!syncCapableProvider) {
|
||||
SyncLog.warn(
|
||||
'SyncWrapperService: Cannot force download - provider not available',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider);
|
||||
if (this._sessionValidation.hasFailed()) {
|
||||
SyncLog.err(
|
||||
'SyncWrapperService: Force download applied but post-sync validation failed; reporting ERROR',
|
||||
);
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
} else {
|
||||
this._providerManager.setSyncStatus('IN_SYNC');
|
||||
}
|
||||
SyncLog.log('SyncWrapperService: Force download complete');
|
||||
} catch (error) {
|
||||
SyncLog.err('SyncWrapperService: Force download failed:', error);
|
||||
const errStr = getSyncErrorStr(error);
|
||||
this._snackService.open({
|
||||
msg: errStr,
|
||||
type: 'ERROR',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async configuredAuthForSyncProviderIfNecessary(
|
||||
|
|
|
|||
|
|
@ -506,6 +506,37 @@ describe('OperationLogEffects', () => {
|
|||
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledTimes(2);
|
||||
}));
|
||||
|
||||
it('re-extracts the SAME action on the quota-exceeded retry, never a second op (#8307)', fakeAsync(() => {
|
||||
// Regression (#8307, now structural): the positional dequeue is gone.
|
||||
// entityChanges is recomputed by the pure, idempotent extractEntityChanges()
|
||||
// on every write, so the quota retry re-extracts THIS action's changes and
|
||||
// can never steal the next pending action's entry the way the old
|
||||
// double-dequeue did.
|
||||
const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError');
|
||||
let appendCount = 0;
|
||||
mockOpLogStore.appendWithVectorClockUpdate.and.callFake(() => {
|
||||
appendCount++;
|
||||
if (appendCount === 1) {
|
||||
return Promise.reject(quotaError);
|
||||
}
|
||||
return Promise.resolve(1);
|
||||
});
|
||||
|
||||
const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE);
|
||||
actions$ = of(action);
|
||||
|
||||
effects.persistOperation$.subscribe();
|
||||
|
||||
tick(100);
|
||||
// Append ran twice (initial + retry). Extraction ran once per write and
|
||||
// always against the same action — no positional queue to mis-consume.
|
||||
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(mockOperationCaptureService.extractEntityChanges).toHaveBeenCalledTimes(2);
|
||||
expect(mockOperationCaptureService.extractEntityChanges).toHaveBeenCalledWith(
|
||||
action,
|
||||
);
|
||||
}));
|
||||
|
||||
it('should use updated clientId after backup import generates new one', (done) => {
|
||||
const action1 = createPersistentAction(ActionType.TASK_SHARED_ADD);
|
||||
const action2 = createPersistentAction(ActionType.TASK_SHARED_UPDATE);
|
||||
|
|
|
|||
|
|
@ -533,7 +533,11 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
|
|||
try {
|
||||
// Set circuit breaker before retry to prevent recursive handling
|
||||
this.isHandlingQuotaExceeded = true;
|
||||
// Retry the failed operation after compaction freed space
|
||||
// Retry the failed operation after compaction freed space.
|
||||
// #8307 (structural): there is no longer a positional dequeue to
|
||||
// double-consume — entityChanges is recomputed by the pure, idempotent
|
||||
// extractEntityChanges() inside writeOperation, so the retry simply
|
||||
// re-extracts the same changes. Pass isDeferredWrite through unchanged.
|
||||
await this.writeOperation(action, isDeferredWrite, options);
|
||||
this.snackService.open({
|
||||
type: 'SUCCESS',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SyncProviderId } from '../sync-providers/provider.const';
|
|||
import { DataInitStateService } from '../../core/data-init/data-init-state.service';
|
||||
import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service';
|
||||
import { SyncSessionValidationService } from './sync-session-validation.service';
|
||||
import { SyncCycleGuardService } from './sync-cycle-guard.service';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { RejectedOpInfo } from '../core/types/sync-results.types';
|
||||
|
||||
|
|
@ -106,6 +107,10 @@ describe('ImmediateUploadService', () => {
|
|||
});
|
||||
|
||||
service = TestBed.inject(ImmediateUploadService);
|
||||
// The cycle guard is a root singleton; reset it so a prior test that left
|
||||
// it claimed (e.g. an assertion threw before guard.end()) can't poison this
|
||||
// one. Mirrors SyncSessionValidationService's per-test reset.
|
||||
TestBed.inject(SyncCycleGuardService)._resetForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -189,6 +194,47 @@ describe('ImmediateUploadService', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
// #8309: the immediate-upload side channel must not interleave with another
|
||||
// sync cycle (main sync, force flow, or WS download). It claims the in-tab
|
||||
// SyncCycleGuard synchronously at the start of _performUpload and skips if a
|
||||
// cycle is already active.
|
||||
describe('sync-cycle guard (#8309)', () => {
|
||||
it('skips the upload when another sync cycle is active', fakeAsync(() => {
|
||||
const guard = TestBed.inject(SyncCycleGuardService);
|
||||
// Simulate another cycle holding the guard (e.g. a WS download).
|
||||
expect(guard.tryBegin()).toBe(true);
|
||||
|
||||
mockSyncService.uploadPendingOps.and.returnValue(
|
||||
Promise.resolve(completedResult({ uploadedCount: 1 })),
|
||||
);
|
||||
|
||||
service.initialize();
|
||||
service.trigger();
|
||||
tick(2000);
|
||||
flush();
|
||||
|
||||
expect(mockSyncService.uploadPendingOps).not.toHaveBeenCalled();
|
||||
|
||||
guard.end();
|
||||
}));
|
||||
|
||||
it('releases the guard after the upload so a later cycle can run', fakeAsync(() => {
|
||||
const guard = TestBed.inject(SyncCycleGuardService);
|
||||
mockSyncService.uploadPendingOps.and.returnValue(
|
||||
Promise.resolve(completedResult({ uploadedCount: 1 })),
|
||||
);
|
||||
|
||||
service.initialize();
|
||||
service.trigger();
|
||||
tick(2000);
|
||||
flush();
|
||||
|
||||
expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(1);
|
||||
// Guard was released in the finally — a subsequent cycle can claim it.
|
||||
expect(guard.isActive).toBe(false);
|
||||
}));
|
||||
});
|
||||
|
||||
// #7330 follow-up: uploadPendingOps() processes piggybacked remote ops
|
||||
// through validateAfterSync(). Without an explicit withSession() wrapper
|
||||
// the latch flip would either fire outside any session (silently dropped
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { DataInitStateService } from '../../core/data-init/data-init-state.servi
|
|||
import { handleStorageQuotaError } from './sync-error-utils';
|
||||
import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service';
|
||||
import { SyncSessionValidationService } from './sync-session-validation.service';
|
||||
import { SyncCycleGuardService } from './sync-cycle-guard.service';
|
||||
|
||||
const IMMEDIATE_UPLOAD_DEBOUNCE_MS = 2000;
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ export class ImmediateUploadService implements OnDestroy {
|
|||
private _dataInitStateService = inject(DataInitStateService);
|
||||
private _syncWrapper = inject(SyncWrapperService);
|
||||
private _sessionValidation = inject(SyncSessionValidationService);
|
||||
private _syncCycleGuard = inject(SyncCycleGuardService);
|
||||
|
||||
private _uploadTrigger$ = new Subject<void>();
|
||||
private _subscription: Subscription | null = null;
|
||||
|
|
@ -182,6 +184,25 @@ export class ImmediateUploadService implements OnDestroy {
|
|||
* the immediate-upload path.
|
||||
*/
|
||||
private async _performUpload(): Promise<void> {
|
||||
// #8309: opportunistically claim the sync cycle. Skip if any cycle (the
|
||||
// main sync, a force flow, or the WS-download side channel) is already
|
||||
// active — the running cycle or the next trigger covers this upload, and a
|
||||
// background upload must not mutate state / flip the session-validation
|
||||
// latch while another cycle (or its conflict dialog) is open.
|
||||
if (!this._syncCycleGuard.tryBegin()) {
|
||||
OpLog.verbose(
|
||||
'ImmediateUploadService: Skipping immediate upload — another sync cycle is active',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._performUploadInner();
|
||||
} finally {
|
||||
this._syncCycleGuard.end();
|
||||
}
|
||||
}
|
||||
|
||||
private async _performUploadInner(): Promise<void> {
|
||||
const provider = this._providerManager.getActiveProvider();
|
||||
if (!provider) {
|
||||
return;
|
||||
|
|
|
|||
42
src/app/op-log/sync/sync-cycle-guard.service.spec.ts
Normal file
42
src/app/op-log/sync/sync-cycle-guard.service.spec.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { SyncCycleGuardService } from './sync-cycle-guard.service';
|
||||
|
||||
describe('SyncCycleGuardService', () => {
|
||||
let guard: SyncCycleGuardService;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new SyncCycleGuardService();
|
||||
});
|
||||
|
||||
it('claims the cycle when free and reports active', () => {
|
||||
expect(guard.isActive).toBe(false);
|
||||
expect(guard.tryBegin()).toBe(true);
|
||||
expect(guard.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false without claiming when a cycle is already active', () => {
|
||||
expect(guard.tryBegin()).toBe(true);
|
||||
// A second claimant (another entry point) must be told to skip.
|
||||
expect(guard.tryBegin()).toBe(false);
|
||||
expect(guard.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('frees the cycle on end()', () => {
|
||||
expect(guard.tryBegin()).toBe(true);
|
||||
guard.end();
|
||||
expect(guard.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('can be re-acquired after end()', () => {
|
||||
expect(guard.tryBegin()).toBe(true);
|
||||
guard.end();
|
||||
expect(guard.tryBegin()).toBe(true);
|
||||
expect(guard.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('_resetForTest clears active state', () => {
|
||||
expect(guard.tryBegin()).toBe(true);
|
||||
guard._resetForTest();
|
||||
expect(guard.isActive).toBe(false);
|
||||
expect(guard.tryBegin()).toBe(true);
|
||||
});
|
||||
});
|
||||
65
src/app/op-log/sync/sync-cycle-guard.service.ts
Normal file
65
src/app/op-log/sync/sync-cycle-guard.service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* In-tab mutual-exclusion guard for the four top-level sync entry points:
|
||||
* - `SyncWrapperService.sync()` (periodic / user-triggered full sync)
|
||||
* - `SyncWrapperService._forceDownload()` (sync-error dialog recovery)
|
||||
* - `ImmediateUploadService._performUpload()` (side channel)
|
||||
* - `WsTriggeredDownloadService._downloadOps()` (side channel)
|
||||
*
|
||||
* ## Why (#8309)
|
||||
* These flows share the per-tab {@link SyncSessionValidationService} latch,
|
||||
* whose single-mutable-boolean design is only safe if at most ONE session is
|
||||
* active at a time. They also run lock-free seams — the SYNC_IMPORT
|
||||
* conflict-gate decision (which awaits a user dialog) and `setLastServerSeq`
|
||||
* persistence — that a concurrent flow can invalidate. The apply phase is
|
||||
* already serialized by the cross-tab `OPERATION_LOG`/`UPLOAD`/`DOWNLOAD` Web
|
||||
* Locks; this guard closes the remaining in-tab seams and prevents
|
||||
* `withSession()` latch misattribution (two overlapping sessions sharing one
|
||||
* latch).
|
||||
*
|
||||
* ## Why a synchronous in-memory skip-guard, not a Web Lock
|
||||
* - The conflict gate awaits a user dialog. Holding a cross-tab Web Lock across
|
||||
* that wait would stall other tabs until the 30s lock timeout.
|
||||
* - Every entry point claims the cycle with {@link tryBegin} *before its first
|
||||
* `await`*, so the check-and-set is atomic on the single-threaded event loop,
|
||||
* and skips when a cycle is already active. Skipping (rather than queuing) is
|
||||
* the correct semantics: an opportunistic side channel must not mutate state
|
||||
* while another cycle (or its conflict dialog) is open, and a user-triggered
|
||||
* sync that collides with a short-lived side channel is simply retried on the
|
||||
* next trigger. Because nothing ever waits on the guard, it cannot deadlock.
|
||||
*
|
||||
* Cross-tab apply-phase serialization remains the job of the existing Web
|
||||
* Locks; cross-tab gate/seq staleness is out of scope for this guard.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SyncCycleGuardService {
|
||||
private _isActive = false;
|
||||
|
||||
get isActive(): boolean {
|
||||
return this._isActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously claim the cycle. Returns `false` (without claiming) if a
|
||||
* cycle is already active. MUST be called before the caller's first `await`
|
||||
* so the check-and-set is atomic within the single-threaded event loop.
|
||||
*/
|
||||
tryBegin(): boolean {
|
||||
if (this._isActive) {
|
||||
return false;
|
||||
}
|
||||
this._isActive = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release the cycle. Always call from a `finally` block. */
|
||||
end(): void {
|
||||
this._isActive = false;
|
||||
}
|
||||
|
||||
/** @internal Test-only reset for the root singleton between unit tests. */
|
||||
_resetForTest(): void {
|
||||
this._isActive = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { SyncProviderManager } from '../sync-providers/provider-manager.service'
|
|||
import { WrappedProviderService } from '../sync-providers/wrapped-provider.service';
|
||||
import { WsTriggeredDownloadService } from './ws-triggered-download.service';
|
||||
import { SyncSessionValidationService } from './sync-session-validation.service';
|
||||
import { SyncCycleGuardService } from './sync-cycle-guard.service';
|
||||
import { AuthFailSPError, MissingCredentialsSPError } from '../sync-exports';
|
||||
|
||||
describe('WsTriggeredDownloadService', () => {
|
||||
|
|
@ -61,6 +62,10 @@ describe('WsTriggeredDownloadService', () => {
|
|||
});
|
||||
|
||||
service = TestBed.inject(WsTriggeredDownloadService);
|
||||
// The cycle guard is a root singleton; reset it so a prior test that left
|
||||
// it claimed (e.g. an assertion threw before guard.end()) can't poison this
|
||||
// one. Mirrors SyncSessionValidationService's per-test reset.
|
||||
TestBed.inject(SyncCycleGuardService)._resetForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -112,6 +117,37 @@ describe('WsTriggeredDownloadService', () => {
|
|||
expect(mockSyncService.downloadRemoteOps).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
// #8309: the WS-download side channel claims the in-tab SyncCycleGuard and
|
||||
// skips when another cycle (main sync, force flow, or immediate upload) is
|
||||
// active, so its gate decision / setLastServerSeq can't race a concurrent
|
||||
// flow and overlapping withSession() calls can't misattribute the latch.
|
||||
it('should skip the download when another sync cycle is active (#8309)', fakeAsync(() => {
|
||||
const guard = TestBed.inject(SyncCycleGuardService);
|
||||
expect(guard.tryBegin()).toBe(true);
|
||||
|
||||
service.start();
|
||||
notification$.next({ latestSeq: 7 });
|
||||
tick(500);
|
||||
flushMicrotasks();
|
||||
|
||||
expect(mockWrappedProvider.getOperationSyncCapable).not.toHaveBeenCalled();
|
||||
expect(mockSyncService.downloadRemoteOps).not.toHaveBeenCalled();
|
||||
|
||||
guard.end();
|
||||
}));
|
||||
|
||||
it('releases the guard after the download so a later cycle can run (#8309)', fakeAsync(() => {
|
||||
const guard = TestBed.inject(SyncCycleGuardService);
|
||||
|
||||
service.start();
|
||||
notification$.next({ latestSeq: 8 });
|
||||
tick(500);
|
||||
flushMicrotasks();
|
||||
|
||||
expect(mockSyncService.downloadRemoteOps).toHaveBeenCalledTimes(1);
|
||||
expect(guard.isActive).toBe(false);
|
||||
}));
|
||||
|
||||
it('should stop listening after an auth failure', fakeAsync(() => {
|
||||
mockSyncService.downloadRemoteOps.and.callFake(async () => {
|
||||
throw new AuthFailSPError('unauthorized');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { OperationLogSyncService } from './operation-log-sync.service';
|
|||
import { SyncProviderManager } from '../sync-providers/provider-manager.service';
|
||||
import { WrappedProviderService } from '../sync-providers/wrapped-provider.service';
|
||||
import { SyncSessionValidationService } from './sync-session-validation.service';
|
||||
import { SyncCycleGuardService } from './sync-cycle-guard.service';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { AuthFailSPError, MissingCredentialsSPError } from '../sync-exports';
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ export class WsTriggeredDownloadService implements OnDestroy {
|
|||
private _providerManager = inject(SyncProviderManager);
|
||||
private _wrappedProvider = inject(WrappedProviderService);
|
||||
private _sessionValidation = inject(SyncSessionValidationService);
|
||||
private _syncCycleGuard = inject(SyncCycleGuardService);
|
||||
|
||||
private _subscription: Subscription | null = null;
|
||||
|
||||
|
|
@ -64,6 +66,27 @@ export class WsTriggeredDownloadService implements OnDestroy {
|
|||
return;
|
||||
}
|
||||
|
||||
// #8309: claim the sync cycle. Both this check and the isSyncInProgress
|
||||
// check above are synchronous (no await between), so the claim is atomic.
|
||||
// Skip if any cycle (main sync, force flow, or the immediate-upload side
|
||||
// channel) is active — its apply or its conflict dialog must not race this
|
||||
// download's gate decision / setLastServerSeq, and overlapping withSession()
|
||||
// calls would misattribute the validation latch.
|
||||
if (!this._syncCycleGuard.tryBegin()) {
|
||||
SyncLog.log(
|
||||
'WsTriggeredDownloadService: Another sync cycle is active, skipping WS download',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this._downloadOpsInner(latestSeq);
|
||||
} finally {
|
||||
this._syncCycleGuard.end();
|
||||
}
|
||||
}
|
||||
|
||||
private async _downloadOpsInner(latestSeq: number): Promise<void> {
|
||||
// WS-triggered downloads are their own session boundary. The session
|
||||
// wrapper resets the latch up-front so the read at the end reflects
|
||||
// only this session, and a leaked-failed latch from a prior path can't
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@
|
|||
"GET_READY": "قم بالإستعداد لجلسة التركيز",
|
||||
"GOGOGO": "!هيا, هيا, هياااااا!",
|
||||
"LONG_BREAK": "انقطاع طويل",
|
||||
"LONG_BREAK_TITLE": "استراحة طويلة - دورة {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "استراحة طويلة",
|
||||
"ON": "نشط",
|
||||
"OPEN_ISSUE_IN_BROWSER": "إفتح مشكلة في المتصفح",
|
||||
"PAUSE_SESSION": "جلسة إيقاف مؤقت",
|
||||
|
|
@ -287,7 +287,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "اختر مهمة للتركيز عليها",
|
||||
"SESSION_COMPLETED": "اكتملت جلسة التركيز!",
|
||||
"SHORT_BREAK": "استراحة قصيرة",
|
||||
"SHORT_BREAK_TITLE": "استراحة قصيرة - دورة {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "استراحة قصيرة",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "أظهر / أخفي ملاحظات و مرفقات المهمة",
|
||||
"SKIP_BREAK": "استراحة تخطي",
|
||||
"START_FOCUS_SESSION": "إبدأ جلسة التركيز",
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@
|
|||
"GOGOGO": "Jděte, jděte, jděte!!!",
|
||||
"GO_TO_PROCRASTINATION": "Získat pomoc při prokrastinaci",
|
||||
"LONG_BREAK": "Dlouhá přestávka",
|
||||
"LONG_BREAK_TITLE": "Dlouhá přestávka – cyklus {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Dlouhá přestávka",
|
||||
"ON": "zapnuto",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Otevřít problém v prohlížeči",
|
||||
"PAUSE_BREAK": "Pozastavit přestávku",
|
||||
|
|
@ -268,7 +268,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Vyberte úkol k soustředění",
|
||||
"SESSION_COMPLETED": "Relace zaměření dokončena!",
|
||||
"SHORT_BREAK": "Krátká přestávka",
|
||||
"SHORT_BREAK_TITLE": "Krátká přestávka – cyklus {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Krátká přestávka",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Zobrazit/skrýt poznámky a přílohy k úkolu",
|
||||
"SKIP_BREAK": "Přeskočit přestávku",
|
||||
"START_BREAK": "Spustit přestávku",
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@
|
|||
"GO_TO_PROCRASTINATION": "Hilfe bei Prokrastination erhalten",
|
||||
"GOGOGO": "Los, los, los!!!",
|
||||
"LONG_BREAK": "Lange Pause",
|
||||
"LONG_BREAK_TITLE": "Lange Pause - Zyklus {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Lange Pause",
|
||||
"ON": "An",
|
||||
"OVERTIME_MSG": "Die Zeit ist um — mach eine Pause, wenn du bereit bist",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Issue im Browser öffnen",
|
||||
|
|
@ -369,7 +369,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Aufgabe zum Fokussieren auswählen",
|
||||
"SESSION_COMPLETED": "Fokussitzung abgeschlossen!",
|
||||
"SHORT_BREAK": "Kurze Pause",
|
||||
"SHORT_BREAK_TITLE": "Kurze Pause - Zyklus {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Kurze Pause",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Aufgabennotizen und Anhänge ein-/ausblenden",
|
||||
"SKIP_BREAK": "Pause überspringen",
|
||||
"START_BREAK": "Pause starten",
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@
|
|||
"GET_READY": "¡Prepárate para tu sesión de enfoque!",
|
||||
"GOGOGO": "¡Vamos, vamos, vamos!",
|
||||
"LONG_BREAK": "Descanso largo",
|
||||
"LONG_BREAK_TITLE": "Descanso largo - Ciclo {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Descanso largo",
|
||||
"ON": "activado",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Abrir incidencia en el navegador",
|
||||
"PAUSE_SESSION": "Pausar sesión",
|
||||
|
|
@ -298,7 +298,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Seleccionar tarea para enfocarte",
|
||||
"SESSION_COMPLETED": "¡Sesión de enfoque completada!",
|
||||
"SHORT_BREAK": "Descanso corto",
|
||||
"SHORT_BREAK_TITLE": "Descanso corto - Ciclo {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Descanso corto",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Mostrar/ocultar notas y adjuntos de la tarea",
|
||||
"SKIP_BREAK": "Saltar descanso",
|
||||
"START_BREAK": "Iniciar descanso",
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@
|
|||
"GET_READY": "برای نشست تمرکز خود آماده شوید!",
|
||||
"GOGOGO": "برو، برو، برو!!!",
|
||||
"LONG_BREAK": "وقفه طولانی",
|
||||
"LONG_BREAK_TITLE": "وقفه طولانی - چرخه {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "وقفه طولانی",
|
||||
"ON": "در",
|
||||
"OPEN_ISSUE_IN_BROWSER": "موضوع را در مرورگر باز کنید",
|
||||
"PAUSE_SESSION": "جلسه توقف",
|
||||
|
|
@ -247,7 +247,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "انتخاب وظیفه برای تمرکز",
|
||||
"SESSION_COMPLETED": "جلسه تمرکز به پایان رسید!",
|
||||
"SHORT_BREAK": "وقفه کوتاه",
|
||||
"SHORT_BREAK_TITLE": "وقفه کوتاه - چرخه {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "وقفه کوتاه",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "نمایش/پنهان کردن یادداشتها و پیوستها",
|
||||
"SKIP_BREAK": "اسکیپ بریک",
|
||||
"START_FOCUS_SESSION": "شروع جلسه تمرکز",
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@
|
|||
"GET_READY": "Valmistaudu keskittymistilaan!",
|
||||
"GOGOGO": "Mennään, mennään, mennään!!!",
|
||||
"LONG_BREAK": "Pitkä tauko",
|
||||
"LONG_BREAK_TITLE": "Pitkä tauko - Kierros {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Pitkä tauko",
|
||||
"ON": "päällä",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Avaa ongelma selaimessa",
|
||||
"PAUSE_SESSION": "Tauota istunto",
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Valitse keskityttävä tehtävä",
|
||||
"SESSION_COMPLETED": "Keskittymissessio suoritettu!",
|
||||
"SHORT_BREAK": "Lyhyt tauko",
|
||||
"SHORT_BREAK_TITLE": "Lyhyt tauko - Kierros {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Lyhyt tauko",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Näytä/piilota tehtävän muistiinpanot ja liitteet",
|
||||
"SKIP_BREAK": "Ohita tauko",
|
||||
"START_BREAK": "Aloita tauko",
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@
|
|||
"GET_READY": "Préparez-vous pour votre session de concentration !!!",
|
||||
"GOGOGO": "Allez! Allez! Allez!!!",
|
||||
"LONG_BREAK": "Pause longue",
|
||||
"LONG_BREAK_TITLE": "Pause longue - Cycle {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Pause longue",
|
||||
"ON": "activé",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Ouvrir le problème dans un navigateur",
|
||||
"PAUSE_SESSION": "Mettre la session en pause",
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Sélectionner la tâche à concentrer",
|
||||
"SESSION_COMPLETED": "Session de concentration terminée !",
|
||||
"SHORT_BREAK": "Pause courte",
|
||||
"SHORT_BREAK_TITLE": "Pause courte - Cycle {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Pause courte",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Afficher/masquer les notes et pièces jointes de la tâche",
|
||||
"SKIP_BREAK": "Passer la pause",
|
||||
"START_BREAK": "Commencer la pause",
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@
|
|||
"GO_TO_PROCRASTINATION": "Potraži pomoć kad odugovlačiš",
|
||||
"GOGOGO": "Idemoooo!!!",
|
||||
"LONG_BREAK": "Duga pauza",
|
||||
"LONG_BREAK_TITLE": "Duga pauza – ciklus {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Duga pauza",
|
||||
"ON": "na",
|
||||
"OVERTIME_MSG": "Vrijeme je isteklo – uzmi pauzu kad budeš spreman",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Otvori problem u pregledniku",
|
||||
|
|
@ -310,7 +310,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Odaberi zadatak za fokus",
|
||||
"SESSION_COMPLETED": "Fokus sesija je završena!",
|
||||
"SHORT_BREAK": "Kratka pauza",
|
||||
"SHORT_BREAK_TITLE": "Kratka pauza – ciklus {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Kratka pauza",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Prikaži/sakrij bilješke i privitke zadatka",
|
||||
"SKIP_BREAK": "Preskoči pauzu",
|
||||
"START_BREAK": "Pokreni pauzu",
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@
|
|||
"GET_READY": "Bersiaplah untuk sesi fokus Anda!",
|
||||
"GOGOGO": "Ayo, ayo, ayo !!!",
|
||||
"LONG_BREAK": "Istirahat Panjang",
|
||||
"LONG_BREAK_TITLE": "Istirahat Panjang - Siklus {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Istirahat Panjang",
|
||||
"ON": "pada",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Masalah terbuka di Browser",
|
||||
"PAUSE_SESSION": "Jeda sesi",
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Pilih Tugas untuk Difokuskan",
|
||||
"SESSION_COMPLETED": "Sesi Fokus Selesai!",
|
||||
"SHORT_BREAK": "Istirahat Singkat",
|
||||
"SHORT_BREAK_TITLE": "Istirahat Singkat - Siklus {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Istirahat Singkat",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Menampilkan/menyembunyikan catatan tugas dan lampiran",
|
||||
"SKIP_BREAK": "Lewati Istirahat",
|
||||
"START_BREAK": "Mulai Istirahat",
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@
|
|||
"GET_READY": "Preparati per la tua prossima sessione!",
|
||||
"GOGOGO": "Vai, vai, vai!",
|
||||
"LONG_BREAK": "Pausa lunga",
|
||||
"LONG_BREAK_TITLE": "Pausa lunga - Ciclo {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Pausa lunga",
|
||||
"ON": "su",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Apri issue nel Browser",
|
||||
"PAUSE_SESSION": "Metti in pausa la sessione",
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Seleziona attività su cui concentrarti",
|
||||
"SESSION_COMPLETED": "Sessione di concentrazione completata!",
|
||||
"SHORT_BREAK": "Pausa breve",
|
||||
"SHORT_BREAK_TITLE": "Pausa breve - Ciclo {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Pausa breve",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Mostra/nascondi note e allegati dell'attività",
|
||||
"SKIP_BREAK": "Salta pausa",
|
||||
"START_BREAK": "Inizia pausa",
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@
|
|||
"GET_READY": "集中セッションの準備をしましょう!",
|
||||
"GOGOGO": "行け、行け、行け!",
|
||||
"LONG_BREAK": "ロング・ブレイク",
|
||||
"LONG_BREAK_TITLE": "ロングブレイク - サイクル {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "ロングブレイク",
|
||||
"ON": "オン",
|
||||
"OPEN_ISSUE_IN_BROWSER": "ブラウザで問題を開く",
|
||||
"PAUSE_SESSION": "一時停止セッション",
|
||||
|
|
@ -287,7 +287,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "タスクを集中させる",
|
||||
"SESSION_COMPLETED": "フォーカスセッションが完了しました!",
|
||||
"SHORT_BREAK": "ショートブレイク",
|
||||
"SHORT_BREAK_TITLE": "ショートブレイク - サイクル {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "ショートブレイク",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "タスクのメモと添付ファイルを表示/非表示",
|
||||
"SKIP_BREAK": "スキップブレイク",
|
||||
"START_FOCUS_SESSION": "フォーカスセッションを開始",
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@
|
|||
"FOCUS_TIME_TOOLTIP": "집중 시간",
|
||||
"GO_TO_PROCRASTINATION": "미루기 도움 받기",
|
||||
"LONG_BREAK": "긴 휴식",
|
||||
"LONG_BREAK_TITLE": "긴 휴식 - 사이클 {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "긴 휴식",
|
||||
"PAUSE_BREAK": "휴식 일시정지",
|
||||
"PAUSE_SESSION": "세션 일시정지",
|
||||
"PAUSE_TRACKING": "추적 일시정지",
|
||||
|
|
@ -286,7 +286,7 @@
|
|||
"SELECT_TASK_FIRST": "추적을 시작하려면 먼저 작업을 선택하세요",
|
||||
"SELECT_TASK_TO_FOCUS": "집중할 작업 선택",
|
||||
"SHORT_BREAK": "짧은 휴식",
|
||||
"SHORT_BREAK_TITLE": "짧은 휴식 - 사이클 {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "짧은 휴식",
|
||||
"SKIP_BREAK": "휴식 건너뛰기",
|
||||
"START_BREAK": "휴식 시작",
|
||||
"SWITCH_TASK": "작업 전환",
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@
|
|||
"GET_READY": "Gjør deg klar for fokusøkten!",
|
||||
"GOGOGO": "Gå, gå, gå!!!",
|
||||
"LONG_BREAK": "Lang pause",
|
||||
"LONG_BREAK_TITLE": "Lang pause - syklus {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Lang pause",
|
||||
"ON": "på",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Åpne problemet i nettleseren",
|
||||
"PAUSE_SESSION": "Sett økten på pause",
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Velg oppgave å fokusere på",
|
||||
"SESSION_COMPLETED": "Fokusøkt fullført!",
|
||||
"SHORT_BREAK": "Kort pause",
|
||||
"SHORT_BREAK_TITLE": "Kort pause - syklus {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Kort pause",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Vis/skjul oppgavenotater og vedlegg",
|
||||
"SKIP_BREAK": "Hopp over pause",
|
||||
"START_BREAK": "Start pause",
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@
|
|||
"GET_READY": "Maak je klaar voor je focus sessie!",
|
||||
"GOGOGO": "Go, go, go!!!",
|
||||
"LONG_BREAK": "Lange pauze",
|
||||
"LONG_BREAK_TITLE": "Lange pauze - Cyclus {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Lange pauze",
|
||||
"ON": "aan",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Issue in Browser openen",
|
||||
"PAUSE_SESSION": "Sessie pauzeren",
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Selecteer taak om op te focussen",
|
||||
"SESSION_COMPLETED": "Focussessie Voltooid!",
|
||||
"SHORT_BREAK": "Korte pauze",
|
||||
"SHORT_BREAK_TITLE": "Korte pauze - Cyclus {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Korte pauze",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Taaknotities en bijlagen tonen/verbergen",
|
||||
"SKIP_BREAK": "Pauze overslaan",
|
||||
"START_BREAK": "Pauze starten",
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@
|
|||
"GO_TO_PROCRASTINATION": "Uzyskaj pomoc przeciw prokrastynacji",
|
||||
"GOGOGO": "Dawaj, dawaj, dawaj!!!",
|
||||
"LONG_BREAK": "Długa przerwa",
|
||||
"LONG_BREAK_TITLE": "Długa przerwa - cykl {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Długa przerwa",
|
||||
"ON": "nad zadaniem",
|
||||
"OVERTIME_MSG": "Czas minął — zrób przerwę, gdy będziesz gotów",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Otwórz zgłoszenie w przeglądarce",
|
||||
|
|
@ -303,7 +303,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Wybierz zadanie, na którym chcesz się skupić",
|
||||
"SESSION_COMPLETED": "Sesja skupienia zakończona!",
|
||||
"SHORT_BREAK": "Krótka przerwa",
|
||||
"SHORT_BREAK_TITLE": "Krótka przerwa - cykl {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Krótka przerwa",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Pokaż/ukryj notatki i załączniki zadania",
|
||||
"SKIP_BREAK": "Pomiń przerwę",
|
||||
"START_BREAK": "Rozpocznij przerwę",
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@
|
|||
"GO_TO_PROCRASTINATION": "Obter ajuda ao procrastinar",
|
||||
"GOGOGO": "Foco total!!!",
|
||||
"LONG_BREAK": "Pausa longa",
|
||||
"LONG_BREAK_TITLE": "Pausa Longa - Ciclo {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Pausa Longa",
|
||||
"ON": "em",
|
||||
"OVERTIME_MSG": "O tempo acabou — faça uma pausa quando estiver pronto",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Abrir issue no navegador",
|
||||
|
|
@ -303,7 +303,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Selecione a tarefa para focar",
|
||||
"SESSION_COMPLETED": "Sessão de foco concluída!",
|
||||
"SHORT_BREAK": "Pausa curta",
|
||||
"SHORT_BREAK_TITLE": "Pausa curta - ciclo {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Pausa curta",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Mostrar/ocultar notas e anexos da tarefa",
|
||||
"SKIP_BREAK": "Pular pausa",
|
||||
"START_BREAK": "Iniciar pausa",
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@
|
|||
"GET_READY": "Prepare-se para sua sessão de foco!",
|
||||
"GOGOGO": "Vamos, vamos, vamos!",
|
||||
"LONG_BREAK": "Pausa Longa",
|
||||
"LONG_BREAK_TITLE": "Pausa Longa - Ciclo {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Pausa Longa",
|
||||
"ON": "Ativado",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Abrir tarefa em um navegador",
|
||||
"PAUSE_SESSION": "Pausar sessão",
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Selecionar Tarefa para Focar",
|
||||
"SESSION_COMPLETED": "Sessão de Foco Concluída!",
|
||||
"SHORT_BREAK": "Pausa Curta",
|
||||
"SHORT_BREAK_TITLE": "Pausa Curta - Ciclo {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Pausa Curta",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Mostrar/ocultar notas e anexos da tarefa",
|
||||
"SKIP_BREAK": "Pular Pausa",
|
||||
"START_BREAK": "Iniciar Pausa",
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@
|
|||
"GO_TO_PROCRASTINATION": "Obține ajutor cînd amîni sarcinile",
|
||||
"GOGOGO": "Dă-i, dă-i, dă-i!!!",
|
||||
"LONG_BREAK": "Pauză lungă",
|
||||
"LONG_BREAK_TITLE": "Pauză lungă - Ciclu {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Pauză lungă",
|
||||
"ON": "activat",
|
||||
"OVERTIME_MSG": "Timpul a expirat — ia o pauză cînd ești pregătit",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Deschide problema în navigatorul web",
|
||||
|
|
@ -310,7 +310,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Alege sarcina pe care vrei să te concentrezi",
|
||||
"SESSION_COMPLETED": "Sesiunea de concentrare s-a încheiat!",
|
||||
"SHORT_BREAK": "Pauză scurtă",
|
||||
"SHORT_BREAK_TITLE": "Pauză scurtă - ciclu {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Pauză scurtă",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Afișează/ascunde notițele și atașamentele sarcinii",
|
||||
"SKIP_BREAK": "Sari peste pauză",
|
||||
"START_BREAK": "Începe pauza",
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@
|
|||
"GO_TO_PROCRASTINATION": "Obține ajutor când amâni sarcinile",
|
||||
"GOGOGO": "Dă-i, dă-i, dă-i!!!",
|
||||
"LONG_BREAK": "Pauză lungă",
|
||||
"LONG_BREAK_TITLE": "Pauză lungă - Ciclu {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Pauză lungă",
|
||||
"ON": "activat",
|
||||
"OVERTIME_MSG": "Timpul a expirat — ia o pauză când ești pregătit",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Deschide problema în navigatorul web",
|
||||
|
|
@ -310,7 +310,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Alege sarcina pe care vrei să te concentrezi",
|
||||
"SESSION_COMPLETED": "Sesiunea de concentrare s-a încheiat!",
|
||||
"SHORT_BREAK": "Pauză scurtă",
|
||||
"SHORT_BREAK_TITLE": "Pauză scurtă - ciclu {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Pauză scurtă",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Afișează/ascunde notițele și atașamentele sarcinii",
|
||||
"SKIP_BREAK": "Sari peste pauză",
|
||||
"START_BREAK": "Începe pauza",
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@
|
|||
"GET_READY": "Приготовьтесь к своему фокус-сеансу!",
|
||||
"GOGOGO": "Вперёд, вперёд, вперёд!!!",
|
||||
"LONG_BREAK": "Длинный перерыв",
|
||||
"LONG_BREAK_TITLE": "Длинный перерыв - цикл {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Длинный перерыв",
|
||||
"ON": "на",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Открыть проблему в Браузере",
|
||||
"PAUSE_SESSION": "Пауза сессии",
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Выберите задачу для фокуса",
|
||||
"SESSION_COMPLETED": "Сессия фокуса завершена!",
|
||||
"SHORT_BREAK": "Короткий перерыв",
|
||||
"SHORT_BREAK_TITLE": "Короткий перерыв - цикл {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Короткий перерыв",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Показать/скрыть заметки о задачах и вложениях",
|
||||
"SKIP_BREAK": "Пропустить перерыв",
|
||||
"START_BREAK": "Начать перерыв",
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@
|
|||
"GET_READY": "Pripravte sa na sústredenie!",
|
||||
"GOGOGO": "Choď, choď, choď!!!",
|
||||
"LONG_BREAK": "Dlhá prestávka",
|
||||
"LONG_BREAK_TITLE": "Dlhá prestávka - cyklus {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Dlhá prestávka",
|
||||
"ON": "na stránke .",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Otvorenie problému v prehliadači",
|
||||
"PAUSE_SESSION": "Pozastaviť reláciu",
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Vyberte úlohu na zameranie",
|
||||
"SESSION_COMPLETED": "Relácia zamerania dokončená!",
|
||||
"SHORT_BREAK": "Krátka prestávka",
|
||||
"SHORT_BREAK_TITLE": "Krátka prestávka - cyklus {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Krátka prestávka",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Zobrazenie/skrytie poznámok k úlohám a príloh",
|
||||
"SKIP_BREAK": "Preskočiť prestávku",
|
||||
"START_BREAK": "Začať prestávku",
|
||||
|
|
|
|||
|
|
@ -268,8 +268,8 @@
|
|||
"FOCUS_TIME_TOOLTIP": "Fokuseringstid",
|
||||
"LONG_BREAK": "Lång paus",
|
||||
"SHORT_BREAK": "Kort paus",
|
||||
"LONG_BREAK_TITLE": "Lång paus - Cykel {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Kort paus - Cykel {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Lång paus",
|
||||
"SHORT_BREAK_TITLE": "Kort paus",
|
||||
"GO_TO_PROCRASTINATION": "Få hjälp vid prokrastinering",
|
||||
"PAUSE_BREAK": "Pausa rast",
|
||||
"POMODORO_SETTINGS": "Pomodoro-inställningar",
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@
|
|||
"GO_TO_PROCRASTINATION": "Ertelediğinde yardım al",
|
||||
"GOGOGO": "Hadi hadi hadi!!!",
|
||||
"LONG_BREAK": "Uzun Mola",
|
||||
"LONG_BREAK_TITLE": "Uzun Mola - Döngü {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Uzun Mola",
|
||||
"ON": "on",
|
||||
"OVERTIME_MSG": "Süre doldu — hazır olduğunuzda ara verin",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Sorunu Tarayıcıda aç",
|
||||
|
|
@ -330,7 +330,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Odaklanmak için Görevi seçin",
|
||||
"SESSION_COMPLETED": "Odak Oturumu Tamamlandı!",
|
||||
"SHORT_BREAK": "Kısa Mola",
|
||||
"SHORT_BREAK_TITLE": "Kısa Mola - Döngü {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Kısa Mola",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Görev notlarını ve eklerini göster/gizle",
|
||||
"SKIP_BREAK": "Molayı Atla",
|
||||
"START_BREAK": "Mola başlat",
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@
|
|||
"GOGOGO": "Вперед, вперед, вперед!!!",
|
||||
"GO_TO_PROCRASTINATION": "Отримати допомогу при прокрастинації",
|
||||
"LONG_BREAK": "Довга перерва",
|
||||
"LONG_BREAK_TITLE": "Довга перерва - Цикл {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Довга перерва",
|
||||
"ON": "на",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Відкрити завдання в браузері",
|
||||
"PAUSE_BREAK": "Призупинити перерву",
|
||||
|
|
@ -268,7 +268,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Виберіть завдання для фокусу",
|
||||
"SESSION_COMPLETED": "Фокус-сесія завершена!",
|
||||
"SHORT_BREAK": "Коротка перерва",
|
||||
"SHORT_BREAK_TITLE": "Коротка перерва - цикл {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Коротка перерва",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Показати/приховати нотатки та вкладення завдання",
|
||||
"SKIP_BREAK": "Пропустити перерву",
|
||||
"START_BREAK": "Почати перерву",
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@
|
|||
"GO_TO_PROCRASTINATION": "Nhận giúp đỡ khi trì hoãn",
|
||||
"GOGOGO": "Đi nào, đi nào, đi nào!!!",
|
||||
"LONG_BREAK": "Nghỉ dài",
|
||||
"LONG_BREAK_TITLE": "Nghỉ dài - Chu kỳ {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "Nghỉ dài",
|
||||
"ON": "trên",
|
||||
"OVERTIME_MSG": "Hết giờ — hãy nghỉ khi sẵn sàng",
|
||||
"OPEN_ISSUE_IN_BROWSER": "Mở sự cố trong trình duyệt web",
|
||||
|
|
@ -329,7 +329,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "Chọn công việc để tập trung",
|
||||
"SESSION_COMPLETED": "Phiên tập trung đã hoàn thành!",
|
||||
"SHORT_BREAK": "Nghỉ ngắn",
|
||||
"SHORT_BREAK_TITLE": "Nghỉ ngắn - chu kỳ {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "Nghỉ ngắn",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Hiện/ẩn ghi chú và tệp đính kèm công việc",
|
||||
"SKIP_BREAK": "Bỏ qua nghỉ",
|
||||
"START_BREAK": "Bắt đầu nghỉ",
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@
|
|||
"GO_TO_PROCRASTINATION": "取得抗拖延協助",
|
||||
"GOGOGO": "加油,加油,加油!!!",
|
||||
"LONG_BREAK": "長休息",
|
||||
"LONG_BREAK_TITLE": "長休息 - 循環 {{cycle}}",
|
||||
"LONG_BREAK_TITLE": "長休息",
|
||||
"ON": "於",
|
||||
"OPEN_ISSUE_IN_BROWSER": "在瀏覽器中開啟議題",
|
||||
"PAUSE_BREAK": "暫停休息",
|
||||
|
|
@ -303,7 +303,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "選擇要專注的工作",
|
||||
"SESSION_COMPLETED": "專注時段完成!",
|
||||
"SHORT_BREAK": "短休息",
|
||||
"SHORT_BREAK_TITLE": "短休息 - 循環 {{cycle}}",
|
||||
"SHORT_BREAK_TITLE": "短休息",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "顯示/隱藏工作筆記和附件",
|
||||
"SKIP_BREAK": "跳過休息",
|
||||
"START_BREAK": "開始休息",
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@
|
|||
"GOGOGO": "冲,冲,冲!!!",
|
||||
"GO_TO_PROCRASTINATION": "获取抗拖延帮助",
|
||||
"LONG_BREAK": "长休息",
|
||||
"LONG_BREAK_TITLE": "长休息 - 第 {{cycle}} 轮",
|
||||
"LONG_BREAK_TITLE": "长休息",
|
||||
"ON": "在",
|
||||
"OPEN_ISSUE_IN_BROWSER": "在浏览器中打开问题",
|
||||
"PAUSE_BREAK": "暂停休息",
|
||||
|
|
@ -308,7 +308,7 @@
|
|||
"SELECT_TASK_TO_FOCUS": "选择要专注的任务",
|
||||
"SESSION_COMPLETED": "专注会话已完成!",
|
||||
"SHORT_BREAK": "短休息",
|
||||
"SHORT_BREAK_TITLE": "短休息 - 第 {{cycle}} 轮",
|
||||
"SHORT_BREAK_TITLE": "短休息",
|
||||
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "显示/隐藏任务备注和附件",
|
||||
"SKIP_BREAK": "跳过休息",
|
||||
"START_BREAK": "开始休息",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue