@terreno/mcp 58.0.1 → 58.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -20,6 +20,12 @@ Upgrade notes for consumer action live in [`mcp-server/src/docs/upgrades/`](mcp-
20
20
 
21
21
  Unreleased changes live in [`changelog/unreleased/`](changelog/unreleased/). Add one Markdown file per feature (see that directory's README) instead of editing this section.
22
22
 
23
+ ## [58.0.2] - 2026-11-09
24
+
25
+ ### Fixed
26
+
27
+ - Patch the remaining critical and high security advisories in Terreno's dependency tree. `@terreno/ui` now asks for `@react-navigation/native` `^7.5.1`, whose `@react-navigation/core` no longer pulls in the vulnerable `decode-uri-component` (#1693). The docs site moves to tinypool 2.2.0 (critical) and serialize-javascript 7.1.4 (#1690, #1691), Metro's stale nested copy no longer brings in image-size 1.x (#1694), and rulesync 43.8.0 drops its pinned zod 4.6.5 (#1695). Dependabot now opens one security PR per advisory instead of one grouped PR per package block (#1689). Expo tooling still carries zod 3.25.76, which has no fixed 3.x release.
28
+
23
29
  ## [58.0.1] - 2026-11-09
24
30
 
25
31
  ### Added
@@ -69,16 +69,9 @@ JavaScript packages, GitHub Actions, and a few Expo JS-only names may update. Me
69
69
 
70
70
  ## Dependabot configuration
71
71
 
72
- `.github/dependabot.yml` sets `open-pull-requests-limit: 0` on every block. That turns off version-update PRs; security updates ignore the limit and still honor `ignore`. Each block's group uses `applies-to: security-updates`, so fixes arrive grouped:
72
+ `.github/dependabot.yml` sets `open-pull-requests-limit: 0` on every block. That turns off version-update PRs; security updates ignore the limit and still honor `ignore`. Security updates are not grouped: each advisory opens its own PR per block (`github-actions` at `/`, `bun` at `/`, the backend directories, and the frontend directories), so one fix can merge without waiting on another.
73
73
 
74
- | Group | Ecosystem |
75
- | --- | --- |
76
- | `github-actions` | `github-actions` at `/` |
77
- | `root` | `bun` at `/` (root catalog and root tools) |
78
- | `backend` | `bun` under api, ai, backends, … |
79
- | `frontend` | `bun` under ui, syncdb, admin-frontend, demo, apps, … |
80
-
81
- Frontend ignores catalog-managed names so those fixes land once, in the root catalog. Root ignores fingerprint-skip names, so a security fix for a native/Expo package never opens a PR; handle it by hand in a release. `dependabot-auto-merge.yml` approves patch and minor Dependabot PRs and enables squash auto-merge, so those security PRs merge when required checks pass. A semver-major security PR (or a group containing one) gets no approval or auto-merge and waits for a human. When one fails because the fix needs code changes, fold the bump into the rolling PR ([Update dependencies](../how-to/update-dependencies.md)); never push to the `dependabot/*` branch.
74
+ Frontend ignores catalog-managed names so those fixes land once, in the root catalog. Root ignores fingerprint-skip names, so a security fix for a native/Expo package never opens a PR; handle it by hand in a release. `dependabot-auto-merge.yml` approves patch and minor Dependabot PRs and enables squash auto-merge, so those security PRs merge when required checks pass. A semver-major security PR gets no approval or auto-merge and waits for a human. When one fails because the fix needs code changes, fold the bump into the rolling PR ([Update dependencies](../how-to/update-dependencies.md)); never push to the `dependabot/*` branch.
82
75
 
83
76
  ## Daily rolling PR
84
77
 
@@ -1451,6 +1451,19 @@ Delete the `@terreno/rtk` package and every Redux dependency from the monorepo.
1451
1451
 
1452
1452
  ---
1453
1453
 
1454
+ ## syncdb-high-churn-fields
1455
+
1456
+ **Title:** `SyncDB: high-churn fields without a sequence claim per write`
1457
+
1458
+ **Labels:** `area:api`, `type:feature`
1459
+ **Project fields:** Area=`api`, Target=`Next`, Impact=`Feature`, IP=`syncdb-high-churn-fields`, Status=`Shaping`
1460
+
1461
+ A synced document can carry fields that change every few seconds (a gauge, a heartbeat, a progress counter) without each change costing three replicated writes and a round trip through the stream's counter document.
1462
+
1463
+ - **Implementation plan:** [syncdb-high-churn-fields.md](https://github.com/TerrenoLabs/terreno/blob/master/docs/implementationPlans/syncdb-high-churn-fields.md)
1464
+
1465
+ ---
1466
+
1454
1467
  # Shipped, umbrella, and declined IPs
1455
1468
 
1456
1469
  Tracking issues created for IPs that previously lacked a `**Roadmap issue:**` header.
@@ -60,4 +60,4 @@ CircleCI attributes a pipeline to the GitHub user whose token pushed it and emai
60
60
 
61
61
  ## Dependabot security PRs
62
62
 
63
- `.github/dependabot.yml` sets `open-pull-requests-limit: 0` on every block, so Dependabot opens no version-update PRs. Security updates still open, grouped per block, and skip ignored native/Expo names. `dependabot-auto-merge.yml` approves them and enables squash auto-merge, so a green security PR merges without this skill. A red one gets its bump folded into the rolling PR. A security fix for an ignored native package is a manual release task (`release` + `upgrading-expo`).
63
+ `.github/dependabot.yml` sets `open-pull-requests-limit: 0` on every block, so Dependabot opens no version-update PRs. Security updates still open, one PR per advisory, and skip ignored native/Expo names. `dependabot-auto-merge.yml` approves them and enables squash auto-merge, so a green security PR merges without this skill. A red one gets its bump folded into the rolling PR. A security fix for an ignored native package is a manual release task (`release` + `upgrading-expo`).
@@ -0,0 +1,266 @@
1
+ # SyncDB: high-churn fields without a sequence claim per write
2
+
3
+ **Status:** Draft
4
+ **Roadmap:** Area=`api`, Target=`Next`, Impact=`Feature`
5
+ **Branch:** syncdb-high-churn-fields
6
+ **Owner:** Josh Gachnang
7
+ **Created:** 2026-11-09
8
+
9
+ This is an options document. It describes the problem, compares five ways to handle it, and
10
+ recommends an order. Nothing here is implemented yet; the Decisions table lists what needs an
11
+ answer before a task list is written.
12
+
13
+ ## Goal
14
+
15
+ A synced document can carry fields that change every few seconds (a gauge, a heartbeat, a
16
+ progress counter) without each change costing three replicated writes and a round trip through
17
+ the stream's counter document.
18
+
19
+ ## Problem
20
+
21
+ Every write to a synced model outside a caller session does three things
22
+ (`api/src/sync/syncSeqPlugin.ts`, `api/src/sync/models.ts`):
23
+
24
+ 1. `claimSyncSeqs`: a pipeline update on the stream's `synccounters` document that increments
25
+ `seq` and pushes a `pending` entry.
26
+ 2. The document write itself, stamped with `_syncSeq`.
27
+ 3. `confirmSyncSeqs`: a `$pull` of the `pending` entry.
28
+
29
+ That is three oplog entries per write, two of them on one hot document per stream. The cost is
30
+ the same whether the write changes a title a person typed or a memory reading that moved by a
31
+ few kilobytes.
32
+
33
+ ### Measured in a consumer app (Shade, 2026-11-09)
34
+
35
+ Shade projects each agent session's container memory (`memUsedBytes`, `memPercent`) into two
36
+ synced collections every few seconds. Over a 20-hour oplog:
37
+
38
+ | Namespace | Oplog entries | Size | Share |
39
+ |---|---|---|---|
40
+ | `synccounters` | 1,519,392 | 547 MB | 56% |
41
+ | the two projection collections | 443,273 | 167 MB | 17% |
42
+ | everything else | about 395,000 | about 270 MB | 27% |
43
+
44
+ In one hour the sessions collection took 12,596 updates and its counter took 25,094. The
45
+ replication oplog window fell from about 48 hours to 20 hours and tripped the oplog-window
46
+ alert. Shade has since added a deadband in its own projection code, which is Option B below done
47
+ by hand in one app.
48
+
49
+ ### What makes this hard
50
+
51
+ - **Catch-up reads by sequence.** A snapshot pages `_syncSeq` in `(cursor, frontier]`
52
+ (`api/src/sync/routes.ts`). A write with no new sequence is invisible to a client that was
53
+ offline when it happened.
54
+ - **Clients drop deltas that do not raise the sequence.** `applyDelta` ignores a delta whose
55
+ `seq` is not strictly higher than the stored entity's (`syncdb/src/sync/deltaApplier.ts`). An
56
+ unsequenced change cannot reuse the `sync:delta` event as it stands.
57
+ - **The no-op check is all or nothing.** `pre("save")` already skips the claim when the only
58
+ modified path is `updated`. There is no way for a model to add its own fields to that set, and
59
+ the query-write path (`findOneAndUpdate`, `updateOne`) has no such check at all.
60
+
61
+ ## Non-Goals
62
+
63
+ - Changing ordering or frontier guarantees for ordinary fields (C1 stays as it is).
64
+ - Offline edits to high-churn fields. They are server-written values; client mutations on them
65
+ keep going through the normal sequenced path or are rejected.
66
+ - Replacing the per-stream counter with another ordering source. See Option E, listed only for
67
+ completeness.
68
+
69
+ ## Options
70
+
71
+ ### Option A: volatile fields (unsequenced writes, live-only delivery, periodic sequencing)
72
+
73
+ A model declares fields that change often:
74
+
75
+ ```ts
76
+ sync: {
77
+ scope: {type: "broadcast"},
78
+ volatile: {fields: ["memUsedBytes", "memPercent"], sequenceEveryMs: 60_000},
79
+ }
80
+ ```
81
+
82
+ - A write whose meaningful modified paths are all volatile skips the claim and the confirm. The
83
+ document write still happens, so the value is durable and a snapshot of the document returns
84
+ the current value. Cost: one oplog entry instead of three.
85
+ - The change-stream watcher sees the update with an unchanged `_syncSeq` and emits a new
86
+ `sync:volatile` event carrying `{collection, id, stream, seq, fields}`. The client merges the
87
+ fields into the entity it holds when `seq` matches the entity's sequence, and ignores the
88
+ event otherwise (a sequenced delta is on its way or already applied).
89
+ - To bound staleness for clients that were offline, a volatile-only write is sequenced normally
90
+ when the document's last sequenced write is older than `sequenceEveryMs`. Catch-up then sees
91
+ a value at most that old. The plugin needs the time of the last sequenced write, either a new
92
+ `_syncSeqAt` stamp or an in-process map keyed by document id.
93
+ - A write that touches any non-volatile field is sequenced as today and carries the volatile
94
+ fields with it.
95
+
96
+ Cost and risk:
97
+
98
+ - Protocol addition: a new socket event and a client handler. Older clients ignore the event and
99
+ see volatile fields refresh only on sequenced writes, which is a safe degradation.
100
+ - The query-write path has to learn which fields an update touches (`$set`, `$unset`, `$inc`
101
+ keys) to decide whether it is volatile-only. Replacements are always sequenced.
102
+ - The watcher has to distinguish "update with unchanged `_syncSeq`" from a sequenced delta.
103
+ `updateDescription.updatedFields` already says whether `_syncSeq` changed.
104
+ - Permissions and masking apply to `sync:volatile` exactly as to deltas; the event must go
105
+ through the same per-socket serialization.
106
+
107
+ Effect on the measured case: 3 entries per memory write down to 1, with no loss of live
108
+ resolution. The two counter updates behind each projection write were about 320 MB of the
109
+ 980 MB measured, so roughly a third off Shade's total oplog volume.
110
+
111
+ ### Option B: declarative write coalescing (drop writes that do not matter yet)
112
+
113
+ A model declares a tolerance and a minimum interval per field:
114
+
115
+ ```ts
116
+ sync: {
117
+ scope: {type: "broadcast"},
118
+ coalesce: {
119
+ memPercent: {minDelta: 1, minIntervalMs: 15_000},
120
+ memUsedBytes: {follows: "memPercent"},
121
+ },
122
+ }
123
+ ```
124
+
125
+ - In `pre("save")` and the query-write hook, a write whose meaningful modified paths are all
126
+ coalesced fields, and where each change is inside its tolerance or its interval, is dropped
127
+ entirely: no claim, no document write, no oplog entry.
128
+ - A change that exceeds the tolerance, or the first change after the interval, is written and
129
+ sequenced as today.
130
+
131
+ Cost and risk:
132
+
133
+ - No protocol change, no client change. Smallest diff of the five.
134
+ - Lossy by design: clients see the value in steps. Fine for gauges, wrong for anything that must
135
+ be exact at rest. A trailing flush (write the last dropped value after the interval) fixes
136
+ "exact at rest" but needs an in-process timer, which does not survive a restart and misbehaves
137
+ with several API instances unless the timer is per instance and idempotent.
138
+ - Dropping a `save()` silently is surprising. The hook must unmark the paths and leave the
139
+ in-memory document holding the stored value, or callers will read back a value that was never
140
+ written.
141
+ - `minDelta` only means something for numbers. Other types get `minIntervalMs` only.
142
+
143
+ Effect on the measured case: depends on the tolerance. A one-point deadband on `memPercent`
144
+ removes nearly all of the writes for an idle session.
145
+
146
+ ### Option C: cheaper sequencing for every write (batched confirms)
147
+
148
+ Keep the semantics and reduce the cost of the confirm. Today each confirm is its own `$pull`.
149
+ Instead:
150
+
151
+ - Hold confirmed seqs in a per-stream, in-process queue.
152
+ - Fold the queue into the next claim on that stream: the claim's pipeline update already
153
+ rewrites `pending`, so it can filter out confirmed seqs in the same update.
154
+ - Flush the queue with a standalone `$pull` after a short idle delay (for example 50 ms) so a
155
+ quiet stream's frontier still advances promptly.
156
+
157
+ Cost and risk:
158
+
159
+ - Under sustained writes this takes 3 entries per write to 2, and counter writes from 2 to 1.
160
+ A quiet stream gains nothing, since each write still gets its own flush.
161
+ - The frontier lags by up to the flush delay. Deltas already carry `frontierSeq` and clients
162
+ clamp to it, so correctness holds, but cursor advancement and `hasMore` lag by the same
163
+ amount.
164
+ - A crash loses queued confirms. Those entries age out through the existing 60-second lease,
165
+ so the frontier stalls for up to a minute after a crash under load. That is the same failure
166
+ mode as today's crash between claim and confirm, but it would now cover more seqs.
167
+ - Helps every synced model with no configuration. Does nothing about the write to the document
168
+ itself, or about deltas fanned out to every subscriber.
169
+
170
+ Effect on the measured case: up to half of the counter entries, so at most about a quarter
171
+ off the total, with no app changes.
172
+
173
+ ### Option D: ephemeral channel (values that never touch MongoDB)
174
+
175
+ Give apps a first-class way to publish values that are not part of the document at all:
176
+
177
+ ```ts
178
+ syncApp.publishEphemeral({collection: "zergsessions", id, stream, fields: {memPercent: 41.2}});
179
+ ```
180
+
181
+ - The server keeps the latest value per `(collection, id)` in memory and emits `sync:ephemeral`
182
+ to the stream's room. A client joining the room gets the current values in the join
183
+ acknowledgement.
184
+ - The client keeps ephemeral fields in a separate in-memory layer that query hooks merge over
185
+ the stored entity. Nothing is written to the local store or to MongoDB.
186
+
187
+ Cost and risk:
188
+
189
+ - Zero oplog entries. The right model for presence, typing indicators, live gauges and progress.
190
+ - Values are lost on server restart until the next publish, and are not available offline.
191
+ - Several API instances need a shared fan-out (the Socket.IO adapter already in use, or Redis)
192
+ and a shared "latest value" store for joins, or a join can miss values published on another
193
+ instance.
194
+ - Largest surface of the five: a server API, a socket event, a client layer, hook changes and
195
+ codegen typing for the ephemeral fields. Apps must move the fields out of their schema.
196
+
197
+ Effect on the measured case: removes the projection writes and their counter updates, about
198
+ half of Shade's oplog volume.
199
+
200
+ ### Option E: order by cluster time instead of a counter (not recommended)
201
+
202
+ Stamp documents with the server's operation time (`$currentDate` with `$type: "timestamp"`) and
203
+ use that as the cursor. No counter document, so one oplog entry per write for every model.
204
+
205
+ Rejected for now: the stable-frontier problem that C1 solved comes back in a harder form
206
+ (timestamps are assigned before commit, so a reader can see a later timestamp before an earlier
207
+ one lands), stream-local ordering turns into global ordering, every stored client cursor needs a
208
+ migration, and scope-move markers and tombstone compaction are both built on per-stream seqs.
209
+ It is a rewrite of the protocol, listed so the comparison is complete.
210
+
211
+ ## Comparison
212
+
213
+ | | Oplog entries per high-churn write | Live resolution | Offline catch-up | Protocol change | Client change | App change |
214
+ |---|---|---|---|---|---|---|
215
+ | Today | 3 | full | exact | | | |
216
+ | A: volatile fields | 1 | full | stale by at most `sequenceEveryMs` | new event | new handler | config only |
217
+ | B: coalescing | 0 for dropped writes, 3 for kept | stepped | exact for what was written | none | none | config only |
218
+ | C: batched confirms | 2 under load | full | exact | none | none | none |
219
+ | D: ephemeral channel | 0 | full | none | new event and join payload | new layer | move fields, new publish call |
220
+ | E: cluster time | 1 | full | exact | rewrite | rewrite | none |
221
+
222
+ ## Recommendation
223
+
224
+ 1. **Ship B first.** It needs no protocol or client work, it is what the affected app has
225
+ already done by hand, and it fixes the alerting problem on its own. Start with
226
+ `minDelta` and `minIntervalMs`, without a trailing flush.
227
+ 2. **Then A**, for fields where stepping is not acceptable. It keeps full live resolution at a
228
+ third of the cost and degrades safely for older clients.
229
+ 3. **C is independent** and worth doing for every model, but it is an optimisation of the hot
230
+ path with a crash-recovery trade-off, so it should come with a load-harness comparison
231
+ (`api/src/sync/loadHarness.ts`) before it is approved.
232
+ 4. **D only if** presence-style features (typing, cursors, live progress) arrive on the roadmap.
233
+ It is the cleanest answer for values that should never be stored, and too much surface to
234
+ build for gauges alone.
235
+
236
+ ## Decisions
237
+
238
+ | Question | Decision |
239
+ |----------|----------|
240
+ | Which options go ahead, and in what order? | Open. Recommendation: B, then A; C separately after a load test; D deferred. |
241
+ | B: does a dropped write need a trailing flush so the stored value is exact at rest? | Open. Recommendation: no in the first version. |
242
+ | B: should a dropped `save()` be silent, or logged at debug level? | Open. Recommendation: debug log with model and paths. |
243
+ | A: where does "last sequenced at" live, a `_syncSeqAt` field or process memory? | Open. Recommendation: `_syncSeqAt`, so it is correct across instances and restarts. |
244
+ | A: default for `sequenceEveryMs` | Open. Recommendation: 60 seconds. |
245
+ | A: may clients mutate volatile fields? | Open. Recommendation: reject in the mutation handler; they are server-written. |
246
+ | C: flush delay, and is a longer post-crash frontier stall acceptable? | Open. Needs load-harness numbers. |
247
+
248
+ ## Phases
249
+
250
+ Written once the decisions above are made. Each chosen option is its own phase with its own
251
+ task list under `docs/tasks/`.
252
+
253
+ ## Files to Create / Modify
254
+
255
+ For B and A, the likely touch points:
256
+
257
+ - `api/src/sync/types.ts`: `SyncConfig.coalesce`, `SyncConfig.volatile`, the `sync:volatile`
258
+ payload type.
259
+ - `api/src/sync/syncSeqPlugin.ts`: extend the no-op check in `pre("save")`; add the same check
260
+ to `preQueryWrite`.
261
+ - `api/src/realtime/changeStreamWatcher.ts`: emit `sync:volatile` for updates that leave
262
+ `_syncSeq` unchanged on a model with volatile fields.
263
+ - `api/src/sync/mutationHandler.ts`: reject client mutations on volatile fields.
264
+ - `syncdb/src/sync/deltaApplier.ts` and `syncdb/src/client.ts`: apply `sync:volatile`.
265
+ - `docs/explanation/local-first-data.md` and `docs/reference/syncdb.md`: document the options
266
+ and when to use each.
package/package.json CHANGED
@@ -11,8 +11,8 @@
11
11
  "@modelcontextprotocol/node": "^2.1.2",
12
12
  "@modelcontextprotocol/server": "^2.0.0",
13
13
  "@sentry/bun": "^11.0.0",
14
- "@terreno/api": "58.0.1",
15
- "@terreno/blocks": "58.0.1",
14
+ "@terreno/api": "58.0.2",
15
+ "@terreno/blocks": "58.0.2",
16
16
  "express": "^5.2.1",
17
17
  "luxon": "^3.7.2",
18
18
  "minisearch": "^7.2.0",
@@ -22,7 +22,7 @@
22
22
  "devDependencies": {
23
23
  "@biomejs/biome": "2.5.13",
24
24
  "@modelcontextprotocol/client": "^2.0.0",
25
- "@terreno/test": "58.0.1",
25
+ "@terreno/test": "58.0.2",
26
26
  "@types/bun": "^1.4.0",
27
27
  "@types/luxon": "^3.7.1",
28
28
  "@types/node": "^25.3.0",
@@ -109,5 +109,5 @@
109
109
  "typecheck": "NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.typecheck.json"
110
110
  },
111
111
  "type": "module",
112
- "version": "58.0.1"
112
+ "version": "58.0.2"
113
113
  }
@@ -69,16 +69,9 @@ JavaScript packages, GitHub Actions, and a few Expo JS-only names may update. Me
69
69
 
70
70
  ## Dependabot configuration
71
71
 
72
- `.github/dependabot.yml` sets `open-pull-requests-limit: 0` on every block. That turns off version-update PRs; security updates ignore the limit and still honor `ignore`. Each block's group uses `applies-to: security-updates`, so fixes arrive grouped:
72
+ `.github/dependabot.yml` sets `open-pull-requests-limit: 0` on every block. That turns off version-update PRs; security updates ignore the limit and still honor `ignore`. Security updates are not grouped: each advisory opens its own PR per block (`github-actions` at `/`, `bun` at `/`, the backend directories, and the frontend directories), so one fix can merge without waiting on another.
73
73
 
74
- | Group | Ecosystem |
75
- | --- | --- |
76
- | `github-actions` | `github-actions` at `/` |
77
- | `root` | `bun` at `/` (root catalog and root tools) |
78
- | `backend` | `bun` under api, ai, backends, … |
79
- | `frontend` | `bun` under ui, syncdb, admin-frontend, demo, apps, … |
80
-
81
- Frontend ignores catalog-managed names so those fixes land once, in the root catalog. Root ignores fingerprint-skip names, so a security fix for a native/Expo package never opens a PR; handle it by hand in a release. `dependabot-auto-merge.yml` approves patch and minor Dependabot PRs and enables squash auto-merge, so those security PRs merge when required checks pass. A semver-major security PR (or a group containing one) gets no approval or auto-merge and waits for a human. When one fails because the fix needs code changes, fold the bump into the rolling PR ([Update dependencies](../how-to/update-dependencies.md)); never push to the `dependabot/*` branch.
74
+ Frontend ignores catalog-managed names so those fixes land once, in the root catalog. Root ignores fingerprint-skip names, so a security fix for a native/Expo package never opens a PR; handle it by hand in a release. `dependabot-auto-merge.yml` approves patch and minor Dependabot PRs and enables squash auto-merge, so those security PRs merge when required checks pass. A semver-major security PR gets no approval or auto-merge and waits for a human. When one fails because the fix needs code changes, fold the bump into the rolling PR ([Update dependencies](../how-to/update-dependencies.md)); never push to the `dependabot/*` branch.
82
75
 
83
76
  ## Daily rolling PR
84
77
 
@@ -1451,6 +1451,19 @@ Delete the `@terreno/rtk` package and every Redux dependency from the monorepo.
1451
1451
 
1452
1452
  ---
1453
1453
 
1454
+ ## syncdb-high-churn-fields
1455
+
1456
+ **Title:** `SyncDB: high-churn fields without a sequence claim per write`
1457
+
1458
+ **Labels:** `area:api`, `type:feature`
1459
+ **Project fields:** Area=`api`, Target=`Next`, Impact=`Feature`, IP=`syncdb-high-churn-fields`, Status=`Shaping`
1460
+
1461
+ A synced document can carry fields that change every few seconds (a gauge, a heartbeat, a progress counter) without each change costing three replicated writes and a round trip through the stream's counter document.
1462
+
1463
+ - **Implementation plan:** [syncdb-high-churn-fields.md](https://github.com/TerrenoLabs/terreno/blob/master/docs/implementationPlans/syncdb-high-churn-fields.md)
1464
+
1465
+ ---
1466
+
1454
1467
  # Shipped, umbrella, and declined IPs
1455
1468
 
1456
1469
  Tracking issues created for IPs that previously lacked a `**Roadmap issue:**` header.
@@ -60,4 +60,4 @@ CircleCI attributes a pipeline to the GitHub user whose token pushed it and emai
60
60
 
61
61
  ## Dependabot security PRs
62
62
 
63
- `.github/dependabot.yml` sets `open-pull-requests-limit: 0` on every block, so Dependabot opens no version-update PRs. Security updates still open, grouped per block, and skip ignored native/Expo names. `dependabot-auto-merge.yml` approves them and enables squash auto-merge, so a green security PR merges without this skill. A red one gets its bump folded into the rolling PR. A security fix for an ignored native package is a manual release task (`release` + `upgrading-expo`).
63
+ `.github/dependabot.yml` sets `open-pull-requests-limit: 0` on every block, so Dependabot opens no version-update PRs. Security updates still open, one PR per advisory, and skip ignored native/Expo names. `dependabot-auto-merge.yml` approves them and enables squash auto-merge, so a green security PR merges without this skill. A red one gets its bump folded into the rolling PR. A security fix for an ignored native package is a manual release task (`release` + `upgrading-expo`).
@@ -0,0 +1,266 @@
1
+ # SyncDB: high-churn fields without a sequence claim per write
2
+
3
+ **Status:** Draft
4
+ **Roadmap:** Area=`api`, Target=`Next`, Impact=`Feature`
5
+ **Branch:** syncdb-high-churn-fields
6
+ **Owner:** Josh Gachnang
7
+ **Created:** 2026-11-09
8
+
9
+ This is an options document. It describes the problem, compares five ways to handle it, and
10
+ recommends an order. Nothing here is implemented yet; the Decisions table lists what needs an
11
+ answer before a task list is written.
12
+
13
+ ## Goal
14
+
15
+ A synced document can carry fields that change every few seconds (a gauge, a heartbeat, a
16
+ progress counter) without each change costing three replicated writes and a round trip through
17
+ the stream's counter document.
18
+
19
+ ## Problem
20
+
21
+ Every write to a synced model outside a caller session does three things
22
+ (`api/src/sync/syncSeqPlugin.ts`, `api/src/sync/models.ts`):
23
+
24
+ 1. `claimSyncSeqs`: a pipeline update on the stream's `synccounters` document that increments
25
+ `seq` and pushes a `pending` entry.
26
+ 2. The document write itself, stamped with `_syncSeq`.
27
+ 3. `confirmSyncSeqs`: a `$pull` of the `pending` entry.
28
+
29
+ That is three oplog entries per write, two of them on one hot document per stream. The cost is
30
+ the same whether the write changes a title a person typed or a memory reading that moved by a
31
+ few kilobytes.
32
+
33
+ ### Measured in a consumer app (Shade, 2026-11-09)
34
+
35
+ Shade projects each agent session's container memory (`memUsedBytes`, `memPercent`) into two
36
+ synced collections every few seconds. Over a 20-hour oplog:
37
+
38
+ | Namespace | Oplog entries | Size | Share |
39
+ |---|---|---|---|
40
+ | `synccounters` | 1,519,392 | 547 MB | 56% |
41
+ | the two projection collections | 443,273 | 167 MB | 17% |
42
+ | everything else | about 395,000 | about 270 MB | 27% |
43
+
44
+ In one hour the sessions collection took 12,596 updates and its counter took 25,094. The
45
+ replication oplog window fell from about 48 hours to 20 hours and tripped the oplog-window
46
+ alert. Shade has since added a deadband in its own projection code, which is Option B below done
47
+ by hand in one app.
48
+
49
+ ### What makes this hard
50
+
51
+ - **Catch-up reads by sequence.** A snapshot pages `_syncSeq` in `(cursor, frontier]`
52
+ (`api/src/sync/routes.ts`). A write with no new sequence is invisible to a client that was
53
+ offline when it happened.
54
+ - **Clients drop deltas that do not raise the sequence.** `applyDelta` ignores a delta whose
55
+ `seq` is not strictly higher than the stored entity's (`syncdb/src/sync/deltaApplier.ts`). An
56
+ unsequenced change cannot reuse the `sync:delta` event as it stands.
57
+ - **The no-op check is all or nothing.** `pre("save")` already skips the claim when the only
58
+ modified path is `updated`. There is no way for a model to add its own fields to that set, and
59
+ the query-write path (`findOneAndUpdate`, `updateOne`) has no such check at all.
60
+
61
+ ## Non-Goals
62
+
63
+ - Changing ordering or frontier guarantees for ordinary fields (C1 stays as it is).
64
+ - Offline edits to high-churn fields. They are server-written values; client mutations on them
65
+ keep going through the normal sequenced path or are rejected.
66
+ - Replacing the per-stream counter with another ordering source. See Option E, listed only for
67
+ completeness.
68
+
69
+ ## Options
70
+
71
+ ### Option A: volatile fields (unsequenced writes, live-only delivery, periodic sequencing)
72
+
73
+ A model declares fields that change often:
74
+
75
+ ```ts
76
+ sync: {
77
+ scope: {type: "broadcast"},
78
+ volatile: {fields: ["memUsedBytes", "memPercent"], sequenceEveryMs: 60_000},
79
+ }
80
+ ```
81
+
82
+ - A write whose meaningful modified paths are all volatile skips the claim and the confirm. The
83
+ document write still happens, so the value is durable and a snapshot of the document returns
84
+ the current value. Cost: one oplog entry instead of three.
85
+ - The change-stream watcher sees the update with an unchanged `_syncSeq` and emits a new
86
+ `sync:volatile` event carrying `{collection, id, stream, seq, fields}`. The client merges the
87
+ fields into the entity it holds when `seq` matches the entity's sequence, and ignores the
88
+ event otherwise (a sequenced delta is on its way or already applied).
89
+ - To bound staleness for clients that were offline, a volatile-only write is sequenced normally
90
+ when the document's last sequenced write is older than `sequenceEveryMs`. Catch-up then sees
91
+ a value at most that old. The plugin needs the time of the last sequenced write, either a new
92
+ `_syncSeqAt` stamp or an in-process map keyed by document id.
93
+ - A write that touches any non-volatile field is sequenced as today and carries the volatile
94
+ fields with it.
95
+
96
+ Cost and risk:
97
+
98
+ - Protocol addition: a new socket event and a client handler. Older clients ignore the event and
99
+ see volatile fields refresh only on sequenced writes, which is a safe degradation.
100
+ - The query-write path has to learn which fields an update touches (`$set`, `$unset`, `$inc`
101
+ keys) to decide whether it is volatile-only. Replacements are always sequenced.
102
+ - The watcher has to distinguish "update with unchanged `_syncSeq`" from a sequenced delta.
103
+ `updateDescription.updatedFields` already says whether `_syncSeq` changed.
104
+ - Permissions and masking apply to `sync:volatile` exactly as to deltas; the event must go
105
+ through the same per-socket serialization.
106
+
107
+ Effect on the measured case: 3 entries per memory write down to 1, with no loss of live
108
+ resolution. The two counter updates behind each projection write were about 320 MB of the
109
+ 980 MB measured, so roughly a third off Shade's total oplog volume.
110
+
111
+ ### Option B: declarative write coalescing (drop writes that do not matter yet)
112
+
113
+ A model declares a tolerance and a minimum interval per field:
114
+
115
+ ```ts
116
+ sync: {
117
+ scope: {type: "broadcast"},
118
+ coalesce: {
119
+ memPercent: {minDelta: 1, minIntervalMs: 15_000},
120
+ memUsedBytes: {follows: "memPercent"},
121
+ },
122
+ }
123
+ ```
124
+
125
+ - In `pre("save")` and the query-write hook, a write whose meaningful modified paths are all
126
+ coalesced fields, and where each change is inside its tolerance or its interval, is dropped
127
+ entirely: no claim, no document write, no oplog entry.
128
+ - A change that exceeds the tolerance, or the first change after the interval, is written and
129
+ sequenced as today.
130
+
131
+ Cost and risk:
132
+
133
+ - No protocol change, no client change. Smallest diff of the five.
134
+ - Lossy by design: clients see the value in steps. Fine for gauges, wrong for anything that must
135
+ be exact at rest. A trailing flush (write the last dropped value after the interval) fixes
136
+ "exact at rest" but needs an in-process timer, which does not survive a restart and misbehaves
137
+ with several API instances unless the timer is per instance and idempotent.
138
+ - Dropping a `save()` silently is surprising. The hook must unmark the paths and leave the
139
+ in-memory document holding the stored value, or callers will read back a value that was never
140
+ written.
141
+ - `minDelta` only means something for numbers. Other types get `minIntervalMs` only.
142
+
143
+ Effect on the measured case: depends on the tolerance. A one-point deadband on `memPercent`
144
+ removes nearly all of the writes for an idle session.
145
+
146
+ ### Option C: cheaper sequencing for every write (batched confirms)
147
+
148
+ Keep the semantics and reduce the cost of the confirm. Today each confirm is its own `$pull`.
149
+ Instead:
150
+
151
+ - Hold confirmed seqs in a per-stream, in-process queue.
152
+ - Fold the queue into the next claim on that stream: the claim's pipeline update already
153
+ rewrites `pending`, so it can filter out confirmed seqs in the same update.
154
+ - Flush the queue with a standalone `$pull` after a short idle delay (for example 50 ms) so a
155
+ quiet stream's frontier still advances promptly.
156
+
157
+ Cost and risk:
158
+
159
+ - Under sustained writes this takes 3 entries per write to 2, and counter writes from 2 to 1.
160
+ A quiet stream gains nothing, since each write still gets its own flush.
161
+ - The frontier lags by up to the flush delay. Deltas already carry `frontierSeq` and clients
162
+ clamp to it, so correctness holds, but cursor advancement and `hasMore` lag by the same
163
+ amount.
164
+ - A crash loses queued confirms. Those entries age out through the existing 60-second lease,
165
+ so the frontier stalls for up to a minute after a crash under load. That is the same failure
166
+ mode as today's crash between claim and confirm, but it would now cover more seqs.
167
+ - Helps every synced model with no configuration. Does nothing about the write to the document
168
+ itself, or about deltas fanned out to every subscriber.
169
+
170
+ Effect on the measured case: up to half of the counter entries, so at most about a quarter
171
+ off the total, with no app changes.
172
+
173
+ ### Option D: ephemeral channel (values that never touch MongoDB)
174
+
175
+ Give apps a first-class way to publish values that are not part of the document at all:
176
+
177
+ ```ts
178
+ syncApp.publishEphemeral({collection: "zergsessions", id, stream, fields: {memPercent: 41.2}});
179
+ ```
180
+
181
+ - The server keeps the latest value per `(collection, id)` in memory and emits `sync:ephemeral`
182
+ to the stream's room. A client joining the room gets the current values in the join
183
+ acknowledgement.
184
+ - The client keeps ephemeral fields in a separate in-memory layer that query hooks merge over
185
+ the stored entity. Nothing is written to the local store or to MongoDB.
186
+
187
+ Cost and risk:
188
+
189
+ - Zero oplog entries. The right model for presence, typing indicators, live gauges and progress.
190
+ - Values are lost on server restart until the next publish, and are not available offline.
191
+ - Several API instances need a shared fan-out (the Socket.IO adapter already in use, or Redis)
192
+ and a shared "latest value" store for joins, or a join can miss values published on another
193
+ instance.
194
+ - Largest surface of the five: a server API, a socket event, a client layer, hook changes and
195
+ codegen typing for the ephemeral fields. Apps must move the fields out of their schema.
196
+
197
+ Effect on the measured case: removes the projection writes and their counter updates, about
198
+ half of Shade's oplog volume.
199
+
200
+ ### Option E: order by cluster time instead of a counter (not recommended)
201
+
202
+ Stamp documents with the server's operation time (`$currentDate` with `$type: "timestamp"`) and
203
+ use that as the cursor. No counter document, so one oplog entry per write for every model.
204
+
205
+ Rejected for now: the stable-frontier problem that C1 solved comes back in a harder form
206
+ (timestamps are assigned before commit, so a reader can see a later timestamp before an earlier
207
+ one lands), stream-local ordering turns into global ordering, every stored client cursor needs a
208
+ migration, and scope-move markers and tombstone compaction are both built on per-stream seqs.
209
+ It is a rewrite of the protocol, listed so the comparison is complete.
210
+
211
+ ## Comparison
212
+
213
+ | | Oplog entries per high-churn write | Live resolution | Offline catch-up | Protocol change | Client change | App change |
214
+ |---|---|---|---|---|---|---|
215
+ | Today | 3 | full | exact | | | |
216
+ | A: volatile fields | 1 | full | stale by at most `sequenceEveryMs` | new event | new handler | config only |
217
+ | B: coalescing | 0 for dropped writes, 3 for kept | stepped | exact for what was written | none | none | config only |
218
+ | C: batched confirms | 2 under load | full | exact | none | none | none |
219
+ | D: ephemeral channel | 0 | full | none | new event and join payload | new layer | move fields, new publish call |
220
+ | E: cluster time | 1 | full | exact | rewrite | rewrite | none |
221
+
222
+ ## Recommendation
223
+
224
+ 1. **Ship B first.** It needs no protocol or client work, it is what the affected app has
225
+ already done by hand, and it fixes the alerting problem on its own. Start with
226
+ `minDelta` and `minIntervalMs`, without a trailing flush.
227
+ 2. **Then A**, for fields where stepping is not acceptable. It keeps full live resolution at a
228
+ third of the cost and degrades safely for older clients.
229
+ 3. **C is independent** and worth doing for every model, but it is an optimisation of the hot
230
+ path with a crash-recovery trade-off, so it should come with a load-harness comparison
231
+ (`api/src/sync/loadHarness.ts`) before it is approved.
232
+ 4. **D only if** presence-style features (typing, cursors, live progress) arrive on the roadmap.
233
+ It is the cleanest answer for values that should never be stored, and too much surface to
234
+ build for gauges alone.
235
+
236
+ ## Decisions
237
+
238
+ | Question | Decision |
239
+ |----------|----------|
240
+ | Which options go ahead, and in what order? | Open. Recommendation: B, then A; C separately after a load test; D deferred. |
241
+ | B: does a dropped write need a trailing flush so the stored value is exact at rest? | Open. Recommendation: no in the first version. |
242
+ | B: should a dropped `save()` be silent, or logged at debug level? | Open. Recommendation: debug log with model and paths. |
243
+ | A: where does "last sequenced at" live, a `_syncSeqAt` field or process memory? | Open. Recommendation: `_syncSeqAt`, so it is correct across instances and restarts. |
244
+ | A: default for `sequenceEveryMs` | Open. Recommendation: 60 seconds. |
245
+ | A: may clients mutate volatile fields? | Open. Recommendation: reject in the mutation handler; they are server-written. |
246
+ | C: flush delay, and is a longer post-crash frontier stall acceptable? | Open. Needs load-harness numbers. |
247
+
248
+ ## Phases
249
+
250
+ Written once the decisions above are made. Each chosen option is its own phase with its own
251
+ task list under `docs/tasks/`.
252
+
253
+ ## Files to Create / Modify
254
+
255
+ For B and A, the likely touch points:
256
+
257
+ - `api/src/sync/types.ts`: `SyncConfig.coalesce`, `SyncConfig.volatile`, the `sync:volatile`
258
+ payload type.
259
+ - `api/src/sync/syncSeqPlugin.ts`: extend the no-op check in `pre("save")`; add the same check
260
+ to `preQueryWrite`.
261
+ - `api/src/realtime/changeStreamWatcher.ts`: emit `sync:volatile` for updates that leave
262
+ `_syncSeq` unchanged on a model with volatile fields.
263
+ - `api/src/sync/mutationHandler.ts`: reject client mutations on volatile fields.
264
+ - `syncdb/src/sync/deltaApplier.ts` and `syncdb/src/client.ts`: apply `sync:volatile`.
265
+ - `docs/explanation/local-first-data.md` and `docs/reference/syncdb.md`: document the options
266
+ and when to use each.