@terreno/mcp 0.31.0 → 56.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/bootstrap.d.ts.map +1 -1
  2. package/dist/bootstrap.js +8 -9
  3. package/dist/bootstrap.js.map +1 -1
  4. package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +1 -1
  5. package/dist/docs/upgrades/56.0.0-beta.2.md +59 -0
  6. package/dist/docs/versioned/next/README.md +2 -0
  7. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +21 -0
  8. package/dist/docs/versioned/next/how-to/README.md +2 -0
  9. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +445 -0
  10. package/dist/docs/versioned/next/implementationPlans/PLAN_INDEX.md +28 -50
  11. package/dist/docs/versioned/next/implementationPlans/b2b-platform-program.md +3 -2
  12. package/dist/docs/versioned/next/implementationPlans/comms-abstraction.md +98 -15
  13. package/dist/docs/versioned/next/implementationPlans/comms-adapter-sendgrid.md +63 -13
  14. package/dist/docs/versioned/next/implementationPlans/comms-adapter-twilio-sms.md +65 -12
  15. package/dist/docs/versioned/next/implementationPlans/comms-adapter-twilio-verify.md +9 -3
  16. package/dist/docs/versioned/next/implementationPlans/comms-admin-dashboard.md +180 -0
  17. package/dist/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci-research.md +126 -0
  18. package/dist/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci.md +269 -0
  19. package/dist/docs/versioned/next/implementationPlans/rtk-to-syncdb-migration-docs.md +52 -47
  20. package/dist/docs/versioned/next/implementationPlans/syncdb-api-inventory.md +303 -0
  21. package/dist/docs/versioned/next/implementationPlans/syncdb-codegen.md +252 -0
  22. package/dist/docs/versioned/next/implementationPlans/syncdb-local-first.md +468 -420
  23. package/dist/docs/versioned/next/implementationPlans/syncdb-phase-c-design.md +425 -0
  24. package/dist/docs/versioned/next/implementationPlans/terreno-syncdb-2.md +800 -0
  25. package/dist/docs/versioned/next/reference/README.md +2 -0
  26. package/dist/docs/versioned/next/reference/comms.md +132 -0
  27. package/dist/docs/versioned/next/reference/environment-variables.md +9 -0
  28. package/dist/docs/versioned/next/reference/syncdb.md +456 -0
  29. package/dist/docs/versioned/next/superpowers/plans/2026-07-28-admin-mobile-navigation.md +97 -0
  30. package/dist/docs/versioned/next/superpowers/specs/2026-07-28-admin-mobile-navigation-design.md +36 -0
  31. package/dist/docs/versioned/next/tasks/comms-abstraction.md +29 -16
  32. package/dist/docs/versioned/next/tasks/comms-adapter-sendgrid.md +17 -7
  33. package/dist/docs/versioned/next/tasks/comms-adapter-twilio-sms.md +17 -7
  34. package/dist/docs/versioned/next/tasks/comms-adapter-twilio-verify.md +3 -3
  35. package/dist/docs/versioned/next/tasks/comms-admin-dashboard.md +52 -0
  36. package/dist/docs/versioned/next/tasks/migrate-cicd-to-circleci.md +215 -0
  37. package/dist/docs/versioned/next/tasks/rtk-to-syncdb-migration-docs.md +10 -10
  38. package/dist/docs/versioned/next/tasks/syncdb-codegen.md +65 -0
  39. package/dist/docs/versioned/next/tasks/syncdb-local-first.md +434 -117
  40. package/package.json +3 -3
  41. package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +1 -1
  42. package/src/docs/upgrades/56.0.0-beta.2.md +59 -0
  43. package/src/docs/versioned/next/README.md +2 -0
  44. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +21 -0
  45. package/src/docs/versioned/next/how-to/README.md +2 -0
  46. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +445 -0
  47. package/src/docs/versioned/next/implementationPlans/PLAN_INDEX.md +28 -50
  48. package/src/docs/versioned/next/implementationPlans/b2b-platform-program.md +3 -2
  49. package/src/docs/versioned/next/implementationPlans/comms-abstraction.md +98 -15
  50. package/src/docs/versioned/next/implementationPlans/comms-adapter-sendgrid.md +63 -13
  51. package/src/docs/versioned/next/implementationPlans/comms-adapter-twilio-sms.md +65 -12
  52. package/src/docs/versioned/next/implementationPlans/comms-adapter-twilio-verify.md +9 -3
  53. package/src/docs/versioned/next/implementationPlans/comms-admin-dashboard.md +180 -0
  54. package/src/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci-research.md +126 -0
  55. package/src/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci.md +269 -0
  56. package/src/docs/versioned/next/implementationPlans/rtk-to-syncdb-migration-docs.md +52 -47
  57. package/src/docs/versioned/next/implementationPlans/syncdb-api-inventory.md +303 -0
  58. package/src/docs/versioned/next/implementationPlans/syncdb-codegen.md +252 -0
  59. package/src/docs/versioned/next/implementationPlans/syncdb-local-first.md +468 -420
  60. package/src/docs/versioned/next/implementationPlans/syncdb-phase-c-design.md +425 -0
  61. package/src/docs/versioned/next/implementationPlans/terreno-syncdb-2.md +800 -0
  62. package/src/docs/versioned/next/reference/README.md +2 -0
  63. package/src/docs/versioned/next/reference/comms.md +132 -0
  64. package/src/docs/versioned/next/reference/environment-variables.md +9 -0
  65. package/src/docs/versioned/next/reference/syncdb.md +456 -0
  66. package/src/docs/versioned/next/superpowers/plans/2026-07-28-admin-mobile-navigation.md +97 -0
  67. package/src/docs/versioned/next/superpowers/specs/2026-07-28-admin-mobile-navigation-design.md +36 -0
  68. package/src/docs/versioned/next/tasks/comms-abstraction.md +29 -16
  69. package/src/docs/versioned/next/tasks/comms-adapter-sendgrid.md +17 -7
  70. package/src/docs/versioned/next/tasks/comms-adapter-twilio-sms.md +17 -7
  71. package/src/docs/versioned/next/tasks/comms-adapter-twilio-verify.md +3 -3
  72. package/src/docs/versioned/next/tasks/comms-admin-dashboard.md +52 -0
  73. package/src/docs/versioned/next/tasks/migrate-cicd-to-circleci.md +215 -0
  74. package/src/docs/versioned/next/tasks/rtk-to-syncdb-migration-docs.md +10 -10
  75. package/src/docs/versioned/next/tasks/syncdb-codegen.md +65 -0
  76. package/src/docs/versioned/next/tasks/syncdb-local-first.md +434 -117
  77. package/dist/__tests__/bootstrap.test.d.ts +0 -2
  78. package/dist/__tests__/bootstrap.test.d.ts.map +0 -1
  79. package/dist/__tests__/bootstrap.test.js +0 -299
  80. package/dist/__tests__/bootstrap.test.js.map +0 -1
  81. package/dist/__tests__/databaseQuery.test.d.ts +0 -2
  82. package/dist/__tests__/databaseQuery.test.d.ts.map +0 -1
  83. package/dist/__tests__/databaseQuery.test.js +0 -83
  84. package/dist/__tests__/databaseQuery.test.js.map +0 -1
  85. package/dist/__tests__/docsRoot.test.d.ts +0 -2
  86. package/dist/__tests__/docsRoot.test.d.ts.map +0 -1
  87. package/dist/__tests__/docsRoot.test.js +0 -59
  88. package/dist/__tests__/docsRoot.test.js.map +0 -1
  89. package/dist/__tests__/inferPackages.test.d.ts +0 -2
  90. package/dist/__tests__/inferPackages.test.d.ts.map +0 -1
  91. package/dist/__tests__/inferPackages.test.js +0 -35
  92. package/dist/__tests__/inferPackages.test.js.map +0 -1
  93. package/dist/__tests__/preload.d.ts +0 -2
  94. package/dist/__tests__/preload.d.ts.map +0 -1
  95. package/dist/__tests__/preload.js +0 -12
  96. package/dist/__tests__/preload.js.map +0 -1
  97. package/dist/__tests__/prompts.test.d.ts +0 -2
  98. package/dist/__tests__/prompts.test.d.ts.map +0 -1
  99. package/dist/__tests__/prompts.test.js +0 -295
  100. package/dist/__tests__/prompts.test.js.map +0 -1
  101. package/dist/__tests__/resources.test.d.ts +0 -2
  102. package/dist/__tests__/resources.test.d.ts.map +0 -1
  103. package/dist/__tests__/resources.test.js +0 -352
  104. package/dist/__tests__/resources.test.js.map +0 -1
  105. package/dist/__tests__/search.test.d.ts +0 -2
  106. package/dist/__tests__/search.test.d.ts.map +0 -1
  107. package/dist/__tests__/search.test.js +0 -163
  108. package/dist/__tests__/search.test.js.map +0 -1
  109. package/dist/__tests__/tools.test.d.ts +0 -2
  110. package/dist/__tests__/tools.test.d.ts.map +0 -1
  111. package/dist/__tests__/tools.test.js +0 -540
  112. package/dist/__tests__/tools.test.js.map +0 -1
  113. package/dist/__tests__/upgradeGuide.test.d.ts +0 -2
  114. package/dist/__tests__/upgradeGuide.test.d.ts.map +0 -1
  115. package/dist/__tests__/upgradeGuide.test.js +0 -22
  116. package/dist/__tests__/upgradeGuide.test.js.map +0 -1
