Merge pull request #8900 from super-productivity/feat/sync-55222e

fix(sync): harden replay, conflict recovery and archive durability
This commit is contained in:
Johannes Millan 2026-07-11 21:34:45 +02:00 committed by GitHub
commit 8193625c0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 11499 additions and 1938 deletions

2
.gitignore vendored
View file

@ -45,7 +45,7 @@ android/.idea
# misc
\\backups
/backups
/.angular/cache
/.angular
/.sass-cache
/connect.lock
/coverage

View file

@ -86,10 +86,13 @@ flowchart TD
end
subgraph RemoteOp["REMOTE Operation (Sync)"]
R1[Download operation<br/>from sync] --> R2[OperationApplierService<br/>dispatches action]
R2 --> R3[Meta-reducers update NgRx state]
R3 --> R4["ArchiveOperationHandler<br/>.handleOperation"]
R4 --> R5["Write to IndexedDB<br/>(archiveYoung/archiveOld)"]
R1[Download operation<br/>from sync] --> R2["Append remote row<br/>status: pending"]
R2 --> R3["Bulk reducer dispatch<br/>meta.isRemote=true"]
R3 --> R4["Atomic reducer checkpoint:<br/>archive_pending + vector clocks"]
R4 --> R5["ArchiveOperationHandler<br/>.handleOperation"]
R5 --> R6{"Archive side effect<br/>succeeded?"}
R6 -->|Yes| R7["Mark applied"]
R6 -->|No| R8["Bump attempted row retryCount;<br/>successors stay quarantined"]
NoEffect["❌ Regular effects DON'T run<br/>(action has meta.isRemote=true)"]
end
@ -113,14 +116,15 @@ flowchart TD
## ArchiveOperationHandler Integration
The `OperationApplierService` applies operations in the order they arrive from the sync server, which preserves causal ordering (each client uploads its ops in causal order, and the server assigns sequence numbers in upload order). It converts each op to an action, applies the batch via a single bulk dispatch, then runs archive side effects. If an operation fails to apply, the applier returns it as a `failedOp`; the caller surfaces a partial-apply failure and re-validates state, and the op is retried on the next hydration.
The `OperationApplierService` applies operations in the order they arrive from the sync server, which preserves causal ordering (each client uploads its ops in causal order, and the server assigns sequence numbers in upload order). Incoming rows are first stored as `pending`. Immediately after the single bulk reducer dispatch, one transaction changes the whole reducer-committed batch to `archive_pending` and merges its vector clocks; only then do archive side effects run. Successful rows become `applied`. If an archive side effect throws, only the attempted row becomes `failed` and consumes `retryCount`; unattempted successors remain `archive_pending`. On startup, hydration restores reducer state and retries `archive_pending`/`failed` rows with reducer dispatch disabled, so additive reducers are never applied twice.
```mermaid
flowchart TD
subgraph OperationApplierService["OperationApplierService (Bulk Dispatch)"]
OA1[Receive operations] --> OA3[convertOpToAction]
OA3 --> OA4["store.dispatch(action)<br/>with meta.isRemote=true"]
OA4 --> OA5["archiveOperationHandler<br/>.handleOperation(action)"]
OA1["Receive rows already stored<br/>as pending"] --> OA3[convertOpToAction]
OA3 --> OA4["Single bulk dispatch<br/>with meta.isRemote=true"]
OA4 --> OA4b["Atomic reducer checkpoint:<br/>archive_pending + vector clocks"]
OA4b --> OA5["archiveOperationHandler<br/>.handleOperation(action)"]
end
subgraph Handler["ArchiveOperationHandler"]

View file

@ -170,6 +170,7 @@ interface OperationLogEntry {
source: 'local' | 'remote';
syncedAt?: number; // For server sync (Part C)
rejectedAt?: number; // When rejected during conflict resolution
applicationStatus?: 'pending' | 'archive_pending' | 'failed' | 'applied';
}
// state_cache table - periodic snapshots
@ -205,6 +206,19 @@ interface StateCache {
**Key insight:** All application data is persisted in the `SUP_OPS` database via the operation log system.
### Remote Apply Checkpoints
Downloaded operations use a durable status transition so reducer state, archive IndexedDB side effects, vector clocks, and the server cursor cannot disagree after a crash:
1. `pending` — the remote op is stored, but no reducer-commit checkpoint exists yet.
2. `archive_pending` — reducers committed and the op's vector clock was merged atomically; archive side effects have not yet completed.
3. `failed` — an archive side-effect attempt failed. `retryCount` is charged only to the attempted row, so later rows in the same batch do not consume retry budget.
4. `applied` — reducer and archive work both completed.
Startup hydration replays persisted state history, quarantines surviving `pending` rows, and retries `archive_pending`/`failed` rows with reducer dispatch disabled. Ordinary sync refuses to download, upload, or advance its cursor while any incomplete rows remain. Database version 8 is a downgrade barrier: released readers that do not understand the distinct reducer checkpoint cannot open the newer store and silently overlook outstanding archive work.
Local actions buffered during a remote-apply window stay ordered until each operation is durable. Transient persistence failures keep the failed suffix queued and block the current sync so a later sync can retry. A deterministically invalid buffered action also remains queued, but requires reload: its reducer already changed live state, so discarding it would let live state diverge from the durable operation log.
## A.2 Write Path
```
@ -428,7 +442,7 @@ async compact(): Promise<void> {
| Max download ops in memory | 50,000 | Bounds memory during API download |
| Remote file retention | 14 days | Server-side operation file retention |
| Max remote files to keep | 100 | Minimum recent files on server |
| Max conflict retry attempts | 5 | Retries before rejecting failed ops |
| Remote archive retries | ∞ | Stay quarantined until side effects complete |
| Max rejected ops before warning | 10 | Threshold for user notification |
| Lock timeout | 30 sec | localStorage fallback lock timeout |
| Lock acquire timeout | 60 sec | Max wait to acquire a lock |
@ -2310,7 +2324,7 @@ When adding new entities or relationships:
> - **Archive validation**: archiveOld tasks now validated for project/tag references, null-safety added
> - **Lock service robustness**: Handle NaN timestamps and invalid lock formats in fallback lock
> - **Array payload rejection**: Explicit check to reject arrays (which bypass `typeof === 'object'`)
> - **Pending operation expiry**: Operations pending >24h are rejected instead of replayed (PENDING_OPERATION_EXPIRY_MS)
> - **Remote apply checkpoints**: reducer commit and vector-clock merge are atomic; `archive_pending` distinguishes unattempted archive work from attempted `failed` rows, while hydration and the sync gate keep incomplete work visible.
---

View file

@ -193,15 +193,14 @@ export class MyEffects {
See `operation-log.const.ts` for all configurable values:
| Constant | Value | Description |
| ----------------------------------- | -------- | ----------------------------------------- |
| `COMPACTION_TRIGGER` | 500 ops | Operations before automatic compaction |
| `COMPACTION_RETENTION_MS` | 7 days | Synced ops older than this may be deleted |
| `EMERGENCY_COMPACTION_RETENTION_MS` | 1 day | Shorter retention for quota exceeded |
| `MAX_COMPACTION_FAILURES` | 3 | Failures before user notification |
| `MAX_DOWNLOAD_OPS_IN_MEMORY` | 50,000 | Bounds memory during API download |
| `REMOTE_OP_FILE_RETENTION_MS` | 14 days | Server-side operation file retention |
| `PENDING_OPERATION_EXPIRY_MS` | 24 hours | Pending ops older than this are rejected |
| Constant | Value | Description |
| ----------------------------------- | ------- | ----------------------------------------- |
| `COMPACTION_TRIGGER` | 500 ops | Operations before automatic compaction |
| `COMPACTION_RETENTION_MS` | 7 days | Synced ops older than this may be deleted |
| `EMERGENCY_COMPACTION_RETENTION_MS` | 1 day | Shorter retention for quota exceeded |
| `MAX_COMPACTION_FAILURES` | 3 | Failures before user notification |
| `MAX_DOWNLOAD_OPS_IN_MEMORY` | 50,000 | Bounds memory during API download |
| `REMOTE_OP_FILE_RETENTION_MS` | 14 days | Server-side operation file retention |
## 7. Quick Reference Checklist

View file

@ -105,6 +105,6 @@ flowchart TD
**Notes:**
- The `Enter Password` and `Decrypt Error` dialogs correspond to `DecryptNoPasswordError` and `DecryptError` respectively — they are distinct components with different options.
- `IMPORT_CONFLICT` gate uses pending ops only, not store contents (`_hasMeaningfulPendingOps()`). PASSWORD_CHANGED SYNC_IMPORTs without pending ops fall through this gate naturally to silent acceptance — the data is identical, only the encryption changed. "Meaningful" = TASK/PROJECT/TAG/NOTE create/update/delete or full-state ops — config-only ops don't count. Already-synced store data is not a conflict with the incoming SYNC_IMPORT — the user-facing warning happens on the originating device. Including store contents in the gate would let an old client pick `USE_LOCAL` and force-upload its stale pre-import state, rolling back the remote import for everyone.
- `IMPORT_CONFLICT` uses pending ops only, not already-synced store contents (`_hasMeaningfulPendingOps()`). Every pending op is protected except onboarding example-task creates and the never-synced `GLOBAL_CONFIG:sync` provider-setup write. Other config sections, non-task entities, and MIGRATION/RECOVERY full-state batches remain protected. PASSWORD_CHANGED SYNC_IMPORTs without meaningful pending ops still fall through to silent acceptance — the data is identical, only the encryption changed. Including already-synced store contents would let an old client pick `USE_LOCAL` and force-upload stale pre-import state, rolling back the remote import for everyone.
- LWW tie-breaking: on equal timestamps, remote wins (server-authoritative). `moveToArchive` operations always win regardless of timestamp.
- Re-download retry limit: max 3 resolution attempts per entity (`MAX_CONCURRENT_RESOLUTION_ATTEMPTS`); if exceeded, ops are permanently rejected.

View file

@ -154,20 +154,21 @@ Comprehensive spec of all scenarios that can occur during SuperSync synchronizat
3. Throw `LocalDataConflictError`
4. Show full conflict dialog: USE_LOCAL / USE_REMOTE / CANCEL
5. USE_LOCAL → `forceUploadLocalState()` (creates SYNC_IMPORT)
6. USE_REMOTE → `forceDownloadRemoteState()` (clears local ops)
6. USE_REMOTE → capture a single-slot pre-replace backup, then `forceDownloadRemoteState()` (clears local ops)
7. The replacement transaction sets a raw-rebuild resume marker. Completion atomically replaces it with a durable Undo provenance token; startup re-offers Undo after reload while the same backup remains.
**User sees:** Full conflict resolution dialog.
### C.3: Fresh Client — Has Pending Ops with Meaningful User Data (File-Based Sync Only)
**Trigger:** Client has unsynced ops containing task/project/tag/note create/update actions, receiving a snapshot from a file-based provider
**Trigger:** Client has unsynced local work and receives a snapshot from a file-based provider
**Expected:**
1. Download detects remote snapshot (file-based sync path)
2. Check unsynced ops for meaningful user data: TASK/PROJECT/TAG/NOTE CREATE/UPDATE ops, or any full-state op (SYNC_IMPORT/BACKUP_IMPORT/REPAIR)
2. Treat every pending op as meaningful except onboarding example-task creates and, before the first completed sync only, the `GLOBAL_CONFIG:sync` setup write needed to configure the provider. Other config sections, non-task entities, and MIGRATION/RECOVERY full-state batches remain protected.
3. If meaningful → throw `LocalDataConflictError` → full conflict dialog
4. If only config/system ops → proceed without dialog
4. If only the explicitly discardable startup ops remain → proceed without dialog and reject those ops locally so they cannot replay after the imported state
**Note:** This op-content check only applies to the file-based snapshot path. For SuperSync (incremental ops path), the fresh client check uses `_hasMeaningfulStoreData()` (store-based check) instead.
@ -198,13 +199,13 @@ Comprehensive spec of all scenarios that can occur during SuperSync synchronizat
**Expected:**
1. Download batch contains SYNC_IMPORT
2. Check pending local ops → N > 0 (condition satisfied regardless of meaningful data)
2. Check pending local ops. Any pending work triggers the dialog except onboarding example-task creates and the never-synced `GLOBAL_CONFIG:sync` provider-setup write. Other GLOBAL_CONFIG sections remain protected.
3. **Show conflict dialog BEFORE processing** with `scenario: 'INCOMING_IMPORT'` and `syncImportReason`
4. USE_LOCAL → `forceUploadLocalState()` (overrides remote with local data)
5. USE_REMOTE → `forceDownloadRemoteState()` (clears local ops, downloads from seq 0)
5. USE_REMOTE → capture a pre-replace backup, then `forceDownloadRemoteState()` (clears local ops, downloads from seq 0)
6. CANCEL → return with `cancelled: true`, skip upload phase
**User sees:** Conflict dialog explaining remote import detected with local changes at risk. "Use Server Data" recommended.
**User sees:** Conflict dialog explaining remote import detected with local changes at risk. "Use Server Data" recommended. After replacement, a persistent Undo action remains recoverable across reloads while its matching backup exists.
### D.3: Remote Ops Filtered by Stored Local SYNC_IMPORT ✓
@ -254,12 +255,12 @@ Comprehensive spec of all scenarios that can occur during SuperSync synchronizat
1. Upload completes → server returns piggybacked ops containing SYNC_IMPORT
2. Check for SYNC_IMPORT in piggybacked ops BEFORE `processRemoteOps()`
3. If found AND `_hasMeaningfulPendingOps()` = true (unsynced TASK/PROJECT/TAG/NOTE C/U/D or full-state ops):
3. If found AND `_hasMeaningfulPendingOps()` = true (all pending work except onboarding example-task creates and the never-synced `GLOBAL_CONFIG:sync` provider-setup write):
- **Show conflict dialog** with `scenario: 'INCOMING_IMPORT'` and `syncImportReason` from the piggybacked op
- USE_LOCAL → `forceUploadLocalState()` (overrides remote)
- USE_REMOTE → `forceDownloadRemoteState()` (clears local, downloads from seq 0)
- CANCEL → return with `cancelled: true`, callers skip post-upload logic
4. If no meaningful pending ops → `processRemoteOps()` applies silently (no dialog) regardless of whether the NgRx store already has user data — that data was already synced and the SYNC_IMPORT is the new authoritative state.
4. If no meaningful pending ops → `processRemoteOps()` applies silently (no dialog), then reject live discardable startup ops only after processing succeeds. Existing NgRx store data does not trigger a dialog because it was already synced and the SYNC_IMPORT is the new authoritative state.
**Mirrors the download path (D.1 / D.2):** the gate is unsynced pending changes, not store contents. Prompting on already-synced store data would let an old client roll back the remote import via USE_LOCAL.
@ -465,9 +466,9 @@ network changes while switching Wi-Fi) before surfacing the warning.
**Expected:** Log warning ("Remote model version newer than local — app update may be required"). Returns `HANDLED_ERROR`. No alert shown to user. User needs to update app.
### G.8: Operation Migration Failure
### G.8: Operation Migration or Apply Failure
**Expected:** Failed ops skipped. Snackbar shown once per session. Other ops applied normally.
**Expected:** A schema migration block keeps the server cursor behind the incompatible op and reports an error instead of skipping it. A remote op whose reducer/archive checkpoint is incomplete stays `pending`, `archive_pending`, or `failed`; later same-session downloads and uploads are blocked so the cursor cannot advance past it. Startup hydration restores reducer state and retries only outstanding archive side effects before sync can continue.
### G.9: Concurrent Sync Attempts

View file

@ -77,7 +77,7 @@ The Windows Store build uses a different path that includes the Windows Store pa
### IndexedDB Databases
- **SUP_OPS** (current, version 4): Main database. Object stores: `ops`, `state_cache`, `import_backup`, `vector_clock`, `archive_young`, `archive_old`. Holds operations log, state snapshots, vector clocks, and archives.
- **SUP_OPS** (current, version 8): Main database. Object stores: `ops`, `state_cache`, `import_backup`, `vector_clock`, `archive_young`, `archive_old`. Holds operations log, state snapshots, vector clocks, and archives.
- **pf:** Legacy database used for migration and recovery.
- **SUPPluginCache:** Plugin cache.
- **SUP_CONFLICT_JOURNAL** (version 1): Sync **conflict journal** — a device-local record of automatic sync-conflict resolutions, reviewable under `/sync-conflicts`. One object store (`conflicts`). Deliberately separate from `SUP_OPS`, **never synced or uploaded** (entries capture the discarded side of conflicts verbatim), pruned on start to 14 days / newest 200 entries, and cleared whenever the full dataset is replaced (backup import/restore, profile switch).
@ -108,7 +108,7 @@ On mobile (Android/iOS), if the app launches with no local data but a usable on-
**Manual backups:** (1) **Create manual backup** in Settings → Sync & Export creates a backup using the same mechanism as automatic backups (platform-dependent location). (2) **Safety backups** are created automatically before certain sync operations; the app keeps a limited number of slots (e.g. two most recent, one first from today, one first from day before). (3) **Export data** downloads a complete backup JSON file to a path you choose (or the browser download folder on web).
**What is included:** All application data is included in backups and exports. The only exclusion is the device-local sync **conflict journal** (`SUP_CONFLICT_JOURNAL`): it is a review log of past conflict resolutions, not application data, and is neither exported nor restored. When archives are included (e.g. when using "Export data" with full backup), both `archive_young` and `archive_old` are part of the file. A separate **import backup** (pre-import state) is stored in IndexedDB (`import_backup` store) only for recovery if an import fails; it is not part of the user-visible backup set.
**What is included:** All application data is included in backups and exports. The only exclusion is the device-local sync **conflict journal** (`SUP_CONFLICT_JOURNAL`): it is a review log of past conflict resolutions, not application data, and is neither exported nor restored. When archives are included (e.g. when using "Export data" with full backup), both `archive_young` and `archive_old` are part of the file. A separate, single-slot **import backup** is stored locally in IndexedDB (`import_backup`). The app captures it before replacing local state through a file import or the sync conflict action **Use Server Data**. A successful Use Server Data replacement shows a persistent **Undo** action that restores this backup; interrupted replacements offer the same recovery action when sync resumes. The Undo remains available after an app reload while that same backup is still present. The slot is internal recovery state and is not included in exports or the user-visible automatic/manual backup lists.
## Import and Export
@ -177,7 +177,7 @@ On mobile (Android/iOS), if the app launches with no local data but a usable on-
## Configuration and Versioning
- There are no separate config files in the User Data Folder. Configuration is stored in IndexedDB (`SUP_OPS`, `state_cache`) and in localStorage (`SUP_*` and related keys).
- **Database version:** `SUP_OPS` is at schema version 4.
- **Database version:** `SUP_OPS` is at schema version 8.
- **Application version:** Defined in the build (e.g. `src/environments/versions.ts`), not stored in the User Data Folder.
- **Backup files:** Each JSON file carries full state; desktop automatic backup naming includes date and time.

View file

@ -0,0 +1,233 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import { ImportPage } from '../../pages/import.page';
/**
* SuperSync: incoming SYNC_IMPORT must not silently discard NON-task local work.
*
* Regression test for the widened incoming-full-state conflict gate + the
* piggyback pre-upload pending-snapshot fix (sync-import-conflict-gate.service).
*
* Before the fix the gate only treated CRUD ops on TASK/PROJECT/TAG/NOTE as
* "meaningful", so a pending SIMPLE_COUNTER change (and MOV/BATCH, time
* tracking, planner, boards, ...) was silently overwritten when an incoming
* SYNC_IMPORT from another client arrived no conflict dialog, no choice.
* The gate now treats every synced entity as user work, and the piggyback path
* judges against the pending set captured BEFORE the upload round (ops accepted
* mid-round are marked synced and would otherwise vanish from a live re-read).
*
* Scenario:
* 1. Client A + B set up sync; A creates a click counter, increments it, syncs.
* 2. Client B syncs and receives the counter (B now has synced history).
* 3. Client B increments the counter locally a pending SIMPLE_COUNTER op it
* does NOT sync.
* 4. Client B starts syncing, but its completed download response is held.
* 5. Client A imports a backup and uploads its SYNC_IMPORT while B is between
* download and upload.
* 6. B's download is released. The upload response piggybacks A's import, and
* the conflict dialog MUST appear instead of discarding B's counter change.
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-conflict-gate-non-task-work.spec.ts
*/
const createClickCounter = async (
client: SimulatedE2EClient,
title: string,
): Promise<void> => {
await client.page.goto('/#/habits');
await client.page.waitForURL(/habits/);
const addBtn = client.page.locator('.add-habit-btn');
await addBtn.waitFor({ state: 'visible', timeout: 10000 });
await addBtn.click();
const dialog = client.page.locator('dialog-simple-counter-edit-settings');
await dialog.waitFor({ state: 'visible', timeout: 10000 });
const titleInput = dialog.locator('formly-form input').first();
await titleInput.waitFor({ state: 'visible', timeout: 5000 });
await titleInput.fill(title);
const typeSelect = dialog.locator('mat-select').first();
await typeSelect.waitFor({ state: 'visible', timeout: 5000 });
await typeSelect.click();
const clickCounterOption = client.page.locator('mat-option:has-text("Click Counter")');
await clickCounterOption.waitFor({ state: 'visible', timeout: 5000 });
await clickCounterOption.click();
await dialog.locator('button[type="submit"]').click();
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
await client.page.goto('/#/tag/TODAY/tasks');
await client.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/);
};
const getNamedClickCounter = async (
client: SimulatedE2EClient,
title: string,
): Promise<ReturnType<typeof client.page.locator>> => {
const counter = client.page
.locator('.counters-action-group simple-counter-button')
.last();
await counter.waitFor({ state: 'visible', timeout: 15000 });
// The button renders only the title's initial; the full title lives in a
// matTooltip that does NOT open on Playwright's synthetic hover in headless
// CI, so we can't identify by tooltip. This test only ever has one counter
// per client at each interaction point (A imports after its only counter
// interaction; B keeps local via USE_LOCAL and never adopts the backup's
// counters), so the last button is unambiguous — sanity-check its rendered
// initial matches the expected title instead of hovering for a tooltip.
await expect(counter.locator('.habit-initial')).toHaveText(
title.charAt(0).toUpperCase(),
);
return counter;
};
const expectClickCounterValue = async (
client: SimulatedE2EClient,
title: string,
expectedValue: number,
): Promise<void> => {
const counter = await getNamedClickCounter(client, title);
await expect(counter.locator('.label')).toHaveText(String(expectedValue));
};
const incrementClickCounter = async (
client: SimulatedE2EClient,
title: string,
expectedValue: number,
): Promise<void> => {
const counter = await getNamedClickCounter(client, title);
await counter.locator('.main-btn').click();
await expect(counter.locator('.label')).toHaveText(String(expectedValue));
};
test.describe.configure({ mode: 'serial' });
test.describe('@supersync incoming SYNC_IMPORT preserves non-task local work', () => {
test('a pending simple-counter change survives an upload-piggybacked import conflict', async ({
browser,
baseURL,
testRunId,
}) => {
test.slow();
const counterTitle = `GateCounter-${testRunId}-${Date.now()}`;
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ===== PHASE 1: A creates + increments a counter, syncs =====
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
await createClickCounter(clientA, counterTitle);
await incrementClickCounter(clientA, counterTitle, 1);
await clientA.sync.syncAndWait();
console.log('[Gate] Client A created + synced a simple counter');
// ===== PHASE 2: B joins, receives the counter (now non-fresh) =====
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait();
await clientB.page.goto('/#/tag/TODAY/tasks');
await clientB.page.waitForLoadState('networkidle');
await expectClickCounterValue(clientB, counterTitle, 1);
console.log('[Gate] Client B synced and received the counter');
// ===== PHASE 3: B makes a pending NON-task change (does NOT sync) =====
await incrementClickCounter(clientB, counterTitle, 2);
console.log('[Gate] Client B incremented the counter locally (pending, unsynced)');
// ===== PHASE 4: B downloads before A's import, then pauses =====
// The captured response is already fixed at the pre-import server state.
// Holding it here creates the exact download→upload race deterministically:
// A's later import can only reach B through the upload response piggyback.
let markDownloadCaptured!: () => void;
const downloadCaptured = new Promise<void>((resolve) => {
markDownloadCaptured = resolve;
});
let releaseDownload!: () => void;
const downloadRelease = new Promise<void>((resolve) => {
releaseDownload = resolve;
});
let hasCapturedDownload = false;
await clientB.page.route('**/api/sync/ops**', async (route) => {
if (!hasCapturedDownload && route.request().method() === 'GET') {
const response = await route.fetch();
hasCapturedDownload = true;
markDownloadCaptured();
await downloadRelease;
await route.fulfill({ response });
return;
}
await route.continue();
});
await clientB.sync.syncBtn.click();
await downloadCaptured;
console.log('[Gate] Client B completed its pre-import download; response paused');
// ===== PHASE 5: A imports + uploads while B is between download/upload =====
// A already synced a populated state (PHASE 1), so the import diverges from
// the server: A's own sync raises the sync-import conflict gate against its
// pending import op. Resolve it as USE_LOCAL ({ useLocal: true }) so A force
// uploads the import as a NEW SYNC_IMPORT the server keeps. The default
// (USE_REMOTE) would discard A's import here, leaving the server unchanged
// and B with nothing to conflict against — so the PHASE 6 dialog never shows.
const importPageA = new ImportPage(clientA.page);
try {
await importPageA.navigateToImportPage();
await importPageA.importBackupFile(ImportPage.getFixturePath('test-backup.json'));
await clientA.sync.syncAndWait({ useLocal: true });
} finally {
// Never strand B's routed request if A's setup/assertion fails.
releaseDownload();
}
console.log('[Gate] Client A imported a backup and synced the SYNC_IMPORT');
// ===== PHASE 6: B uploads and receives A's import via piggyback =====
await expect(clientB.sync.syncImportConflictDialog).toBeVisible({ timeout: 30000 });
console.log('[Gate] ✓ Piggyback conflict shown for pending non-task work');
// Resolving with "Use My Data" keeps B's local work; the point of the test
// is that B was given the choice at all.
await clientB.sync.syncImportUseLocalBtn.click();
await clientB.sync.syncImportConflictDialog.waitFor({
state: 'hidden',
timeout: 10000,
});
await clientB.sync.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 });
// The dialog itself is not the guarantee: prove the exact pending counter
// value won, then run another sync and prove it remains durable.
await clientB.page.goto('/#/tag/TODAY/tasks');
await clientB.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/);
await expectClickCounterValue(clientB, counterTitle, 2);
await clientB.sync.syncAndWait();
await expectClickCounterValue(clientB, counterTitle, 2);
// Prove convergence, not just Client B's local durability: Client A must
// download the state B force-uploaded when it chose "Use My Data".
await clientA.sync.syncAndWait();
await clientA.page.goto('/#/tag/TODAY/tasks');
await clientA.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/);
await expectClickCounterValue(clientA, counterTitle, 2);
console.log('[Gate] ✓ Pending counter value converged on both clients');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
});

View file

@ -10,29 +10,38 @@ import {
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
// Reads the op-log store directly and counts terminally-rejected ops (rejectedAt
// set). This is the precise, timing-independent discriminator for #8331: the
// transient blip must NOT have rejected the pending edit.
const countRejectedOps = (page: Page): Promise<number> =>
page.evaluate(async () => {
// Reads the exact server-rejected op from the op-log. This is the precise,
// timing-independent discriminator for #8331: the transient blip must leave
// that edit pending, regardless of unrelated startup-only op cleanup.
const isOpPending = (page: Page, opId: string): Promise<boolean> =>
page.evaluate(async (id) => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open('SUP_OPS');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
try {
if (!db.objectStoreNames.contains('ops')) return 0;
const entries = await new Promise<{ rejectedAt?: number }[]>((resolve, reject) => {
if (!db.objectStoreNames.contains('ops')) return false;
const entry = await new Promise<
{ syncedAt?: number; rejectedAt?: number } | undefined
>((resolve, reject) => {
const tx = db.transaction('ops', 'readonly');
const r = tx.objectStore('ops').getAll();
r.onsuccess = () => resolve(r.result as { rejectedAt?: number }[]);
r.onerror = () => reject(r.error);
const request = tx.objectStore('ops').index('byId').get(id);
request.onsuccess = () =>
resolve(
request.result as { syncedAt?: number; rejectedAt?: number } | undefined,
);
request.onerror = () => reject(request.error);
});
return entries.filter((e) => e.rejectedAt).length;
return (
entry !== undefined &&
entry.syncedAt === undefined &&
entry.rejectedAt === undefined
);
} finally {
db.close();
}
});
}, opId);
/**
* Regression: transient download failure during rejected-ops resolution must
@ -45,11 +54,10 @@ const countRejectedOps = (page: Page): Promise<number> =>
* locally but never reached other devices. The fix leaves the op pending so it
* re-resolves on the next sync.
*
* Discriminator: after the blip, B's pending edit must NOT be terminally
* rejected (op-log has zero rejectedAt ops) with the bug the catch called
* markRejected() so that count is >= 1. End-to-end: once the fault clears, B's
* edit resolves and both clients converge to the same title; with the bug B's
* edit is dropped and the clients diverge.
* Discriminator: after the blip, the exact CONFLICT_CONCURRENT op must still be
* pending. With the bug, the catch called markRejected() on that op. End-to-end:
* once the fault clears, B's edit resolves and both clients converge to the
* same title; with the bug B's edit is dropped and the clients diverge.
*
* Prerequisites:
* - super-sync-server running on localhost:1901 with TEST_MODE=true
@ -153,6 +161,7 @@ test.describe('@supersync Rejected-ops transient download (#8331)', () => {
let abortedResolutionGets = 0;
let strippedPreUploadDownloads = 0;
let strippedConflictPiggybacks = 0;
let concurrentRejectedOpId: string | null = null;
await clientB.page.route('**/api/sync/ops*', async (route) => {
const method = route.request().method();
if (method === 'GET') {
@ -193,6 +202,15 @@ test.describe('@supersync Rejected-ops transient download (#8331)', () => {
sawUploadAttempt = true;
const response = await route.fetch();
const json = await response.json();
const concurrentResult = Array.isArray(json.results)
? json.results.find(
(result: { errorCode?: unknown }) =>
result.errorCode === 'CONFLICT_CONCURRENT',
)
: undefined;
if (typeof concurrentResult?.opId === 'string') {
concurrentRejectedOpId = concurrentResult.opId;
}
if (Array.isArray(json.newOps) && json.newOps.length > 0) {
json.newOps = [];
json.hasMorePiggyback = false;
@ -226,11 +244,15 @@ test.describe('@supersync Rejected-ops transient download (#8331)', () => {
expect(strippedPreUploadDownloads).toBeGreaterThanOrEqual(1);
expect(strippedConflictPiggybacks).toBeGreaterThanOrEqual(1);
expect(abortedResolutionGets).toBeGreaterThanOrEqual(1);
expect(concurrentRejectedOpId).not.toBeNull();
if (!concurrentRejectedOpId) {
throw new Error('Expected a CONFLICT_CONCURRENT upload result');
}
// Precise discriminator (timing-independent): the blip must not have
// terminally rejected B's pending edit. With the bug, the catch called
// markRejected() so this is >= 1; with the fix the edit stays pending -> 0.
expect(await countRejectedOps(clientB.page)).toBe(0);
// terminally rejected B's exact pending edit. With the bug, the catch
// called markRejected(); with the fix the entry remains pending.
expect(await isOpPending(clientB.page, concurrentRejectedOpId)).toBe(true);
// 7. Remove the fault and let B sync cleanly — the still-pending edit
// resolves and uploads (the merged op may need a second flush).

View file

@ -0,0 +1,177 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import { ImportPage } from '../../pages/import.page';
import {
closeClient,
createSimulatedClient,
createTestUser,
getSuperSyncConfig,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import { waitForAppReady } from '../../utils/waits';
const CRASH_STATE_KEY = 'e2e-use-remote-crash-state';
const CRASH_LOG = '[CrashResume] Simulating reload after remote baseline commit';
const REBUILD_COMMITTED_LOG =
'OperationLogSyncService: Replaced local persistence with remote baseline.';
const RESUME_DETECTED_LOG =
'OperationLogSyncService: Interrupted USE_REMOTE rebuild detected';
/**
* SuperSync USE_REMOTE crash recovery.
*
* The test reloads Client B immediately after runRemoteStateReplacement() has
* atomically committed the remote baseline and its raw-rebuild-incomplete marker,
* but before replay can finish and clear that marker. On the next sync, B must:
*
* 1. detect the interrupted rebuild,
* 2. redo the raw server-history download,
* 3. keep the FIRST attempt's pre-replace import backup,
* 4. finish on the remote state, and
* 5. offer Undo that restores B's original imported state.
*
* Run with:
* npm run e2e:supersync:file e2e/tests/sync/supersync-use-remote-crash-resume.spec.ts
*/
test.describe('@supersync USE_REMOTE interrupted rebuild recovery', () => {
test.describe.configure({ mode: 'serial' });
test('resumes after reload and preserves the original Undo backup', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(180000);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
const remoteTask = `CrashResumeRemote-${testRunId}`;
const importedTask = 'E2E Import Test - Active Task With Subtask';
// ===== PHASE 1: A establishes the remote state =====
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
await clientA.workView.addTask(remoteTask);
await clientA.sync.syncAndWait();
// ===== PHASE 2: B installs a one-shot crash failpoint before app boot =====
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.page.addInitScript(
({ crashLog, crashStateKey, rebuildCommittedLog }) => {
const e2eGlobal = globalThis as typeof globalThis & {
__SP_E2E_BLOCK_AUTO_SYNC?: boolean;
__SP_E2E_BLOCK_IMMEDIATE_UPLOAD?: boolean;
__SP_E2E_BLOCK_WS_DOWNLOAD?: boolean;
};
// Allow setup's initial sync before the crash. On the reload, block
// automatic sync so the test can observe and trigger recovery itself.
e2eGlobal.__SP_E2E_BLOCK_AUTO_SYNC =
sessionStorage.getItem(crashStateKey) === 'crashed';
e2eGlobal.__SP_E2E_BLOCK_IMMEDIATE_UPLOAD = true;
e2eGlobal.__SP_E2E_BLOCK_WS_DOWNLOAD = true;
// Install before Angular modules load so Log's pre-bound console method
// captures this wrapper. The session marker makes the reload one-shot.
const originalLog = console.log.bind(console);
console.log = (...args: unknown[]): void => {
originalLog(...args);
const message = args.map(String).join(' ');
if (
sessionStorage.getItem(crashStateKey) === 'armed' &&
message.includes(rebuildCommittedLog)
) {
sessionStorage.setItem(crashStateKey, 'crashed');
originalLog(crashLog);
// Abort the current replay task at the exact committed-baseline
// cutoff. Playwright reloads the document after observing crashLog.
throw new Error(crashLog);
}
};
},
{
crashLog: CRASH_LOG,
crashStateKey: CRASH_STATE_KEY,
rebuildCommittedLog: REBUILD_COMMITTED_LOG,
},
);
await clientB.page.reload();
await waitForAppReady(clientB.page);
// Importing gives B a known local state and creates a full-state local op,
// guaranteeing the incoming server history requires an explicit choice.
const importPageB = new ImportPage(clientB.page);
await importPageB.navigateToImportPage();
await importPageB.importBackupFile(ImportPage.getFixturePath('test-backup.json'));
await clientB.page.goto('/#/tag/TODAY/tasks');
await clientB.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/);
await expect(clientB.page.locator('task', { hasText: importedTask })).toBeVisible({
timeout: 15000,
});
await clientB.page.evaluate(
({ crashStateKey }) => sessionStorage.setItem(crashStateKey, 'armed'),
{ crashStateKey: CRASH_STATE_KEY },
);
// Setup starts the first sync but leaves its conflict dialog for the test.
await clientB.sync.setupSuperSync({
...syncConfig,
waitForInitialSync: false,
});
await expect(clientB.sync.syncImportConflictDialog).toBeVisible({ timeout: 30000 });
// ===== PHASE 3: choose USE_REMOTE and reload at the atomic baseline cutoff =====
const crashObserved = clientB.page.waitForEvent('console', {
predicate: (message) => message.text().includes(CRASH_LOG),
timeout: 30000,
});
await clientB.sync.syncImportUseRemoteBtn.click();
await crashObserved;
await clientB.page.reload();
await waitForAppReady(clientB.page);
expect(
await clientB.page.evaluate(
({ crashStateKey }) => sessionStorage.getItem(crashStateKey),
{ crashStateKey: CRASH_STATE_KEY },
),
).toBe('crashed');
// ===== PHASE 4: next sync must resume raw rebuild and keep first backup =====
const resumeDetected = clientB.page.waitForEvent('console', {
predicate: (message) => message.text().includes(RESUME_DETECTED_LOG),
timeout: 30000,
});
await clientB.sync.syncAndWait({ timeout: 60000 });
await resumeDetected;
// B was already on Today before the reload. Keep the current work context:
// changing it intentionally dismisses snacks, including the persistent Undo.
await expect(clientB.page.locator('task', { hasText: remoteTask })).toBeVisible({
timeout: 15000,
});
await expect(
clientB.page.locator('task', { hasText: importedTask }),
).not.toBeVisible();
const undoSnack = clientB.page.locator('snack-custom', {
hasText: /replaced with the server/i,
});
await expect(undoSnack).toBeVisible({ timeout: 30000 });
await undoSnack.locator('button.action').click();
// Undo must restore the backup from BEFORE the first, interrupted replace,
// not a backup of the partial remote baseline captured during crash resume.
await expect(clientB.page.locator('task', { hasText: importedTask })).toBeVisible({
timeout: 15000,
});
console.log('[CrashResume] ✓ Original imported state restored from first backup');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
});

View file

@ -1,9 +1,5 @@
// Schema version constants
export {
CURRENT_SCHEMA_VERSION,
MIN_SUPPORTED_SCHEMA_VERSION,
MAX_VERSION_SKIP,
} from './schema-version';
export { CURRENT_SCHEMA_VERSION, MIN_SUPPORTED_SCHEMA_VERSION } from './schema-version';
// Types
export type {

View file

@ -30,17 +30,14 @@ export const MiscToTasksSettingsMigration_v1v2: SchemaMigration = {
const tasks = state.globalConfig?.tasks ?? {};
// Skip if already migrated (tasks has new fields AND misc has no migrated fields)
if (tasks.isConfirmBeforeDelete !== undefined && !hasMigratedFields(misc)) {
return state;
}
return {
...state,
globalConfig: {
...state.globalConfig,
misc: removeMigratedFields(misc),
tasks: { ...tasks, ...transformMiscToTasks(misc) },
// Existing target values come from a newer/partial migration and must
// not be overwritten by stale legacy copies left in misc.
tasks: { ...transformMiscToTasks(misc), ...tasks },
},
};
},
@ -97,6 +94,7 @@ export const MiscToTasksSettingsMigration_v1v2: SchemaMigration = {
result.push({
...op,
id: `${op.id}_misc`,
entityIds: ['misc'],
payload: buildPayload(miscCfg, 'misc'),
});
}
@ -106,6 +104,7 @@ export const MiscToTasksSettingsMigration_v1v2: SchemaMigration = {
...op,
id: `${op.id}_tasks`,
entityId: 'tasks',
entityIds: ['tasks'],
payload: buildPayload(tasksCfg, 'tasks'),
});
}

View file

@ -10,9 +10,6 @@ export const CURRENT_SCHEMA_VERSION = 2;
*/
export const MIN_SUPPORTED_SCHEMA_VERSION = 1;
/**
* Maximum version difference we tolerate before forcing an app update.
* If remote data is more than MAX_VERSION_SKIP versions ahead,
* the user must update their app.
*/
export const MAX_VERSION_SKIP = 3;
// NOTE: there is deliberately NO forward-compat band: any op from a NEWER
// schema version is blocked outright (the client cannot know how to interpret
// it), and the user is prompted to update the app.

View file

@ -5,6 +5,12 @@ export const SUPER_SYNC_MAX_CLIENT_ID_LENGTH = 255;
export const SUPER_SYNC_MAX_OPS_PER_UPLOAD = 100;
export const SUPER_SYNC_MAX_ENTITY_IDS_PER_OP = 1000;
// Upload-only fields must be loose enough to reach per-operation validation,
// but still bounded so one invalid item cannot amplify logs/responses or make
// semantic validation walk an arbitrarily large identifier collection.
const SUPER_SYNC_MAX_INVALID_FIELD_TRANSPORT_LENGTH = 4096;
const SUPER_SYNC_MAX_INVALID_ENTITY_IDS_TRANSPORT = SUPER_SYNC_MAX_ENTITY_IDS_PER_OP * 2;
export const SUPER_SYNC_OP_TYPES = [
'CRT',
'UPD',
@ -69,13 +75,32 @@ export const SuperSyncOperationSchema = z.object({
payload: z.unknown(),
vectorClock: SuperSyncVectorClockSchema,
timestamp: z.number(),
schemaVersion: z.number(),
schemaVersion: z.number().int().min(1).max(100),
isPayloadEncrypted: z.boolean().optional(),
syncImportReason: z.enum(SUPER_SYNC_IMPORT_REASONS).optional(),
});
// Upload requests are envelopes for independently validated operations. Keep
// structural types and fields that ValidationService does not handle strict,
// but defer semantic operation validation to the server so one malformed op
// cannot reject and stall every valid sibling in the batch. Download/response
// schemas remain strict.
const SuperSyncUploadOperationSchema = SuperSyncOperationSchema.extend({
id: z.string().max(SUPER_SYNC_MAX_INVALID_FIELD_TRANSPORT_LENGTH),
clientId: z.string().max(SUPER_SYNC_MAX_INVALID_FIELD_TRANSPORT_LENGTH),
opType: z.string().max(SUPER_SYNC_MAX_INVALID_FIELD_TRANSPORT_LENGTH),
entityType: z.string().max(SUPER_SYNC_MAX_INVALID_FIELD_TRANSPORT_LENGTH),
entityId: z.string().max(SUPER_SYNC_MAX_INVALID_FIELD_TRANSPORT_LENGTH).optional(),
entityIds: z
.array(z.string().max(SUPER_SYNC_MAX_INVALID_FIELD_TRANSPORT_LENGTH))
.max(SUPER_SYNC_MAX_INVALID_ENTITY_IDS_TRANSPORT)
.optional(),
vectorClock: z.record(z.string(), z.unknown()),
schemaVersion: z.number(),
});
export const SuperSyncUploadOpsRequestSchema = z.object({
ops: z.array(SuperSyncOperationSchema).min(1).max(SUPER_SYNC_MAX_OPS_PER_UPLOAD),
ops: z.array(SuperSyncUploadOperationSchema).min(1).max(SUPER_SYNC_MAX_OPS_PER_UPLOAD),
clientId: SuperSyncClientIdSchema,
lastKnownServerSeq: z.number().optional(),
requestId: SuperSyncRequestIdSchema.optional(),
@ -87,19 +112,29 @@ export const SuperSyncDownloadOpsQuerySchema = z.object({
excludeClient: SuperSyncClientIdSchema.optional(),
});
export const SuperSyncUploadSnapshotRequestSchema = z.object({
state: z.unknown(),
clientId: SuperSyncClientIdSchema,
reason: z.enum(SUPER_SYNC_SNAPSHOT_REASONS),
vectorClock: SuperSyncVectorClockSchema,
schemaVersion: z.number().optional(),
isPayloadEncrypted: z.boolean().optional(),
syncImportReason: z.enum(SUPER_SYNC_IMPORT_REASONS).optional(),
opId: z.string().uuid().optional(),
isCleanSlate: z.boolean().optional(),
snapshotOpType: z.enum(SUPER_SYNC_SNAPSHOT_OP_TYPES).optional(),
requestId: SuperSyncRequestIdSchema.optional(),
});
export const SuperSyncUploadSnapshotRequestSchema = z
.object({
state: z.unknown(),
clientId: SuperSyncClientIdSchema,
reason: z.enum(SUPER_SYNC_SNAPSHOT_REASONS),
vectorClock: SuperSyncVectorClockSchema,
schemaVersion: z.number().int().min(1).max(100).optional(),
isPayloadEncrypted: z.boolean().optional(),
syncImportReason: z.enum(SUPER_SYNC_IMPORT_REASONS).optional(),
opId: z.string().uuid().optional(),
isCleanSlate: z.boolean().optional(),
snapshotOpType: z.enum(SUPER_SYNC_SNAPSHOT_OP_TYPES).optional(),
requestId: SuperSyncRequestIdSchema.optional(),
})
.superRefine((request, context) => {
if (request.isCleanSlate && !request.opId) {
context.addIssue({
code: 'custom',
path: ['opId'],
message: 'opId is required for clean-slate snapshot idempotency',
});
}
});
export const SuperSyncOperationResponseSchema = SuperSyncOperationSchema.passthrough();

View file

@ -64,7 +64,7 @@ describe('Migrate MiscConfig to TasksConfig', () => {
expect(migratedState).toEqual(initialState);
});
it('should complete migration even if tasks.isConfirmBeforeDelete exists but misc still has migrated fields', () => {
it('should preserve target choices while migrating remaining legacy fields', () => {
const initialState = {
globalConfig: {
misc: {
@ -86,8 +86,9 @@ describe('Migrate MiscConfig to TasksConfig', () => {
};
};
// Should migrate remaining fields from misc
expect(migratedState.globalConfig.tasks.isConfirmBeforeDelete).toBe(true); // Migrated value overwrites
// A populated target field belongs to the newer/partial migration and wins
// over the stale legacy copy.
expect(migratedState.globalConfig.tasks.isConfirmBeforeDelete).toBe(false);
expect(migratedState.globalConfig.tasks.defaultProjectId).toBe('proj-123'); // Migrated
expect(migratedState.globalConfig.tasks.existingField).toBe('existing'); // Preserved
expect(migratedState.globalConfig.misc.isConfirmBeforeTaskDelete).toBeUndefined(); // Removed

View file

@ -4,6 +4,7 @@ import {
SUPER_SYNC_MAX_OPS_PER_UPLOAD,
SuperSyncDownloadOpsQuerySchema,
SuperSyncDownloadOpsResponseSchema,
SuperSyncOperationSchema,
SuperSyncUploadOpsRequestSchema,
SuperSyncUploadSnapshotRequestSchema,
} from '../src/supersync-http-contract';
@ -46,21 +47,22 @@ describe('SuperSync HTTP contract schemas', () => {
expect('extraOpField' in parsed.ops[0]).toBe(false);
});
it('caps entityIds per operation', () => {
expect(() =>
SuperSyncUploadOpsRequestSchema.parse({
ops: [
{
...createValidOperation(),
entityIds: Array.from(
{ length: SUPER_SYNC_MAX_ENTITY_IDS_PER_OP + 1 },
(_, i) => `task-${i}`,
),
},
],
clientId: 'client_1',
}),
).toThrow();
it('passes oversized entityIds through for per-operation validation', () => {
const operation = {
...createValidOperation(),
entityIds: Array.from(
{ length: SUPER_SYNC_MAX_ENTITY_IDS_PER_OP + 1 },
(_, i) => `task-${i}`,
),
};
const parsed = SuperSyncUploadOpsRequestSchema.parse({
ops: [operation],
clientId: 'client_1',
});
expect(parsed.ops[0].entityIds).toHaveLength(SUPER_SYNC_MAX_ENTITY_IDS_PER_OP + 1);
expect(() => SuperSyncOperationSchema.parse(operation)).toThrow();
});
it('rejects invalid client IDs in upload requests', () => {
@ -72,6 +74,87 @@ describe('SuperSync HTTP contract schemas', () => {
).toThrow();
});
it.each([0, 1.5, -1, 101])(
'passes operation schema version %s through the upload transport schema',
(schemaVersion) => {
const parsed = SuperSyncUploadOpsRequestSchema.parse({
ops: [{ ...createValidOperation(), schemaVersion }],
clientId: 'client_1',
});
expect(parsed.ops[0].schemaVersion).toBe(schemaVersion);
expect(() =>
SuperSyncOperationSchema.parse({
...createValidOperation(),
schemaVersion,
}),
).toThrow();
},
);
it.each([
['empty operation ID', { id: '' }],
['overlong operation ID', { id: 'x'.repeat(256) }],
['mismatched operation client ID', { clientId: 'other client' }],
['unknown operation type', { opType: 'UNKNOWN' }],
['unknown entity type', { entityType: 'UNKNOWN' }],
['overlong entity ID', { entityId: 'x'.repeat(256) }],
['invalid vector-clock entry', { vectorClock: { client_1: 'invalid' } }],
])('passes semantic %s through for per-operation validation', (_label, override) => {
const operation = { ...createValidOperation(), ...override };
const parsed = SuperSyncUploadOpsRequestSchema.parse({
ops: [operation],
clientId: 'client_1',
});
expect(parsed.ops).toHaveLength(1);
});
it.each([
['non-string operation ID', { id: 123 }],
['empty action type', { actionType: '' }],
['non-string operation type', { opType: 123 }],
['non-string entity type', { entityType: 123 }],
['non-string entity ID', { entityId: 123 }],
['non-string entityIds member', { entityIds: ['task-1', 123] }],
['non-object vector clock', { vectorClock: [] }],
['non-numeric timestamp', { timestamp: '123' }],
['non-numeric schema version', { schemaVersion: '1' }],
['non-boolean encryption flag', { isPayloadEncrypted: 'true' }],
['unknown import reason', { syncImportReason: 'UNKNOWN' }],
])('keeps the upload transport constraint for %s', (_label, override) => {
expect(() =>
SuperSyncUploadOpsRequestSchema.parse({
ops: [{ ...createValidOperation(), ...override }],
clientId: 'client_1',
}),
).toThrow();
});
it('rejects semantically invalid identifiers beyond the absolute transport cap', () => {
expect(() =>
SuperSyncUploadOpsRequestSchema.parse({
ops: [{ ...createValidOperation(), id: 'x'.repeat(4097) }],
clientId: 'client_1',
}),
).toThrow();
});
it.each([0, 1.5, -1, 101])(
'rejects malformed snapshot schema version %s',
(schemaVersion) => {
expect(() =>
SuperSyncUploadSnapshotRequestSchema.parse({
state: {},
clientId: 'client_1',
reason: 'recovery',
vectorClock: { client_1: 1 },
schemaVersion,
}),
).toThrow();
},
);
it('coerces download query numbers like the route-level schema', () => {
const parsed = SuperSyncDownloadOpsQuerySchema.parse({
sinceSeq: '5',
@ -105,6 +188,19 @@ describe('SuperSync HTTP contract schemas', () => {
expect(parsed.requestId).toBe('snapshot-v1-request');
});
it('requires an operation ID for destructive clean-slate snapshots', () => {
expect(() =>
SuperSyncUploadSnapshotRequestSchema.parse({
state: {},
clientId: 'client_1',
reason: 'recovery',
vectorClock: { client_1: 1 },
schemaVersion: 1,
isCleanSlate: true,
}),
).toThrow();
});
it('rejects requestIds containing characters outside the safe-log charset', () => {
// Control character — would be unsafe to embed in server log lines.
expect(() =>

View file

@ -1,4 +1,5 @@
import { Prisma } from '@prisma/client';
import { CURRENT_SCHEMA_VERSION } from '@sp/shared-schema';
import { Logger } from '../logger';
import {
BatchUploadCandidate,
@ -34,11 +35,7 @@ export const detectConflict = async (
// Build list of entity IDs to check for conflicts.
// Operations may have either entityId (singular) or entityIds (batch operations).
const rawEntityIdsToCheck = op.entityIds?.length
? op.entityIds
: op.entityId
? [op.entityId]
: [];
const rawEntityIdsToCheck = getConflictEntityIds(op);
// Skip if no entity IDs (can't have entity-level conflicts)
if (rawEntityIdsToCheck.length === 0) {
@ -49,6 +46,18 @@ export const detectConflict = async (
return detectConflictForEntity(userId, op, rawEntityIdsToCheck[0], tx);
}
// v1 GLOBAL_CONFIG:misc contains fields that became GLOBAL_CONFIG:tasks in
// v2. This compatibility path is intentionally per-key: it is rare, keeps
// the normal multi-entity SQL unchanged, and works for encrypted legacy
// payloads whose contents the server cannot inspect.
if (isLegacyMiscConfigOperation(op)) {
for (const entityId of rawEntityIdsToCheck) {
const result = await detectConflictForEntity(userId, op, entityId, tx);
if (result.hasConflict) return result;
}
return { hasConflict: false };
}
const entityIdsToCheck = Array.from(new Set(rawEntityIdsToCheck));
return detectConflictForEntities(userId, op, entityIdsToCheck, tx);
};
@ -210,16 +219,45 @@ export const detectConflictForEntity = async (
entityType: op.entityType,
OR: [{ entityId }, { entityIds: { has: entityId } }],
},
select: { clientId: true, vectorClock: true },
select: { clientId: true, vectorClock: true, serverSeq: true },
orderBy: { serverSeq: 'desc' },
});
// Histories written before schema v2 persist migrated task settings under
// the raw `GLOBAL_CONFIG:misc` key. Consult that key as an alias when the
// incoming write targets `tasks`; no backfill (and no payload decryption) is
// required. Pick the newer of the canonical and legacy rows.
//
// NOTE: the alias exists only on this per-entity path. A v2 MULTI-entity op
// whose entityIds include 'tasks' goes through detectConflictForEntities and
// skips the legacy lookup — acceptable today because GLOBAL_CONFIG writes are
// single-entity; revisit if a batch path ever carries config entities.
const legacyMiscOp =
op.entityType === 'GLOBAL_CONFIG' && entityId === 'tasks'
? await tx.operation.findFirst({
where: {
userId,
entityType: 'GLOBAL_CONFIG',
entityId: 'misc',
schemaVersion: { lt: CURRENT_SCHEMA_VERSION },
},
select: { clientId: true, vectorClock: true, serverSeq: true },
orderBy: { serverSeq: 'desc' },
})
: null;
const latestExistingOp =
legacyMiscOp &&
(!existingOp || (legacyMiscOp.serverSeq ?? -1) > (existingOp.serverSeq ?? -1))
? legacyMiscOp
: existingOp;
// No existing operation = no conflict
if (!existingOp) {
if (!latestExistingOp) {
return { hasConflict: false };
}
return resolveConflictForExistingOp(op, entityId, existingOp);
return resolveConflictForExistingOp(op, entityId, latestExistingOp);
};
export const isSameDuplicateOperation = (
@ -246,6 +284,10 @@ export const isSameDuplicateOperation = (
existingOp.opType === op.opType &&
existingOp.entityType === op.entityType &&
existingOp.entityId === (op.entityId ?? null) &&
// Compare against the same normalization the row was persisted with
// (getStoredEntityIds: single-entity sets collapse to []), so a genuine
// retry matches while a batch op differing only in entityIds does not.
areJsonValuesEqual(existingOp.entityIds, getStoredEntityIds(op)) &&
payloadsMatch &&
areJsonValuesEqual(existingOp.vectorClock, storedVectorClock) &&
existingOp.schemaVersion === op.schemaVersion &&
@ -261,6 +303,34 @@ export const isSameDuplicateOperation = (
);
};
/** Complete identity comparison for two validated operations in one request. */
export const isSameIncomingOperation = (
first: Operation,
second: Operation,
firstOriginalTimestamp: number = first.timestamp,
secondOriginalTimestamp: number = second.timestamp,
): boolean => {
const bothEncrypted =
(first.isPayloadEncrypted ?? false) && (second.isPayloadEncrypted ?? false);
return (
first.clientId === second.clientId &&
first.actionType === second.actionType &&
first.opType === second.opType &&
first.entityType === second.entityType &&
first.entityId === second.entityId &&
areJsonValuesEqual(getStoredEntityIds(first), getStoredEntityIds(second)) &&
(bothEncrypted || areJsonValuesEqual(first.payload, second.payload)) &&
areJsonValuesEqual(
limitVectorClockSize(first.vectorClock, [first.clientId]),
limitVectorClockSize(second.vectorClock, [second.clientId]),
) &&
first.schemaVersion === second.schemaVersion &&
firstOriginalTimestamp === secondOriginalTimestamp &&
(first.isPayloadEncrypted ?? false) === (second.isPayloadEncrypted ?? false) &&
(first.syncImportReason ?? null) === (second.syncImportReason ?? null)
);
};
export const isSameDuplicateTimestamp = (
existingTimestamp: bigint | number | string,
existingReceivedAt: bigint | number | string,
@ -318,14 +388,21 @@ export const toStableJsonValue = (value: unknown): unknown => {
* `entity_ids` column, which uses {@link getStoredEntityIds} (multi-entity only).
*/
export const getConflictEntityIds = (op: Operation): string[] => {
const rawEntityIds = op.entityIds?.length
? op.entityIds
: op.entityId
? [op.entityId]
: [];
const rawEntityIds = [
...(op.entityId ? [op.entityId] : []),
...(op.entityIds?.length ? op.entityIds : []),
];
if (isLegacyMiscConfigOperation(op)) {
rawEntityIds.push('tasks');
}
return Array.from(new Set(rawEntityIds));
};
const isLegacyMiscConfigOperation = (op: Operation): boolean =>
op.schemaVersion < CURRENT_SCHEMA_VERSION &&
op.entityType === 'GLOBAL_CONFIG' &&
op.entityId === 'misc';
/**
* 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
@ -339,7 +416,12 @@ export const getConflictEntityIds = (op: Operation): string[] => {
* would be invisible to conflict lookups (#8334).
*/
export const getStoredEntityIds = (op: Operation): string[] => {
const ids = getConflictEntityIds(op);
// Preserve the historical storage normalization: the scalar is persisted in
// entity_id and only the declared multi-entity set belongs in entity_ids.
// Conflict detection unions both fields via getConflictEntityIds above.
const ids = Array.from(
new Set(op.entityIds?.length ? op.entityIds : op.entityId ? [op.entityId] : []),
);
if (ids.length <= 1 && ids[0] === op.entityId) {
return [];
}
@ -404,7 +486,8 @@ export const prefetchLatestEntityOpsForBatch = async (
o.entity_type AS "entityType",
eid AS "entityId",
o.client_id AS "clientId",
o.vector_clock AS "vectorClock"
o.vector_clock AS "vectorClock",
o.server_seq AS "serverSeq"
FROM operations o
CROSS JOIN LATERAL unnest(
o.entity_ids || CASE WHEN o.entity_id IS NULL THEN '{}'::text[] ELSE ARRAY[o.entity_id] END
@ -425,6 +508,37 @@ export const prefetchLatestEntityOpsForBatch = async (
}
}
if (
entityPairs.some(
({ entityType, entityId }) =>
entityType === 'GLOBAL_CONFIG' && entityId === 'tasks',
)
) {
const legacyMiscOp = await tx.operation.findFirst({
where: {
userId,
entityType: 'GLOBAL_CONFIG',
entityId: 'misc',
schemaVersion: { lt: CURRENT_SCHEMA_VERSION },
},
select: { clientId: true, vectorClock: true, serverSeq: true },
orderBy: { serverSeq: 'desc' },
});
const tasksKey = getEntityConflictKey('GLOBAL_CONFIG', 'tasks');
const currentTasksOp = latestByEntity.get(tasksKey);
if (
legacyMiscOp &&
(!currentTasksOp ||
(legacyMiscOp.serverSeq ?? -1) > (currentTasksOp.serverSeq ?? -1))
) {
latestByEntity.set(tasksKey, {
entityType: 'GLOBAL_CONFIG',
entityId: 'tasks',
...legacyMiscOp,
});
}
}
return latestByEntity;
};

View file

@ -21,6 +21,7 @@ const OPERATION_DOWNLOAD_SELECT = {
opType: true,
entityType: true,
entityId: true,
entityIds: true,
payload: true,
vectorClock: true,
schemaVersion: true,
@ -54,6 +55,7 @@ type OperationDownloadRow = {
opType: string;
entityType: string;
entityId: string | null;
entityIds: string[];
payload: unknown;
vectorClock: unknown;
schemaVersion: number;
@ -72,6 +74,7 @@ const mapOperationRow = (row: OperationDownloadRow): ServerOperation => ({
opType: row.opType as Operation['opType'],
entityType: row.entityType,
entityId: row.entityId ?? undefined,
entityIds: row.entityIds.length > 0 ? row.entityIds : undefined,
payload: row.payload,
vectorClock: row.vectorClock as VectorClock,
schemaVersion: row.schemaVersion,

View file

@ -1,6 +1,10 @@
import { Prisma } from '@prisma/client';
import { Logger } from '../../logger';
import { computeOpStorageBytes } from '../sync.const';
import {
CLIENT_ID_REGEX,
computeOpStorageBytes,
MAX_CLIENT_ID_LENGTH,
} from '../sync.const';
import {
AcceptedBatchOperation,
BatchUploadCandidate,
@ -11,6 +15,7 @@ import {
isFullStateOpType,
limitVectorClockSize,
Operation,
OP_TYPES,
ProcessOperationResult,
SyncConfig,
SYNC_ERROR_CODES,
@ -24,17 +29,63 @@ import {
getStoredEntityIds,
getEntityConflictKey,
isSameDuplicateOperation,
isSameIncomingOperation,
prefetchLatestEntityOpsForBatch,
pruneVectorClockForStorage,
resolveConflictForExistingOp,
} from '../conflict';
import { ValidationService } from './validation.service';
import {
ALLOWED_ENTITY_TYPES,
ValidationService,
type ValidationResult,
} from './validation.service';
// Observability threshold: log a warning when the full-state op aggregate scan
// exceeds this duration. Mirrors the threshold used by the legacy snapshot
// vector-clock aggregate in OperationDownloadService so production logs use a
// consistent slow-aggregate signal.
const SLOW_FULL_STATE_AGGREGATE_MS = 5_000;
const INVALID_AUDIT_FIELD = '[invalid]';
const SAFE_AUDIT_ID_REGEX = /^[A-Za-z0-9_-]+$/;
const isSafeAuditIdentifier = (value: unknown, maxLength: number): value is string =>
typeof value === 'string' &&
value.length > 0 &&
value.length <= maxLength &&
SAFE_AUDIT_ID_REGEX.test(value);
const getSafeAuditOperationMetadata = (
op: Operation,
): { opId: string; entityType: string; entityId?: string; opType: string } => {
const rawOp = op as unknown as Record<string, unknown>;
const rawOpId = rawOp['id'];
const rawEntityType = rawOp['entityType'];
const rawEntityId = rawOp['entityId'];
const rawOpType = rawOp['opType'];
return {
opId: isSafeAuditIdentifier(rawOpId, 255) ? rawOpId : INVALID_AUDIT_FIELD,
entityType:
typeof rawEntityType === 'string' && ALLOWED_ENTITY_TYPES.has(rawEntityType)
? rawEntityType
: INVALID_AUDIT_FIELD,
entityId:
rawEntityId === undefined || rawEntityId === null
? undefined
: isSafeAuditIdentifier(rawEntityId, 255)
? rawEntityId
: INVALID_AUDIT_FIELD,
opType:
typeof rawOpType === 'string' && OP_TYPES.includes(rawOpType as Operation['opType'])
? rawOpType
: INVALID_AUDIT_FIELD,
};
};
const getSafeAuditClientId = (clientId: string): string =>
clientId.length <= MAX_CLIENT_ID_LENGTH && CLIENT_ID_REGEX.test(clientId)
? clientId
: INVALID_AUDIT_FIELD;
const toSafeServerSeq = (value: number | bigint | undefined, userId: number): number => {
if (typeof value === 'bigint') {
@ -74,9 +125,8 @@ export class OperationUploadService {
Logger.audit({
event: 'TIMESTAMP_CLAMPED',
userId,
clientId,
opId: op.id,
entityType: op.entityType,
clientId: getSafeAuditClientId(clientId),
...getSafeAuditOperationMetadata(op),
originalTimestamp,
clampedTo: maxAllowedTimestamp,
driftMs: originalTimestamp - now,
@ -96,13 +146,10 @@ export class OperationUploadService {
Logger.audit({
event: 'OP_REJECTED',
userId,
clientId,
opId: op.id,
entityType: op.entityType,
entityId: op.entityId,
clientId: getSafeAuditClientId(clientId),
...getSafeAuditOperationMetadata(op),
errorCode,
reason: error,
opType: op.opType,
reason: errorCode ?? 'OP_REJECTED',
});
return {
@ -188,6 +235,8 @@ export class OperationUploadService {
ops: Operation[],
now: number,
tx: Prisma.TransactionClient,
prevalidatedResults?: ReadonlyMap<Operation, ValidationResult>,
requestStartOccupiedIds?: ReadonlySet<string>,
): Promise<{
results: UploadResult[];
acceptedDeltaBytes: number;
@ -208,14 +257,13 @@ export class OperationUploadService {
ops,
now,
results,
prevalidatedResults,
);
const uniqueCandidates = this.rejectIntraBatchDuplicates(
userId,
clientId,
validatedCandidates,
results,
);
// Intra-batch duplicate ids were already terminally rejected in stage 1:
// validateAndClampBatch reserves each id on first occurrence (including
// invalid first siblings), so validatedCandidates is unique by op id.
const uniqueCandidates = validatedCandidates;
if (uniqueCandidates.length === 0) {
return {
@ -232,6 +280,7 @@ export class OperationUploadService {
uniqueCandidates,
tx,
results,
requestStartOccupiedIds,
);
dbRoundtrips += classified.dbRoundtrips;
const duplicateFreeCandidates = classified.duplicateFreeCandidates;
@ -276,8 +325,9 @@ export class OperationUploadService {
}
/**
* Stage 1: clamp future timestamps and validate every op in memory (no DB).
* Invalid ops get a terminal rejection written into `results` by index.
* Stage 1: reserve each transport-level operation ID, clamp timestamps, and
* validate every first occurrence in memory (no DB). Invalid first ops and
* every later same-ID sibling get terminal results by index.
*/
private validateAndClampBatch(
userId: number,
@ -285,12 +335,41 @@ export class OperationUploadService {
ops: Operation[],
now: number,
results: UploadResult[],
prevalidatedResults?: ReadonlyMap<Operation, ValidationResult>,
): BatchUploadCandidate[] {
const validatedCandidates: BatchUploadCandidate[] = [];
const firstOperationById = new Map<
string,
{ op: Operation; originalTimestamp: number }
>();
for (let i = 0; i < ops.length; i++) {
const op = ops[i];
const originalTimestamp = this.clampFutureTimestamp(userId, clientId, op, now);
const validation = this.validationService.validateOp(op, clientId);
const firstOperation = firstOperationById.get(op.id);
if (firstOperation) {
const isExactRetry = isSameIncomingOperation(
firstOperation.op,
op,
firstOperation.originalTimestamp,
originalTimestamp,
);
results[i] = this.rejectedUploadResult(
userId,
clientId,
op,
isExactRetry
? 'Duplicate operation ID'
: 'Operation ID already belongs to a different operation',
isExactRetry
? SYNC_ERROR_CODES.DUPLICATE_OPERATION
: SYNC_ERROR_CODES.INVALID_OP_ID,
);
continue;
}
firstOperationById.set(op.id, { op, originalTimestamp });
const validation =
prevalidatedResults?.get(op) ?? this.validationService.validateOp(op, clientId);
if (!validation.valid) {
results[i] = this.rejectedUploadResult(
@ -317,41 +396,12 @@ export class OperationUploadService {
}
/**
* Stage 2: within a single batch, accept the first op for an id and reject
* every later op sharing that id as DUPLICATE_OPERATION (by id, not content
* see plan §1a step 2 / the C4 divergence note). Must run before sequence
* reservation so a duplicate never consumes a server_seq.
*/
private rejectIntraBatchDuplicates(
userId: number,
clientId: string,
validatedCandidates: BatchUploadCandidate[],
results: UploadResult[],
): BatchUploadCandidate[] {
const seenOpIds = new Set<string>();
const uniqueCandidates: BatchUploadCandidate[] = [];
for (const candidate of validatedCandidates) {
if (seenOpIds.has(candidate.op.id)) {
results[candidate.resultIndex] = this.rejectedUploadResult(
userId,
clientId,
candidate.op,
'Duplicate operation ID',
SYNC_ERROR_CODES.DUPLICATE_OPERATION,
);
continue;
}
seenOpIds.add(candidate.op.id);
uniqueCandidates.push(candidate);
}
return uniqueCandidates;
}
/**
* Stage 3: prefetch any already-persisted ops sharing an incoming id (one
* Stage 3: prefetch any currently persisted ops sharing an incoming id (one
* query) and classify each as an idempotent retry (DUPLICATE_OPERATION) or
* an id collision with different content (INVALID_OP_ID). Survivors are
* returned for conflict detection.
* an id collision with different content (INVALID_OP_ID). If quota cleanup
* removed a row after the route's occupancy check, the request-start set
* still rejects that ID rather than letting it consume unestimated storage.
* Survivors are returned for conflict detection.
*/
private async classifyExistingDuplicates(
userId: number,
@ -359,6 +409,7 @@ export class OperationUploadService {
uniqueCandidates: BatchUploadCandidate[],
tx: Prisma.TransactionClient,
results: UploadResult[],
requestStartOccupiedIds?: ReadonlySet<string>,
): Promise<{
duplicateFreeCandidates: BatchUploadCandidate[];
dbRoundtrips: number;
@ -375,6 +426,16 @@ export class OperationUploadService {
for (const candidate of uniqueCandidates) {
const existingOp = existingOpById.get(candidate.op.id);
if (!existingOp) {
if (requestStartOccupiedIds?.has(candidate.op.id)) {
results[candidate.resultIndex] = this.rejectedUploadResult(
userId,
clientId,
candidate.op,
'Operation ID was already occupied before quota enforcement',
SYNC_ERROR_CODES.INVALID_OP_ID,
);
continue;
}
duplicateFreeCandidates.push(candidate);
continue;
}
@ -602,6 +663,9 @@ export class OperationUploadService {
op: Operation,
now: number,
tx: Prisma.TransactionClient,
prevalidatedResult?: ValidationResult,
wasOccupiedAtRequestStart?: boolean,
firstRequestOperation?: { op: Operation; originalTimestamp: number },
): Promise<ProcessOperationResult> {
// Rejected ops have no storage cost; the caller only reads storageBytes when
// result.accepted is true.
@ -616,26 +680,42 @@ export class OperationUploadService {
const originalTimestamp = this.clampFutureTimestamp(userId, clientId, op, now);
// Validate operation (including clientId match)
const validation = this.validationService.validateOp(op, clientId);
if (!validation.valid) {
Logger.audit({
event: 'OP_REJECTED',
userId,
clientId,
opId: op.id,
entityType: op.entityType,
entityId: op.entityId,
errorCode: validation.errorCode,
reason: validation.error,
opType: op.opType,
});
return reject({
opId: op.id,
accepted: false,
error: validation.error,
errorCode: validation.errorCode,
});
const validation =
prevalidatedResult ?? this.validationService.validateOp(op, clientId);
if (firstRequestOperation) {
const isExactRetry = isSameIncomingOperation(
firstRequestOperation.op,
op,
firstRequestOperation.originalTimestamp,
originalTimestamp,
);
return reject(
this.rejectedUploadResult(
userId,
clientId,
op,
isExactRetry
? 'Duplicate operation ID'
: 'Operation ID already belongs to a different operation',
isExactRetry
? SYNC_ERROR_CODES.DUPLICATE_OPERATION
: SYNC_ERROR_CODES.INVALID_OP_ID,
),
);
}
if (!validation.valid) {
return reject(
this.rejectedUploadResult(
userId,
clientId,
op,
validation.error,
validation.errorCode,
),
);
}
// Capture the *unpruned* vector clock for full-state ops. The op row stores
// the pruned clock (see `limitVectorClockSize` call below); persisting the
// unpruned copy on `user_sync_state` lets the download path re-prune at
@ -663,42 +743,38 @@ export class OperationUploadService {
originalTimestamp,
)
) {
Logger.audit({
event: 'OP_REJECTED',
userId,
clientId,
opId: op.id,
entityType: op.entityType,
entityId: op.entityId,
errorCode: SYNC_ERROR_CODES.INVALID_OP_ID,
reason: 'Operation ID already belongs to a different operation',
opType: op.opType,
});
return reject({
opId: op.id,
accepted: false,
error: 'Operation ID already belongs to a different operation',
errorCode: SYNC_ERROR_CODES.INVALID_OP_ID,
});
return reject(
this.rejectedUploadResult(
userId,
clientId,
op,
'Operation ID already belongs to a different operation',
SYNC_ERROR_CODES.INVALID_OP_ID,
),
);
}
Logger.audit({
event: 'OP_REJECTED',
userId,
clientId,
opId: op.id,
entityType: op.entityType,
entityId: op.entityId,
errorCode: SYNC_ERROR_CODES.DUPLICATE_OPERATION,
reason: 'Duplicate operation ID (pre-check)',
opType: op.opType,
});
return reject({
opId: op.id,
accepted: false,
error: 'Duplicate operation ID',
errorCode: SYNC_ERROR_CODES.DUPLICATE_OPERATION,
});
return reject(
this.rejectedUploadResult(
userId,
clientId,
op,
'Duplicate operation ID',
SYNC_ERROR_CODES.DUPLICATE_OPERATION,
),
);
}
if (wasOccupiedAtRequestStart) {
return reject(
this.rejectedUploadResult(
userId,
clientId,
op,
'Operation ID was already occupied before quota enforcement',
SYNC_ERROR_CODES.INVALID_OP_ID,
),
);
}
// Check for conflicts with existing operations
@ -709,24 +785,16 @@ export class OperationUploadService {
conflict.conflictType === 'equal_different_client'
? SYNC_ERROR_CODES.CONFLICT_CONCURRENT
: SYNC_ERROR_CODES.CONFLICT_SUPERSEDED;
Logger.audit({
event: 'OP_REJECTED',
userId,
clientId,
opId: op.id,
entityType: op.entityType,
entityId: op.entityId,
errorCode,
reason: conflict.reason,
opType: op.opType,
});
return reject({
opId: op.id,
accepted: false,
error: conflict.reason,
errorCode,
existingClock: conflict.existingClock,
});
return reject(
this.rejectedUploadResult(
userId,
clientId,
op,
conflict.reason,
errorCode,
conflict.existingClock,
),
);
}
// Get next sequence number
@ -752,24 +820,16 @@ export class OperationUploadService {
finalConflict.conflictType === 'equal_different_client'
? SYNC_ERROR_CODES.CONFLICT_CONCURRENT
: SYNC_ERROR_CODES.CONFLICT_SUPERSEDED;
Logger.audit({
event: 'OP_REJECTED',
userId,
clientId,
opId: op.id,
entityType: op.entityType,
entityId: op.entityId,
errorCode,
reason: `[RACE] ${finalConflict.reason}`,
opType: op.opType,
});
return reject({
opId: op.id,
accepted: false,
error: finalConflict.reason,
errorCode,
existingClock: finalConflict.existingClock,
});
return reject(
this.rejectedUploadResult(
userId,
clientId,
op,
finalConflict.reason,
errorCode,
finalConflict.existingClock,
),
);
}
// Prune vector clock AFTER conflict detection but BEFORE storage.
@ -851,42 +911,26 @@ export class OperationUploadService {
originalTimestamp,
)
) {
Logger.audit({
event: 'OP_REJECTED',
userId,
clientId,
opId: op.id,
entityType: op.entityType,
entityId: op.entityId,
errorCode: SYNC_ERROR_CODES.INVALID_OP_ID,
reason: 'Operation ID already belongs to a different operation',
opType: op.opType,
});
return reject({
opId: op.id,
accepted: false,
error: 'Operation ID already belongs to a different operation',
errorCode: SYNC_ERROR_CODES.INVALID_OP_ID,
});
return reject(
this.rejectedUploadResult(
userId,
clientId,
op,
'Operation ID already belongs to a different operation',
SYNC_ERROR_CODES.INVALID_OP_ID,
),
);
}
Logger.audit({
event: 'OP_REJECTED',
userId,
clientId,
opId: op.id,
entityType: op.entityType,
entityId: op.entityId,
errorCode: SYNC_ERROR_CODES.DUPLICATE_OPERATION,
reason: 'Duplicate operation ID (insert race)',
opType: op.opType,
});
return reject({
opId: op.id,
accepted: false,
error: 'Duplicate operation ID',
errorCode: SYNC_ERROR_CODES.DUPLICATE_OPERATION,
});
return reject(
this.rejectedUploadResult(
userId,
clientId,
op,
'Duplicate operation ID',
SYNC_ERROR_CODES.DUPLICATE_OPERATION,
),
);
}
if (fullStateVectorClock) {

View file

@ -8,6 +8,9 @@
* Entries expire after 5 minutes.
*/
import type { UploadResult } from '../sync.types';
import type { Operation } from '../sync.types';
import { createHash } from 'node:crypto';
import { stableJsonStringify } from '../conflict';
/**
* Maximum entries in deduplication cache to prevent unbounded memory growth.
@ -38,8 +41,18 @@ export interface SnapshotDedupResponse {
* even if the same `requestId` string is reused across namespaces.
*/
type RequestDeduplicationEntry =
| { namespace: 'ops'; processedAt: number; results: UploadResult[] }
| { namespace: 'snapshot'; processedAt: number; results: SnapshotDedupResponse };
| {
namespace: 'ops';
processedAt: number;
results: UploadResult[];
fingerprint?: string;
}
| {
namespace: 'snapshot';
processedAt: number;
results: SnapshotDedupResponse;
fingerprint?: string;
};
type DedupPayload<N extends RequestDedupNamespace> = N extends 'ops'
? UploadResult[]
@ -50,12 +63,19 @@ export class RequestDeduplicationService {
/**
* Check if a request has already been processed for this user + namespace.
* @returns Cached results if found and not expired, null otherwise.
*
* @param getFingerprint lazy fingerprint supplier hashing the full request
* body is expensive (stable stringify + SHA-256 over up to multi-MB
* payloads), so it must only run when an entry for this requestId actually
* exists (genuine retries are rare) and never for first-time requests.
* @returns Cached results if found, not expired, and fingerprint-matching;
* null otherwise.
*/
checkDeduplication<N extends RequestDedupNamespace>(
userId: number,
namespace: N,
requestId: string,
getFingerprint?: () => string,
): DedupPayload<N> | null {
const key = this._key(userId, namespace, requestId);
const entry = this.cache.get(key);
@ -66,6 +86,14 @@ export class RequestDeduplicationService {
}
// Defensive: keying already isolates namespaces, but verify before casting.
if (entry.namespace !== namespace) return null;
if (getFingerprint !== undefined) {
// A pre-fingerprint (legacy) entry cannot prove body equality — treat as
// a miss so the request is re-processed (safe: the server dedups ops by
// id anyway).
if (entry.fingerprint === undefined || entry.fingerprint !== getFingerprint()) {
return null;
}
}
return entry.results as DedupPayload<N>;
}
@ -77,6 +105,7 @@ export class RequestDeduplicationService {
namespace: N,
requestId: string,
results: DedupPayload<N>,
fingerprint?: string,
): void {
const key = this._key(userId, namespace, requestId);
if (this.cache.size >= MAX_CACHE_SIZE) {
@ -88,6 +117,7 @@ export class RequestDeduplicationService {
namespace,
processedAt: Date.now(),
results,
fingerprint,
} as RequestDeduplicationEntry);
}
@ -138,3 +168,41 @@ export class RequestDeduplicationService {
return `${userId}:${namespace}:${requestId}`;
}
}
export const createOpsRequestFingerprint = (
clientId: string,
ops: Operation[],
): string => {
const logicalOps = ops.map((op) => ({
...op,
payload: op.isPayloadEncrypted ? '[encrypted-payload]' : op.payload,
}));
return createHash('sha256')
.update(stableJsonStringify({ clientId, ops: logicalOps }))
.digest('base64url');
};
export interface SnapshotRequestFingerprintInput {
state: unknown;
clientId: string;
reason: string;
vectorClock: Record<string, number>;
schemaVersion?: number;
isPayloadEncrypted?: boolean;
syncImportReason?: string;
opId?: string;
isCleanSlate?: boolean;
snapshotOpType?: string;
}
export const createSnapshotRequestFingerprint = (
request: SnapshotRequestFingerprintInput,
): string => {
const logicalRequest = {
...request,
state: request.isPayloadEncrypted ? '[encrypted-state]' : request.state,
};
return createHash('sha256')
.update(stableJsonStringify(logicalRequest))
.digest('base64url');
};

View file

@ -158,7 +158,11 @@ export class ValidationService {
};
}
if (op.schemaVersion !== undefined) {
if (op.schemaVersion < 1 || op.schemaVersion > 100) {
if (
!Number.isInteger(op.schemaVersion) ||
op.schemaVersion < 1 ||
op.schemaVersion > 100
) {
return {
valid: false,
error: `Invalid schema version: ${op.schemaVersion}`,

View file

@ -25,11 +25,12 @@ import {
sendCompressedBodyParseFailure,
} from './sync.routes.payload';
import {
computeOpsStorageBytesExcludingKnownDuplicates,
computeOpsStorageBytesExcludingUnstorableIds,
enforceStorageQuota,
getRawOpsCount,
sendOpsBatchTooLargeReply,
} from './sync.routes.quota';
import { createOpsRequestFingerprint } from './services/request-deduplication.service';
export const uploadOpsHandler = async (
req: FastifyRequest<{ Body: UploadOpsRequest }>,
@ -108,11 +109,26 @@ export const uploadOpsHandler = async (
});
}
// Compute the request fingerprint AFTER the rate-limit gate (a rate-limited
// client must not burn CPU on it) and BEFORE any processing: uploadOps and
// the quota prevalidation mutate the parsed ops (e.g. vector-clock
// sanitizing/pruning), so hashing later would never match a retry's
// pre-processing hash. Eager is as cheap as lazy here — every non-limited
// requestId-bearing request needs the hash exactly once (either the dedup
// check below or the post-upload cache write).
const requestFingerprint = requestId
? createOpsRequestFingerprint(clientId, ops as unknown as Operation[])
: undefined;
// Check for duplicate request (client retry) BEFORE quota check
// This ensures retries after successful uploads don't fail with 413
// if the original upload pushed the user over quota
if (requestId) {
const cachedResults = syncService.checkOpsRequestDedup(userId, requestId);
if (requestId && requestFingerprint) {
const cachedResults = syncService.checkOpsRequestDedup(
userId,
requestId,
() => requestFingerprint,
);
if (cachedResults) {
Logger.info(`[user:${userId}] Returning cached results for request ${requestId}`);
@ -168,19 +184,25 @@ export const uploadOpsHandler = async (
// Check storage quota before processing (after dedup to allow retries).
// Account using the same per-op payload+vectorClock measure that the
// post-accept counter increment uses, so the gate and the increment
// cannot disagree on what "size" means. Already-stored exact
// duplicates are rejected by uploadOps and never written, so don't make
// quota cleanup reserve space for them.
// cannot disagree on what "size" means. Already-occupied IDs are
// rejected by uploadOps and never written, so don't make quota cleanup
// reserve space for them. Carry the occupied-ID snapshot through upload
// so cleanup cannot make one of those IDs insertable mid-request.
const typedOpsForGate = ops as unknown as Operation[];
const { bytes: estimatedDelta, fallback: gateFallback } =
await computeOpsStorageBytesExcludingKnownDuplicates(
userId,
typedOpsForGate,
syncService.getMaxClockDriftMs(),
);
const validOpsForQuota = syncService.filterValidOpsForQuota(
typedOpsForGate,
clientId,
);
const {
bytes: estimatedDelta,
fallback: gateFallback,
requestStartOccupiedIds,
} = await computeOpsStorageBytesExcludingUnstorableIds(validOpsForQuota, (op) =>
syncService.getPrevalidatedPayloadBytes(op),
);
if (gateFallback > 0) {
Logger.warn(
`computeOpsStorageBytes: ${gateFallback}/${typedOpsForGate.length} unserializable op(s) ` +
`computeOpsStorageBytes: ${gateFallback}/${validOpsForQuota.length} unserializable op(s) ` +
`charged at APPROX_BYTES_PER_OP for user=${userId} (gate)`,
);
}
@ -196,6 +218,8 @@ export const uploadOpsHandler = async (
userId,
clientId,
ops as unknown as Operation[],
undefined,
requestStartOccupiedIds,
);
return uploadResults;
@ -212,9 +236,10 @@ export const uploadOpsHandler = async (
// batch to it, so a single match means the transaction failed. #8332
if (
requestId &&
requestFingerprint &&
!results.some((r) => r.errorCode === SYNC_ERROR_CODES.INTERNAL_ERROR)
) {
syncService.cacheOpsRequestResults(userId, requestId, results);
syncService.cacheOpsRequestResults(userId, requestId, results, requestFingerprint);
}
const accepted = results.filter((r) => r.accepted).length;
@ -225,8 +250,8 @@ export const uploadOpsHandler = async (
if (rejected > 0) {
Logger.debug(
`[user:${userId}] Rejected ops:`,
results.filter((r) => !r.accepted),
`[user:${userId}] Rejected op error codes:`,
results.filter((r) => !r.accepted).map((r) => r.errorCode ?? 'UNKNOWN'),
);
}

View file

@ -3,13 +3,7 @@ import { prisma } from '../db';
import { Logger } from '../logger';
import { getSyncService } from './sync.service';
import { computeOpStorageBytes } from './sync.const';
import {
DUPLICATE_OP_SELECT,
Operation,
SYNC_ERROR_CODES,
type DuplicateOperationCandidate,
} from './sync.types';
import { isSameDuplicateOperation } from './conflict';
import { Operation, SYNC_ERROR_CODES } from './sync.types';
import { errorMessage, MAX_OPS_PER_BATCH } from './sync.routes.payload';
/**
@ -37,34 +31,41 @@ export const computeOpsStorageBytes = (
return { bytes, fallback };
};
export const computeOpsStorageBytesExcludingKnownDuplicates = async (
userId: number,
export const computeOpsStorageBytesExcludingUnstorableIds = async (
ops: Operation[],
maxClockDriftMs: number,
): Promise<{ bytes: number; fallback: number }> => {
if (ops.length === 0) return { bytes: 0, fallback: 0 };
getCachedPayloadBytes?: (op: Operation) => number | undefined,
): Promise<{
bytes: number;
fallback: number;
requestStartOccupiedIds: ReadonlySet<string>;
}> => {
if (ops.length === 0) {
return { bytes: 0, fallback: 0, requestStartOccupiedIds: new Set() };
}
const existingOps = await prisma.operation.findMany({
where: { id: { in: Array.from(new Set(ops.map((op) => op.id))) } },
select: DUPLICATE_OP_SELECT,
select: { id: true },
});
const existingOpById = new Map<string, DuplicateOperationCandidate>(
existingOps.map((existingOp) => [existingOp.id, existingOp]),
);
const requestStartOccupiedIds = new Set(existingOps.map((existingOp) => existingOp.id));
let bytes = 0;
let fallback = 0;
const seenIncomingIds = new Set<string>();
for (const op of ops) {
const existingOp = existingOpById.get(op.id);
if (existingOp && isSameDuplicateOperation(existingOp, userId, op, maxClockDriftMs)) {
// uploadOps stores at most the first new operation for an ID. Any occupied
// ID (exact retry or collision) and every repeated incoming ID is terminally
// rejected, so charging it here can only poison the pre-write quota gate.
if (requestStartOccupiedIds.has(op.id) || seenIncomingIds.has(op.id)) {
continue;
}
seenIncomingIds.add(op.id);
const sized = computeOpStorageBytes(op);
const sized = computeOpStorageBytes(op, getCachedPayloadBytes?.(op));
bytes += sized.bytes;
if (sized.fallback) fallback += 1;
}
return { bytes, fallback };
return { bytes, fallback, requestStartOccupiedIds };
};
export const computeJsonStorageBytes = (value: unknown, fallback: unknown): number => {

View file

@ -6,7 +6,13 @@ import { Logger } from '../logger';
import { getAuthUser } from '../middleware';
import { getSyncService } from './sync.service';
import { getWsConnectionService } from './services/websocket-connection.service';
import { SYNC_ERROR_CODES, UploadResult } from './sync.types';
import {
DUPLICATE_OP_SELECT,
Operation,
SYNC_ERROR_CODES,
UploadResult,
} from './sync.types';
import { isSameIncomingOperation } from './conflict';
import {
isSingleTokenGzipEncoding,
parseCompressedJsonBody,
@ -29,6 +35,7 @@ import {
sendQuotaExceededReply,
sendSyncImportExistsReply,
} from './sync.routes.quota';
import { createSnapshotRequestFingerprint } from './services/request-deduplication.service';
export const uploadSnapshotHandler = async (
req: FastifyRequest<{ Body: unknown }>,
@ -99,9 +106,32 @@ export const uploadSnapshotHandler = async (
requestId,
} = snapshotRequest;
const syncService = getSyncService();
// Lazy + memoized: fingerprinting stable-stringifies + SHA-256-hashes the
// full (possibly multi-MB plaintext) snapshot state. It must not run
// before the pre-quota gate below, and first-time requests never need it —
// the dedup check only invokes it when an entry for this requestId exists
// (a genuine retry).
let memoizedFingerprint: string | undefined;
const getRequestFingerprint = (): string =>
(memoizedFingerprint ??= createSnapshotRequestFingerprint({
state,
clientId,
reason,
vectorClock,
schemaVersion,
isPayloadEncrypted,
syncImportReason,
opId,
isCleanSlate,
snapshotOpType,
}));
if (requestId) {
const cachedResponse = syncService.checkSnapshotRequestDedup(userId, requestId);
const cachedResponse = syncService.checkSnapshotRequestDedup(
userId,
requestId,
getRequestFingerprint,
);
if (cachedResponse) {
Logger.info(
`[user:${userId}] Returning cached snapshot result for request ${requestId}`,
@ -110,6 +140,15 @@ export const uploadSnapshotHandler = async (
}
}
// Defense in depth: the contract superRefine already requires opId for
// clean-slate uploads, but the destructive wipe below must never depend on
// a schema in another package staying strict. Keep the invariant local.
if (isCleanSlate && !opId) {
return reply.code(400).send({
error: 'opId is required for clean-slate snapshot idempotency',
});
}
// Cheap pre-quota gate BEFORE prepareSnapshotCache so quota-exhausted
// clients can't burn CPU on JSON.stringify + zlib.gzipSync. Uses only
// the cached counter; if it says we're already at quota, reconcile
@ -137,6 +176,13 @@ export const uploadSnapshotHandler = async (
}
}
// Pin the fingerprint BEFORE processing mutates anything, but AFTER the
// pre-quota gate above so quota-exhausted clients cannot burn CPU on the
// full-state hash.
if (requestId) {
getRequestFingerprint();
}
// Reject duplicate SYNC_IMPORT before we acquire the per-user lock — a
// duplicate rejection is cheap (one indexed lookup) and skipping the
// lock lets concurrent legitimate clients keep moving.
@ -191,6 +237,57 @@ export const uploadSnapshotHandler = async (
const result = await syncService.runWithStorageUsageLock<UploadResult | null>(
userId,
async () => {
// Clean-slate uploads are destructive, so request-cache deduplication is
// not sufficient: it is process-local and expires. The client-supplied
// opId is the durable idempotency key. Check it inside the per-user lock
// before quota work or uploadOps can delete any existing data.
if (isCleanSlate && opId) {
const existingCleanSlateOp = await prisma.operation.findUnique({
where: { id: opId },
select: { ...DUPLICATE_OP_SELECT, serverSeq: true },
});
if (existingCleanSlateOp) {
const existingOperation: Operation = {
id: existingCleanSlateOp.id,
clientId: existingCleanSlateOp.clientId,
actionType: existingCleanSlateOp.actionType,
opType: existingCleanSlateOp.opType as Operation['opType'],
entityType: existingCleanSlateOp.entityType,
entityId: existingCleanSlateOp.entityId ?? undefined,
entityIds: existingCleanSlateOp.entityIds,
payload: existingCleanSlateOp.payload,
vectorClock: existingCleanSlateOp.vectorClock as Operation['vectorClock'],
timestamp: op.timestamp,
schemaVersion: existingCleanSlateOp.schemaVersion,
isPayloadEncrypted: existingCleanSlateOp.isPayloadEncrypted,
syncImportReason: existingCleanSlateOp.syncImportReason ?? undefined,
};
const isExactRetry =
existingCleanSlateOp.userId === userId &&
(existingCleanSlateOp.opType === 'SYNC_IMPORT' ||
existingCleanSlateOp.opType === 'BACKUP_IMPORT' ||
existingCleanSlateOp.opType === 'REPAIR') &&
isSameIncomingOperation(existingOperation, op, 0, 0);
if (isExactRetry) {
Logger.info(
`[user:${userId}] Idempotent clean-slate retry from client ${clientId} ` +
`for existing op seq=${existingCleanSlateOp.serverSeq}`,
);
return {
opId,
accepted: true,
serverSeq: existingCleanSlateOp.serverSeq,
};
}
return {
opId,
accepted: false,
error: 'Operation ID already belongs to a different operation',
errorCode: SYNC_ERROR_CODES.INVALID_OP_ID,
};
}
}
if (reason === 'initial' && !isCleanSlate) {
const existingImport = await findExistingSyncImport(userId, opId);
@ -340,7 +437,12 @@ export const uploadSnapshotHandler = async (
finalResult.errorCode !== SYNC_ERROR_CODES.DUPLICATE_OPERATION &&
finalResult.errorCode !== SYNC_ERROR_CODES.INTERNAL_ERROR
) {
syncService.cacheSnapshotRequestResult(userId, requestId, responseBody);
syncService.cacheSnapshotRequestResult(
userId,
requestId,
responseBody,
getRequestFingerprint(),
);
}
return reply.send(responseBody);

View file

@ -23,6 +23,7 @@ import {
type CacheSnapshotResult,
type SnapshotDedupResponse,
} from './services';
import type { ValidationResult } from './services/validation.service';
const getPrismaP2002TargetTokens = (
err: Prisma.PrismaClientKnownRequestError,
): string[] => {
@ -82,6 +83,7 @@ export class SyncService {
private storageQuotaService: StorageQuotaService;
private snapshotService: SnapshotService;
private operationUploadService: OperationUploadService;
private prevalidatedOps = new WeakMap<Operation, ValidationResult>();
constructor(config: Partial<SyncConfig> = {}) {
this.config = { ...DEFAULT_SYNC_CONFIG, ...config };
@ -102,6 +104,27 @@ export class SyncService {
return this.config.maxClockDriftMs;
}
/**
* Return only operations that can consume storage if this upload commits.
* Invalid siblings still reach uploadOps so the client receives a terminal
* per-operation rejection, but they must not inflate the pre-write quota gate
* and block otherwise valid operations in the same request.
*/
filterValidOpsForQuota(ops: Operation[], clientId: string): Operation[] {
const seenOperationIds = new Set<string>();
return ops.filter((op) => {
const isFirstOccurrence = !seenOperationIds.has(op.id);
seenOperationIds.add(op.id);
const validation = this.validationService.validateOp(op, clientId);
this.prevalidatedOps.set(op, validation);
return isFirstOccurrence && validation.valid;
});
}
getPrevalidatedPayloadBytes(op: Operation): number | undefined {
return this.prevalidatedOps.get(op)?.payloadBytes;
}
// === Upload Operations ===
async uploadOps(
@ -109,11 +132,19 @@ export class SyncService {
clientId: string,
ops: Operation[],
isCleanSlate?: boolean,
requestStartOccupiedIds?: ReadonlySet<string>,
): Promise<UploadResult[]> {
const results: UploadResult[] = [];
const now = Date.now();
const txStartedAt = Date.now();
let uploadDbRoundtrips = 0;
const prevalidatedResults = new Map<Operation, ValidationResult>();
for (const op of ops) {
const validation = this.prevalidatedOps.get(op);
if (validation) {
prevalidatedResults.set(op, validation);
}
}
try {
// Use transaction to acquire write lock and ensure atomicity
@ -171,6 +202,8 @@ export class SyncService {
ops,
now,
tx,
prevalidatedResults,
requestStartOccupiedIds,
);
results.push(...batchResult.results);
acceptedDeltaBytes = batchResult.acceptedDeltaBytes;
@ -188,7 +221,24 @@ export class SyncService {
});
uploadDbRoundtrips++;
const firstOperationById = new Map<
string,
{ op: Operation; originalTimestamp: number }
>();
for (const op of ops) {
const firstRequestOperation = firstOperationById.get(op.id);
if (!firstRequestOperation) {
firstOperationById.set(op.id, {
op,
originalTimestamp: op.timestamp,
});
}
const validation =
prevalidatedResults.get(op) ??
this.validationService.validateOp(op, clientId);
prevalidatedResults.set(op, validation);
const { result, storageBytes, fallback } =
await this.operationUploadService.processOperation(
userId,
@ -196,6 +246,9 @@ export class SyncService {
op,
now,
tx,
validation,
requestStartOccupiedIds?.has(op.id),
firstRequestOperation,
);
results.push(result);
if (result.accepted) {
@ -454,26 +507,44 @@ export class SyncService {
return this.rateLimitService.cleanupExpiredCounters();
}
checkOpsRequestDedup(userId: number, requestId: string): UploadResult[] | null {
return this.requestDeduplicationService.checkDeduplication(userId, 'ops', requestId);
checkOpsRequestDedup(
userId: number,
requestId: string,
getFingerprint?: () => string,
): UploadResult[] | null {
return this.requestDeduplicationService.checkDeduplication(
userId,
'ops',
requestId,
getFingerprint,
);
}
cacheOpsRequestResults(
userId: number,
requestId: string,
results: UploadResult[],
fingerprint?: string,
): void {
this.requestDeduplicationService.cacheResults(userId, 'ops', requestId, results);
this.requestDeduplicationService.cacheResults(
userId,
'ops',
requestId,
results,
fingerprint,
);
}
checkSnapshotRequestDedup(
userId: number,
requestId: string,
getFingerprint?: () => string,
): SnapshotDedupResponse | null {
return this.requestDeduplicationService.checkDeduplication(
userId,
'snapshot',
requestId,
getFingerprint,
);
}
@ -481,12 +552,14 @@ export class SyncService {
userId: number,
requestId: string,
response: SnapshotDedupResponse,
fingerprint?: string,
): void {
this.requestDeduplicationService.cacheResults(
userId,
'snapshot',
requestId,
response,
fingerprint,
);
}

View file

@ -184,6 +184,7 @@ export interface DuplicateOperationCandidate {
opType: string;
entityType: string;
entityId: string | null;
entityIds: string[];
payload: unknown;
vectorClock: unknown;
schemaVersion: number;
@ -207,6 +208,7 @@ export const DUPLICATE_OP_SELECT = {
opType: true,
entityType: true,
entityId: true,
entityIds: true,
payload: true,
vectorClock: true,
schemaVersion: true,
@ -220,6 +222,7 @@ export interface LatestEntityOperationRow {
entityId: string;
clientId: string;
vectorClock: unknown;
serverSeq?: number;
}
export interface LatestBatchEntityOperationRow extends LatestEntityOperationRow {

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
getConflictEntityIds,
isSameDuplicateOperation,
isSameDuplicateTimestamp,
pruneVectorClockForStorage,
@ -36,6 +37,7 @@ const duplicateCandidate = (
opType: 'CRT',
entityType: 'TASK',
entityId: 'task-1',
entityIds: [],
payload: { title: 'A' },
vectorClock: { 'client-a': 1 },
schemaVersion: 1,
@ -47,6 +49,28 @@ const duplicateCandidate = (
});
describe('conflict helpers', () => {
it('includes a divergent scalar entityId in the incoming conflict set', () => {
expect(
getConflictEntityIds(op({ entityId: 'task-scalar', entityIds: ['task-array'] })),
).toEqual(['task-scalar', 'task-array']);
});
it.each([false, true])(
'aliases legacy misc config writes to tasks when encrypted=%s',
(isPayloadEncrypted) => {
expect(
getConflictEntityIds(
op({
entityType: 'GLOBAL_CONFIG',
entityId: 'misc',
schemaVersion: 1,
isPayloadEncrypted,
}),
),
).toEqual(['misc', 'tasks']);
},
);
it('accepts matching duplicate operations regardless of JSON key order', () => {
const incoming = op({
payload: { title: 'A', nested: { b: 2, a: 1 } },
@ -66,6 +90,57 @@ describe('conflict helpers', () => {
);
});
it('accepts batch retries with identical entityIds', () => {
const incoming = op({ entityIds: ['task-1', 'task-2'] });
const existing = duplicateCandidate({ entityIds: ['task-1', 'task-2'] });
expect(isSameDuplicateOperation(existing, 1, incoming, 60_000)).toBe(true);
});
it('rejects duplicate ids when entityIds differ', () => {
const incoming = op({ entityIds: ['task-1', 'task-3'] });
const existing = duplicateCandidate({ entityIds: ['task-1', 'task-2'] });
expect(isSameDuplicateOperation(existing, 1, incoming, 60_000)).toBe(false);
});
it('rejects duplicate ids when only one side has entityIds', () => {
const incomingWithBatch = op({ entityIds: ['task-1', 'task-2'] });
expect(
isSameDuplicateOperation(duplicateCandidate(), 1, incomingWithBatch, 60_000),
).toBe(false);
const existingWithBatch = duplicateCandidate({ entityIds: ['task-1', 'task-2'] });
expect(isSameDuplicateOperation(existingWithBatch, 1, op(), 60_000)).toBe(false);
});
it('accepts single-entity retries whose entityIds collapse to the scalar entityId', () => {
// getStoredEntityIds persists [] when entityIds is exactly [entityId], so a
// retry that re-sends that redundant array must still match the stored row.
const incoming = op({ entityIds: ['task-1'] });
expect(isSameDuplicateOperation(duplicateCandidate(), 1, incoming, 60_000)).toBe(
true,
);
});
it('rejects encrypted retries when entityIds differ', () => {
// With both sides encrypted the payload comparison is skipped, so entityIds
// must independently block a batch-op id collision.
const incoming = op({
payload: 'BASE64-CIPHERTEXT-A',
isPayloadEncrypted: true,
entityIds: ['task-1', 'task-3'],
});
const existing = duplicateCandidate({
payload: 'BASE64-CIPHERTEXT-B',
isPayloadEncrypted: true,
entityIds: ['task-1', 'task-2'],
});
expect(isSameDuplicateOperation(existing, 1, incoming, 60_000)).toBe(false);
});
it('accepts encrypted retries whose ciphertext differs from the stored payload', () => {
// Regression: when encryption is on, encrypt() generates a fresh random IV
// per call, so a retry of the same logical op produces different ciphertext.

View file

@ -441,6 +441,7 @@ describe('Duplicate Operation Pre-check', () => {
opType: 'UPD',
entityType: 'TASK',
entityId: 'task-race',
entityIds: [],
payload: { foo: 'bar' },
vectorClock: { 'client-1': 1 },
schemaVersion: 1,
@ -516,6 +517,7 @@ describe('Duplicate Operation Pre-check', () => {
opType: 'UPD',
entityType: 'TASK',
entityId: 'task-other-user',
entityIds: [],
payload: { foo: 'bar' },
vectorClock: { 'client-1': 1 },
schemaVersion: 1,

View file

@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import type { Prisma } from '@prisma/client';
import { OperationDownloadService } from '../src/sync/services/operation-download.service';
// Mock prisma
@ -35,6 +36,7 @@ const EXPECTED_OPERATION_DOWNLOAD_SELECT = {
opType: true,
entityType: true,
entityId: true,
entityIds: true,
payload: true,
vectorClock: true,
schemaVersion: true,
@ -54,6 +56,7 @@ const createMockOpRow = (
actionType: string;
entityType: string;
entityId: string | null;
entityIds: string[];
payload: unknown;
vectorClock: Record<string, number>;
schemaVersion: number;
@ -71,6 +74,7 @@ const createMockOpRow = (
entityType: overrides.entityType ?? 'Task',
// Use 'in' check to allow null to be explicitly set
entityId: 'entityId' in overrides ? overrides.entityId : `task-${serverSeq}`,
entityIds: overrides.entityIds ?? [],
payload: overrides.payload ?? { title: `Task ${serverSeq}` },
vectorClock: overrides.vectorClock ?? { [clientId]: serverSeq },
schemaVersion: overrides.schemaVersion ?? 1,
@ -358,6 +362,30 @@ describe('OperationDownloadService', () => {
);
});
it('should round-trip batch entityIds in downloaded operations', async () => {
vi.mocked(prisma.$transaction).mockImplementation(
async (fn: (tx: Prisma.TransactionClient) => Promise<unknown>) =>
fn({
operation: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([
createMockOpRow(1, 'batch-client', {
entityId: 'task-1',
entityIds: ['task-1', 'task-2'],
}),
]),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 1 }),
},
} as unknown as Prisma.TransactionClient),
);
const result = await service.getOpsSinceWithSeq(1, 0);
expect(result.ops[0].op.entityIds).toEqual(['task-1', 'task-2']);
});
it('should detect gap when client is ahead of server', async () => {
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
const mockTx = {

View file

@ -37,6 +37,8 @@ const mocks = vi.hoisted(() => {
getStorageInfo: vi.fn(),
getCachedSnapshotBytes: vi.fn(),
getMaxClockDriftMs: vi.fn(),
filterValidOpsForQuota: vi.fn(),
getPrevalidatedPayloadBytes: vi.fn(),
};
const prisma = {
operation: {
@ -119,6 +121,7 @@ describe('Request dedup — transaction-failure results are not cached (#8332)',
});
mocks.syncService.getCachedSnapshotBytes.mockResolvedValue(0);
mocks.syncService.getMaxClockDriftMs.mockReturnValue(60_000);
mocks.syncService.filterValidOpsForQuota.mockImplementation((ops: unknown[]) => ops);
mocks.syncService.cacheSnapshotIfReplayable.mockResolvedValue({ deltaBytes: 0 });
mocks.syncService.prepareSnapshotCache.mockResolvedValue({
stateBytes: 0,
@ -196,6 +199,7 @@ describe('Request dedup — transaction-failure results are not cached (#8332)',
userId,
'ops-v1-success',
results,
expect.any(String),
);
});
@ -218,6 +222,7 @@ describe('Request dedup — transaction-failure results are not cached (#8332)',
userId,
'ops-v1-conflict',
results,
expect.any(String),
);
});
});
@ -245,6 +250,7 @@ describe('Request dedup — transaction-failure results are not cached (#8332)',
userId,
'snapshot-v1-success',
{ accepted: true, serverSeq: 5, error: undefined },
expect.any(String),
);
});
});
@ -282,6 +288,7 @@ describe('Request dedup round-trip with the real cache (#8332)', () => {
mocks.syncService.getLatestSeq.mockResolvedValue(1);
mocks.syncService.getOpsSinceWithSeq.mockResolvedValue({ ops: [], latestSeq: 1 });
mocks.syncService.getMaxClockDriftMs.mockReturnValue(60_000);
mocks.syncService.filterValidOpsForQuota.mockImplementation((ops: unknown[]) => ops);
mocks.prisma.operation.findMany.mockResolvedValue([]);
app = Fastify();

View file

@ -1,5 +1,8 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { RequestDeduplicationService } from '../src/sync/services/request-deduplication.service';
import {
RequestDeduplicationService,
createSnapshotRequestFingerprint,
} from '../src/sync/services/request-deduplication.service';
import { UploadResult } from '../src/sync/sync.types';
describe('RequestDeduplicationService', () => {
@ -36,6 +39,41 @@ describe('RequestDeduplicationService', () => {
expect(result).toEqual(mockResults);
});
it('should treat the same requestId with a different body fingerprint as a cache miss', () => {
const mockResults = createMockResults(1);
service.cacheResults(1, 'ops', 'request-123', mockResults, 'fingerprint-a');
expect(
service.checkDeduplication(1, 'ops', 'request-123', () => 'fingerprint-a'),
).toEqual(mockResults);
expect(
service.checkDeduplication(1, 'ops', 'request-123', () => 'fingerprint-b'),
).toBeNull();
});
it('should not compute the fingerprint when no entry exists for the requestId', () => {
// The fingerprint hashes the full request body — first-time requests
// (the overwhelming majority) must not pay that cost.
const getFingerprint = vi.fn(() => 'fingerprint-a');
expect(service.checkDeduplication(1, 'ops', 'request-999', getFingerprint)).toBe(
null,
);
expect(getFingerprint).not.toHaveBeenCalled();
});
it('should treat a legacy entry without fingerprint as a miss when a fingerprint is supplied', () => {
const mockResults = createMockResults(1);
service.cacheResults(1, 'ops', 'request-123', mockResults);
const getFingerprint = vi.fn(() => 'fingerprint-a');
expect(
service.checkDeduplication(1, 'ops', 'request-123', getFingerprint),
).toBeNull();
// No entry fingerprint to compare against — the hash is never computed.
expect(getFingerprint).not.toHaveBeenCalled();
});
it('should return null for expired request', () => {
const mockResults = createMockResults(1);
service.cacheResults(1, 'ops', 'request-123', mockResults);
@ -114,6 +152,41 @@ describe('RequestDeduplicationService', () => {
});
});
describe('snapshot request fingerprints', () => {
const request = {
state: { task: { ids: ['task-1'] } },
clientId: 'client-1',
reason: 'recovery',
vectorClock: { 'client-1': 1 },
schemaVersion: 4,
};
it('changes when an unencrypted snapshot body changes', () => {
expect(createSnapshotRequestFingerprint(request)).not.toBe(
createSnapshotRequestFingerprint({
...request,
state: { task: { ids: ['task-2'] } },
}),
);
});
it('stays stable across encrypted snapshot IV changes', () => {
expect(
createSnapshotRequestFingerprint({
...request,
state: 'ciphertext-a',
isPayloadEncrypted: true,
}),
).toBe(
createSnapshotRequestFingerprint({
...request,
state: 'ciphertext-b',
isPayloadEncrypted: true,
}),
);
});
});
describe('cleanupExpiredEntries', () => {
it('should remove all expired entries', () => {
service.cacheResults(1, 'ops', 'request-1', createMockResults(1));

View file

@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => {
getCachedSnapshotBytes: vi.fn(),
markStorageNeedsReconcile: vi.fn(),
getMaxClockDriftMs: vi.fn(),
filterValidOpsForQuota: vi.fn(),
getPrevalidatedPayloadBytes: vi.fn(),
};
const prisma = {
operation: {
@ -68,7 +70,7 @@ vi.mock('../src/db', () => ({
import { syncRoutes } from '../src/sync/sync.routes';
import { SYNC_ERROR_CODES } from '../src/sync/sync.types';
import { computeOpStorageBytes } from '../src/sync/sync.const';
import { SUPER_SYNC_MAX_OPS_PER_UPLOAD } from '@sp/shared-schema';
import { CURRENT_SCHEMA_VERSION, SUPER_SYNC_MAX_OPS_PER_UPLOAD } from '@sp/shared-schema';
const gzipAsync = promisify(zlib.gzip);
@ -93,6 +95,7 @@ const createStoredDuplicateOp = (op: ReturnType<typeof createOp>) => ({
opType: op.opType,
entityType: op.entityType,
entityId: op.entityId,
entityIds: [],
payload: op.payload,
vectorClock: op.vectorClock,
schemaVersion: op.schemaVersion,
@ -114,6 +117,7 @@ describe('Sync compressed body routes', () => {
mocks.syncService.checkOpsRequestDedup.mockReturnValue(null);
mocks.syncService.checkSnapshotRequestDedup.mockReturnValue(null);
mocks.syncService.getMaxClockDriftMs.mockReturnValue(60_000);
mocks.syncService.filterValidOpsForQuota.mockImplementation((ops: unknown[]) => ops);
mocks.syncService.checkStorageQuota.mockResolvedValue({
allowed: true,
currentUsage: 0,
@ -166,6 +170,7 @@ describe('Sync compressed body routes', () => {
});
mocks.syncService.getCachedSnapshotBytes.mockResolvedValue(0);
mocks.prisma.operation.findFirst.mockResolvedValue(null);
mocks.prisma.operation.findUnique.mockReset().mockResolvedValue(null);
mocks.prisma.operation.findMany.mockResolvedValue([]);
app = Fastify();
@ -211,6 +216,117 @@ describe('Sync compressed body routes', () => {
expect(quotaCall[1]).toBeLessThan(payloadSize);
});
it('should pass mixed schema versions to per-operation validation', async () => {
const clientId = 'mixed-schema-client';
const validOp = createOp(clientId);
const invalidOp = {
...createOp(clientId),
id: 'invalid-schema-op',
schemaVersion: 101,
};
mocks.syncService.uploadOps.mockResolvedValueOnce([
{ opId: validOp.id, accepted: true, serverSeq: 1 },
{
opId: invalidOp.id,
accepted: false,
error: 'Invalid schema version: 101',
errorCode: SYNC_ERROR_CODES.INVALID_SCHEMA_VERSION,
},
]);
const response = await app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: {
authorization: `Bearer ${authToken}`,
'content-type': 'application/json',
},
payload: { ops: [validOp, invalidOp], clientId },
});
expect(response.statusCode).toBe(200);
expect(response.json().results).toEqual([
expect.objectContaining({ opId: validOp.id, accepted: true }),
expect.objectContaining({
opId: invalidOp.id,
accepted: false,
errorCode: SYNC_ERROR_CODES.INVALID_SCHEMA_VERSION,
}),
]);
expect(mocks.syncService.uploadOps).toHaveBeenCalledWith(
1,
clientId,
[validOp, invalidOp],
undefined,
new Set(),
);
});
it('should not let an invalid large sibling poison a near-quota upload', async () => {
const clientId = 'invalid-sibling-quota-client';
const validOp = {
...createOp(clientId),
id: 'valid-near-quota-op',
payload: { title: 'Valid task' },
};
const invalidLargeOp = {
...createOp(clientId),
id: 'invalid-large-op',
opType: 'UNKNOWN',
payload: { data: 'x'.repeat(10_000) },
};
const validBytes = computeOpStorageBytes(validOp).bytes;
mocks.syncService.filterValidOpsForQuota.mockReturnValueOnce([validOp]);
mocks.syncService.checkStorageQuota.mockImplementation(
async (_userId: number, additionalBytes: number) => ({
allowed: additionalBytes <= validBytes,
currentUsage: 1_000_000 - validBytes,
quota: 1_000_000,
}),
);
mocks.syncService.uploadOps.mockResolvedValueOnce([
{ opId: validOp.id, accepted: true, serverSeq: 1 },
{
opId: invalidLargeOp.id,
accepted: false,
error: 'Invalid opType',
errorCode: SYNC_ERROR_CODES.INVALID_OP_TYPE,
},
]);
const response = await app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: {
authorization: `Bearer ${authToken}`,
'content-type': 'application/json',
},
payload: { ops: [validOp, invalidLargeOp], clientId },
});
expect(response.statusCode).toBe(200);
expect(response.json().results).toEqual([
expect.objectContaining({ opId: validOp.id, accepted: true }),
expect.objectContaining({
opId: invalidLargeOp.id,
accepted: false,
errorCode: SYNC_ERROR_CODES.INVALID_OP_TYPE,
}),
]);
expect(mocks.syncService.filterValidOpsForQuota).toHaveBeenCalledWith(
[validOp, invalidLargeOp],
clientId,
);
expect(mocks.syncService.checkStorageQuota).toHaveBeenCalledWith(1, validBytes);
expect(mocks.syncService.uploadOps).toHaveBeenCalledWith(
1,
clientId,
[validOp, invalidLargeOp],
undefined,
new Set(),
);
});
it('should subtract exact already-stored duplicate ops from the ops quota gate', async () => {
const clientId = 'known-duplicate-quota-client';
const duplicateOp = {
@ -257,13 +373,16 @@ describe('Sync compressed body routes', () => {
1,
computeOpStorageBytes(newOp).bytes,
);
expect(mocks.syncService.uploadOps).toHaveBeenCalledWith(1, clientId, [
duplicateOp,
newOp,
]);
expect(mocks.syncService.uploadOps).toHaveBeenCalledWith(
1,
clientId,
[duplicateOp, newOp],
undefined,
new Set([duplicateOp.id]),
);
});
it('should keep same-id different-content ops charged in the ops quota gate', async () => {
it('should not charge same-id different-content ops in the quota gate', async () => {
const clientId = 'id-collision-quota-client';
const incomingOp = {
...createOp(clientId),
@ -298,10 +417,49 @@ describe('Sync compressed body routes', () => {
},
});
expect(response.statusCode).toBe(200);
expect(mocks.syncService.checkStorageQuota).toHaveBeenCalledWith(1, 0);
expect(mocks.prisma.operation.findMany).toHaveBeenCalledWith({
where: { id: { in: [incomingOp.id] } },
select: { id: true },
});
expect(mocks.syncService.uploadOps).toHaveBeenCalledWith(
1,
clientId,
[incomingOp],
undefined,
new Set([incomingOp.id]),
);
});
it('should charge only the first occurrence of a repeated new ID', async () => {
const clientId = 'intra-batch-duplicate-quota-client';
const firstOp = {
...createOp(clientId),
id: 'repeated-new-id',
entityId: 'task-first',
payload: { title: 'First content' },
};
const repeatedOp = {
...firstOp,
entityId: 'task-second',
payload: { title: 'Different repeated content' },
};
const response = await app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: {
authorization: `Bearer ${authToken}`,
'content-type': 'application/json',
},
payload: { ops: [firstOp, repeatedOp], clientId },
});
expect(response.statusCode).toBe(200);
expect(mocks.syncService.checkStorageQuota).toHaveBeenCalledWith(
1,
computeOpStorageBytes(incomingOp).bytes,
computeOpStorageBytes(firstOp).bytes,
);
});
@ -748,6 +906,7 @@ describe('Sync compressed body routes', () => {
clientId,
reason: 'initial',
vectorClock: {},
opId: '018f2f0b-1c2d-7a1b-8c3d-123456789abc',
isCleanSlate: true,
},
});
@ -757,6 +916,98 @@ describe('Sync compressed body routes', () => {
expect(mocks.syncService.uploadOps).not.toHaveBeenCalled();
});
it('should return persistent success for a clean-slate retry before deleting data', async () => {
const opId = '018f2f0b-1c2d-7a1b-8c3d-123456789abc';
const clientId = 'clean-slate-retry-client';
const state = { TASK: { 'task-1': { id: 'task-1' } } };
const vectorClock = { [clientId]: 1 };
mocks.prisma.operation.findUnique.mockResolvedValueOnce({
id: opId,
userId: 1,
clientId,
actionType: '[SP_ALL] Load(import) all data',
opType: 'SYNC_IMPORT',
entityType: 'ALL',
entityId: null,
entityIds: [],
payload: state,
vectorClock,
schemaVersion: CURRENT_SCHEMA_VERSION,
clientTimestamp: BigInt(1),
receivedAt: BigInt(1),
isPayloadEncrypted: false,
syncImportReason: null,
serverSeq: 77,
});
const response = await app.inject({
method: 'POST',
url: '/api/sync/snapshot',
headers: { authorization: `Bearer ${authToken}` },
payload: {
state,
clientId,
reason: 'recovery',
vectorClock,
schemaVersion: CURRENT_SCHEMA_VERSION,
opId,
isCleanSlate: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ accepted: true, serverSeq: 77 });
expect(mocks.syncService.uploadOps).not.toHaveBeenCalled();
expect(mocks.syncService.checkStorageQuota).not.toHaveBeenCalled();
});
it('should reject a clean-slate retry whose opId belongs to different content', async () => {
const opId = '018f2f0b-1c2d-7a1b-8c3d-123456789abc';
const clientId = 'clean-slate-collision-client';
const vectorClock = { [clientId]: 1 };
mocks.prisma.operation.findUnique.mockResolvedValueOnce({
id: opId,
userId: 1,
clientId,
actionType: '[SP_ALL] Load(import) all data',
opType: 'SYNC_IMPORT',
entityType: 'ALL',
entityId: null,
entityIds: [],
payload: { TASK: { existing: { id: 'existing' } } },
vectorClock,
schemaVersion: CURRENT_SCHEMA_VERSION,
clientTimestamp: BigInt(1),
receivedAt: BigInt(1),
isPayloadEncrypted: false,
syncImportReason: null,
serverSeq: 77,
});
const response = await app.inject({
method: 'POST',
url: '/api/sync/snapshot',
headers: { authorization: `Bearer ${authToken}` },
payload: {
state: { TASK: { replacement: { id: 'replacement' } } },
clientId,
reason: 'recovery',
vectorClock,
schemaVersion: CURRENT_SCHEMA_VERSION,
opId,
isCleanSlate: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
accepted: false,
error: 'Operation ID already belongs to a different operation',
});
expect(mocks.syncService.uploadOps).not.toHaveBeenCalled();
expect(mocks.syncService.checkStorageQuota).not.toHaveBeenCalled();
});
it('should repeat initial snapshot duplicate detection inside the user lock', async () => {
const clientId = 'initial-race-client';
mocks.prisma.operation.findFirst
@ -809,6 +1060,7 @@ describe('Sync compressed body routes', () => {
expect(mocks.syncService.checkSnapshotRequestDedup).toHaveBeenCalledWith(
1,
'snapshot-v1-retry',
expect.any(Function),
);
expect(mocks.syncService.checkOpsRequestDedup).not.toHaveBeenCalled();
expect(mocks.prisma.operation.findFirst).not.toHaveBeenCalled();
@ -878,6 +1130,7 @@ describe('Sync compressed body routes', () => {
1,
'snapshot-v1-dup-test',
{ accepted: true, serverSeq: 77 },
expect.any(String),
);
});

View file

@ -303,6 +303,9 @@ describe('Sync System Fixes', () => {
const clientA = 'client-a';
const clientB = 'client-b';
const requestId = 'req-123';
// A true retry resends the IDENTICAL body — the request fingerprint
// treats a same-requestId/different-body request as a cache miss.
const opA = createOp(clientA, { entityId: 'task-a' });
// Step 1: Client A uploads with requestId
const firstResponse = await app.inject({
@ -310,7 +313,7 @@ describe('Sync System Fixes', () => {
url: '/api/sync/ops',
headers: { authorization: `Bearer ${authToken}` },
payload: {
ops: [createOp(clientA, { entityId: 'task-a' })],
ops: [opA],
clientId: clientA,
lastKnownServerSeq: 0,
requestId,
@ -339,7 +342,7 @@ describe('Sync System Fixes', () => {
url: '/api/sync/ops',
headers: { authorization: `Bearer ${authToken}` },
payload: {
ops: [createOp(clientA, { entityId: 'task-a' })],
ops: [opA],
clientId: clientA,
lastKnownServerSeq: clientASeq,
requestId,
@ -366,6 +369,7 @@ describe('Sync System Fixes', () => {
const clientA = 'client-a';
const clientB = 'client-b';
const requestId = 'req-456';
const opA = createOp(clientA, { entityId: 'task-1' });
// Client A uploads
await app.inject({
@ -373,7 +377,7 @@ describe('Sync System Fixes', () => {
url: '/api/sync/ops',
headers: { authorization: `Bearer ${authToken}` },
payload: {
ops: [createOp(clientA, { entityId: 'task-1' })],
ops: [opA],
clientId: clientA,
lastKnownServerSeq: 0,
requestId,
@ -397,7 +401,7 @@ describe('Sync System Fixes', () => {
url: '/api/sync/ops',
headers: { authorization: `Bearer ${authToken}` },
payload: {
ops: [createOp(clientA, { entityId: 'task-1' })],
ops: [opA],
clientId: clientA,
lastKnownServerSeq: 0,
requestId,
@ -488,6 +492,7 @@ describe('Sync System Fixes', () => {
it('should return cached results even when quota would be exceeded on retry', async () => {
const clientId = uuidv7();
const requestId = uuidv7();
const op = createOp(clientId, { entityId: 'task-1' });
// First request with requestId
const firstResponse = await app.inject({
@ -495,7 +500,7 @@ describe('Sync System Fixes', () => {
url: '/api/sync/ops',
headers: { authorization: `Bearer ${authToken}` },
payload: {
ops: [createOp(clientId, { entityId: 'task-1' })],
ops: [op],
clientId,
lastKnownServerSeq: 0,
requestId,
@ -513,7 +518,7 @@ describe('Sync System Fixes', () => {
url: '/api/sync/ops',
headers: { authorization: `Bearer ${authToken}` },
payload: {
ops: [createOp(clientId, { entityId: 'task-1' })],
ops: [op],
clientId,
lastKnownServerSeq: 0,
requestId,

View file

@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => {
getLatestSeq: vi.fn(),
getOpsSinceWithSeq: vi.fn(),
getMaxClockDriftMs: vi.fn(),
filterValidOpsForQuota: vi.fn(),
getPrevalidatedPayloadBytes: vi.fn(),
};
return {
@ -106,6 +108,7 @@ describe('Sync upload route rate limiting', () => {
latestSeq: 1,
});
mocks.syncService.getMaxClockDriftMs.mockReturnValue(60_000);
mocks.syncService.filterValidOpsForQuota.mockImplementation((ops: unknown[]) => ops);
mocks.prisma.operation.findMany.mockResolvedValue([]);
app = Fastify();

View file

@ -79,6 +79,23 @@ vi.mock('../src/db', async () => {
}
}
}
if (args.where?.entityType && Array.isArray(args.where?.OR)) {
const targetEntityId =
args.where.OR.find((condition: any) => condition.entityId !== undefined)
?.entityId ??
args.where.OR.find((condition: any) => condition.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 === targetEntityId ||
op.entityIds?.includes(targetEntityId)),
)
.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(
@ -359,6 +376,7 @@ vi.mock('../src/db', async () => {
entityId: op.entityId,
clientId: op.clientId,
vectorClock: op.vectorClock,
serverSeq: op.serverSeq,
}));
}
if (sql.includes('jsonb_each_text(vector_clock)')) {
@ -605,6 +623,8 @@ import { DeviceService } from '../src/sync/services/device.service';
import { OperationDownloadService } from '../src/sync/services/operation-download.service';
import { Operation, DEFAULT_SYNC_CONFIG, SYNC_ERROR_CODES } from '../src/sync/sync.types';
import { prisma } from '../src/db';
import { Logger } from '../src/logger';
import { CURRENT_SCHEMA_VERSION } from '@sp/shared-schema';
describe('SyncService', () => {
const userId = 1;
@ -628,6 +648,19 @@ describe('SyncService', () => {
...overrides,
});
const makeGlobalConfigOp = (overrides: Partial<Operation> = {}): Operation =>
makeOp({
actionType: '[GLOBAL_CONFIG] Update section',
opType: 'UPD',
entityType: 'GLOBAL_CONFIG',
entityId: 'misc',
payload: {
sectionKey: 'misc',
sectionCfg: { defaultProjectId: 'project-1' },
},
...overrides,
});
beforeEach(() => {
// Reset all test data stores
resetTestState();
@ -653,6 +686,48 @@ describe('SyncService', () => {
delete process.env.OLD_OPS_CLEANUP_MAX_DELETED_PER_RUN;
});
describe('filterValidOpsForQuota', () => {
it('excludes invalid schema and oversized payload siblings from quota sizing', () => {
const service = new SyncService({ maxPayloadSizeBytes: 100 });
const validOp = makeOp({
id: 'valid-op',
payload: { title: 'Fits quota' },
});
const invalidSchemaOp = makeOp({
id: 'invalid-schema-op',
schemaVersion: 101,
});
const oversizedInvalidOp = makeOp({
id: 'oversized-invalid-op',
payload: { data: 'x'.repeat(200) },
});
const result = service.filterValidOpsForQuota(
[validOp, invalidSchemaOp, oversizedInvalidOp],
clientId,
);
expect(result).toEqual([validOp]);
});
it('does not charge a later valid sibling when an invalid op reserved its ID', () => {
const service = new SyncService();
const invalidFirst = makeOp({
id: 'reserved-by-invalid-op',
entityType: 'INVALID_ENTITY_TYPE',
});
const laterLargeSibling = makeOp({
id: invalidFirst.id,
entityId: 'fresh-task',
payload: { data: 'x'.repeat(10_000) },
});
expect(
service.filterValidOpsForQuota([invalidFirst, laterLargeSibling], clientId),
).toEqual([]);
});
});
describe('uploadOps', () => {
it('should correctly upload operations', async () => {
const service = getSyncService();
@ -708,15 +783,39 @@ describe('SyncService', () => {
expect(testState.operations.size).toBe(25);
});
it('rejects an intra-batch same-id op as DUPLICATE_OPERATION even when its content differs (deliberate divergence from the legacy per-op path, which yields INVALID_OP_ID)', async () => {
// C4: the batch path dedups intra-batch purely by op.id (plan §1a step 2)
// and rejects the later op as DUPLICATE_OPERATION regardless of content.
// The legacy per-op path inserts the first then catches the second at the
// DB and returns INVALID_OP_ID for differing content. Both are terminal
// rejections with no row and no sequence gap, so sync invariants hold;
// the client treats DUPLICATE_OPERATION as a silent success and
// INVALID_OP_ID as a hard rejection. This test pins the chosen batch
// semantics so the divergence stays intentional, not accidental drift.
it.each([
['legacy serial', false],
['batch', true],
])(
'rejects a request-start occupied ID in the %s path after its row disappears',
async (_label, batchUpload) => {
const service = new SyncService({ batchUpload });
const op = makeOp({
id: 'occupied-before-quota-cleanup',
entityId: 'new-entity-after-cleanup',
payload: { title: 'Must not consume unestimated storage' },
});
const results = await service.uploadOps(
userId,
clientId,
[op],
undefined,
new Set([op.id]),
);
expect(results).toEqual([
expect.objectContaining({
opId: op.id,
accepted: false,
errorCode: SYNC_ERROR_CODES.INVALID_OP_ID,
}),
]);
expect(testState.operations.has(op.id)).toBe(false);
},
);
it('rejects an intra-batch same-id collision as INVALID_OP_ID', async () => {
const service = new SyncService({ batchUpload: true });
const opId = uuidv7();
const first = makeOp({
@ -747,7 +846,7 @@ describe('SyncService', () => {
expect(results[1]).toEqual(
expect.objectContaining({
accepted: false,
errorCode: SYNC_ERROR_CODES.DUPLICATE_OPERATION,
errorCode: SYNC_ERROR_CODES.INVALID_OP_ID,
}),
);
// No sequence gap: lastSeq advanced by exactly 1, exactly one row.
@ -755,6 +854,136 @@ describe('SyncService', () => {
expect(testState.operations.size).toBe(1);
});
it('preserves an exact intra-batch retry as DUPLICATE_OPERATION', async () => {
const service = new SyncService({ batchUpload: true });
const retry = makeOp({
id: uuidv7(),
entityId: 'task-1',
entityIds: ['task-1', 'task-2'],
vectorClock: { [clientId]: 1 },
});
const results = await service.uploadOps(userId, clientId, [retry, { ...retry }]);
expect(results[0]).toEqual(
expect.objectContaining({ accepted: true, serverSeq: 1 }),
);
expect(results[1]).toEqual(
expect.objectContaining({
accepted: false,
errorCode: SYNC_ERROR_CODES.DUPLICATE_OPERATION,
}),
);
});
it('terminally rejects a later serial same-ID sibling when the first one conflicts', async () => {
const service = new SyncService({ batchUpload: false });
const otherClientId = 'other-device';
const existing = makeOp({
id: 'existing-op',
clientId: otherClientId,
entityId: 'blocked-task',
vectorClock: { [otherClientId]: 1 },
});
expect(
(await service.uploadOps(userId, otherClientId, [existing]))[0].accepted,
).toBe(true);
const repeatedId = 'repeated-request-id';
const first = makeOp({
id: repeatedId,
entityId: 'blocked-task',
payload: { title: 'small' },
vectorClock: { [clientId]: 1 },
});
const laterLargeSibling = makeOp({
id: repeatedId,
entityId: 'fresh-task',
payload: { data: 'x'.repeat(10_000) },
vectorClock: { [clientId]: 2 },
timestamp: first.timestamp + 1,
});
const results = await service.uploadOps(userId, clientId, [
first,
laterLargeSibling,
]);
expect(results[0]).toEqual(
expect.objectContaining({
accepted: false,
errorCode: SYNC_ERROR_CODES.CONFLICT_CONCURRENT,
}),
);
expect(results[1]).toEqual(
expect.objectContaining({
accepted: false,
errorCode: SYNC_ERROR_CODES.INVALID_OP_ID,
}),
);
expect(testState.operations.has(repeatedId)).toBe(false);
});
it('redacts malformed operation metadata from audit logs', async () => {
const service = new SyncService({ batchUpload: false });
const privateText = 'private task title that must not be logged';
const auditSpy = vi.spyOn(Logger, 'audit').mockImplementation(() => undefined);
const malformed = makeOp({
id: privateText,
entityType: privateText,
});
const result = await service.uploadOps(userId, clientId, [malformed]);
expect(result[0].accepted).toBe(false);
const rejection = auditSpy.mock.calls
.map(([entry]) => entry)
.find((entry) => entry.event === 'OP_REJECTED');
expect(rejection).toBeDefined();
expect(rejection?.opId).toBe('[invalid]');
expect(rejection?.entityType).toBe('[invalid]');
expect(rejection?.reason).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_TYPE);
expect(JSON.stringify(rejection)).not.toContain(privateText);
});
it.each([
['serial', false],
['batch', true],
])(
'terminally rejects a valid %s sibling whose ID was reserved by an invalid op',
async (_label, batchUpload) => {
const service = new SyncService({ batchUpload });
const invalidFirst = makeOp({
id: 'invalid-first-shared-id',
entityType: 'INVALID_ENTITY_TYPE',
});
const laterLargeSibling = makeOp({
id: invalidFirst.id,
entityId: 'fresh-task',
payload: { data: 'x'.repeat(10_000) },
});
const results = await service.uploadOps(userId, clientId, [
invalidFirst,
laterLargeSibling,
]);
expect(results[0]).toEqual(
expect.objectContaining({
accepted: false,
errorCode: SYNC_ERROR_CODES.INVALID_ENTITY_TYPE,
}),
);
expect(results[1]).toEqual(
expect.objectContaining({
accepted: false,
errorCode: SYNC_ERROR_CODES.INVALID_OP_ID,
}),
);
expect(testState.operations.has(invalidFirst.id)).toBe(false);
},
);
it('should reject intra-batch entity conflicts in order', async () => {
const service = new SyncService({ batchUpload: true });
const ops: Operation[] = [
@ -797,6 +1026,118 @@ describe('SyncService', () => {
expect(testState.operations.size).toBe(1);
});
it.each([
['serial', false],
['batch', true],
])(
'rejects a v2 tasks write against an already-stored raw v1 misc row in the %s path',
async (_label, batchUpload) => {
const legacyClientId = 'legacy-client';
testState.userSyncStates.set(userId, { userId, lastSeq: 1 });
testState.serverSeqCounter = 1;
testState.operations.set('stored-legacy-misc', {
id: 'stored-legacy-misc',
userId,
clientId: legacyClientId,
serverSeq: 1,
actionType: '[GLOBAL_CONFIG] Update section',
opType: 'UPD',
entityType: 'GLOBAL_CONFIG',
entityId: 'misc',
entityIds: [],
payload: {
sectionKey: 'misc',
sectionCfg: { defaultProjectId: 'legacy-project' },
},
payloadBytes: BigInt(10),
vectorClock: { [legacyClientId]: 1 },
schemaVersion: 1,
clientTimestamp: BigInt(Date.now() - 1_000),
receivedAt: BigInt(Date.now() - 1_000),
isPayloadEncrypted: false,
syncImportReason: null,
});
const service = new SyncService({ batchUpload });
const result = await service.uploadOps(userId, clientId, [
makeGlobalConfigOp({
id: 'current-tasks-write',
entityId: 'tasks',
payload: {
sectionKey: 'tasks',
sectionCfg: { defaultProjectId: 'current-project' },
},
vectorClock: { [clientId]: 1 },
schemaVersion: CURRENT_SCHEMA_VERSION,
}),
]);
expect(result).toEqual([
expect.objectContaining({
opId: 'current-tasks-write',
accepted: false,
errorCode: SYNC_ERROR_CODES.CONFLICT_CONCURRENT,
existingClock: { [legacyClientId]: 1 },
}),
]);
expect(testState.operations.size).toBe(1);
},
);
it.each([
['serial', false],
['batch', true],
])(
'atomically rejects a new mixed v1 misc upload that conflicts with v2 tasks in the %s path',
async (_label, batchUpload) => {
const currentClientId = 'current-client';
const service = new SyncService({ batchUpload });
const currentResult = await service.uploadOps(userId, currentClientId, [
makeGlobalConfigOp({
id: 'existing-current-tasks',
clientId: currentClientId,
entityId: 'tasks',
payload: {
sectionKey: 'tasks',
sectionCfg: { defaultProjectId: 'current-project' },
},
vectorClock: { [currentClientId]: 1 },
schemaVersion: CURRENT_SCHEMA_VERSION,
}),
]);
expect(currentResult[0].accepted).toBe(true);
const sourceId = 'incoming-legacy-mixed';
const legacyResult = await service.uploadOps(userId, clientId, [
makeGlobalConfigOp({
id: sourceId,
payload: {
sectionKey: 'misc',
sectionCfg: {
defaultProjectId: 'legacy-project',
isMinimizeToTray: true,
},
},
vectorClock: { [clientId]: 1 },
schemaVersion: 1,
}),
]);
expect(legacyResult).toEqual([
expect.objectContaining({
opId: sourceId,
accepted: false,
errorCode: SYNC_ERROR_CODES.CONFLICT_CONCURRENT,
existingClock: { [currentClientId]: 1 },
}),
]);
expect(testState.operations.has(`${sourceId}_misc`)).toBe(false);
expect(testState.operations.has(`${sourceId}_tasks`)).toBe(false);
expect(testState.operations.has(sourceId)).toBe(false);
expect(testState.operations.size).toBe(1);
},
);
it('should use entityIds when prefetching batch conflicts', async () => {
const service = new SyncService({ batchUpload: true });
testState.userSyncStates.set(userId, { userId, lastSeq: 1 });

View file

@ -315,6 +315,16 @@ describe('ValidationService', () => {
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_SCHEMA_VERSION);
});
it('should reject non-integer schema versions', () => {
const result = validationService.validateOp(
createValidOp({ schemaVersion: 1.5 }),
clientId,
);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_SCHEMA_VERSION);
});
it('should accept valid schema versions 1-100', () => {
for (const schemaVersion of [1, 50, 100]) {
const op = createValidOp({ schemaVersion });

View file

@ -12,7 +12,11 @@ export interface ApplyOperationsResult<TOperation extends Operation<string> = Op
/**
* If an error occurred, this contains the failed operation and the error.
* Operations after this one in the batch were NOT applied.
* The failed op and the operations after it in the batch did NOT complete
* their archive side effects but their reducer effects DID commit: the
* bulk dispatch is all-or-nothing and runs before archive handling, so
* archive side effects are the only per-op failure point. Retry paths must
* therefore use `skipReducerDispatch` to avoid double-applying reducers.
*/
failedOp?: {
op: TOperation;
@ -27,4 +31,13 @@ export interface ApplyOperationsOptions {
* local operations during hydration.
*/
isLocalHydration?: boolean;
/**
* When true, skip the bulk reducer dispatch and run only the post-dispatch
* archive side effects. Used to retry operations whose reducer effects
* already committed in an earlier batch (see `failedOp`): re-dispatching on
* retry would double-apply additive reducers such as time-tracking deltas
* and counter increments.
*/
skipReducerDispatch?: boolean;
}

View file

@ -109,6 +109,7 @@ export type {
ConflictUiPort,
DeferredLocalActionsPort,
OperationApplyPort,
ReducerCommitAwareOperationApplyPort,
RemoteApplyWindowPort,
SyncActionLike,
} from './ports';

View file

@ -148,10 +148,13 @@ export interface OperationLogEntry<TOperation extends Operation<string> = Operat
rejectedAt?: number;
/**
* For remote ops only: tracks whether the op was successfully applied to local state.
* Used for crash recovery: on startup, any 'pending' or 'failed' remote ops are re-dispatched.
* For remote ops only: tracks whether the op was successfully applied to
* local state. `archive_pending` means reducers and clocks committed but
* archive side effects are outstanding; `failed` means an attempted archive
* side effect failed and retryCount was bumped. Startup recovery replays
* reducers status-blind and retries outstanding archive work.
*/
applicationStatus?: 'pending' | 'applied' | 'failed';
applicationStatus?: 'pending' | 'archive_pending' | 'applied' | 'failed';
/**
* For 'failed' ops: number of retry attempts.

View file

@ -20,7 +20,28 @@ export interface SyncActionLike {
export interface OperationApplyPort<TOperation extends Operation<string> = Operation> {
applyOperations(
ops: TOperation[],
options?: ApplyOperationsOptions,
options?: ApplyOperationsOptions & {
/** Persist reducer-commit bookkeeping before post-dispatch side effects start. */
onReducersCommitted?: (ops: TOperation[]) => Promise<void>;
},
): Promise<ApplyOperationsResult<TOperation>>;
}
/**
* Operation applier contract for crash-safe remote application.
*
* Unlike the general-purpose {@link OperationApplyPort}, remote application
* requires the reducer-commit callback: the coordinator must durably checkpoint
* the whole reducer batch before archive side effects can begin.
*/
export interface ReducerCommitAwareOperationApplyPort<
TOperation extends Operation<string> = Operation,
> {
applyOperations(
ops: TOperation[],
options: ApplyOperationsOptions & {
onReducersCommitted: (ops: TOperation[]) => Promise<void>;
},
): Promise<ApplyOperationsResult<TOperation>>;
}

View file

@ -1,6 +1,6 @@
import type { ApplyOperationsResult } from './apply.types';
import type { Operation } from './operation.types';
import type { OperationApplyPort } from './ports';
import type { ReducerCommitAwareOperationApplyPort } from './ports';
export interface RemoteOperationAppendResult<
TOperation extends Operation<string> = Operation,
@ -24,9 +24,10 @@ export interface RemoteOperationApplyStorePort<
source: 'remote',
options: { pendingApply: true },
): Promise<RemoteOperationAppendResult<TOperation>>;
mergeRemoteOpClocks(ops: TOperation[]): Promise<void>;
markReducersCommittedAndMergeClocks(seqs: number[], ops: TOperation[]): Promise<void>;
markApplied(seqs: number[]): Promise<void>;
markFailed(opIds: string[]): Promise<void>;
mergeRemoteOpClocks(ops: TOperation[]): Promise<void>;
clearFullStateOpsExcept(excludeIds: string[]): Promise<number>;
}
@ -35,8 +36,10 @@ export interface ApplyRemoteOperationsOptions<
> {
ops: TOperation[];
store: RemoteOperationApplyStorePort<TOperation>;
applier: OperationApplyPort<TOperation>;
applier: ReducerCommitAwareOperationApplyPort<TOperation>;
isFullStateOperation?: (op: TOperation) => boolean;
/** Runs after incoming clocks are durable, before reducer application starts. */
onRemoteClocksDurable?: (ops: TOperation[]) => void;
}
export interface RemoteApplyOperationsResult<
@ -70,11 +73,17 @@ const emptyRemoteApplyResult = <
* the generic ordering:
*
* 1. append incoming remote ops as pending, skipping duplicates atomically;
* 2. apply only the newly appended ops through the host applier;
* 3. mark successfully applied seqs;
* 4. merge applied remote vector clocks;
* 5. retain only the newest applied full-state ops when configured;
* 6. mark the failed op and remaining unapplied ops as failed on partial error.
* 2. durably merge all newly appended remote clocks before reducers or a
* deferred-local-action window can start;
* 3. bulk-apply only the newly appended ops through the host applier;
* 4. at reducer commit, atomically checkpoint the whole batch as
* `archive_pending` and merge all reducer-committed vector clocks before
* archive side effects begin;
* 5. mark archive-complete seqs applied;
* 6. after applying a full-state op, clear older full-state ops while
* retaining every full-state op of this batch (incl. quarantined ones);
* 7. mark only the attempted archive failure as `failed`; unattempted
* successors stay `archive_pending` for ordered startup recovery.
*/
export const applyRemoteOperations = async <
TOperation extends Operation<string> = Operation,
@ -83,6 +92,7 @@ export const applyRemoteOperations = async <
store,
applier,
isFullStateOperation = () => false,
onRemoteClocksDurable,
}: ApplyRemoteOperationsOptions<TOperation>): Promise<
RemoteApplyOperationsResult<TOperation>
> => {
@ -101,6 +111,13 @@ export const applyRemoteOperations = async <
};
}
// Pending rows make this pre-apply clock merge crash-safe: if anything fails
// after it commits, startup recovery still replays the same rows. Advancing
// the clock before entering the reducer window also means buffered local
// actions can always be drained against a durable remote frontier.
await store.mergeRemoteOpClocks(appendResult.writtenOps);
onRemoteClocksDurable?.(appendResult.writtenOps);
const opIdToSeq = new Map<string, number>();
appendResult.writtenOps.forEach((op, index) => {
const seq = appendResult.seqs[index];
@ -109,8 +126,117 @@ export const applyRemoteOperations = async <
}
});
const applyResult = await applier.applyOperations(appendResult.writtenOps);
const appliedSeqs = applyResult.appliedOps
let reducerCommitCallbackCount = 0;
let reducerCommitCallbackError: Error | undefined;
let reducerCommitPromise: Promise<void> | undefined;
let applyResult: ApplyOperationsResult<TOperation>;
const observeReducerCommitPromisePreservingPrimaryError = async (): Promise<void> => {
const promise = reducerCommitPromise;
if (!promise) {
return;
}
try {
await promise;
} catch {
// The already-selected apply/contract error is the primary failure. The
// checkpoint rejection is still observed here so it cannot become an
// unhandled rejection; pending rows make it recoverable at startup.
}
};
try {
applyResult = await applier.applyOperations(appendResult.writtenOps, {
onReducersCommitted: (reducerCommittedOps) => {
reducerCommitCallbackCount++;
if (reducerCommitCallbackCount !== 1) {
reducerCommitCallbackError = new Error(
'applyRemoteOperations: reducer-commit callback must be invoked exactly once.',
);
throw reducerCommitCallbackError;
}
const isEntireAppendedBatch =
reducerCommittedOps.length === appendResult.writtenOps.length &&
reducerCommittedOps.every(
(op, index) => op.id === appendResult.writtenOps[index]?.id,
);
if (!isEntireAppendedBatch) {
reducerCommitCallbackError = new Error(
'applyRemoteOperations: reducer-commit callback must contain the entire appended batch in order.',
);
throw reducerCommitCallbackError;
}
reducerCommitPromise = (async () => {
const reducerCommittedSeqs = appendResult.writtenOps
.map((op) => opIdToSeq.get(op.id))
.filter((seq): seq is number => seq !== undefined);
if (reducerCommittedSeqs.length !== appendResult.writtenOps.length) {
throw new Error(
'applyRemoteOperations: reducer commit contained an operation outside the appended batch.',
);
}
await store.markReducersCommittedAndMergeClocks(
reducerCommittedSeqs,
appendResult.writtenOps,
);
})();
return reducerCommitPromise;
},
});
} catch (applyError) {
// A valid first callback may already have started durable bookkeeping before
// a duplicate callback (or another applier error) throws. Observe that
// promise before propagating so it cannot become an unhandled rejection.
await observeReducerCommitPromisePreservingPrimaryError();
throw applyError;
}
if (reducerCommitCallbackError) {
await observeReducerCommitPromisePreservingPrimaryError();
throw reducerCommitCallbackError;
}
if (reducerCommitCallbackCount !== 1 || reducerCommitPromise === undefined) {
throw new Error(
'applyRemoteOperations: applier did not invoke the reducer-commit callback.',
);
}
// Also await host bookkeeping when an applier invoked the callback without
// awaiting its returned promise. Pending ops must never be marked applied
// before the reducer/archive checkpoint is durable.
await reducerCommitPromise;
const appliedOpCount = applyResult.appliedOps.length;
const hasExactAppliedPrefix =
appliedOpCount <= appendResult.writtenOps.length &&
applyResult.appliedOps.every(
(op, index) => op.id === appendResult.writtenOps[index]?.id,
);
if (!hasExactAppliedPrefix) {
throw new Error(
'applyRemoteOperations: applied operations must be the exact ordered prefix of the appended batch.',
);
}
const expectedFailedOp = appendResult.writtenOps[appliedOpCount];
if (
(applyResult.failedOp && applyResult.failedOp.op.id !== expectedFailedOp?.id) ||
(!applyResult.failedOp && expectedFailedOp !== undefined)
) {
const reportedFailedOpId = applyResult.failedOp?.op.id ?? 'none';
const expectedFailedOpId = expectedFailedOp?.id ?? 'none';
throw new Error(
'applyRemoteOperations: applier must report the failed operation immediately after the applied prefix. ' +
`Reported ${reportedFailedOpId}; expected ${expectedFailedOpId}.`,
);
}
const authoritativeAppliedOps = appendResult.writtenOps.slice(0, appliedOpCount);
const authoritativeFailedOp = applyResult.failedOp
? {
op: expectedFailedOp!,
error: applyResult.failedOp.error,
}
: undefined;
const appliedSeqs = authoritativeAppliedOps
.map((op) => opIdToSeq.get(op.id))
.filter((seq): seq is number => seq !== undefined);
@ -118,21 +244,26 @@ export const applyRemoteOperations = async <
if (appliedSeqs.length > 0) {
await store.markApplied(appliedSeqs);
await store.mergeRemoteOpClocks(applyResult.appliedOps);
const appliedFullStateOpIds = applyResult.appliedOps
const appliedFullStateOpIds = authoritativeAppliedOps
.filter(isFullStateOperation)
.map((op) => op.id);
if (appliedFullStateOpIds.length > 0) {
clearedFullStateOpCount =
await store.clearFullStateOpsExcept(appliedFullStateOpIds);
// Exclude every full-state op of THIS batch, not only the applied ones:
// a later full-state op whose reducers committed but whose archive
// handling failed is still archive_pending/failed and must survive
// cleanup so the retry path can finish it. Deleting it here would let
// markFailed miss it and lose a change already visible at runtime on
// the next startup replay.
const batchFullStateOpIds = appendResult.writtenOps
.filter(isFullStateOperation)
.map((op) => op.id);
clearedFullStateOpCount = await store.clearFullStateOpsExcept(batchFullStateOpIds);
}
}
const failedOpIds = applyResult.failedOp
? getFailedOpIds(appendResult.writtenOps, applyResult.failedOp.op)
: [];
const failedOpIds = authoritativeFailedOp ? [authoritativeFailedOp.op.id] : [];
if (failedOpIds.length > 0) {
await store.markFailed(failedOpIds);
@ -141,19 +272,10 @@ export const applyRemoteOperations = async <
return {
appendedOps: appendResult.writtenOps,
skippedCount: appendResult.skippedCount,
appliedOps: applyResult.appliedOps,
appliedOps: authoritativeAppliedOps,
appliedSeqs,
clearedFullStateOpCount,
failedOp: applyResult.failedOp,
failedOp: authoritativeFailedOp,
failedOpIds,
};
};
const getFailedOpIds = <TOperation extends Operation<string>>(
opsToApply: TOperation[],
failedOp: TOperation,
): string[] => {
const failedOpIndex = opsToApply.findIndex((op) => op.id === failedOp.id);
const failedOps = failedOpIndex === -1 ? [failedOp] : opsToApply.slice(failedOpIndex);
return failedOps.map((op) => op.id);
};

View file

@ -35,6 +35,7 @@ export interface OperationReplayCoordinatorOptions<
operationToAction?: (op: TOperation) => TReplayAction;
isArchiveAffectingAction?: (action: TReplayAction) => boolean;
onRemoteArchiveDataApplied?: () => void;
onReducersCommitted?: (ops: TOperation[]) => Promise<void>;
onArchiveSideEffectError?: (
context: OperationReplayArchiveFailureContext<TOperation>,
) => void;
@ -71,7 +72,9 @@ export const yieldToEventLoop = (): Promise<void> =>
* package. This coordinator only owns the generic ordering:
*
* 1. open the remote-apply window;
* 2. dispatch the host's bulk replay action;
* 2. dispatch the host's bulk replay action (skipped when the caller marks
* reducers as already committed via `skipReducerDispatch` retry paths
* must not double-apply additive reducers);
* 3. yield once so host state reducers finish before side effects;
* 4. run remote archive side effects after dispatch, when configured;
* 5. start post-sync cooldown before closing the remote-apply window;
@ -92,6 +95,7 @@ export const replayOperationBatch = async <
operationToAction,
isArchiveAffectingAction = () => false,
onRemoteArchiveDataApplied,
onReducersCommitted,
onArchiveSideEffectError,
onPostSyncCooldownError,
yieldToEventLoop: waitForEventLoop = yieldToEventLoop,
@ -103,6 +107,7 @@ export const replayOperationBatch = async <
}
const isLocalHydration = applyOptions.isLocalHydration ?? false;
const skipReducerDispatch = applyOptions.skipReducerDispatch ?? false;
if (!isLocalHydration && archiveSideEffects !== undefined && !operationToAction) {
throw new Error(
@ -112,8 +117,11 @@ export const replayOperationBatch = async <
remoteApplyWindow.startApplyingRemoteOps();
try {
dispatcher.dispatch(createBulkApplyAction(ops));
await waitForEventLoop();
if (!skipReducerDispatch) {
dispatcher.dispatch(createBulkApplyAction(ops));
await waitForEventLoop();
await onReducersCommitted?.(ops);
}
if (!isLocalHydration && archiveSideEffects !== undefined && operationToAction) {
const archiveResult = await processArchiveSideEffects({

View file

@ -11,24 +11,33 @@ export interface PlanRegularOpsAfterFullStateUploadOptions<
TEntry extends OperationLogEntry<Operation<string>> = OperationLogEntry,
> {
regularOps: TEntry[];
lastUploadedFullStateOpId?: string;
lastUploadedFullStateOpSeq?: number;
}
/**
* Splits regular operations around an uploaded full-state snapshot.
*
* UUIDv7 operation IDs are time-ordered in Super Productivity. Core only
* depends on lexical ID ordering supplied by the host. Ops before the full-state
* operation are already represented in the snapshot and can be marked synced;
* later ops still need the normal upload path.
* Classification uses the local op-log `seq` (monotonic append order on this
* client), NOT the UUIDv7 op id: lexical id order follows the wall clock, which
* can roll back (e.g. NTP correction, restart), so a post-snapshot op could get
* a smaller id, be treated as "included in snapshot", and silently never reach
* the server. `seq` is exactly creation order, which is what "captured in the
* frozen snapshot payload" means. Ops appended before the full-state op are
* already represented in the snapshot and can be marked synced; later ops still
* need the normal upload path.
*
* When no seq is available (no full-state op uploaded, or its local entry is
* unknown, e.g. the id came from a remote source), everything stays on the
* upload path uploading an op that is also in the snapshot is safe (server
* dedups by op id), whereas skipping one that is not would lose data.
*/
export const planRegularOpsAfterFullStateUpload = <
TEntry extends OperationLogEntry<Operation<string>> = OperationLogEntry,
>({
regularOps,
lastUploadedFullStateOpId,
lastUploadedFullStateOpSeq,
}: PlanRegularOpsAfterFullStateUploadOptions<TEntry>): RegularOpsAfterFullStateUploadPlan<TEntry> => {
if (!lastUploadedFullStateOpId) {
if (lastUploadedFullStateOpSeq === undefined) {
return {
opsIncludedInSnapshot: [],
opsAfterSnapshot: regularOps,
@ -39,7 +48,7 @@ export const planRegularOpsAfterFullStateUpload = <
const opsAfterSnapshot: TEntry[] = [];
for (const entry of regularOps) {
if (entry.op.id < lastUploadedFullStateOpId) {
if (entry.seq < lastUploadedFullStateOpSeq) {
opsIncludedInSnapshot.push(entry);
} else {
opsAfterSnapshot.push(entry);

View file

@ -1,7 +1,11 @@
import { describe, expect, it, vi } from 'vitest';
import { applyRemoteOperations } from '../src/remote-apply';
import type { RemoteOperationApplyStorePort } from '../src/remote-apply';
import type { Operation, OperationApplyPort } from '../src';
import type {
Operation,
OperationApplyPort,
ReducerCommitAwareOperationApplyPort,
} from '../src';
const createOperation = (id: string, opType = 'UPD'): Operation<string> => ({
id,
@ -22,16 +26,20 @@ const createStore = (appendResult: {
skippedCount: number;
}): RemoteOperationApplyStorePort<Operation<string>> => ({
appendBatchSkipDuplicates: vi.fn().mockResolvedValue(appendResult),
mergeRemoteOpClocks: vi.fn().mockResolvedValue(undefined),
markReducersCommittedAndMergeClocks: vi.fn().mockResolvedValue(undefined),
markApplied: vi.fn().mockResolvedValue(undefined),
markFailed: vi.fn().mockResolvedValue(undefined),
mergeRemoteOpClocks: vi.fn().mockResolvedValue(undefined),
clearFullStateOpsExcept: vi.fn().mockResolvedValue(0),
});
const createApplier = (
result: Awaited<ReturnType<OperationApplyPort<Operation<string>>['applyOperations']>>,
): OperationApplyPort<Operation<string>> => ({
applyOperations: vi.fn().mockResolvedValue(result),
): ReducerCommitAwareOperationApplyPort<Operation<string>> => ({
applyOperations: vi.fn(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
return result;
}),
});
describe('applyRemoteOperations', () => {
@ -50,9 +58,13 @@ describe('applyRemoteOperations', () => {
expect(store.appendBatchSkipDuplicates).toHaveBeenCalledWith([op1, op2], 'remote', {
pendingApply: true,
});
expect(applier.applyOperations).toHaveBeenCalledWith([op2]);
expect(store.markApplied).toHaveBeenCalledWith([10]);
expect(applier.applyOperations).toHaveBeenCalledWith(
[op2],
jasmineLikeObjectContainingFunction(),
);
expect(store.mergeRemoteOpClocks).toHaveBeenCalledWith([op2]);
expect(store.markReducersCommittedAndMergeClocks).toHaveBeenCalledWith([10], [op2]);
expect(store.markApplied).toHaveBeenCalledWith([10]);
expect(result).toEqual({
appendedOps: [op2],
skippedCount: 1,
@ -73,6 +85,7 @@ describe('applyRemoteOperations', () => {
expect(applier.applyOperations).not.toHaveBeenCalled();
expect(store.markApplied).not.toHaveBeenCalled();
expect(store.mergeRemoteOpClocks).not.toHaveBeenCalled();
expect(store.markReducersCommittedAndMergeClocks).not.toHaveBeenCalled();
expect(result).toEqual({
appendedOps: [],
skippedCount: 1,
@ -104,7 +117,39 @@ describe('applyRemoteOperations', () => {
expect(result.clearedFullStateOpCount).toBe(3);
});
it('marks the failed op and remaining unapplied ops as failed', async () => {
it('cleanup preserves a batch full-state op whose archive handling failed (quarantined)', async () => {
// Both imports reducer-committed; the LATER one failed only its archive
// side effects. Cleanup keyed on the applied one must not delete the
// quarantined entry, or markFailed misses it and the change is lost
// from the next startup replay.
const appliedImport = createOperation('sync-import-1', 'SYNC_IMPORT');
const failedImport = createOperation('sync-import-2', 'SYNC_IMPORT');
const error = new Error('archive failure');
const store = createStore({
seqs: [1, 2],
writtenOps: [appliedImport, failedImport],
skippedCount: 0,
});
const applier = createApplier({
appliedOps: [appliedImport],
failedOp: { op: failedImport, error },
});
await applyRemoteOperations({
ops: [appliedImport, failedImport],
store,
applier,
isFullStateOperation: (op) => op.opType === 'SYNC_IMPORT',
});
expect(store.clearFullStateOpsExcept).toHaveBeenCalledWith([
'sync-import-1',
'sync-import-2',
]);
expect(store.markFailed).toHaveBeenCalledWith(['sync-import-2']);
});
it('checkpoints the whole reducer batch but charges only the attempted archive failure', async () => {
const op1 = createOperation('op-1');
const op2 = createOperation('op-2');
const op3 = createOperation('op-3');
@ -126,13 +171,16 @@ describe('applyRemoteOperations', () => {
});
expect(store.markApplied).toHaveBeenCalledWith([1]);
expect(store.mergeRemoteOpClocks).toHaveBeenCalledWith([op1]);
expect(store.markFailed).toHaveBeenCalledWith(['op-2', 'op-3']);
expect(store.markReducersCommittedAndMergeClocks).toHaveBeenCalledWith(
[1, 2, 3],
[op1, op2, op3],
);
expect(store.markFailed).toHaveBeenCalledWith(['op-2']);
expect(result.failedOp).toEqual({ op: op2, error });
expect(result.failedOpIds).toEqual(['op-2', 'op-3']);
expect(result.failedOpIds).toEqual(['op-2']);
});
it('marks only the reported failed op if it is not in the appended batch', async () => {
it('rejects an applier result whose failed op is not in the appended batch', async () => {
const op1 = createOperation('op-1');
const unknownFailedOp = createOperation('unknown-failed-op');
const store = createStore({ seqs: [1], writtenOps: [op1], skippedCount: 0 });
@ -141,9 +189,348 @@ describe('applyRemoteOperations', () => {
failedOp: { op: unknownFailedOp, error: new Error('unexpected') },
});
const result = await applyRemoteOperations({ ops: [op1], store, applier });
await expect(applyRemoteOperations({ ops: [op1], store, applier })).rejects.toThrow(
'unknown-failed-op',
);
expect(store.markFailed).not.toHaveBeenCalled();
});
expect(store.markFailed).toHaveBeenCalledWith(['unknown-failed-op']);
expect(result.failedOpIds).toEqual(['unknown-failed-op']);
it('fails closed when the applier ignores the reducer-commit callback', async () => {
const op = createOperation('op-1');
const store = createStore({ seqs: [1], writtenOps: [op], skippedCount: 0 });
const applier: OperationApplyPort<Operation<string>> = {
applyOperations: vi.fn().mockResolvedValue({ appliedOps: [op] }),
};
await expect(applyRemoteOperations({ ops: [op], store, applier })).rejects.toThrow(
'reducer-commit callback',
);
expect(store.markApplied).not.toHaveBeenCalled();
});
it('fails closed when the reducer-commit callback omits an appended op', async () => {
const op1 = createOperation('op-1');
const op2 = createOperation('op-2');
const store = createStore({
seqs: [1, 2],
writtenOps: [op1, op2],
skippedCount: 0,
});
const applier: OperationApplyPort<Operation<string>> = {
applyOperations: vi.fn(async (_ops, options) => {
await options?.onReducersCommitted?.([op1]);
return { appliedOps: [op1, op2] };
}),
};
await expect(
applyRemoteOperations({ ops: [op1, op2], store, applier }),
).rejects.toThrow('entire appended batch');
expect(store.markApplied).not.toHaveBeenCalled();
});
it('fails closed when the reducer-commit callback is invoked twice', async () => {
const op = createOperation('op-1');
const store = createStore({ seqs: [1], writtenOps: [op], skippedCount: 0 });
const applier: OperationApplyPort<Operation<string>> = {
applyOperations: vi.fn(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
await options?.onReducersCommitted?.(ops);
return { appliedOps: ops };
}),
};
await expect(applyRemoteOperations({ ops: [op], store, applier })).rejects.toThrow(
'exactly once',
);
expect(store.markApplied).not.toHaveBeenCalled();
});
it('uses authoritative written operations for clocks, cleanup, and results', async () => {
const writtenImport = createOperation('sync-import-1', 'SYNC_IMPORT');
writtenImport.vectorClock = { authoritative: 7 };
const callbackClone = {
...writtenImport,
vectorClock: { forgedCallback: 99 },
};
const appliedClone = {
...writtenImport,
opType: 'UPD',
vectorClock: { forgedResult: 100 },
};
const store = createStore({
seqs: [11],
writtenOps: [writtenImport],
skippedCount: 0,
});
const applier: OperationApplyPort<Operation<string>> = {
applyOperations: vi.fn(async (_ops, options) => {
await options?.onReducersCommitted?.([callbackClone]);
return { appliedOps: [appliedClone] };
}),
};
const result = await applyRemoteOperations({
ops: [writtenImport],
store,
applier,
isFullStateOperation: (op) => op.opType === 'SYNC_IMPORT',
});
expect(store.markReducersCommittedAndMergeClocks).toHaveBeenCalledWith(
[11],
[writtenImport],
);
expect(store.clearFullStateOpsExcept).toHaveBeenCalledWith(['sync-import-1']);
expect(result.appendedOps[0]).toBe(writtenImport);
expect(result.appliedOps[0]).toBe(writtenImport);
});
it('rejects applied operations that are not the exact ordered written prefix', async () => {
const op1 = createOperation('op-1');
const op2 = createOperation('op-2');
const store = createStore({
seqs: [1, 2],
writtenOps: [op1, op2],
skippedCount: 0,
});
const applier = createApplier({ appliedOps: [op2, op1] });
await expect(
applyRemoteOperations({ ops: [op1, op2], store, applier }),
).rejects.toThrow('exact ordered prefix');
expect(store.markApplied).not.toHaveBeenCalled();
});
it('rejects a partial applied prefix without the next failed operation', async () => {
const op1 = createOperation('op-1');
const op2 = createOperation('op-2');
const store = createStore({
seqs: [1, 2],
writtenOps: [op1, op2],
skippedCount: 0,
});
const applier = createApplier({ appliedOps: [op1] });
await expect(
applyRemoteOperations({ ops: [op1, op2], store, applier }),
).rejects.toThrow('failed operation immediately after the applied prefix');
expect(store.markApplied).not.toHaveBeenCalled();
});
it('rejects a failed operation that is not immediately after the applied prefix', async () => {
const op1 = createOperation('op-1');
const op2 = createOperation('op-2');
const op3 = createOperation('op-3');
const store = createStore({
seqs: [1, 2, 3],
writtenOps: [op1, op2, op3],
skippedCount: 0,
});
const applier = createApplier({
appliedOps: [op1],
failedOp: { op: op3, error: new Error('wrong failure') },
});
await expect(
applyRemoteOperations({ ops: [op1, op2, op3], store, applier }),
).rejects.toThrow('failed operation immediately after the applied prefix');
expect(store.markFailed).not.toHaveBeenCalled();
});
it('throws synchronously from a malformed reducer-commit callback', async () => {
const op1 = createOperation('op-1');
const op2 = createOperation('op-2');
const store = createStore({
seqs: [1, 2],
writtenOps: [op1, op2],
skippedCount: 0,
});
let callbackThrewSynchronously = false;
const applier: OperationApplyPort<Operation<string>> = {
applyOperations: vi.fn(async (_ops, options) => {
try {
void options?.onReducersCommitted?.([op2, op1]);
} catch (error) {
callbackThrewSynchronously = true;
throw error;
}
return { appliedOps: [op1, op2] };
}),
};
await expect(
applyRemoteOperations({ ops: [op1, op2], store, applier }),
).rejects.toThrow('entire appended batch in order');
expect(callbackThrewSynchronously).toBe(true);
});
it('throws synchronously from a duplicate reducer-commit callback', async () => {
const op = createOperation('op-1');
const store = createStore({ seqs: [1], writtenOps: [op], skippedCount: 0 });
let duplicateCallbackThrewSynchronously = false;
const applier: OperationApplyPort<Operation<string>> = {
applyOperations: vi.fn(async (ops, options) => {
void options?.onReducersCommitted?.(ops);
try {
void options?.onReducersCommitted?.(ops);
} catch (error) {
duplicateCallbackThrewSynchronously = true;
throw error;
}
return { appliedOps: ops };
}),
};
await expect(applyRemoteOperations({ ops: [op], store, applier })).rejects.toThrow(
'exactly once',
);
expect(duplicateCallbackThrewSynchronously).toBe(true);
});
it('preserves the applier error when an unawaited reducer checkpoint also fails', async () => {
const op = createOperation('op-1');
const applyError = new Error('dispatcher failed');
const checkpointError = new Error('checkpoint failed');
const store = createStore({ seqs: [1], writtenOps: [op], skippedCount: 0 });
vi.mocked(store.markReducersCommittedAndMergeClocks).mockRejectedValue(
checkpointError,
);
const applier: ReducerCommitAwareOperationApplyPort<Operation<string>> = {
applyOperations: vi.fn(async (ops, options) => {
void options.onReducersCommitted(ops);
throw applyError;
}),
};
await expect(applyRemoteOperations({ ops: [op], store, applier })).rejects.toBe(
applyError,
);
});
it('preserves a callback-contract error when the first reducer checkpoint also fails', async () => {
const op = createOperation('op-1');
const checkpointError = new Error('checkpoint failed');
const store = createStore({ seqs: [1], writtenOps: [op], skippedCount: 0 });
vi.mocked(store.markReducersCommittedAndMergeClocks).mockRejectedValue(
checkpointError,
);
let callbackContractError: unknown;
const applier: ReducerCommitAwareOperationApplyPort<Operation<string>> = {
applyOperations: vi.fn(async (ops, options) => {
void options.onReducersCommitted(ops);
try {
void options.onReducersCommitted(ops);
} catch (error) {
callbackContractError = error;
throw error;
}
return { appliedOps: ops };
}),
};
let thrown: unknown;
try {
await applyRemoteOperations({ ops: [op], store, applier });
} catch (error) {
thrown = error;
}
expect(callbackContractError).toBeInstanceOf(Error);
expect(thrown).toBe(callbackContractError);
});
it('does not start reducer application when the pre-apply clock merge fails', async () => {
const op = createOperation('op-1');
const clockError = new Error('clock merge failed');
const store = createStore({ seqs: [1], writtenOps: [op], skippedCount: 0 });
vi.mocked(store.mergeRemoteOpClocks).mockRejectedValue(clockError);
const applier = createApplier({ appliedOps: [op] });
const onRemoteClocksDurable = vi.fn();
await expect(
applyRemoteOperations({
ops: [op],
store,
applier,
onRemoteClocksDurable,
}),
).rejects.toBe(clockError);
expect(onRemoteClocksDurable).not.toHaveBeenCalled();
expect(applier.applyOperations).not.toHaveBeenCalled();
expect(store.markReducersCommittedAndMergeClocks).not.toHaveBeenCalled();
expect(store.markApplied).not.toHaveBeenCalled();
});
it('signals durable remote clocks before a later atomic checkpoint failure', async () => {
const op = createOperation('op-1');
const checkpointError = new Error('checkpoint failed');
const store = createStore({ seqs: [1], writtenOps: [op], skippedCount: 0 });
vi.mocked(store.markReducersCommittedAndMergeClocks).mockRejectedValue(
checkpointError,
);
const onRemoteClocksDurable = vi.fn();
await expect(
applyRemoteOperations({
ops: [op],
store,
applier: createApplier({ appliedOps: [op] }),
onRemoteClocksDurable,
}),
).rejects.toBe(checkpointError);
expect(onRemoteClocksDurable).toHaveBeenCalledWith([op]);
expect(store.markApplied).not.toHaveBeenCalled();
});
it('signals durable remote clocks before later bookkeeping can fail', async () => {
const op = createOperation('op-1');
const markAppliedError = new Error('mark applied failed');
const store = createStore({ seqs: [1], writtenOps: [op], skippedCount: 0 });
vi.mocked(store.markApplied).mockRejectedValue(markAppliedError);
const onRemoteClocksDurable = vi.fn();
await expect(
applyRemoteOperations({
ops: [op],
store,
applier: createApplier({ appliedOps: [op] }),
onRemoteClocksDurable,
}),
).rejects.toBe(markAppliedError);
expect(onRemoteClocksDurable).toHaveBeenCalledWith([op]);
});
it('merges clocks before applying reducers and keeps the atomic checkpoint afterward', async () => {
const op = createOperation('op-1');
const store = createStore({ seqs: [1], writtenOps: [op], skippedCount: 0 });
const callOrder: string[] = [];
vi.mocked(store.mergeRemoteOpClocks).mockImplementation(async () => {
callOrder.push('mergeRemoteOpClocks');
});
vi.mocked(store.markReducersCommittedAndMergeClocks).mockImplementation(async () => {
callOrder.push('markReducersCommittedAndMergeClocks');
});
const applier: ReducerCommitAwareOperationApplyPort<Operation<string>> = {
applyOperations: vi.fn(async (ops, options) => {
callOrder.push('applyOperations');
await options?.onReducersCommitted?.(ops);
return { appliedOps: ops };
}),
};
await applyRemoteOperations({ ops: [op], store, applier });
expect(callOrder).toEqual([
'mergeRemoteOpClocks',
'applyOperations',
'markReducersCommittedAndMergeClocks',
]);
});
});
const jasmineLikeObjectContainingFunction = (): object =>
expect.objectContaining({ onReducersCommitted: expect.any(Function) });

View file

@ -69,6 +69,9 @@ describe('replayOperationBatch', () => {
const onRemoteArchiveDataApplied = vi.fn(() => {
callOrder.push('remoteArchiveDataApplied');
});
const onReducersCommitted = vi.fn(async () => {
callOrder.push('reducersCommitted');
});
const result = await replayOperationBatch({
ops,
@ -87,6 +90,7 @@ describe('replayOperationBatch', () => {
}),
isArchiveAffectingAction: (action) => action.archiveAffecting === true,
onRemoteArchiveDataApplied,
onReducersCommitted,
yieldToEventLoop,
});
@ -101,6 +105,7 @@ describe('replayOperationBatch', () => {
'startApplyingRemoteOps',
'dispatchBulk',
'yield',
'reducersCommitted',
'archive:op-1',
'archive:op-2',
'yield',
@ -264,6 +269,88 @@ describe('replayOperationBatch', () => {
]);
});
it('runs only archive side effects when skipReducerDispatch is set (retry path)', async () => {
const callOrder: string[] = [];
const ops = [createOperation('op-1'), createOperation('op-2')];
const dispatcher: ActionDispatchPort<BulkReplayAction> = {
dispatch: vi.fn(() => {
callOrder.push('dispatchBulk');
}),
};
const archiveSideEffects: ArchiveSideEffectPort<ReplayAction> = {
handleOperation: vi.fn(async (action) => {
callOrder.push(`archive:${action.opId}`);
}),
};
const result = await replayOperationBatch({
ops,
applyOptions: { skipReducerDispatch: true },
dispatcher,
createBulkApplyAction: (operations) => ({
type: '[Test] Bulk Apply',
operations,
}),
remoteApplyWindow: createRemoteApplyWindow(callOrder),
deferredLocalActions: createDeferredLocalActions(callOrder),
archiveSideEffects,
operationToAction: (op) => ({ type: '[Test] Action', opId: op.id }),
yieldToEventLoop: vi.fn(async () => {
callOrder.push('yield');
}),
});
expect(result).toEqual({ appliedOps: ops });
expect(dispatcher.dispatch).not.toHaveBeenCalled();
expect(callOrder).toEqual([
'startApplyingRemoteOps',
'archive:op-1',
'archive:op-2',
'yield',
'startPostSyncCooldown',
'endApplyingRemoteOps',
'processDeferredActions',
]);
});
it('reports archive failures without re-dispatching when skipReducerDispatch is set', async () => {
const op1 = createOperation('op-1');
const op2 = createOperation('op-2');
const archiveError = new Error('archive failed again');
const dispatcher: ActionDispatchPort<BulkReplayAction> = { dispatch: vi.fn() };
const archiveSideEffects: ArchiveSideEffectPort<ReplayAction> = {
handleOperation: vi.fn(async (action) => {
if (action.opId === 'op-2') {
throw archiveError;
}
}),
};
const result = await replayOperationBatch({
ops: [op1, op2],
applyOptions: { skipReducerDispatch: true },
dispatcher,
createBulkApplyAction: (operations) => ({
type: '[Test] Bulk Apply',
operations,
}),
remoteApplyWindow: createRemoteApplyWindow([]),
deferredLocalActions: createDeferredLocalActions([]),
archiveSideEffects,
operationToAction: (op) => ({ type: '[Test] Action', opId: op.id }),
yieldToEventLoop: vi.fn(async () => undefined),
});
expect(dispatcher.dispatch).not.toHaveBeenCalled();
expect(result).toEqual({
appliedOps: [op1],
failedOp: {
op: op2,
error: archiveError,
},
});
});
it('fails fast when archive side effects are configured without operation conversion', async () => {
const callOrder: string[] = [];
const archiveSideEffects: ArchiveSideEffectPort<ReplayAction> = {
@ -328,6 +415,45 @@ describe('replayOperationBatch', () => {
]);
});
it('closes the sync window and flushes deferred actions when reducer bookkeeping throws', async () => {
const callOrder: string[] = [];
const checkpointError = new Error('reducer checkpoint failed');
await expect(
replayOperationBatch({
ops: [createOperation('op-1')],
dispatcher: {
dispatch: vi.fn(() => {
callOrder.push('dispatchBulk');
}),
},
createBulkApplyAction: (operations) => ({
type: '[Test] Bulk Apply',
operations,
}),
remoteApplyWindow: createRemoteApplyWindow(callOrder),
deferredLocalActions: createDeferredLocalActions(callOrder),
onReducersCommitted: vi.fn(async () => {
callOrder.push('reducersCommitted');
throw checkpointError;
}),
yieldToEventLoop: vi.fn(async () => {
callOrder.push('yield');
}),
}),
).rejects.toBe(checkpointError);
expect(callOrder).toEqual([
'startApplyingRemoteOps',
'dispatchBulk',
'yield',
'reducersCommitted',
'startPostSyncCooldown',
'endApplyingRemoteOps',
'processDeferredActions',
]);
});
it('closes the sync window even when post-sync cooldown throws', async () => {
const callOrder: string[] = [];
const cooldownError = new Error('cooldown failed');

View file

@ -32,7 +32,7 @@ describe('planRegularOpsAfterFullStateUpload', () => {
expect(
planRegularOpsAfterFullStateUpload({
regularOps: entries,
lastUploadedFullStateOpId: undefined,
lastUploadedFullStateOpSeq: undefined,
}),
).toEqual({
opsIncludedInSnapshot: [],
@ -40,7 +40,7 @@ describe('planRegularOpsAfterFullStateUpload', () => {
});
});
it('splits regular ops before and after the uploaded full-state op id', () => {
it('splits regular ops before and after the uploaded full-state op seq', () => {
const before = createEntry(1, '001');
const same = createEntry(2, '010');
const after = createEntry(3, '011');
@ -48,13 +48,30 @@ describe('planRegularOpsAfterFullStateUpload', () => {
expect(
planRegularOpsAfterFullStateUpload({
regularOps: [before, same, after],
lastUploadedFullStateOpId: '010',
lastUploadedFullStateOpSeq: 2,
}),
).toEqual({
opsIncludedInSnapshot: [before],
opsAfterSnapshot: [same, after],
});
});
it('uploads an op created after the snapshot even when its id sorts before the full-state op id (clock rollback)', () => {
// Wall-clock rollback: the post-snapshot op got a lexically SMALLER UUIDv7
// id than the full-state op. Local seq order must win — the op is NOT in
// the frozen snapshot payload and must be uploaded, not marked synced.
const afterSnapshotWithSmallerId = createEntry(5, '001');
expect(
planRegularOpsAfterFullStateUpload({
regularOps: [afterSnapshotWithSmallerId],
lastUploadedFullStateOpSeq: 4,
}),
).toEqual({
opsIncludedInSnapshot: [],
opsAfterSnapshot: [afterSnapshotWithSmallerId],
});
});
});
describe('planUploadLastServerSeqUpdate', () => {

View file

@ -25,6 +25,7 @@ import { MetricService } from '../../features/metric/metric.service';
import { DateService } from '../../core/date/date.service';
import { UserProfileService } from '../../features/user-profile/user-profile.service';
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
import { SyncStatus } from '../../op-log/sync-exports';
// Regression test for #7477: in a project view a long title pushed the
// right-side header actions (simple-counter / habit buttons) off screen.
@ -166,6 +167,7 @@ describe('MainHeaderComponent focus button visibility', () => {
{
provide: SyncWrapperService,
useValue: {
sync: jasmine.createSpy('sync'),
isEnabledAndReady$: of(false),
syncState$: of('IN_SYNC'),
isSyncInProgress$: of(false),
@ -173,7 +175,13 @@ describe('MainHeaderComponent focus button visibility', () => {
superSyncIsConfirmedInSync$: of(false),
},
},
{ provide: SnackService, useValue: { open: jasmine.createSpy('open') } },
{
provide: SnackService,
useValue: {
open: jasmine.createSpy('open'),
hasPendingPersistentAction: jasmine.createSpy('hasPendingPersistentAction'),
},
},
{ provide: Router, useValue: { events: EMPTY } },
{
provide: GlobalConfigService,
@ -229,4 +237,19 @@ describe('MainHeaderComponent focus button visibility', () => {
expect(component.isFocusButtonVisible()).toBe(false);
});
it('keeps a persistent recovery action instead of showing routine sync success', async () => {
component = createComponent();
const syncWrapperService = TestBed.inject(
SyncWrapperService,
) as jasmine.SpyObj<SyncWrapperService>;
const snackService = TestBed.inject(SnackService) as jasmine.SpyObj<SnackService>;
syncWrapperService.sync.and.resolveTo(SyncStatus.UpdateRemote);
snackService.hasPendingPersistentAction.and.returnValue(true);
component.sync();
await Promise.resolve();
expect(snackService.open).not.toHaveBeenCalled();
});
});

View file

@ -304,6 +304,11 @@ export class MainHeaderComponent implements OnDestroy {
sync(): void {
this.syncWrapperService.sync(true).then((r) => {
// Keep persistent recovery actions (for example USE_REMOTE Undo) visible;
// routine sync-success feedback must not replace them.
if (this._snackService.hasPendingPersistentAction()) {
return;
}
if (
r === SyncStatus.UpdateLocal ||
r === SyncStatus.UpdateRemoteAll ||

View file

@ -0,0 +1,54 @@
import { TestBed } from '@angular/core/testing';
import { MAT_SNACK_BAR_DATA, MatSnackBarRef } from '@angular/material/snack-bar';
import { SnackParams } from '../snack.model';
import { SnackCustomComponent } from './snack-custom.component';
describe('SnackCustomComponent', () => {
const createComponent = (
data: SnackParams,
): {
component: SnackCustomComponent;
ref: jasmine.SpyObj<MatSnackBarRef<SnackCustomComponent>>;
} => {
const ref = jasmine.createSpyObj<MatSnackBarRef<SnackCustomComponent>>(
'MatSnackBarRef',
['dismiss', 'dismissWithAction'],
);
TestBed.configureTestingModule({
providers: [
{ provide: MAT_SNACK_BAR_DATA, useValue: data },
{ provide: MatSnackBarRef, useValue: ref },
],
});
const component = TestBed.runInInjectionContext(() => new SnackCustomComponent());
return { component, ref };
};
afterEach(() => TestBed.resetTestingModule());
it('should invoke the non-action dismissal callback from the close control', async () => {
const dismissFn = jasmine.createSpy('dismissFn');
const { component, ref } = createComponent({ msg: 'Undo', dismissFn });
await component.close();
expect(dismissFn).toHaveBeenCalled();
expect(ref.dismiss).toHaveBeenCalled();
});
it('should not invoke the dismissal callback when the action is used', () => {
const actionFn = jasmine.createSpy('actionFn');
const dismissFn = jasmine.createSpy('dismissFn');
const { component, ref } = createComponent({
msg: 'Undo',
actionFn,
dismissFn,
});
component.actionClick();
expect(actionFn).toHaveBeenCalled();
expect(dismissFn).not.toHaveBeenCalled();
expect(ref.dismissWithAction).toHaveBeenCalled();
});
});

View file

@ -58,8 +58,9 @@ export class SnackCustomComponent implements OnInit, OnDestroy {
this.snackBarRef.dismissWithAction();
}
close(ev?: MouseEvent): void {
async close(ev?: MouseEvent): Promise<void> {
ev?.stopPropagation();
await this.data.dismissFn?.();
this.snackBarRef.dismiss();
}
}

View file

@ -14,6 +14,7 @@ export interface SnackParams {
actionId?: string;
// eslint-disable-next-line
actionFn?: Function;
dismissFn?: () => void | Promise<void>;
actionPayload?: unknown;
config?: MatSnackBarConfig;
isSpinner?: boolean;

View file

@ -0,0 +1,76 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { Store } from '@ngrx/store';
import { EMPTY } from 'rxjs';
import { LOCAL_ACTIONS } from '../../util/local-actions.token';
import { SnackParams } from './snack.model';
import { SnackService } from './snack.service';
describe('SnackService', () => {
let service: SnackService;
let openSnackSpy: jasmine.Spy<(params: SnackParams) => void>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
SnackService,
{ provide: Store, useValue: { dispatch: jasmine.createSpy('dispatch') } },
{
provide: TranslateService,
useValue: { instant: (value: string): string => value },
},
{ provide: LOCAL_ACTIONS, useValue: EMPTY },
{ provide: MatSnackBar, useValue: {} },
],
});
service = TestBed.inject(SnackService);
openSnackSpy = spyOn(
service as unknown as { _openSnack: (params: SnackParams) => void },
'_openSnack',
);
});
it('should not let an ordinary snack replace a persistent recovery action', () => {
service.open({
msg: 'Recovery available',
actionStr: 'Undo',
config: { duration: 0 },
});
service.open({ msg: 'Sync complete', type: 'SUCCESS' });
service.open('Another ordinary message');
expect(openSnackSpy).toHaveBeenCalledTimes(1);
expect(service.hasPendingPersistentAction()).toBeTrue();
});
it('should allow a newer persistent action to replace the current one', () => {
service.open({
msg: 'Recovery available',
actionStr: 'Undo',
config: { duration: 0 },
});
service.open({
msg: 'Update required',
actionStr: 'Update',
config: { duration: 0 },
});
expect(openSnackSpy).toHaveBeenCalledTimes(2);
});
it('should accept ordinary snacks after the persistent action is closed', () => {
service.open({
msg: 'Recovery available',
actionStr: 'Undo',
config: { duration: 0 },
});
service.close();
service.open({ msg: 'Restore complete', type: 'SUCCESS' });
expect(openSnackSpy).toHaveBeenCalledTimes(2);
expect(service.hasPendingPersistentAction()).toBeFalse();
});
});

View file

@ -3,7 +3,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { SnackParams } from './snack.model';
import { Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { take, takeUntil } from 'rxjs/operators';
import { DEFAULT_SNACK_CFG } from './snack.const';
import { SnackCustomComponent } from './snack-custom/snack-custom.component';
import { TranslateService } from '@ngx-translate/core';
@ -23,6 +23,7 @@ export class SnackService {
private _matSnackBar = inject(MatSnackBar);
private _ref?: MatSnackBarRef<SnackCustomComponent | SimpleSnackBar>;
private _hasPendingPersistentAction = false;
constructor() {
const _onWorkContextChange$: Observable<unknown> = this._actions$.pipe(
@ -37,10 +38,29 @@ export class SnackService {
if (typeof params === 'string') {
params = { msg: params };
}
const isPersistentAction = !!(params.actionStr && params.config?.duration === 0);
// A sticky recovery/update action must survive unrelated success, info and
// error feedback in the app's single snack slot. Another sticky actionable
// snack may still replace it intentionally.
if (this._hasPendingPersistentAction && !isPersistentAction) {
return;
}
// Track a persistent recovery action (a sticky, actionable snack)
// synchronously, before the debounced render, so an immediate follow-up
// check (e.g. the header's post-sync success feedback) cannot unknowingly
// replace it. `_openSnack` is debounced trailing-edge, so this last-writer
// value matches the snack it will actually render — including the case
// where a non-persistent snack supersedes a persistent one.
this._hasPendingPersistentAction = isPersistentAction;
this._openSnack(params);
}
hasPendingPersistentAction(): boolean {
return this._hasPendingPersistentAction;
}
close(): void {
this._hasPendingPersistentAction = false;
if (this._ref) {
this._ref.dismiss();
}
@ -107,6 +127,30 @@ export class SnackService {
}
}
// The pending-persistent-action flag is set authoritatively in open()
// before this debounced render. Here we only clear it once this specific
// snack is dismissed (guarded so a newer snack's flag isn't cleared).
const openedRef = this._ref;
openedRef
?.afterDismissed()
.pipe(take(1))
.subscribe(() => {
if (this._ref === openedRef) {
this._hasPendingPersistentAction = false;
}
});
if (actionStr && openedRef) {
openedRef
.onAction()
.pipe(take(1))
.subscribe(() => {
if (this._ref === openedRef) {
this._hasPendingPersistentAction = false;
}
});
}
if (actionStr && actionId && this._ref) {
this._ref
.onAction()

View file

@ -222,6 +222,59 @@ describe('StartupService', () => {
});
});
describe('raw rebuild recovery', () => {
const callRecoveryCheck = async (): Promise<void> => {
await (
service as unknown as {
_offerInterruptedRebuildRecoveryIfNeeded: () => Promise<void>;
}
)._offerInterruptedRebuildRecoveryIfNeeded();
};
it('should re-offer Undo after reload when a completed recovery token exists', async () => {
const opLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'isRawRebuildIncomplete',
'loadRawRebuildRecovery',
]);
opLogStore.isRawRebuildIncomplete.and.resolveTo(false);
opLogStore.loadRawRebuildRecovery.and.resolveTo({
backupId: 'backup-4242',
backupSavedAt: 4242,
completedAt: 5000,
});
const syncService = jasmine.createSpyObj('OperationLogSyncService', [
'offerInterruptedRebuildRecovery',
]);
syncService.offerInterruptedRebuildRecovery.and.resolveTo();
Object.assign(service as object, {
_opLogStore: opLogStore,
_injector: { get: () => syncService },
});
await callRecoveryCheck();
expect(syncService.offerInterruptedRebuildRecovery).toHaveBeenCalled();
});
it('should not instantiate sync recovery when no marker exists', async () => {
const opLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'isRawRebuildIncomplete',
'loadRawRebuildRecovery',
]);
opLogStore.isRawRebuildIncomplete.and.resolveTo(false);
opLogStore.loadRawRebuildRecovery.and.resolveTo(null);
const get = jasmine.createSpy('get');
Object.assign(service as object, {
_opLogStore: opLogStore,
_injector: { get },
});
await callRecoveryCheck();
expect(get).not.toHaveBeenCalled();
});
});
describe('_isTourLikelyToBeShown (private)', () => {
it('should return false if IS_SKIP_TOUR is set', () => {
(localStorage.getItem as jasmine.Spy).and.callFake((key: string) => {

View file

@ -14,6 +14,7 @@ import { IS_ELECTRON } from '../../app.constants';
import { Log } from '../log';
import { T } from '../../t.const';
import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service';
import { OperationLogSyncService } from '../../op-log/sync/operation-log-sync.service';
import { LegacyPfDbService } from '../persistence/legacy-pf-db.service';
import { BannerId } from '../banner/banner.model';
import { isOnline$ } from '../../util/is-online';
@ -169,6 +170,9 @@ export class StartupService {
this._ratePromptService.init();
await this._initPlugins();
// Last in the deferred body: the snack it may open is persistent and the
// single snack slot must not be reclaimed by the productivity tip above.
await this._offerInterruptedRebuildRecoveryIfNeeded();
}, DEFERRED_INIT_DELAY_MS);
if (IS_ELECTRON) {
@ -258,6 +262,32 @@ export class StartupService {
}
}
/**
* An interrupted USE_REMOTE rebuild leaves the user booting into the rebuild
* baseline instead of their data. Sync (when it runs) resumes the rebuild by
* itself but when it cannot (offline, or the user disabled sync after
* finding the app "emptied" by the crash), the pre-replace backup would have
* no visible entry point. Surfaces the persistent restore snack in that case.
*/
private async _offerInterruptedRebuildRecoveryIfNeeded(): Promise<void> {
try {
const [isIncomplete, completedRecovery] = await Promise.all([
this._opLogStore.isRawRebuildIncomplete(),
this._opLogStore.loadRawRebuildRecovery(),
]);
if (isIncomplete || completedRecovery) {
await this._injector
.get(OperationLogSyncService)
.offerInterruptedRebuildRecovery();
}
} catch (err) {
Log.err({
stage: 'interrupted-rebuild-recovery-check',
error: (err as Error)?.message,
});
}
}
private async _checkIsSingleInstance(): Promise<boolean> {
const channel = new BroadcastChannel('superProductivityTab');
let isAnotherInstanceActive = false;

View file

@ -83,6 +83,86 @@ describe('ArchiveService', () => {
expect(savedData.task.ids).toContain('task-1');
});
it('should serialize concurrent task archive mutations without dropping either task', async () => {
let archiveYoung = createEmptyArchive(Date.now());
let releaseFirstSave: (() => void) | undefined;
let firstSaveStarted: (() => void) | undefined;
const firstSaveStartedPromise = new Promise<void>((resolve) => {
firstSaveStarted = resolve;
});
const firstSaveGate = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
mockArchiveDbAdapter.loadArchiveYoung.and.callFake(async () => archiveYoung);
mockArchiveDbAdapter.loadArchiveOld.and.callFake(async () =>
createEmptyArchive(Date.now()),
);
mockArchiveDbAdapter.saveArchiveYoung.and.callFake(async (nextArchive) => {
if (mockArchiveDbAdapter.saveArchiveYoung.calls.count() === 1) {
firstSaveStarted?.();
await firstSaveGate;
}
archiveYoung = nextArchive;
});
const first = service.moveTasksToArchiveAndFlushArchiveIfDue([
createMockTask('task-a'),
]);
await firstSaveStartedPromise;
const second = service.moveTasksToArchiveAndFlushArchiveIfDue([
createMockTask('task-b'),
]);
// The second read must wait for the first write to commit.
await Promise.resolve();
expect(mockArchiveDbAdapter.loadArchiveYoung).toHaveBeenCalledTimes(1);
releaseFirstSave?.();
await Promise.all([first, second]);
expect(archiveYoung.task.ids).toEqual(['task-a', 'task-b']);
expect(Object.keys(archiveYoung.task.entities).sort()).toEqual([
'task-a',
'task-b',
]);
});
it('should replace a stale archived task on retry and keep ids deduped and sorted', async () => {
const staleTask = createMockTask('task-b', {
title: 'Stale archived title',
timeSpent: 1,
});
const otherTask = createMockTask('task-a');
mockArchiveDbAdapter.loadArchiveYoung.and.returnValue(
Promise.resolve({
...createEmptyArchive(),
task: {
ids: ['task-b', 'task-a', 'task-b'],
entities: {
[otherTask.id]: otherTask,
[staleTask.id]: staleTask,
},
},
}),
);
mockArchiveDbAdapter.loadArchiveOld.and.returnValue(
Promise.resolve(createEmptyArchive(Date.now() - 1000)),
);
const retriedTask = createMockTask('task-b', {
title: 'Newest active title',
timeSpent: 42,
});
await service.moveTasksToArchiveAndFlushArchiveIfDue([retriedTask]);
const savedTaskState =
mockArchiveDbAdapter.saveArchiveYoung.calls.first().args[0].task;
expect(savedTaskState.ids).toEqual(['task-a', 'task-b']);
expect(savedTaskState.entities['task-b']!.title).toBe('Newest active title');
expect(savedTaskState.entities['task-b']!.timeSpent).toBe(42);
});
it('should dispatch updateWholeState for time tracking', async () => {
const tasks = [createMockTask('task-1')];
@ -344,6 +424,38 @@ describe('ArchiveService', () => {
});
describe('writeTasksToArchiveForRemoteSync', () => {
it('should replace a stale archived task on remote retry and keep ids deduped and sorted', async () => {
const staleTask = createMockTask('task-b', {
title: 'Stale remote title',
timeSpent: 1,
});
const otherTask = createMockTask('task-a');
mockArchiveDbAdapter.loadArchiveYoung.and.returnValue(
Promise.resolve({
...createEmptyArchive(),
task: {
ids: ['task-b', 'task-a', 'task-b'],
entities: {
[otherTask.id]: otherTask,
[staleTask.id]: staleTask,
},
},
}),
);
const retriedTask = createMockTask('task-b', {
title: 'Newest remote title',
timeSpent: 84,
});
await service.writeTasksToArchiveForRemoteSync([retriedTask]);
const savedTaskState =
mockArchiveDbAdapter.saveArchiveYoung.calls.mostRecent().args[0].task;
expect(savedTaskState.ids).toEqual(['task-a', 'task-b']);
expect(savedTaskState.entities['task-b']!.title).toBe('Newest remote title');
expect(savedTaskState.entities['task-b']!.timeSpent).toBe(84);
});
it('should ignore malformed tasks without valid ids', async () => {
const invalidTask = {
title: 'Invalid task',

View file

@ -1,5 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { Task, TaskWithSubTasks } from '../tasks/task.model';
import { Task, TaskArchive, TaskWithSubTasks } from '../tasks/task.model';
import { flattenTasks } from '../tasks/store/task.selectors';
import { createEmptyEntity } from '../../util/create-empty-entity';
import { taskAdapter } from '../tasks/store/task.adapter';
@ -19,6 +19,8 @@ import { first } from 'rxjs/operators';
import { firstValueFrom } from 'rxjs';
import { selectTimeTrackingState } from '../time-tracking/store/time-tracking.selectors';
import { isValidEntityId } from '../../op-log/validation/is-valid-entity-id';
import { LockService } from '../../op-log/sync/lock.service';
import { LOCK_NAMES } from '../../op-log/core/operation-log.const';
/**
* Maps tasks to archive format by:
@ -60,6 +62,23 @@ const mapTasksToArchiveFormat = (
});
};
const replaceTasksInArchive = (
archiveTasks: Task[],
taskArchiveState: TaskArchive,
): TaskArchive => {
const updatedTaskArchive = taskAdapter.setMany(archiveTasks, taskArchiveState);
const uniqueStringIds = new Set(
updatedTaskArchive.ids.filter((id): id is string => typeof id === 'string'),
);
return {
...updatedTaskArchive,
// Keep retry output deterministic even when an older archive already
// contains duplicate or unsorted IDs.
ids: [...uniqueStringIds].sort(),
};
};
export const sanitizeTasksForArchiving = (
tasksIn: TaskWithSubTasks[],
logPrefix: string,
@ -153,10 +172,29 @@ const DEFAULT_ARCHIVE: ArchiveModel = {
export class ArchiveService {
private readonly _archiveDbAdapter = inject(ArchiveDbAdapter);
private readonly _store = inject(Store);
private readonly _lockService = inject(LockService);
/**
* Serializes task archive read-modify-write cycles. Separate task actions can
* arrive before either IndexedDB write finishes; without one shared queue,
* both calls can read the same archive and the last save silently drops the
* other task. A failed mutation does not poison later retries.
*/
private _runTaskArchiveMutation(mutation: () => Promise<void>): Promise<void> {
return this._lockService.request(LOCK_NAMES.TASK_ARCHIVE, mutation);
}
// NOTE: we choose this method as trigger to check for flushing to archive, since
// it is usually triggered every work-day once
async moveTasksToArchiveAndFlushArchiveIfDue(tasks: TaskWithSubTasks[]): Promise<void> {
moveTasksToArchiveAndFlushArchiveIfDue(tasks: TaskWithSubTasks[]): Promise<void> {
return this._runTaskArchiveMutation(() =>
this._moveTasksToArchiveAndFlushArchiveIfDue(tasks),
);
}
private async _moveTasksToArchiveAndFlushArchiveIfDue(
tasks: TaskWithSubTasks[],
): Promise<void> {
const now = Date.now();
const sanitizedTasks = sanitizeTasksForArchiving(tasks, 'moveToArchive');
const flatTasks = flattenTasks(sanitizedTasks);
@ -178,12 +216,7 @@ export class ArchiveService {
const taskArchiveState = archiveYoung.task || createEmptyEntity();
const archiveTasks = mapTasksToArchiveFormat(flatTasks, now, 'moveToArchive');
const newTaskArchiveUnsorted = taskAdapter.addMany(archiveTasks, taskArchiveState);
// Sort ids for deterministic ordering across clients (UUIDv7 is lexicographically sortable)
const newTaskArchive = {
...newTaskArchiveUnsorted,
ids: [...newTaskArchiveUnsorted.ids].sort(),
};
const newTaskArchive = replaceTasksInArchive(archiveTasks, taskArchiveState);
// ------------------------------------------------
// Result A:
@ -317,7 +350,15 @@ export class ArchiveService {
* only on the originating client and synced via the flushYoungToOld action.
* This ensures flushes happen exactly once, not on every receiving client.
*/
async writeTasksToArchiveForRemoteSync(tasks: TaskWithSubTasks[]): Promise<void> {
writeTasksToArchiveForRemoteSync(tasks: TaskWithSubTasks[]): Promise<void> {
return this._runTaskArchiveMutation(() =>
this._writeTasksToArchiveForRemoteSync(tasks),
);
}
private async _writeTasksToArchiveForRemoteSync(
tasks: TaskWithSubTasks[],
): Promise<void> {
const now = Date.now();
const sanitizedTasks = sanitizeTasksForArchiving(tasks, 'Remote sync');
const flatTasks = flattenTasks(sanitizedTasks);
@ -339,12 +380,7 @@ export class ArchiveService {
const taskArchiveState = archiveYoung.task || createEmptyEntity();
const archiveTasks = mapTasksToArchiveFormat(flatTasks, now, 'Remote sync');
const newTaskArchiveUnsorted = taskAdapter.addMany(archiveTasks, taskArchiveState);
// Sort ids for deterministic ordering across clients (UUIDv7 is lexicographically sortable)
const newTaskArchive = {
...newTaskArchiveUnsorted,
ids: [...newTaskArchiveUnsorted.ids].sort(),
};
const newTaskArchive = replaceTasksInArchive(archiveTasks, taskArchiveState);
// Also move historical time tracking data to archiveYoung
// This ensures the remote client's archive matches the originating client

View file

@ -6,11 +6,39 @@ import { Task, TaskArchive, TaskState } from '../tasks/task.model';
import { ArchiveModel } from './archive.model';
import { Update } from '@ngrx/entity';
import { RoundTimeOption } from '../project/project.model';
import { ArchiveService } from './archive.service';
import { of } from 'rxjs';
import { LockService } from '../../op-log/sync/lock.service';
class TestLockService {
private readonly _tails = new Map<string, Promise<void>>();
async request<T>(lockName: string, callback: () => Promise<T>): Promise<T> {
const previous = this._tails.get(lockName) ?? Promise.resolve();
let release: (() => void) | undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const tail = previous.then(() => current);
this._tails.set(lockName, tail);
await previous;
try {
return await callback();
} finally {
release?.();
if (this._tails.get(lockName) === tail) {
this._tails.delete(lockName);
}
}
}
}
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
describe('TaskArchiveService', () => {
let service: TaskArchiveService;
let archiveService: ArchiveService;
let storeMock: jasmine.SpyObj<Store>;
let archiveDbAdapterMock: jasmine.SpyObj<ArchiveDbAdapter>;
@ -53,7 +81,8 @@ describe('TaskArchiveService', () => {
});
beforeEach(() => {
storeMock = jasmine.createSpyObj<Store>('Store', ['dispatch']);
storeMock = jasmine.createSpyObj<Store>('Store', ['dispatch', 'select']);
storeMock.select.and.returnValue(of({ project: {}, tag: {} }));
archiveDbAdapterMock = jasmine.createSpyObj<ArchiveDbAdapter>('ArchiveDbAdapter', [
'loadArchiveYoung',
@ -75,10 +104,58 @@ describe('TaskArchiveService', () => {
TaskArchiveService,
{ provide: ArchiveDbAdapter, useValue: archiveDbAdapterMock },
{ provide: Store, useValue: storeMock },
{ provide: LockService, useClass: TestLockService },
],
});
service = TestBed.inject(TaskArchiveService);
archiveService = TestBed.inject(ArchiveService);
});
describe('archive mutation locking', () => {
it('should serialize a task update with an ArchiveService mutation', async () => {
const existingTask = createMockTask('existing', { title: 'Original' });
const archivedTask = {
...createMockTask('new-task', {
isDone: true,
doneOn: Date.now(),
}),
subTasks: [],
};
let archiveYoung = createMockArchiveModel([existingTask]);
let releaseFirstSave: (() => void) | undefined;
let firstSaveStarted: (() => void) | undefined;
const firstSaveStartedPromise = new Promise<void>((resolve) => {
firstSaveStarted = resolve;
});
const firstSaveGate = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
archiveDbAdapterMock.loadArchiveYoung.and.callFake(async () => archiveYoung);
archiveDbAdapterMock.saveArchiveYoung.and.callFake(async (nextArchive) => {
if (archiveDbAdapterMock.saveArchiveYoung.calls.count() === 1) {
firstSaveStarted?.();
await firstSaveGate;
}
archiveYoung = nextArchive;
});
const updatePromise = service.updateTask('existing', { title: 'Updated' });
await firstSaveStartedPromise;
const archivePromise = archiveService.moveTasksToArchiveAndFlushArchiveIfDue([
archivedTask,
]);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(archiveDbAdapterMock.loadArchiveYoung).toHaveBeenCalledTimes(1);
releaseFirstSave?.();
await Promise.all([updatePromise, archivePromise]);
expect(archiveYoung.task.entities['existing']!.title).toBe('Updated');
expect(archiveYoung.task.entities['new-task']).toBeDefined();
});
});
describe('loadYoung', () => {
@ -575,17 +652,17 @@ describe('TaskArchiveService', () => {
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
// Mock the updateTasks method to track calls
spyOn(service, 'updateTasks').and.returnValue(Promise.resolve());
// Track the internal (already-locked) update path
spyOn(service as any, '_updateTasks').and.returnValue(Promise.resolve());
await service.removeRepeatCfgFromArchiveTasks('repeat1');
expect(service.updateTasks).toHaveBeenCalledWith(
expect((service as any)._updateTasks).toHaveBeenCalledWith(
[
{ id: 'task1', changes: { repeatCfgId: undefined } },
{ id: 'task2', changes: { repeatCfgId: undefined } },
],
{ isSkipDispatch: true, isIgnoreDBLock: undefined },
{ isSkipDispatch: true },
);
});
@ -600,11 +677,11 @@ describe('TaskArchiveService', () => {
Promise.resolve(createMockArchiveModel([])),
);
spyOn(service, 'updateTasks').and.returnValue(Promise.resolve());
spyOn(service as any, '_updateTasks').and.returnValue(Promise.resolve());
await service.removeRepeatCfgFromArchiveTasks('repeat1');
expect(service.updateTasks).not.toHaveBeenCalled();
expect((service as any)._updateTasks).not.toHaveBeenCalled();
});
});
@ -634,11 +711,11 @@ describe('TaskArchiveService', () => {
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
spyOn(service, 'updateTasks').and.returnValue(Promise.resolve());
spyOn(service as any, '_updateTasks').and.returnValue(Promise.resolve());
await service.unlinkIssueProviderFromArchiveTasks('provider1');
expect(service.updateTasks).toHaveBeenCalledWith(
expect((service as any)._updateTasks).toHaveBeenCalledWith(
[
{
id: 'task1',
@ -667,7 +744,7 @@ describe('TaskArchiveService', () => {
},
},
],
{ isSkipDispatch: true, isIgnoreDBLock: undefined },
{ isSkipDispatch: true },
);
});
@ -682,11 +759,11 @@ describe('TaskArchiveService', () => {
Promise.resolve(createMockArchiveModel([])),
);
spyOn(service, 'updateTasks').and.returnValue(Promise.resolve());
spyOn(service as any, '_updateTasks').and.returnValue(Promise.resolve());
await service.unlinkIssueProviderFromArchiveTasks('provider1');
expect(service.updateTasks).not.toHaveBeenCalled();
expect((service as any)._updateTasks).not.toHaveBeenCalled();
});
});
@ -708,11 +785,11 @@ describe('TaskArchiveService', () => {
};
spyOn(service, 'load').and.returnValue(Promise.resolve(mockArchive));
spyOn(service, 'deleteTasks').and.returnValue(Promise.resolve());
spyOn(service as any, '_deleteTasks').and.returnValue(Promise.resolve());
await service.removeAllArchiveTasksForProject('project1');
expect(service.deleteTasks).toHaveBeenCalledWith(['task1', 'task2'], undefined);
expect((service as any)._deleteTasks).toHaveBeenCalledWith(['task1', 'task2']);
});
});
@ -822,8 +899,8 @@ describe('TaskArchiveService', () => {
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
// Spy on deleteTasks to verify it's called with the right parameters
spyOn(service, 'deleteTasks').and.returnValue(Promise.resolve());
// Spy on the internal (already-locked) delete path
spyOn(service as any, '_deleteTasks').and.returnValue(Promise.resolve());
await service.removeTagsFromAllTasks(['tag1']);
@ -832,10 +909,11 @@ describe('TaskArchiveService', () => {
// parent1 has no tags and no project after removal, so it and its subtasks are orphaned
// task1 still has other tags, so it's not orphaned
// task3 has a project, so it's not orphaned
expect(service.deleteTasks).toHaveBeenCalledWith(
['task2', 'parent1', 'sub1'],
undefined,
);
expect((service as any)._deleteTasks).toHaveBeenCalledWith([
'task2',
'parent1',
'sub1',
]);
});
});

View file

@ -17,6 +17,8 @@ import { TAG_FEATURE_NAME, tagAdapter } from '../tag/store/tag.reducer';
import { WORK_CONTEXT_FEATURE_NAME } from '../work-context/store/work-context.selectors';
import { plannerFeatureKey } from '../planner/store/planner.reducer';
import { TODAY_TAG } from '../tag/tag.const';
import { LockService } from '../../op-log/sync/lock.service';
import { LOCK_NAMES } from '../../op-log/core/operation-log.const';
// Normalize timeSpentOnDay at the data boundary so all consumers can trust the
// invariant: timeSpentOnDay is always a valid object, never undefined. This mirrors
@ -74,6 +76,7 @@ type TaskArchiveAction =
})
export class TaskArchiveService {
private _injector = inject(Injector);
private readonly _lockService = inject(LockService);
private _archiveDbAdapter?: ArchiveDbAdapter;
private get archiveDbAdapter(): ArchiveDbAdapter {
if (!this._archiveDbAdapter) {
@ -106,6 +109,24 @@ export class TaskArchiveService {
constructor() {}
/**
* Every mutation in THIS service is serialized behind the cross-tab
* TASK_ARCHIVE lock including remote sync side effects: TASK_ARCHIVE is
* deliberately a separate lock from OPERATION_LOG, so acquiring it while
* sync holds the op-log lock is safe (and the remote moveToArchive path
* already does). The legacy `isIgnoreDBLock` option no longer bypasses this
* mutex; a bypassed remote mutation could interleave with a locked local one
* and silently drop one side's archive write.
*
* Known paths still OUTSIDE the lock (tracked in #8941):
* ArchiveCompressionService.compressArchive, the remote loadAllData archive
* import (holds no lock across its user-confirmation guard by design), and
* TimeTrackingService's project/tag archive cleanups.
*/
private _runTaskArchiveMutation(mutation: () => Promise<void>): Promise<void> {
return this._lockService.request(LOCK_NAMES.TASK_ARCHIVE, mutation);
}
async loadYoung(): Promise<TaskArchive> {
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
@ -230,10 +251,14 @@ export class TaskArchiveService {
return result;
}
async deleteTasks(
deleteTasks(
taskIdsToDelete: string[],
options?: { isIgnoreDBLock?: boolean },
): Promise<void> {
return this._runTaskArchiveMutation(() => this._deleteTasks(taskIdsToDelete));
}
private async _deleteTasks(taskIdsToDelete: string[]): Promise<void> {
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
const toDeleteInArchiveYoung = taskIdsToDelete.filter(
@ -272,7 +297,17 @@ export class TaskArchiveService {
}
}
async updateTask(
updateTask(
id: string,
changedFields: Partial<Task>,
options?: { isSkipDispatch?: boolean; isIgnoreDBLock?: boolean },
): Promise<void> {
return this._runTaskArchiveMutation(() =>
this._updateTask(id, changedFields, options),
);
}
private async _updateTask(
id: string,
changedFields: Partial<Task>,
options?: { isSkipDispatch?: boolean; isIgnoreDBLock?: boolean },
@ -311,7 +346,14 @@ export class TaskArchiveService {
throw new Error('Archive task to update not found');
}
async updateTasks(
updateTasks(
updates: Update<Task>[],
options?: { isSkipDispatch?: boolean; isIgnoreDBLock?: boolean },
): Promise<void> {
return this._runTaskArchiveMutation(() => this._updateTasks(updates, options));
}
private async _updateTasks(
updates: Update<Task>[],
options?: { isSkipDispatch?: boolean; isIgnoreDBLock?: boolean },
): Promise<void> {
@ -364,9 +406,17 @@ export class TaskArchiveService {
}
// -----------------------------------------
async removeAllArchiveTasksForProject(
removeAllArchiveTasksForProject(
projectIdToDelete: string,
options?: { isIgnoreDBLock?: boolean },
): Promise<void> {
return this._runTaskArchiveMutation(() =>
this._removeAllArchiveTasksForProject(projectIdToDelete),
);
}
private async _removeAllArchiveTasksForProject(
projectIdToDelete: string,
): Promise<void> {
const taskArchiveState: TaskArchive = await this.load();
const archiveTaskIdsToDelete = !!taskArchiveState
@ -378,13 +428,19 @@ export class TaskArchiveService {
return t.projectId === projectIdToDelete;
})
: [];
await this.deleteTasks(archiveTaskIdsToDelete, options);
await this._deleteTasks(archiveTaskIdsToDelete);
}
async removeTagsFromAllTasks(
removeTagsFromAllTasks(
tagIdsToRemove: string[],
options?: { isIgnoreDBLock?: boolean },
): Promise<void> {
return this._runTaskArchiveMutation(() =>
this._removeTagsFromAllTasks(tagIdsToRemove),
);
}
private async _removeTagsFromAllTasks(tagIdsToRemove: string[]): Promise<void> {
const taskArchiveState: TaskArchive = await this.load();
await this._execActionBoth(
TaskSharedActions.removeTagsForAllTasks({ tagIdsToRemove }),
@ -406,16 +462,22 @@ export class TaskArchiveService {
}
});
// TODO check to maybe update to today tag instead
await this.deleteTasks(
[...archiveMainTaskIdsToDelete, ...archiveSubTaskIdsToDelete],
options,
);
await this._deleteTasks([
...archiveMainTaskIdsToDelete,
...archiveSubTaskIdsToDelete,
]);
}
async removeRepeatCfgFromArchiveTasks(
removeRepeatCfgFromArchiveTasks(
repeatConfigId: string,
options?: { isIgnoreDBLock?: boolean },
): Promise<void> {
return this._runTaskArchiveMutation(() =>
this._removeRepeatCfgFromArchiveTasks(repeatConfigId),
);
}
private async _removeRepeatCfgFromArchiveTasks(repeatConfigId: string): Promise<void> {
const taskArchive = await this.load();
const newState = { ...taskArchive };
@ -435,16 +497,23 @@ export class TaskArchiveService {
},
};
});
await this.updateTasks(updates, {
await this._updateTasks(updates, {
isSkipDispatch: true,
isIgnoreDBLock: options?.isIgnoreDBLock,
});
}
}
async unlinkIssueProviderFromArchiveTasks(
unlinkIssueProviderFromArchiveTasks(
issueProviderId: string,
options?: { isIgnoreDBLock?: boolean },
): Promise<void> {
return this._runTaskArchiveMutation(() =>
this._unlinkIssueProviderFromArchiveTasks(issueProviderId),
);
}
private async _unlinkIssueProviderFromArchiveTasks(
issueProviderId: string,
): Promise<void> {
const taskArchive = await this.load();
@ -466,14 +535,23 @@ export class TaskArchiveService {
issuePoints: undefined,
},
}));
await this.updateTasks(updates, {
await this._updateTasks(updates, {
isSkipDispatch: true,
isIgnoreDBLock: options?.isIgnoreDBLock,
});
}
}
async roundTimeSpent({
roundTimeSpent(params: {
day: string;
taskIds: string[];
roundTo: RoundTimeOption;
isRoundUp: boolean;
projectId?: string | null;
}): Promise<void> {
return this._runTaskArchiveMutation(() => this._roundTimeSpent(params));
}
private async _roundTimeSpent({
day,
taskIds,
roundTo,

View file

@ -457,6 +457,72 @@ describe('TaskService', () => {
});
describe('moveToArchive', () => {
it('should persist parent tasks before dispatching the archive action', async () => {
const task = createMockTaskWithSubTasks(createMockTask('task-1'));
const callOrder: string[] = [];
archiveService.moveTasksToArchiveAndFlushArchiveIfDue.and.callFake(async () => {
callOrder.push('persist-archive');
});
store.dispatch = jasmine
.createSpy('dispatch')
.and.callFake(() => callOrder.push('dispatch'));
await service.moveToArchive(task);
expect(callOrder).toEqual(['persist-archive', 'dispatch']);
});
it('should coalesce concurrent archive requests for the same task', async () => {
const task = createMockTaskWithSubTasks(createMockTask('task-1'));
let finishPersistence: (() => void) | undefined;
archiveService.moveTasksToArchiveAndFlushArchiveIfDue.and.returnValue(
new Promise<void>((resolve) => {
finishPersistence = resolve;
}),
);
const first = service.moveToArchive(task);
const duplicate = service.moveToArchive(task);
let duplicateResolved = false;
void duplicate.then(() => {
duplicateResolved = true;
});
expect(archiveService.moveTasksToArchiveAndFlushArchiveIfDue).toHaveBeenCalledTimes(
1,
);
expect(store.dispatch).not.toHaveBeenCalledWith(
jasmine.objectContaining({ type: TaskSharedActions.moveToArchive.type }),
);
await Promise.resolve();
expect(duplicateResolved).toBeFalse();
finishPersistence?.();
await Promise.all([first, duplicate]);
expect(duplicateResolved).toBeTrue();
expect(store.dispatch).toHaveBeenCalledTimes(1);
});
it('should release the in-flight archive guard after persistence fails', async () => {
const task = createMockTaskWithSubTasks(createMockTask('task-1'));
archiveService.moveTasksToArchiveAndFlushArchiveIfDue.and.rejectWith(
new Error('archive write failed'),
);
await expectAsync(service.moveToArchive(task)).toBeRejectedWithError(
'archive write failed',
);
archiveService.moveTasksToArchiveAndFlushArchiveIfDue.and.resolveTo();
await service.moveToArchive(task);
expect(archiveService.moveTasksToArchiveAndFlushArchiveIfDue).toHaveBeenCalledTimes(
2,
);
expect(store.dispatch).toHaveBeenCalledTimes(1);
});
it('should dispatch moveToArchive and call archive service for parent tasks', async () => {
const task = createMockTaskWithSubTasks(
createMockTask('task-1', { projectId: 'test-project' }),

View file

@ -123,6 +123,7 @@ export class TaskService {
private readonly _taskFocusService = inject(TaskFocusService);
private readonly _deletedTaskIssueSidecar = inject(DeletedTaskIssueSidecarService);
private readonly _timeBlockDeleteSidecar = inject(TimeBlockDeleteSidecarService);
private readonly _archiveTaskPromisesById = new Map<string, Promise<void>>();
currentTaskId$: Observable<string | null> = this._store.pipe(
select(selectCurrentTaskId),
@ -965,15 +966,60 @@ export class TaskService {
}
}
if (parentTasks.length) {
TaskLog.log('[TaskService] Dispatching moveToArchive action for parent tasks');
const parentTasksToArchive: TaskWithSubTasks[] = [];
const reservedTaskIds = new Set<string>();
const existingArchivePromises = new Set<Promise<void>>();
for (const task of parentTasks) {
if (task.id) {
const existingArchivePromise = this._archiveTaskPromisesById.get(task.id);
if (existingArchivePromise) {
TaskLog.log('[TaskService] Archive already in progress', { id: task.id });
existingArchivePromises.add(existingArchivePromise);
continue;
}
if (reservedTaskIds.has(task.id)) {
continue;
}
reservedTaskIds.add(task.id);
}
parentTasksToArchive.push(task);
}
if (parentTasksToArchive.length) {
// Only move parent tasks to archive, never subtasks
// Note: Full task payload required for sync - see docs/archive-operation-redesign.md
this._store.dispatch(TaskSharedActions.moveToArchive({ tasks: parentTasks }));
// Only archive parent tasks to prevent orphaned subtasks
TaskLog.log('[TaskService] Calling archive service to persist tasks');
await this._archiveService.moveTasksToArchiveAndFlushArchiveIfDue(parentTasks);
TaskLog.log('[TaskService] Archive operation completed successfully');
// Persist first: dispatch removes the tasks from NgRx and makes the captured
// operation eligible for a full-state snapshot. If archive persistence were
// still in flight, that snapshot could acknowledge the operation while
// omitting its archived task data.
const archivePromise = (async (): Promise<void> => {
TaskLog.log('[TaskService] Calling archive service to persist tasks');
await this._archiveService.moveTasksToArchiveAndFlushArchiveIfDue(
parentTasksToArchive,
);
TaskLog.log('[TaskService] Dispatching moveToArchive action for parent tasks');
this._store.dispatch(
TaskSharedActions.moveToArchive({ tasks: parentTasksToArchive }),
);
TaskLog.log('[TaskService] Archive operation completed successfully');
})();
for (const taskId of reservedTaskIds) {
this._archiveTaskPromisesById.set(taskId, archivePromise);
}
try {
await Promise.all([...existingArchivePromises, archivePromise]);
} finally {
for (const taskId of reservedTaskIds) {
if (this._archiveTaskPromisesById.get(taskId) === archivePromise) {
this._archiveTaskPromisesById.delete(taskId);
}
}
}
} else if (existingArchivePromises.size > 0) {
// A duplicate caller observes the same success/failure and does not return
// before the durable archive write plus NgRx removal have completed.
await Promise.all(existingArchivePromises);
} else {
TaskLog.log('[TaskService] No parent tasks to archive');
}

View file

@ -38,6 +38,7 @@ import {
UploadRevToMatchMismatchAPIError,
WebDavNativeRequestError,
EncryptNoPasswordError,
IncompleteRemoteOperationsError,
} from '../../op-log/core/errors/sync-errors';
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component';
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';
@ -158,7 +159,11 @@ describe('SyncWrapperService', () => {
cfg$: configSubject.asObservable(),
});
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockSnackService = jasmine.createSpyObj('SnackService', [
'open',
'hasPendingPersistentAction',
]);
mockSnackService.hasPendingPersistentAction.and.returnValue(false);
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open'], {
openDialogs: [],
});
@ -584,6 +589,19 @@ describe('SyncWrapperService', () => {
expect(mockProviderManager.setLastSyncedProviderId).not.toHaveBeenCalled();
});
it('should stop with ERROR when download is blocked by an incompatible op', async () => {
mockSyncService.downloadRemoteOps.and.resolveTo({
kind: 'blocked_incompatible',
});
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockProviderManager.setLastSyncedProviderId).not.toHaveBeenCalled();
expect(mockSyncService.uploadPendingOps).not.toHaveBeenCalled();
});
});
describe('_sync() - Sync flow', () => {
@ -1001,6 +1019,17 @@ describe('SyncWrapperService', () => {
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
});
it('should stop with ERROR when upload piggyback is blocked by an incompatible op', async () => {
mockSyncService.uploadPendingOps.and.resolveTo({
kind: 'blocked_incompatible',
});
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
});
it('should set IN_SYNC when permanentRejectionCount is 0 even with empty rejectedOps array', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
@ -1022,6 +1051,35 @@ describe('SyncWrapperService', () => {
});
describe('Error handling', () => {
it('should surface incomplete remote application as a sticky translated error', async () => {
mockSyncService.downloadRemoteOps.and.rejectWith(
new IncompleteRemoteOperationsError(),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSnackService.open).toHaveBeenCalledWith({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
});
it('should preserve an existing persistent recovery action for incomplete remote work', async () => {
mockSnackService.hasPendingPersistentAction.and.returnValue(true);
mockSyncService.downloadRemoteOps.and.rejectWith(
new IncompleteRemoteOperationsError(new Error('archive failed')),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSnackService.open).not.toHaveBeenCalled();
});
it('should handle PotentialCorsError with snack message', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new PotentialCorsError('https://example.com')),
@ -1844,6 +1902,19 @@ describe('SyncWrapperService', () => {
);
});
it('should preserve a persistent recovery action when sync rethrows', async () => {
mockSnackService.hasPendingPersistentAction.and.returnValue(true);
mockSyncService.downloadRemoteOps.and.rejectWith(
new Error('Interrupted rebuild could not resume'),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSnackService.open).not.toHaveBeenCalled();
});
it('should treat UploadRevToMatchMismatchAPIError as transient: set UNKNOWN_OR_CHANGED, no error snackbar', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.reject(

View file

@ -21,6 +21,7 @@ import {
EmptyRemoteBodySPError,
JsonParseError,
LegacySyncFormatDetectedError,
IncompleteRemoteOperationsError,
SyncDataCorruptedError,
UploadRevToMatchMismatchAPIError,
} from '../../op-log/core/errors/sync-errors';
@ -507,6 +508,13 @@ export class SyncWrapperService {
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
}
if (downloadResult.kind === 'blocked_incompatible') {
SyncLog.warn(
'SyncWrapperService: Download blocked by an incompatible operation.',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
// Track the successfully synced provider for switch detection on next sync
this._providerManager.setLastSyncedProviderId(providerId);
@ -516,6 +524,13 @@ export class SyncWrapperService {
syncCapableProvider,
{ isNeverSynced: isNeverSyncedAtSyncStart },
);
if (uploadResult.kind === 'blocked_incompatible') {
SyncLog.warn(
'SyncWrapperService: Upload piggyback blocked by an incompatible operation.',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
const completedUploadResults: CompletedUploadOutcome[] =
uploadResult.kind === 'completed' ? [uploadResult] : [];
if (uploadResult.kind === 'completed') {
@ -583,6 +598,13 @@ export class SyncWrapperService {
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
}
if (reuploadResult.kind === 'blocked_incompatible') {
SyncLog.warn(
'SyncWrapperService: LWW re-upload blocked by an incompatible operation.',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
if (reuploadResult.kind === 'completed') {
completedUploadResults.push(reuploadResult);
}
@ -687,6 +709,16 @@ export class SyncWrapperService {
config: { duration: 12000 },
});
return 'HANDLED_ERROR';
} else if (error instanceof IncompleteRemoteOperationsError) {
this._providerManager.setSyncStatus('ERROR');
if (!this._snackService.hasPendingPersistentAction()) {
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
}
return 'HANDLED_ERROR';
} else if (
error instanceof AuthFailSPError ||
error instanceof MissingRefreshTokenAPIError ||
@ -931,14 +963,20 @@ export class SyncWrapperService {
} else {
this._providerManager.setSyncStatus('ERROR');
const errStr = getSyncErrorStr(error);
this._snackService.open({
// msg: T.F.SYNC.S.UNKNOWN_ERROR,
msg: errStr,
type: 'ERROR',
translateParams: {
err: errStr,
},
});
// A lower-level recovery path may already have shown a sticky action
// (for example Undo after an interrupted remote-state rebuild). Snack
// rendering is debounced, so opening the generic error here would win
// the race and silently remove the only recovery action.
if (!this._snackService.hasPendingPersistentAction()) {
this._snackService.open({
// msg: T.F.SYNC.S.UNKNOWN_ERROR,
msg: errStr,
type: 'ERROR',
translateParams: {
err: errStr,
},
});
}
return 'HANDLED_ERROR';
}
}

View file

@ -22,6 +22,8 @@ import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { ArchiveModel } from '../../features/archive/archive.model';
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
import { OpType } from '../core/operation.types';
import { LockService } from '../sync/lock.service';
import { LOCK_NAMES } from '../core/operation-log.const';
import { confirmDialog } from '../../util/native-dialogs';
/**
@ -36,6 +38,15 @@ const createEmptyArchiveModel = (): ArchiveModel => ({
/**
* Action types that affect archive storage and require special handling.
*
* INVARIANT: every archive side effect for these actions must be idempotent
* even when it already fully succeeded once. Crash recovery marks interrupted
* remote ops `archive_pending` (OperationLogRecoveryService) and the hydrator
* retry re-runs their archive work with `skipReducerDispatch` the crash
* window is between archive completion and `markApplied()`, so a re-run can hit
* an archive that is already up to date. Use id-keyed writes / overwrites, never
* additive merges (e.g. `archiveTT[...] = value`, not `+=`); an additive path
* here would silently double-count time-tracking / counter deltas on this path.
*/
const ARCHIVE_AFFECTING_ACTION_TYPES: string[] = [
TaskSharedActions.moveToArchive.type,
@ -95,7 +106,7 @@ export const isArchiveAffectingAction = (action: Action): action is PersistentAc
*
* ## Important Notes
*
* - For remote operations, uses `isIgnoreDBLock: true` because sync processing has the DB locked
* - Remote archive mutations serialize behind the TASK_ARCHIVE mutex (separate from the OPERATION_LOG lock sync holds)
* - All operations are idempotent - safe to run multiple times
* - Use `isArchiveAffectingAction()` helper to check if an action needs archive handling
*/
@ -119,6 +130,7 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort<Persistent
// ═══════════════════════════════════════════════════════════════════════════
private _injector = inject(Injector);
private _archiveDbAdapter = inject(ArchiveDbAdapter);
private _lockService = inject(LockService);
private _getArchiveService = lazyInject(this._injector, ArchiveService);
private _getTaskArchiveService = lazyInject(this._injector, TaskArchiveService);
private _getTimeTrackingService = lazyInject(this._injector, TimeTrackingService);
@ -130,9 +142,10 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort<Persistent
/**
* Process an action and handle any archive-related side effects.
*
* This method handles both local and remote operations. For remote operations
* (action.meta.isRemote === true), it uses isIgnoreDBLock: true because sync
* processing has the database locked.
* This method handles both local and remote operations. Remote operations
* run while sync holds the OPERATION_LOG lock; archive mutations additionally
* serialize behind the separate TASK_ARCHIVE mutex (the legacy
* isIgnoreDBLock option no longer bypasses it).
*
* @param action The action that was dispatched
* @returns Promise that resolves when archive operations are complete
@ -208,7 +221,7 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort<Persistent
* Removes a restored task from archive storage.
*
* @localBehavior Executes normally (acquires DB lock)
* @remoteBehavior Executes with isIgnoreDBLock (sync has DB locked)
* @remoteBehavior Executes under the TASK_ARCHIVE mutex
*/
private async _handleRestoreTask(action: PersistentAction): Promise<void> {
const task = (action as ReturnType<typeof TaskSharedActions.restoreTask>).task;
@ -304,27 +317,33 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort<Persistent
const timestamp = (action as ReturnType<typeof flushYoungToOld>).timestamp;
// Load current state using ArchiveDbAdapter
// Default to empty archives if they don't exist (first-time usage)
const currentArchiveYoung =
(await this._archiveDbAdapter.loadArchiveYoung()) ?? createEmptyArchiveModel();
const currentArchiveOld =
(await this._archiveDbAdapter.loadArchiveOld()) ?? createEmptyArchiveModel();
// Serialize against local archive mutations: this is a read-modify-write
// over BOTH archives, so an unlocked run could silently drop a concurrent
// locked local write. TASK_ARCHIVE is separate from OPERATION_LOG, so
// acquiring it while sync holds the op-log lock is safe.
await this._lockService.request(LOCK_NAMES.TASK_ARCHIVE, async () => {
// Load current state using ArchiveDbAdapter
// Default to empty archives if they don't exist (first-time usage)
const currentArchiveYoung =
(await this._archiveDbAdapter.loadArchiveYoung()) ?? createEmptyArchiveModel();
const currentArchiveOld =
(await this._archiveDbAdapter.loadArchiveOld()) ?? createEmptyArchiveModel();
const newSorted = sortTimeTrackingAndTasksFromArchiveYoungToOld({
archiveYoung: currentArchiveYoung,
archiveOld: currentArchiveOld,
threshold: ARCHIVE_TASK_YOUNG_TO_OLD_THRESHOLD,
now: timestamp,
const newSorted = sortTimeTrackingAndTasksFromArchiveYoungToOld({
archiveYoung: currentArchiveYoung,
archiveOld: currentArchiveOld,
threshold: ARCHIVE_TASK_YOUNG_TO_OLD_THRESHOLD,
now: timestamp,
});
// Atomic write: both archives written in a single IndexedDB transaction.
// If either write fails, the entire transaction is rolled back automatically.
await this._archiveDbAdapter.saveArchivesAtomic(
{ ...newSorted.archiveYoung, lastTimeTrackingFlush: timestamp },
{ ...newSorted.archiveOld, lastTimeTrackingFlush: timestamp },
);
});
// Atomic write: both archives written in a single IndexedDB transaction.
// If either write fails, the entire transaction is rolled back automatically.
await this._archiveDbAdapter.saveArchivesAtomic(
{ ...newSorted.archiveYoung, lastTimeTrackingFlush: timestamp },
{ ...newSorted.archiveOld, lastTimeTrackingFlush: timestamp },
);
OpLog.log(
'______________________\nFLUSHED ALL FROM ARCHIVE YOUNG TO OLD (via remote op handler)\n_______________________',
);
@ -354,7 +373,7 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort<Persistent
* Removes all archived tasks for a deleted project.
*
* @localBehavior Executes (cleans up archive for deleted project)
* @remoteBehavior Executes with isIgnoreDBLock (sync has DB locked)
* @remoteBehavior Executes under the TASK_ARCHIVE mutex
*/
private async _handleDeleteProject(action: PersistentAction): Promise<void> {
const projectId = (action as ReturnType<typeof TaskSharedActions.deleteProject>)
@ -371,7 +390,7 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort<Persistent
* Removes tag references from archived tasks and deletes orphaned tasks.
*
* @localBehavior Executes (cleans up archive for deleted tags)
* @remoteBehavior Executes with isIgnoreDBLock (sync has DB locked)
* @remoteBehavior Executes under the TASK_ARCHIVE mutex
*/
private async _handleDeleteTags(action: PersistentAction): Promise<void> {
const tagIdsToRemove =
@ -394,7 +413,7 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort<Persistent
* Removes repeatCfgId from archived tasks.
*
* @localBehavior Executes (cleans up archive for deleted repeat config)
* @remoteBehavior Executes with isIgnoreDBLock (sync has DB locked)
* @remoteBehavior Executes under the TASK_ARCHIVE mutex
*/
private async _handleDeleteTaskRepeatCfg(action: PersistentAction): Promise<void> {
const repeatCfgId = (
@ -411,7 +430,7 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort<Persistent
* Unlinks issue data from archived tasks for a deleted issue provider.
*
* @localBehavior Executes (cleans up archive for deleted provider)
* @remoteBehavior Executes with isIgnoreDBLock (sync has DB locked)
* @remoteBehavior Executes under the TASK_ARCHIVE mutex
*/
private async _handleDeleteIssueProvider(action: PersistentAction): Promise<void> {
const issueProviderId = (
@ -428,7 +447,7 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort<Persistent
* Unlinks issue data from archived tasks for multiple deleted issue providers.
*
* @localBehavior Executes (cleans up archive for deleted providers)
* @remoteBehavior Executes with isIgnoreDBLock (sync has DB locked)
* @remoteBehavior Executes under the TASK_ARCHIVE mutex
*/
private async _handleDeleteIssueProviders(action: PersistentAction): Promise<void> {
const ids = (action as ReturnType<typeof TaskSharedActions.deleteIssueProviders>).ids;

View file

@ -1,27 +0,0 @@
import { getFailedOpIdsFromBatch } from './failed-op-ids.util';
import { Operation } from '../core/operation.types';
const op = (id: string): Operation => ({ id }) as Operation;
describe('getFailedOpIdsFromBatch', () => {
it('returns the failed op and every op after it (slice-from-failure)', () => {
const ops = [op('a'), op('b'), op('c'), op('d')];
expect(getFailedOpIdsFromBatch(ops, op('b'))).toEqual(['b', 'c', 'd']);
});
it('returns just the last op when it is the one that failed', () => {
const ops = [op('a'), op('b'), op('c')];
expect(getFailedOpIdsFromBatch(ops, op('c'))).toEqual(['c']);
});
it('returns all ops when the first one failed', () => {
const ops = [op('a'), op('b'), op('c')];
expect(getFailedOpIdsFromBatch(ops, op('a'))).toEqual(['a', 'b', 'c']);
});
it('falls back to only the failed op when it is not in the batch (defensive -1 guard)', () => {
// Guards against slice(-1) wrongly selecting just the last op.
const ops = [op('a'), op('b'), op('c')];
expect(getFailedOpIdsFromBatch(ops, op('x'))).toEqual(['x']);
});
});

View file

@ -1,25 +0,0 @@
import { Operation } from '../core/operation.types';
/**
* Given the ops handed to a single `applyOperations()` batch in causal (seq)
* order and the op whose archive side effect threw, return the ids of every op
* that stays unapplied: the failed op plus everything after it.
*
* The batch applier (`replayOperationBatch`) stops at the first failing archive
* side effect, so ops after `failedOp` were never processed and must be retried
* together with it. This is the slice-from-failure handling shared by every
* caller that applies a batch and then marks the remainder failed (the remote
* apply path, conflict resolution, and the failed-op retry on hydration).
*
* The `-1` guard is defensive: `failedOp` always originates from `opsToApply`,
* but if it somehow isn't found we mark only the failed op rather than letting
* `slice(-1)` wrongly select just the last op.
*/
export const getFailedOpIdsFromBatch = (
opsToApply: Operation[],
failedOp: Operation,
): string[] => {
const failedIndex = opsToApply.findIndex((op) => op.id === failedOp.id);
const stillFailed = failedIndex === -1 ? [failedOp] : opsToApply.slice(failedIndex);
return stillFailed.map((op) => op.id);
};

View file

@ -554,7 +554,10 @@ describe('OperationApplierService', () => {
const result = await service.applyOperations(ops);
// Bulk dispatch succeeded (all ops applied to NgRx state)
// Bulk dispatch succeeded: the reducer effects of ALL FIVE ops are
// committed to NgRx state, including the "failed" ones — failedOp only
// means their archive side effects are outstanding. That is why retry
// paths must use skipReducerDispatch (see test below).
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
// But archive handling failed on op-3
@ -566,6 +569,44 @@ describe('OperationApplierService', () => {
// Archive handler was called 3 times (op-1, op-2, op-3)
expect(mockArchiveOperationHandler.handleOperation).toHaveBeenCalledTimes(3);
});
it('should not re-run reducers when retrying the failed slice with skipReducerDispatch', async () => {
const ops = [
createMockOperation('op-1', 'TASK', OpType.Update, { title: 'First' }),
createMockOperation('op-2', 'TASK', OpType.Update, { title: 'Second' }),
createMockOperation('op-3', 'TASK', OpType.Update, { title: 'Third' }),
createMockOperation('op-4', 'TASK', OpType.Update, { title: 'Fourth' }),
createMockOperation('op-5', 'TASK', OpType.Update, { title: 'Fifth' }),
];
let callCount = 0;
mockArchiveOperationHandler.handleOperation.and.callFake(() => {
callCount++;
return callCount === 3
? Promise.reject(new Error('Archive write failed on op-3'))
: Promise.resolve();
});
const firstPass = await service.applyOperations(ops);
expect(firstPass.failedOp!.op.id).toBe('op-3');
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
mockStore.dispatch.calls.reset();
mockArchiveOperationHandler.handleOperation.calls.reset();
mockArchiveOperationHandler.handleOperation.and.returnValue(Promise.resolve());
// Retry the failed slice the way retryFailedRemoteOps does: archive only.
const retry = await service.applyOperations(ops.slice(2), {
skipReducerDispatch: true,
});
// No reducer re-dispatch — additive reducers (e.g. syncTimeSpent,
// increaseSimpleCounterCounterToday) cannot double-apply on retry.
expect(mockStore.dispatch).not.toHaveBeenCalled();
expect(mockArchiveOperationHandler.handleOperation).toHaveBeenCalledTimes(3);
expect(retry.appliedOps).toEqual(ops.slice(2));
expect(retry.failedOp).toBeUndefined();
});
});
describe('effects isolation (key architectural benefit)', () => {

View file

@ -106,7 +106,10 @@ export class OperationApplierService implements OperationApplyPort<Operation> {
const result = await replayOperationBatch({
ops,
applyOptions: { isLocalHydration },
applyOptions: {
isLocalHydration,
skipReducerDispatch: options.skipReducerDispatch,
},
dispatcher: this.store,
createBulkApplyAction: (operations) =>
bulkApplyOperations({ operations, localClientId }),
@ -126,6 +129,7 @@ export class OperationApplierService implements OperationApplyPort<Operation> {
// this action is now disabled to prevent UI freezes during bulk archive sync.
this.store.dispatch(remoteArchiveDataApplied());
},
onReducersCommitted: options.onReducersCommitted,
onArchiveSideEffectError: ({ op, processedCount, error }) => {
OpLog.err(
`OperationApplierService: Failed archive handling for operation ${op.id}. ` +

View file

@ -21,6 +21,7 @@ describe('BackupService', () => {
let mockOperationWriteFlushService: jasmine.SpyObj<OperationWriteFlushService>;
let mockLockService: jasmine.SpyObj<LockService>;
let mockConflictJournal: jasmine.SpyObj<ConflictJournalService>;
const backupRef = { backupId: 'backup-123', savedAt: 123 };
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const createMinimalValidBackup = () => ({
@ -120,7 +121,7 @@ describe('BackupService', () => {
mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(
createMinimalValidBackup() as any,
);
mockOpLogStore.saveImportBackup.and.resolveTo(123);
mockOpLogStore.saveImportBackup.and.resolveTo(backupRef);
mockOpLogStore.loadImportBackup.and.resolveTo(null);
mockOpLogStore.clearImportBackup.and.resolveTo();
mockOpLogStore.runDestructiveStateReplacement.and.resolveTo();
@ -157,12 +158,13 @@ describe('BackupService', () => {
expect(mockOpLogStore.saveImportBackup).toHaveBeenCalledWith(snapshot);
});
it('should return the savedAt provenance token from the store', async () => {
mockOpLogStore.saveImportBackup.and.resolveTo(456);
it('should return the opaque backup reference from the store', async () => {
const expectedRef = { backupId: 'backup-456', savedAt: 456 };
mockOpLogStore.saveImportBackup.and.resolveTo(expectedRef);
const token = await service.captureImportBackup();
expect(token).toBe(456);
expect(token).toEqual(expectedRef);
});
it('should propagate errors so the caller can abort the destructive op', async () => {
@ -175,13 +177,20 @@ describe('BackupService', () => {
describe('restoreImportBackup (#8107)', () => {
it('should import the saved snapshot and return true when one exists', async () => {
const saved = createMinimalValidBackup();
mockOpLogStore.loadImportBackup.and.resolveTo({ state: saved, savedAt: 123 });
mockOpLogStore.loadImportBackup.and.resolveTo({ state: saved, ...backupRef });
const importSpy = spyOn(service, 'importCompleteBackup').and.resolveTo();
const result = await service.restoreImportBackup();
expect(result).toBe(true);
expect(importSpy).toHaveBeenCalledWith(saved as any, true, true, true);
expect(importSpy).toHaveBeenCalledWith(
saved as any,
true,
true,
true,
true,
backupRef.backupId,
);
});
it('should return false and not import when no backup exists', async () => {
@ -196,42 +205,83 @@ describe('BackupService', () => {
it('should clear the single-slot backup after a successful restore', async () => {
const saved = createMinimalValidBackup();
mockOpLogStore.loadImportBackup.and.resolveTo({ state: saved, savedAt: 123 });
mockOpLogStore.loadImportBackup.and.resolveTo({ state: saved, ...backupRef });
spyOn(service, 'importCompleteBackup').and.resolveTo();
await service.restoreImportBackup();
expect(mockOpLogStore.clearImportBackup).toHaveBeenCalled();
expect(mockOpLogStore.clearImportBackup).toHaveBeenCalledWith(backupRef.backupId);
});
it('should restore when the provenance token matches the stored backup', async () => {
const saved = createMinimalValidBackup();
mockOpLogStore.loadImportBackup.and.resolveTo({ state: saved, savedAt: 777 });
const expectedRef = { backupId: 'backup-777', savedAt: 777 };
mockOpLogStore.loadImportBackup.and.resolveTo({ state: saved, ...expectedRef });
const importSpy = spyOn(service, 'importCompleteBackup').and.resolveTo();
const result = await service.restoreImportBackup(777);
const result = await service.restoreImportBackup(expectedRef);
expect(result).toBe(true);
expect(importSpy).toHaveBeenCalledWith(saved as any, true, true, true);
expect(importSpy).toHaveBeenCalledWith(
saved as any,
true,
true,
true,
true,
expectedRef.backupId,
);
});
it('should refuse to restore (and not clear) when the backup was superseded since capture', async () => {
// The single slot is shared with the backup-import flow; an intervening
// write changes savedAt. Restoring it would silently roll back to the
// write changes backupId. Restoring it would silently roll back to the
// wrong snapshot, so we must refuse. (#8107)
const saved = createMinimalValidBackup();
mockOpLogStore.loadImportBackup.and.resolveTo({ state: saved, savedAt: 999 });
mockOpLogStore.loadImportBackup.and.resolveTo({
state: saved,
backupId: 'replacement-backup',
savedAt: 777,
});
const importSpy = spyOn(service, 'importCompleteBackup').and.resolveTo();
const result = await service.restoreImportBackup(777);
const result = await service.restoreImportBackup({
backupId: 'expected-backup',
savedAt: 777,
});
expect(result).toBe(false);
expect(importSpy).not.toHaveBeenCalled();
expect(mockOpLogStore.clearImportBackup).not.toHaveBeenCalled();
});
it('should keep the same backup usable when a restore attempt fails', async () => {
const saved = createMinimalValidBackup();
mockOpLogStore.loadImportBackup.and.resolveTo({ state: saved, ...backupRef });
const importSpy = spyOn(service, 'importCompleteBackup').and.rejectWith(
new Error('restore failed'),
);
await expectAsync(service.restoreImportBackup(backupRef)).toBeRejected();
expect(mockOpLogStore.clearImportBackup).not.toHaveBeenCalled();
importSpy.and.resolveTo();
await expectAsync(service.restoreImportBackup(backupRef)).toBeResolvedTo(true);
});
});
describe('importCompleteBackup', () => {
it('should reject inconsistent skip-backup provenance arguments', async () => {
const backup = createMinimalValidBackup() as any;
await expectAsync(
service.importCompleteBackup(backup, true, true, true, true),
).toBeRejectedWithError(/requires exactly one verified recovery backup ID/);
await expectAsync(
service.importCompleteBackup(backup, true, true, true, false, backupRef.backupId),
).toBeRejectedWithError(/requires exactly one verified recovery backup ID/);
expect(mockOpLogStore.runDestructiveStateReplacement).not.toHaveBeenCalled();
});
it('should dispatch loadAllData with the imported data', async () => {
const backupData = createMinimalValidBackup();
@ -486,6 +536,25 @@ describe('BackupService', () => {
expect(mockOpLogStore.saveImportBackup).toHaveBeenCalledWith(currentState);
});
it('should keep the recovery slot unchanged while restoring that backup', async () => {
await service.importCompleteBackup(
createMinimalValidBackup() as any,
true,
true,
true,
true,
backupRef.backupId,
);
expect(mockStateSnapshotService.getStateSnapshotAsync).not.toHaveBeenCalled();
expect(mockOpLogStore.saveImportBackup).not.toHaveBeenCalled();
expect(mockOpLogStore.runDestructiveStateReplacement).toHaveBeenCalled();
expect(
mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent().args[0]
.requiredImportBackupId,
).toBe(backupRef.backupId);
});
it('should pass snapshotEntityKeys derived from the imported data', async () => {
const backupData = createMinimalValidBackup();

View file

@ -2,7 +2,10 @@ import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { ImexViewService } from '../../imex/imex-meta/imex-view.service';
import { StateSnapshotService } from './state-snapshot.service';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import {
ImportBackupRef,
OperationLogStoreService,
} from '../persistence/operation-log-store.service';
import { generateClientId } from '../../core/util/generate-client-id';
import { Operation, OpType, ActionType } from '../core/operation.types';
import { CURRENT_SCHEMA_VERSION } from '../persistence/schema-migration.service';
@ -75,13 +78,24 @@ export class BackupService {
* @param isSkipLegacyWarnings - If true, skip legacy data format warnings
* @param isSkipReload - If true, don't reload the page after import
* @param isForceConflict - If true, reload page after import
* @param isSkipPreImportBackup - Keep an existing recovery backup in its
* single slot while restoring that exact backup.
* @param requiredImportBackupId - Abort the destructive commit unless this
* backup still occupies the single recovery slot.
*/
async importCompleteBackup(
data: AppDataComplete | CompleteBackup<AllModelConfig>,
isSkipLegacyWarnings: boolean = false,
isSkipReload: boolean = false,
isForceConflict: boolean = false,
isSkipPreImportBackup: boolean = false,
requiredImportBackupId?: string,
): Promise<void> {
if (isSkipPreImportBackup !== (requiredImportBackupId !== undefined)) {
throw new Error(
'BackupService: Skipping the pre-import backup requires exactly one verified recovery backup ID.',
);
}
try {
this._imexViewService.setDataImportInProgress(true);
@ -149,7 +163,11 @@ export class BackupService {
// 4. Persist to operation log
await this._operationWriteFlushService.flushPendingWrites();
await this._lockService.request(LOCK_NAMES.OPERATION_LOG, async () => {
await this._persistImportToOperationLog(validatedData);
await this._persistImportToOperationLog(
validatedData,
isSkipPreImportBackup,
requiredImportBackupId,
);
// 4b. The conflict journal is a device-local side store describing
// conflicts in the op history that was JUST replaced — every import
@ -193,11 +211,11 @@ export class BackupService {
*
* Mirrors the pre-import backup taken in `_persistImportToOperationLog`. Errors
* propagate so the caller can abort the destructive operation rather than wipe
* local data without a recovery point. Returns the backup's `savedAt` token so
* the caller can later verify the (single-slot) backup hasn't been replaced by
* an unrelated write before restoring it. (#8107)
* local data without a recovery point. Returns the backup's opaque ID plus its
* display timestamp so the caller can verify that the single slot has not been
* replaced by an unrelated write before restoring it. (#8107)
*/
async captureImportBackup(): Promise<number> {
async captureImportBackup(): Promise<ImportBackupRef> {
const currentState = await this._stateSnapshotService.getStateSnapshotAsync();
return this._opLogStore.saveImportBackup(currentState);
}
@ -207,22 +225,21 @@ export class BackupService {
* before a backup import) if one exists. Returns false when there is nothing
* to restore. Used by the post-replace "Undo" affordance.
*
* The backup state is read BEFORE `importCompleteBackup` runs (which itself
* re-snapshots the current state into the same slot), so the good state is
* already in hand and round-trips correctly.
* The backup state is read before `importCompleteBackup` runs. This recovery
* path skips the normal pre-import snapshot so the original remains durable
* until the destructive import has fully succeeded.
*
* @param expectedSavedAt - When provided, only restore if the stored backup
* still carries this `savedAt` token. The slot is shared with the backup-
* @param expectedBackup - When provided, only restore if the stored backup
* still carries this opaque backup ID. The slot is shared with the backup-
* import flow, so an intervening import (or a second "Use Server Data")
* would overwrite it; restoring that wrong snapshot is silent data loss, so
* we refuse instead. (#8107)
* would overwrite it; restoring that wrong snapshot is silent data loss.
*/
async restoreImportBackup(expectedSavedAt?: number): Promise<boolean> {
async restoreImportBackup(expectedBackup?: ImportBackupRef): Promise<boolean> {
const backup = await this._opLogStore.loadImportBackup();
if (!backup) {
return false;
}
if (expectedSavedAt !== undefined && backup.savedAt !== expectedSavedAt) {
if (expectedBackup && backup.backupId !== expectedBackup.backupId) {
OpLog.warn(
'BackupService: Import backup was superseded since capture; skipping restore to avoid restoring the wrong snapshot.',
);
@ -233,15 +250,20 @@ export class BackupService {
true, // isSkipLegacyWarnings
true, // isSkipReload - loadAllData updates state live
true, // isForceConflict
true, // keep this exact recovery backup until the full restore succeeds
backup.backupId,
);
// Restored data is now live; drop the (now stale) single-slot backup so a
// full copy of the replaced state doesn't linger in IndexedDB. (#8107)
await this._opLogStore.clearImportBackup();
// Retire the restored slot only if it still has the same opaque identity.
// The import path or another tab may have created a newer safety backup
// while the async restore ran; that newer backup must survive. (#8107)
await this._opLogStore.clearImportBackup(backup.backupId);
return true;
}
private async _persistImportToOperationLog(
importedData: AppDataComplete,
isSkipPreImportBackup: boolean,
requiredImportBackupId?: string,
): Promise<void> {
OpLog.normal('BackupService: Persisting import to operation log...');
@ -250,20 +272,22 @@ export class BackupService {
// after this method returns, so silently skipping the destructive write
// would leave the device in a hybrid state (imported NgRx/archives, old
// op-log) that is worse than either outcome.
try {
const currentState = await this._stateSnapshotService.getStateSnapshotAsync();
OpLog.normal('BackupService: Backing up current state before import...');
await this._opLogStore.saveImportBackup(currentState);
} catch (e) {
// `message` is intentionally omitted: log history is user-exportable
// (CLAUDE.md sync rule 9), and a future validator/IDB error type could
// interpolate user content into its message. Log the error `name` only.
OpLog.warn('BackupService: Failed to backup state before import:', {
name: (e as Error | undefined)?.name,
});
throw new Error(
'BackupService: Pre-import backup failed; aborting import to preserve local state.',
);
if (!isSkipPreImportBackup) {
try {
const currentState = await this._stateSnapshotService.getStateSnapshotAsync();
OpLog.normal('BackupService: Backing up current state before import...');
await this._opLogStore.saveImportBackup(currentState);
} catch (e) {
// `message` is intentionally omitted: log history is user-exportable
// (CLAUDE.md sync rule 9), and a future validator/IDB error type could
// interpolate user content into its message. Log the error `name` only.
OpLog.warn('BackupService: Failed to backup state before import:', {
name: (e as Error | undefined)?.name,
});
throw new Error(
'BackupService: Pre-import backup failed; aborting import to preserve local state.',
);
}
}
// Mint a fresh clientId for the new sync baseline. It is pure here —
@ -300,6 +324,7 @@ export class BackupService {
snapshotEntityKeys: extractEntityKeysFromState(importedData),
archiveYoung: importedData.archiveYoung,
archiveOld: importedData.archiveOld,
requiredImportBackupId,
});
OpLog.normal('BackupService: Import persisted to operation log.');

View file

@ -20,6 +20,7 @@ import { selectPluginMetadataFeatureState } from '../../plugins/store/plugin-met
import { selectReminderFeatureState } from '../../features/reminder/store/reminder.reducer';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
import { initialTimeTrackingState } from '../../features/time-tracking/store/time-tracking.reducer';
import { TaskState } from '../../features/tasks/task.model';
describe('StateSnapshotService', () => {
let service: StateSnapshotService;
@ -225,6 +226,32 @@ describe('StateSnapshotService', () => {
expect(archiveDbAdapterSpy.loadArchiveYoung).toHaveBeenCalledTimes(1);
expect(archiveDbAdapterSpy.loadArchiveOld).toHaveBeenCalledTimes(1);
});
it('should capture NgRx state before awaiting archive I/O', async () => {
let releaseArchives!: () => void;
const archiveGate = new Promise<void>((resolve) => {
releaseArchives = resolve;
});
archiveDbAdapterSpy.loadArchiveYoung.and.callFake(async () => {
await archiveGate;
return mockArchiveYoung;
});
archiveDbAdapterSpy.loadArchiveOld.and.callFake(async () => {
await archiveGate;
return mockArchiveOld;
});
const snapshotPromise = service.getStateSnapshotAsync();
store.overrideSelector(selectTaskFeatureState, {
...mockTaskState,
ids: ['later-task'],
} as unknown as TaskState);
store.refreshState();
releaseArchives();
const snapshot = await snapshotPromise;
expect((snapshot.task as TaskState).ids).toEqual(['task1']);
});
});
describe('backward compatibility aliases', () => {

View file

@ -1,6 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { Selector, Store } from '@ngrx/store';
import { combineLatest, firstValueFrom } from 'rxjs';
import { first } from 'rxjs/operators';
import { selectBoardsState } from '../../features/boards/store/boards.selectors';
@ -135,24 +134,18 @@ export class StateSnapshotService {
* Returns live store references never mutate (clone first). See class doc.
*/
async getStateSnapshotAsync(): Promise<AppStateSnapshot> {
// Capture NgRx synchronously before the first await. Callers that hold the
// operation-log barrier can now establish an exact reducer-state cutoff:
// actions dispatched while archive I/O is pending are not half-included in
// the snapshot and will be persisted after it.
const ngRxData = this._getNgRxDataSync();
const [archiveYoung, archiveOld] = await Promise.all([
this._loadArchive('archiveYoung'),
this._loadArchive('archiveOld'),
]);
const ngRxValues = await firstValueFrom(
combineLatest(SNAPSHOT_SELECTORS.map((s) => this._store.select(s.selector))).pipe(
first(),
),
);
const result: Partial<Record<NgRxModelKey, unknown>> = {};
for (let i = 0; i < SNAPSHOT_SELECTORS.length; i++) {
result[SNAPSHOT_SELECTORS[i].key] = ngRxValues[i];
}
return {
...this._normalizeNgRxData(result),
...ngRxData,
archiveYoung,
archiveOld,
};

View file

@ -7,6 +7,7 @@ import {
bufferDeferredAction,
getDeferredActions,
clearDeferredActions,
DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD,
} from './operation-capture.meta-reducer';
import { OperationCaptureService } from './operation-capture.service';
import { Action } from '@ngrx/store';
@ -247,7 +248,7 @@ describe('operationCaptureMetaReducer', () => {
expect(buffered).toEqual([]);
});
it('should clear buffer after returning actions', () => {
it('should return a non-destructive snapshot until actions are acknowledged', () => {
const action = createMockAction();
bufferDeferredAction(action);
@ -255,7 +256,7 @@ describe('operationCaptureMetaReducer', () => {
const secondCall = getDeferredActions();
expect(firstCall).toEqual([action]);
expect(secondCall).toEqual([]);
expect(secondCall).toEqual([action]);
});
});
@ -269,6 +270,103 @@ describe('operationCaptureMetaReducer', () => {
expect(getDeferredActions()).toEqual([]);
});
});
describe('buffer limits', () => {
// devError shows a native alert + confirm (and throws if confirm returns
// true), so force confirm to return false. src/test.ts installs a
// PERMANENT global confirm spy (jasmine.createSpy, never auto-restored),
// so reset its accumulated calls per test and restore the global
// returnValue(true) default afterwards.
const spyNativeDialogs = (): jasmine.Spy => {
if (!jasmine.isSpy(window.alert)) {
spyOn(window, 'alert');
}
const confirmSpy = jasmine.isSpy(window.confirm)
? (window.confirm as jasmine.Spy)
: spyOn(window, 'confirm');
confirmSpy.calls.reset();
confirmSpy.and.returnValue(false);
return confirmSpy;
};
afterEach(() => {
if (jasmine.isSpy(window.confirm)) {
(window.confirm as jasmine.Spy).and.returnValue(true);
}
});
const createManyActions = (count: number): PersistentAction[] =>
Array.from({ length: count }, (_, i) =>
createMockAction({ type: `[Test] Action ${i}` }),
);
it('should preserve ALL actions in order past the reload-warning threshold', () => {
spyNativeDialogs();
const actions = createManyActions(DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD + 50);
actions.forEach((a) => bufferDeferredAction(a));
// Nothing may be dropped: each buffered action's state change was
// already accepted into NgRx — dropping = permanent unsyncable divergence.
expect(getDeferredActions()).toEqual(actions);
});
it('should fire devError at the reload-warning threshold without dropping', () => {
const confirmSpy = spyNativeDialogs();
const actions = createManyActions(DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD);
const reloadWarningCalls = (): unknown[][] =>
confirmSpy.calls
.allArgs()
.filter((args) => /consider reloading/.test(String(args[0])));
actions.slice(0, -1).forEach((a) => bufferDeferredAction(a));
expect(reloadWarningCalls().length).toBe(0);
bufferDeferredAction(actions[actions.length - 1]);
expect(reloadWarningCalls().length).toBe(1);
expect(getDeferredActions()).toEqual(actions);
});
it('should fire the reload warning only once per stuck window (no per-action spam)', () => {
const confirmSpy = spyNativeDialogs();
const actions = createManyActions(
DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD + 200,
);
const reloadWarningCalls = (): unknown[][] =>
confirmSpy.calls
.allArgs()
.filter((args) => /consider reloading/.test(String(args[0])));
actions.forEach((a) => bufferDeferredAction(a));
// In dev builds devError opens a blocking dialog; firing it on every
// buffered action past the threshold would freeze the session exactly
// when sync is stuck.
expect(reloadWarningCalls().length).toBe(1);
expect(getDeferredActions()).toEqual(actions);
});
it('should warn again after the buffer drained and a new stuck window crosses the threshold', () => {
const confirmSpy = spyNativeDialogs();
const reloadWarningCalls = (): unknown[][] =>
confirmSpy.calls
.allArgs()
.filter((args) => /consider reloading/.test(String(args[0])));
createManyActions(DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD).forEach((a) =>
bufferDeferredAction(a),
);
expect(reloadWarningCalls().length).toBe(1);
clearDeferredActions();
createManyActions(DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD).forEach((a) =>
bufferDeferredAction(a),
);
expect(reloadWarningCalls().length).toBe(2);
});
});
});
describe('sync buffering (user interaction during sync)', () => {

View file

@ -133,40 +133,53 @@ export const getIsApplyingRemoteOps = (): boolean => {
};
/**
* Maximum number of deferred actions before warning.
* Soft-warning threshold for the deferred actions buffer.
* If exceeded, sync may be stuck or taking too long.
*/
const MAX_DEFERRED_ACTIONS_WARNING = 10;
const DEFERRED_ACTIONS_SOFT_WARNING_THRESHOLD = 10;
/**
* Hard limit for deferred actions buffer.
* If reached, oldest actions are dropped to prevent unbounded memory growth.
* Reload-warning threshold: a devError advises the user to reload, but
* NOTHING is dropped. Every buffered action's state change was already
* accepted into NgRx; dropping it would mean no operation ever represents
* it a permanent, unsyncable local divergence.
* Exported for tests.
*/
const MAX_DEFERRED_ACTIONS_HARD_LIMIT = 100;
export const DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD = 100;
/**
* Highest threshold already warned about in the current stuck window; each
* warning fires once per crossing instead of on every buffered action (in dev
* builds devError opens a blocking dialog, so per-action firing would freeze
* the session exactly when sync is stuck). Reset when the buffer drains.
*/
let highestWarnedThreshold = 0;
/**
* Buffers an action for processing after sync completes.
* Called by the meta-reducer when a persistent action arrives during sync.
*
* Never drops an accepted persistent action: its reducer mutation already
* exists in NgRx, so removal here would create permanent unsyncable state.
*/
export const bufferDeferredAction = (action: PersistentAction): void => {
// Hard limit: drop oldest action if buffer is full (sync stuck scenario).
// NOTE: The shifted action remains in deferredActionSet (WeakSet has no delete-by-value).
// The effect filters it via isDeferredAction(), and getDeferredActions() won't return it,
// so it is silently lost. This is acceptable: the hard limit is itself an error condition
// (sync stuck), and dropping the oldest action is the lesser evil vs unbounded growth.
if (deferredActions.length >= MAX_DEFERRED_ACTIONS_HARD_LIMIT) {
devError(
`[operationCaptureMetaReducer] Deferred actions buffer exceeded ${MAX_DEFERRED_ACTIONS_HARD_LIMIT} items. ` +
`Dropping oldest action. Sync may be stuck - consider reloading the app.`,
);
deferredActions.shift();
}
deferredActions.push(action);
deferredActionSet.add(action);
// Soft warning at 10 items
if (deferredActions.length > MAX_DEFERRED_ACTIONS_WARNING) {
if (
deferredActions.length >= DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD &&
highestWarnedThreshold < DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD
) {
highestWarnedThreshold = DEFERRED_ACTIONS_RELOAD_WARNING_THRESHOLD;
devError(
`[operationCaptureMetaReducer] Deferred actions buffer has ${deferredActions.length} items. ` +
`Sync may be stuck - consider reloading the app. Nothing is dropped; actions remain buffered.`,
);
} else if (
deferredActions.length > DEFERRED_ACTIONS_SOFT_WARNING_THRESHOLD &&
highestWarnedThreshold < DEFERRED_ACTIONS_SOFT_WARNING_THRESHOLD
) {
highestWarnedThreshold = DEFERRED_ACTIONS_SOFT_WARNING_THRESHOLD;
devError(
`[operationCaptureMetaReducer] Deferred actions buffer has ${deferredActions.length} items - sync may be stuck or taking too long`,
);
@ -174,26 +187,37 @@ export const bufferDeferredAction = (action: PersistentAction): void => {
};
/**
* Gets and clears the deferred actions buffer.
* Called after sync completes to process buffered actions.
* Returns a stable snapshot of the deferred actions buffer. Entries remain queued
* until their operation is durably written and explicitly acknowledged.
*/
export const getDeferredActions = (): PersistentAction[] => {
const actions = deferredActions;
deferredActions = [];
return actions;
return [...deferredActions];
};
export const acknowledgeDeferredAction = (action: PersistentAction): void => {
const index = deferredActions.indexOf(action);
if (index === -1) {
return;
}
deferredActions.splice(index, 1);
deferredActionSet.delete(action);
if (deferredActions.length === 0) {
highestWarnedThreshold = 0;
}
};
/**
* Clears the deferred actions buffer without processing.
* Used for cleanup during testing or error recovery.
*
* Note: deferredActionSet (WeakSet) is not cleared here because WeakSet has
* no .clear() method. Entries are garbage-collected when action references are
* released. In practice, cleared actions are not reused by reference, so stale
* entries in the WeakSet are harmless.
* WeakSet has no clear(), so delete the known buffered references individually.
*/
export const clearDeferredActions = (): void => {
for (const action of deferredActions) {
deferredActionSet.delete(action);
}
deferredActions = [];
highestWarnedThreshold = 0;
};
/**

View file

@ -16,6 +16,7 @@ import { COMPACTION_THRESHOLD } from '../core/operation-log.const';
import {
bufferDeferredAction,
clearDeferredActions,
getDeferredActions,
} from './operation-capture.meta-reducer';
import { ClientIdService } from '../../core/util/client-id.service';
import { OperationCaptureService } from './operation-capture.service';
@ -804,7 +805,7 @@ describe('OperationLogEffects', () => {
});
it('should do nothing when no deferred actions are buffered', async () => {
await effects.processDeferredActions();
await expectAsync(effects.processDeferredActions()).toBeResolved();
expect(mockOpLogStore.appendWithVectorClockUpdate).not.toHaveBeenCalled();
});
@ -876,8 +877,114 @@ describe('OperationLogEffects', () => {
// Should not throw - errors are logged but don't stop processing
await expectAsync(effects.processDeferredActions()).toBeResolved();
// Both actions should have been attempted
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledTimes(2);
// The failed write is retried once, then processing continues to action 2.
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledTimes(3);
});
it('should stop the drain at an exhausted transient failure, keeping it AND its successors queued in order', async () => {
// Persisting a successor before the failed action would record them in
// reversed order with inverted vector clocks — the OLDER same-entity
// edit would win LWW on every client.
const failedAction = createPersistentAction(ActionType.TASK_SHARED_ADD);
const successorAction = createPersistentAction(ActionType.TASK_SHARED_UPDATE);
bufferDeferredAction(failedAction);
bufferDeferredAction(successorAction);
mockOpLogStore.appendWithVectorClockUpdate.and.rejectWith(
new Error('transient failure'),
);
await expectAsync(effects.processDeferredActions()).toBeRejected();
// Only the failed action was attempted (3 retries); the successor was
// never written out of order and both remain buffered.
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledTimes(3);
expect(getDeferredActions()).toEqual([failedAction, successorAction]);
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
msg: T.F.SYNC.S.DEFERRED_ACTION_FAILED,
actionStr: T.G.DISMISS,
}),
);
mockOpLogStore.appendWithVectorClockUpdate.calls.reset();
mockOpLogStore.appendWithVectorClockUpdate.and.resolveTo(2);
await effects.processDeferredActions();
// Next window drains both in the original order.
const calls = mockOpLogStore.appendWithVectorClockUpdate.calls.all();
expect(calls.length).toBe(2);
expect(calls[0].args[0].actionType).toBe(ActionType.TASK_SHARED_ADD);
expect(calls[1].args[0].actionType).toBe(ActionType.TASK_SHARED_UPDATE);
expect(getDeferredActions()).toEqual([]);
});
it('should block and keep a permanently invalid deferred action with its successors', async () => {
// Invalid entity identifiers are deterministic: retrying every sync
// window forever (with a sticky error snack each time) can never succeed.
const invalidAction = createPersistentAction(ActionType.TASK_SHARED_ADD);
(invalidAction.meta as { entityId?: string }).entityId = '';
const validAction = createPersistentAction(ActionType.TASK_SHARED_UPDATE);
bufferDeferredAction(invalidAction);
bufferDeferredAction(validAction);
await expectAsync(effects.processDeferredActions()).toBeRejected();
// The invalid reducer action already changed live state. Neither it nor
// its successor may be discarded or persisted out of order.
expect(mockOpLogStore.appendWithVectorClockUpdate).not.toHaveBeenCalled();
expect(getDeferredActions()).toEqual([invalidAction, validAction]);
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
msg: T.F.SYNC.S.DEFERRED_ACTION_PERMANENT_FAILED,
actionStr: T.PS.RELOAD,
actionFn: jasmine.any(Function),
}),
);
});
it('should serialize overlapping drains so one buffered action is persisted exactly once', async () => {
// getDeferredActions() is a non-destructive snapshot: without
// serialization two concurrent drains would both see the same
// unacknowledged action and mint two ops for one user intent.
const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE);
bufferDeferredAction(action);
let resolveFirstWrite!: (seq: number) => void;
mockOpLogStore.appendWithVectorClockUpdate.and.returnValue(
new Promise<number>((resolve) => {
resolveFirstWrite = resolve;
}),
);
const firstDrain = effects.processDeferredActions();
const secondDrain = effects.processDeferredActions();
// Let the first drain reach its (pending) write before releasing it.
await new Promise((resolve) => setTimeout(resolve, 0));
resolveFirstWrite(1);
await Promise.all([firstDrain, secondDrain]);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledTimes(1);
expect(getDeferredActions()).toEqual([]);
});
it('should not re-append a deferred action when post-append bookkeeping fails', async () => {
// After appendWithVectorClockUpdate commits, a bookkeeping throw (e.g.
// getCompactionCounter) must not bubble into the retry loop — that would
// append the same user action again under a fresh op id and double-apply
// additive payloads on every client.
const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE);
bufferDeferredAction(action);
mockOpLogStore.getCompactionCounter.and.rejectWith(
new Error('bookkeeping failure'),
);
await effects.processDeferredActions();
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledTimes(1);
expect(getDeferredActions()).toEqual([]);
expect(mockSnackService.open).not.toHaveBeenCalledWith(
jasmine.objectContaining({ msg: T.F.SYNC.S.DEFERRED_ACTION_FAILED }),
);
});
it('should use fresh vector clock for deferred actions', async () => {
@ -922,7 +1029,9 @@ describe('OperationLogEffects', () => {
new DOMException('Quota exceeded', 'QuotaExceededError'),
);
await effects.processDeferredActions({ callerHoldsOperationLogLock: true });
await expectAsync(
effects.processDeferredActions({ callerHoldsOperationLogLock: true }),
).toBeRejected();
expect(mockCompactionService.emergencyCompact).not.toHaveBeenCalled();
expect(mockLockService.request).not.toHaveBeenCalledWith(
@ -955,7 +1064,9 @@ describe('OperationLogEffects', () => {
new DOMException('Quota exceeded', 'QuotaExceededError'),
);
await effects.processDeferredActions({ callerHoldsOperationLogLock: true });
await expectAsync(
effects.processDeferredActions({ callerHoldsOperationLogLock: true }),
).toBeRejected();
// 1. The bail path actually ran (proves handleQuotaExceeded was invoked
// AND took the caller-holds-lock branch — not some other code path).

View file

@ -4,7 +4,10 @@ import type { DeferredLocalActionsPort } from '@sp/sync-core';
import { ALL_ACTIONS } from '../../util/local-actions.token';
import { concatMap, filter } from 'rxjs/operators';
import { LockService } from '../sync/lock.service';
import { LockAcquisitionTimeoutError } from '../core/errors/sync-errors';
import {
LockAcquisitionTimeoutError,
PermanentDeferredWriteError,
} from '../core/errors/sync-errors';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import {
isPersistentAction,
@ -28,7 +31,11 @@ import {
import { CURRENT_SCHEMA_VERSION } from '../persistence/schema-migration.service';
import { OperationCaptureService } from './operation-capture.service';
import { ImmediateUploadService } from '../sync/immediate-upload.service';
import { getDeferredActions, isDeferredAction } from './operation-capture.meta-reducer';
import {
acknowledgeDeferredAction,
getDeferredActions,
isDeferredAction,
} from './operation-capture.meta-reducer';
import { ClientIdService } from '../../core/util/client-id.service';
import { SuperSyncStatusService } from '../sync/super-sync-status.service';
@ -59,6 +66,12 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
* Initialized lazily from persisted value on first operation.
*/
private inMemoryCompactionCounter: number | null = null;
/**
* Serialization chain for processDeferredActions overlapping drains would
* double-persist the same buffered action (see processDeferredActions).
*/
private _deferredProcessingChain: Promise<void> = Promise.resolve();
/**
* Dedupe timestamp for the storage-quota snackbar. #7700: when quota fires
* inside the deferred-action retry loop, the retry loop calls handleQuotaExceeded
@ -178,6 +191,14 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
if (!isBulkAllOperation && !hasValidEntityId && !hasValidEntityIds) {
// No queue bookkeeping needed here: the effect wrapper's `finally`
// decrements the pending counter for this action even on early return.
if (isDeferredWrite) {
// Typed throw BEFORE devError: in dev builds devError itself can throw
// a generic Error, which the deferred drain would misclassify as a
// transient (retryable) failure. The drain logs the abandonment loudly.
throw new PermanentDeferredWriteError(
`Deferred action ${action.type} has invalid entity identifiers.`,
);
}
devError(
`[OperationLogEffects] Action ${action.type} has invalid entityId/entityIds (${action.meta.entityId}) - skipping persistence`,
);
@ -284,6 +305,11 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
// (recordCriticalErrorTime) — that is fed by GlobalErrorHandler and the
// validateState seam only; snackbar-surfaced conditions are out of scope
// by design. See util/critical-error-signal.ts.
if (isDeferredWrite) {
throw new PermanentDeferredWriteError(
`Deferred action ${action.type} has an invalid payload.`,
);
}
this.snackService.open({
type: 'ERROR',
msg: T.F.SYNC.S.INVALID_OPERATION_PAYLOAD,
@ -310,32 +336,44 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
// The op.vectorClock already contains the incremented clock (from newClock above).
await this.opLogStore.appendWithVectorClockUpdate(op, 'local');
// Mark that we have pending ops (not yet uploaded) for UI indicator
this.superSyncStatusService.updatePendingOpsStatus(true);
// The op is durably committed past this point. Bookkeeping failures
// below must NOT propagate: a throw would send the deferred retry loop
// back through writeOperation, appending the same user action again
// under a fresh op id — additive payloads (time tracking, counters)
// would then double-apply on every client.
try {
// Mark that we have pending ops (not yet uploaded) for UI indicator
this.superSyncStatusService.updatePendingOpsStatus(true);
// Track write count for high-volume debugging
this.writeCount++;
if (this.writeCount % 50 === 0) {
OpLog.normal(
`OperationLogEffects: Wrote ${this.writeCount} operations to IndexedDB`,
// Track write count for high-volume debugging
this.writeCount++;
if (this.writeCount % 50 === 0) {
OpLog.normal(
`OperationLogEffects: Wrote ${this.writeCount} operations to IndexedDB`,
);
}
// 1b. Trigger immediate upload to SuperSync (async, non-blocking)
this.immediateUploadService.trigger();
// 2. Check if compaction is needed
// PERF: Use in-memory counter instead of IndexedDB transaction on every operation.
// Initialize from persisted value on first use (for crash recovery).
if (this.inMemoryCompactionCounter === null) {
this.inMemoryCompactionCounter = await this.opLogStore.getCompactionCounter();
}
this.inMemoryCompactionCounter++;
if (this.inMemoryCompactionCounter >= COMPACTION_THRESHOLD) {
// Trigger compaction asynchronously (don't block write operation)
// Counter is reset in compaction service on success
this.triggerCompaction();
}
} catch (bookkeepingError) {
OpLog.err(
'OperationLogEffects: Post-append bookkeeping failed (operation is already durable; not retrying the append)',
{ name: (bookkeepingError as Error | undefined)?.name },
);
}
// 1b. Trigger immediate upload to SuperSync (async, non-blocking)
this.immediateUploadService.trigger();
// 2. Check if compaction is needed
// PERF: Use in-memory counter instead of IndexedDB transaction on every operation.
// Initialize from persisted value on first use (for crash recovery).
if (this.inMemoryCompactionCounter === null) {
this.inMemoryCompactionCounter = await this.opLogStore.getCompactionCounter();
}
this.inMemoryCompactionCounter++;
if (this.inMemoryCompactionCounter >= COMPACTION_THRESHOLD) {
// Trigger compaction asynchronously (don't block write operation)
// Counter is reset in compaction service on success
this.triggerCompaction();
}
};
if (options.callerHoldsOperationLogLock) {
@ -355,6 +393,11 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
} catch (e) {
// 4.1.1 Error Handling for Optimistic Updates
OpLog.err('OperationLogEffects: Failed to persist operation', e);
if (e instanceof PermanentDeferredWriteError) {
// Deterministic invalidity, already surfaced via its own snack —
// rethrow untouched so the deferred drain abandons instead of retrying.
throw e;
}
if (e instanceof LockAcquisitionTimeoutError) {
// #7700: do NOT silently swallow lock timeouts. Pre-fix, a reentrant
// sp_op_log timeout was caught here, the user got a snackbar, and the
@ -366,7 +409,9 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
// #8306: the effect path catches this throw in writeOperationFromEffect
// — the shared persistOperation$ stream must NOT be torn down by one
// failed write — while still showing the sticky snackbar below.
this.notifyUserAndTriggerRollback();
if (!isDeferredWrite) {
this.notifyUserAndTriggerRollback();
}
throw e;
}
if (this.isQuotaExceededError(e)) {
@ -375,13 +420,19 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
OpLog.err(
'OperationLogEffects: Quota exceeded during retry - aborting to prevent loop',
);
this.notifyUserAndTriggerRollback();
if (!isDeferredWrite) {
this.notifyUserAndTriggerRollback();
}
} else {
await this.handleQuotaExceeded(action, isDeferredWrite, options);
return;
}
} else {
this.notifyUserAndTriggerRollback();
if (!isDeferredWrite) {
this.notifyUserAndTriggerRollback();
}
}
throw e;
}
}
@ -512,6 +563,7 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
// Use lock for cross-tab coordination - only one tab handles quota at a time
let bailReason: Error | null = null;
let recovered = false;
await this.lockService.request('sp_quota_exceeded', async () => {
if (options.callerHoldsOperationLogLock) {
OpLog.err(
@ -541,6 +593,7 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
// extractEntityChanges() inside writeOperation, so the retry simply
// re-extracts the same changes. Pass isDeferredWrite through unchanged.
await this.writeOperation(action, isDeferredWrite, options);
recovered = true;
this.snackService.open({
type: 'SUCCESS',
msg: T.F.SYNC.S.STORAGE_RECOVERED_AFTER_COMPACTION,
@ -562,6 +615,10 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
if (bailReason !== null) {
throw bailReason;
}
if (recovered) {
return;
}
throw new Error('Storage quota recovery failed; operation was not persisted.');
}
private showStorageQuotaExceededError(): void {
@ -602,6 +659,28 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
* Called after sync operations are applied and remote op bookkeeping is complete.
*/
async processDeferredActions(options: WriteOperationOptions = {}): Promise<void> {
// Serialize overlapping invocations: getDeferredActions() is a
// non-destructive snapshot, so two concurrent drains (e.g. a WS-triggered
// download finishing while a piggyback apply flushes — they run under
// different locks) would both see the same unacknowledged action and each
// mint an op with a fresh id for it. One user intent = one op; additive
// payloads would otherwise double-apply on every client. Chaining makes
// the second caller wait and re-snapshot after the first finishes.
const run = this._deferredProcessingChain.then(() =>
this._processDeferredActionsImpl(options),
);
// Keep the chain alive even if this run rejects; errors still surface to
// this invocation's caller via `run`.
this._deferredProcessingChain = run.then(
() => undefined,
() => undefined,
);
return run;
}
private async _processDeferredActionsImpl(
options: WriteOperationOptions,
): Promise<void> {
const deferredActions = getDeferredActions();
if (deferredActions.length === 0) {
return;
@ -613,7 +692,7 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 100;
let failedCount = 0;
let failure: unknown;
for (const action of deferredActions) {
let lastError: unknown;
@ -629,6 +708,10 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
break;
} catch (e) {
lastError = e;
if (e instanceof PermanentDeferredWriteError) {
// Deterministic invalidity — retrying cannot succeed.
break;
}
if (attempt < MAX_RETRIES - 1) {
// Exponential backoff: 100ms, 200ms, 400ms
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
@ -641,29 +724,62 @@ export class OperationLogEffects implements DeferredLocalActionsPort {
}
}
if (!success) {
failedCount++;
// Log error after all retries exhausted, continue processing remaining actions
OpLog.err(
`OperationLogEffects: Failed to process deferred action after ${MAX_RETRIES} retries`,
{ actionType: action.type, error: lastError },
);
if (success) {
acknowledgeDeferredAction(action);
continue;
}
if (lastError instanceof PermanentDeferredWriteError) {
// The reducer already committed this action. Dropping it would leave
// live state ahead of durable state and let sync claim convergence.
// Keep the full suffix buffered and require reload to roll live state
// back to the last durable snapshot.
OpLog.err('OperationLogEffects: Permanently invalid deferred action', {
actionType: action.type,
});
failure = lastError;
break;
}
// Transient failure: STOP the drain here instead of persisting later
// actions first. Writing a successor now and this action on a future
// drain would record them in reversed order with inverted vector clocks,
// so the OLDER same-entity edit would win LWW on every client. The whole
// suffix stays buffered and is retried in order on the next window.
OpLog.err(
`OperationLogEffects: Deferred action failed after ${MAX_RETRIES} retries; keeping it and all later actions queued in order`,
{
actionType: action.type,
errorName: (lastError as Error | undefined)?.name,
},
);
failure = lastError;
break;
}
// Show notification if any actions failed
if (failedCount > 0) {
if (failure) {
const isPermanent = failure instanceof PermanentDeferredWriteError;
this.snackService.open({
type: 'ERROR',
msg: T.F.SYNC.S.DEFERRED_ACTION_FAILED,
actionStr: T.PS.RELOAD,
actionFn: (): void => {
window.location.reload();
},
msg: isPermanent
? T.F.SYNC.S.DEFERRED_ACTION_PERMANENT_FAILED
: T.F.SYNC.S.DEFERRED_ACTION_FAILED,
actionStr: isPermanent ? T.PS.RELOAD : T.G.DISMISS,
...(isPermanent
? {
actionFn: (): void => {
window.location.reload();
},
}
: {}),
config: {
duration: 0, // Sticky - don't auto-dismiss critical errors
},
});
throw new Error(
'Deferred local actions remain unpersisted after retry exhaustion.',
{ cause: failure },
);
}
}
}

View file

@ -112,6 +112,47 @@ export class UnknownSyncStateError extends Error {
override name = 'UnknownSyncStateError';
}
/**
* A deferred action can never be persisted (invalid entity identifiers or an
* invalid operation payload) a deterministic condition, not a transient
* I/O failure. The reducer already committed, so the action stays buffered and
* sync remains blocked until reload restores the last durable state.
*/
export class PermanentDeferredWriteError extends Error {
override name = 'PermanentDeferredWriteError';
}
/**
* A local action was captured while a USE_REMOTE rebuild held the op-log lock,
* after the destructive replacement committed. The attempt must abort the
* raced action's reducer ran against live state the replay rewrites, so
* completing could let a later snapshot cover an op whose live effect is
* missing. The raced ops are preserved and re-applied by the retry/resume.
*/
export class CaptureRacedRebuildError extends Error {
override name = 'CaptureRacedRebuildError';
constructor() {
super(
'USE_REMOTE incomplete: a local change arrived during the rebuild and will be restored on retry.',
);
}
}
/** Previously downloaded operations have not completed reducer/archive recovery. */
export class IncompleteRemoteOperationsError extends Error {
override name = 'IncompleteRemoteOperationsError';
constructor(cause?: unknown) {
super(
cause instanceof Error
? cause.message
: 'Downloaded operations are not fully applied.',
cause === undefined ? undefined : { cause },
);
}
}
// -----ENCRYPTION & COMPRESSION----
export class DecryptNoPasswordError extends AdditionalLogErrorBase {
override name = 'DecryptNoPasswordError';

View file

@ -21,7 +21,6 @@ import { InjectionToken } from '@angular/core';
* | Retention window | 7 days | COMPACTION_RETENTION_MS | Normal compaction |
* | Emergency retention | 1 day | EMERGENCY_COMPACTION_RETENTION_MS | When quota exceeded |
* | Clock drift warning | 5 min | CLOCK_DRIFT_THRESHOLD_MS | Warns user once per session |
* | Max conflict retries | 5 | MAX_CONFLICT_RETRY_ATTEMPTS | Then marked as rejected |
* | Max concurrent resolution | 3 | MAX_CONCURRENT_RESOLUTION_ATTEMPTS | Prevents infinite merge loop |
* | Post-sync cooldown | 2s | POST_SYNC_COOLDOWN_MS | Suppresses selector effects |
*
@ -50,6 +49,13 @@ import { InjectionToken } from '@angular/core';
* IMPORTANT: These names are also used in tests - update tests if renaming.
*/
export const LOCK_NAMES = {
/**
* Serializes archive task read-modify-write cycles across tabs. This stays
* separate from OPERATION_LOG because remote archive handlers already run
* while holding that non-reentrant lock.
*/
TASK_ARCHIVE: 'sp_task_archive',
/**
* Main operation log lock. Used for:
* - Writing operations to IndexedDB
@ -138,12 +144,6 @@ export const MAX_DOWNLOAD_OPS_IN_MEMORY = 50000;
*/
export const MAX_DOWNLOAD_ITERATIONS = 1000;
/**
* Maximum retry attempts for operations that fail during conflict resolution.
* After this many retries across restarts, the operation is marked as rejected.
*/
export const MAX_CONFLICT_RETRY_ATTEMPTS = 5;
/**
* Maximum number of operations to be rejected before showing user warning.
* Once this threshold is reached, user is notified about permanently failed ops.
@ -158,14 +158,6 @@ export const MAX_REJECTED_OPS_BEFORE_WARNING = 10;
*/
export const MAX_BATCH_OPERATIONS_SIZE = 50;
/**
* Maximum age for pending operations before they expire and are rejected (milliseconds).
* If an operation has been pending for longer than this (e.g., due to data corruption
* or repeated crashes), it's marked as rejected instead of being replayed.
* Default: 24 hours - enough time for legitimate recovery scenarios
*/
export const PENDING_OPERATION_EXPIRY_MS = 24 * 60 * 60 * 1000;
/**
* Threshold for warning about clock drift between client and server (milliseconds).
* If the client's clock differs from the server by more than this amount,

View file

@ -7,6 +7,11 @@ import type { Operation } from '../operation.types';
export interface ApplyOperationsResult {
/** Operations that were successfully applied. */
appliedOps: Operation[];
/**
* First op whose archive side effect threw. Its reducer effect (and that of
* every op after it) DID commit the bulk dispatch is all-or-nothing and
* precedes archive handling so retry paths must pass `skipReducerDispatch`.
*/
failedOp?: {
op: Operation;
error: Error;
@ -27,4 +32,20 @@ export interface ApplyOperationsOptions {
* their vector clocks.
*/
skipDeferredLocalActions?: boolean;
/**
* When true, skip the bulk reducer dispatch and run only the archive side
* effects. Used to retry ops marked `failed`, whose reducer effects already
* committed (see `ApplyOperationsResult.failedOp`): re-dispatching would
* double-apply additive reducers such as syncTimeSpent or
* increaseSimpleCounterCounterToday.
*/
skipReducerDispatch?: boolean;
/**
* Called after the bulk reducer dispatch commits and before archive side effects.
* Remote apply uses this to persist its reducer/archive checkpoint and merge the
* causal frontier before deferred local actions can be written.
*/
onReducersCommitted?: (ops: Operation[]) => Promise<void>;
}

View file

@ -1,4 +1,4 @@
import { Operation, VectorClock } from '../operation.types';
import { Operation, OperationLogEntry, VectorClock } from '../operation.types';
import { OperationSyncProviderMode } from '../../sync-providers/provider.interface';
/**
@ -106,6 +106,13 @@ export interface UploadResult {
piggybackedOps: Operation[];
rejectedCount: number;
rejectedOps: RejectedOpInfo[];
/** Exact in-lock pending set considered by this upload round. */
selectedPendingOps?: OperationLogEntry[];
/**
* Accepted/local-only operation sequences whose acknowledgement was deliberately
* deferred until the caller has resolved and applied piggybacked operations.
*/
pendingAcknowledgementSeqs?: number[];
/**
* Number of local-win update ops created during LWW conflict resolution.
* These ops need to be uploaded to propagate local state to other clients.
@ -171,6 +178,13 @@ export interface UploadOptions {
*/
preUploadCallback?: () => Promise<void>;
/**
* Return accepted sequence numbers to the caller instead of marking them synced
* immediately. Required when piggybacked full-state operations may need user
* resolution before the upload round is considered committed locally.
*/
deferAcknowledgement?: boolean;
/**
* If true, instructs server to delete all existing user data before accepting uploaded operations.
* Used for clean slate operations like encryption password changes or full imports.
@ -254,6 +268,10 @@ export type DownloadOutcome =
| {
/** User cancelled a SYNC_IMPORT conflict dialog. */
kind: 'cancelled';
}
| {
/** Processing stopped at an op this app version cannot interpret safely. */
kind: 'blocked_incompatible';
};
/**
@ -284,4 +302,8 @@ export type UploadOutcome =
| {
/** User cancelled a piggybacked SYNC_IMPORT conflict dialog. */
kind: 'cancelled';
}
| {
/** Piggyback processing stopped at an incompatible operation. */
kind: 'blocked_incompatible';
};

View file

@ -65,6 +65,6 @@ export interface CompactOperationLogEntry {
source: 'local' | 'remote';
syncedAt?: number;
rejectedAt?: number;
applicationStatus?: 'pending' | 'applied' | 'failed';
applicationStatus?: 'pending' | 'archive_pending' | 'applied' | 'failed';
retryCount?: number;
}

View file

@ -11,8 +11,15 @@ import { CompleteBackup } from '../sync-exports';
/** Database name */
export const DB_NAME = 'SUP_OPS';
/** Current database schema version */
export const DB_VERSION = 7;
/**
* Current database schema version.
*
* Version 8 is a deliberate downgrade barrier for the distinct
* `archive_pending` reducer checkpoint. Released v7 readers only understand
* `failed`; IndexedDB must reject their attempt to open a newer database rather
* than let outstanding archive work become invisible.
*/
export const DB_VERSION = 8;
/** Object store names */
export const STORE_NAMES = {
@ -45,6 +52,29 @@ export const BACKUP_KEY = 'backup' as const;
/** Meta key for derived full-state operation refs */
export const FULL_STATE_OPS_META_KEY = 'full_state_ops' as const;
/**
* Meta key marking an interrupted USE_REMOTE raw rebuild: set atomically with
* the baseline replacement, cleared after the server-history replay commits.
* While set, the next sync must redo the raw (own-ops-included) rebuild
* the normal download path excludes this client's own ops server-side, so an
* interrupted replay would otherwise silently lose them.
*/
export const RAW_REBUILD_INCOMPLETE_META_KEY = 'raw_rebuild_incomplete' as const;
/**
* Meta key retaining the Undo provenance after a USE_REMOTE rebuild completed.
* This is separate from the incomplete/resume marker: completion atomically
* replaces the latter with this entry so a reload cannot strand the backup.
*/
export const RAW_REBUILD_RECOVERY_META_KEY = 'raw_rebuild_recovery' as const;
/** Versioned marker for the one-time legacy terminal remote failure repair. */
export const LEGACY_TERMINAL_REMOTE_FAILURES_MIGRATION_META_KEY =
'legacy_terminal_remote_failures_migration' as const;
/** Increment when the legacy terminal remote failure repair needs to run again. */
export const LEGACY_TERMINAL_REMOTE_FAILURES_MIGRATION_VERSION = 1;
/** Index names for ops object store */
export const OPS_INDEXES = {
BY_ID: 'byId' as const,

View file

@ -146,4 +146,8 @@ export const runDbUpgrade = (
db.createObjectStore(STORE_NAMES.META);
populateFullStateOpsMetaDuringUpgrade(transaction);
}
// Version 8: no shape change. The version itself is a downgrade barrier for
// `archive_pending`; v7 readers must fail closed instead of silently skipping
// reducer-committed operations whose archive work is still outstanding.
};

View file

@ -24,7 +24,7 @@ describe('IndexedDbOpLogAdapter', () => {
const makeOpEntry = (
id: string,
source: 'local' | 'remote',
applicationStatus?: 'pending' | 'applied' | 'failed',
applicationStatus?: 'pending' | 'archive_pending' | 'applied' | 'failed',
syncedAt?: number,
): Record<string, unknown> => ({
op: { id },

View file

@ -40,7 +40,7 @@ export interface OpLogDbSchema {
}
/**
* Current `SUP_OPS` schema, mirroring db-upgrade.ts (currently v7).
* Current `SUP_OPS` schema, mirroring db-upgrade.ts (currently v8).
*
* `name`/`version` are reused from `db-keys.const.ts` (not re-literaled) so the
* adapter opens at exactly the version `runDbUpgrade` migrates to a future

View file

@ -15,7 +15,6 @@ import { OperationApplierService } from '../apply/operation-applier.service';
import { HydrationStateService } from '../apply/hydration-state.service';
import { VectorClockService } from '../sync/vector-clock.service';
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
import { MAX_CONFLICT_RETRY_ATTEMPTS } from '../core/operation-log.const';
import { ActionType, EntityType, Operation, OpType } from '../core/operation.types';
import { ApplyOperationsResult } from '../core/types/apply.types';
import { uuidv7 } from '../../util/uuid-v7';
@ -25,10 +24,9 @@ import { uuidv7 } from '../../util/uuid-v7';
* OperationLogStoreService (real IndexedDB), with only the applier controlled.
*
* The co-located unit spec mocks the store, so it can't prove the actual status
* transitions the retry depends on: failed -> applied on success, the
* slice-from-failure re-mark, and the retry-count -> reject lifecycle that ends
* with getFailedRemoteOps filtering the op out. These tests exercise exactly
* that, end to end, across simulated reboots.
* transitions the retry depends on: failed -> applied on success and durable
* failed quarantine across repeated startup retries. These tests exercise that
* end to end, across simulated reboots.
*/
describe('OperationLogHydratorService retryFailedRemoteOps (integration, real store)', () => {
let hydrator: OperationLogHydratorService;
@ -132,6 +130,13 @@ describe('OperationLogHydratorService retryFailedRemoteOps (integration, real st
expect(applier.applyOperations).toHaveBeenCalledTimes(1);
const passed = applier.applyOperations.calls.argsFor(0)[0] as Operation[];
expect(passed.length).toBe(3);
// Archive side effects only: the failed ops' reducers committed in the
// batch that marked them failed, so a reducer re-dispatch would
// double-apply additive reducers.
expect(applier.applyOperations.calls.argsFor(0)[1]).toEqual({
skipReducerDispatch: true,
skipDeferredLocalActions: true,
});
});
it('on partial failure marks the failed op and every op after it as still-failing', async () => {
@ -153,7 +158,7 @@ describe('OperationLogHydratorService retryFailedRemoteOps (integration, real st
expect(stillFailed).toEqual([b.id, c.id].sort());
});
it('eventually rejects a permanently-failing op across reboots, then stops returning it', async () => {
it('keeps a permanently-failing archive op quarantined across retries', async () => {
const op = createOp();
await seedFailedRemoteOps([op]); // retryCount starts at 1 after the seed markFailed
applier.applyOperations.and.callFake((toApply: Operation[]) =>
@ -163,19 +168,18 @@ describe('OperationLogHydratorService retryFailedRemoteOps (integration, real st
} as ApplyOperationsResult),
);
// Each call simulates one boot's retry. The seed left retryCount at 1, so
// it takes MAX_CONFLICT_RETRY_ATTEMPTS - 1 more failed retries to hit the cap.
let guard = 0;
while ((await store.getFailedRemoteOps()).length > 0 && guard < 10) {
// Each call simulates one boot's retry. It must remain visible to both the
// next startup retry and the sync safety gate instead of turning rejected.
const repeatedStartupRetries = 6;
for (let retry = 0; retry < repeatedStartupRetries; retry++) {
await hydrator.retryFailedRemoteOps();
guard++;
}
// Op is gone from the retry set and persisted as rejected, not failed.
expect(await store.getFailedRemoteOps()).toEqual([]);
expect(guard).toBe(MAX_CONFLICT_RETRY_ATTEMPTS - 1);
const rejected = await store.getOpById(op.id);
expect(rejected?.rejectedAt).toBeTruthy();
expect(rejected?.applicationStatus).toBeUndefined();
expect((await store.getFailedRemoteOps()).map((entry) => entry.op.id)).toEqual([
op.id,
]);
const quarantined = await store.getOpById(op.id);
expect(quarantined?.rejectedAt).toBeUndefined();
expect(quarantined?.applicationStatus).toBe('failed');
});
});

View file

@ -27,13 +27,11 @@ import {
import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { bulkApplyHydrationOperations } from '../apply/bulk-hydration.action';
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
import {
MAX_CONFLICT_RETRY_ATTEMPTS,
MAX_VECTOR_CLOCK_SIZE,
} from '../core/operation-log.const';
import { MAX_VECTOR_CLOCK_SIZE } from '../core/operation-log.const';
import { IndexedDBOpenError } from '../core/errors/indexed-db-open.error';
import { IDB_OPEN_ERROR_RELOAD_KEY } from './operation-log-hydrator.service';
import { SyncProviderId } from '../sync-providers/provider.const';
import { OperationLogEffects } from '../capture/operation-log.effects';
describe('OperationLogHydratorService', () => {
let service: OperationLogHydratorService;
@ -47,6 +45,7 @@ describe('OperationLogHydratorService', () => {
let mockRepairOperationService: jasmine.SpyObj<RepairOperationService>;
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
let mockOperationApplierService: jasmine.SpyObj<OperationApplierService>;
let mockOperationLogEffects: jasmine.SpyObj<OperationLogEffects>;
let mockHydrationStateService: jasmine.SpyObj<HydrationStateService>;
let mockSnapshotService: jasmine.SpyObj<OperationLogSnapshotService>;
let mockRecoveryService: jasmine.SpyObj<OperationLogRecoveryService>;
@ -142,6 +141,10 @@ describe('OperationLogHydratorService', () => {
mockOperationApplierService = jasmine.createSpyObj('OperationApplierService', [
'applyOperations',
]);
mockOperationLogEffects = jasmine.createSpyObj('OperationLogEffects', [
'processDeferredActions',
]);
mockOperationLogEffects.processDeferredActions.and.resolveTo();
mockHydrationStateService = jasmine.createSpyObj('HydrationStateService', [
'startApplyingRemoteOps',
'endApplyingRemoteOps',
@ -219,6 +222,7 @@ describe('OperationLogHydratorService', () => {
{ provide: RepairOperationService, useValue: mockRepairOperationService },
{ provide: VectorClockService, useValue: mockVectorClockService },
{ provide: OperationApplierService, useValue: mockOperationApplierService },
{ provide: OperationLogEffects, useValue: mockOperationLogEffects },
{ provide: HydrationStateService, useValue: mockHydrationStateService },
{ provide: OperationLogSnapshotService, useValue: mockSnapshotService },
{ provide: OperationLogRecoveryService, useValue: mockRecoveryService },
@ -410,6 +414,84 @@ describe('OperationLogHydratorService', () => {
expect(mockHydrationStateService.endApplyingRemoteOps).toHaveBeenCalled();
});
it('should replay a tail op with a malformed stored schemaVersion verbatim instead of failing into recovery', async () => {
// Strict schemaVersion parsing guards the receive/upload paths; on the
// local hydration path one legacy/corrupt entry must not turn every
// boot into attemptRecovery() (which can lose tail data).
const snapshot = createMockSnapshot({ lastAppliedOpSeq: 5 });
const malformedOp = createMockOperation('op-6', OpType.Update, {
schemaVersion: '2' as unknown as number,
});
const tailOps = [
createMockEntry(6, malformedOp),
createMockEntry(7, createMockOperation('op-7')),
];
mockOpLogStore.loadStateCache.and.returnValue(Promise.resolve(snapshot));
mockOpLogStore.getOpsAfterSeq.and.returnValue(Promise.resolve(tailOps));
await service.hydrateStore();
expect(mockRecoveryService.attemptRecovery).not.toHaveBeenCalled();
// Both ops replay in order; the malformed one is passed through with a
// sanitized version stamp (payload untouched).
expect(mockStore.dispatch).toHaveBeenCalledWith(
bulkApplyHydrationOperations({
operations: [
{ ...malformedOp, schemaVersion: CURRENT_SCHEMA_VERSION },
tailOps[1].op,
],
localClientId: 'test-client',
}),
);
});
it('should apply a failed tail op exactly once across hydration replay + retry (one boot)', async () => {
// Regression: a remote op marked 'failed' (archive side effect threw
// after its reducer committed) with seq > lastAppliedOpSeq used to get
// its reducer applied TWICE per boot — once by the status-blind tail
// replay and once more by retryFailedRemoteOps re-dispatching. The
// retry must complete it with archive side effects only.
const snapshot = createMockSnapshot({ lastAppliedOpSeq: 5 });
const failedOp = createMockOperation('op-failed');
const failedTailEntry: OperationLogEntry = {
seq: 6,
op: failedOp,
appliedAt: Date.now(),
source: 'remote',
applicationStatus: 'failed',
retryCount: 1,
};
mockOpLogStore.loadStateCache.and.returnValue(Promise.resolve(snapshot));
mockOpLogStore.getOpsAfterSeq.and.returnValue(Promise.resolve([failedTailEntry]));
mockOpLogStore.getFailedRemoteOps.and.returnValue(
Promise.resolve([failedTailEntry]),
);
mockOperationApplierService.applyOperations.and.callFake((ops: Operation[]) =>
Promise.resolve({ appliedOps: ops }),
);
await service.hydrateStore();
// Reducer application happens exactly once: the tail replay bulk dispatch.
const bulkDispatches = mockStore.dispatch.calls
.allArgs()
.map((args) => args[0] as unknown as { type: string; operations?: Operation[] })
.filter((action) => action.type === bulkApplyHydrationOperations.type);
expect(bulkDispatches.length).toBe(1);
expect(bulkDispatches[0].operations!.map((o) => o.id)).toEqual(['op-failed']);
// The retry completes the op WITHOUT re-dispatching its reducer.
expect(mockOperationApplierService.applyOperations).toHaveBeenCalledTimes(1);
const [retriedOps, retryOptions] =
mockOperationApplierService.applyOperations.calls.argsFor(0);
expect(retriedOps.map((o: Operation) => o.id)).toEqual(['op-failed']);
expect(retryOptions).toEqual({
skipReducerDispatch: true,
skipDeferredLocalActions: true,
});
expect(mockOpLogStore.markApplied).toHaveBeenCalledWith([6]);
});
it('should request ops after snapshot sequence', async () => {
const snapshot = createMockSnapshot({ lastAppliedOpSeq: 42 });
mockOpLogStore.loadStateCache.and.returnValue(Promise.resolve(snapshot));
@ -949,10 +1031,12 @@ describe('OperationLogHydratorService', () => {
lastAppliedOpSeq: 5,
});
// schemaVersion 1 = a legitimately old version (the floor); 0 would be
// malformed and is sanitized by the lenient hydration boundary instead.
const tailOps = [
createMockEntry(
6,
createMockOperation('op-6', OpType.Update, { schemaVersion: 0 }), // Old
createMockOperation('op-6', OpType.Update, { schemaVersion: 1 }), // Old
),
createMockEntry(
7,
@ -962,7 +1046,7 @@ describe('OperationLogHydratorService', () => {
),
createMockEntry(
8,
createMockOperation('op-8', OpType.Update, { schemaVersion: 0 }), // Old
createMockOperation('op-8', OpType.Update, { schemaVersion: 1 }), // Old
),
];
@ -971,7 +1055,7 @@ describe('OperationLogHydratorService', () => {
mockSchemaMigrationService.needsMigration.and.returnValue(false);
// At least one operation needs migration
mockSchemaMigrationService.operationNeedsMigration.and.callFake(
(op: Operation) => op.schemaVersion === 0,
(op: Operation) => op.schemaVersion === 1,
);
await service.hydrateStore();
@ -1364,6 +1448,29 @@ describe('OperationLogHydratorService', () => {
expect(passedOps.map((o: Operation) => o.id)).toEqual(['op-a', 'op-b', 'op-c']);
expect(mockOpLogStore.markApplied).toHaveBeenCalledWith([40, 41, 42]);
expect(mockOpLogStore.markFailed).not.toHaveBeenCalled();
// Applied-op clocks are merged on completion (parity with the primary
// remote-apply path, which only merges the clocks of ops it marked applied).
expect(mockOpLogStore.mergeRemoteOpClocks).toHaveBeenCalledWith(passedOps);
});
it('should retry archive side effects only — never re-dispatch reducers', async () => {
// Failed ops had their reducers committed by the bulk dispatch of the
// batch that marked them failed; re-dispatching on retry double-applies
// additive reducers (syncTimeSpent, increaseSimpleCounterCounterToday).
mockOpLogStore.getFailedRemoteOps.and.returnValue(
Promise.resolve([failedEntry(40, 'op-a')]),
);
mockOperationApplierService.applyOperations.and.callFake((ops: Operation[]) =>
Promise.resolve({ appliedOps: ops }),
);
await service.retryFailedRemoteOps();
const options = mockOperationApplierService.applyOperations.calls.argsFor(0)[1];
expect(options).toEqual({
skipReducerDispatch: true,
skipDeferredLocalActions: true,
});
});
it('should apply failed ops in ascending seq order regardless of store order', async () => {
@ -1386,7 +1493,7 @@ describe('OperationLogHydratorService', () => {
expect(mockOpLogStore.markApplied).toHaveBeenCalledWith([40, 41, 42]);
});
it('should mark the failed op and every op after it (in seq order) as still-failing', async () => {
it('should charge retry budget only to the attempted archive failure', async () => {
const entries = [
failedEntry(40, 'op-a'),
failedEntry(41, 'op-b'),
@ -1405,12 +1512,39 @@ describe('OperationLogHydratorService', () => {
await service.retryFailedRemoteOps();
expect(mockOpLogStore.markApplied).toHaveBeenCalledWith([40]);
// op-b (failed) and op-c (after it) are both marked failed, with the
// retry-cap so a permanently-failing op is eventually rejected.
expect(mockOpLogStore.markFailed).toHaveBeenCalledWith(
['op-b', 'op-c'],
MAX_CONFLICT_RETRY_ATTEMPTS,
// op-c remains archive-pending and has not consumed a retry attempt.
expect(mockOpLogStore.markFailed).toHaveBeenCalledOnceWith(['op-b']);
});
it('should merge clocks before marking archive retries applied', async () => {
mockOpLogStore.getFailedRemoteOps.and.resolveTo([failedEntry(40, 'op-a')]);
mockOperationApplierService.applyOperations.and.resolveTo({
appliedOps: [failedEntry(40, 'op-a').op],
});
mockOpLogStore.mergeRemoteOpClocks.and.rejectWith(new Error('clock write failed'));
await expectAsync(service.retryFailedRemoteOps()).toBeRejected();
expect(mockOpLogStore.markApplied).not.toHaveBeenCalled();
});
it('should not escalate a deferred-drain failure into hydration recovery', async () => {
// A drain throw here used to propagate out of hydrateStore() into
// attemptRecovery(), which can import stale legacy data over a correctly
// hydrated store. The failure is logged; buffered actions stay queued
// for the next drain point (e.g. the pre-sync flush).
mockOperationLogEffects.processDeferredActions.and.rejectWith(
new Error('drain failed'),
);
mockOpLogStore.getFailedRemoteOps.and.resolveTo([failedEntry(40, 'op-a')]);
mockOperationApplierService.applyOperations.and.callFake((ops: Operation[]) =>
Promise.resolve({ appliedOps: ops }),
);
await expectAsync(service.retryFailedRemoteOps()).toBeResolved();
// Bookkeeping completed despite the failed drain.
expect(mockOpLogStore.markApplied).toHaveBeenCalledWith([40]);
});
it('should do nothing when there are no failed ops', async () => {

View file

@ -1,10 +1,12 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable, Injector } from '@angular/core';
import { Store } from '@ngrx/store';
import { OperationLogStoreService } from './operation-log-store.service';
import { processDeferredActions } from '../sync/process-deferred-actions-flush.util';
import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { OperationLogMigrationService } from './operation-log-migration.service';
import {
CURRENT_SCHEMA_VERSION,
getOperationSchemaVersion,
SchemaMigrationService,
} from './schema-migration.service';
import { OperationLogSnapshotService } from './operation-log-snapshot.service';
@ -22,9 +24,7 @@ import { ValidateStateService } from '../validation/validate-state.service';
import { OperationApplierService } from '../apply/operation-applier.service';
import { HydrationStateService } from '../apply/hydration-state.service';
import { bulkApplyOperations } from '../apply/bulk-hydration.action';
import { getFailedOpIdsFromBatch } from '../apply/failed-op-ids.util';
import { VectorClockService } from '../sync/vector-clock.service';
import { MAX_CONFLICT_RETRY_ATTEMPTS } from '../core/operation-log.const';
import { AppDataComplete } from '../model/model-config';
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
import { limitVectorClockSize } from '../../core/util/vector-clock';
@ -55,6 +55,7 @@ export class OperationLogHydratorService {
private vectorClockService = inject(VectorClockService);
private operationApplierService = inject(OperationApplierService);
private hydrationStateService = inject(HydrationStateService);
private injector = inject(Injector);
private clientIdProvider: ClientIdProvider = inject(CLIENT_ID_PROVIDER);
// Extracted services
@ -208,6 +209,21 @@ export class OperationLogHydratorService {
);
// 4. Replay tail operations (A.7.13: with operation migration)
//
// Replay is deliberately status-blind (getOpsAfterSeq has no status
// filter) — every entry's reducer effect belongs in state exactly once:
// - applied ops: their effect is state history by definition.
// - failed ops (remote, archive side effect threw): their reducers DID
// commit before the failure (bulk dispatch precedes archive handling),
// so replay restores that effect; retryFailedRemoteOps() below then
// re-runs ONLY the outstanding archive side effects.
// - rejected ops: every rejection path appends its compensation AFTER
// them in seq order, so replay converges to post-resolution runtime
// state — server-rejected local ops are followed by merged ops
// (SupersededOperationResolver) or keep their effect (permanent
// rejections never revert state), and LWW-losing remote ops are
// followed by the local-win op that overwrites them
// (ConflictResolutionService).
const tailOps = await this.opLogStore.getOpsAfterSeq(snapshot.lastAppliedOpSeq);
if (tailOps.length > 0) {
@ -293,6 +309,8 @@ export class OperationLogHydratorService {
);
// No snapshot means we might be in a fresh install state or post-migration-check with no legacy data.
// We must replay ALL operations from the beginning of the log.
// Status-blind on purpose — see the replay-policy note in the snapshot
// branch above.
const allOps = await this.opLogStore.getOpsAfterSeq(0);
if (allOps.length === 0) {
@ -468,20 +486,39 @@ export class OperationLogHydratorService {
* @returns Array of migrated operations
*/
private _migrateTailOps(ops: Operation[]): Operation[] {
// Lenient boundary: a malformed stored schemaVersion (legacy or corrupt
// entry) must not abort the WHOLE hydration into attemptRecovery() — that
// trades one questionable op for possible tail-data loss on every boot.
// Strict parsing stays on the receive/upload paths; locally we replay the
// op verbatim as a best effort (stamping the current version so
// migrateOperations passes it through unchanged, preserving order).
const sanitizedOps = ops.map((op) => {
try {
getOperationSchemaVersion(op);
return op;
} catch {
OpLog.warn(
'OperationLogHydratorService: Stored op has a malformed schemaVersion; replaying verbatim without migration.',
{ id: op.id },
);
return { ...op, schemaVersion: CURRENT_SCHEMA_VERSION };
}
});
// Check if any ops need migration
const needsMigration = ops.some((op) =>
const needsMigration = sanitizedOps.some((op) =>
this.schemaMigrationService.operationNeedsMigration(op),
);
if (!needsMigration) {
return ops;
return sanitizedOps;
}
OpLog.normal(
`OperationLogHydratorService: Migrating ${ops.length} tail ops to current schema version...`,
`OperationLogHydratorService: Migrating ${sanitizedOps.length} tail ops to current schema version...`,
);
return this.schemaMigrationService.migrateOperations(ops);
return this.schemaMigrationService.migrateOperations(sanitizedOps);
}
/**
@ -557,8 +594,10 @@ export class OperationLogHydratorService {
* Called after hydration to give failed ops another chance to apply now that
* more state might be available (e.g., dependencies resolved by sync).
*
* Failed ops are ops that previously failed during conflict resolution
* but may succeed now that more state has been loaded.
* Failed ops are ops whose archive side effect threw after their reducers
* committed, so the retry runs archive side effects ONLY
* (`skipReducerDispatch`) hydration replay / the snapshot already carry
* their reducer effects.
*/
async retryFailedRemoteOps(): Promise<void> {
const failedOps = await this.opLogStore.getFailedRemoteOps();
@ -583,36 +622,75 @@ export class OperationLogHydratorService {
const opsToApply = orderedFailedOps.map((e) => e.op);
const opIdToSeq = new Map(orderedFailedOps.map((e) => [e.op.id, e.seq]));
const result = await this.operationApplierService.applyOperations(opsToApply);
// `failed` can only be set AFTER a bulk dispatch committed (archive side
// effects run after the dispatch and are the only per-op failure point),
// so every failed op's reducer effect is already in state — via the
// snapshot when its seq <= lastAppliedOpSeq, via the status-blind tail
// replay above otherwise. Skip the reducer dispatch and re-run only the
// outstanding archive side effects: re-dispatching would double-apply
// additive reducers (syncTimeSpent, increaseSimpleCounterCounterToday)
// on every retry attempt.
try {
const result = await this.operationApplierService.applyOperations(opsToApply, {
skipReducerDispatch: true,
// The drain runs in the finally below with its own error boundary. Left
// to the applier's finally, a drain throw would mask the archive result
// (markFailed below never runs) and escalate out of hydrateStore() into
// attemptRecovery(), which can import stale legacy data over a
// correctly hydrated store.
skipDeferredLocalActions: true,
});
// Mark successfully applied ops.
const appliedSeqs = result.appliedOps
.map((op) => opIdToSeq.get(op.id))
.filter((seq): seq is number => seq !== undefined);
if (appliedSeqs.length > 0) {
await this.opLogStore.markApplied(appliedSeqs);
OpLog.normal(
`OperationLogHydratorService: Successfully retried ${appliedSeqs.length} failed ops`,
);
}
// Mark successfully applied ops.
const appliedSeqs = result.appliedOps
.map((op) => opIdToSeq.get(op.id))
.filter((seq): seq is number => seq !== undefined);
if (appliedSeqs.length > 0) {
// The primary remote-apply path (applyRemoteOperations) merges clocks at
// reducer commit for the WHOLE batch, including ops whose archive
// handling later fails — so these clocks were usually merged already.
// Re-merging here is a harmless component-wise max and also covers ops
// that reached `failed`/`archive_pending` via crash recovery, where the
// reducer-commit callback (and its clock merge) may never have run.
await this.opLogStore.mergeRemoteOpClocks(result.appliedOps);
await this.opLogStore.markApplied(appliedSeqs);
OpLog.normal(
`OperationLogHydratorService: Successfully retried ${appliedSeqs.length} failed ops`,
);
}
// On a partial failure the batch applier stops at the first op whose archive
// side effect throws and returns it; that op and every op after it in seq
// order stay unapplied (slice-from-failure, shared with the primary path).
// markFailed bumps the retry count (rejecting ops past
// MAX_CONFLICT_RETRY_ATTEMPTS), so a permanently-failing op can't be retried
// forever.
if (result.failedOp) {
const stillFailedOpIds = getFailedOpIdsFromBatch(opsToApply, result.failedOp.op);
// On a partial failure the batch applier stops at the first archive error.
// Charge only that attempted operation: successors remain archive-pending
// without consuming retry budget and will run after the blocker succeeds.
// A persistent blocker stays failed so ordinary sync remains safely paused.
if (result.failedOp) {
const failedOpIds = [result.failedOp.op.id];
OpLog.warn(
`OperationLogHydratorService: Failed to retry op ${result.failedOp.op.id}`,
result.failedOp.error,
);
await this.opLogStore.markFailed(stillFailedOpIds, MAX_CONFLICT_RETRY_ATTEMPTS);
OpLog.warn(
`OperationLogHydratorService: ${stillFailedOpIds.length} ops still failing after retry`,
);
OpLog.warn(
`OperationLogHydratorService: Failed to retry op ${result.failedOp.op.id}`,
result.failedOp.error,
);
// Keep archive failure visible to the sync safety gate. A retry cap that
// rejects it would hide incomplete downloaded work and allow false IN_SYNC.
await this.opLogStore.markFailed(failedOpIds);
OpLog.warn(
'OperationLogHydratorService: Archive operation still failing after retry',
);
}
} finally {
// Local actions captured while the retry held the remote-apply window
// open. Runs after mergeRemoteOpClocks so their clocks dominate the
// retried remote ops (#7700). A failed drain keeps the actions buffered
// for the next drain point (e.g. the pre-sync flush) — never escalate
// it into hydration recovery.
try {
await processDeferredActions(this.injector, false);
} catch (drainError) {
OpLog.err(
'OperationLogHydratorService: Deferred-action drain failed after archive retry; actions stay buffered.',
{ name: (drainError as Error | undefined)?.name },
);
}
}
}

View file

@ -5,7 +5,6 @@ import { OperationLogStoreService } from './operation-log-store.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { ClientIdService } from '../../core/util/client-id.service';
import { ActionType, OpType } from '../core/operation.types';
import { PENDING_OPERATION_EXPIRY_MS } from '../core/operation-log.const';
import { ValidateStateService } from '../validation/validate-state.service';
describe('OperationLogRecoveryService', () => {
@ -24,11 +23,15 @@ describe('OperationLogRecoveryService', () => {
'saveStateCache',
'setVectorClock',
'getPendingRemoteOps',
'recoverLegacyTerminalRemoteFailures',
'markRejected',
'markFailed',
'markApplied',
'markReducersCommittedAndMergeClocks',
'getUnsynced',
]);
mockOpLogStore.setVectorClock.and.resolveTo(undefined);
mockOpLogStore.recoverLegacyTerminalRemoteFailures.and.resolveTo(0);
mockLegacyPfDb = jasmine.createSpyObj('LegacyPfDbService', [
'hasUsableEntityData',
'loadAllEntityData',
@ -183,87 +186,54 @@ describe('OperationLogRecoveryService', () => {
await service.recoverPendingRemoteOps();
expect(mockOpLogStore.markApplied).not.toHaveBeenCalled();
expect(mockOpLogStore.markReducersCommittedAndMergeClocks).not.toHaveBeenCalled();
expect(mockOpLogStore.markRejected).not.toHaveBeenCalled();
expect(mockOpLogStore.markFailed).not.toHaveBeenCalled();
});
it('should mark valid pending ops as applied', async () => {
it('should quarantine crash-interrupted ops for archive recovery', async () => {
const now = Date.now();
const pendingOps = [
{ seq: 1, op: { id: 'op1' }, appliedAt: now - 1000, source: 'remote' },
{ seq: 2, op: { id: 'op2' }, appliedAt: now - 2000, source: 'remote' },
] as any;
mockOpLogStore.getPendingRemoteOps.and.resolveTo(pendingOps);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markReducersCommittedAndMergeClocks.and.resolveTo(undefined);
await service.recoverPendingRemoteOps();
expect(mockOpLogStore.markApplied).toHaveBeenCalledWith([1, 2]);
});
it('should reject ops that exceed PENDING_OPERATION_EXPIRY_MS', async () => {
const now = Date.now();
const pendingOps = [
{ seq: 1, op: { id: 'valid' }, appliedAt: now - 1000, source: 'remote' }, // Valid
{
seq: 2,
op: { id: 'expired' },
appliedAt: now - PENDING_OPERATION_EXPIRY_MS - 1,
source: 'remote',
}, // Expired
] as any;
mockOpLogStore.getPendingRemoteOps.and.resolveTo(pendingOps);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
await service.recoverPendingRemoteOps();
expect(mockOpLogStore.markApplied).toHaveBeenCalledWith([1]);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['expired']);
});
it('should reject all expired ops when all are superseded', async () => {
const now = Date.now();
const expiredTime = now - PENDING_OPERATION_EXPIRY_MS - 100000;
const pendingOps = [
{ seq: 1, op: { id: 'old1' }, appliedAt: expiredTime, source: 'remote' },
{ seq: 2, op: { id: 'old2' }, appliedAt: expiredTime - 1000, source: 'remote' },
] as any;
mockOpLogStore.getPendingRemoteOps.and.resolveTo(pendingOps);
mockOpLogStore.markRejected.and.resolveTo(undefined);
await service.recoverPendingRemoteOps();
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['old1', 'old2']);
expect(mockOpLogStore.markReducersCommittedAndMergeClocks).toHaveBeenCalledWith(
[1, 2],
pendingOps.map((entry) => entry.op),
);
expect(mockOpLogStore.markApplied).not.toHaveBeenCalled();
});
it('should handle mixed valid and expired ops correctly', async () => {
it('should quarantine regardless of age without charging retry budget', async () => {
// The former PENDING_OPERATION_EXPIRY_MS split changed nothing: every
// crash-interrupted op lands in the same quarantine, and retryCount is
// only ever bumped for an actually attempted archive failure.
const now = Date.now();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const pendingOps = [
{ seq: 1, op: { id: 'valid1' }, appliedAt: now - 1000, source: 'remote' },
{ seq: 1, op: { id: 'fresh' }, appliedAt: now - 1000, source: 'remote' },
{
seq: 2,
op: { id: 'expired1' },
appliedAt: now - PENDING_OPERATION_EXPIRY_MS - 1,
source: 'remote',
},
{ seq: 3, op: { id: 'valid2' }, appliedAt: now - 5000, source: 'remote' },
{
seq: 4,
op: { id: 'expired2' },
appliedAt: now - PENDING_OPERATION_EXPIRY_MS - 2,
op: { id: 'week-old' },
appliedAt: now - weekMs,
source: 'remote',
},
] as any;
mockOpLogStore.getPendingRemoteOps.and.resolveTo(pendingOps);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOpLogStore.markReducersCommittedAndMergeClocks.and.resolveTo(undefined);
await service.recoverPendingRemoteOps();
expect(mockOpLogStore.markApplied).toHaveBeenCalledWith([1, 3]);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['expired1', 'expired2']);
expect(mockOpLogStore.markReducersCommittedAndMergeClocks).toHaveBeenCalledWith(
[1, 2],
pendingOps.map((entry) => entry.op),
);
expect(mockOpLogStore.markFailed).not.toHaveBeenCalled();
});
});

View file

@ -8,7 +8,6 @@ import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { Operation, OpType, ActionType } from '../core/operation.types';
import { SINGLETON_ENTITY_ID } from '../core/entity-registry';
import { uuidv7 } from '../../util/uuid-v7';
import { PENDING_OPERATION_EXPIRY_MS } from '../core/operation-log.const';
import { OpLog } from '../../core/log';
import { AppDataComplete } from '../model/model-config';
import { ValidateStateService } from '../validation/validate-state.service';
@ -136,52 +135,36 @@ export class OperationLogRecoveryService {
/**
* Recovers from pending remote ops that were stored but not applied (crash recovery).
* These ops are in the log and will be replayed during normal hydration, so we just
* need to mark them as applied to prevent them appearing as orphaned.
*
* Operations pending for longer than PENDING_OPERATION_EXPIRY_MS are considered
* superseded (likely due to data corruption or repeated failures) and are rejected
* instead of replayed.
* These ops are replayed through reducers during normal hydration, but a crash may
* have happened before their archive side effects completed. Move them to the
* 'archive_pending' checkpoint so hydration retries archive work without
* double-applying reducers; sync stays blocked until that recovery succeeds.
*/
async recoverPendingRemoteOps(): Promise<void> {
const recoveredLegacyFailures =
await this.opLogStore.recoverLegacyTerminalRemoteFailures();
if (recoveredLegacyFailures > 0) {
OpLog.warn(
`OperationLogRecoveryService: Re-quarantined ${recoveredLegacyFailures} legacy terminal remote failure(s).`,
);
}
const pendingOps = await this.opLogStore.getPendingRemoteOps();
if (pendingOps.length === 0) {
return;
}
const now = Date.now();
const validOps = pendingOps.filter(
(e) => now - e.appliedAt < PENDING_OPERATION_EXPIRY_MS,
// Reducers are replayed status-blind during hydration; archive work is
// retried after. Age is irrelevant — every crash-interrupted op lands in
// the same quarantine, and retryCount stays untouched (no attempt was made).
const seqs = pendingOps.map((e) => e.seq);
await this.opLogStore.markReducersCommittedAndMergeClocks(
seqs,
pendingOps.map((entry) => entry.op),
);
const expiredOps = pendingOps.filter(
(e) => now - e.appliedAt >= PENDING_OPERATION_EXPIRY_MS,
);
// Reject expired ops - they've been pending too long
if (expiredOps.length > 0) {
const expiredIds = expiredOps.map((e) => e.op.id);
await this.opLogStore.markRejected(expiredIds);
OpLog.warn(
`OperationLogRecoveryService: Rejected ${expiredOps.length} expired pending remote ops ` +
`(pending > ${PENDING_OPERATION_EXPIRY_MS / (60 * 60 * 1000)}h). ` +
`Oldest was ${Math.round((now - Math.min(...expiredOps.map((e) => e.appliedAt))) / (60 * 60 * 1000))}h old.`,
);
}
// Mark valid ops as applied - they'll be replayed during normal hydration
if (validOps.length > 0) {
const seqs = validOps.map((e) => e.seq);
await this.opLogStore.markApplied(seqs);
OpLog.warn(
`OperationLogRecoveryService: Found ${validOps.length} pending remote ops from previous crash. ` +
`Marking as applied (they will be replayed during hydration).`,
);
}
OpLog.normal(
`OperationLogRecoveryService: Recovered ${validOps.length} pending remote ops, ` +
`rejected ${expiredOps.length} expired ops.`,
OpLog.warn(
`OperationLogRecoveryService: Found ${pendingOps.length} pending remote ops from previous crash. ` +
'Quarantined their archive work (reducers will replay during hydration).',
);
}

View file

@ -6,6 +6,7 @@ import { VectorClockService } from '../sync/vector-clock.service';
import {
ActionType,
Operation,
OperationLogEntry,
OpType,
EntityType,
VectorClock,
@ -29,7 +30,13 @@ import {
MAX_VECTOR_CLOCK_SIZE,
} from '../core/operation-log.const';
import { IndexedDBOpenError } from '../core/errors/indexed-db-open.error';
import { FULL_STATE_OPS_META_KEY, SINGLETON_KEY, STORE_NAMES } from './db-keys.const';
import {
FULL_STATE_OPS_META_KEY,
OPS_INDEXES,
SINGLETON_KEY,
STORE_NAMES,
} from './db-keys.const';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
describe('OperationLogStoreService', () => {
let service: OperationLogStoreService;
@ -1404,6 +1411,143 @@ describe('OperationLogStoreService', () => {
});
});
describe('appendMixedSourceBatchSkipDuplicates', () => {
it('should atomically order remote losers before monotonically clocked local compensations', async () => {
await service.setVectorClock({ testClient: 5, existingClient: 2 });
const remoteLoser = createTestOperation({
id: 'remote-loser',
clientId: 'remoteClient',
vectorClock: { remoteClient: 7 },
});
const firstCompensation = createTestOperation({
id: 'first-compensation',
vectorClock: { testClient: 3, remoteClient: 7 },
});
const secondCompensation = createTestOperation({
id: 'second-compensation',
vectorClock: { testClient: 6, otherRemote: 4 },
});
const result = await service.appendMixedSourceBatchSkipDuplicates([
{ ops: [remoteLoser], source: 'remote' },
{ ops: [firstCompensation, secondCompensation], source: 'local' },
]);
expect(result.written.map(({ op }) => op.id)).toEqual([
'remote-loser',
'first-compensation',
'second-compensation',
]);
expect(result.written.map(({ source }) => source)).toEqual([
'remote',
'local',
'local',
]);
expect(result.written[1].op.vectorClock).toEqual({
testClient: 6,
existingClient: 2,
remoteClient: 7,
});
expect(result.written[2].op.vectorClock).toEqual({
testClient: 7,
existingClient: 2,
remoteClient: 7,
otherRemote: 4,
});
const stored = await service.getOpsAfterSeq(0);
expect(stored.map(({ op }) => op.id)).toEqual([
'remote-loser',
'first-compensation',
'second-compensation',
]);
expect(stored[1].op.vectorClock).toEqual(result.written[1].op.vectorClock);
expect(stored[2].op.vectorClock).toEqual(result.written[2].op.vectorClock);
expect(await service.getVectorClock()).toEqual(result.written[2].op.vectorClock);
});
it('should skip existing and intra-batch duplicate IDs without allocating clocks for them', async () => {
await service.setVectorClock({ testClient: 2 });
const existingRemote = createTestOperation({
id: 'existing-remote',
clientId: 'remoteClient',
});
const newRemote = createTestOperation({
id: 'new-remote',
clientId: 'remoteClient',
});
const compensation = createTestOperation({
id: 'compensation',
vectorClock: { testClient: 1 },
});
await service.append(existingRemote, 'remote');
const result = await service.appendMixedSourceBatchSkipDuplicates([
{ ops: [existingRemote, newRemote], source: 'remote' },
{ ops: [compensation, compensation], source: 'local' },
]);
expect(result.skippedCount).toBe(2);
expect(result.written.map(({ op }) => op.id)).toEqual([
'new-remote',
'compensation',
]);
expect(result.written[1].op.vectorClock.testClient).toBe(3);
expect((await service.getVectorClock())?.testClient).toBe(3);
});
it('should roll back both source groups and the clock when the clock write fails', async () => {
await service.setVectorClock({ testClient: 4 });
const adapter = (
service as unknown as {
_adapter: OpLogDbAdapter;
}
)._adapter;
const originalTransaction = adapter.transaction.bind(adapter);
spyOn(adapter, 'transaction').and.callFake(async (stores, mode, callback) =>
originalTransaction(stores, mode, async (tx) => {
const failingTx = new Proxy(tx, {
get: (target, property): unknown => {
if (property === 'put') {
return async (store: string, value: unknown, key?: string | number) => {
if (store === STORE_NAMES.VECTOR_CLOCK) {
throw new Error('injected mixed-batch clock failure');
}
return target.put(store, value, key);
};
}
const value = Reflect.get(target, property);
return typeof value === 'function' ? value.bind(target) : value;
},
});
return callback(failingTx);
}),
);
await expectAsync(
service.appendMixedSourceBatchSkipDuplicates([
{
ops: [
createTestOperation({
id: 'remote-loser',
clientId: 'remoteClient',
}),
],
source: 'remote',
},
{
ops: [createTestOperation({ id: 'compensation' })],
source: 'local',
},
]),
).toBeRejectedWithError('injected mixed-batch clock failure');
expect(await service.getOpsAfterSeq(0)).toEqual([]);
service.clearVectorClockCache();
expect(await service.getVectorClock()).toEqual({ testClient: 4 });
});
});
describe('getOpById', () => {
it('should return operation entry by ID', async () => {
const op = createTestOperation();
@ -1423,6 +1567,102 @@ describe('OperationLogStoreService', () => {
});
describe('markApplied', () => {
it('should checkpoint reducer-committed operations as archive_pending', async () => {
const op = createTestOperation();
const seq = await service.append(op, 'remote', { pendingApply: true });
await service.markReducersCommittedAndMergeClocks([seq], [op]);
const [stored] = await service.getOpsAfterSeq(0);
expect(stored.applicationStatus).toBe('archive_pending');
// No attempt was made, so no retry budget is charged.
expect(stored.retryCount).toBeUndefined();
expect((await service.getPendingRemoteOps()).length).toBe(0);
expect((await service.getFailedRemoteOps()).map((entry) => entry.op.id)).toEqual([
op.id,
]);
});
it('should atomically checkpoint reducer commit and merge its vector clock', async () => {
const op = createTestOperation();
const seq = await service.append(op, 'remote', { pendingApply: true });
await service.setVectorClock({ testClient: 2 });
await service.markReducersCommittedAndMergeClocks(
[seq],
[{ ...op, vectorClock: { remoteClient: 4 } }],
);
const [stored] = await service.getOpsAfterSeq(0);
expect(stored.applicationStatus).toBe('archive_pending');
expect(await service.getVectorClock()).toEqual({
testClient: 2,
remoteClient: 4,
});
});
it('should roll back reducer checkpoint and clock when the atomic clock write fails', async () => {
const op = createTestOperation();
const seq = await service.append(op, 'remote', { pendingApply: true });
await service.setVectorClock({ testClient: 2 });
const adapter = (
service as unknown as {
_adapter: OpLogDbAdapter;
}
)._adapter;
const originalTransaction = adapter.transaction.bind(adapter);
spyOn(adapter, 'transaction').and.callFake(async (stores, mode, callback) =>
originalTransaction(stores, mode, async (tx) => {
const failingTx = new Proxy(tx, {
get: (target, property): unknown => {
if (property === 'put') {
return async (store: string, value: unknown, key?: string | number) => {
if (store === STORE_NAMES.VECTOR_CLOCK) {
throw new Error('injected vector-clock write failure');
}
return target.put(store, value, key);
};
}
const value = Reflect.get(target, property);
return typeof value === 'function' ? value.bind(target) : value;
},
});
return callback(failingTx);
}),
);
await expectAsync(
service.markReducersCommittedAndMergeClocks(
[seq],
[{ ...op, vectorClock: { remoteClient: 4 } }],
),
).toBeRejectedWithError('injected vector-clock write failure');
const [stored] = await service.getOpsAfterSeq(0);
expect(stored.applicationStatus).toBe('pending');
service.clearVectorClockCache();
expect(await service.getVectorClock()).toEqual({ testClient: 2 });
});
it('should abort the atomic checkpoint when a row is missing or no longer pending', async () => {
const op = createTestOperation();
const seq = await service.append(op, 'remote');
await service.setVectorClock({ testClient: 2 });
await expectAsync(
service.markReducersCommittedAndMergeClocks(
[seq],
[{ ...op, vectorClock: { remoteClient: 4 } }],
),
).toBeRejectedWithError(/requires pending remote operation/);
const [stored] = await service.getOpsAfterSeq(0);
expect(stored.applicationStatus).toBe('applied');
service.clearVectorClockCache();
expect(await service.getVectorClock()).toEqual({ testClient: 2 });
});
it('should update applicationStatus from pending to applied', async () => {
const op = createTestOperation();
const seq = await service.append(op, 'remote', { pendingApply: true });
@ -1478,6 +1718,17 @@ describe('OperationLogStoreService', () => {
expect(afterMarkApplied[0].applicationStatus).toBe('applied');
});
it('should update applicationStatus from reducer-commit checkpoint to applied', async () => {
const op = createTestOperation();
const seq = await service.append(op, 'remote', { pendingApply: true });
await service.markReducersCommittedAndMergeClocks([seq], [op]);
await service.markApplied([seq]);
const [stored] = await service.getOpsAfterSeq(0);
expect(stored.applicationStatus).toBe('applied');
});
it('should remove failed ops from getFailedRemoteOps after markApplied is called', async () => {
// Create an op and mark it as pending
const op = createTestOperation();
@ -1526,9 +1777,48 @@ describe('OperationLogStoreService', () => {
expect(pending.length).toBe(0);
});
it('should exclude rejected ops (parity with getFailedRemoteOps)', async () => {
// A rejected-but-still-pending row must not trip the incomplete-remote
// sync gate: nothing will ever apply it, so counting it would wedge sync
// for the whole session.
const op = createTestOperation({ entityId: 'rejected-pending' });
await service.append(op, 'remote', { pendingApply: true });
await service.markRejected([op.id]);
const pending = await service.getPendingRemoteOps();
expect(pending.length).toBe(0);
});
});
describe('markFailed', () => {
const seedLegacyTerminalRemoteFailure = async (op: Operation): Promise<void> => {
await service.append(op, 'remote', { pendingApply: true });
for (let retry = 0; retry < 4; retry++) {
await service.markFailed([op.id]);
}
await service.markRejected([op.id]);
const adapter = (
service as unknown as {
_adapter: OpLogDbAdapter;
}
)._adapter;
await adapter.transaction([STORE_NAMES.OPS], 'readwrite', async (tx) => {
const entry = await tx.getFromIndex<OperationLogEntry>(
STORE_NAMES.OPS,
OPS_INDEXES.BY_ID,
op.id,
);
if (!entry) {
throw new Error('Expected seeded legacy operation');
}
entry.applicationStatus = undefined;
await tx.put(STORE_NAMES.OPS, entry);
});
};
it('should increment retry count', async () => {
const op = createTestOperation();
await service.append(op, 'remote', { pendingApply: true });
@ -1552,17 +1842,58 @@ describe('OperationLogStoreService', () => {
expect(ops[0].retryCount).toBe(3);
});
it('should mark as rejected when max retries reached', async () => {
it('should keep failed operations quarantined after repeated failures', async () => {
const op = createTestOperation();
await service.append(op, 'remote', { pendingApply: true });
await service.markFailed([op.id], 3); // maxRetries = 3
await service.markFailed([op.id], 3);
await service.markFailed([op.id], 3); // 3rd failure = rejected
await service.markFailed([op.id]);
await service.markFailed([op.id]);
await service.markFailed([op.id]);
const ops = await service.getOpsAfterSeq(0);
expect(ops[0].rejectedAt).toBeDefined();
expect(ops[0].applicationStatus).toBeUndefined();
expect(ops[0].rejectedAt).toBeUndefined();
expect(ops[0].applicationStatus).toBe('failed');
expect(ops[0].retryCount).toBe(3);
});
it('should re-quarantine legacy terminal remote failures', async () => {
const op = createTestOperation();
await seedLegacyTerminalRemoteFailure(op);
expect(await service.recoverLegacyTerminalRemoteFailures()).toBe(1);
const [recovered] = await service.getFailedRemoteOps();
expect(recovered.op.id).toBe(op.id);
expect(recovered.rejectedAt).toBeUndefined();
expect(recovered.applicationStatus).toBe('failed');
});
it('should run the legacy terminal remote failure repair only once', async () => {
const firstLegacyOp = createTestOperation({ entityId: 'first-legacy' });
await seedLegacyTerminalRemoteFailure(firstLegacyOp);
expect(await service.recoverLegacyTerminalRemoteFailures()).toBe(1);
const laterLegacyOp = createTestOperation({ entityId: 'later-legacy' });
await seedLegacyTerminalRemoteFailure(laterLegacyOp);
expect(await service.recoverLegacyTerminalRemoteFailures()).toBe(0);
expect((await service.getOpById(laterLegacyOp.id))?.rejectedAt).toBeDefined();
});
it('should leave legitimately rejected failed remote work untouched', async () => {
const op = createTestOperation({ entityId: 'legitimately-rejected' });
await service.append(op, 'remote', { pendingApply: true });
for (let retry = 0; retry < 4; retry++) {
await service.markFailed([op.id]);
}
await service.markRejected([op.id]);
expect(await service.recoverLegacyTerminalRemoteFailures()).toBe(0);
const stored = await service.getOpById(op.id);
expect(stored?.rejectedAt).toBeDefined();
expect(stored?.applicationStatus).toBe('failed');
});
it('should handle empty array', async () => {
@ -1775,7 +2106,7 @@ describe('OperationLogStoreService', () => {
expect(unsynced2[0].op.id).toBe(op2.id);
});
it('should invalidate cache when markFailed terminally rejects an op', async () => {
it('should keep local failed ops unsynced after repeated failures', async () => {
const op1 = createTestOperation({ entityId: 'task1' });
const op2 = createTestOperation({ entityId: 'task2' });
await service.append(op1);
@ -1784,11 +2115,10 @@ describe('OperationLogStoreService', () => {
const unsynced1 = await service.getUnsynced();
expect(unsynced1.length).toBe(2);
await service.markFailed([op1.id], 1);
await service.markFailed([op1.id]);
const unsynced2 = await service.getUnsynced();
expect(unsynced2.length).toBe(1);
expect(unsynced2[0].op.id).toBe(op2.id);
expect(unsynced2.map((entry) => entry.op.id)).toEqual([op1.id, op2.id]);
});
it('should not include already synced ops when incrementally updating', async () => {
@ -1887,16 +2217,29 @@ describe('OperationLogStoreService', () => {
it('should save and load import backup', async () => {
const state = { tasks: ['task1', 'task2'], projects: [] };
const savedAt = await service.saveImportBackup(state);
const backupRef = await service.saveImportBackup(state);
const backup = await service.loadImportBackup();
expect(backup).not.toBeNull();
expect(backup!.state).toEqual(state);
expect(backup!.savedAt).toBeDefined();
expect(typeof backup!.savedAt).toBe('number');
// The returned token is the provenance value persisted in the slot, so
// callers can later confirm the backup hasn't been replaced. (#8107)
expect(savedAt).toBe(backup!.savedAt);
expect(backup!.backupId).toBeDefined();
expect(backupRef).toEqual({
backupId: backup!.backupId,
savedAt: backup!.savedAt,
});
});
it('should assign distinct opaque IDs to same-millisecond replacements', async () => {
spyOn(Date, 'now').and.returnValue(1234);
const first = await service.saveImportBackup({ version: 1 });
const second = await service.saveImportBackup({ version: 2 });
expect(first.savedAt).toBe(second.savedAt);
expect(first.backupId).not.toBe(second.backupId);
expect((await service.loadImportBackup())?.backupId).toBe(second.backupId);
});
it('should return null when no backup exists', async () => {
@ -1925,6 +2268,17 @@ describe('OperationLogStoreService', () => {
expect(backup).toBeNull();
});
it('should not let a stale identity clear a replacement backup', async () => {
const first = await service.saveImportBackup({ version: 1 });
const second = await service.saveImportBackup({ version: 2 });
await service.clearImportBackup(first.backupId);
expect((await service.loadImportBackup())?.backupId).toBe(second.backupId);
await service.clearImportBackup(second.backupId);
expect(await service.loadImportBackup()).toBeNull();
});
it('should check if backup exists with hasImportBackup', async () => {
expect(await service.hasImportBackup()).toBe(false);
@ -2076,6 +2430,30 @@ describe('OperationLogStoreService', () => {
lastTimeTrackingFlush: 0,
});
it('should atomically clear an interrupted raw-rebuild marker', async () => {
await service.runRemoteStateReplacement({
baselineState: { task: { ids: [], entities: {} } },
vectorClock: { remote: 1 },
schemaVersion: 4,
snapshotEntityKeys: [],
archiveYoung: createArchive('remote-young'),
archiveOld: createArchive('remote-old'),
});
expect(await service.isRawRebuildIncomplete()).toBe(true);
await service.runDestructiveStateReplacement({
syncImportOp: createTestOperation({
opType: OpType.BackupImport,
entityType: 'ALL' as EntityType,
entityId: 'restored-backup',
payload: { task: { ids: [], entities: {} } },
}),
snapshotEntityKeys: [],
});
expect(await service.isRawRebuildIncomplete()).toBe(false);
});
it('should write archives in the same destructive replacement', async () => {
const archiveYoung = createArchive('young-task');
const archiveOld = createArchive('old-task');
@ -2301,6 +2679,323 @@ describe('OperationLogStoreService', () => {
});
});
describe('runRemoteStateReplacement', () => {
const createArchive = (taskId: string): ArchiveModel =>
({
task: {
ids: [taskId],
entities: { [taskId]: { id: taskId, title: taskId } },
},
timeTracking: { project: {}, tag: {} },
lastTimeTrackingFlush: 0,
}) as unknown as ArchiveModel;
it('atomically replaces ops, cache, clock, metadata, and both archives', async () => {
await service.append(
createTestOperation({
opType: OpType.SyncImport,
entityType: 'ALL' as EntityType,
}),
);
const baselineState = { task: { ids: [], entities: {} } };
const archiveYoung = createArchive('remote-young');
const archiveOld = createArchive('remote-old');
await service.runRemoteStateReplacement({
baselineState,
vectorClock: { remote: 4 },
schemaVersion: 4,
snapshotEntityKeys: ['TASK:remote-task'],
archiveYoung,
archiveOld,
});
expect(await service.getOpsAfterSeq(0)).toEqual([]);
expect(await service.getLatestFullStateOpEntry()).toBeUndefined();
expect(await service.loadStateCache()).toEqual(
jasmine.objectContaining({
state: baselineState,
lastAppliedOpSeq: 0,
vectorClock: { remote: 4 },
schemaVersion: 4,
snapshotEntityKeys: ['TASK:remote-task'],
}),
);
expect(await service.getVectorClock()).toEqual({ remote: 4 });
const db = (
service as unknown as {
db: IDBPDatabase<unknown>;
}
).db;
expect((await db.get(STORE_NAMES.ARCHIVE_YOUNG, SINGLETON_KEY)).data).toEqual(
archiveYoung,
);
expect((await db.get(STORE_NAMES.ARCHIVE_OLD, SINGLETON_KEY)).data).toEqual(
archiveOld,
);
});
it('sets the raw-rebuild-incomplete marker atomically with the replacement and clears it on demand', async () => {
expect(await service.isRawRebuildIncomplete()).toBe(false);
await service.runRemoteStateReplacement({
baselineState: { task: { ids: [], entities: {} } },
vectorClock: { remote: 1 },
schemaVersion: 4,
snapshotEntityKeys: [],
archiveYoung: createArchive('young'),
archiveOld: createArchive('old'),
});
// A crash after the replacement but before the replay commits must leave
// the marker set so the next sync redoes the raw rebuild.
expect(await service.isRawRebuildIncomplete()).toBe(true);
await service.completeRawRebuild();
expect(await service.isRawRebuildIncomplete()).toBe(false);
});
it('should abort replacement if the captured backup slot was superseded', async () => {
const priorOp = createTestOperation({ id: 'prior-local-op' });
await service.append(priorOp, 'local');
const capturedBackup = await service.saveImportBackup({ version: 1 });
const replacementBackup = await service.saveImportBackup({ version: 2 });
await expectAsync(
service.runRemoteStateReplacement({
baselineState: { task: { ids: [], entities: {} } },
vectorClock: { remote: 1 },
schemaVersion: 4,
snapshotEntityKeys: [],
archiveYoung: createArchive('young'),
archiveOld: createArchive('old'),
backupRef: capturedBackup,
}),
).toBeRejectedWithError(/backup was superseded/);
expect((await service.getOpsAfterSeq(0)).map(({ op }) => op.id)).toEqual([
priorOp.id,
]);
expect((await service.loadImportBackup())?.backupId).toBe(
replacementBackup.backupId,
);
expect(await service.isRawRebuildIncomplete()).toBeFalse();
});
it('atomically transitions a completed rebuild to a durable recovery token', async () => {
await service.runRemoteStateReplacement({
baselineState: { task: { ids: [], entities: {} } },
vectorClock: { remote: 1 },
schemaVersion: 4,
snapshotEntityKeys: [],
archiveYoung: createArchive('young'),
archiveOld: createArchive('old'),
});
const backupRef = {
backupId: 'backup-4242',
savedAt: 4242,
};
await service.saveImportBackup({ original: true });
const adapter = (
service as unknown as {
_adapter: OpLogDbAdapter;
}
)._adapter;
await adapter.put(STORE_NAMES.IMPORT_BACKUP, {
id: SINGLETON_KEY,
state: { original: true },
...backupRef,
});
expect(await service.completeRawRebuild(backupRef)).toBeTrue();
expect(await service.isRawRebuildIncomplete()).toBe(false);
expect(await service.loadRawRebuildRecovery()).toEqual(
jasmine.objectContaining({
backupId: 'backup-4242',
backupSavedAt: 4242,
}),
);
await service.clearRawRebuildRecovery('stale-backup');
expect(await service.loadRawRebuildRecovery()).not.toBeNull();
await service.clearRawRebuildRecovery('backup-4242');
expect(await service.loadRawRebuildRecovery()).toBeNull();
});
it('should identity-guard dismissal retirement of marker and backup', async () => {
const backupRef = await service.saveImportBackup({ original: true });
await service.runRemoteStateReplacement({
baselineState: { task: { ids: [], entities: {} } },
vectorClock: { remote: 1 },
schemaVersion: 4,
snapshotEntityKeys: [],
archiveYoung: createArchive('young'),
archiveOld: createArchive('old'),
backupRef,
});
expect(await service.completeRawRebuild(backupRef)).toBeTrue();
expect(await service.retireCompletedRawRebuildRecovery('stale-backup')).toBeFalse();
expect(await service.loadRawRebuildRecovery()).not.toBeNull();
expect(await service.loadImportBackup()).not.toBeNull();
expect(
await service.retireCompletedRawRebuildRecovery(backupRef.backupId),
).toBeTrue();
expect(await service.loadRawRebuildRecovery()).toBeNull();
expect(await service.loadImportBackup()).toBeNull();
});
it('rolls back the incomplete-to-recovery transition when the token write fails', async () => {
await service.runRemoteStateReplacement({
baselineState: { task: { ids: [], entities: {} } },
vectorClock: { remote: 1 },
schemaVersion: 4,
snapshotEntityKeys: [],
archiveYoung: createArchive('young'),
archiveOld: createArchive('old'),
});
const backupRef = await service.saveImportBackup({ original: true });
const adapter = (
service as unknown as {
_adapter: OpLogDbAdapter;
}
)._adapter;
const originalTransaction = adapter.transaction.bind(adapter);
spyOn(adapter, 'transaction').and.callFake(async (stores, mode, callback) =>
originalTransaction(stores, mode, async (tx) => {
const failingTx = new Proxy(tx, {
get: (target, property): unknown => {
if (property === 'put') {
return async (): Promise<void> => {
throw new Error('injected recovery-token write failure');
};
}
const value = Reflect.get(target, property);
return typeof value === 'function' ? value.bind(target) : value;
},
});
return callback(failingTx);
}),
);
await expectAsync(service.completeRawRebuild(backupRef)).toBeRejectedWithError(
'injected recovery-token write failure',
);
expect(await service.isRawRebuildIncomplete()).toBe(true);
expect(await service.loadRawRebuildRecovery()).toBeNull();
});
it('durably carries post-crash local ops in the rebuild marker', async () => {
const preservedLocalOp = createTestOperation({
id: '01900000-0000-7000-8000-000000000091',
entityId: 'edited-after-crash',
clientId: 'localClient',
vectorClock: { localClient: 2, remote: 1 },
});
const backupRef = await service.saveImportBackup({ original: true });
await service.runRemoteStateReplacement({
baselineState: { task: { ids: [], entities: {} } },
vectorClock: { remote: 1 },
schemaVersion: 4,
snapshotEntityKeys: [],
archiveYoung: createArchive('young'),
archiveOld: createArchive('old'),
preservedLocalOps: [preservedLocalOp],
backupRef,
});
expect(await service.getOpsAfterSeq(0)).toEqual([]);
expect(await service.loadRawRebuildIncomplete()).toEqual(
jasmine.objectContaining({
incomplete: true,
preservedLocalOps: [preservedLocalOp],
backupRef,
}),
);
});
it('rolls back every store if one archive write fails', async () => {
const priorOp = createTestOperation({ entityId: 'prior-task' });
const priorState = { sentinel: 'prior-state' };
const priorYoung = createArchive('prior-young');
const priorOld = createArchive('prior-old');
await service.append(priorOp);
await service.saveStateCache({
state: priorState,
lastAppliedOpSeq: 1,
vectorClock: { testClient: 1 },
compactedAt: Date.now(),
});
await service.setVectorClock({ testClient: 1 });
const db = (
service as unknown as {
db: IDBPDatabase<unknown>;
}
).db;
await db.put(STORE_NAMES.ARCHIVE_YOUNG, {
id: SINGLETON_KEY,
data: priorYoung,
lastModified: 1,
});
await db.put(STORE_NAMES.ARCHIVE_OLD, {
id: SINGLETON_KEY,
data: priorOld,
lastModified: 1,
});
const realTransaction = db.transaction.bind(db);
spyOn(db, 'transaction').and.callFake(((
stores: Parameters<typeof db.transaction>[0],
mode: Parameters<typeof db.transaction>[1],
) => {
const tx = realTransaction(stores, mode);
if (Array.isArray(stores) && stores.includes(STORE_NAMES.ARCHIVE_OLD)) {
const realObjectStore = tx.objectStore.bind(tx);
tx.objectStore = ((storeName: string) => {
const store = realObjectStore(storeName);
if (storeName === STORE_NAMES.ARCHIVE_OLD) {
store.put = async () => {
throw new Error('Simulated archive write failure');
};
}
return store;
}) as typeof tx.objectStore;
}
return tx;
}) as typeof db.transaction);
await expectAsync(
service.runRemoteStateReplacement({
baselineState: { sentinel: 'new-state' },
vectorClock: { remote: 2 },
schemaVersion: 4,
snapshotEntityKeys: [],
archiveYoung: createArchive('new-young'),
archiveOld: createArchive('new-old'),
}),
).toBeRejected();
expect((await service.getOpsAfterSeq(0)).map((entry) => entry.op.id)).toEqual([
priorOp.id,
]);
expect((await service.loadStateCache())!.state).toEqual(priorState);
expect(await service.getVectorClock()).toEqual({ testClient: 1 });
expect((await db.get(STORE_NAMES.ARCHIVE_YOUNG, SINGLETON_KEY)).data).toEqual(
priorYoung,
);
expect((await db.get(STORE_NAMES.ARCHIVE_OLD, SINGLETON_KEY)).data).toEqual(
priorOld,
);
});
});
describe('appendWithVectorClockUpdate', () => {
it('should append operation and update vector clock atomically for local ops', async () => {
const op = createTestOperation({

View file

@ -18,6 +18,10 @@ import {
SINGLETON_KEY,
BACKUP_KEY,
FULL_STATE_OPS_META_KEY,
LEGACY_TERMINAL_REMOTE_FAILURES_MIGRATION_META_KEY,
LEGACY_TERMINAL_REMOTE_FAILURES_MIGRATION_VERSION,
RAW_REBUILD_INCOMPLETE_META_KEY,
RAW_REBUILD_RECOVERY_META_KEY,
OPS_INDEXES,
ArchiveStoreEntry,
ProfileDataStoreEntry,
@ -50,6 +54,7 @@ import {
decodeOperation,
encodeOperation,
} from './compact/operation-codec.service';
import { uuidv7 } from '../../util/uuid-v7';
/**
* Vector clock entry stored in the vector_clock object store.
@ -60,6 +65,27 @@ interface VectorClockEntry {
lastUpdate: number;
}
export interface MixedSourceOperationBatch {
ops: readonly Operation[];
source: 'local' | 'remote';
options?: { pendingApply?: boolean };
}
export interface MixedSourceWrittenOperation {
seq: number;
op: Operation;
source: 'local' | 'remote';
}
export interface ImportBackupRef {
backupId: string;
savedAt: number;
}
export interface ImportBackupEntry extends ImportBackupRef {
state: unknown;
}
/**
* Shape stored in the `state_cache` store (keyPath `id`).
*
@ -79,6 +105,29 @@ interface StateCacheEntry {
snapshotEntityKeys?: string[];
}
export interface RawRebuildIncompleteEntry {
incomplete: true;
startedAt: number;
preservedLocalOps: Operation[];
backupRef?: ImportBackupRef;
}
export interface RawRebuildRecoveryEntry {
backupId: string;
backupSavedAt: number;
completedAt: number;
}
interface LegacyTerminalRemoteFailuresMigrationEntry {
version: number;
}
type OpLogMetaEntry =
| FullStateOpsMetaEntry
| RawRebuildIncompleteEntry
| RawRebuildRecoveryEntry
| LegacyTerminalRemoteFailuresMigrationEntry;
/**
* Stored operation log entry that can hold either compact or full operation format.
* Used internally for backwards compatibility with existing data.
@ -90,7 +139,7 @@ interface StoredOperationLogEntry {
source: 'local' | 'remote';
syncedAt?: number;
rejectedAt?: number;
applicationStatus?: 'pending' | 'applied' | 'failed';
applicationStatus?: 'pending' | 'archive_pending' | 'applied' | 'failed';
retryCount?: number;
}
@ -123,6 +172,53 @@ const getOpId = (op: Operation | CompactOperation): string => {
const getStoredOpType = (op: Operation | CompactOperation): string =>
isCompactOperation(op) ? op.o : op.opType;
/**
* Calculates the durable clock after a reducer-committed remote batch.
*
* Kept pure so both the standalone merge path and the atomic reducer checkpoint
* use exactly the same full-state reset and pruning semantics.
*/
const calculateRemoteClockMerge = (
currentClock: VectorClock,
ops: readonly Operation[],
currentClientId: string | null,
): VectorClock => {
let mergedClock: VectorClock = { ...currentClock };
for (const op of ops) {
if (FULL_STATE_OP_TYPES.has(op.opType)) {
const clockBeforeReset = mergedClock;
if (!currentClientId) {
mergedClock = { ...op.vectorClock };
continue;
}
const resetClock: VectorClock = {};
const importCounter = op.vectorClock[op.clientId];
if (importCounter !== undefined) {
resetClock[op.clientId] = importCounter;
}
const ownCounter = Math.max(
clockBeforeReset[currentClientId] ?? 0,
op.vectorClock[currentClientId] ?? 0,
);
if (ownCounter > 0) {
resetClock[currentClientId] = ownCounter;
}
mergedClock = resetClock;
continue;
}
for (const [clientId, counter] of Object.entries(op.vectorClock)) {
mergedClock[clientId] = Math.max(mergedClock[clientId] ?? 0, counter);
}
}
return currentClientId
? limitVectorClockSize(mergedClock, currentClientId)
: mergedClock;
};
// Note: DBSchema requires literal string keys matching STORE_NAMES values
interface OpLogDB extends DBSchema {
[STORE_NAMES.OPS]: {
@ -154,6 +250,7 @@ interface OpLogDB extends DBSchema {
id: string;
state: unknown;
savedAt: number;
backupId?: string;
};
};
/**
@ -204,7 +301,7 @@ interface OpLogDB extends DBSchema {
*/
[STORE_NAMES.META]: {
key: string;
value: FullStateOpsMetaEntry;
value: OpLogMetaEntry;
};
}
@ -679,6 +776,171 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
}
}
/**
* Atomically appends ordered batches from different sources while skipping
* existing operation IDs. Local operations are rebased on the durable clock
* inside the same transaction, so multiple synthetic operations cannot reuse
* or regress this client's counter.
*
* Batch order is durable sequence order. Conflict resolution relies on this
* to persist remote loser rows before the local compensations that supersede
* them, without exposing a crash point between the two groups.
*/
async appendMixedSourceBatchSkipDuplicates(
batches: readonly MixedSourceOperationBatch[],
): Promise<{ written: MixedSourceWrittenOperation[]; skippedCount: number }> {
const nonEmptyBatches = batches.filter((batch) => batch.ops.length > 0);
if (nonEmptyBatches.length === 0) {
return { written: [], skippedCount: 0 };
}
await this._ensureInit();
const hasLocalOps = nonEmptyBatches.some((batch) => batch.source === 'local');
const currentClientId = hasLocalOps
? await this.clientIdProvider.loadClientId()
: null;
if (hasLocalOps && !currentClientId) {
throw new Error('Cannot append local operations without a current client ID.');
}
const storeNames: OpLogStoreName[] = [STORE_NAMES.OPS, STORE_NAMES.VECTOR_CLOCK];
if (
nonEmptyBatches.some((batch) =>
batch.ops.some((op) => isFullStateOpType(op.opType)),
)
) {
storeNames.push(STORE_NAMES.META);
}
const written: MixedSourceWrittenOperation[] = [];
let skippedCount = 0;
let committedClock: VectorClock | undefined;
try {
await this._adapter.transaction(storeNames, 'readwrite', async (tx) => {
const currentClockEntry = hasLocalOps
? await tx.get<VectorClockEntry>(STORE_NAMES.VECTOR_CLOCK, SINGLETON_KEY)
: undefined;
let runningClock: VectorClock = { ...(currentClockEntry?.clock ?? {}) };
let didWriteLocal = false;
for (const batch of nonEmptyBatches) {
for (const proposedOp of batch.ops) {
const existingKey = await tx.getKeyFromIndex(
STORE_NAMES.OPS,
OPS_INDEXES.BY_ID,
proposedOp.id,
);
if (existingKey !== undefined) {
skippedCount++;
continue;
}
let op = proposedOp;
if (batch.source === 'local') {
if (proposedOp.clientId !== currentClientId) {
throw new Error(
'Cannot append a local operation for a non-current client ID.',
);
}
const mergedClock: VectorClock = { ...runningClock };
for (const [clientId, counter] of Object.entries(proposedOp.vectorClock)) {
mergedClock[clientId] = Math.max(mergedClock[clientId] ?? 0, counter);
}
mergedClock[currentClientId] = Math.max(
(runningClock[currentClientId] ?? 0) + 1,
proposedOp.vectorClock[currentClientId] ?? 0,
);
runningClock = mergedClock;
op = { ...proposedOp, vectorClock: mergedClock };
didWriteLocal = true;
}
const entry = this._buildStoredEntry(op, batch.source, batch.options);
const seq = await tx.add(STORE_NAMES.OPS, entry);
await this._recordFullStateOpInTx(tx, entry.op, seq);
written.push({ seq, op, source: batch.source });
}
}
if (didWriteLocal) {
committedClock = runningClock;
await tx.put(
STORE_NAMES.VECTOR_CLOCK,
{ clock: runningClock, lastUpdate: Date.now() } satisfies VectorClockEntry,
SINGLETON_KEY,
);
}
});
} catch (e) {
this._handleAppendError(e);
}
if (committedClock) {
this._vectorClockCache = { ...committedClock };
}
return { written, skippedCount };
}
/**
* Atomically records reducer completion and merges the corresponding clocks.
* A committed reducer must never be durable without its clock: that would let
* the next local operation be causally older than state already visible in
* NgRx. The in-memory cache is updated only after the transaction commits.
*/
async markReducersCommittedAndMergeClocks(
seqs: number[],
ops: Operation[],
): Promise<void> {
if (seqs.length !== ops.length) {
throw new Error(
'markReducersCommittedAndMergeClocks requires one sequence per operation.',
);
}
if (ops.length === 0) {
return;
}
await this._ensureInit();
const currentClientId = await this.clientIdProvider.loadClientId();
let committedClock: VectorClock | undefined;
await this._adapter.transaction(
[STORE_NAMES.OPS, STORE_NAMES.VECTOR_CLOCK],
'readwrite',
async (tx) => {
const currentEntry = await tx.get<VectorClockEntry>(
STORE_NAMES.VECTOR_CLOCK,
SINGLETON_KEY,
);
committedClock = calculateRemoteClockMerge(
currentEntry?.clock ?? {},
ops,
currentClientId,
);
for (const seq of seqs) {
const entry = await tx.get<StoredOperationLogEntry>(STORE_NAMES.OPS, seq);
if (entry?.applicationStatus !== 'pending') {
throw new Error(
`Reducer checkpoint requires pending remote operation at seq ${seq}.`,
);
}
entry.applicationStatus = 'archive_pending';
await tx.put(STORE_NAMES.OPS, entry);
}
await tx.put(
STORE_NAMES.VECTOR_CLOCK,
{ clock: committedClock, lastUpdate: Date.now() } satisfies VectorClockEntry,
SINGLETON_KEY,
);
},
);
this._vectorClockCache = committedClock ? { ...committedClock } : null;
}
/**
* Marks operations as successfully applied.
* Called after remote operations have been dispatched to NgRx.
@ -689,11 +951,12 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
await this._adapter.transaction([STORE_NAMES.OPS], 'readwrite', async (tx) => {
for (const seq of seqs) {
const entry = await tx.get<StoredOperationLogEntry>(STORE_NAMES.OPS, seq);
// Allow transitioning from 'pending' or 'failed' to 'applied'
// 'failed' ops can be retried and need to be cleared when successful
// Reducer-committed/failed ops can be retried and cleared when successful.
if (
entry &&
(entry.applicationStatus === 'pending' || entry.applicationStatus === 'failed')
(entry.applicationStatus === 'pending' ||
entry.applicationStatus === 'archive_pending' ||
entry.applicationStatus === 'failed')
) {
entry.applicationStatus = 'applied';
await tx.put(STORE_NAMES.OPS, entry);
@ -728,8 +991,10 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
(entry) => entry.source === 'remote' && entry.applicationStatus === 'pending',
);
}
// Decode compact operations for backwards compatibility
return storedEntries.map(decodeStoredEntry);
// Exclude rejected ops (mirrors getFailedRemoteOps): a rejected-but-still-
// pending row must not trip the incomplete-remote sync gate — nothing will
// ever apply it, so it would wedge sync for the whole session.
return storedEntries.filter((e) => !e.rejectedAt).map(decodeStoredEntry);
}
async hasOp(id: string): Promise<boolean> {
@ -1063,14 +1328,12 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
}
/**
* Marks operations as failed (can be retried later).
* Increments the retry count for each operation.
* If maxRetries is provided and reached, marks as rejected instead.
* Marks operations as failed (can be retried later) and increments retry count.
* Remote reducer/archive work must never become rejected merely because it
* retried often: rejection would hide incomplete downloaded state from sync.
*/
async markFailed(opIds: string[], maxRetries?: number): Promise<void> {
async markFailed(opIds: string[]): Promise<void> {
await this._ensureInit();
const now = Date.now();
let terminallyRejected = false;
await this._adapter.transaction([STORE_NAMES.OPS], 'readwrite', async (tx) => {
for (const opId of opIds) {
const entry = await tx.getFromIndex<StoredOperationLogEntry>(
@ -1081,39 +1344,90 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
if (entry) {
const newRetryCount = (entry.retryCount ?? 0) + 1;
// If max retries reached, mark as rejected permanently
if (maxRetries !== undefined && newRetryCount >= maxRetries) {
entry.rejectedAt = now;
entry.applicationStatus = undefined;
terminallyRejected = true;
} else {
entry.applicationStatus = 'failed';
entry.retryCount = newRetryCount;
}
entry.applicationStatus = 'failed';
entry.retryCount = newRetryCount;
await tx.put(STORE_NAMES.OPS, entry);
}
}
});
if (terminallyRejected) {
this._invalidateUnsyncedCache();
}
}
/**
* Gets remote operations that failed and can be retried.
* These are ops that were attempted but failed (e.g., missing dependency).
* Upgrade repair for versions that terminally rejected remote archive work
* after five attempts. Those rows retained retryCount=4 while rejectedAt was
* set and applicationStatus cleared. Re-quarantine them so startup archive
* retry and the incomplete-remote sync gate can see them again.
*/
async recoverLegacyTerminalRemoteFailures(): Promise<number> {
await this._ensureInit();
let recoveredCount = 0;
await this._adapter.transaction(
[STORE_NAMES.OPS, STORE_NAMES.META],
'readwrite',
async (tx) => {
const migration = await tx.get<LegacyTerminalRemoteFailuresMigrationEntry>(
STORE_NAMES.META,
LEGACY_TERMINAL_REMOTE_FAILURES_MIGRATION_META_KEY,
);
if (
(migration?.version ?? 0) >= LEGACY_TERMINAL_REMOTE_FAILURES_MIGRATION_VERSION
) {
return;
}
const entries = await tx.getAll<StoredOperationLogEntry>(STORE_NAMES.OPS);
for (const entry of entries) {
if (
entry.source === 'remote' &&
entry.rejectedAt !== undefined &&
entry.applicationStatus === undefined &&
(entry.retryCount ?? 0) >= 4
) {
entry.rejectedAt = undefined;
entry.applicationStatus = 'failed';
await tx.put(STORE_NAMES.OPS, entry);
recoveredCount++;
}
}
await tx.put(
STORE_NAMES.META,
{
version: LEGACY_TERMINAL_REMOTE_FAILURES_MIGRATION_VERSION,
} satisfies LegacyTerminalRemoteFailuresMigrationEntry,
LEGACY_TERMINAL_REMOTE_FAILURES_MIGRATION_META_KEY,
);
},
);
return recoveredCount;
}
/**
* Gets remote operations whose archive work is incomplete and can be retried.
* Includes both reducer-committed rows whose archive handler has not run and
* attempted failures.
* PERF: Uses compound index to reduce scan scope, then filters by rejectedAt.
*/
async getFailedRemoteOps(): Promise<OperationLogEntry[]> {
await this._ensureInit();
let storedEntries: StoredOperationLogEntry[];
try {
// Exact compound-key match expressed as a degenerate [k, k] range.
storedEntries = await this._adapter.getAllFromIndex<StoredOperationLogEntry>(
STORE_NAMES.OPS,
OPS_INDEXES.BY_SOURCE_AND_STATUS,
{ lower: ['remote', 'failed'], upper: ['remote', 'failed'] },
);
const [archivePendingEntries, failedEntries] = await Promise.all([
this._adapter.getAllFromIndex<StoredOperationLogEntry>(
STORE_NAMES.OPS,
OPS_INDEXES.BY_SOURCE_AND_STATUS,
{
lower: ['remote', 'archive_pending'],
upper: ['remote', 'archive_pending'],
},
),
this._adapter.getAllFromIndex<StoredOperationLogEntry>(
STORE_NAMES.OPS,
OPS_INDEXES.BY_SOURCE_AND_STATUS,
{ lower: ['remote', 'failed'], upper: ['remote', 'failed'] },
),
]);
storedEntries = [...archivePendingEntries, ...failedEntries];
} catch (e) {
// Fallback for databases created before version 3 index migration
Log.warn(
@ -1121,7 +1435,10 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
);
const allOps = await this._adapter.getAll<StoredOperationLogEntry>(STORE_NAMES.OPS);
storedEntries = allOps.filter(
(entry) => entry.source === 'remote' && entry.applicationStatus === 'failed',
(entry) =>
entry.source === 'remote' &&
(entry.applicationStatus === 'archive_pending' ||
entry.applicationStatus === 'failed'),
);
}
// Decode and filter out rejected ops
@ -1440,37 +1757,74 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
* Migrated to route through `_adapter` (Phase A). Behavior is identical:
* the adapter operates on the same connection adopted in `init()`.
*/
async saveImportBackup(state: unknown): Promise<number> {
async saveImportBackup(state: unknown): Promise<ImportBackupRef> {
await this._ensureInit();
const savedAt = Date.now();
const backupId = uuidv7();
await this._adapter.put(STORE_NAMES.IMPORT_BACKUP, {
id: SINGLETON_KEY,
state,
savedAt,
backupId,
});
// Returned so callers can later confirm the (single-slot) backup is still
// the one they captured before restoring it — see BackupService. (#8107)
return savedAt;
return { backupId, savedAt };
}
/**
* Loads the import backup, if one exists.
*/
async loadImportBackup(): Promise<{ state: unknown; savedAt: number } | null> {
async loadImportBackup(): Promise<ImportBackupEntry | null> {
await this._ensureInit();
const backup = await this._adapter.get<{ state: unknown; savedAt: number }>(
STORE_NAMES.IMPORT_BACKUP,
SINGLETON_KEY,
return this._adapter.transaction(
[STORE_NAMES.IMPORT_BACKUP],
'readwrite',
async (tx) => {
const backup = await tx.get<{
state: unknown;
savedAt: number;
backupId?: string;
}>(STORE_NAMES.IMPORT_BACKUP, SINGLETON_KEY);
if (!backup) {
return null;
}
// Lazily give pre-token backup rows an opaque identity. From this read
// onward even a same-millisecond slot replacement cannot masquerade as
// the backup offered by a durable Undo marker.
const backupId = backup.backupId ?? uuidv7();
if (backup.backupId === undefined) {
await tx.put(STORE_NAMES.IMPORT_BACKUP, {
id: SINGLETON_KEY,
...backup,
backupId,
});
}
return { state: backup.state, savedAt: backup.savedAt, backupId };
},
);
return backup ? { state: backup.state, savedAt: backup.savedAt } : null;
}
/**
* Clears the import backup.
*/
async clearImportBackup(): Promise<void> {
async clearImportBackup(expectedBackupId?: string): Promise<void> {
await this._ensureInit();
await this._adapter.delete(STORE_NAMES.IMPORT_BACKUP, SINGLETON_KEY);
await this._adapter.transaction(
[STORE_NAMES.IMPORT_BACKUP],
'readwrite',
async (tx) => {
if (expectedBackupId !== undefined) {
const current = await tx.get<{ backupId?: string }>(
STORE_NAMES.IMPORT_BACKUP,
SINGLETON_KEY,
);
if (current?.backupId !== expectedBackupId) {
return;
}
}
await tx.delete(STORE_NAMES.IMPORT_BACKUP, SINGLETON_KEY);
},
);
}
/**
@ -1504,6 +1858,259 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
this._invalidateAppliedAndUnsyncedCaches();
}
/**
* Atomically prepares the local persistence baseline for an authoritative
* remote rebuild. The remote operations are replayed after this transaction,
* but every committed intermediate state is self-consistent: an empty op-log,
* the supplied baseline snapshot, its vector clock, and authoritative archive
* contents all become visible together.
*
* If replay is interrupted, startup hydrates this baseline and the next sync
* resumes from server cursor 0. It can never combine a cleared op-log with the
* stale pre-replacement state cache or archives.
*/
async runRemoteStateReplacement(opts: {
baselineState: unknown;
vectorClock: VectorClock;
schemaVersion: number;
snapshotEntityKeys: string[];
archiveYoung: ArchiveStoreEntry['data'];
archiveOld: ArchiveStoreEntry['data'];
preservedLocalOps?: Operation[];
backupRef?: ImportBackupRef;
}): Promise<void> {
await this._ensureInit();
const now = Date.now();
try {
await this._adapter.transaction(
[
STORE_NAMES.OPS,
STORE_NAMES.META,
STORE_NAMES.STATE_CACHE,
STORE_NAMES.VECTOR_CLOCK,
STORE_NAMES.ARCHIVE_YOUNG,
STORE_NAMES.ARCHIVE_OLD,
STORE_NAMES.IMPORT_BACKUP,
],
'readwrite',
async (tx) => {
if (opts.backupRef) {
const currentBackup = await tx.get<{ backupId?: string }>(
STORE_NAMES.IMPORT_BACKUP,
SINGLETON_KEY,
);
if (currentBackup?.backupId !== opts.backupRef.backupId) {
throw new Error(
'Pre-replace backup was superseded before remote replacement.',
);
}
}
await tx.clear(STORE_NAMES.OPS);
await tx.put(
STORE_NAMES.META,
buildFullStateOpsMeta([]),
FULL_STATE_OPS_META_KEY,
);
// Set atomically with the replacement; the caller clears it after
// the post-replacement replay commits. A crash in between leaves the
// marker set so the next sync redoes the raw rebuild instead of a
// normal download (which excludes this client's own ops).
await tx.put(
STORE_NAMES.META,
{
incomplete: true,
startedAt: now,
preservedLocalOps: opts.preservedLocalOps ?? [],
backupRef: opts.backupRef,
} satisfies RawRebuildIncompleteEntry,
RAW_REBUILD_INCOMPLETE_META_KEY,
);
// A new replacement supersedes any earlier completed-rebuild Undo.
// The new backup token becomes authoritative only on completion.
await tx.delete(STORE_NAMES.META, RAW_REBUILD_RECOVERY_META_KEY);
await tx.put(STORE_NAMES.STATE_CACHE, {
id: SINGLETON_KEY,
state: opts.baselineState,
lastAppliedOpSeq: 0,
vectorClock: opts.vectorClock,
compactedAt: now,
schemaVersion: opts.schemaVersion,
snapshotEntityKeys: opts.snapshotEntityKeys,
});
await tx.put(
STORE_NAMES.VECTOR_CLOCK,
{ clock: opts.vectorClock, lastUpdate: now },
SINGLETON_KEY,
);
await tx.put(STORE_NAMES.ARCHIVE_YOUNG, {
id: SINGLETON_KEY,
data: opts.archiveYoung,
lastModified: now,
});
await tx.put(STORE_NAMES.ARCHIVE_OLD, {
id: SINGLETON_KEY,
data: opts.archiveOld,
lastModified: now,
});
},
);
this._invalidateAppliedAndUnsyncedCaches();
this._vectorClockCache = { ...opts.vectorClock };
} catch (e) {
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
throw new StorageQuotaExceededError();
}
throw e;
}
}
/**
* Whether a USE_REMOTE raw rebuild committed its baseline replacement but
* has not (yet) committed the follow-up server-history replay. See
* RAW_REBUILD_INCOMPLETE_META_KEY.
*/
async isRawRebuildIncomplete(): Promise<boolean> {
return (await this.loadRawRebuildIncomplete()) !== null;
}
/**
* Loads the durable resume marker, including local operations created after
* an interrupted replacement. Older markers did not contain the operation
* array, so they normalize to an empty list.
*/
async loadRawRebuildIncomplete(): Promise<RawRebuildIncompleteEntry | null> {
await this._ensureInit();
const entry = await this._adapter.get<Partial<RawRebuildIncompleteEntry>>(
STORE_NAMES.META,
RAW_REBUILD_INCOMPLETE_META_KEY,
);
if (entry?.incomplete !== true) {
return null;
}
return {
incomplete: true,
startedAt: typeof entry.startedAt === 'number' ? entry.startedAt : 0,
preservedLocalOps: Array.isArray(entry.preservedLocalOps)
? entry.preservedLocalOps
: [],
backupRef:
typeof entry.backupRef?.backupId === 'string' &&
typeof entry.backupRef.savedAt === 'number'
? {
backupId: entry.backupRef.backupId,
savedAt: entry.backupRef.savedAt,
}
: undefined,
};
}
/**
* Atomically transitions a raw rebuild from resumable/incomplete to complete.
* When a pre-replace backup exists, its provenance token remains durable so
* startup can re-offer Undo after a reload.
*/
async completeRawRebuild(backup?: ImportBackupRef): Promise<boolean> {
await this._ensureInit();
return this._adapter.transaction(
[STORE_NAMES.META, STORE_NAMES.IMPORT_BACKUP],
'readwrite',
async (tx) => {
const currentBackup = backup
? await tx.get<{ backupId?: string }>(STORE_NAMES.IMPORT_BACKUP, SINGLETON_KEY)
: undefined;
const hasMatchingBackup =
backup !== undefined && currentBackup?.backupId === backup.backupId;
await tx.delete(STORE_NAMES.META, RAW_REBUILD_INCOMPLETE_META_KEY);
if (!hasMatchingBackup) {
await tx.delete(STORE_NAMES.META, RAW_REBUILD_RECOVERY_META_KEY);
return false;
}
await tx.put(
STORE_NAMES.META,
{
backupId: backup.backupId,
backupSavedAt: backup.savedAt,
completedAt: Date.now(),
} satisfies RawRebuildRecoveryEntry,
RAW_REBUILD_RECOVERY_META_KEY,
);
return true;
},
);
}
async loadRawRebuildRecovery(): Promise<RawRebuildRecoveryEntry | null> {
await this._ensureInit();
const entry = await this._adapter.get<Partial<RawRebuildRecoveryEntry>>(
STORE_NAMES.META,
RAW_REBUILD_RECOVERY_META_KEY,
);
if (
typeof entry?.backupSavedAt !== 'number' ||
typeof entry.backupId !== 'string' ||
typeof entry.completedAt !== 'number'
) {
return null;
}
return {
backupId: entry.backupId,
backupSavedAt: entry.backupSavedAt,
completedAt: entry.completedAt,
};
}
async clearRawRebuildRecovery(expectedBackupId?: string): Promise<void> {
await this._ensureInit();
await this._adapter.transaction([STORE_NAMES.META], 'readwrite', async (tx) => {
if (expectedBackupId !== undefined) {
const current = await tx.get<Partial<RawRebuildRecoveryEntry>>(
STORE_NAMES.META,
RAW_REBUILD_RECOVERY_META_KEY,
);
if (current?.backupId !== expectedBackupId) {
return;
}
}
await tx.delete(STORE_NAMES.META, RAW_REBUILD_RECOVERY_META_KEY);
});
}
/**
* Retires an explicitly dismissed completed-rebuild Undo. Both deletes are
* identity-guarded in one transaction so a stale snack can never clear a
* newer recovery marker or backup occupying the single slot.
*/
async retireCompletedRawRebuildRecovery(backupId: string): Promise<boolean> {
await this._ensureInit();
return this._adapter.transaction(
[STORE_NAMES.META, STORE_NAMES.IMPORT_BACKUP],
'readwrite',
async (tx) => {
const recovery = await tx.get<Partial<RawRebuildRecoveryEntry>>(
STORE_NAMES.META,
RAW_REBUILD_RECOVERY_META_KEY,
);
if (recovery?.backupId !== backupId) {
return false;
}
await tx.delete(STORE_NAMES.META, RAW_REBUILD_RECOVERY_META_KEY);
const backup = await tx.get<{ backupId?: string }>(
STORE_NAMES.IMPORT_BACKUP,
SINGLETON_KEY,
);
if (backup?.backupId === backupId) {
await tx.delete(STORE_NAMES.IMPORT_BACKUP, SINGLETON_KEY);
}
return true;
},
);
}
// ============================================================
// Vector Clock Management (Performance Optimization)
// ============================================================
@ -1576,10 +2183,8 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
* 5. These ops are compared as CONCURRENT with the import, not GREATER_THAN
* 6. SyncImportFilterService incorrectly filters them as "invalidated by import"
*
* NOTE: When a full-state op (SYNC_IMPORT/BACKUP_IMPORT/REPAIR) is present,
* its clock REPLACES (not merges with) the local clock. Callers must not mix
* pre-import and post-import ops in a single call all ops in the batch
* should belong to the same "epoch" (post-import or no import).
* Full-state ops reset the clock at their position in the batch. Operations
* after the final reset are merged onto that new epoch in order.
*
* @param ops Remote operations whose clocks should be merged into local clock
*/
@ -1598,16 +2203,12 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
` Merging ${ops.length} remote ops`,
);
// Check if any op is a full-state operation (SYNC_IMPORT / BACKUP_IMPORT / REPAIR).
// Full-state ops represent a complete state reset — old clock entries are irrelevant.
// Using the import's clock as the base (REPLACE) instead of the current clock (MERGE)
// prevents clock bloat that causes server-side pruning to drop the import's entry,
// which would make subsequent ops appear CONCURRENT with the import.
const fullStateOp = ops.find((op) => FULL_STATE_OP_TYPES.has(op.opType));
const mergedClock = fullStateOp
? { ...fullStateOp.vectorClock }
: { ...currentClock };
let fullStateOp: Operation | undefined;
for (const op of ops) {
if (FULL_STATE_OP_TYPES.has(op.opType)) {
fullStateOp = op;
}
}
if (fullStateOp) {
Log.log(
@ -1619,12 +2220,6 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
);
}
for (const op of ops) {
for (const [clientId, counter] of Object.entries(op.vectorClock)) {
mergedClock[clientId] = Math.max(mergedClock[clientId] ?? 0, counter);
}
}
const currentClientId = await this.clientIdProvider.loadClientId();
if (!currentClientId) {
Log.warn(
@ -1633,54 +2228,13 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
);
}
let clockToStore: Record<string, number>;
const clockToStore = calculateRemoteClockMerge(currentClock, ops, currentClientId);
if (fullStateOp && currentClientId) {
// CLOCK RESET: After a full-state op (SYNC_IMPORT / BACKUP_IMPORT / REPAIR),
// reset the working clock to minimal — only the import client's entry and our
// own entry. This prevents dead client IDs from accumulating in the clock.
//
// The full import clock is preserved in the stored operation for
// SyncImportFilterService to use when filtering pre-import ops.
// Post-import ops are recognized by having the import client's counter
// (see SyncImportFilterService's import-client-counter exception).
clockToStore = {};
const importClientId = fullStateOp.clientId;
if (mergedClock[importClientId] !== undefined) {
clockToStore[importClientId] = mergedClock[importClientId];
}
if (currentClientId !== importClientId) {
// Preserve our own counter using the maximum of:
// - mergedClock[currentClientId]: from any of the incoming remote ops
// - currentClock[currentClientId]: our own counter BEFORE the merge
//
// This matters when our own ops (e.g. GLOBAL_CONFIG) created a counter
// that is NOT reflected in the incoming full-state op's clock (because the
// full-state op was created by another client and doesn't know about our ops).
// Without this, the reset would drop our own counter, causing subsequent ops
// to reuse the same counter value and appear as EQUAL (duplicate) to remote
// clients that have already seen our earlier op with that counter.
const myCounter = Math.max(
mergedClock[currentClientId] ?? 0,
currentClock[currentClientId] ?? 0,
);
if (myCounter > 0) {
clockToStore[currentClientId] = myCounter;
}
}
Log.log(
`[OpLogStore] mergeRemoteOpClocks: RESET clock to minimal after ${fullStateOp.opType}\n` +
` Full merged clock (${Object.keys(mergedClock).length} entries): ${vectorClockToString(mergedClock)}\n` +
` Minimal clock (${Object.keys(clockToStore).length} entries): ${vectorClockToString(clockToStore)}`,
);
} else {
// Normal case: prune the merged clock to MAX_VECTOR_CLOCK_SIZE to break the
// inflate/prune cycle: without this, the union of all downloaded ops'
// clocks re-introduces pruned client IDs, exceeding the limit again.
// The server already prunes with the same algorithm on upload.
clockToStore = currentClientId
? limitVectorClockSize(mergedClock, currentClientId)
: mergedClock;
}
// DIAGNOSTIC LOGGING: Log merged clock after merge
@ -1792,6 +2346,7 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
snapshotEntityKeys: string[];
archiveYoung?: ArchiveStoreEntry['data'];
archiveOld?: ArchiveStoreEntry['data'];
requiredImportBackupId?: string;
}): Promise<void> {
await this._ensureInit();
@ -1814,6 +2369,9 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
if (archiveOld != null) {
storeNames.push(STORE_NAMES.ARCHIVE_OLD);
}
if (opts.requiredImportBackupId !== undefined) {
storeNames.push(STORE_NAMES.IMPORT_BACKUP);
}
try {
// The adapter's transaction() commits on resolve and aborts on throw,
@ -1822,6 +2380,15 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
// `opsStore.add`; that still fires here because the adapter operates on
// that same adopted connection.
await this._adapter.transaction(storeNames, 'readwrite', async (tx) => {
if (opts.requiredImportBackupId !== undefined) {
const currentBackup = await tx.get<{ backupId?: string }>(
STORE_NAMES.IMPORT_BACKUP,
SINGLETON_KEY,
);
if (currentBackup?.backupId !== opts.requiredImportBackupId) {
throw new Error('Recovery backup was superseded before destructive restore.');
}
}
// Rotate the clientId first, inside this same atomic transaction.
// Writing it before the OPS clear means an interrupt injected into a
// later step still aborts this queued put — exercising the genuine
@ -1844,6 +2411,12 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
FULL_STATE_OPS_META_KEY,
);
// This replacement supersedes any interrupted USE_REMOTE rebuild. Clear
// the marker in the same transaction as the restored/clean-slate
// baseline so a successful Undo cannot immediately re-enter recovery.
await tx.delete(STORE_NAMES.META, RAW_REBUILD_INCOMPLETE_META_KEY);
await tx.delete(STORE_NAMES.META, RAW_REBUILD_RECOVERY_META_KEY);
await tx.put(
STORE_NAMES.VECTOR_CLOCK,
{ clock: newVectorClock, lastUpdate: Date.now() },

View file

@ -147,6 +147,13 @@ describe('SchemaMigrationService', () => {
// Should return the operation (with undefined treated as version 1)
expect(result).not.toBeNull();
});
it('should reject a present non-integer schema version at the migration boundary', () => {
const op = createMockOperation('malformed-op');
op.schemaVersion = '2' as unknown as number;
expect(() => service.migrateOperation(op)).toThrowError(/schemaVersion/);
});
});
describe('migrateOperations', () => {
@ -181,6 +188,33 @@ describe('SchemaMigrationService', () => {
expect(result.map((op) => op.id)).toEqual(['op-a', 'op-b', 'op-c']);
});
it('should preserve unique migrated identities when a v1 config operation splits', () => {
const op = createMockOperation('config-op', 1);
op.actionType = ActionType.GLOBAL_CONFIG_UPDATE_SECTION;
op.entityType = 'GLOBAL_CONFIG';
op.entityId = 'misc';
op.entityIds = ['misc'];
op.payload = {
actionPayload: {
sectionKey: 'misc',
sectionCfg: {
isConfirmBeforeTaskDelete: true,
unrelatedMiscSetting: 'keep-me',
},
},
entityChanges: [],
};
const result = service.migrateOperations([op]);
expect(result.map((migrated) => migrated.id)).toEqual([
'config-op_misc',
'config-op_tasks',
]);
expect(result.map((migrated) => migrated.entityId)).toEqual(['misc', 'tasks']);
expect(result.map((migrated) => migrated.entityIds)).toEqual([['misc'], ['tasks']]);
});
});
describe('migrateIfNeeded (deprecated)', () => {

View file

@ -3,7 +3,6 @@ import { Operation, VectorClock } from '../core/operation.types';
import { OpLog } from '../../core/log';
import {
CURRENT_SCHEMA_VERSION as SHARED_CURRENT_SCHEMA_VERSION,
MAX_VERSION_SKIP as SHARED_MAX_VERSION_SKIP,
MIN_SUPPORTED_SCHEMA_VERSION as SHARED_MIN_SUPPORTED_SCHEMA_VERSION,
MIGRATIONS,
migrateState,
@ -17,12 +16,31 @@ import {
// Re-export shared constants for backwards compatibility
export const CURRENT_SCHEMA_VERSION = SHARED_CURRENT_SCHEMA_VERSION;
export const MAX_VERSION_SKIP = SHARED_MAX_VERSION_SKIP;
export const MIN_SUPPORTED_SCHEMA_VERSION = SHARED_MIN_SUPPORTED_SCHEMA_VERSION;
// Re-export types
export type { SchemaMigration };
export const getOperationSchemaVersion = (op: { schemaVersion?: unknown }): number => {
if (op.schemaVersion === undefined) {
return 1;
}
if (
typeof op.schemaVersion !== 'number' ||
!Number.isInteger(op.schemaVersion) ||
// Deliberately accepts 0 (unlike the server contract's min(1)): a parsed 0
// then fails the MIN_SUPPORTED_SCHEMA_VERSION comparison and surfaces as
// VERSION_UNSUPPORTED — a truthful message — instead of a generic
// migration-failure. Safety is identical either way.
op.schemaVersion < 0
) {
throw new Error(
'Operation schemaVersion must be a non-negative integer when present.',
);
}
return op.schemaVersion;
};
/**
* Interface for state cache that may need migration.
*/
@ -133,7 +151,7 @@ export class SchemaMigrationService {
* @returns The migrated operation(s), or null if it should be dropped
*/
migrateOperation(op: Operation): Operation | Operation[] | null {
const opVersion = op.schemaVersion ?? 1;
const opVersion = getOperationSchemaVersion(op);
if (opVersion >= CURRENT_SCHEMA_VERSION) {
return op;
@ -164,6 +182,7 @@ export class SchemaMigrationService {
if (Array.isArray(result.data)) {
return result.data.map((migratedOpLike) => ({
...op,
id: migratedOpLike.id,
opType: migratedOpLike.opType as Operation['opType'],
entityType: migratedOpLike.entityType as Operation['entityType'],
entityId: migratedOpLike.entityId,
@ -176,9 +195,11 @@ export class SchemaMigrationService {
// Merge migrated fields back into the original operation
return {
...op,
id: result.data.id,
opType: result.data.opType as Operation['opType'],
entityType: result.data.entityType as Operation['entityType'],
entityId: result.data.entityId,
entityIds: result.data.entityIds,
payload: result.data.payload,
schemaVersion: result.data.schemaVersion,
};
@ -225,13 +246,14 @@ export class SchemaMigrationService {
* Returns true if the operation needs migration.
*/
operationNeedsMigration(op: Operation): boolean {
const schemaVersion = getOperationSchemaVersion(op);
return sharedOperationNeedsMigration(
{
id: op.id,
opType: op.opType,
entityType: op.entityType,
payload: op.payload,
schemaVersion: op.schemaVersion ?? 1,
schemaVersion,
},
CURRENT_SCHEMA_VERSION,
);

View file

@ -248,7 +248,7 @@ class FakeSqliteDb implements SqliteDb {
const makeOpEntry = (
id: string,
source: 'local' | 'remote',
applicationStatus?: 'pending' | 'applied' | 'failed',
applicationStatus?: 'pending' | 'archive_pending' | 'applied' | 'failed',
syncedAt?: number,
): Record<string, unknown> => ({
op: { id },

View file

@ -81,14 +81,27 @@ describe('ConflictResolution → ConflictJournal hook (integration)', () => {
mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'appendBatchSkipDuplicates',
'appendMixedSourceBatchSkipDuplicates',
'appendWithVectorClockUpdate',
'markApplied',
'markRejected',
'markFailed',
'getUnsyncedByEntity',
'mergeRemoteOpClocks',
'markReducersCommittedAndMergeClocks',
]);
mockOpLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
mockOpLogStore.markReducersCommittedAndMergeClocks.and.resolveTo(undefined);
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.and.callFake(async (batches) => ({
written: batches.flatMap((batch) =>
batch.ops.map((batchOp, index) => ({
seq: index + 1,
op: batchOp,
source: batch.source,
})),
),
skippedCount: 0,
}));
mockOpLogStore.getUnsyncedByEntity.and.resolveTo(new Map());
mockOpLogStore.appendWithVectorClockUpdate.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);

View file

@ -66,9 +66,11 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
});
const mergedOpArgs = (): Operation | undefined =>
mockOpLogStore.appendWithVectorClockUpdate.calls
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.calls
.allArgs()
.map(([o]) => o as Operation)
.flatMap(([batches]) => batches)
.filter((batch) => batch.source === 'local')
.flatMap((batch) => [...batch.ops])
.find((o) => o.entityId === 'task-1' && o.opType === OpType.Update);
beforeEach(() => {
@ -82,14 +84,27 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'appendBatchSkipDuplicates',
'appendMixedSourceBatchSkipDuplicates',
'appendWithVectorClockUpdate',
'markApplied',
'markRejected',
'markFailed',
'getUnsyncedByEntity',
'mergeRemoteOpClocks',
'markReducersCommittedAndMergeClocks',
]);
mockOpLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
mockOpLogStore.markReducersCommittedAndMergeClocks.and.resolveTo(undefined);
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.and.callFake(async (batches) => ({
written: batches.flatMap((batch) =>
batch.ops.map((batchOp, index) => ({
seq: index + 1,
op: batchOp,
source: batch.source,
})),
),
skippedCount: 0,
}));
mockOpLogStore.getUnsyncedByEntity.and.resolveTo(new Map());
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOpLogStore.markApplied.and.resolveTo(undefined);
@ -136,6 +151,110 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
journal = TestBed.inject(ConflictJournalService);
});
// ── regression: checkpoint contract vs synthetic merged ops (#8900 seam) ───
it('resolves without checkpointing the synthetic merged op when the applier reports reducer commit', async () => {
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'Local title', notes: 'base notes' }),
);
// Honor the coordinator contract: the reducer-commit callback receives the
// ENTIRE apply batch (including the synthetic merged local op).
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
return { appliedOps: ops };
});
// Enforce the real store's pending-only checkpoint assertion: only rows
// appended with pendingApply may be checkpointed.
const pendingAppendedIds = new Set<string>();
mockOpLogStore.appendBatchSkipDuplicates.and.callFake(
(ops: Operation[], _source, options) => {
if (options?.pendingApply) {
ops.forEach((o) => pendingAppendedIds.add(o.id));
}
return Promise.resolve({
seqs: ops.map((_, i) => i + 1),
writtenOps: ops,
skippedCount: 0,
});
},
);
mockOpLogStore.markReducersCommittedAndMergeClocks.and.callFake(
async (_seqs, ops) => {
for (const o of ops) {
if (!pendingAppendedIds.has(o.id)) {
throw new Error(
`Reducer checkpoint requires pending remote operation (${o.id}).`,
);
}
}
},
);
const localOp = op({
id: 'local-cp',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'Local title' } } },
});
const remoteOp = op({
id: 'remote-cp',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 1000,
payload: { task: { id: 'task-1', changes: { notes: 'Remote notes' } } },
});
await expectAsync(
service.autoResolveConflictsLWW([conflictOf([localOp], [remoteOp])]),
).toBeResolved();
// The merged op reached the reducers…
const appliedOps = mockOperationApplier.applyOperations.calls.mostRecent()
.args[0] as Operation[];
expect(
appliedOps.some((o) => o.opType === OpType.Update && o.entityId === 'task-1'),
).toBeTrue();
// …but only pending-appended rows were ever checkpointed.
const checkpointedOps = mockOpLogStore.markReducersCommittedAndMergeClocks.calls
.allArgs()
.flatMap(([, ops]) => ops);
expect(checkpointedOps.every((o) => pendingAppendedIds.has(o.id))).toBeTrue();
});
it('persists merge writes through the atomic mixed-source batch, never the clock-overwriting append', async () => {
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'Local title', notes: 'base notes' }),
);
const localOp = op({
id: 'local-mb',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'Local title' } } },
});
const remoteOp = op({
id: 'remote-mb',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 1000,
payload: { task: { id: 'task-1', changes: { notes: 'Remote notes' } } },
});
await service.autoResolveConflictsLWW([conflictOf([localOp], [remoteOp])]);
// appendWithVectorClockUpdate REPLACES the durable clock with the caller's
// clock (built only from the conflict's ops) — the batch rebases instead.
expect(mockOpLogStore.appendWithVectorClockUpdate).not.toHaveBeenCalled();
const batches =
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.calls.mostRecent().args[0];
const remoteBatch = batches.find((b) => b.source === 'remote');
const localBatch = batches.find((b) => b.source === 'local');
expect(remoteBatch!.ops.map((o) => o.id)).toEqual(['remote-mb']);
expect(localBatch!.ops.length).toBe(1);
expect(localBatch!.ops[0].opType).toBe(OpType.Update);
});
// ── (a) title vs notes → merge both ────────────────────────────────────────
it('(a) merges concurrent title-vs-notes edits into one op keeping BOTH', async () => {
mockStore.select.and.returnValue(
@ -354,7 +473,9 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'Local title', notes: 'base' }),
);
mockOpLogStore.appendWithVectorClockUpdate.and.rejectWith(new Error('append failed'));
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.and.rejectWith(
new Error('append failed'),
);
const localOp = op({
id: 'local-1',
@ -634,14 +755,27 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
const opLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'appendBatchSkipDuplicates',
'appendMixedSourceBatchSkipDuplicates',
'appendWithVectorClockUpdate',
'markApplied',
'markRejected',
'markFailed',
'getUnsyncedByEntity',
'mergeRemoteOpClocks',
'markReducersCommittedAndMergeClocks',
]);
opLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
opLogStore.markReducersCommittedAndMergeClocks.and.resolveTo(undefined);
opLogStore.appendMixedSourceBatchSkipDuplicates.and.callFake(async (batches) => ({
written: batches.flatMap((batch) =>
batch.ops.map((batchOp, index) => ({
seq: index + 1,
op: batchOp,
source: batch.source,
})),
),
skippedCount: 0,
}));
opLogStore.getUnsyncedByEntity.and.resolveTo(new Map());
opLogStore.markRejected.and.resolveTo(undefined);
opLogStore.markApplied.and.resolveTo(undefined);
@ -688,9 +822,11 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
const svc = TestBed.inject(ConflictResolutionService);
await svc.autoResolveConflictsLWW([conflict]);
const synthesized = opLogStore.appendWithVectorClockUpdate.calls
const synthesized = opLogStore.appendMixedSourceBatchSkipDuplicates.calls
.allArgs()
.map(([o]) => o as Operation)
.flatMap(([batches]) => batches)
.filter((batch) => batch.source === 'local')
.flatMap((batch) => [...batch.ops])
.find((o) => o.entityId === 'task-1' && o.opType === OpType.Update);
return { synthesized };
};

View file

@ -10,11 +10,16 @@ import { BannerId } from '../../core/banner/banner.model';
import { ValidateStateService } from '../validation/validate-state.service';
import { of } from 'rxjs';
import { ActionType, EntityConflict, OpType, Operation } from '../core/operation.types';
import { VectorClock, VectorClockComparison } from '../../core/util/vector-clock';
import {
compareVectorClocks,
VectorClock,
VectorClockComparison,
} from '../../core/util/vector-clock';
import { CLIENT_ID_PROVIDER } from '../util/client-id.provider';
import { MAX_VECTOR_CLOCK_SIZE } from '../core/operation-log.const';
import { buildEntityRegistry, ENTITY_REGISTRY } from '../core/entity-registry';
import { OperationLogEffects } from '../capture/operation-log.effects';
import { IncompleteRemoteOperationsError } from '../core/errors/sync-errors';
describe('ConflictResolutionService', () => {
let service: ConflictResolutionService;
@ -43,6 +48,30 @@ describe('ConflictResolutionService', () => {
schemaVersion: 1,
});
const getMixedLocalOps = (): readonly Operation[] =>
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.calls
.allArgs()
.flatMap(([batches]) =>
batches.filter((batch) => batch.source === 'local').flatMap((batch) => batch.ops),
);
const getMixedRemoteOps = (): readonly Operation[] =>
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.calls
.allArgs()
.flatMap(([batches]) =>
batches
.filter((batch) => batch.source === 'remote')
.flatMap((batch) => batch.ops),
);
const getFirstMixedLocalOp = (): Operation => {
const op = getMixedLocalOps()[0];
if (!op) {
throw new Error('Expected a local operation in the mixed-resolution batch');
}
return op;
};
beforeEach(() => {
mockStore = jasmine.createSpyObj('Store', ['select']);
// Default: select returns of(undefined) - can be overridden in specific tests
@ -55,15 +84,21 @@ describe('ConflictResolutionService', () => {
'hasOp',
'append',
'appendBatchSkipDuplicates',
'appendWithVectorClockUpdate',
'appendMixedSourceBatchSkipDuplicates',
'mergeRemoteOpClocks',
'markReducersCommittedAndMergeClocks',
'markApplied',
'markRejected',
'markFailed',
'getUnsyncedByEntity',
'mergeRemoteOpClocks',
]);
mockOpLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockOpLogStore.markReducersCommittedAndMergeClocks.and.resolveTo(undefined);
mockSnackService = jasmine.createSpyObj('SnackService', [
'open',
'hasPendingPersistentAction',
]);
mockSnackService.hasPendingPersistentAction.and.returnValue(false);
mockValidateStateService = jasmine.createSpyObj('ValidateStateService', [
'validateAndRepairCurrentState',
]);
@ -105,6 +140,16 @@ describe('ConflictResolutionService', () => {
skippedCount: 0,
}),
);
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.and.callFake(async (batches) => ({
written: batches.flatMap((batch) =>
batch.ops.map((op, index) => ({
seq: index + 1,
op,
source: batch.source,
})),
),
skippedCount: 0,
}));
});
describe('getCurrentEntityState', () => {
@ -402,9 +447,6 @@ describe('ConflictResolutionService', () => {
// Default mock behaviors for LWW tests
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.callFake((op: Operation) => Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.callFake((op: Operation) =>
Promise.resolve(1),
);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOperationApplier.applyOperations.and.resolveTo({ appliedOps: [] });
@ -454,11 +496,9 @@ describe('ConflictResolutionService', () => {
// Both local and remote ops should be rejected
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-1']);
// Remote ops need to be appended first (via batch), then rejected
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
[jasmine.objectContaining({ id: 'remote-1' })],
'remote',
undefined,
// Remote loser and its compensation are persisted atomically, then rejected.
expect(getMixedRemoteOps()).toEqual(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-1' })]),
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-1']);
// SPAP-15: count snack replaced by the journal-driven summary banner;
@ -789,11 +829,9 @@ describe('ConflictResolutionService', () => {
);
// Second conflict: local wins (newer timestamp)
// Remote op should be appended (via batch) then rejected
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
// Remote loser and its compensation are persisted atomically, then rejected.
expect(getMixedRemoteOps()).toEqual(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-2' })]),
'remote',
undefined,
);
// SPAP-15: the generic count snack was removed. Both conflicts here carry
@ -1711,6 +1749,100 @@ describe('ConflictResolutionService', () => {
expect(result).toEqual({ localWinOpsCreated: 1 });
});
it('should persist local-win compensation before applying mixed remote winners', async () => {
const now = Date.now();
const remoteWinner = createOpWithTimestamp('remote-winner', 'client-b', now);
const remoteLoser = createOpWithTimestamp(
'remote-loser',
'client-b',
now - 1000,
OpType.Update,
'task-2',
);
const localWinOp = createMockLocalWinOp('task-2');
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-loser', 'client-a', now - 1000)],
[remoteWinner],
),
createConflict(
'task-2',
[
createOpWithTimestamp(
'local-winner',
'client-a',
now,
OpType.Update,
'task-2',
),
],
[remoteLoser],
),
];
const callOrder: string[] = [];
spyOn<any>(service, '_createLocalWinUpdateOp').and.resolveTo(localWinOp);
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.and.callFake(
async (batches) => {
callOrder.push('persist-mixed-resolution');
return {
written: batches.flatMap((batch) =>
batch.ops.map((op, index) => ({
seq: index + 1,
op,
source: batch.source,
})),
),
skippedCount: 0,
};
},
);
mockOpLogStore.markRejected.and.callFake(async () => {
callOrder.push('mark-rejected');
});
mockOperationApplier.applyOperations.and.callFake(async () => {
callOrder.push('apply-remote');
throw new Error('remote archive apply failed');
});
await expectAsync(
service.autoResolveConflictsLWW(conflicts),
).toBeRejectedWithError('remote archive apply failed');
expect(callOrder).toEqual([
'persist-mixed-resolution',
'mark-rejected',
'mark-rejected',
'apply-remote',
]);
});
it('should not reject or apply remote rows when local-win compensation cannot persist', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-winner', 'client-a', now)],
[createOpWithTimestamp('remote-loser', 'client-b', now - 1000)],
),
];
const persistenceError = new Error('compensation persistence failed');
spyOn<any>(service, '_createLocalWinUpdateOp').and.resolveTo(
createMockLocalWinOp('task-1'),
);
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.and.rejectWith(
persistenceError,
);
await expectAsync(service.autoResolveConflictsLWW(conflicts)).toBeRejectedWith(
persistenceError,
);
expect(mockOpLogStore.markRejected).not.toHaveBeenCalled();
expect(mockOperationApplier.applyOperations).not.toHaveBeenCalled();
expect(mockOpLogStore.appendBatchSkipDuplicates).not.toHaveBeenCalled();
});
it('should return 0 when local wins but entity not found', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
@ -1734,7 +1866,7 @@ describe('ConflictResolutionService', () => {
});
describe('vector clock update', () => {
it('should use appendWithVectorClockUpdate for local-win ops to ensure vector clock is updated atomically', async () => {
it('should atomically append remote losers before local-win compensation', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
@ -1763,18 +1895,94 @@ describe('ConflictResolutionService', () => {
await service.autoResolveConflictsLWW(conflicts);
// Verify appendWithVectorClockUpdate is called (not plain append)
// This ensures the vector clock store is updated atomically with the operation
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
const batches =
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.calls.mostRecent().args[0];
expect(batches.map((batch) => batch.source)).toEqual(['remote', 'local']);
expect(batches[1].ops).toEqual([
jasmine.objectContaining({
id: 'lww-update-task-1',
actionType: '[TASK] LWW Update' as ActionType,
}),
'local',
);
]);
});
it('should call mergeRemoteOpClocks after applying remote ops (remote wins case)', async () => {
it('should create a dominating GLOBAL_CONFIG:tasks compensation for a migrated local row', async () => {
const migratedLocalOp: Operation = {
id: 'legacy-local-config_tasks',
actionType: ActionType.GLOBAL_CONFIG_UPDATE_SECTION,
opType: OpType.Update,
entityType: 'GLOBAL_CONFIG',
entityId: 'tasks',
entityIds: ['tasks'],
payload: {
actionPayload: {
sectionKey: 'tasks',
sectionCfg: { isConfirmBeforeDelete: true },
},
entityChanges: [],
},
clientId: 'localClient',
vectorClock: { localClient: 4 },
timestamp: 2_000,
schemaVersion: 2,
};
const currentRemoteOp: Operation = {
id: 'remote-current-tasks',
actionType: ActionType.GLOBAL_CONFIG_UPDATE_SECTION,
opType: OpType.Update,
entityType: 'GLOBAL_CONFIG',
entityId: 'tasks',
entityIds: ['tasks'],
payload: {
actionPayload: {
sectionKey: 'tasks',
sectionCfg: { isConfirmBeforeDelete: false },
},
entityChanges: [],
},
clientId: 'remoteClient',
vectorClock: { remoteClient: 3 },
timestamp: 1_000,
schemaVersion: 2,
};
mockClientIdProvider.loadClientId.and.resolveTo('localClient');
mockStore.select.and.returnValue(
of({
misc: { unrelatedMiscSetting: 'keep-me' },
tasks: { isConfirmBeforeDelete: true },
}),
);
const result = await service.autoResolveConflictsLWW([
{
entityType: 'GLOBAL_CONFIG',
entityId: 'tasks',
localOps: [migratedLocalOp],
remoteOps: [currentRemoteOp],
suggestedResolution: 'manual',
},
]);
const compensation = getFirstMixedLocalOp();
expect(result.localWinOpsCreated).toBe(1);
expect(getMixedRemoteOps()).toEqual([currentRemoteOp]);
expect(compensation).toEqual(
jasmine.objectContaining({
actionType: '[GLOBAL_CONFIG] LWW Update' as ActionType,
entityType: 'GLOBAL_CONFIG',
entityId: 'tasks',
vectorClock: { localClient: 5, remoteClient: 3 },
}),
);
expect(
compareVectorClocks(compensation.vectorClock, migratedLocalOp.vectorClock),
).toBe(VectorClockComparison.GREATER_THAN);
expect(
compareVectorClocks(compensation.vectorClock, currentRemoteOp.vectorClock),
).toBe(VectorClockComparison.GREATER_THAN);
});
it('should merge remote-winner clocks before apply and checkpoint them with reducer status', async () => {
// REGRESSION TEST: Bug where remote ops applied via conflict resolution
// didn't have their clocks merged into the local clock store.
// Without clock merge, subsequent local ops would have clocks that are
@ -1791,16 +1999,20 @@ describe('ConflictResolutionService', () => {
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.resolveTo(1);
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [remoteOp],
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
return { appliedOps: [remoteOp] };
});
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
await service.autoResolveConflictsLWW(conflicts);
// CRITICAL: mergeRemoteOpClocks must be called with applied remote ops
expect(mockOpLogStore.mergeRemoteOpClocks).toHaveBeenCalledWith([remoteOp]);
expect(mockOpLogStore.markReducersCommittedAndMergeClocks).toHaveBeenCalledWith(
[1],
[remoteOp],
);
});
it('should process deferred actions after merging remote clocks when caller holds lock', async () => {
@ -1816,16 +2028,20 @@ describe('ConflictResolutionService', () => {
const callOrder: string[] = [];
mockOpLogStore.hasOp.and.resolveTo(false);
mockOperationApplier.applyOperations.and.callFake(async () => {
mockOpLogStore.mergeRemoteOpClocks.and.callFake(async () => {
callOrder.push('mergeRemoteOpClocks');
});
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
callOrder.push('applyOperations');
await options?.onReducersCommitted?.(ops);
return { appliedOps: [remoteOp] };
});
mockOpLogStore.markReducersCommittedAndMergeClocks.and.callFake(async () => {
callOrder.push('checkpointReducersAndClocks');
});
mockOpLogStore.markApplied.and.callFake(async () => {
callOrder.push('markApplied');
});
mockOpLogStore.mergeRemoteOpClocks.and.callFake(async () => {
callOrder.push('mergeRemoteOpClocks');
});
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOperationLogEffects.processDeferredActions.and.callFake(async () => {
callOrder.push('processDeferredActions');
@ -1835,21 +2051,26 @@ describe('ConflictResolutionService', () => {
callerHoldsOperationLogLock: true,
});
expect(mockOperationApplier.applyOperations).toHaveBeenCalledWith([remoteOp], {
skipDeferredLocalActions: true,
});
expect(mockOperationApplier.applyOperations).toHaveBeenCalledWith(
[remoteOp],
jasmine.objectContaining({
skipDeferredLocalActions: true,
onReducersCommitted: jasmine.any(Function),
}),
);
expect(mockOperationLogEffects.processDeferredActions).toHaveBeenCalledWith({
callerHoldsOperationLogLock: true,
});
expect(callOrder).toEqual([
'applyOperations',
'markApplied',
'mergeRemoteOpClocks',
'applyOperations',
'checkpointReducersAndClocks',
'markApplied',
'processDeferredActions',
]);
});
it('should call mergeRemoteOpClocks for non-conflicting ops piggybacked through conflict resolution', async () => {
it('should checkpoint clocks for non-conflicting ops piggybacked through conflict resolution', async () => {
const now = Date.now();
const conflictRemoteOp = createOpWithTimestamp('remote-1', 'client-b', now);
const nonConflictingOp = createOpWithTimestamp(
@ -1869,8 +2090,9 @@ describe('ConflictResolutionService', () => {
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.resolveTo(1);
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [conflictRemoteOp, nonConflictingOp],
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
return { appliedOps: [conflictRemoteOp, nonConflictingOp] };
});
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
@ -1882,6 +2104,10 @@ describe('ConflictResolutionService', () => {
conflictRemoteOp,
nonConflictingOp,
]);
expect(mockOpLogStore.markReducersCommittedAndMergeClocks).toHaveBeenCalledWith(
[1, 1],
[conflictRemoteOp, nonConflictingOp],
);
});
});
@ -2387,9 +2613,6 @@ describe('ConflictResolutionService', () => {
beforeEach(() => {
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.callFake((op: Operation) => Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.callFake((op: Operation) =>
Promise.resolve(1),
);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
});
@ -2439,11 +2662,10 @@ describe('ConflictResolutionService', () => {
// Local archive op should be rejected (will be replaced by new archive op)
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-archive']);
// New archive op should be appended
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
expect(getMixedLocalOps()).toContain(
jasmine.objectContaining({
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
}),
'local',
);
});
@ -2459,8 +2681,7 @@ describe('ConflictResolutionService', () => {
await service.autoResolveConflictsLWW(conflicts);
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
const appendedOp = getFirstMixedLocalOp();
// Merged clock should include entries from both sides and be incremented
expect(appendedOp.vectorClock['client-a']).toBeGreaterThanOrEqual(1);
expect(appendedOp.vectorClock['client-b']).toBeGreaterThanOrEqual(1);
@ -2484,8 +2705,7 @@ describe('ConflictResolutionService', () => {
await service.autoResolveConflictsLWW(conflicts);
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
const appendedOp = getFirstMixedLocalOp();
expect(appendedOp.payload).toEqual(archiveOp.payload);
expect(appendedOp.entityIds).toEqual(['task-1', 'task-2', 'task-3']);
expect(appendedOp.entityId).toBe('task-1');
@ -2525,7 +2745,7 @@ describe('ConflictResolutionService', () => {
const result = await service.autoResolveConflictsLWW(conflicts);
// No local-win op should be created (clientId unavailable)
expect(mockOpLogStore.appendWithVectorClockUpdate).not.toHaveBeenCalled();
expect(getMixedLocalOps()).toEqual([]);
expect(result.localWinOpsCreated).toBe(0);
});
@ -2551,11 +2771,10 @@ describe('ConflictResolutionService', () => {
'local-archive',
]);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-upd']);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
expect(getMixedLocalOps()).toContain(
jasmine.objectContaining({
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
}),
'local',
);
});
@ -2581,11 +2800,10 @@ describe('ConflictResolutionService', () => {
// Archive should still win — it has moveToArchive, so archive-wins logic kicks in
// regardless of remote op type
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-del']);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
expect(getMixedLocalOps()).toContain(
jasmine.objectContaining({
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
}),
'local',
);
});
@ -2637,11 +2855,10 @@ describe('ConflictResolutionService', () => {
// Archive-win op should be created (from Conflict 1)
expect(result.localWinOpsCreated).toBe(1);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
expect(getMixedLocalOps()).toContain(
jasmine.objectContaining({
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
}),
'local',
);
// No remote ops should be applied to the store — both conflicts resolve
@ -2691,9 +2908,6 @@ describe('ConflictResolutionService', () => {
beforeEach(() => {
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.callFake((op: Operation) => Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.callFake((op: Operation) =>
Promise.resolve(1),
);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
});
@ -2719,9 +2933,8 @@ describe('ConflictResolutionService', () => {
await service.autoResolveConflictsLWW(conflicts);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalled();
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
expect(getMixedLocalOps().length).toBeGreaterThan(0);
const appendedOp = getFirstMixedLocalOp();
// All keys preserved — no client-side pruning (server handles it)
expect(Object.keys(appendedOp.vectorClock).length).toBeGreaterThan(
MAX_VECTOR_CLOCK_SIZE,
@ -2765,9 +2978,8 @@ describe('ConflictResolutionService', () => {
await service.autoResolveConflictsLWW(conflicts);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalled();
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
expect(getMixedLocalOps().length).toBeGreaterThan(0);
const appendedOp = getFirstMixedLocalOp();
// All keys preserved — no client-side pruning (server handles it)
expect(Object.keys(appendedOp.vectorClock).length).toBeGreaterThan(
MAX_VECTOR_CLOCK_SIZE,
@ -2801,8 +3013,7 @@ describe('ConflictResolutionService', () => {
await service.autoResolveConflictsLWW(conflicts);
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
const appendedOp = getFirstMixedLocalOp();
// No client-side pruning — all keys preserved including protected client
expect(appendedOp.vectorClock[protectedId]).toBeDefined();
expect(appendedOp.vectorClock[TEST_CLIENT_ID]).toBeDefined();
@ -2853,7 +3064,7 @@ describe('ConflictResolutionService', () => {
// Local archive wins — a new op should be created
expect(result.localWinOpsCreated).toBe(1);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalled();
expect(getMixedLocalOps().length).toBeGreaterThan(0);
});
it('should resolve remote moveToArchive winning over local UPDATE with later timestamp', async () => {
@ -2950,7 +3161,7 @@ describe('ConflictResolutionService', () => {
// Local archive wins over remote DELETE
expect(result.localWinOpsCreated).toBe(1);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalled();
expect(getMixedLocalOps().length).toBeGreaterThan(0);
});
});
@ -3886,7 +4097,6 @@ describe('ConflictResolutionService', () => {
beforeEach(() => {
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.callFake(() => Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.callFake(() => Promise.resolve(1));
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOpLogStore.markFailed.and.resolveTo(undefined);
@ -3906,9 +4116,12 @@ describe('ConflictResolutionService', () => {
},
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [],
failedOp: { op: remoteOp, error: new Error('Apply failed for task-1') },
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
return {
appliedOps: [],
failedOp: { op: remoteOp, error: new Error('Apply failed for task-1') },
};
});
// FIXED: Should throw on apply failure (parity with applyNonConflictingOps)
@ -3942,8 +4155,9 @@ describe('ConflictResolutionService', () => {
];
const callOrder: string[] = [];
mockOperationApplier.applyOperations.and.callFake(async () => {
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
callOrder.push('applyOperations');
await options?.onReducersCommitted?.(ops);
return {
appliedOps: [],
failedOp: { op: remoteOp, error: new Error('Apply failed for task-1') },
@ -3973,6 +4187,160 @@ describe('ConflictResolutionService', () => {
callOrder.indexOf('applyOperations'),
);
});
it('should preserve the incomplete-remote error when the deferred drain also fails', async () => {
const localOp = createOpForBug('local-1', 'client-a', now - 1000);
const remoteOp = createOpForBug('remote-1', 'client-b', now);
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localOp],
remoteOps: [remoteOp],
suggestedResolution: 'manual',
},
];
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
return {
appliedOps: [],
failedOp: { op: remoteOp, error: new Error('archive failed') },
};
});
mockOperationLogEffects.processDeferredActions.and.rejectWith(
new Error('deferred drain failed'),
);
let thrown: unknown;
try {
await service.autoResolveConflictsLWW(conflicts);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(IncompleteRemoteOperationsError);
expect((thrown as Error).message).toBe('archive failed');
});
it('should not start apply or drain deferred actions when the pre-apply clock merge fails', async () => {
const localOp = createOpForBug('local-1', 'client-a', now - 1000);
const remoteOp = createOpForBug('remote-1', 'client-b', now);
const clockError = new Error('clock merge failed');
mockOpLogStore.mergeRemoteOpClocks.and.rejectWith(clockError);
await expectAsync(
service.autoResolveConflictsLWW([
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localOp],
remoteOps: [remoteOp],
suggestedResolution: 'manual',
},
]),
).toBeRejectedWith(clockError);
expect(mockOperationApplier.applyOperations).not.toHaveBeenCalled();
expect(mockOpLogStore.markReducersCommittedAndMergeClocks).not.toHaveBeenCalled();
expect(mockOperationLogEffects.processDeferredActions).not.toHaveBeenCalled();
});
it('should drain deferred actions when the atomic reducer+clock checkpoint fails after pre-merge', async () => {
const localOp = createOpForBug('local-1', 'client-a', now - 1000);
const remoteOp = createOpForBug('remote-1', 'client-b', now);
const checkpointError = new Error('checkpoint failed');
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
return { appliedOps: ops };
});
mockOpLogStore.markReducersCommittedAndMergeClocks.and.rejectWith(checkpointError);
await expectAsync(
service.autoResolveConflictsLWW([
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localOp],
remoteOps: [remoteOp],
suggestedResolution: 'manual',
},
]),
).toBeRejectedWith(checkpointError);
expect(mockOperationLogEffects.processDeferredActions).toHaveBeenCalled();
});
it('should drain deferred actions when reducer dispatch fails after pre-merge', async () => {
const localOp = createOpForBug('local-1', 'client-a', now - 1000);
const remoteOp = createOpForBug('remote-1', 'client-b', now);
const dispatchError = new Error('dispatcher failed');
mockOperationApplier.applyOperations.and.rejectWith(dispatchError);
await expectAsync(
service.autoResolveConflictsLWW([
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localOp],
remoteOps: [remoteOp],
suggestedResolution: 'manual',
},
]),
).toBeRejectedWith(dispatchError);
expect(mockOperationLogEffects.processDeferredActions).toHaveBeenCalled();
});
it('should drain deferred actions when bookkeeping fails after the atomic checkpoint', async () => {
const localOp = createOpForBug('local-1', 'client-a', now - 1000);
const remoteOp = createOpForBug('remote-1', 'client-b', now);
const markAppliedError = new Error('mark applied failed');
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
return { appliedOps: ops };
});
mockOpLogStore.markApplied.and.rejectWith(markAppliedError);
await expectAsync(
service.autoResolveConflictsLWW([
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localOp],
remoteOps: [remoteOp],
suggestedResolution: 'manual',
},
]),
).toBeRejectedWith(markAppliedError);
expect(mockOperationLogEffects.processDeferredActions).toHaveBeenCalled();
});
it('should preserve a bookkeeping error when the deferred drain also fails', async () => {
const localOp = createOpForBug('local-1', 'client-a', now - 1000);
const remoteOp = createOpForBug('remote-1', 'client-b', now);
const markAppliedError = new Error('mark applied failed');
mockOperationApplier.applyOperations.and.callFake(async (ops, options) => {
await options?.onReducersCommitted?.(ops);
return { appliedOps: ops };
});
mockOpLogStore.markApplied.and.rejectWith(markAppliedError);
mockOperationLogEffects.processDeferredActions.and.rejectWith(
new Error('deferred drain failed'),
);
await expectAsync(
service.autoResolveConflictsLWW([
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localOp],
remoteOps: [remoteOp],
suggestedResolution: 'manual',
},
]),
).toBeRejectedWith(markAppliedError);
});
});
describe('LWW Update payload always has top-level id (#7330)', () => {

View file

@ -36,7 +36,6 @@ import {
} from '../core/operation.types';
import { toLwwUpdateActionType } from '../core/lww-update-action-types';
import { OperationApplierService } from '../apply/operation-applier.service';
import { getFailedOpIdsFromBatch } from '../apply/failed-op-ids.util';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import { OpLog } from '../../core/log';
import { toEntityKey } from '../util/entity-key.util';
@ -50,7 +49,6 @@ import { TranslateService } from '@ngx-translate/core';
import { T } from '../../t.const';
import { ValidateStateService } from '../validation/validate-state.service';
import { SyncSessionValidationService } from './sync-session-validation.service';
import { MAX_CONFLICT_RETRY_ATTEMPTS } from '../core/operation-log.const';
import {
compareVectorClocks,
incrementVectorClock,
@ -64,6 +62,7 @@ import { uuidv7 } from '../../util/uuid-v7';
import { CURRENT_SCHEMA_VERSION } from '../persistence/schema-migration.service';
import { SYNC_LOGGER } from '../core/sync-logger.adapter';
import { processDeferredActionsAfterRemoteApply } from './process-deferred-actions-flush.util';
import { IncompleteRemoteOperationsError } from '../core/errors/sync-errors';
import { ConflictJournalService } from './conflict-journal.service';
import { SyncConflictBannerService } from './sync-conflict-banner.service';
import { buildConflictJournalEntry } from './conflict-journal-emission.util';
@ -344,6 +343,9 @@ export class ConflictResolutionService {
const allOpsToApply: Operation[] = [];
const allStoredOps: Array<{ id: string; seq: number }> = [];
// Synthetic local ops (disjoint merges) ride in the apply batch but are NOT
// pending remote rows — the reducer-commit checkpoint must never see them.
const checkpointExemptOpIds = new Set<string>();
const lwwPartitions = partitionLwwResolutions<Operation, EntityConflict>(
resolutions,
@ -357,8 +359,6 @@ export class ConflictResolutionService {
);
const {
localWinsCount,
remoteWinsCount,
remoteWinsOps,
localWinsRemoteOps,
remoteOpsToReject,
@ -367,6 +367,7 @@ export class ConflictResolutionService {
} = lwwPartitions;
const localOpsToReject = [...lwwPartitions.localOpsToReject];
const localOpsToRejectSet = new Set(localOpsToReject);
let writtenLocalWinOps: Operation[] = [];
for (const resolution of resolutions) {
// Note: localWinOp is undefined for archive-wins sibling conflicts
@ -403,11 +404,29 @@ export class ConflictResolutionService {
}
// ─────────────────────────────────────────────────────────────────────────
// Batch process local-wins remote ops: filter duplicates and append in batch
// Uses retry to handle race condition (issue #6213)
// Atomically persist remote losers followed by their local-win
// compensations. Hydration is status-blind, so exposing a durable loser
// without its later compensation would let the loser overwrite local state
// after a crash.
// ─────────────────────────────────────────────────────────────────────────
if (localWinsRemoteOps.length > 0) {
await this._filterAndAppendOpsWithRetry(localWinsRemoteOps, 'remote');
if (localWinsRemoteOps.length > 0 || newLocalWinOps.length > 0) {
const result = await this.opLogStore.appendMixedSourceBatchSkipDuplicates([
{ ops: localWinsRemoteOps, source: 'remote' },
{ ops: newLocalWinOps, source: 'local' },
]);
writtenLocalWinOps = result.written
.filter((entry) => entry.source === 'local')
.map((entry) => entry.op);
if (result.skippedCount > 0) {
OpLog.verbose(
`ConflictResolutionService: Skipped ${result.skippedCount} duplicate mixed-resolution op(s)`,
);
}
for (const op of writtenLocalWinOps) {
OpLog.normal(
`ConflictResolutionService: Appended local-win update op ${op.id} for ${op.entityType}:${op.entityId}`,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
@ -456,32 +475,66 @@ export class ConflictResolutionService {
// client's state picks up the remote side's fields — local's are already
// optimistically applied). The op stays unsynced+not-rejected → it uploads.
// ─────────────────────────────────────────────────────────────────────────
for (const merged of mergedResolutions) {
for (const op of merged.conflict.localOps) {
if (!localOpsToRejectSet.has(op.id)) {
localOpsToReject.push(op.id);
localOpsToRejectSet.add(op.id);
if (mergedResolutions.length > 0) {
for (const merged of mergedResolutions) {
for (const op of merged.conflict.localOps) {
if (!localOpsToRejectSet.has(op.id)) {
localOpsToReject.push(op.id);
localOpsToRejectSet.add(op.id);
}
}
}
if (merged.conflict.remoteOps.length > 0) {
await this._filterAndAppendOpsWithRetry(merged.conflict.remoteOps, 'remote');
remoteOpsToReject.push(...merged.conflict.remoteOps.map((op) => op.id));
}
const seq = await this.opLogStore.appendWithVectorClockUpdate(
merged.mergedOp,
'local',
);
allStoredOps.push({ id: merged.mergedOp.id, seq });
allOpsToApply.push(merged.mergedOp);
// ONE atomic mixed-source batch for all merge writes: an original remote
// loser must never be durable without its superseding merged op (crash
// safety), and the batch rebases each merged op on the durable clock so a
// synthetic op cannot reuse or regress this client's counter. The rebased
// clock still dominates both original sides.
const mergeBatch = await this.opLogStore.appendMixedSourceBatchSkipDuplicates([
{
ops: mergedResolutions.flatMap((merged) => merged.conflict.remoteOps),
source: 'remote',
},
{
ops: mergedResolutions.map((merged) => merged.mergedOp),
source: 'local',
},
]);
if (mergeBatch.skippedCount > 0) {
OpLog.verbose(
`ConflictResolutionService: Skipped ${mergeBatch.skippedCount} duplicate merge-resolution op(s)`,
);
}
const writtenMergedOpIds = new Set<string>();
for (const entry of mergeBatch.written) {
if (entry.source !== 'local') {
continue;
}
// Apply/upload the WRITTEN op — it carries the rebased vector clock.
allStoredOps.push({ id: entry.op.id, seq: entry.seq });
allOpsToApply.push(entry.op);
checkpointExemptOpIds.add(entry.op.id);
writtenMergedOpIds.add(entry.op.id);
OpLog.normal(
`ConflictResolutionService: Appended disjoint-merge op ${entry.op.id} for ` +
`${entry.op.entityType}:${entry.op.entityId}`,
);
}
// Journal ONLY after the append: once persisted as a pending local op the
// merge is durable (it applies/uploads even across a crash), so a `merged`
// ("kept both") entry can never describe a merge that didn't happen.
await this._journalMergedResolution(merged.plan);
OpLog.normal(
`ConflictResolutionService: Appended disjoint-merge op ${merged.mergedOp.id} for ` +
`${merged.mergedOp.entityType}:${merged.mergedOp.entityId}`,
);
// ("kept both") entry can never describe a merge that didn't happen. The
// inverse window is accepted: batch committed but crash before this loop
// means a durable merge without a journal entry (observe-only log; the
// remote originals are recorded-as-seen, so it never re-enters
// resolution and the entry stays absent).
for (const merged of mergedResolutions) {
if (writtenMergedOpIds.has(merged.mergedOp.id)) {
await this._journalMergedResolution(merged.plan);
}
}
}
// ─────────────────────────────────────────────────────────────────────────
@ -501,22 +554,46 @@ export class ConflictResolutionService {
}
// ─────────────────────────────────────────────────────────────────────────
// STEP 5+6: Apply remote ops (single batch) and append local-win update ops.
// The deferred-actions flush in `finally` runs whether the apply succeeded
// or threw — matches the pre-fix replayOperationBatch.finally semantics
// and guarantees buffered local actions don't leak to the next sync. (#7700)
// STEP 5: Apply remote ops in a single batch.
// Merge their clocks before entering the reducer/deferred-action window.
// Pending rows make this durable frontier crash-safe, and any subsequent
// dispatch/checkpoint/bookkeeping failure can drain buffered local actions.
// (#7700)
// ─────────────────────────────────────────────────────────────────────────
let didApplyRemoteOps = false;
let canDrainDeferredActions = false;
let hasPrimaryError = false;
try {
if (allOpsToApply.length > 0) {
OpLog.normal(
`ConflictResolutionService: Applying ${allOpsToApply.length} ops in single batch`,
);
didApplyRemoteOps = true;
await this.opLogStore.mergeRemoteOpClocks(allOpsToApply);
canDrainDeferredActions = true;
const opIdToSeq = new Map(allStoredOps.map((o) => [o.id, o.seq]));
const applyResult = await this.operationApplier.applyOperations(allOpsToApply, {
skipDeferredLocalActions: true,
onReducersCommitted: async (reducerCommittedOps) => {
// Disjoint-merge ops are synthetic LOCAL rows in the apply batch;
// their durability contract is the mixed-source append + upload
// path. The checkpoint's pending-only assertion must only see rows
// appended with pendingApply.
const checkpointOps = reducerCommittedOps.filter(
(op) => !checkpointExemptOpIds.has(op.id),
);
const reducerCommittedSeqs = checkpointOps
.map((op) => opIdToSeq.get(op.id))
.filter((seq): seq is number => seq !== undefined);
if (reducerCommittedSeqs.length !== checkpointOps.length) {
throw new Error(
'ConflictResolutionService: reducer commit contained an unknown operation.',
);
}
await this.opLogStore.markReducersCommittedAndMergeClocks(
reducerCommittedSeqs,
checkpointOps,
);
},
});
const appliedSeqs = applyResult.appliedOps
@ -526,71 +603,68 @@ export class ConflictResolutionService {
if (appliedSeqs.length > 0) {
await this.opLogStore.markApplied(appliedSeqs);
// CRITICAL: Merge remote ops' vector clocks into local clock.
// This ensures subsequent local operations have clocks that "dominate"
// the applied remote ops (GREATER_THAN instead of CONCURRENT).
// Without this, ops created after conflict resolution would have clocks
// that are CONCURRENT with the applied ops, causing them to be incorrectly
// filtered by SyncImportFilterService or rejected as conflicts on next sync.
await this.opLogStore.mergeRemoteOpClocks(applyResult.appliedOps);
OpLog.normal(
`ConflictResolutionService: Successfully applied ${appliedSeqs.length} ops`,
);
}
if (applyResult.failedOp) {
const failedOpIds = getFailedOpIdsFromBatch(
allOpsToApply,
applyResult.failedOp.op,
);
const failedOpIds = [applyResult.failedOp.op.id];
OpLog.err(
`ConflictResolutionService: ${applyResult.appliedOps.length} ops applied before failure. ` +
`Marking ${failedOpIds.length} ops as failed.`,
'Marking the attempted archive operation as failed.',
applyResult.failedOp.error,
);
await this.opLogStore.markFailed(failedOpIds, MAX_CONFLICT_RETRY_ATTEMPTS);
await this.opLogStore.markFailed(failedOpIds);
this.snackService.open({
type: 'ERROR',
msg: T.F.SYNC.S.CONFLICT_RESOLUTION_FAILED,
actionStr: T.PS.RELOAD,
actionFn: (): void => {
window.location.reload();
},
});
// Never replace a visible persistent recovery action (e.g. the
// USE_REMOTE Undo — the only entry point to the pre-replace backup).
// The IncompleteRemoteOperationsError thrown below still flips the
// sync status to ERROR via the wrapper's (equally guarded) handler.
if (!this.snackService.hasPendingPersistentAction()) {
this.snackService.open({
type: 'ERROR',
msg: T.F.SYNC.S.CONFLICT_RESOLUTION_FAILED,
actionStr: T.PS.RELOAD,
actionFn: (): void => {
window.location.reload();
},
});
}
// FIX #6571: Throw on apply failure (parity with applyNonConflictingOps).
// Previously, apply failures during LWW resolution were logged but not
// thrown, causing sync to report IN_SYNC despite lost operations.
// Deferred-actions flush runs in the finally below before the throw
// propagates.
throw applyResult.failedOp.error;
throw new IncompleteRemoteOperationsError(applyResult.failedOp.error);
}
}
// ───────────────────────────────────────────────────────────────────────
// Append new update ops for local wins (will sync on next cycle)
// Uses appendWithVectorClockUpdate to ensure vector clock store stays in sync
// ───────────────────────────────────────────────────────────────────────
for (const op of newLocalWinOps) {
await this.opLogStore.appendWithVectorClockUpdate(op, 'local');
OpLog.normal(
`ConflictResolutionService: Appended local-win update op ${op.id} for ${op.entityType}:${op.entityId}`,
);
}
} catch (error) {
hasPrimaryError = true;
throw error;
} finally {
if (didApplyRemoteOps) {
await processDeferredActionsAfterRemoteApply(
this.injector,
options.callerHoldsOperationLogLock ?? false,
);
if (canDrainDeferredActions) {
try {
await processDeferredActionsAfterRemoteApply(
this.injector,
options.callerHoldsOperationLogLock ?? false,
);
} catch (deferredError) {
if (!hasPrimaryError) {
throw deferredError;
}
OpLog.err(
'ConflictResolutionService: Deferred-action drain also failed after the primary remote-apply error',
{ name: (deferredError as Error | undefined)?.name },
);
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// STEP 7: Show non-blocking notification
// STEP 6: Show non-blocking notification
//
// Distinguish "routine" self-healing (reschedule/repeat/archive/done churn
// that resolves correctly on its own) from resolutions that discarded a real
@ -598,12 +672,12 @@ export class ConflictResolutionService {
// existing transient count; genuine content loss gets a dismissible banner
// naming the affected task(s) so the user can double-check. (#8694)
// ─────────────────────────────────────────────────────────────────────────
if (localWinsCount > 0 || remoteWinsCount > 0) {
await this._notifyResolutionOutcome(resolutions, localWinsCount, remoteWinsCount);
if (resolutions.length > 0) {
await this._notifyResolutionOutcome(resolutions);
}
// ─────────────────────────────────────────────────────────────────────────
// STEP 8: Validate and repair state after resolution
// STEP 7: Validate and repair state after resolution
// Validation failure flips the SyncSessionValidationService latch — the
// wrapper reads it before deciding IN_SYNC vs ERROR. (#7330)
// ─────────────────────────────────────────────────────────────────────────
@ -615,9 +689,11 @@ export class ConflictResolutionService {
// caller uses this count to trigger the immediate re-upload
// (immediate-upload.service.ts) — omitting merges lets a merge-only sync
// report IN_SYNC while its merged op sits unsynced until a later cycle.
// Mirrors the rejection-handler path (operation-log-sync.service.ts:361).
// Mirrors the rejection-handler accumulation in operation-log-sync.service.
// writtenLocalWinOps (not newLocalWinOps) is the post-dedupe set the atomic
// mixed-source batch actually persisted.
return {
localWinOpsCreated: newLocalWinOps.length + mergedResolutions.length,
localWinOpsCreated: writtenLocalWinOps.length + mergedResolutions.length,
};
}
@ -632,11 +708,7 @@ export class ConflictResolutionService {
* Purely a read of the already-decided resolutions it never influences which
* ops were applied or rejected.
*/
private async _notifyResolutionOutcome(
resolutions: LWWResolution[],
localWinsCount: number,
remoteWinsCount: number,
): Promise<void> {
private async _notifyResolutionOutcome(resolutions: LWWResolution[]): Promise<void> {
const contentConflicts = findLwwContentConflicts(resolutions, (entityType) =>
this._resolvePayloadKey(entityType as EntityType),
);

View file

@ -9,6 +9,9 @@ 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';
import { SnackService } from '../../core/snack/snack.service';
import { IncompleteRemoteOperationsError } from '../core/errors/sync-errors';
import { T } from '../../t.const';
describe('ImmediateUploadService', () => {
let service: ImmediateUploadService;
@ -16,6 +19,7 @@ describe('ImmediateUploadService', () => {
let mockSyncService: jasmine.SpyObj<OperationLogSyncService>;
let mockDataInitStateService: { isAllDataLoadedInitially$: BehaviorSubject<boolean> };
let mockSyncWrapperService: { isEncryptionOperationInProgress: boolean };
let mockSnackService: jasmine.SpyObj<SnackService>;
let mockProvider: any;
let originalOnLineDescriptor: PropertyDescriptor | undefined;
@ -27,6 +31,7 @@ describe('ImmediateUploadService', () => {
permanentRejectionCount: number;
hasMorePiggyback: boolean;
rejectedOps: RejectedOpInfo[];
encryptionRequiredKeyMissing: boolean;
}> = {},
): {
kind: 'completed';
@ -36,6 +41,7 @@ describe('ImmediateUploadService', () => {
permanentRejectionCount: number;
hasMorePiggyback: boolean;
rejectedOps: RejectedOpInfo[];
encryptionRequiredKeyMissing?: boolean;
} => ({
kind: 'completed',
uploadedCount: 0,
@ -95,6 +101,11 @@ describe('ImmediateUploadService', () => {
mockSyncWrapperService = {
isEncryptionOperationInProgress: false,
};
mockSnackService = jasmine.createSpyObj('SnackService', [
'open',
'hasPendingPersistentAction',
]);
mockSnackService.hasPendingPersistentAction.and.returnValue(false);
TestBed.configureTestingModule({
providers: [
@ -103,6 +114,7 @@ describe('ImmediateUploadService', () => {
{ provide: OperationLogSyncService, useValue: mockSyncService },
{ provide: DataInitStateService, useValue: mockDataInitStateService },
{ provide: SyncWrapperService, useValue: mockSyncWrapperService },
{ provide: SnackService, useValue: mockSnackService },
],
});
@ -136,6 +148,80 @@ describe('ImmediateUploadService', () => {
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
}));
it('should report UNKNOWN_OR_CHANGED when the initial upload lacks a mandatory encryption key', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.resolveTo(
completedResult({
uploadedCount: 1,
encryptionRequiredKeyMissing: true,
}),
);
service.initialize();
service.trigger();
tick(2000);
flush();
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
'UNKNOWN_OR_CHANGED',
);
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
}));
it('should report incomplete remote application as a sticky translated error', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.rejectWith(
new IncompleteRemoteOperationsError(new Error('archive failed')),
);
service.initialize();
service.trigger();
tick(2000);
flush();
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSnackService.open).toHaveBeenCalledWith({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
}));
it('should preserve an existing persistent recovery action for incomplete remote work', fakeAsync(() => {
mockSnackService.hasPendingPersistentAction.and.returnValue(true);
mockSyncService.uploadPendingOps.and.rejectWith(
new IncompleteRemoteOperationsError(new Error('archive failed')),
);
service.initialize();
service.trigger();
tick(2000);
flush();
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSnackService.open).not.toHaveBeenCalled();
}));
it('should report UNKNOWN_OR_CHANGED when a local-win follow-up lacks a mandatory encryption key', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.returnValues(
Promise.resolve(completedResult({ uploadedCount: 1, localWinOpsCreated: 1 })),
Promise.resolve(
completedResult({
uploadedCount: 0,
encryptionRequiredKeyMissing: true,
}),
),
);
service.initialize();
service.trigger();
tick(2000);
flush();
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
'UNKNOWN_OR_CHANGED',
);
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
}));
it('should NOT show checkmark when piggybacked ops exist', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve(completedResult({ uploadedCount: 2, piggybackedOpsCount: 1 })),
@ -179,6 +265,19 @@ describe('ImmediateUploadService', () => {
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
});
it('should report ERROR when piggyback processing is blocked by an incompatible op', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.resolveTo({
kind: 'blocked_incompatible',
});
service.initialize();
service.trigger();
tick(2000);
flush();
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
}));
it('should NOT show checkmark when piggybacked ops exist (multiple)', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve(completedResult({ uploadedCount: 5, piggybackedOpsCount: 3 })),
@ -192,6 +291,49 @@ describe('ImmediateUploadService', () => {
// Piggybacked ops are processed internally, no checkmark shown
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
}));
it('should report ERROR when the local-win follow-up is blocked', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.returnValues(
Promise.resolve(completedResult({ uploadedCount: 1, localWinOpsCreated: 1 })),
Promise.resolve({ kind: 'blocked_incompatible' }),
);
service.initialize();
service.trigger();
tick(2000);
flush();
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
}));
it('should not show a checkmark when the local-win follow-up is cancelled', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.returnValues(
Promise.resolve(completedResult({ uploadedCount: 1, localWinOpsCreated: 1 })),
Promise.resolve({ kind: 'cancelled' }),
);
service.initialize();
service.trigger();
tick(2000);
flush();
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
}));
it('should defer the checkmark when the local-win follow-up receives piggybacked ops', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.returnValues(
Promise.resolve(completedResult({ uploadedCount: 1, localWinOpsCreated: 1 })),
Promise.resolve(completedResult({ uploadedCount: 1, piggybackedOpsCount: 1 })),
);
service.initialize();
service.trigger();
tick(2000);
flush();
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
}));
});
// #8309: the immediate-upload side channel must not interleave with another

Some files were not shown because too many files have changed in this diff Show more