@terreno/mcp 57.13.0 → 57.14.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/docs/upgrades/57.14.0.md +43 -0
- package/dist/docs/versioned/next/how-to/README.md +1 -0
- package/dist/docs/versioned/next/how-to/use-syncdb-correctly.md +111 -0
- package/dist/docs/versioned/next/reference/syncdb.md +1 -0
- package/package.json +3 -3
- package/src/docs/upgrades/57.14.0.md +43 -0
- package/src/docs/versioned/next/how-to/README.md +1 -0
- package/src/docs/versioned/next/how-to/use-syncdb-correctly.md +111 -0
- package/src/docs/versioned/next/reference/syncdb.md +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Upgrading to 57.14.0
|
|
2
|
+
|
|
3
|
+
**Action required:** No
|
|
4
|
+
**Affected packages:** @terreno/syncdb
|
|
5
|
+
|
|
6
|
+
## Summary
|
|
7
|
+
|
|
8
|
+
`@terreno/syncdb` pages snapshot streams in parallel (up to 6 at once by default) instead of one at a time, so cold start, reconcile, subscribe catch-up, `forceResync`, and wipe re-bootstrap finish faster when an app has many collections or scopes. Each stream now runs one snapshot pass at a time, which stops a concurrent pass from marking a purged stream bootstrapped over rows it never paged. No code change is required.
|
|
9
|
+
|
|
10
|
+
## Breaking changes
|
|
11
|
+
|
|
12
|
+
None
|
|
13
|
+
|
|
14
|
+
## Deprecations
|
|
15
|
+
|
|
16
|
+
None
|
|
17
|
+
|
|
18
|
+
## New capabilities
|
|
19
|
+
|
|
20
|
+
### `snapshotConcurrency` on `createSyncDb`
|
|
21
|
+
|
|
22
|
+
The client now sends up to `snapshotConcurrency` snapshot requests at once (default `DEFAULT_SNAPSHOT_CONCURRENCY`, 6). If your backend cannot take the extra parallel load at cold start, lower it. `1` restores the old one-stream-at-a-time behavior.
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
// Before
|
|
26
|
+
export const syncDb = createSyncDb({authProvider, baseUrl, collections, name: "my-app"});
|
|
27
|
+
|
|
28
|
+
// After (only if you need to limit parallel snapshot requests)
|
|
29
|
+
export const syncDb = createSyncDb({
|
|
30
|
+
authProvider,
|
|
31
|
+
baseUrl,
|
|
32
|
+
collections,
|
|
33
|
+
name: "my-app",
|
|
34
|
+
snapshotConcurrency: 2,
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A new how-to, `docs/how-to/use-syncdb-correctly.md`, lists library behavior apps have to handle.
|
|
39
|
+
|
|
40
|
+
## Verification
|
|
41
|
+
|
|
42
|
+
- `bun run compile` and `bun run lint` at the app root
|
|
43
|
+
- Log in on a fresh device or after `wipeLocalData`; synced screens load, and the backend logs show several `/sync/snapshot` requests in flight together
|
|
@@ -47,6 +47,7 @@ Problem-oriented, practical steps. Use these when you know what you want to do.
|
|
|
47
47
|
- [Compose charts in a dashboard grid](charts-and-dashboards.md) — `LineChart` / `BarChart` / `AreaChart` / `DonutChart` inside `Card`s in `DashboardGrid` (ops-dashboard scorecards and spans: [dashboard-chart-parity](../implementationPlans/dashboard-chart-parity.md))
|
|
48
48
|
- [Compare rendered chart snapshots](compare-chart-rendered-snapshots.md) — PNG goldens for the chart visual gallery, not JSON snapshots
|
|
49
49
|
- [Migrate from @terreno/rtk to @terreno/syncdb](migrate-rtk-to-syncdb.md) — Move data sync to the local-first layer
|
|
50
|
+
- [Use syncdb correctly](use-syncdb-correctly.md) — Serial snapshot startup, outbox-halting 403s, phantom creates, conflicts, and other library behavior apps must handle
|
|
50
51
|
- [Generate a REST CLI from OpenAPI](generate-a-rest-cli.md) — Scaffold `terreno generate rest-cli` for an app API
|
|
51
52
|
- [In-app notifications](in-app-notifications.md) — Register `NotificationsApp`, syncdb inbox, and UI bell
|
|
52
53
|
- [Add organizations](add-organizations.md) — Opt into Membership-backed tenancy, RBAC, admin, and seeded examples
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# How to Use syncdb Correctly
|
|
2
|
+
|
|
3
|
+
This page lists `@terreno/syncdb` behavior that a working app has to account for. Each entry says what the library does today, what breaks if you ignore it, and what to do instead. It was checked against `syncdb/src` and `api/src/sync` at 57.13.0. Several entries are library gaps rather than intended behavior. Delete an entry when its gap is fixed.
|
|
4
|
+
|
|
5
|
+
Read [Local-first data](../explanation/local-first-data.md) and [Migrate from @terreno/rtk to @terreno/syncdb](migrate-rtk-to-syncdb.md) first.
|
|
6
|
+
|
|
7
|
+
## Startup and snapshots
|
|
8
|
+
|
|
9
|
+
### Cold start costs one round trip per stream
|
|
10
|
+
|
|
11
|
+
A stream is one collection in one scope, so a user has roughly (synced collections) × (tenants they belong to) streams. Each stream costs at least one `GET /sync/snapshot` round trip, even when it is empty. Reconcile, subscribe catch-up, `forceResync` and wipe re-bootstrap page up to `snapshotConcurrency` streams side by side (default 6). Each stream runs one pass at a time, and a pass that purges (force resync, wipe) waits for the stream's in-flight pass to finish first.
|
|
12
|
+
|
|
13
|
+
Measured with a fake HTTP channel at 150 ms latency, 13 collections and 2 workspaces (26 streams): 4.1 s to the first full reconcile when paging one stream at a time, 0.95 s at the default concurrency.
|
|
14
|
+
|
|
15
|
+
| Do | Why |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| Sync only the collections the client reads. | Each collection adds one stream per tenant to cold start. |
|
|
18
|
+
| Gate each screen on its **own** stream's bootstrap (see [No loading flag on synced reads](#no-loading-flag-on-synced-reads)), not on "sync finished". | The stream a screen needs may land after others. |
|
|
19
|
+
| Don't call `forceResync()` on a hot path. | It purges and re-pages every stream. |
|
|
20
|
+
| Raise `snapshotConcurrency` only if the backend can take it. | Each in-flight stream is one concurrent snapshot query on the server. |
|
|
21
|
+
|
|
22
|
+
The startup reconcile and each collection's `sync:subscribed` catch-up both page the same streams. A catch-up queues behind a pass that started before the room join, because that pass may have missed a write. So a cold start still pages each stream twice, one pass after the other.
|
|
23
|
+
|
|
24
|
+
### No loading flag on synced reads
|
|
25
|
+
|
|
26
|
+
`useQuery` returns a plain array. `useListQuery` returns `{data}`. The `isPending` on `useEntity`/`useReadQuery` means an outbox write is pending, not that data is loading. An empty array before bootstrap looks the same as an empty collection, so a screen that renders its empty state from `[]` flashes "nothing here" on every cold start.
|
|
27
|
+
|
|
28
|
+
The per-stream `bootstrapped` mark is the right signal, but `isStreamBootstrapped` is not exported. Read the cell directly:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
const isBootstrapped = (stream: string): boolean =>
|
|
32
|
+
syncDb.store.raw.getCell("_cursors", stream, "bootstrapped") === true;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Don't use the cursor (`useSyncStatus().streams`) for this. A live delta can move the cursor to the head before the snapshot below it has been paged. `useWindowQuery` (query collections only) does expose `isLoading`.
|
|
36
|
+
|
|
37
|
+
A stream key is `<collection>|<scopeType>:<value>`, for example `documents|tenant:<workspaceId>`. A user with several tenants has one stream per tenant. Treat a collection as loaded only once every known stream for it is bootstrapped (`syncDb.store.getKnownStreams()`).
|
|
38
|
+
|
|
39
|
+
## Writes
|
|
40
|
+
|
|
41
|
+
### Mutations return an id, not an outcome
|
|
42
|
+
|
|
43
|
+
`create`, `update` and `remove` from `useMutate` (and from the generated `useCreate*`/`useUpdate*`/`useDelete*` hooks) return `{mutationId, id}` synchronously. There is no promise for the server ack. The client also drops the ack's `warning`. A `warning` means a server post-hook threw after the write committed.
|
|
44
|
+
|
|
45
|
+
If a screen needs to know the outcome (close a modal only once the create landed, toast a refusal), wrap the transport and HTTP channel and record ack and nack outcomes by `mutationId`. Then await that `mutationId`. Don't poll the store.
|
|
46
|
+
|
|
47
|
+
### A 403 from a write halts the whole outbox
|
|
48
|
+
|
|
49
|
+
The server maps 403/405 to an `unauthorized` nack and 400/404/422 to `validation`. They behave very differently:
|
|
50
|
+
|
|
51
|
+
| Nack | Client behavior |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `unauthorized` | The mutation is re-queued and the **whole drain stops** (auth-pause). On re-auth the same `mutationId` replays, reads the same stored nack, and pauses again. It never settles. |
|
|
54
|
+
| `validation` | The mutation is marked `failed` and the drain continues. That entity's later writes stay blocked until `retryFailed({entityId})`. |
|
|
55
|
+
| `conflict` | That entity's later writes stay blocked (or every write is blocked, with `haltQueueOnConflict: true`) until `resolveConflict`. |
|
|
56
|
+
| `error` | Retried up to the retry budget, then handled like `validation`. |
|
|
57
|
+
|
|
58
|
+
So a router hook that refuses a synced write for a business reason must throw **422**, not 403. That includes `preCreate`/`preUpdate` membership checks. Keep 403 for real auth failures.
|
|
59
|
+
|
|
60
|
+
### Don't send a stale tenant field on create
|
|
61
|
+
|
|
62
|
+
For a tenant-scoped collection, `enforceWriteScope` checks a client-sent scope field (for example `workspaceId`) against the user's memberships **before** `preCreate` runs. A value the user does not belong to is a 403, which halts the outbox (see above). If the field is missing or null, the check is skipped and `preCreate` can fill it in.
|
|
63
|
+
|
|
64
|
+
Either omit the tenant field and let `preCreate` set it, or send it only from the currently synced row of the parent tenant, never from a persisted id that may be stale.
|
|
65
|
+
|
|
66
|
+
### The local row never gets server-computed fields from the create
|
|
67
|
+
|
|
68
|
+
After an ack, the client stamps the row with the ack's seq and keeps the optimistic data. The server's create delta has the same seq, so the client skips it. Whatever `preCreate`, schema defaults or hooks set on the server does not reach the local row until that row changes again or is repaired. If the delta arrives before the ack, the row is marked for repair, but repair only runs on the next `reconcile()` (reconnect, seq jump, or every 5 minutes).
|
|
69
|
+
|
|
70
|
+
Do one of these:
|
|
71
|
+
|
|
72
|
+
- Send every field the UI reads in the create payload, matching what the server would compute (defaults, `order`, ownership fields).
|
|
73
|
+
- After the ack, patch the local row yourself.
|
|
74
|
+
|
|
75
|
+
The local row's `data` is exactly what you passed. Include `_id` in the payload if anything reads `row._id`. Server-written rows carry `_id` and `id`.
|
|
76
|
+
|
|
77
|
+
### A refused create leaves a phantom row
|
|
78
|
+
|
|
79
|
+
When a create is nacked as `validation` (or runs out of `error` retries), the repair fetch finds no server row and only clears the repair mark. The optimistic row stays in the store with `stream: ""` and shows up in queries. Only `forceResync` purges such rows.
|
|
80
|
+
|
|
81
|
+
On a refused create, tombstone the local row yourself and call `retryFailed({entityId})` so later writes to that id are released.
|
|
82
|
+
|
|
83
|
+
### Conflicts are never resolved for you
|
|
84
|
+
|
|
85
|
+
A `conflict` nack blocks every later write to that row, and any queued write whose args mention its id, until someone calls `client.resolveConflict({mutationId, strategy: "keepMine" | "useServer"})` (or `useConflicts().resolve`). Local edits keep applying optimistically, so the UI looks fine while nothing reaches the server.
|
|
86
|
+
|
|
87
|
+
If the app has no conflict UI, run a watcher that resolves each conflict as it is recorded. Use `keepMine` for fields the user types, and `useServer` for everything else.
|
|
88
|
+
|
|
89
|
+
## Server side
|
|
90
|
+
|
|
91
|
+
### Write synced models one document at a time, by `_id`
|
|
92
|
+
|
|
93
|
+
On a synced model, `updateMany`, `deleteMany`, `deleteOne`, `findOneAndDelete` and `bulkWrite` throw. Single-document writes by `_id` without upsert are allowed: `save()`, `updateOne`, `findOneAndUpdate`, `findByIdAndUpdate`, `replaceOne`. Delete by setting `deleted: true`, because a hard delete emits no delta.
|
|
94
|
+
|
|
95
|
+
In tests, clean up with `Model.collection.deleteMany(...)`, which bypasses Mongoose.
|
|
96
|
+
|
|
97
|
+
### Deltas don't apply `queryFilter`
|
|
98
|
+
|
|
99
|
+
Snapshots and `/sync/entities` apply the router's `queryFilter`. Live deltas check only `permissions.read` (or RBAC) per recipient. A router that restricts visibility only through `queryFilter` leaks rows over the socket. Every synced router's `read` permission must enforce the same restriction itself.
|
|
100
|
+
|
|
101
|
+
### Membership grants arrive late
|
|
102
|
+
|
|
103
|
+
A user's new tenant is picked up by the socket's session revalidation sweep (`sessionRevalidationIntervalMs`, 60 s by default) or a resubscribe. The client finds the new stream on its next `reconcile()` (`reconcileIntervalMs`, 5 minutes by default, plus reconnects). Until then the new tenant's rows don't appear.
|
|
104
|
+
|
|
105
|
+
When the product grants membership, push a content-free event to the user's sockets and have the client reconnect syncdb. Reconnecting resubscribes and reconciles. Grants written another way (admin, scripts) still wait for the timers.
|
|
106
|
+
|
|
107
|
+
## Auth
|
|
108
|
+
|
|
109
|
+
### Logging out does not wipe local data
|
|
110
|
+
|
|
111
|
+
The client's own `onAuthChange` handling treats a logout as a pause and **never wipes**. A different user logging in does wipe. To clear one user's data on sign-out, set `wipeOnSignOut: true` and call `client.signOut()` explicitly when the app's user clears.
|
|
@@ -185,6 +185,7 @@ be passed to `betterAuthAdapter` directly.
|
|
|
185
185
|
| `wipeOnSignOut` | `boolean` | `false` | `signOut()` also wipes local data |
|
|
186
186
|
| `batchSize` | `number` | `50` (server caps at 100) | Max mutations per batched drain send |
|
|
187
187
|
| `haltQueueOnConflict` | `boolean` | `false` | `true` = conflict halts entire drain; `false` = per-entity blocking |
|
|
188
|
+
| `snapshotConcurrency` | `number` | `6` | Streams snapshot-paged at once by reconcile, subscribe catch-up, `forceResync` and wipe re-bootstrap. Each stream runs one pass at a time. `1` pages one stream at a time |
|
|
188
189
|
| `onDecryptFailure` | `() => void` | wipe + re-bootstrap (with `console.warn`) | Override web decrypt-failure recovery |
|
|
189
190
|
| `tombstoneRetentionMs` | `number` | `7776000000` (90 days); `0` disables | Client tombstone compaction after successful reconcile |
|
|
190
191
|
| `startAuthRetryAttempts` | `number` | `3`; `1` disables | `start()` attempts to resolve `getUserId()` |
|
package/package.json
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
12
12
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
13
13
|
"@sentry/bun": "^10.39.0",
|
|
14
|
-
"@terreno/api": "57.
|
|
15
|
-
"@terreno/blocks": "57.
|
|
14
|
+
"@terreno/api": "57.14.0",
|
|
15
|
+
"@terreno/blocks": "57.14.0",
|
|
16
16
|
"express": "^5.2.1",
|
|
17
17
|
"luxon": "^3.7.2",
|
|
18
18
|
"minisearch": "^7.2.0",
|
|
@@ -105,5 +105,5 @@
|
|
|
105
105
|
"test:coverage": "bun run ../scripts/check-coverage.ts"
|
|
106
106
|
},
|
|
107
107
|
"type": "module",
|
|
108
|
-
"version": "57.
|
|
108
|
+
"version": "57.14.0"
|
|
109
109
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Upgrading to 57.14.0
|
|
2
|
+
|
|
3
|
+
**Action required:** No
|
|
4
|
+
**Affected packages:** @terreno/syncdb
|
|
5
|
+
|
|
6
|
+
## Summary
|
|
7
|
+
|
|
8
|
+
`@terreno/syncdb` pages snapshot streams in parallel (up to 6 at once by default) instead of one at a time, so cold start, reconcile, subscribe catch-up, `forceResync`, and wipe re-bootstrap finish faster when an app has many collections or scopes. Each stream now runs one snapshot pass at a time, which stops a concurrent pass from marking a purged stream bootstrapped over rows it never paged. No code change is required.
|
|
9
|
+
|
|
10
|
+
## Breaking changes
|
|
11
|
+
|
|
12
|
+
None
|
|
13
|
+
|
|
14
|
+
## Deprecations
|
|
15
|
+
|
|
16
|
+
None
|
|
17
|
+
|
|
18
|
+
## New capabilities
|
|
19
|
+
|
|
20
|
+
### `snapshotConcurrency` on `createSyncDb`
|
|
21
|
+
|
|
22
|
+
The client now sends up to `snapshotConcurrency` snapshot requests at once (default `DEFAULT_SNAPSHOT_CONCURRENCY`, 6). If your backend cannot take the extra parallel load at cold start, lower it. `1` restores the old one-stream-at-a-time behavior.
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
// Before
|
|
26
|
+
export const syncDb = createSyncDb({authProvider, baseUrl, collections, name: "my-app"});
|
|
27
|
+
|
|
28
|
+
// After (only if you need to limit parallel snapshot requests)
|
|
29
|
+
export const syncDb = createSyncDb({
|
|
30
|
+
authProvider,
|
|
31
|
+
baseUrl,
|
|
32
|
+
collections,
|
|
33
|
+
name: "my-app",
|
|
34
|
+
snapshotConcurrency: 2,
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A new how-to, `docs/how-to/use-syncdb-correctly.md`, lists library behavior apps have to handle.
|
|
39
|
+
|
|
40
|
+
## Verification
|
|
41
|
+
|
|
42
|
+
- `bun run compile` and `bun run lint` at the app root
|
|
43
|
+
- Log in on a fresh device or after `wipeLocalData`; synced screens load, and the backend logs show several `/sync/snapshot` requests in flight together
|
|
@@ -47,6 +47,7 @@ Problem-oriented, practical steps. Use these when you know what you want to do.
|
|
|
47
47
|
- [Compose charts in a dashboard grid](charts-and-dashboards.md) — `LineChart` / `BarChart` / `AreaChart` / `DonutChart` inside `Card`s in `DashboardGrid` (ops-dashboard scorecards and spans: [dashboard-chart-parity](../implementationPlans/dashboard-chart-parity.md))
|
|
48
48
|
- [Compare rendered chart snapshots](compare-chart-rendered-snapshots.md) — PNG goldens for the chart visual gallery, not JSON snapshots
|
|
49
49
|
- [Migrate from @terreno/rtk to @terreno/syncdb](migrate-rtk-to-syncdb.md) — Move data sync to the local-first layer
|
|
50
|
+
- [Use syncdb correctly](use-syncdb-correctly.md) — Serial snapshot startup, outbox-halting 403s, phantom creates, conflicts, and other library behavior apps must handle
|
|
50
51
|
- [Generate a REST CLI from OpenAPI](generate-a-rest-cli.md) — Scaffold `terreno generate rest-cli` for an app API
|
|
51
52
|
- [In-app notifications](in-app-notifications.md) — Register `NotificationsApp`, syncdb inbox, and UI bell
|
|
52
53
|
- [Add organizations](add-organizations.md) — Opt into Membership-backed tenancy, RBAC, admin, and seeded examples
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# How to Use syncdb Correctly
|
|
2
|
+
|
|
3
|
+
This page lists `@terreno/syncdb` behavior that a working app has to account for. Each entry says what the library does today, what breaks if you ignore it, and what to do instead. It was checked against `syncdb/src` and `api/src/sync` at 57.13.0. Several entries are library gaps rather than intended behavior. Delete an entry when its gap is fixed.
|
|
4
|
+
|
|
5
|
+
Read [Local-first data](../explanation/local-first-data.md) and [Migrate from @terreno/rtk to @terreno/syncdb](migrate-rtk-to-syncdb.md) first.
|
|
6
|
+
|
|
7
|
+
## Startup and snapshots
|
|
8
|
+
|
|
9
|
+
### Cold start costs one round trip per stream
|
|
10
|
+
|
|
11
|
+
A stream is one collection in one scope, so a user has roughly (synced collections) × (tenants they belong to) streams. Each stream costs at least one `GET /sync/snapshot` round trip, even when it is empty. Reconcile, subscribe catch-up, `forceResync` and wipe re-bootstrap page up to `snapshotConcurrency` streams side by side (default 6). Each stream runs one pass at a time, and a pass that purges (force resync, wipe) waits for the stream's in-flight pass to finish first.
|
|
12
|
+
|
|
13
|
+
Measured with a fake HTTP channel at 150 ms latency, 13 collections and 2 workspaces (26 streams): 4.1 s to the first full reconcile when paging one stream at a time, 0.95 s at the default concurrency.
|
|
14
|
+
|
|
15
|
+
| Do | Why |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| Sync only the collections the client reads. | Each collection adds one stream per tenant to cold start. |
|
|
18
|
+
| Gate each screen on its **own** stream's bootstrap (see [No loading flag on synced reads](#no-loading-flag-on-synced-reads)), not on "sync finished". | The stream a screen needs may land after others. |
|
|
19
|
+
| Don't call `forceResync()` on a hot path. | It purges and re-pages every stream. |
|
|
20
|
+
| Raise `snapshotConcurrency` only if the backend can take it. | Each in-flight stream is one concurrent snapshot query on the server. |
|
|
21
|
+
|
|
22
|
+
The startup reconcile and each collection's `sync:subscribed` catch-up both page the same streams. A catch-up queues behind a pass that started before the room join, because that pass may have missed a write. So a cold start still pages each stream twice, one pass after the other.
|
|
23
|
+
|
|
24
|
+
### No loading flag on synced reads
|
|
25
|
+
|
|
26
|
+
`useQuery` returns a plain array. `useListQuery` returns `{data}`. The `isPending` on `useEntity`/`useReadQuery` means an outbox write is pending, not that data is loading. An empty array before bootstrap looks the same as an empty collection, so a screen that renders its empty state from `[]` flashes "nothing here" on every cold start.
|
|
27
|
+
|
|
28
|
+
The per-stream `bootstrapped` mark is the right signal, but `isStreamBootstrapped` is not exported. Read the cell directly:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
const isBootstrapped = (stream: string): boolean =>
|
|
32
|
+
syncDb.store.raw.getCell("_cursors", stream, "bootstrapped") === true;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Don't use the cursor (`useSyncStatus().streams`) for this. A live delta can move the cursor to the head before the snapshot below it has been paged. `useWindowQuery` (query collections only) does expose `isLoading`.
|
|
36
|
+
|
|
37
|
+
A stream key is `<collection>|<scopeType>:<value>`, for example `documents|tenant:<workspaceId>`. A user with several tenants has one stream per tenant. Treat a collection as loaded only once every known stream for it is bootstrapped (`syncDb.store.getKnownStreams()`).
|
|
38
|
+
|
|
39
|
+
## Writes
|
|
40
|
+
|
|
41
|
+
### Mutations return an id, not an outcome
|
|
42
|
+
|
|
43
|
+
`create`, `update` and `remove` from `useMutate` (and from the generated `useCreate*`/`useUpdate*`/`useDelete*` hooks) return `{mutationId, id}` synchronously. There is no promise for the server ack. The client also drops the ack's `warning`. A `warning` means a server post-hook threw after the write committed.
|
|
44
|
+
|
|
45
|
+
If a screen needs to know the outcome (close a modal only once the create landed, toast a refusal), wrap the transport and HTTP channel and record ack and nack outcomes by `mutationId`. Then await that `mutationId`. Don't poll the store.
|
|
46
|
+
|
|
47
|
+
### A 403 from a write halts the whole outbox
|
|
48
|
+
|
|
49
|
+
The server maps 403/405 to an `unauthorized` nack and 400/404/422 to `validation`. They behave very differently:
|
|
50
|
+
|
|
51
|
+
| Nack | Client behavior |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `unauthorized` | The mutation is re-queued and the **whole drain stops** (auth-pause). On re-auth the same `mutationId` replays, reads the same stored nack, and pauses again. It never settles. |
|
|
54
|
+
| `validation` | The mutation is marked `failed` and the drain continues. That entity's later writes stay blocked until `retryFailed({entityId})`. |
|
|
55
|
+
| `conflict` | That entity's later writes stay blocked (or every write is blocked, with `haltQueueOnConflict: true`) until `resolveConflict`. |
|
|
56
|
+
| `error` | Retried up to the retry budget, then handled like `validation`. |
|
|
57
|
+
|
|
58
|
+
So a router hook that refuses a synced write for a business reason must throw **422**, not 403. That includes `preCreate`/`preUpdate` membership checks. Keep 403 for real auth failures.
|
|
59
|
+
|
|
60
|
+
### Don't send a stale tenant field on create
|
|
61
|
+
|
|
62
|
+
For a tenant-scoped collection, `enforceWriteScope` checks a client-sent scope field (for example `workspaceId`) against the user's memberships **before** `preCreate` runs. A value the user does not belong to is a 403, which halts the outbox (see above). If the field is missing or null, the check is skipped and `preCreate` can fill it in.
|
|
63
|
+
|
|
64
|
+
Either omit the tenant field and let `preCreate` set it, or send it only from the currently synced row of the parent tenant, never from a persisted id that may be stale.
|
|
65
|
+
|
|
66
|
+
### The local row never gets server-computed fields from the create
|
|
67
|
+
|
|
68
|
+
After an ack, the client stamps the row with the ack's seq and keeps the optimistic data. The server's create delta has the same seq, so the client skips it. Whatever `preCreate`, schema defaults or hooks set on the server does not reach the local row until that row changes again or is repaired. If the delta arrives before the ack, the row is marked for repair, but repair only runs on the next `reconcile()` (reconnect, seq jump, or every 5 minutes).
|
|
69
|
+
|
|
70
|
+
Do one of these:
|
|
71
|
+
|
|
72
|
+
- Send every field the UI reads in the create payload, matching what the server would compute (defaults, `order`, ownership fields).
|
|
73
|
+
- After the ack, patch the local row yourself.
|
|
74
|
+
|
|
75
|
+
The local row's `data` is exactly what you passed. Include `_id` in the payload if anything reads `row._id`. Server-written rows carry `_id` and `id`.
|
|
76
|
+
|
|
77
|
+
### A refused create leaves a phantom row
|
|
78
|
+
|
|
79
|
+
When a create is nacked as `validation` (or runs out of `error` retries), the repair fetch finds no server row and only clears the repair mark. The optimistic row stays in the store with `stream: ""` and shows up in queries. Only `forceResync` purges such rows.
|
|
80
|
+
|
|
81
|
+
On a refused create, tombstone the local row yourself and call `retryFailed({entityId})` so later writes to that id are released.
|
|
82
|
+
|
|
83
|
+
### Conflicts are never resolved for you
|
|
84
|
+
|
|
85
|
+
A `conflict` nack blocks every later write to that row, and any queued write whose args mention its id, until someone calls `client.resolveConflict({mutationId, strategy: "keepMine" | "useServer"})` (or `useConflicts().resolve`). Local edits keep applying optimistically, so the UI looks fine while nothing reaches the server.
|
|
86
|
+
|
|
87
|
+
If the app has no conflict UI, run a watcher that resolves each conflict as it is recorded. Use `keepMine` for fields the user types, and `useServer` for everything else.
|
|
88
|
+
|
|
89
|
+
## Server side
|
|
90
|
+
|
|
91
|
+
### Write synced models one document at a time, by `_id`
|
|
92
|
+
|
|
93
|
+
On a synced model, `updateMany`, `deleteMany`, `deleteOne`, `findOneAndDelete` and `bulkWrite` throw. Single-document writes by `_id` without upsert are allowed: `save()`, `updateOne`, `findOneAndUpdate`, `findByIdAndUpdate`, `replaceOne`. Delete by setting `deleted: true`, because a hard delete emits no delta.
|
|
94
|
+
|
|
95
|
+
In tests, clean up with `Model.collection.deleteMany(...)`, which bypasses Mongoose.
|
|
96
|
+
|
|
97
|
+
### Deltas don't apply `queryFilter`
|
|
98
|
+
|
|
99
|
+
Snapshots and `/sync/entities` apply the router's `queryFilter`. Live deltas check only `permissions.read` (or RBAC) per recipient. A router that restricts visibility only through `queryFilter` leaks rows over the socket. Every synced router's `read` permission must enforce the same restriction itself.
|
|
100
|
+
|
|
101
|
+
### Membership grants arrive late
|
|
102
|
+
|
|
103
|
+
A user's new tenant is picked up by the socket's session revalidation sweep (`sessionRevalidationIntervalMs`, 60 s by default) or a resubscribe. The client finds the new stream on its next `reconcile()` (`reconcileIntervalMs`, 5 minutes by default, plus reconnects). Until then the new tenant's rows don't appear.
|
|
104
|
+
|
|
105
|
+
When the product grants membership, push a content-free event to the user's sockets and have the client reconnect syncdb. Reconnecting resubscribes and reconciles. Grants written another way (admin, scripts) still wait for the timers.
|
|
106
|
+
|
|
107
|
+
## Auth
|
|
108
|
+
|
|
109
|
+
### Logging out does not wipe local data
|
|
110
|
+
|
|
111
|
+
The client's own `onAuthChange` handling treats a logout as a pause and **never wipes**. A different user logging in does wipe. To clear one user's data on sign-out, set `wipeOnSignOut: true` and call `client.signOut()` explicitly when the app's user clears.
|
|
@@ -185,6 +185,7 @@ be passed to `betterAuthAdapter` directly.
|
|
|
185
185
|
| `wipeOnSignOut` | `boolean` | `false` | `signOut()` also wipes local data |
|
|
186
186
|
| `batchSize` | `number` | `50` (server caps at 100) | Max mutations per batched drain send |
|
|
187
187
|
| `haltQueueOnConflict` | `boolean` | `false` | `true` = conflict halts entire drain; `false` = per-entity blocking |
|
|
188
|
+
| `snapshotConcurrency` | `number` | `6` | Streams snapshot-paged at once by reconcile, subscribe catch-up, `forceResync` and wipe re-bootstrap. Each stream runs one pass at a time. `1` pages one stream at a time |
|
|
188
189
|
| `onDecryptFailure` | `() => void` | wipe + re-bootstrap (with `console.warn`) | Override web decrypt-failure recovery |
|
|
189
190
|
| `tombstoneRetentionMs` | `number` | `7776000000` (90 days); `0` disables | Client tombstone compaction after successful reconcile |
|
|
190
191
|
| `startAuthRetryAttempts` | `number` | `3`; `1` disables | `start()` attempts to resolve `getUserId()` |
|