@omg-dev/server 0.4.24

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.
@@ -0,0 +1,654 @@
1
+ // ── Subscriptions ────────────────────────────────────────────────────────────
2
+ //
3
+ // Per-client, per-query reactive state. Where broker.ts broadcasts a single
4
+ // invalidate signal to every SSE viewer regardless of what they care about,
5
+ // this module tracks {client → set of subscriptions} and on a write only
6
+ // re-pushes data to subscribers of the affected collection.
7
+ //
8
+ // Phase 4 scope: sequence numbers + reconnect resume. Every snapshot and
9
+ // every delta carries a monotonic global `seq`; subscribers keep their
10
+ // last-seen seq and send it as `resumeFromSeq` when reconnecting. Each
11
+ // collection holds a small ring buffer of recent (oldRowRaw, newRowRaw)
12
+ // write events — on resume, the server replays only the events whose seq
13
+ // is > resumeFromSeq, re-evaluated through the resuming sub's predicate.
14
+ // If the gap exceeds the ring (~256 entries by default), the server
15
+ // falls back to a full snapshot. The end of a successful replay is
16
+ // signalled by a `resumed` frame so the client knows it's caught up; a
17
+ // snapshot frame tells the client to replace its local state.
18
+ //
19
+ // Transport-agnostic by design: a SubClient is just `{ send, readyState,
20
+ // ctx }`. The vite-plugin wires Bun's native WebSocket in prod and the `ws`
21
+ // package in dev to that shape.
22
+
23
+ import type { Schema } from "@omg-dev/schema"
24
+ import { getDbInstance } from "./db.ts"
25
+ import { decodeRow } from "./codec.ts"
26
+ import {
27
+ validatePredicate,
28
+ compileToSql,
29
+ compileToJs,
30
+ PredicateValidationError,
31
+ type Predicate,
32
+ } from "./predicate.ts"
33
+
34
+ // ── Wire protocol ────────────────────────────────────────────────────────────
35
+
36
+ export type SubClientMessage =
37
+ | {
38
+ op: "sub"
39
+ subId: string
40
+ collection: string
41
+ /** Optional JSON predicate AST — narrows the snapshot. See predicate.ts. */
42
+ where?: Predicate
43
+ /**
44
+ * If provided, attempt to resume from the given monotonic seq instead
45
+ * of issuing a fresh snapshot. The server replays only events whose
46
+ * seq is > resumeFromSeq; if the gap exceeds the ring buffer it
47
+ * falls back to a snapshot. The client tells the two apart by the
48
+ * frame type it gets next (snapshot vs resumed).
49
+ */
50
+ resumeFromSeq?: number
51
+ }
52
+ | { op: "unsub"; subId: string }
53
+
54
+ export type SubServerMessage =
55
+ | { type: "snapshot"; subId: string; collection: string; seq: number; rows: Record<string, unknown>[] }
56
+ | { type: "delta"; subId: string; collection: string; seq: number; op: "insert"; row: Record<string, unknown> }
57
+ | { type: "delta"; subId: string; collection: string; seq: number; op: "update"; row: Record<string, unknown> }
58
+ | { type: "delta"; subId: string; collection: string; seq: number; op: "delete"; id: string }
59
+ | { type: "resumed"; subId: string; collection: string; fromSeq: number; toSeq: number; replayed: number }
60
+ | { type: "error"; subId?: string; code: string; message: string }
61
+
62
+ // ── Client adapter shape ─────────────────────────────────────────────────────
63
+
64
+ export interface SubClient {
65
+ /** Send a single JSON-stringified frame to this client. */
66
+ send: (data: string) => void
67
+ /** WebSocket readyState convention: 1 = open, 3 = closed. */
68
+ readyState: number
69
+ /** Auth context captured at upgrade time. Reused on every snapshot. */
70
+ ctx: { userId: string | null; userEmail?: string; userName?: string; appId?: string }
71
+ }
72
+
73
+ interface ClientSub {
74
+ subId: string
75
+ collection: string
76
+ /** The predicate AST as sent on the wire (kept for echo / Inspect). */
77
+ predicate: Predicate | null
78
+ /** Pre-compiled JS row matcher — used by Phase 3 delta computation. */
79
+ predicateJs: ((row: Record<string, unknown>) => boolean) | null
80
+ }
81
+
82
+ // ── State ────────────────────────────────────────────────────────────────────
83
+
84
+ // client → (subId → sub)
85
+ const clients = new Map<SubClient, Map<string, ClientSub>>()
86
+ // collection → set of clients with at least one sub on it (for fast fan-out)
87
+ const subsByCollection = new Map<string, Set<SubClient>>()
88
+ // Schema is injected by createVibesServer + reload paths. Subscriptions are
89
+ // rejected when no schema is set or when the collection isn't declared —
90
+ // without this, the SUB frame's `collection` string would be interpolated
91
+ // straight into a SELECT (SQL injection vector).
92
+ let currentSchema: Schema | null = null
93
+
94
+ export function setSubscriptionSchema(schema: Schema | null): void {
95
+ currentSchema = schema
96
+ }
97
+
98
+ // ── Sequence numbers + per-collection ring buffer (Phase 4) ──────────────────
99
+ //
100
+ // `seqCounter` is module-global so deltas across collections have a strict
101
+ // total order — easier reasoning for clients and matches the way the wire
102
+ // protocol identifies "fresh writes" (any frame with seq > lastSeen).
103
+ //
104
+ // `ringByCollection` caches the raw write events so a reconnecting sub can
105
+ // replay just the deltas it missed instead of re-snapshotting the whole
106
+ // collection. Cap is RING_CAP per collection; once exceeded, the oldest
107
+ // entry is dropped. A resume request that would dip below the ring's
108
+ // oldest seq falls back to a fresh snapshot.
109
+
110
+ interface RingEntry {
111
+ seq: number
112
+ kind: "insert" | "update" | "delete"
113
+ oldRowRaw: Record<string, unknown> | null
114
+ newRowRaw: Record<string, unknown> | null
115
+ }
116
+
117
+ let seqCounter = 0
118
+ const ringByCollection = new Map<string, RingEntry[]>()
119
+ let RING_CAP = 256
120
+
121
+ // Hardening (Phase 6): per-connection subscription quota. Without a cap a
122
+ // hostile or buggy client could open one WS and register thousands of
123
+ // subs, each holding state, predicate evaluators, and ring-buffer
124
+ // traversal cost. Default sized to "comfortably more than any real
125
+ // dashboard needs" — a single page rendering 5-10 lists is normal.
126
+ let MAX_SUBS_PER_CLIENT = 64
127
+
128
+ export function setMaxSubsPerClient(n: number): void {
129
+ if (!Number.isInteger(n) || n < 1) throw new Error("max subs per client must be a positive integer")
130
+ MAX_SUBS_PER_CLIENT = n
131
+ }
132
+
133
+ /** Test/operator hook: override the per-collection ring cap. */
134
+ export function setSubscriptionRingCap(cap: number): void {
135
+ if (!Number.isInteger(cap) || cap < 1) throw new Error("ring cap must be positive integer")
136
+ RING_CAP = cap
137
+ }
138
+
139
+ function nextSeq(): number {
140
+ seqCounter += 1
141
+ return seqCounter
142
+ }
143
+
144
+ function pushRing(collection: string, entry: RingEntry): void {
145
+ let ring = ringByCollection.get(collection)
146
+ if (!ring) { ring = []; ringByCollection.set(collection, ring) }
147
+ ring.push(entry)
148
+ if (ring.length > RING_CAP) ring.splice(0, ring.length - RING_CAP)
149
+ }
150
+
151
+ export function addSubClient(client: SubClient): void {
152
+ if (clients.has(client)) return
153
+ clients.set(client, new Map())
154
+ }
155
+
156
+ export function removeSubClient(client: SubClient): void {
157
+ const subs = clients.get(client)
158
+ if (!subs) return
159
+ for (const sub of subs.values()) {
160
+ const set = subsByCollection.get(sub.collection)
161
+ if (set) {
162
+ set.delete(client)
163
+ if (set.size === 0) subsByCollection.delete(sub.collection)
164
+ }
165
+ }
166
+ clients.delete(client)
167
+ }
168
+
169
+ export function subClientCount(): number {
170
+ return clients.size
171
+ }
172
+
173
+ export function subscriptionCount(collection?: string): number {
174
+ if (collection) return subsByCollection.get(collection)?.size ?? 0
175
+ let n = 0
176
+ for (const subs of clients.values()) n += subs.size
177
+ return n
178
+ }
179
+
180
+ // ── Inbound message dispatch ─────────────────────────────────────────────────
181
+
182
+ export async function handleSubMessage(client: SubClient, raw: string): Promise<void> {
183
+ let msg: SubClientMessage
184
+ try {
185
+ msg = JSON.parse(raw) as SubClientMessage
186
+ } catch {
187
+ sendError(client, undefined, "bad_json", "message is not valid JSON")
188
+ return
189
+ }
190
+ if (!msg || typeof msg !== "object" || !("op" in msg)) {
191
+ sendError(client, undefined, "bad_shape", "missing op field")
192
+ return
193
+ }
194
+
195
+ if (msg.op === "sub") {
196
+ if (!msg.subId || typeof msg.subId !== "string") {
197
+ sendError(client, undefined, "bad_subId", "subId required")
198
+ return
199
+ }
200
+ if (!msg.collection || typeof msg.collection !== "string") {
201
+ sendError(client, msg.subId, "bad_collection", "collection required")
202
+ return
203
+ }
204
+ const subs = clients.get(client)
205
+ if (!subs) {
206
+ sendError(client, msg.subId, "client_not_registered", "client not registered — call addSubClient first")
207
+ return
208
+ }
209
+ if (subs.has(msg.subId)) {
210
+ sendError(client, msg.subId, "duplicate_subId", "subId already in use on this client")
211
+ return
212
+ }
213
+ if (subs.size >= MAX_SUBS_PER_CLIENT) {
214
+ sendError(client, msg.subId, "sub_limit_exceeded", `client has reached the subscription cap (${MAX_SUBS_PER_CLIENT})`)
215
+ return
216
+ }
217
+ // Collection must exist in the registered schema. This both whitelists
218
+ // the table name for safe SQL interpolation and surfaces typos.
219
+ if (!currentSchema || !currentSchema.collections[msg.collection]) {
220
+ sendError(client, msg.subId, "unknown_collection", `no such collection: ${msg.collection}`)
221
+ return
222
+ }
223
+ // Eager auth check for scoped collections — without this, a sub on a
224
+ // scoped collection from an anonymous client would get registered
225
+ // (and accumulate fan-out work) before the snapshot path errored.
226
+ // Phase 3 makes that visible because deltas would otherwise still be
227
+ // evaluated against the dangling sub.
228
+ const colDef = currentSchema.collections[msg.collection]
229
+ if (colDef.scope === "user" && !client.ctx.userId) {
230
+ sendError(client, msg.subId, "auth_required", `scoped collection "${msg.collection}" requires an authenticated user`)
231
+ return
232
+ }
233
+ // Predicate is optional. When present, validate before storing.
234
+ let predicate: Predicate | null = null
235
+ let predicateJs: ((row: Record<string, unknown>) => boolean) | null = null
236
+ if (msg.where !== undefined) {
237
+ try {
238
+ validatePredicate(msg.where)
239
+ predicate = msg.where
240
+ predicateJs = compileToJs(msg.where)
241
+ } catch (err) {
242
+ if (err instanceof PredicateValidationError) {
243
+ sendError(client, msg.subId, "bad_predicate", err.message)
244
+ } else {
245
+ sendError(client, msg.subId, "bad_predicate", (err as Error).message)
246
+ }
247
+ return
248
+ }
249
+ }
250
+ const sub: ClientSub = {
251
+ subId: msg.subId,
252
+ collection: msg.collection,
253
+ predicate,
254
+ predicateJs,
255
+ }
256
+ subs.set(msg.subId, sub)
257
+ let set = subsByCollection.get(msg.collection)
258
+ if (!set) { set = new Set(); subsByCollection.set(msg.collection, set) }
259
+ set.add(client)
260
+
261
+ // Resume path: if the client supplied resumeFromSeq AND the ring still
262
+ // has every event since then, replay them. Otherwise (no ring entries
263
+ // since that seq, or seq predates the ring's oldest entry) fall back
264
+ // to a fresh snapshot — same as the no-resume case.
265
+ if (
266
+ typeof msg.resumeFromSeq === "number" &&
267
+ msg.resumeFromSeq >= 0 &&
268
+ canResumeFrom(msg.collection, msg.resumeFromSeq)
269
+ ) {
270
+ await sendResumeReplay(client, sub, msg.resumeFromSeq)
271
+ return
272
+ }
273
+ await sendSnapshot(client, sub)
274
+ return
275
+ }
276
+
277
+ if (msg.op === "unsub") {
278
+ const subs = clients.get(client)
279
+ if (!subs) return
280
+ const sub = subs.get(msg.subId)
281
+ if (!sub) return
282
+ subs.delete(msg.subId)
283
+ // Only drop the client from the collection fan-out set when no other
284
+ // subscription on this client still targets the same collection.
285
+ let stillOnCollection = false
286
+ for (const remaining of subs.values()) {
287
+ if (remaining.collection === sub.collection) { stillOnCollection = true; break }
288
+ }
289
+ if (!stillOnCollection) {
290
+ const set = subsByCollection.get(sub.collection)
291
+ if (set) {
292
+ set.delete(client)
293
+ if (set.size === 0) subsByCollection.delete(sub.collection)
294
+ }
295
+ }
296
+ return
297
+ }
298
+
299
+ sendError(client, undefined, "unknown_op", `unknown op: ${(msg as { op: string }).op}`)
300
+ }
301
+
302
+ // ── Fan-out on write ─────────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * Push a per-row delta to every subscriber of `collection`. Auto-crud
306
+ * invokes this with raw (storage-shape) old/new rows; we decode through
307
+ * the schema once and then run each sub's predicate against the decoded
308
+ * row to compute the membership transition.
309
+ *
310
+ * Pass `oldRow=null` for inserts, `newRow=null` for deletes. For an update
311
+ * pass both; if a sub's predicate flips one direction we emit insert /
312
+ * delete deltas (the row entered or left the read set), if it stays on
313
+ * both sides we emit update.
314
+ *
315
+ * Returns a Promise (same pattern as notifyCollectionChange) so callers
316
+ * can fire-and-forget while tests await for determinism.
317
+ */
318
+ export function notifyRowChange(
319
+ collection: string,
320
+ kind: "insert" | "update" | "delete",
321
+ oldRowRaw: Record<string, unknown> | null,
322
+ newRowRaw: Record<string, unknown> | null,
323
+ ): Promise<void> {
324
+ if (!currentSchema) return Promise.resolve()
325
+ const col = currentSchema.collections[collection]
326
+ if (!col) return Promise.resolve()
327
+
328
+ // Always bump seq + record in the ring BEFORE the subscriber-set check.
329
+ // A sub that subscribes a moment later may try to resume from a seq
330
+ // earlier than this write — the ring entry is how it catches up.
331
+ const seq = nextSeq()
332
+ pushRing(collection, { seq, kind, oldRowRaw, newRowRaw })
333
+
334
+ const set = subsByCollection.get(collection)
335
+ if (!set || set.size === 0) return Promise.resolve()
336
+
337
+ // Decode once per write — cheaper than per-subscriber, and the predicate
338
+ // matcher expects the same shape the client sees.
339
+ const oldRow = oldRowRaw ? decodeRow(oldRowRaw, col.fields) : null
340
+ const newRow = newRowRaw ? decodeRow(newRowRaw, col.fields) : null
341
+
342
+ // Scope enforcement: for user-scoped collections, the row only reaches a
343
+ // subscriber whose ctx.userId matches the row's _owner. This must happen
344
+ // BEFORE the predicate check — leaking a `delete <id>` delta to the
345
+ // wrong user would let them probe row existence by id.
346
+ const scoped = col.scope === "user"
347
+ const rowOwner =
348
+ scoped
349
+ ? (newRowRaw?._owner as string | undefined) ?? (oldRowRaw?._owner as string | undefined) ?? null
350
+ : null
351
+
352
+ const pending: Promise<void>[] = []
353
+ // Snapshot to a stable array — sends may evict clients mid-iteration.
354
+ const targets = Array.from(set)
355
+ for (const client of targets) {
356
+ if (client.readyState !== 1) {
357
+ removeSubClient(client)
358
+ continue
359
+ }
360
+ if (scoped) {
361
+ // Must be authed AND own the row. Anything else is a no-op for this
362
+ // sub (the sub's snapshot already excluded the row by _owner =
363
+ // userId, so dropping the delta keeps the local mirror correct).
364
+ if (!client.ctx.userId || client.ctx.userId !== rowOwner) continue
365
+ }
366
+ const subs = clients.get(client)
367
+ if (!subs) continue
368
+ for (const sub of subs.values()) {
369
+ if (sub.collection !== collection) continue
370
+ pending.push(dispatchDeltaWithSeq(client, sub, kind, oldRow, newRow, seq))
371
+ }
372
+ }
373
+ return Promise.allSettled(pending).then(() => undefined)
374
+ }
375
+
376
+ async function dispatchDeltaWithSeq(
377
+ client: SubClient,
378
+ sub: ClientSub,
379
+ kind: "insert" | "update" | "delete",
380
+ oldRow: Record<string, unknown> | null,
381
+ newRow: Record<string, unknown> | null,
382
+ seq: number,
383
+ ): Promise<boolean> {
384
+ // Returns true if a delta frame was actually emitted, false otherwise.
385
+ // The replay path uses this to count `replayed` correctly — entries
386
+ // whose predicate filtered the row out shouldn't be counted.
387
+ //
388
+ // No predicate → membership is trivially TRUE on both sides as long as
389
+ // the row exists at that side. The was/now transition then matches the
390
+ // raw kind 1-for-1.
391
+ const match = sub.predicateJs
392
+ const was = oldRow ? (match ? match(oldRow) : true) : false
393
+ const now = newRow ? (match ? match(newRow) : true) : false
394
+
395
+ if (kind === "insert") {
396
+ if (now) {
397
+ emitDelta(client, sub, "insert", newRow as Record<string, unknown>, seq)
398
+ return true
399
+ }
400
+ return false
401
+ }
402
+ if (kind === "delete") {
403
+ if (was) {
404
+ const id = (oldRow as Record<string, unknown>).id as string
405
+ sendMessage(client, {
406
+ type: "delta", subId: sub.subId, collection: sub.collection,
407
+ seq, op: "delete", id,
408
+ })
409
+ return true
410
+ }
411
+ return false
412
+ }
413
+ // kind === "update"
414
+ if (!was && now) {
415
+ emitDelta(client, sub, "insert", newRow as Record<string, unknown>, seq)
416
+ return true
417
+ }
418
+ if (was && now) {
419
+ emitDelta(client, sub, "update", newRow as Record<string, unknown>, seq)
420
+ return true
421
+ }
422
+ if (was && !now) {
423
+ const id = (oldRow as Record<string, unknown>).id as string
424
+ sendMessage(client, {
425
+ type: "delta", subId: sub.subId, collection: sub.collection,
426
+ seq, op: "delete", id,
427
+ })
428
+ return true
429
+ }
430
+ // !was && !now: row is outside this sub's read set on both sides — no-op.
431
+ return false
432
+ }
433
+
434
+ function emitDelta(
435
+ client: SubClient,
436
+ sub: ClientSub,
437
+ op: "insert" | "update",
438
+ row: Record<string, unknown>,
439
+ seq: number,
440
+ ): void {
441
+ // Keep _owner on the wire — REST `GET /api/<col>` returns the same
442
+ // shape today (auto-crud's SELECT * is unfiltered). Stripping here
443
+ // would diverge subscriber state from REST state.
444
+ sendMessage(client, {
445
+ type: "delta",
446
+ subId: sub.subId,
447
+ collection: sub.collection,
448
+ seq,
449
+ op,
450
+ row,
451
+ })
452
+ }
453
+
454
+ /**
455
+ * Coarse fan-out fallback: re-push the entire snapshot to every subscriber
456
+ * of `collection`. Used by user-authored raw-SQL handlers that mutate
457
+ * outside auto-crud — auto-crud itself prefers notifyRowChange. Kept for
458
+ * back-compat; calling this is strictly less efficient than the delta
459
+ * path but always correct.
460
+ */
461
+ export function notifyCollectionChange(collection: string): Promise<void> {
462
+ const set = subsByCollection.get(collection)
463
+ if (!set || set.size === 0) return Promise.resolve()
464
+ const targets = Array.from(set)
465
+ const pending: Promise<void>[] = []
466
+ for (const client of targets) {
467
+ if (client.readyState !== 1) {
468
+ removeSubClient(client)
469
+ continue
470
+ }
471
+ const subs = clients.get(client)
472
+ if (!subs) continue
473
+ for (const sub of subs.values()) {
474
+ if (sub.collection !== collection) continue
475
+ pending.push(sendSnapshot(client, sub))
476
+ }
477
+ }
478
+ return Promise.allSettled(pending).then(() => undefined)
479
+ }
480
+
481
+ // ── Snapshot fetch + send ────────────────────────────────────────────────────
482
+
483
+ async function sendSnapshot(client: SubClient, sub: ClientSub): Promise<void> {
484
+ const db = getDbInstance()
485
+ if (!db) {
486
+ sendError(client, sub.subId, "db_not_ready", "db not initialized")
487
+ return
488
+ }
489
+ if (!currentSchema) {
490
+ sendError(client, sub.subId, "no_schema", "schema not registered")
491
+ return
492
+ }
493
+ const col = currentSchema.collections[sub.collection]
494
+ if (!col) {
495
+ // Should not happen — sub-time validation rejects unknown collections.
496
+ sendError(client, sub.subId, "unknown_collection", `no such collection: ${sub.collection}`)
497
+ return
498
+ }
499
+
500
+ const scoped = col.scope === "user"
501
+ const conditions: string[] = []
502
+ const params: unknown[] = []
503
+
504
+ if (scoped) {
505
+ if (!client.ctx.userId) {
506
+ sendError(client, sub.subId, "auth_required", `scoped collection "${sub.collection}" requires an authenticated user`)
507
+ return
508
+ }
509
+ conditions.push("_owner = ?")
510
+ params.push(client.ctx.userId)
511
+ }
512
+
513
+ if (sub.predicate) {
514
+ // Reject predicate columns that don't exist in this collection's schema
515
+ // — same defense-in-depth as the column-identifier regex inside the
516
+ // predicate validator. (validatePredicate already rejected non-
517
+ // identifier strings; this also rejects columns whose name is well-
518
+ // formed but undeclared.)
519
+ if (!predicateColumnsExist(sub.predicate, col.fields)) {
520
+ sendError(client, sub.subId, "bad_predicate", "predicate references unknown column")
521
+ return
522
+ }
523
+ const compiled = compileToSql(sub.predicate)
524
+ conditions.push(compiled.sql)
525
+ params.push(...compiled.params)
526
+ }
527
+
528
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
529
+ try {
530
+ const rawRows = db.raw()
531
+ .prepare(`SELECT * FROM ${sub.collection} ${where} ORDER BY created_at DESC`)
532
+ .all(...params) as Record<string, unknown>[]
533
+ const rows = rawRows.map(r => decodeRow(r, col.fields))
534
+ // The snapshot's high-water mark is the current global seq — any delta
535
+ // with seq > hwm happened AFTER this snapshot's SELECT. Note that we
536
+ // don't bump the counter for snapshots; their seq is shared with the
537
+ // most recent write so resume-from-seq math is straightforward.
538
+ sendMessage(client, {
539
+ type: "snapshot",
540
+ subId: sub.subId,
541
+ collection: sub.collection,
542
+ seq: seqCounter,
543
+ rows,
544
+ })
545
+ } catch (err) {
546
+ sendError(client, sub.subId, "snapshot_failed", (err as Error).message ?? String(err))
547
+ }
548
+ }
549
+
550
+ // ── Resume replay ────────────────────────────────────────────────────────────
551
+
552
+ function canResumeFrom(collection: string, fromSeq: number): boolean {
553
+ const ring = ringByCollection.get(collection)
554
+ // No ring (no writes since boot) is a successful resume — there's
555
+ // nothing to replay and the client is by definition already caught up.
556
+ if (!ring || ring.length === 0) return fromSeq <= seqCounter
557
+ const oldest = ring[0]
558
+ if (!oldest) return fromSeq <= seqCounter
559
+ return fromSeq >= oldest.seq - 1
560
+ }
561
+
562
+ async function sendResumeReplay(
563
+ client: SubClient,
564
+ sub: ClientSub,
565
+ fromSeq: number,
566
+ ): Promise<void> {
567
+ if (!currentSchema) {
568
+ sendError(client, sub.subId, "no_schema", "schema not registered")
569
+ return
570
+ }
571
+ const col = currentSchema.collections[sub.collection]
572
+ if (!col) {
573
+ sendError(client, sub.subId, "unknown_collection", `no such collection: ${sub.collection}`)
574
+ return
575
+ }
576
+ const ring = ringByCollection.get(sub.collection) ?? []
577
+ let replayed = 0
578
+ for (const entry of ring) {
579
+ if (entry.seq <= fromSeq) continue
580
+ // Same scope + predicate gate notifyRowChange runs. Phase 3's logic
581
+ // is in dispatchDelta, but it expects decoded rows. Decode here.
582
+ const scoped = col.scope === "user"
583
+ if (scoped) {
584
+ const owner =
585
+ (entry.newRowRaw?._owner as string | undefined) ??
586
+ (entry.oldRowRaw?._owner as string | undefined) ??
587
+ null
588
+ if (!client.ctx.userId || client.ctx.userId !== owner) continue
589
+ }
590
+ const oldRow = entry.oldRowRaw ? decodeRow(entry.oldRowRaw, col.fields) : null
591
+ const newRow = entry.newRowRaw ? decodeRow(entry.newRowRaw, col.fields) : null
592
+ const emitted = await dispatchDeltaWithSeq(client, sub, entry.kind, oldRow, newRow, entry.seq)
593
+ if (emitted) replayed += 1
594
+ }
595
+ sendMessage(client, {
596
+ type: "resumed",
597
+ subId: sub.subId,
598
+ collection: sub.collection,
599
+ fromSeq,
600
+ toSeq: seqCounter,
601
+ replayed,
602
+ })
603
+ }
604
+
605
+ const RESERVED = new Set(["id", "created_at", "updated_at", "_owner"])
606
+
607
+ function predicateColumnsExist(
608
+ p: Predicate,
609
+ fields: Record<string, { type: unknown }>,
610
+ ): boolean {
611
+ switch (p.op) {
612
+ case "and":
613
+ case "or":
614
+ return p.clauses.every(c => predicateColumnsExist(c, fields))
615
+ case "not":
616
+ return predicateColumnsExist(p.clause, fields)
617
+ default:
618
+ // All leaf nodes carry a `column` property.
619
+ return RESERVED.has(p.column) || p.column in fields
620
+ }
621
+ }
622
+
623
+ function sendMessage(client: SubClient, msg: SubServerMessage): void {
624
+ if (client.readyState !== 1) {
625
+ removeSubClient(client)
626
+ return
627
+ }
628
+ try {
629
+ client.send(JSON.stringify(msg))
630
+ } catch {
631
+ removeSubClient(client)
632
+ }
633
+ }
634
+
635
+ function sendError(client: SubClient, subId: string | undefined, code: string, message: string): void {
636
+ sendMessage(client, { type: "error", subId, code, message })
637
+ }
638
+
639
+ // ── Test-only escape hatch ───────────────────────────────────────────────────
640
+
641
+ /** Clear all subscription state. Used by tests; not part of the public API. */
642
+ export function _resetSubscriptionState(): void {
643
+ clients.clear()
644
+ subsByCollection.clear()
645
+ ringByCollection.clear()
646
+ seqCounter = 0
647
+ RING_CAP = 256
648
+ MAX_SUBS_PER_CLIENT = 64
649
+ }
650
+
651
+ /** Test helper: peek at the current global seq. Not part of the public API. */
652
+ export function _currentSeq(): number {
653
+ return seqCounter
654
+ }