@terreno/mcp 0.31.0 → 56.0.0-beta.0
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.
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +15 -16
- package/dist/bootstrap.js.map +1 -1
- package/dist/docs/templates/bootstrap/backend/types/models/userTypes.template.md +4 -5
- package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +1 -1
- package/dist/docs/templates/tools/model.template.md +2 -4
- package/dist/docs/versioned/next/README.md +1 -0
- package/dist/docs/versioned/next/explanation/README.md +1 -7
- package/dist/docs/versioned/next/how-to/README.md +2 -8
- package/dist/docs/versioned/next/how-to/deploy-to-gcp.md +228 -20
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +445 -0
- package/dist/docs/versioned/next/implementationPlans/PLAN_INDEX.md +4 -57
- package/dist/docs/versioned/next/implementationPlans/README.md +30 -56
- package/dist/docs/versioned/next/implementationPlans/agentic-sdlc-plugin.md +1 -2
- package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/build-terreno-app-validation.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/deploy-to-gcp.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/deploy-to-vercel.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/deployment-foundation.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/docs-reference-coverage.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/docs-tutorials-ai-first.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/examples-demo-coverage.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/oss-governance-baseline.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/positioning-django-rails-universal.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/public-roadmap-github.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/rtk-to-syncdb-migration-docs.md +52 -47
- package/dist/docs/versioned/next/implementationPlans/syncdb-api-inventory.md +303 -0
- package/dist/docs/versioned/next/implementationPlans/syncdb-codegen.md +252 -0
- package/dist/docs/versioned/next/implementationPlans/syncdb-local-first.md +468 -420
- package/dist/docs/versioned/next/implementationPlans/syncdb-phase-c-design.md +425 -0
- package/dist/docs/versioned/next/implementationPlans/terreno-syncdb-2.md +800 -0
- package/dist/docs/versioned/next/implementationPlans/upgrade-guides-and-skill.md +0 -1
- package/dist/docs/versioned/next/implementationPlans/web-ssr-and-admin-spa.md +0 -1
- package/dist/docs/versioned/next/reference/README.md +1 -4
- package/dist/docs/versioned/next/reference/environment-variables.md +382 -220
- package/dist/docs/versioned/next/reference/syncdb.md +456 -0
- package/dist/docs/versioned/next/superpowers/plans/2026-07-28-admin-mobile-navigation.md +97 -0
- package/dist/docs/versioned/next/superpowers/specs/2026-07-28-admin-mobile-navigation-design.md +36 -0
- package/dist/docs/versioned/next/tasks/agentic-sdlc-plugin.md +5 -5
- package/dist/docs/versioned/next/tasks/deploy-to-gcp.md +11 -11
- package/dist/docs/versioned/next/tasks/deployment-foundation.md +12 -12
- package/dist/docs/versioned/next/tasks/docs-reference-coverage.md +4 -4
- package/dist/docs/versioned/next/tasks/oss-governance-baseline.md +18 -18
- package/dist/docs/versioned/next/tasks/positioning-django-rails-universal.md +4 -4
- package/dist/docs/versioned/next/tasks/public-roadmap-github.md +15 -15
- package/dist/docs/versioned/next/tasks/rtk-to-syncdb-migration-docs.md +10 -10
- package/dist/docs/versioned/next/tasks/syncdb-codegen.md +65 -0
- package/dist/docs/versioned/next/tasks/syncdb-local-first.md +434 -117
- package/package.json +4 -5
- package/src/docs/templates/bootstrap/backend/types/models/userTypes.template.md +4 -5
- package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +1 -1
- package/src/docs/templates/tools/model.template.md +2 -4
- package/src/docs/versioned/next/README.md +1 -0
- package/src/docs/versioned/next/explanation/README.md +1 -7
- package/src/docs/versioned/next/how-to/README.md +2 -8
- package/src/docs/versioned/next/how-to/deploy-to-gcp.md +228 -20
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +445 -0
- package/src/docs/versioned/next/implementationPlans/PLAN_INDEX.md +4 -57
- package/src/docs/versioned/next/implementationPlans/README.md +30 -56
- package/src/docs/versioned/next/implementationPlans/agentic-sdlc-plugin.md +1 -2
- package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +0 -1
- package/src/docs/versioned/next/implementationPlans/build-terreno-app-validation.md +0 -1
- package/src/docs/versioned/next/implementationPlans/deploy-to-gcp.md +0 -1
- package/src/docs/versioned/next/implementationPlans/deploy-to-vercel.md +0 -1
- package/src/docs/versioned/next/implementationPlans/deployment-foundation.md +0 -1
- package/src/docs/versioned/next/implementationPlans/docs-reference-coverage.md +0 -1
- package/src/docs/versioned/next/implementationPlans/docs-tutorials-ai-first.md +0 -1
- package/src/docs/versioned/next/implementationPlans/examples-demo-coverage.md +0 -1
- package/src/docs/versioned/next/implementationPlans/oss-governance-baseline.md +0 -1
- package/src/docs/versioned/next/implementationPlans/positioning-django-rails-universal.md +0 -1
- package/src/docs/versioned/next/implementationPlans/public-roadmap-github.md +0 -1
- package/src/docs/versioned/next/implementationPlans/rtk-to-syncdb-migration-docs.md +52 -47
- package/src/docs/versioned/next/implementationPlans/syncdb-api-inventory.md +303 -0
- package/src/docs/versioned/next/implementationPlans/syncdb-codegen.md +252 -0
- package/src/docs/versioned/next/implementationPlans/syncdb-local-first.md +468 -420
- package/src/docs/versioned/next/implementationPlans/syncdb-phase-c-design.md +425 -0
- package/src/docs/versioned/next/implementationPlans/terreno-syncdb-2.md +800 -0
- package/src/docs/versioned/next/implementationPlans/upgrade-guides-and-skill.md +0 -1
- package/src/docs/versioned/next/implementationPlans/web-ssr-and-admin-spa.md +0 -1
- package/src/docs/versioned/next/reference/README.md +1 -4
- package/src/docs/versioned/next/reference/environment-variables.md +382 -220
- package/src/docs/versioned/next/reference/syncdb.md +456 -0
- package/src/docs/versioned/next/superpowers/plans/2026-07-28-admin-mobile-navigation.md +97 -0
- package/src/docs/versioned/next/superpowers/specs/2026-07-28-admin-mobile-navigation-design.md +36 -0
- package/src/docs/versioned/next/tasks/agentic-sdlc-plugin.md +5 -5
- package/src/docs/versioned/next/tasks/deploy-to-gcp.md +11 -11
- package/src/docs/versioned/next/tasks/deployment-foundation.md +12 -12
- package/src/docs/versioned/next/tasks/docs-reference-coverage.md +4 -4
- package/src/docs/versioned/next/tasks/oss-governance-baseline.md +18 -18
- package/src/docs/versioned/next/tasks/positioning-django-rails-universal.md +4 -4
- package/src/docs/versioned/next/tasks/public-roadmap-github.md +15 -15
- package/src/docs/versioned/next/tasks/rtk-to-syncdb-migration-docs.md +10 -10
- package/src/docs/versioned/next/tasks/syncdb-codegen.md +65 -0
- package/src/docs/versioned/next/tasks/syncdb-local-first.md +434 -117
- package/LICENSE +0 -21
- package/dist/__tests__/bootstrap.test.d.ts +0 -2
- package/dist/__tests__/bootstrap.test.d.ts.map +0 -1
- package/dist/__tests__/bootstrap.test.js +0 -299
- package/dist/__tests__/bootstrap.test.js.map +0 -1
- package/dist/__tests__/databaseQuery.test.d.ts +0 -2
- package/dist/__tests__/databaseQuery.test.d.ts.map +0 -1
- package/dist/__tests__/databaseQuery.test.js +0 -83
- package/dist/__tests__/databaseQuery.test.js.map +0 -1
- package/dist/__tests__/docsRoot.test.d.ts +0 -2
- package/dist/__tests__/docsRoot.test.d.ts.map +0 -1
- package/dist/__tests__/docsRoot.test.js +0 -59
- package/dist/__tests__/docsRoot.test.js.map +0 -1
- package/dist/__tests__/inferPackages.test.d.ts +0 -2
- package/dist/__tests__/inferPackages.test.d.ts.map +0 -1
- package/dist/__tests__/inferPackages.test.js +0 -35
- package/dist/__tests__/inferPackages.test.js.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
- package/dist/__tests__/preload.js +0 -12
- package/dist/__tests__/preload.js.map +0 -1
- package/dist/__tests__/prompts.test.d.ts +0 -2
- package/dist/__tests__/prompts.test.d.ts.map +0 -1
- package/dist/__tests__/prompts.test.js +0 -295
- package/dist/__tests__/prompts.test.js.map +0 -1
- package/dist/__tests__/resources.test.d.ts +0 -2
- package/dist/__tests__/resources.test.d.ts.map +0 -1
- package/dist/__tests__/resources.test.js +0 -352
- package/dist/__tests__/resources.test.js.map +0 -1
- package/dist/__tests__/search.test.d.ts +0 -2
- package/dist/__tests__/search.test.d.ts.map +0 -1
- package/dist/__tests__/search.test.js +0 -163
- package/dist/__tests__/search.test.js.map +0 -1
- package/dist/__tests__/tools.test.d.ts +0 -2
- package/dist/__tests__/tools.test.d.ts.map +0 -1
- package/dist/__tests__/tools.test.js +0 -540
- package/dist/__tests__/tools.test.js.map +0 -1
- package/dist/__tests__/upgradeGuide.test.d.ts +0 -2
- package/dist/__tests__/upgradeGuide.test.d.ts.map +0 -1
- package/dist/__tests__/upgradeGuide.test.js +0 -22
- package/dist/__tests__/upgradeGuide.test.js.map +0 -1
- package/dist/docs/upgrades/0.31.0.md +0 -60
- package/dist/docs/versioned/next/explanation/deployment-architecture-gcp.md +0 -65
- package/dist/docs/versioned/next/explanation/deployment-baseline.md +0 -78
- package/dist/docs/versioned/next/explanation/positioning.md +0 -164
- package/dist/docs/versioned/next/explanation/repository-settings.md +0 -44
- package/dist/docs/versioned/next/explanation/roadmap-process.md +0 -330
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +0 -866
- package/dist/docs/versioned/next/how-to/build-for-web.md +0 -69
- package/dist/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +0 -115
- package/dist/docs/versioned/next/how-to/deploy-web-to-gcs-cdn.md +0 -183
- package/dist/docs/versioned/next/implementationPlans/b2b-platform-program.md +0 -114
- package/dist/docs/versioned/next/implementationPlans/billing-stripe.md +0 -158
- package/dist/docs/versioned/next/implementationPlans/comms-abstraction.md +0 -240
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-expo-push.md +0 -107
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-sendgrid.md +0 -107
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-twilio-sms.md +0 -102
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-twilio-verify.md +0 -93
- package/dist/docs/versioned/next/implementationPlans/infra-mcp-research.md +0 -188
- package/dist/docs/versioned/next/implementationPlans/infra-mcp.md +0 -269
- package/dist/docs/versioned/next/implementationPlans/native-module-baseline.md +0 -105
- package/dist/docs/versioned/next/implementationPlans/orgs-and-teams.md +0 -158
- package/dist/docs/versioned/next/implementationPlans/password-reset-and-email-verification.md +0 -147
- package/dist/docs/versioned/next/reference/admin-spa.md +0 -130
- package/dist/docs/versioned/next/reference/ai.md +0 -519
- package/dist/docs/versioned/next/reference/test.md +0 -108
- package/dist/docs/versioned/next/roadmap.md +0 -9
- package/dist/docs/versioned/next/tasks/README.md +0 -42
- package/dist/docs/versioned/next/tasks/billing-stripe.md +0 -75
- package/dist/docs/versioned/next/tasks/comms-abstraction.md +0 -77
- package/dist/docs/versioned/next/tasks/comms-adapter-expo-push.md +0 -29
- package/dist/docs/versioned/next/tasks/comms-adapter-sendgrid.md +0 -29
- package/dist/docs/versioned/next/tasks/comms-adapter-twilio-sms.md +0 -29
- package/dist/docs/versioned/next/tasks/comms-adapter-twilio-verify.md +0 -19
- package/dist/docs/versioned/next/tasks/infra-mcp.md +0 -43
- package/dist/docs/versioned/next/tasks/native-module-baseline.md +0 -42
- package/dist/docs/versioned/next/tasks/orgs-and-teams.md +0 -70
- package/dist/docs/versioned/next/tasks/password-reset-and-email-verification.md +0 -60
- package/src/docs/upgrades/0.31.0.md +0 -60
- package/src/docs/versioned/next/explanation/deployment-architecture-gcp.md +0 -65
- package/src/docs/versioned/next/explanation/deployment-baseline.md +0 -78
- package/src/docs/versioned/next/explanation/positioning.md +0 -164
- package/src/docs/versioned/next/explanation/repository-settings.md +0 -44
- package/src/docs/versioned/next/explanation/roadmap-process.md +0 -330
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +0 -866
- package/src/docs/versioned/next/how-to/build-for-web.md +0 -69
- package/src/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +0 -115
- package/src/docs/versioned/next/how-to/deploy-web-to-gcs-cdn.md +0 -183
- package/src/docs/versioned/next/implementationPlans/b2b-platform-program.md +0 -114
- package/src/docs/versioned/next/implementationPlans/billing-stripe.md +0 -158
- package/src/docs/versioned/next/implementationPlans/comms-abstraction.md +0 -240
- package/src/docs/versioned/next/implementationPlans/comms-adapter-expo-push.md +0 -107
- package/src/docs/versioned/next/implementationPlans/comms-adapter-sendgrid.md +0 -107
- package/src/docs/versioned/next/implementationPlans/comms-adapter-twilio-sms.md +0 -102
- package/src/docs/versioned/next/implementationPlans/comms-adapter-twilio-verify.md +0 -93
- package/src/docs/versioned/next/implementationPlans/infra-mcp-research.md +0 -188
- package/src/docs/versioned/next/implementationPlans/infra-mcp.md +0 -269
- package/src/docs/versioned/next/implementationPlans/native-module-baseline.md +0 -105
- package/src/docs/versioned/next/implementationPlans/orgs-and-teams.md +0 -158
- package/src/docs/versioned/next/implementationPlans/password-reset-and-email-verification.md +0 -147
- package/src/docs/versioned/next/reference/admin-spa.md +0 -130
- package/src/docs/versioned/next/reference/ai.md +0 -519
- package/src/docs/versioned/next/reference/test.md +0 -108
- package/src/docs/versioned/next/roadmap.md +0 -9
- package/src/docs/versioned/next/tasks/README.md +0 -42
- package/src/docs/versioned/next/tasks/billing-stripe.md +0 -75
- package/src/docs/versioned/next/tasks/comms-abstraction.md +0 -77
- package/src/docs/versioned/next/tasks/comms-adapter-expo-push.md +0 -29
- package/src/docs/versioned/next/tasks/comms-adapter-sendgrid.md +0 -29
- package/src/docs/versioned/next/tasks/comms-adapter-twilio-sms.md +0 -29
- package/src/docs/versioned/next/tasks/comms-adapter-twilio-verify.md +0 -19
- package/src/docs/versioned/next/tasks/infra-mcp.md +0 -43
- package/src/docs/versioned/next/tasks/native-module-baseline.md +0 -42
- package/src/docs/versioned/next/tasks/orgs-and-teams.md +0 -70
- package/src/docs/versioned/next/tasks/password-reset-and-email-verification.md +0 -60
|
@@ -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.
|