@kedem/okdb 1.8.17 → 1.9.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.
Files changed (39) hide show
  1. package/bin/okdb.js +1 -1
  2. package/docs/change-log.md +16 -16
  3. package/docs/embeddings.md +1 -1
  4. package/docs/functions.md +26 -25
  5. package/docs/http-cluster.md +157 -0
  6. package/docs/index.md +1 -0
  7. package/docs/manifest.json +5 -1
  8. package/docs/process-registry.md +185 -0
  9. package/docs/processors.md +61 -65
  10. package/docs/queue.md +23 -13
  11. package/docs/subscriptions.md +230 -0
  12. package/docs/worker-fleet.md +139 -0
  13. package/okdb-functions-runner-child.js +1 -1
  14. package/okdb-functions-sandbox-worker.js +1 -0
  15. package/okdb-http-worker-child.js +1 -0
  16. package/okdb-queue-load-handler.js +1 -0
  17. package/okdb-queue-spawn-child.js +1 -1
  18. package/okdb-views-bootstrap-worker.js +1 -1
  19. package/okdb-worker-child.js +1 -0
  20. package/okdb.js +1 -1
  21. package/package.json +1 -1
  22. package/public/sections/db/parts/db-overview.ok.js +1 -1
  23. package/public/sections/embeddings/parts/pipeline-create-panel.ok.js +1 -1
  24. package/public/sections/queue/parts/queue-jobs.ok.js +1 -1
  25. package/public/sections/system/index.ok.html +1 -1
  26. package/public/sections/system/modals/data-ops-modal.ok.js +1 -1
  27. package/public/sections/system/parts/system-process-panel.ok.js +1 -0
  28. package/public/sections/system/parts/system-processing-panel.ok.js +1 -0
  29. package/public/sections/system/parts/system-runtime-overview.ok.js +1 -0
  30. package/public/sections/system/parts/system-workers-panel.ok.js +1 -0
  31. package/types/environment.d.ts +21 -4
  32. package/types/features/embeddings.d.ts +11 -2
  33. package/types/features/queue.d.ts +27 -5
  34. package/types/features/views.d.ts +10 -10
  35. package/types/index.d.ts +54 -6
  36. package/types/options.d.ts +12 -0
  37. package/okdb-fts-handler.js +0 -1
  38. package/okdb-processor-worker.js +0 -1
  39. package/public/sections/processors/index.ok.html +0 -1
@@ -9,15 +9,29 @@ A **processor** maintains derived state from a source type's change log. You reg
9
9
 
10
10
  OKDB's own features (indexes, views, FTS, materializer, embeddings, time-machine) are built on this primitive — and it's a public extension point you can use directly.
11
11
 
12
+ > **See also:** [Workers](worker-fleet.md) for _where_ a processor's work runs — the worker
13
+ > population, claiming, and scaling. This doc defines _what_ a processor is and its **modes**
14
+ > (the guarantee); worker-fleet.md covers placement.
15
+
12
16
  ---
13
17
 
14
- ## Modes — where and when your handler runs
18
+ ## Modes — the delivery guarantee
19
+
20
+ A mode names the **delivery guarantee**: how many instances run the handler. Handlers are always **closures** that run in **this process** — trusted derived work runs on the main event loop, chunked into cooperative quanta so a large drain never blocks the loop monolithically (see `plans/runtime-model/overview.md`). There is no worker-thread or worker-process placement choice: the only off-loop thread in the system is the function sandbox (for untrusted user code), never trusted processor work.
21
+
22
+ | mode | cardinality | cursor | use when |
23
+ | -------- | ----------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------- |
24
+ | `inline` | every writer, inside commit | — | derived state must be consistent the instant the write returns, and the work is cheap |
25
+ | `single` | exactly one claimant topology-wide | durable (at-least-once) | background derived work drained on the loop; the lease makes it 1-of-N across processes |
26
+ | `fanout` | every registered instance, own loop | ephemeral (resume-from-now) | N-of-N consumers: each instance runs its own copy, effects must be process-local or convergent |
27
+
28
+ > **Deprecated aliases.** `single` and `fanout` were previously named `worker` and `async` (released in 1.8.x). The old names still work as **deprecated aliases** — `register()`/`setMode()` accept them and map to the canonical names, warning once per process. `stop.status().mode` always reports the canonical `single`/`fanout`/`inline`.
15
29
 
