@kedem/okdb 1.8.18 → 1.9.1
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/bin/okdb.js +1 -1
- package/docs/change-log.md +16 -16
- package/docs/embeddings.md +1 -1
- package/docs/functions.md +26 -25
- package/docs/http-cluster.md +157 -0
- package/docs/index.md +1 -0
- package/docs/manifest.json +5 -1
- package/docs/process-registry.md +185 -0
- package/docs/processors.md +61 -65
- package/docs/queue.md +23 -13
- package/docs/subscriptions.md +230 -0
- package/docs/worker-fleet.md +139 -0
- package/okdb-functions-runner-child.js +1 -1
- package/okdb-functions-sandbox-worker.js +1 -0
- package/okdb-http-worker-child.js +1 -0
- package/okdb-queue-load-handler.js +1 -0
- package/okdb-queue-spawn-child.js +1 -1
- package/okdb-views-bootstrap-worker.js +1 -1
- package/okdb-worker-child.js +1 -0
- package/okdb.js +1 -1
- package/package.json +1 -1
- package/public/sections/db/parts/db-overview.ok.js +1 -1
- package/public/sections/embeddings/parts/pipeline-create-panel.ok.js +1 -1
- package/public/sections/queue/parts/queue-jobs.ok.js +1 -1
- package/public/sections/system/index.ok.html +1 -1
- package/public/sections/system/modals/data-ops-modal.ok.js +1 -1
- package/public/sections/system/parts/system-process-panel.ok.js +1 -0
- package/public/sections/system/parts/system-processing-panel.ok.js +1 -0
- package/public/sections/system/parts/system-runtime-overview.ok.js +1 -0
- package/public/sections/system/parts/system-workers-panel.ok.js +1 -0
- package/types/environment.d.ts +21 -4
- package/types/features/embeddings.d.ts +11 -2
- package/types/features/queue.d.ts +27 -5
- package/types/features/views.d.ts +10 -10
- package/types/index.d.ts +54 -6
- package/types/options.d.ts +12 -0
- package/okdb-fts-handler.js +0 -1
- package/okdb-processor-worker.js +0 -1
- package/public/sections/processors/index.ok.html +0 -1
package/docs/processors.md
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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: '
|
|
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
|
-
`
|
|
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
|
-
##
|
|
70
|
+
## Snapshot bootstrap and chunked drains
|
|
53
71
|
|
|
54
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
---
|
|
67
77
|
|
|
68
|
-
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
86
|
+
## Cookbook
|
|
76
87
|
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
147
|
+
## Processing jobs with `process(...)`
|
|
146
148
|
|
|
147
|
-
Use a normal in-process
|
|
149
|
+
Use a normal in-process consumer like this:
|
|
148
150
|
|
|
149
151
|
```javascript
|
|
150
|
-
const worker = okdb.queue.
|
|
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
|
-
###
|
|
201
|
+
### Placement: `process` vs `worker` vs `spawn`
|
|
201
202
|
|
|
202
|
-
|
|
203
|
+
The three verbs split on **where the consumer runs** — and therefore on whether they take a closure or a module:
|
|
203
204
|
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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).
|