@@ -0,0 +1,800 @@
1
+ # SyncDB Hardening Plan (terreno-syncdb-2)
2
+
3
+ Implementation plan for hardening PR #869 (`@terreno/syncdb` local-first data layer,
4
+ branch `worktree-ip-syncdb`). This plan is self-contained: it restates each defect,
5
+ the exact fix, the files involved, and the tests that prove it. Work happens on a
6
+ branch cut from `worktree-ip-syncdb` (or on that branch directly).
7
+
8
+ Every file path below is relative to the repo root. Line numbers reference the PR
9
+ head commit `c854c146` and may drift slightly — always locate by the described code,
10
+ not the line number alone.
11
+
12
+ ---
13
+
14
+ ## Global invariants — apply to EVERY task in this plan
15
+
16
+ These override anything else in this document if there is a conflict.
17
+
18
+ **INV-1: Ordering is sacred.**
19
+ Mutation order matters. Within an entity, order is absolute. Across entities and
20
+ collections, assume order matters too (creates can be referenced by later mutations
21
+ in other collections). The replay pipeline must preserve **global FIFO order by
22
+ `enqueueOrder`** unless a task explicitly says otherwise. When any mutation fails
23
+ with a retryable outcome, **stop the line** — do not skip ahead and apply later
24
+ mutations while an earlier one is unresolved. The only sanctioned exception is
25
+ per-entity conflict skipping (Task B4), which is explicitly specified there.
26
+
27
+ **INV-2: A 401 / auth failure NEVER destroys local data.**
28
+ Unauthorized outcomes (HTTP 401, `AuthRequiredError`, `unauthorized` nack, socket
29
+ auth rejection) must: (a) leave every outbox row, entity, cursor, and conflict
30
+ exactly as it was; (b) not increment retry budgets; (c) transition the client into
31
+ a visible `paused: "auth"` state; (d) resume automatically and completely when the
32
+ SAME user re-authenticates. Local data is wiped only when a DIFFERENT userId
33
+ authenticates (existing `runUserCheck` behavior) or when the host app explicitly
34
+ calls the wipe API. Token expiry, session revocation, and transient 401s are
35
+ recoverable states, not logout.
36
+
37
+ **INV-3: Idempotency is the retry safety net — never weaken it.**
38
+ Every mutation keeps its client-minted `mutationId`, and the server ledger
39
+ (`api/src/sync/mutationHandler.ts`) keeps exactly-once semantics per id. Any retry
40
+ mechanism (batch or single) must be safe to replay wholesale because of this.
41
+ Changes that could double-apply a mutation are wrong by definition.
42
+
43
+ **INV-4: Repo conventions.**
44
+ bun test + expect; Luxon (never `Date` in app logic); RORO; const arrow functions;
45
+ interfaces over types; early returns; `logger.*` on backend, `console.info/debug/
46
+ warn/error` on frontend. E2E: testIDs only, no `waitForTimeout`.
47
+
48
+ ---
49
+
50
+ ## Model flags summary
51
+
52
+ Most tasks are **Sonnet-implementable** because this plan does the design work.
53
+ Flags where that is not enough:
54
+
55
+ | Task | Flag | Why |
56
+ |---|---|---|
57
+ | C1 (seq commit fence) | **OPUS — design + implementation** | Distributed-ordering correctness under concurrent writers; requires reasoning about Mongo transaction/commit semantics that a spec can't fully pin down. |
58
+ | C2 (per-stream cursors) | **OPUS — design; Sonnet can execute after** | Client-visible protocol change with migration for already-deployed cursors; get the contract reviewed before code. |
59
+ | C4 (scope-move tombstones on write path) | **OPUS recommended** | Change-stream/write-path interleaving is subtle; wrong fix silently loses tombstones. |
60
+ | B1–B5 (batch protocol) | Sonnet implement, **OPUS review the final semantics** before merge | Spec below is complete, but ordering edge cases deserve a second set of eyes. |
61
+ | Everything else (A, D, E, F) | **Sonnet** | Mechanical once specified. |
62
+
63
+ Suggested execution order: Phase A → B → D → E → C → F. A/B/D/E are independent
64
+ enough to parallelize; C changes the wire contract and should land before any real
65
+ external consumer adopts the protocol; F (tests/infra) accompanies each phase and
66
+ finishes last.
67
+
68
+ ---
69
+
70
+ # Phase A — Client replay correctness & scheduling (Sonnet)
71
+
72
+ The replay scheduler is trigger-driven with no self-wake-up, no crash recovery, and
73
+ a baseVersion scheme that self-conflicts. These five tasks kill the observed
74
+ "queues everything up and gets stuck" symptoms.
75
+
76
+ ## A1. Startup recovery for stranded outbox rows
77
+
78
+ **Problem.** Nothing recovers durable `inFlight` rows after a crash/reload.
79
+ `outbox.listQueued` only returns `status === "queued"`
80
+ (`syncdb/src/mutations/outbox.ts` ~line 163), and `client.start()`
81
+ (`syncdb/src/client.ts` ~382-404) has no recovery pass. A row stranded `inFlight`
82
+ never replays, and its entity's `pendingMutationId` blocks every future delta
83
+ (`syncdb/src/sync/deltaApplier.ts` ~47-50) and snapshot page
84
+ (`syncdb/src/sync/bootstrap.ts` ~56-59) forever. Sibling crash windows:
85
+ between `markAcked` and `releaseEntity`, and between `markConflicted` and
86
+ `writeConflict` (`syncdb/src/sync/replayCoordinator.ts`).
87
+
88
+ **Fix.**
89
+ 1. Add `outbox.recoverStartupState({userId})`:
90
+ - Every `inFlight` row → transition back to `queued`. Do NOT increment
91
+ `attemptCount` (recovery is not an attempt).
92
+ - Every `acked` row whose entity still has `pendingMutationId === mutationId`
93
+ → clear the entity's `pendingMutationId` (replays the missing
94
+ `releaseEntity`).
95
+ - Every `conflicted` row with no matching `_conflicts` row → write the conflict
96
+ row now (localData from the entity, serverData `null`, `serverSeq 0`), so the
97
+ UI can surface it. Alternatively re-queue it; pick writing the conflict row —
98
+ it is the state the machine had committed to.
99
+ 2. Call it from `client.start()` immediately after the persister finishes
100
+ `startAutoLoad()` and `runUserCheck` resolves the userId, before the first
101
+ `replayOutbox()`.
102
+
103
+ **Tests** (`syncdb/src/mutations/outbox.test.ts`, `syncdb/src/client.test.ts`):
104
+ - Store persisted with an `inFlight` row → after `start()`, row is `queued` and
105
+ replays; entity receives deltas again after resolution.
106
+ - `acked` row + entity still pending → pendingMutationId cleared on start.
107
+ - `conflicted` row without conflict row → conflict row exists after start.
108
+
109
+ ## A2. Send-time baseVersion refresh
110
+
111
+ **Problem.** `mutate()` captures `baseVersion: existing?.seq` at enqueue time
112
+ (`syncdb/src/client.ts` ~470, ~497). Optimistic writes never advance `seq`, so two
113
+ queued edits to the same entity carry the same base; after the first acks at seq
114
+ N+1, the second (base N) is a guaranteed `conflict` nack against the server's
115
+ strict equality check (`api/src/sync/executors.ts` ~400). Every offline session
116
+ with >1 edit per doc manufactures conflicts.
117
+
118
+ **Fix.** In the replay coordinator, when building the request
119
+ (`replayCoordinator.ts` `buildRequest`), for `update`/`delete` operations read the
120
+ entity's CURRENT `seq` from the store and use it as `baseVersion` when it is newer
121
+ than the stored one (the stored one remains the floor; never send a lower value
122
+ than stored). The prior mutation's ack has already stamped the entity's seq via
123
+ `releaseEntity(mutation, ack.seq)`, so this chains bases correctly through a queue
124
+ of edits. Keep the enqueue-time capture as the initial value.
125
+
126
+ Do NOT coalesce mutations in this task. (Optional coalescing is a follow-up,
127
+ gated behind a config flag, and is intentionally out of scope: server-side hooks
128
+ observe each write, and collapsing intermediate states changes semantics.)
129
+
130
+ **Tests** (`syncdb/src/sync/replayCoordinator.test.ts`, plus one integration test
131
+ in `api/src/sync/integration.test.ts`):
132
+ - Enqueue create + update to same entity offline → replay online → both ack, no
133
+ conflict.
134
+ - Enqueue update + update → both ack; second request carried the first's acked seq.
135
+ - Regression: a genuinely stale base (server changed underneath) still conflicts.
136
+
137
+ ## A3. Real scheduler: drain-until-empty + timed wake-ups + jittered backoff
138
+
139
+ **Problem.** Four park-without-wakeup paths (`replayCoordinator.ts`):
140
+ transport failure returns with no reschedule; error-nack backoff sets `retryAt`
141
+ but no timer fires when it elapses; a mutation enqueued during a running replay
142
+ coalesces into a stale snapshot and is never sent; and the only safety net is the
143
+ 5-minute periodic timer (`client.ts` `DEFAULT_RECONCILE_INTERVAL_MS`). Plus:
144
+ transport failures burn the error-nack `attemptCount` budget (outbox
145
+ `markInFlight` increments every attempt; the terminality check reads the same
146
+ counter), so an offline stretch can push the first real server error straight to
147
+ terminal `failed`.
148
+
149
+ **Fix.** Rework `createReplayCoordinator`:
150
+ 1. **Drain until empty.** After a drain pass completes, re-run `listQueued`; if
151
+ new queued mutations exist (and nothing parked the queue), immediately run
152
+ another pass. `replay()` resolves only when the queue is empty or parked.
153
+ 2. **Timed wake-ups.** Whenever a drain parks (transport failure or error-nack
154
+ backoff), arm a single `setTimeout` for the earliest `retryAt` (transport
155
+ failures get their own backoff schedule, below) that calls the internal drain
156
+ again. Store the timer handle; clear it on `stop()` (coordinator needs a
157
+ `dispose()` called from `client.stop()`), on wipe, and when an external
158
+ trigger runs a drain first. Never hold more than one armed timer per user.
159
+ 3. **Two separate retry budgets:**
160
+ - *Server error-nacks* keep the existing budget: `MAX_ERROR_NACK_ATTEMPTS`
161
+ (5), exponential backoff — but add full jitter:
162
+ `delay = random(0, base * 2^(attempt-1))`, capped at 30s.
163
+ - *Transport failures* (thrown sends, timeouts) get UNLIMITED retries with
164
+ the same capped jittered backoff and must NOT count toward the error-nack
165
+ budget. Implement by tracking a separate in-memory
166
+ `transportFailures: Map<mutationId, count>` for backoff computation, and by
167
+ changing terminality to use a dedicated `errorNackCount` cell on the outbox
168
+ row (new column, incremented only on error-nacks) instead of `attemptCount`.
169
+ `attemptCount` stays as a diagnostic total.
170
+ 4. **Global FIFO drain (INV-1).** Replace the per-collection parallel drain
171
+ (`Promise.all` over collections) with ONE serial drain over the global
172
+ `enqueueOrder`-sorted queue. This removes cross-collection reordering. (The
173
+ batch task B3 builds directly on this.)
174
+ 5. `client.replayOutbox()` must skip the extra full `listQueued` scan when debug
175
+ logging is disabled (currently unconditional, `client.ts` ~250).
176
+
177
+ **Tests** (`replayCoordinator.test.ts` with injected `now` + fake timers):
178
+ - Enqueue during an active drain → sent by the same `replay()` call, no external
179
+ trigger.
180
+ - Error-nack → backoff elapses → retry fires from the armed timer alone.
181
+ - Transport failure ×10 → still queued (not terminal); a subsequent error-nack
182
+ starts its budget at 1.
183
+ - Jitter: two backoffs for the same attempt differ (seeded random injectable).
184
+ - Global order: mutations across two collections replay strictly in enqueue
185
+ order.
186
+ - `dispose()` clears armed timers (no post-stop sends).
187
+
188
+ ## A4. Auth-pause pipeline (the 401 contract) — INV-2 made concrete
189
+
190
+ **Problem.** Today: `unauthorized` socket nacks pause correctly, but
191
+ `AuthRequiredError` thrown by the HTTP channel (`syncdb/src/sync/httpChannel.ts`
192
+ ~15-20, 77-79) is swallowed by the coordinator's bare `catch` as a generic
193
+ transport failure — retried forever with the dead token, burning budget. There is
194
+ no user-visible paused state, no re-auth hook, and nothing resumes replay
195
+ specifically on re-authentication of the same user.
196
+
197
+ **Fix.**
198
+ 1. In `drainCollection`'s catch, detect `AuthRequiredError` (instanceof, exported
199
+ from httpChannel) and treat it exactly like an `unauthorized` nack: requeue
200
+ (no budget consumption), set `authPaused`, stop the drain, return
201
+ `{paused: "auth"}`.
202
+ 2. Add to client state and `SyncStatus` (`client.ts` `getSyncStatus`, and
203
+ `syncdb/src/react/hooks.ts` `useSyncStatus`): `paused?: "auth"`, plus
204
+ `queuedCount` already present and new `failedCount`. Set `paused: "auth"`
205
+ whenever a replay returns it OR the socket transport reports an auth-rejected
206
+ (re)connect; clear it when replay subsequently succeeds.
207
+ 3. Add `onAuthRequired?: () => void` to `SyncDbConfig`, fired (debounced — at most
208
+ once per pause episode) when entering the paused state, so the host app can
209
+ show a re-login prompt.
210
+ 4. On `authProvider` change events (`handleAuthChange`): if the resolved userId
211
+ equals `currentUserId`, clear the pause and immediately replay — with the
212
+ outbox fully intact. (Different userId keeps existing wipe behavior.) When
213
+ `getUserId()` returns null/undefined (logged out), DO NOT wipe: keep data and
214
+ remain paused. Wipe on logout only if the host app opts in via a new
215
+ `wipeOnSignOut?: boolean` config consumed by an explicit `client.signOut()`
216
+ method (which the app calls deliberately) — never inferred from a 401.
217
+ 5. Before surfacing the pause, give the auth adapter one chance to refresh:
218
+ `betterAuthAdapter` (`syncdb/src/auth/betterAuthAdapter.ts`) should expose an
219
+ optional `refresh(): Promise<boolean>`; the client calls it once per pause
220
+ episode and retries replay if it returns true.
221
+ 6. While paused for auth, the reconcile/bootstrap loops must also stand down
222
+ (they hit the same 401s) — gate `reconcile()` on the pause flag, and make
223
+ `handleStatusChange`/timer triggers no-op replay+reconcile while paused
224
+ (auth-change is the only unpause path).
225
+
226
+ **Tests** (`client.test.ts`, `replayCoordinator.test.ts`,
227
+ `api/src/sync/integration.test.ts`):
228
+ - HTTP channel throws `AuthRequiredError` → status shows `paused: "auth"`,
229
+ outbox untouched, zero budget consumed, `onAuthRequired` fired once.
230
+ - Same-user re-auth → pause clears, queue drains fully, entities converge.
231
+ - Logout (userId → undefined) → data retained; later same-user login → queue
232
+ drains (integration: offline edits survive a token expiry + re-login round
233
+ trip).
234
+ - Different-user login → wipe still happens (regression).
235
+ - No reconcile/snapshot requests are issued while paused.
236
+
237
+ ## A5. Outbox hygiene: pruning, O(1) ordering, durable sort key
238
+
239
+ **Problem.** Acked/failed rows are never deleted; `nextEnqueueOrder` scans the
240
+ whole table per enqueue; `listQueued` scans per replay/mutate/status; FIFO sorts
241
+ primarily on locale-offset ISO `createdAt` via `localeCompare` (DST/timezone
242
+ travel can reorder an update before its create).
243
+
244
+ **Fix** (all in `syncdb/src/mutations/outbox.ts`):
245
+ 1. Sort `listQueued` by `enqueueOrder` primary, `createdAt` tiebreak (invert
246
+ today's order).
247
+ 2. Keep a `_meta` value cell (`store.raw.setValue`) holding the max
248
+ `enqueueOrder`; `nextEnqueueOrder` reads/increments it (rebuild from a table
249
+ scan once at startup if absent).
250
+ 3. Add `outbox.prune({userId, keepFailed?: number})`: delete `acked` rows
251
+ immediately upon a successful drain pass (they carry no future value — the
252
+ server ledger owns idempotency), keep the most recent N `failed` rows
253
+ (default 50) for debugging/UI, delete older ones. Call from the coordinator
254
+ after each drain pass. `conflicted` rows are never pruned automatically.
255
+
256
+ **Tests:** pruning after drain; enqueueOrder survives restart; ordering stable
257
+ across a simulated timezone offset change in `createdAt`.
258
+
259
+ ---
260
+
261
+ # Phase B — Ordered batch protocol (Sonnet implement; OPUS review semantics)
262
+
263
+ Goal: N queued mutations should cost ~N/50 round-trips instead of N, without ever
264
+ reordering or continuing past a failure.
265
+
266
+ ## B1. Wire contract
267
+
268
+ New request/response types in `api/src/sync/types.ts` and `syncdb/src/types.ts`:
269
+
270
+ ```ts
271
+ interface SyncMutateBatchRequest {
272
+ /** Ordered. The server MUST apply strictly in array order. */
273
+ mutations: SyncMutateRequest[]; // each still carries its own mutationId
274
+ }
275
+
276
+ interface SyncMutateBatchResponse {
277
+ /**
278
+ * One result per PROCESSED mutation, in the same order as the request.
279
+ * Length < request length means the server halted at the first non-ack:
280
+ * results[last] is that failing outcome, and every mutation after it was
281
+ * NOT attempted (still safe to resend).
282
+ */
283
+ results: ({type: "ack"; ack: SyncAck} | {type: "nack"; nack: SyncNack})[];
284
+ }
285
+ ```
286
+
287
+ Transports: `POST /sync/mutate/batch` (`api/src/sync/routes.ts`) and socket event
288
+ `sync:mutateBatch` (`api/src/sync/socketHandlers.ts`). Batch size limit: server
289
+ rejects > 100 mutations per batch with a `validation` nack-shaped 422; enforce
290
+ before any processing.
291
+
292
+ ## B2. Server: strict-order, stop-on-first-non-ack
293
+
294
+ In `api/src/sync/mutationHandler.ts` add `applySyncMutationBatch`:
295
+ 1. Iterate the array **strictly serially** (`for … await`), calling the existing
296
+ `applySyncMutation` per item — full reuse of the idempotency ledger,
297
+ executors, permissions, and delta emission. No parallelism, ever.
298
+ 2. **Stop-on-error (the user's hard requirement):** on the first result whose
299
+ type is `nack`, append that nack to `results` and RETURN immediately.
300
+ Mutations after it are not attempted, not ledgered, not acked — the client
301
+ re-sends them later and INV-3 makes the overlap safe.
302
+ 3. Rate limiting: the socket path's existing mutation limiter counts each
303
+ mutation in the batch (not each batch) against the window; add the same
304
+ counting to the HTTP route (which currently has no limiter — add one).
305
+ 4. Duplicate `mutationId`s *within* one batch: reject the whole batch up front
306
+ with a `validation` outcome (client bug; fail loudly).
307
+
308
+ **Tests** (`api/src/sync/mutationHandler.test.ts`, `syncRoutes.test.ts`,
309
+ `syncSocket.test.ts`):
310
+ - 10 mutations, #4 conflicts → results length 4, mutations 5–10 unledgered;
311
+ resending 4–10 as a new batch: #4 returns its recorded conflict from the
312
+ ledger (idempotent), 5–10 apply.
313
+ - Whole-batch duplicate resend → all results served from ledger, docs written
314
+ once.
315
+ - Order proof: create → update → delete for one entity in one batch works;
316
+ reversed order fails at #1 without touching #2/#3.
317
+ - Oversized batch and intra-batch duplicate ids rejected before processing.
318
+
319
+ ## B3. Client: batched drain on top of A3's global FIFO
320
+
321
+ In `replayCoordinator.ts`:
322
+ 1. Build each send as the next contiguous chunk (≤ 50, configurable
323
+ `batchSize`) of the global queue — preserving `enqueueOrder`, possibly mixing
324
+ collections (the request items each carry their collection).
325
+ 2. `baseVersion` refresh (A2) applies per item at chunk-build time. Within one
326
+ chunk, if it contains multiple mutations for the same entity, compute later
327
+ items' `baseVersion` optimistically as "previous item's expected post-ack
328
+ seq" is NOT possible (seqs are server-assigned) — so instead: **a chunk may
329
+ contain at most one mutation per entity**; the chunk builder cuts the chunk
330
+ short when it would include a second mutation for an entity already in it.
331
+ The next chunk (after acks land and A2 refreshes bases) carries the rest.
332
+ This keeps per-entity chaining correct without guessing seqs.
333
+ 3. Response handling walks `results` in order, applying the existing
334
+ single-mutation handlers (`handleAck`, `handleConflict`,
335
+ `handleTerminalFailure`, unauthorized-pause, error-backoff). Every request
336
+ mutation with no result (server halted) → back to `queued`, untouched
337
+ budgets. Then apply the stop-the-line rules of B4.
338
+ 4. Transport fallback & capability detection: on HTTP 404 / socket
339
+ `sync:error {code: "unknown-event"}` for the batch call, set a per-connection
340
+ `batchUnsupported` flag and fall back to the existing single-mutation sends
341
+ (still in global FIFO order). Re-probe after reconnect.
342
+ 5. Send path: batches prefer the socket when connected, HTTP channel otherwise
343
+ (mirror the existing single-send selection in `client.ts`).
344
+
345
+ ## B4. Stop-the-line rules (client-side outcome policy)
346
+
347
+ After processing a batch response, decide whether the drain continues:
348
+
349
+ | First non-ack outcome | Policy |
350
+ |---|---|
351
+ | `error` (transient) | **Halt the whole drain.** Jittered backoff (A3), timed wake-up retries from that mutation onward. Nothing after it is sent this pass. |
352
+ | transport failure / timeout | **Halt the whole drain.** Unlimited-budget backoff (A3). The whole batch is safe to resend (INV-3). |
353
+ | `unauthorized` | **Halt everything**, enter auth pause (A4). No data touched. |
354
+ | `conflict` | Mark conflicted + write conflict row (existing). The ENTITY is now blocked: subsequent drains **skip every queued mutation for an entity that has an unresolved conflict or a queued predecessor that was skipped** (they stay `queued`, budgets untouched). Other entities continue — with one escape hatch: config `haltQueueOnConflict?: boolean` (default `false`) halts the entire drain instead, for apps with cross-entity ordering dependencies. Document both modes in `syncdb/README.md`. |
355
+ | `validation` | Terminal for that mutation (existing `markFailed` + release). Then apply the same per-entity skip rule to its queued successors — a successor building on a rejected write is very likely also invalid; skip-and-surface beats blind apply. Successors become eligible again only via explicit `client.retryFailed({entityId})` (new API) or when the user resolves/dismisses. Continue the drain for other entities. |
356
+
357
+ Rationale for the per-entity (not global) default on conflict/validation: a global
358
+ halt on a *user-resolvable* outcome can freeze all sync for days behind one
359
+ conflict sheet. Transient/auth outcomes halt globally because retrying will fix
360
+ them without user action. **OPUS should review this table before merge.**
361
+
362
+ **Tests** (`replayCoordinator.test.ts` + `integration.test.ts`):
363
+ - 120 queued across 2 collections → exactly 3 batch round-trips (lock in the
364
+ contract), global order preserved on the server (assert via seq order).
365
+ - Conflict on entity X mid-batch → X's later mutations stay queued and are
366
+ absent from subsequent batches; entity Y's mutations proceed; after the user
367
+ resolves X (keepMine requeue), X's successors drain in original relative
368
+ order.
369
+ - Same scenario with `haltQueueOnConflict: true` → nothing after the conflict is
370
+ sent at all.
371
+ - Batch response shorter than request → tail re-queued untouched; next drain
372
+ resends from the halt point; server ledger dedupes the boundary mutation.
373
+ - Mid-batch socket disconnect → entire batch re-queued, resent on reconnect,
374
+ applied exactly once (integration).
375
+ - Validation failure → successors for that entity skipped and surfaced via
376
+ status; `retryFailed` re-enables them.
377
+
378
+ ## B5. SyncStatus + UI surfacing
379
+
380
+ Extend `SyncStatus` (client + `useSyncStatus` + `ui/src/SyncStatusBanner.tsx`):
381
+ `queuedCount`, `failedCount`, `blockedEntities: number`, `paused?: "auth"`,
382
+ `draining: boolean`, and drain progress (`sentThisDrain/totalThisDrain`).
383
+ Banner states: syncing (with progress when > 20 queued), paused-for-auth (tap →
384
+ `onAuthRequired`), N failed (tap → detail). Keep testIDs
385
+ (`sync-status-*`) per convention; add `sync-paused-auth-indicator`.
386
+
387
+ ---
388
+
389
+ # Phase C — Server protocol correctness
390
+
391
+ ## C1. Seq commit fence — **OPUS (design + implementation)**
392
+
393
+ **Problem.** `_syncSeq` is claimed from a separate counter in `pre("save")`
394
+ (`api/src/sync/syncSeqPlugin.ts` ~81) before the document write commits, with no
395
+ transaction and no ordering fence. Writer A claims seq 5, stalls; writer B claims
396
+ 6 and commits first; any client whose cursor reaches 6 (via delta or snapshot)
397
+ uses `$gt: 6` forever and permanently misses A's doc (`api/src/sync/routes.ts`
398
+ ~159). Also: the pre-query-write lookup can stamp a seq from stream X's counter
399
+ onto a doc in stream Y (non-`_id` filters), producing duplicate seqs in a stream.
400
+
401
+ **Direction to evaluate (Opus decides):**
402
+ - Option 1 — transactional assignment: counter `$inc` + document write in one
403
+ Mongo transaction (replica set is already required for change streams). Cost:
404
+ transactions on every synced write; retry logic for `TransientTransactionError`;
405
+ Mongoose session plumbed through the executors.
406
+ - Option 2 — stable frontier: keep optimistic claims, but expose per stream a
407
+ `stableSeq` = highest seq below which no claim is uncommitted (track in-flight
408
+ claims in the counter doc: claim registers, commit confirms; frontier =
409
+ min(pending)−1 or head). Snapshot/delta consumers only advance cursors to the
410
+ frontier. Cost: complexity + a liveness dependency on claim cleanup for
411
+ crashed writers (needs a lease/TTL).
412
+ - Option 3 — post-commit sequencing: writes commit without a seq; a single
413
+ ordered assigner (change-stream driven) stamps seqs afterward. Cost: an extra
414
+ write per mutation and an ordering singleton.
415
+
416
+ Whatever is chosen must also fix: the multi-doc lookup mis-stamp (m9), no-op
417
+ `save()` burning seqs and emitting deltas (m10), and must define the contract in
418
+ `docs/implementationPlans/syncdb-local-first.md`.
419
+
420
+ **Tests:** concurrent writers with forced commit inversion (delay the lower seq's
421
+ commit via a `pre("save")` test hook) → a client bootstrapping/catching up at
422
+ every intermediate cursor sees BOTH docs eventually; property-style test looping
423
+ random interleavings.
424
+
425
+ ## C2. Per-stream snapshot cursors — **OPUS design, Sonnet execute**
426
+
427
+ **Problem.** `GET /sync/snapshot` merges all of a user's streams into one query
428
+ and returns a single max-seq cursor (`routes.ts` ~142-180), but each stream has
429
+ an independent counter. A user in org1 (seq 1000) and org2 (seq 5) can never
430
+ catch up org2 past cursor 1000; joining a tenant post-bootstrap never backfills.
431
+
432
+ **Fix shape (contract for Opus to ratify):** snapshot request/response become
433
+ per-stream: client sends `cursors: Record<stream, number>` (or requests one
434
+ stream at a time — simpler and preferred); server returns entities + per-stream
435
+ next cursor + `hasMore`. Server must expose "which streams does this user have"
436
+ (`GET /sync/streams` or embedded in the snapshot response) so the client can
437
+ detect NEW streams (tenant joined → cursor 0 backfill) and REMOVED streams
438
+ (tenant left → local purge of that stream's entities — respecting INV-2: purge
439
+ only on confirmed membership change, never on 401). Client keeps
440
+ `_cursors` rows per real stream (the plumbing already exists for deltas;
441
+ `snapshot:{collection}` pseudo-streams get replaced). Migration: on first run
442
+ with the new client, discard legacy snapshot cursors and re-bootstrap
443
+ incrementally (cursor 0 per stream — cheap because pages are idempotent upserts).
444
+
445
+ **Tests:** two-tenant skew scenario end-to-end; tenant join backfills; tenant
446
+ leave purges locally; legacy-cursor migration path.
447
+
448
+ ## C3. Legacy-doc pagination infinite loop (Sonnet)
449
+
450
+ **Problem.** With > `limit` docs lacking `_syncSeq`, the first snapshot page is
451
+ all `seq: 0`, cursor never advances, `hasMore` stays true → client loops forever
452
+ (`routes.ts` ~157-179; the client-side guard in `bootstrap.ts` stops the loop but
453
+ bootstrap then never completes).
454
+
455
+ **Fix.** Page the seq-0 stratum by `_id`: when `cursor === 0`, sort
456
+ `{_syncSeq: 1, _id: 1}` and return an opaque `legacyCursor` (last `_id`) the
457
+ client echoes back until the unsynced stratum is exhausted, then switch to seq
458
+ paging. Alternative (simpler, evaluate): a one-time migration script/plugin
459
+ startup pass that back-stamps `_syncSeq` on legacy docs per stream — if chosen,
460
+ the route should refuse (500 with clear message) when unstamped docs exceed the
461
+ page limit, pointing at the migration. Either way the failure mode must be loud,
462
+ not an infinite loop.
463
+
464
+ **Tests:** 1,201 legacy docs, limit 500 → bootstrap terminates with all docs;
465
+ mixed legacy+stamped pagination; loop-guard regression.
466
+
467
+ ## C4. Scope-move tombstones from the write path — **OPUS recommended**
468
+
469
+ **Problem.** The old-stream tombstone is derived from `_syncPrevStream` read via
470
+ change-stream `updateLookup` post-image (`api/src/realtime/changeStreamWatcher.ts`
471
+ ~576-608): a second write racing the first event's processing resets the field,
472
+ and the moved-away-from user never gets the tombstone (keeps stale data +
473
+ post-move edits until reconcile).
474
+
475
+ **Fix direction.** Emit the removal signal at write time, not stream time: when
476
+ `syncSeqPlugin` detects a stream change, write an explicit tombstone marker
477
+ (e.g. a `SyncScopeMove` document: {stream, entityId, seq claimed from the OLD
478
+ stream's counter}) in the same operation scope as the move; the watcher emits
479
+ old-stream tombstones from those markers (and they appear in snapshots for the
480
+ old stream). This makes moves durable and replayable instead of racy. TTL the
481
+ markers with the same retention as tombstones (E7/C7).
482
+
483
+ **Tests:** move + immediate second write with artificially delayed change-event
484
+ processing → old-scope client still receives the tombstone; snapshot catch-up
485
+ from a pre-move cursor sees it too.
486
+
487
+ ## C5. Mutation-ledger crash resilience (Sonnet)
488
+
489
+ **Problem A (M4).** A server crash between inserting the `pending` ledger row and
490
+ finalizing it wedges that `mutationId` for the 30-day TTL — every retry polls 1s
491
+ then nacks "still in flight" (`api/src/sync/mutationHandler.ts` ~75-104,
492
+ ~223-235).
493
+ **Problem B (M5).** A post-hook throw AFTER the document write finalizes the
494
+ ledger as `failed`/`validation` even though the write (and its delta) happened —
495
+ client rolls back real data, ledger lies forever.
496
+
497
+ **Fix.**
498
+ - A: add a lease — `pending` rows carry `claimedAt`; the duplicate-claim path may
499
+ take over rows older than 60s via
500
+ `findOneAndUpdate({_id, status: "pending", claimedAt: {$lt: cutoff}},
501
+ {$set: {claimedAt: now}})` and re-run the mutation (idempotent by INV-3 —
502
+ the executor path must tolerate the write having already landed: treat
503
+ create-E11000-on-same-id + identical `mutationId` provenance as success by
504
+ reading the doc back; for update/delete re-check seq before re-applying).
505
+ - B: reorder the pipeline — run the document write, then finalize the ledger
506
+ `applied` (with the ack payload) BEFORE running post-hooks; post-hook errors
507
+ are logged (`logger.error`) and reported to the response as ack-with-warning,
508
+ not converted into a nack. If the `applied` finalize itself fails, crash the
509
+ request loudly (500 → client transport-retry → duplicate path returns the
510
+ doc-derived outcome via the lease takeover above).
511
+
512
+ **Tests:** kill between claim and finalize (simulate by stubbing) → retry after
513
+ lease expiry succeeds; post-hook throw → doc changed AND ack returned AND ledger
514
+ `applied`; replay returns the recorded ack.
515
+
516
+ ## C6. Sync write-scope enforcement + snapshot read parity (Sonnet)
517
+
518
+ - **Create scope check (M6):** in `applySyncMutation` for `create` (and for
519
+ `update` when the incoming data changes the scope field), resolve the entry's
520
+ scope; for `owner` strategy force/verify the owner field equals the
521
+ authenticated user id; for `tenant` strategy verify the tenant field value is
522
+ in `getUserScopes(user, entry)` — nack `unauthorized` otherwise. This is the
523
+ sync boundary backstop regardless of consumer `preCreate` quality.
524
+ - **Snapshot per-doc read checks (M2):** after fetching a page in
525
+ `GET /sync/snapshot`, run the same per-document `read` permission the delta
526
+ path uses (`checkPermissions("read", …, doc)`), dropping denied docs but STILL
527
+ advancing the cursor past them (parity with delta behavior). Also honor
528
+ `options.queryFilter`.
529
+ - **`$or`/`deleted` clobber (M1):** build the snapshot query as
530
+ `{$and: [scopeFilter, {deleted: {$in: [true,false]}}, seqFilter]}` — never
531
+ spread-merge filters.
532
+ - **Upsert bypass (M8):** `syncSeqPlugin` pre-query-write hook must throw on
533
+ `upsert: true` for synced models (same loud contract as `updateMany`).
534
+ - **Socket user parity (D2 dependency):** these checks must run against the full
535
+ user (see D2), not the synthetic socket user.
536
+
537
+ **Tests:** create with foreign ownerId/tenant nacked over both transports;
538
+ snapshot omits per-doc-denied docs that deltas also omit; `$or` snapshotFilter
539
+ composes correctly (page respects both filters); upsert throws.
540
+
541
+ ## C7. Server tombstone/ledger retention (Sonnet)
542
+
543
+ Tombstones are served forever with full `data` (privacy + payload growth); the
544
+ watcher also processes bookkeeping collections' own change events. Fix: (a) strip
545
+ `data` from tombstones at write time or serialization time — a tombstone needs
546
+ only id/seq/deleted; (b) add `synccounters`, `syncmutations` (and C4's marker
547
+ collection) to `DEFAULT_IGNORED_COLLECTIONS` in
548
+ `api/src/realtime/changeStreamWatcher.ts`; (c) document a retention policy:
549
+ tombstones older than N days (default 90, configurable per model in the sync
550
+ options) may be hard-deleted by a provided maintenance script
551
+ (`api/src/sync/scripts/compactTombstones.ts`), paired with the client-side rule
552
+ that a cursor older than the retention window must trigger full re-bootstrap
553
+ (client compares snapshot response's `oldestRetainedSeq` — add to response — with
554
+ its cursor).
555
+
556
+ **Tests:** tombstone payloads carry no data; ignored collections generate no
557
+ watcher work (spy); stale-cursor client detects retention gap and re-bootstraps.
558
+
559
+ ## C8. Server minor batch (Sonnet, one PR)
560
+
561
+ - `baseVersion` omitted on update → `validation` nack (never accidental
562
+ conflict/accidental success) — `mutationHandler.ts` ~250.
563
+ - Delete of already-deleted doc → idempotent ack (return current seq), not 404
564
+ `validation` — `executors.ts` ~488-492.
565
+ - Cap `sync:subscribe` array length (e.g. 100) before iterating; length check
566
+ first — `socketHandlers.ts` ~151.
567
+ - Reject duplicate `collectionTag` at registration; make snapshot-index
568
+ `createIndex` failure a startup error, not a warn — `registry.ts`.
569
+ - `serialize.ts` responseHandler method fidelity: pass `"read"` for
570
+ single-entity sync serialization instead of hardcoded `"list"`/`"update"`.
571
+ - Await/serialize change-handler dispatch per document id (keep cross-doc
572
+ concurrency) so per-entity delta order is guaranteed; document the per-entity
573
+ LWW-by-seq contract in `api/src/sync/types.ts`.
574
+
575
+ Each with a focused test.
576
+
577
+ ---
578
+
579
+ # Phase D — Auth & security (Sonnet)
580
+
581
+ ## D1. Socket session re-validation
582
+
583
+ **Problem.** Sockets authenticate once at handshake (`api/src/realtime/
584
+ socketAuth.ts` ~141-177); expiry/revocation/disable never disconnects — deltas
585
+ (PHI) keep flowing indefinitely.
586
+
587
+ **Fix.** Add to the realtime app a periodic re-validation sweep (default every
588
+ 60s, configurable): for each connected socket, re-run the cheap parts of its
589
+ validator (JWT: verify expiry locally; Better Auth: batch session lookups) and
590
+ re-load the user's `disabled` flag; on failure emit `sync:auth-expired` then
591
+ `socket.disconnect(true)`. The syncdb client must map `sync:auth-expired` +
592
+ subsequent reconnect auth failure into the A4 auth-pause path (NO wipe — INV-2).
593
+ Also: pass `issuer` to the legacy JWT socket validator for parity with HTTP
594
+ (`socketAuth.ts` vs `api/src/auth.ts` ~359-361), and replace the
595
+ dot-count JWT discrimination in `auth.ts` ~362-370 with header-decode detection.
596
+
597
+ **Tests:** new `api/src/realtime/socketAuth.test.ts` — validator matrix (valid/
598
+ expired/wrong-secret/wrong-issuer JWT; valid/invalid Better Auth bearer; chain
599
+ fall-through; missing token). Sweep disconnects an expired/disabled session;
600
+ client lands in auth-pause with outbox intact.
601
+
602
+ ## D2. Full user for socket-side authorization
603
+
604
+ **Problem.** Socket paths authorize with a synthetic `{_id, admin, id}` user
605
+ (`api/src/realtime/socketUser.ts`), so `getUserScopes` sees no
606
+ `organizationIds` — tenant sync over sockets silently returns nothing (fails
607
+ closed here, but consumers can fail open).
608
+
609
+ **Fix.** Load the full user document once at handshake (by decoded id), cache on
610
+ `socket.data.user`, refresh during D1's sweep; pass THAT to `getUserScopes`,
611
+ `checkPermissions`, and delta filters. Remove the synthetic-shape pathway for
612
+ sync. Add a test where `getUserScopes` actually reads `user.organizationIds`
613
+ (every existing test stubs it — that masked this bug).
614
+
615
+ ## D3. Tenant create-escape + reference hardening
616
+
617
+ Fix `example-backend/src/api/projects.ts` `preCreate`: spread body FIRST, then
618
+ force/validate `organizationId ∈ user.organizationIds` (throw `APIError` 403
619
+ otherwise). C6 adds the framework backstop; the reference implementation must
620
+ still model the right pattern. Test both transports.
621
+
622
+ ## D4. Membership revocation vs socket rooms
623
+
624
+ On D1's sweep (or a user-updated hook), re-resolve the user's streams and
625
+ `socket.leave()` rooms for streams no longer held. Test: revoke org membership →
626
+ no further deltas for that org on the live socket.
627
+
628
+ ## D5. Admin/password hygiene
629
+
630
+ `setPasswordForUser` route (`example-backend/src/api/adminUsers.ts` +
631
+ `api/src/auth.ts` helper): add `logger.info` audit line (admin id, target id,
632
+ timestamp — never the password), an upper length bound (e.g. 256), and a test
633
+ for `requireAdminMiddleware` 403 paths. Small task, do alongside D1.
634
+
635
+ ---
636
+
637
+ # Phase E — Client storage & React (Sonnet)
638
+
639
+ ## E1. Lifecycle serialization (`start`/`stop`/auth-change mutex)
640
+
641
+ **Problem.** `stop()` re-reads module-level `persister` after awaiting the
642
+ debounced `save()`; interleaved `start()` for a new user gets its persister
643
+ destroyed → client half-started, every `mutate()` throws (`client.ts` ~406-422 +
644
+ `example-frontend/app/_layout.tsx` ~133-150). `start()` twice also double-
645
+ registers listeners and leaks the reconcile interval.
646
+
647
+ **Fix.** Introduce a generation counter + a single promise-chain mutex
648
+ (`lifecycle = lifecycle.then(op)`) through which `start`, `stop`,
649
+ `handleAuthChange`, and `runUserCheck` all pass. Capture `persister` into a
650
+ local before any await; after each await, abort if the generation changed.
651
+ `start()` when already started → no-op (or stop-then-start, but pick one and
652
+ test it). `stop()` disposes the A3 coordinator timers.
653
+
654
+ **Tests:** rapid stop/start user-switch (would have caught the bug — assert new
655
+ user's persistence works and `mutate()` succeeds); double-start listener counts;
656
+ Playwright regression: remove the serial-file workaround comment in
657
+ `example-frontend/playwright.config.ts` (~16-19) once concurrent syncdb clients
658
+ are stable, and let two syncdb specs share a worker.
659
+
660
+ ## E2. Wire schema versioning
661
+
662
+ `getSchemaVersion()` has zero callers. On `start()` after autoload: if persisted
663
+ `schemaVersion` exists and ≠ `SYNC_SCHEMA_VERSION` → `wipeLocalData` + set
664
+ current version + full re-bootstrap (this wipe is sanctioned: it is a schema
665
+ migration, not an auth event). Always stamp the version on fresh stores.
666
+ Test: persist v1 data, load under v2 → wiped + re-bootstrapped; equal versions
667
+ untouched.
668
+
669
+ ## E3. Persistence failure surfaces
670
+
671
+ - Move the `idbGet` inside the try in `encryptedIndexedDbPersister.getPersisted`
672
+ and distinguish "no data" (return undefined → fresh store OK) from "read
673
+ error" (throw → persister must NOT autosave an empty store over the blob:
674
+ propagate a load failure state instead) — `syncdb/src/persisters/
675
+ encryptedIndexedDbPersister.ts` ~79-93.
676
+ - Wire `onDecryptFailure` end-to-end: add to `SyncDbConfig`; default behavior =
677
+ wipe + re-bootstrap + `console.warn` (documented). This also revives
678
+ wipe-on-user-change on web.
679
+ - Web factory: check `globalThis.indexedDB`; when absent fall back to the memory
680
+ persister and emit a one-time `console.warn` + a `persistence: "memory" |
681
+ "durable" | "error"` field on `SyncStatus`.
682
+ - Track and clear the debounce timer in `destroy()`; wipe `keyCacheDbNames` in
683
+ `runUserCheck` (`client.ts` ~304) and `SyncDevPanel`.
684
+
685
+ **Tests:** rejecting `idbGet` at load leaves the stored blob intact; quota-
686
+ exceeded on save surfaces `persistence: "error"`; decrypt failure invokes the
687
+ config hook; destroy-with-pending-save writes nothing after destroy.
688
+
689
+ ## E4. Batched application + render hygiene
690
+
691
+ - Wrap each snapshot page (`bootstrap.ts` ~92-101) and each socket delta burst
692
+ (`deltaApplier` callers) in `store.raw.transaction(() => …)` — one listener
693
+ notification + one autosave per page instead of per row.
694
+ - `useQuery` (`syncdb/src/react/hooks.ts` ~129): skip rows whose `data` is null
695
+ (corrupt-row guard — currently crashes list consumers).
696
+ - Move `optionsRef.current = options` out of render (layout effect or snapshot
697
+ path).
698
+
699
+ **Tests:** listener fire-count == 1 per applied page (would have caught the
700
+ O(N²)); corrupt row present → `useQuery` returns the healthy rows, no throw;
701
+ listener stats return to baseline after unmount (add for all four hooks).
702
+
703
+ ## E5. Client-side compaction
704
+
705
+ Prune local tombstones after they age past the server retention window (C7):
706
+ on each successful reconcile, delete `deleted: true` rows older than the
707
+ window (age from a `deletedAt` cell to add at tombstone-apply time), and drop
708
+ their MergeableStore metadata where TinyBase allows. Test with fake clock.
709
+
710
+ ## E6. UI minor batch
711
+
712
+ `ConflictSheet` testIDs suffixed with `mutationId` (duplicate-testID strict-mode
713
+ fix); banner conflict badge gets `accessibilityRole="button"`;
714
+ `debugLog.clear()` resets stats coherently; align its doc comments. One PR.
715
+
716
+ ---
717
+
718
+ # Phase F — Test & load infrastructure (Sonnet)
719
+
720
+ Unit/functional tests live inside Phases A–E. This phase is cross-cutting infra.
721
+
722
+ ## F1. Integration additions (`api/src/sync/integration.test.ts`)
723
+
724
+ - Socket drop after k of n batch-acked → remaining n−k apply exactly once.
725
+ - Token expiry mid-session → auth-pause → re-auth → cursor-correct resume,
726
+ outbox intact (INV-2 end-to-end).
727
+ - Two real syncdb clients (same user, two "devices") editing one doc —
728
+ delta-vs-pending interleavings.
729
+ - Ack lost client-side (drop the response, not the request) → resend consumes
730
+ the recorded ledger ack.
731
+
732
+ ## F2. Server-side load harness (no browser)
733
+
734
+ New `api/src/sync/loadHarness.ts` (bun script, not in default test run; wire a
735
+ `bun run api:load` script): reuse the integration rig — N socket.io clients on N
736
+ owner streams (start N=50), seed 5k docs via `example-backend/src/api/
737
+ loadtest.ts` generate, then drive batched `POST /sync/mutate/batch` at a target
738
+ rate with mixed ops + deliberate duplicate mutationIds + deliberate mid-batch
739
+ conflicts. Report: mutate p50/p95/p99, change-stream→delta fan-out lag per
740
+ socket, 5k-doc bootstrap wall time, final-state convergence check per client
741
+ (exact set equality vs Mongo). Nightly CI job with generous thresholds
742
+ (p95 mutate < 250ms local-Mongo, delta lag < 2s @ 50 sockets) — fail loudly,
743
+ tune later. Also add plain route tests for `loadtest.ts` (admin guard, clamps).
744
+
745
+ ## F3. Chaos/spotty-connection e2e
746
+
747
+ Extend `example-frontend/e2e/helpers/syncdbSuite.ts`'s `routeWebSocket` proxy
748
+ into a chaos proxy (do NOT use CDP `emulateNetworkConditions` — it does not
749
+ throttle WebSocket frames):
750
+ - per-frame latency injection: `setTimeout(forward, rand(0, latencyMs))`;
751
+ - `dropSocket()` closing live sockets at random intervals;
752
+ - HTTP jitter via delayed `route.continue()`;
753
+ - a flap loop over existing `goSyncOffline`/`goSyncOnline` with random 1–8s
754
+ dwells.
755
+
756
+ New spec `syncdb-chaos.spec.ts` (own CI shard): queue ~30 UI mutations while
757
+ flapping (this covers reconnect-mid-drain), run server-side churn concurrently
758
+ (`loadtest churn`), stop chaos, then assert: queued badge clears, local set ==
759
+ `listTodosAs(user)` via REST, **zero duplicates** (the key assertion — flapping
760
+ mid-ack forges duplicate deliveries), and total mutate-request count ≤
761
+ ceil(mutations/batchSize) + retries budget (locks in batching). All waits by
762
+ testID/`expect.poll`; no `waitForTimeout`.
763
+
764
+ ## F4. Client-side load e2e
765
+
766
+ `syncdb-loadlab.spec.ts`, `@load` tag, nightly shard (not PR-blocking): admin
767
+ suite user (add one to `fixtures/testUsers.ts` — the loadtest routes are
768
+ admin-guarded), `loadtest generate {count: 2000}`, assert a new `todos-count`
769
+ testID converges (do NOT count DOM rows; virtualize the list first if it isn't),
770
+ then 10 churn rounds with the page open, final convergence + time-to-converge
771
+ recorded via Playwright annotations. This will regress instantly if E4's
772
+ transaction batching breaks.
773
+
774
+ ## F5. Coverage restorations
775
+
776
+ The PR deletes `offline.spec.ts` (728 lines) covering the legacy RTK offline
777
+ middleware, which non-sync screens still use. Either restore a slim 2–3 test
778
+ version of it or document the deprecation of the RTK offline path explicitly in
779
+ `rtk/README.md`. Decide with the repo owner; default: restore slim version.
780
+
781
+ ---
782
+
783
+ ## Acceptance checklist (definition of done for the whole plan)
784
+
785
+ - [ ] 500 mutations queued offline drain in ≤ 12 round-trips after reconnect,
786
+ in exact enqueue order, surviving a mid-drain disconnect with zero
787
+ duplicates and zero losses (F2/F3 assert this).
788
+ - [ ] Two offline edits to one doc never self-conflict (A2).
789
+ - [ ] App killed mid-send recovers on next start; no permanently frozen
790
+ entities (A1).
791
+ - [ ] 401 at any point (HTTP, socket, mid-drain, mid-bootstrap) pauses sync
792
+ visibly, preserves all local data, and fully resumes on same-user re-auth
793
+ (A4/D1 — INV-2).
794
+ - [ ] A conflict blocks only its entity (or the whole queue when
795
+ `haltQueueOnConflict`), never silently skipped past (B4 — INV-1).
796
+ - [ ] No client can permanently miss a committed document via cursor catch-up
797
+ (C1/C2/C3).
798
+ - [ ] Sockets stop delivering data within 60s of revocation/expiry/disable (D1).
799
+ - [ ] `bun run lint`, `bun run api:test`, `bun run ui:test`, syncdb package
800
+ tests, and all four+2 e2e shards green.