16
- | mode | runs | use when |
17
- | -------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
18
- | `inline` | synchronously inside the writer's commit | derived state must be consistent the instant the write returns, and the work is cheap |
19
- | `async` | post-commit, batched, on the main event loop | default for light/moderate derived work |
20
- | `worker` | post-commit, batched, on a **worker thread** | per-event work is CPU-heavy (parse / tokenize / encode / compress) and you want it off the main loop |
30
+ **Cardinality contracts:**
31
+
32
+ - `single` (1-of-N): a cluster-wide lease ensures exactly one process drains the log at a time. Others wait and take over if the holder dies. Use for derived state that must have a single authoritative writer. The drain runs on the holder's main loop, chunked; the cursor advances only on a successful drain (at-least-once → write idempotent handlers).
33
+ - `fanout` (N-of-N): no lease; every registered instance runs independently from its own in-memory cursor (seeded to "now" at registration — no history replay on restart). Use for process-local derived state, or idempotent/convergent effects. `bootstrap` is always `'none'` — fanout processors do not run snapshot bootstraps.
34
+ - `inline` (every writer): runs synchronously inside the commit. **M3 closure advisory**: a plain-closure inline handler is fine for a single writer process, but with multiple writer processes (HTTP cluster workers, fn runners, workers) other instances' writes silently bypass it — `register()` warns once. Declare `definition: { durable: true }` (your code reconstructs the handler on every instance at open — the views/indexes pattern) to silence it. `setMode(…, 'inline')` — which actively creates the multi-writer hazard — hard-rejects plain closures (`PROC_INLINE_REQUIRES_DURABLE_DEFINITION`).
21
35
 
22
36
  ---
23
37
 
@@ -25,7 +39,7 @@ OKDB's own features (indexes, views, FTS, materializer, embeddings, time-machine
25
39
 
26
40
  ```javascript
27
41
  const stop = env.processor.register('Order', {
28
- mode: 'async',
42
+ mode: 'fanout',
29
43
  name: 'order-stats', // shown in admin / status
30
44
  cursorKey: 'order-stats', // durable resume key — unique per processor
31
45
  originMode: 'all', // 'self' | 'remote' | 'all'
@@ -43,87 +57,69 @@ stop.pause();
43
57
  stop.resume();
44
58
  ```
45
59
 
46
- `changes` are change-log records (`{ type, key, action, clock, ... }`). In `async` mode with the default `hydrateValues: true`, `put` records also carry `.value` (the current document). In `worker` mode you hydrate yourself see below.
60
+ The `handler` is always a **closure** (a function). The portable `handler: {module, export}` form which ran on a worker thread — was removed alongside the worker-thread executor; passing it now throws. Move shared handler logic into a module your closure calls (`const apply = require('./my-handler'); handler: (changes, info) => apply(changes, info)`) if you want to reuse it across registrations.
61
+
62
+ `changes` are change-log records (`{ type, key, action, clock, ... }`). With the default `hydrateValues: true`, `put` records also carry `.value` (the current document); otherwise read the document via `info.env.get(c.type, c.key)`.
47
63
 
