@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.
- package/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
|
@@ -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
|
+
}
|