48
64
  `originMode` filters by who wrote the change: `self` (this instance's writes only), `remote` (other instances/processes), or `all`.
49
65
 
66
+ **`rebuild` (snapshot-bootstrap processors).** With `bootstrap: 'snapshot'` you may pass `rebuild: async () => { ... }` — a feature-owned **full rebuild** (scan the actual docs, then reposition the cursor via `setCursor` + `markSnapshotComplete`). It runs whenever the processor bootstraps with an **un-positioned cursor**: after a cursor reset (the admin ⟲ "full reprocess"), or when a build handoff was interrupted by a crash. Without it, the framework's only option is replaying the changelog from 0 — which is **not** a rebuild: sync-GC prunes the changelog (its horizon ignores processor cursors), so replay-from-0 can silently yield a partial derived store. FTS registers one (re-scans all docs of the type into every index, clearing first).
67
+
50
68
  ---
51
69
 
52
- ## `worker` mode and `workerKey` (sharing)
70
+ ## Snapshot bootstrap and chunked drains
53
71
 
54
- `worker` mode runs your handler on a worker thread. It requires a **resolvable handler module** a file path and export name, **not a closure** (closures can't cross the thread boundary; the worker loads the module in its own isolate):
72
+ A `single` processor with `bootstrap: 'snapshot'` performs a cooperative snapshot scan on the main loop, yielding every batch so the event loop stays responsive while a large type is bootstrapped. Live drains use the same chunked `_flush` (a `FLUSH_QUANTUM` bounds each on-loop quantum, then yields). This is exactly the path FTS uses: every FTS-indexed type registers a `single`-mode processor whose closure handler hydrates + tokenizes + writes postings, drained on the loop in quanta. There is no shared worker thread and no `workerKey` — those were removed in the runtime-model rework.
55
73
 
56
- ```javascript
57
- env.processor.register('Doc', {
58
- mode: 'worker',
59
- workerKey: `myfeature:${env.name}`, // optional — see below
60
- cursorKey: 'myfeature:Doc',
61
- originMode: 'all',
62
- handler: { module: require.resolve('./my-handler.js'), export: 'handle' },
63
- });
64
- ```
74
+ The cursor only advances on a successful drain, so processing is **at-least-once**; make handlers idempotent. Derived state is eventually-consistent; if you need a deterministic "caught up" point, wait on `stop.status().lastClock` reaching the source clock.
65
75
 
66
- **`workerKey` controls sharing:**
76
+ ---
67
77
 
68
- - **Omit it** → a **dedicated** worker for this processor (the key defaults to the processor's own id). Best for isolation, or for genuinely parallel CPU work across separate threads.
69
- - **Set the same key** on several processors → they **share one** worker thread (and one OKDB instance), processing drains serially. This is what FTS does: every FTS-indexed type registers a `worker`-mode processor with `workerKey: 'fts:<env>'`, so the whole env's FTS indexing runs on a single shared worker. All processors sharing a key **must declare the same handler module/export** (validated at registration).
78
+ ## Cross-process behaviour
79
+
80
+ Each processor takes a lease (the `'proc'` lock). With several processes open on the same env, exactly one runs each `single` processor; the others wait and take over if the holder dies. The lease-holding process drains on its own loop, so there is always a single writer to your derived store.
70
81
 
71
- The worker is **lazily spawned** on the first real work, **torn down after `workerIdleMs`** of idle (default 30 s), and **respawns automatically** (the cursor is durable, so no state is lost). If the worker dies or a single drain exceeds `drainTimeoutMs` (default 60 s), the in-flight work is retriedthe cursor only advances on success, so processing is **at-least-once**; make handlers idempotent.
82
+ A processor that hits an error **keeps its lease** (in-place `retry()`/`restart()` keeps its slot against standbys in other processes). Re-bootstrap reuses the held lease handle, and `tryAcquire` treats a fresh lease held by the same pid + processorId as a re-acquire so an errored processor can never deadlock in `waiting` against its own heartbeat.
72
83
 
73
84
  ---
74
85
 
75
- ## Writing a worker handler module
86
+ ## Cookbook
76
87
 
77
- The module loads in a fresh worker isolate, so it must be **self-contained** and **replay any state it needs from durable sources** (disk / the env) in-process registrations made on the main thread are not visible to it.
88
+ - **A background derived indexer (like FTS)** `mode: 'single'`, closure handler, drained on the loop in chunked quanta.
89
+ - **An immediate-consistency derived field** — `mode: 'inline'` (cheap work only; it runs on the write path).
90
+ - **A light aggregate on the main loop** — `mode: 'fanout'`.
78
91
 
79
- ```javascript
80
- // my-handler.js
81
- module.exports = {
82
- // optional — called once at worker startup (open derived sub-DBs, read config, …)
83
- async init({ env }) {
84
- this.store = env.openDB?.('my-derived');
85
- },
92
+ ---
86
93
 
87
- // called per drain; `changes` are RAW (not hydrated) — read values via info.env
88
- async handle(changes, info) {
89
- const env = info.env; // the worker's own OKDBEnv — real reads/writes
90
- for (const c of changes) {
91
- if (c.action === 'put') {
92
- const doc = c.value ?? env.get(c.type, c.key); // hydrate on demand
93
- /* ...update your derived state via env / your sub-DBs... */
94
- } else if (c.action === 'remove') {
95
- /* ...handle deletion... */
96
- }
97
- }
98
- },
99
- };
94
+ ## Runtime mode switching (M6)
100
95
 
101
- // A plain function export also works:
102
- // module.exports = async function handle(changes, info) { ... }
103
- // register with export: 'handle' (or the function's name); it must resolve to a function.
104
- ```
96
+ `env.processor.setMode(logicalKey, mode)` (HTTP: `POST /api/processors/:logicalKey/mode`)
97
+ switches a processor's mode at runtime through a durable config record (registry
98
+ desired-state scope `'procmode'`) + a clock-boundary handoff:
105
99
 
106
- Rules, stated plainly:
100
+ | transition | mechanics |
101
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
+ | `single → fanout` | holder drains to clock C, releases the lease; every instance seeds its in-memory cursor at C |
103
+ | `fanout → single` | instances stop at the flip; durable cursor seeded at max(C, head-at-observance); lease claiming begins |
104
+ | `* → inline` | M3 gate re-checked (closures without a durable definition → rejected); gap-fill to head; a per-write version gate makes every writer observe the flip before its next commit |
105
+ | `inline → *` | inline stops on observance; the deferred cursor starts at the observance clock |
107
106
 
108
- - **Resolvable module + export**, never a closure.
109
- - The worker has its **own OKDB** open on the same path (background features off). It shares the underlying LMDB storage with the main process, so your writes are visible to it — and to readers/queries — immediately on commit.
110
- - **Raw changes**: in worker mode the framework does not pre-hydrate; read `info.env.get(c.type, c.key)` when you need the document.
111
- - **You don't manage the cursor** — the main thread owns it and advances it only when your `handle` resolves. Throwing makes the framework retry the range (at-least-once) → write **idempotent** handlers.
112
- - **Be worker-portable**: read schemas / config / definitions from disk in `init`, not from closures.
107
+ Transitions are serialized per processor (a flip while one is unapplied → `PROC_MODE_FLIP_IN_FLIGHT`).
108
+ Overlap windows double-run the deferred mode the (already required) idempotency contract makes
109
+ that safe. The per-write gate costs one boolean compare per write when no flip is pending.
113
110
 
114
111
  ---
115
112
 
116
- ## Cross-process behaviour
117
-
118
- Each processor takes a lease (the `'proc'` lock). With several processes open on the same env, exactly one runs each processor; the others wait and take over if the holder dies. Worker sharing is per-process — the lease-holding process's worker does the work, so there is still a single writer to your derived store.
113
+ ## Instance processing policy
119
114
 
120
- ---
115
+ The `processing` constructor option controls where **this instance** drains processors. It is independent of the mode declared at `register()`.
121
116
 
122
- ## Cookbook
117
+ | value | behavior |
118
+ | -------- | ----------------------------------------------------------------------------------------------------------------------- |
119
+ | `'auto'` | **default.** Self-process on the loop when no workers are configured; yield to the population when desired workers > 0. |
120
+ | `'main'` | Self-process on the main event loop (diagnostic / single-process environments). |
121
+ | `'none'` | Never drain (passive/read-only instances). Used internally for HTTP cluster workers. |
123
122
 
124
- - **A dedicated CPU-heavy indexer** `mode: 'worker'`, no `workerKey`.
125
- - **Several indexers sharing one thread/OKDB (like FTS)** — `mode: 'worker'`, same `workerKey`.
126
- - **An immediate-consistency derived field** — `mode: 'inline'` (cheap work only; it runs on the write path).
127
- - **A light aggregate on the main loop** — `mode: 'async'`.
123
+ > **Deprecated:** `processing: 'threads'` / `'processes'` (the old worker-thread/process pool) are accepted with a one-time deprecation warning and mapped to `'auto'` the pool executor was removed in the runtime-model rework; trusted drains run on the loop.
128
124
 
129
- > Note: a `worker` processor adds first-index latency after idle (the lazy OKDB spawn a few hundred ms). Derived state is eventually-consistent; if you need a deterministic "caught up" point, wait on `stop.status().lastClock` reaching the source clock.
125
+ `'auto'` is the recommended default for application code. When a population is configured via `db.workers.ensure()`, `'auto'` instances automatically stop claiming leases so population workers drain instead no configuration change required at the embedder.
package/docs/queue.md CHANGED
@@ -22,7 +22,10 @@ async function main() {
22
22
  const okdb = new OKDB('./db');
23
23
  await okdb.open();
24
24
 
25
- const worker = okdb.queue.worker(
25
+ // process() runs the consumer IN THIS process (it takes a closure). For a consumer that
26
+ // runs on the worker population or a dedicated child, use worker(type, module) /
27
+ // spawn(type, module) — those cross a process boundary, so they take a module path.
28
+ const worker = okdb.queue.process(
26
29
  'send-email',
27
30
  async (payload, ctx) => {
28
31
  await ctx.markProgress('sending email');
@@ -37,7 +40,6 @@ async function main() {
37
40
  },
38
41
  {
39
42
  concurrency: 4,
40
- pollInterval: 100,
41
43
  ttl: 30_000,
42
44
  },
43
45
  );
@@ -142,12 +144,12 @@ Notes:
142
144
 
143
145
  ---
144
146
 
145
- ## Processing jobs with `worker(...)`
147
+ ## Processing jobs with `process(...)`
146
148
 
147
- Use a normal in-process worker like this:
149
+ Use a normal in-process consumer like this:
148
150
 
149
151
  ```javascript
150
- const worker = okdb.queue.worker(
152
+ const worker = okdb.queue.process(
151
153
  'send-email',
152
154
  async (payload, ctx) => {
153
155
  await ctx.markProgress('validating');
@@ -165,7 +167,6 @@ const worker = okdb.queue.worker(
165
167
  },
166
168
  {
167
169
  concurrency: 2,
168
- pollInterval: 250,
169
170
  ttl: 30_000,
170
171
  onPermanentFail: async (job, err) => {
171
172
  console.error('permanently failed', job.id, err.message);
@@ -197,22 +198,31 @@ These local worker helpers now map to the same public queue API that remote work
197
198
  - `okdb.queue.markJobComplete(jobId, claimId, result?)`
198
199
  - `okdb.queue.markJobFail(jobId, claimId, error, code?)`
199
200
 
200
- ### Important note
201
+ ### Placement: `process` vs `worker` vs `spawn`
201
202
 
202
- `worker(...)` runs in the **same Node process** unless you explicitly use `spawn(...)` or a separate worker process yourself.
203
+ The three verbs split on **where the consumer runs** and therefore on whether they take a closure or a module:
203
204
 
204
- That means:
205
+ | Verb | Runs in | 2nd arg | Lifetime |
206
+ | ---------------------------- | ------------------------------------------- | -------------------------------------- | -------------------------- |
207
+ | `queue.process(type, fn)` | **this** Node process | a **closure** | dies with this instance |
208
+ | `queue.worker(type, module)` | the **worker population** (other processes) | a **module path** / `{module, export}` | durable; the fleet runs it |
209
+ | `queue.spawn(type, module)` | a **dedicated forked child** | a **module path** | tied to the child |
210
+
211
+ A closure can't cross a process boundary, so `worker()`/`spawn()` require a module. Passing a closure to `worker()` is an error that points you at `process()`.
205
212
 
206
- - great for I/O-heavy jobs
207
- - not isolation from CPU-heavy work
213
+ `process(...)` is great for I/O-heavy jobs but gives no isolation from CPU-heavy work — use `spawn()` (dedicated child) or `worker()` (population) for that.
208
214
 
209
215
  ---
210
216
 
211
217
  ## Worker options
212
218
 
219
+ ### No polling — event-driven wakeup
220
+
221
+ A consumer does **not** poll. An idle lane sleeps on a wake handle and resumes immediately when work appears: a local `enqueue` (same process), a job finishing (frees a `max_concurrency` slot), or a cross-process queue write (the UDP bus POKE). The only timer is a long **backstop** (default 30 s, `OKDB_QUEUE_IDLE_MS`) that exists solely to cover a dropped bus POKE and to wake for time-scheduled jobs — the lane caps its sleep at the soonest future `when`, so delayed jobs still fire on time.
222
+
213
223
  ### `pollInterval`
214
224
 
215
- How long to sleep when the queue is empty.
225
+ Optional. When set, it **caps the idle backstop** for this consumer (an upper bound on wakeup latency if a bus POKE is ever dropped). It is no longer a poll cadence — leave it unset to get the default 30 s backstop with instant event-driven pickup.
216
226
 
217
227
  ```javascript
218
228
  {
@@ -363,7 +373,7 @@ When a running job's claim expires, reconciliation moves it back to `pending`.
363
373
  For long-running jobs, call `ctx.heartbeat()` periodically:
364
374
 
365
375
  ```javascript
366
- okdb.queue.worker(
376
+ okdb.queue.process(
367
377
  'import',
368
378
  async (payload, ctx) => {
369
379
  for (let i = 0; i < 10; i++) {
@@ -0,0 +1,230 @@
1
+ # Live Subscriptions
2
+
3
+ Live subscriptions let a client watch a data environment for changes in real time over
4
+ Server-Sent Events (SSE). OKDB uses a **signal-SSE + durable-session** model: the SSE
5
+ stream carries only lightweight **signals** — never document data — and the client
6
+ refetches the truth it needs from the normal query API.
7
+
8
+ ## The two-domain rule at the client edge
9
+
10
+ OKDB internally separates the lossy **signal** plane (the UDP bus) from the durable
11
+ **data** plane (LMDB). Subscriptions apply the same split to the client connection:
12
+
13
+ - **SSE carries signals only** — `{ type, key, op, clock }`. No `value`, no `prevValue`.
14
+ - **Data comes from the query API** — on a signal, the client refetches the affected
15
+ key(s)/queries (`get`/`query`, which already solve filter/sort/limit/pagination) and
16
+ reconciles its local view.
17
+ - **The subscription is a durable, shared resource** — it lives in a dedicated `~sub`
18
+ environment (`sync:false`), reachable by every process on the same LMDB path. The
19
+ connection-owning worker is not a single point of truth.
20
+ - **The client owns its view** (the matched set). The server never stores or maintains it.
21
+
22
+ This makes subscriptions correct under clustering and multiple processes by construction
23
+ (shared LMDB + path-local POKE), with no cross-worker IPC routing.
24
+
25
+ ## Endpoints
26
+
27
+ All routes are under `/api/<dataEnv>/subscriptions`, where `<dataEnv>` is the real data
28
+ environment whose changes you want to watch.
29
+
30
+ | Method | Path | Purpose |
31
+ | -------- | -------------------------------------- | ------------------------------------------------- |
32
+ | `GET` | `/subscriptions?type=…&filter=…&sub=…` | Open the signal SSE stream; mint/accept a session |
33
+ | `POST` | `/subscriptions/:id/interests` | Add/remove key interests (`{ add?, remove? }`) |
34
+ | `PUT` | `/subscriptions/:id/interests` | Replace the full interest set (`{ interests }`) |
35
+ | `POST` | `/subscriptions/:id/filter` | Set the enter-discovery filter (`{ filter }`) |
36
+ | `DELETE` | `/subscriptions/:id` | Close the session (interests cascade) |
37
+
38
+ `:id` is the `sessionId` issued in the first SSE frame.
39
+
40
+ ## The signal stream
41
+
42
+ `GET /api/<dataEnv>/subscriptions` opens an SSE stream:
43
+
44
+ 1. **Open frame** — the server mints a `sessionId` (or accepts `?sub=<id>` to reuse one),
45
+ persists a durable `~sub` `session` record, and sends the id as the first frame:
46
+
47
+ ```
48
+ event: subscription:open
49
+ data: { "sessionId": "…", "env": "default", "type": "Order", "filter": null, "reconnect": false }
50
+ ```
51
+
52
+ 2. **Signals** — every change matching the session's interests/filter arrives as:
53
+
54
+ ```
55
+ id: <clock>
56
+ event: signal
57
+ data: { "type": "Order", "key": "o-42", "op": "put", "clock": 1234 }
58
+ ```
59
+
60
+ Signals never carry document data. The client refetches `Order/o-42` and updates its
61
+ view. `op` is `put` or `remove`; `clock` is the per-`(env,type)` changelog clock.
62
+
63
+ 3. **Heartbeat / liveness** — a periodic beat (default 30s) refreshes the session's TTL
64
+ (default 90s, skip-if-far so most beats are free) and re-checks `session.version` to
65
+ pick up control changes. A `: ping` SSE keepalive rides the same beat.
66
+
67
+ 4. **Disconnect** — a clean disconnect deletes the `session` (its `interest` rows cascade
68
+ via FK `onDelete`). An unclean drop is reaped by TTL. Nothing leaks.
69
+
70
+ ## Interests: keys vs. filter
71
+
72
+ A session expresses what it tracks two ways (combine as needed):
73
+
74
+ - **Explicit key interests** — for the keys the client currently shows. Register on enter,
75
+ deregister on leave. Best for small/specific sets; there is a configurable cap on the
76
+ number of explicit keys — push large/dynamic sets to a filter instead.
77
+ - **A declarative filter** — a `sift` query on the type. The router does **forward-only**
78
+ enter-discovery: when a change to the type matches the filter and the session isn't
79
+ already tracking that key, it signals an **enter candidate**.
80
+
81
+ Filters are declarative objects only (never functions — injection-safe), the same matcher
82
+ items/views/FTS use.
83
+
84
+ ## Control plane (no IPC)
85
+
86
+ Control is plain REST keyed by `sessionId`. Each control call is a **durable write to the
87
+ `~sub` env plus a monotonic `version++`, committed atomically in one transaction**. The
88
+ write emits a path-local `SYSTEM_POKE`, which the owning connection catches as a latency
89
+ fast path; its heartbeat `version`-check is the correctness backstop, so a dropped POKE
90
+ self-heals within one beat (bounded staleness). Any path-local node can serve a control
91
+ write — the durable record is the single source of truth, so **no cross-worker IPC** is
92
+ needed.
93
+
94
+ ```
95
+ POST /api/<dataEnv>/subscriptions/<id>/interests { "add": [{ "type": "Order", "key": "o-42" }] }
96
+ PUT /api/<dataEnv>/subscriptions/<id>/interests { "interests": [{ "type": "Order", "key": "o-42" }] }
97
+ POST /api/<dataEnv>/subscriptions/<id>/filter { "filter": { "status": "open" } }
98
+ DELETE /api/<dataEnv>/subscriptions/<id>
99
+ ```
100
+
101
+ Unknown `sessionId` → `404`.
102
+
103
+ ## Reconnect = resync
104
+
105
+ When a client reconnects with a still-live `?sub=<id>`, the server re-attaches to the same
106
+ durable session and emits one control frame:
107
+
108
+ ```
109
+ event: subscription:resync
110
+ data: { "sessionId": "…", "version": 7 }
111
+ ```
112
+
113
+ On `resync` the client re-runs its queries against current truth and **PUT-replaces** its
114
+ full interest set (`PUT …/interests`). Replace, not append: this drops any stale
115
+ pre-disconnect interests so they can't mis-route (the server also backstop-clears the
116
+ interest set on reconnect to make additive clients safe). There is no persisted cursor
117
+ anywhere — reconnect is always a resync to current state.
118
+
119
+ A brand-new or TTL-expired `sessionId` is a normal fresh open (no `resync`). Cross-machine
120
+ continuity is intentionally not provided — a client that reconnects to a different machine
121
+ re-registers and resyncs.
122
+
123
+ ## Convergence, not per-transition
124
+
125
+ The data change-feed is **coalesced current-state** for cross-process writes, so
126
+ intermediate enter/leave transitions can be skipped. v2 guarantees **convergence to current
127
+ state**, not a per-transition event log: the client only ever learns "something changed,
128
+ here is the key," refetches, and reconciles. Design your client to be idempotent under
129
+ coalesced signals.
130
+
131
+ ## Reference client
132
+
133
+ ```js
134
+ // Minimal browser client. Holds a sessionId, refetches on each signal, and
135
+ // PUT-replaces its interest set on (re)connect / resync.
136
+ function subscribe(dataEnv, { type, filter } = {}) {
137
+ const view = new Map(); // key -> doc (the client owns its matched set)
138
+ let sessionId = sessionStorage.getItem('subId') || crypto.randomUUID();
139
+ sessionStorage.setItem('subId', sessionId);
140
+
141
+ const qs = new URLSearchParams({ sub: sessionId });
142
+ if (type) qs.set('type', type);
143
+ if (filter) qs.set('filter', JSON.stringify(filter));
144
+ const es = new EventSource(`/api/${dataEnv}/subscriptions?${qs}`);
145
+
146
+ // Re-declare the full interest set (PUT-replace, not append — race-safe on reconnect).
147
+ async function resync() {
148
+ const docs = await fetch(`/api/${dataEnv}/types/${type}/query`, {
149
+ method: 'POST',
150
+ headers: { 'content-type': 'application/json' },
151
+ body: JSON.stringify({ filter: filter || {} }),
152
+ }).then((r) => r.json());
153
+
154
+ view.clear();
155
+ for (const d of docs.items) view.set(d.key, d.value);
156
+
157
+ await fetch(`/api/${dataEnv}/subscriptions/${sessionId}/interests`, {
158
+ method: 'PUT',
159
+ headers: { 'content-type': 'application/json' },
160
+ body: JSON.stringify({ interests: [...view.keys()].map((key) => ({ type, key })) }),
161
+ });
162
+ render(view);
163
+ }
164
+
165
+ es.addEventListener('subscription:open', (e) => {
166
+ sessionId = JSON.parse(e.data).sessionId; // accept the server's id
167
+ sessionStorage.setItem('subId', sessionId);
168
+ resync(); // initial set = a client query
169
+ });
170
+
171
+ // Reconnect → re-run queries + PUT-replace interests.
172
+ es.addEventListener('subscription:resync', () => resync());
173
+
174
+ // Signal → refetch the one key, reconcile the local view.
175
+ es.addEventListener('signal', async (e) => {
176
+ const { key } = JSON.parse(e.data);
177
+ const res = await fetch(`/api/${dataEnv}/types/${type}/${key}`);
178
+ if (res.status === 404) {
179
+ view.delete(key); // leave: deregister the interest
180
+ await control('POST', { remove: [{ type, key }] });
181
+ } else {
182
+ const doc = await res.json();
183
+ if (matches(doc.value, filter)) {
184
+ if (!view.has(key)) await control('POST', { add: [{ type, key }] }); // enter
185
+ view.set(key, doc.value);
186
+ } else if (view.has(key)) {
187
+ view.delete(key); // edited out → leave
188
+ await control('POST', { remove: [{ type, key }] });
189
+ }
190
+ }
191
+ render(view);
192
+ });
193
+
194
+ function control(method, body) {
195
+ return fetch(`/api/${dataEnv}/subscriptions/${sessionId}/interests`, {
196
+ method,
197
+ headers: { 'content-type': 'application/json' },
198
+ body: JSON.stringify(body),
199
+ });
200
+ }
201
+
202
+ return () => es.close(); // DELETE …/:id also closes server-side; close() lets TTL reap.
203
+ }
204
+ ```
205
+
206
+ Key contract points the client must honour:
207
+
208
+ - **Register before refetch** on an enter candidate — register the provisional interest
209
+ first, then refetch; if the doc no longer matches, deregister and don't show it. This
210
+ closes the race where a change arrives in the refetch window.
211
+ - **Deregister on leave** — when a key leaves the view, remove its interest.
212
+ - **PUT-replace on reconnect/resync** — re-declare the whole set; never additively
213
+ re-register.
214
+ - **Tolerate coalesced signals** — refetch is idempotent; the view converges to current
215
+ state.
216
+
217
+ ## Storage (`~sub` env)
218
+
219
+ Subscription state lives in a dedicated environment named `~sub`, opened `sync:false`:
220
+
221
+ - **`session`** — `{ sessionId, dataEnv, type, filter?, version, createdAt, expiresAt }`,
222
+ with per-doc TTL on `expiresAt`.
223
+ - **`interest`** — `{ id, sessionId, type, key }`, with a `ref sessionId → session`,
224
+ `onDelete: cascade`, and a reverse index on `[type, key]` for O(interested sessions)
225
+ routing.
226
+
227
+ The `~sub` _env_ name starts with `~` so its writes stay out of the data change-feed, while
228
+ the non-`~` type names keep FK cascade and TTL working. `sync:false` keeps machine-bound
229
+ session/heartbeat churn off replication; the path-local POKE still fires for control
230
+ re-notify. The `~sub` env has no changelog (it is read by key/index, never tailed).