dzql 0.7.0 → 0.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.
@@ -0,0 +1,706 @@
1
+ /**
2
+ * DocListener — one server, one LISTEN, many doc types.
3
+ *
4
+ * A listener answers for the doc types it is given (`types`, and `register`
5
+ * later); two servers in one process each have their own.
6
+ *
7
+ * Auth is pluggable via `opts.auth`. When provided, every doc message passes
8
+ * through `auth.gate(client)` and the resulting identity is threaded into
9
+ * `type.open / apply / openAt` so backends can scope queries (e.g. with
10
+ * `withAppAuth` + RLS), and a type's `owns(identity, docName)` decides whether
11
+ * that identity may have the document at all (404 when not). When auth is
12
+ * omitted, no gate runs and identity is undefined — delta itself has no
13
+ * opinion on authentication.
14
+ */
15
+ import type { WsServer } from "@blueshed/delta/backend";
16
+ import { trackSubscribe, trackUnsubscribe, onClientDrop } from "@blueshed/delta/backend";
17
+ import { createLogger } from "@blueshed/delta/logger";
18
+ import type { Pool, PoolClient } from "pg";
19
+ import { callAs, docTypes, showAnswer } from "./registry";
20
+ import { showDoc, showOps, type DocDef, type Schema } from "@blueshed/delta/backend";
21
+ import { resolveScope } from "@blueshed/delta/backend";
22
+ import type { DocType } from "./registry";
23
+ import type { DeltaAuth } from "@blueshed/delta/auth";
24
+ import { isAuthError } from "@blueshed/delta/auth";
25
+ import type { DeltaOp } from "@blueshed/delta/core";
26
+ import { isEntryId, ledgerWho, socketCursor } from "@blueshed/delta/backend";
27
+ import { assertOwned, createCustomDocs, NOT_FOUND, sendTo, type CustomDoc, type Owns, type Refusal } from "@blueshed/delta/backend";
28
+
29
+ const log = createLogger("[doc]");
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Custom doc — predicate-based membership view over watched collections.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * A custom doc on Postgres (`../custom`): its reads take the pool and answer
37
+ * with a promise. A membership `query` runs once per doc name and identity
38
+ * (the gated identity, undefined without auth), and its result is cached and
39
+ * shared by the subscribers of that name who are that identity -- never by
40
+ * another: bind the identity yourself (`withAppAuth(pool, id, …)`) so RLS
41
+ * scopes what it reads, as recompute does. A `recompute` reads the WHOLE doc,
42
+ * on open and on any write to a watched collection, and republishes it to each
43
+ * subscriber as a root-replace op -- for nested/joined reads a per-row
44
+ * `matches` can't express: re-evaluated PER SUBSCRIBER under that client's
45
+ * identity, so RLS applies, hence it binds `app.user_id` itself.
46
+ */
47
+ export interface CustomDocDef<C = unknown, I = any> extends CustomDoc<C, I, Pool> {
48
+ query?: (pool: Pool, criteria: C, identity?: I) => Promise<Record<string, any[]>>;
49
+ recompute?: (pool: Pool, criteria: C, identity?: I) => Promise<any>;
50
+ }
51
+
52
+ export function defineCustomDoc<C, I = any>(
53
+ prefix: string,
54
+ opts: Omit<CustomDocDef<C, I>, "prefix">,
55
+ ): CustomDocDef<C, I> {
56
+ return { prefix, ...opts };
57
+ }
58
+
59
+ /**
60
+ * The schema the database declares (`_delta_collections`, the last
61
+ * `generateSql` applied), as a listener given none shows it: read once, as
62
+ * it starts.
63
+ */
64
+ async function declaredSchema(pool: Pool): Promise<Schema> {
65
+ try {
66
+ const { rows } = await pool.query("SELECT collection_key, table_name, columns_def, parent_collection, parent_fk, temporal FROM _delta_collections");
67
+ return {
68
+ tables: Object.fromEntries(rows.map((r: any) => [r.collection_key, {
69
+ name: r.table_name, docKey: r.collection_key, columns: r.columns_def ?? {}, temporal: !!r.temporal, children: [], referencedBy: [],
70
+ ...(r.parent_fk ? { parent: { collection: r.parent_collection, fkColumn: r.parent_fk } } : {}),
71
+ }])),
72
+ };
73
+ } catch {
74
+ return { tables: {} }; // no framework yet: nothing declared, nothing left out
75
+ }
76
+ }
77
+
78
+ function errMsg(err: unknown): string {
79
+ return err instanceof Error ? err.message : String(err);
80
+ }
81
+
82
+ /**
83
+ * The wire code for an error the database raised -- the same table as the
84
+ * other backends. The framework raises SQLSTATE 22023 for a malformed path or
85
+ * op, 22P02 for a row id it could not mint, 22007 and 22008 for a time it
86
+ * cannot read, and 23502 for an add that leaves out a required field (a
87
+ * client's mistake, 400); P0002 for a row that is not
88
+ * there (404); 23505 for an add of a row that is, and 40001 for an undo of an
89
+ * entry that is not the cursor's next (409). And 40P01, a write that met another
90
+ * at once and gave way to it (two adds of temporal ids in opposite orders wait
91
+ * on each other's row locks, todo #16): nothing was written, and the same write
92
+ * sent again goes through, or meets the rows the other made (409). Anything
93
+ * else is the server's (500).
94
+ */
95
+ const CODE_OF_SQLSTATE: Record<string, number> = { "22023": 400, "22P02": 400, "22007": 400, "22008": 400, "23502": 400, "25006": 403, P0002: 404, "23505": 409, "40001": 409, "40P01": 409 };
96
+ function wireCode(err: unknown): number {
97
+ const state = (err as { code?: unknown } | null)?.code;
98
+ return (typeof state === "string" && CODE_OF_SQLSTATE[state]) || 500;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // State — per-doc subscriber tracking
103
+ // ---------------------------------------------------------------------------
104
+
105
+ interface DocState {
106
+ version: number;
107
+ /**
108
+ * The last version a notification has announced (or an open read): a drain
109
+ * reads no further (todo #55). A later entry is heard when its own
110
+ * notification comes, so each notification maps to entries not yet heard,
111
+ * and a document let go of and tracked again by a late notification does not
112
+ * read an entry twice. A resync after a reconnect, which missed notifications,
113
+ * reads to the end.
114
+ */
115
+ until: number;
116
+ subscribers: Set<any>;
117
+ notifying: boolean;
118
+ /** A notification arrived while a fetch was in flight — re-drain when it ends. */
119
+ pending: boolean;
120
+ }
121
+
122
+ /** _delta_fetch_log (delta_fetch_ops' rows) caps each call at this many; a full page means there may be more. */
123
+ const FETCH_PAGE = 1000;
124
+
125
+ /**
126
+ * How the listener reads a document's log. `_delta_fetch_log` (001e) gives each
127
+ * entry with the write as applied, for custom documents (todo #44). Framework
128
+ * SQL older than this listener -- vendored with `delta init` into an initdb
129
+ * directory and not re-applied -- has no such function, and every fetch would
130
+ * fail, so no broadcast would reach anyone: there the listener reads
131
+ * `delta_fetch_ops`, each entry heard as told, as before, and says so once.
132
+ */
133
+ const FETCH_LOG = "SELECT version, ops, applied FROM _delta_fetch_log($1, $2)";
134
+ const FETCH_TOLD = "SELECT version, ops, NULL::jsonb AS applied FROM delta_fetch_ops($1, $2)";
135
+ async function fetchQuery(pool: Pool): Promise<string> {
136
+ const { rows } = await pool.query("SELECT to_regprocedure('_delta_fetch_log(text, bigint)') IS NOT NULL AS ok");
137
+ if (rows[0]?.ok) return FETCH_LOG;
138
+ log.warn(
139
+ "the framework SQL is older than this listener (no _delta_fetch_log, 001e): changes are read with delta_fetch_ops, " +
140
+ "and a custom document hears a write once for each document told of it. Re-apply it (applyFramework, or " +
141
+ "bunx @blueshed/delta init) and restart.",
142
+ );
143
+ return FETCH_TOLD;
144
+ }
145
+
146
+ export async function createDocListener<I = unknown>(
147
+ ws: WsServer,
148
+ pool: Pool,
149
+ opts?: {
150
+ /** The documents this listener answers for: `docTypeFromDef(def, pool)`, or a `DocType` written by hand. */
151
+ types?: DocType<any, I>[];
152
+ auth?: DeltaAuth<I>;
153
+ custom?: CustomDocDef<any>[];
154
+ /** Keep the ledger (001g): every write with its inverse, who made it and the cursor undo walks; and `undo`, `redo`, `history`. */
155
+ ledger?: boolean;
156
+ /** How an identity is written in the ledger. Default: a string or number as it is, anything else as JSON. */
157
+ who?: (identity: I) => string;
158
+ /**
159
+ * This process's schema: the schema is the contract (reference/migrations.md). It writes
160
+ * by the columns it declares, and sends only those -- two versions on one database each
161
+ * see and write their own. Without it, the columns the database declares as the
162
+ * listener starts (`_delta_collections`, the last `generateSql` applied).
163
+ */
164
+ schema?: Schema;
165
+ },
166
+ ) {
167
+ const auth = opts?.auth;
168
+ // Default-deny, as docTypeFromDef is: with auth, every document -- a custom
169
+ // one, a registered type -- says who owns it, or that it is shared. Asked
170
+ // before the pool is touched, so a refusal leaves nothing to clean up.
171
+ assertOwned(opts?.custom ?? [], auth);
172
+ const registry = docTypes((opts?.types ?? []) as DocType[], (opts?.custom ?? []).map((d) => d.prefix), !!auth);
173
+ // what this process declares: it writes by it (`declared`, set for each write's transaction) and sends only it (`shown`)
174
+ const declared = opts?.schema ? JSON.stringify(Object.fromEntries(Object.entries(opts.schema.tables).map(([key, t]) => [key, t.columns]))) : undefined;
175
+ const shownBy: Schema = opts?.schema ?? (await declaredSchema(pool));
176
+ /** A document as this process shows it: each row its declared columns (`showDoc`). */
177
+ const shownDoc = (type: DocType<any, I>, docName: string, doc: any) =>
178
+ type.def && doc ? showDoc(type.def, shownBy, doc, resolveScope(type.def, docName.slice(type.def.prefix.length)).mode === "single") : doc;
179
+ /** Ops, and an answer's ops and inverse, as this process shows them (`showOps`). */
180
+ const shownOps = (def: DocDef | undefined, ops: any) => (def && Array.isArray(ops) ? showOps(def, shownBy, ops) : ops);
181
+ const shownAnswer = <R extends { ops?: unknown; inverse?: unknown } | null>(def: DocDef | undefined, r: R): R =>
182
+ r && def ? { ...r, ...(r.ops ? { ops: shownOps(def, r.ops) } : {}), ...(r.inverse ? { inverse: shownOps(def, r.inverse) } : {}) } : r;
183
+ const resolveDoc = registry.resolve;
184
+ const ledger = !!opts?.ledger;
185
+ const whoOf = (identity: I | undefined): string | null => ledgerWho(identity, opts?.who);
186
+ // The cursor undo walks: named by a caller in this process, and over the
187
+ // socket the connection itself -- signed in, the person and the connection
188
+ // together, so another person holding the same connection id (a client may
189
+ // choose it, to keep its cursor across a reconnect) cannot walk it.
190
+ const cursorOf = (msg: any, client: any, identity: I | undefined): string | null =>
191
+ client?.data?.local ? (typeof msg.cursor === "string" ? msg.cursor : null) : socketCursor(whoOf(identity), client);
192
+ // Who is writing, for the ledger: the identity the gate gives when there is
193
+ // an auth module; without one, the identity the client carries
194
+ // (`createLocal().as(identity)`, or one set at upgrade) -- as the SQLite backend reads it.
195
+ const writerOf = (identity: I | undefined, client: any): I | undefined =>
196
+ auth ? identity : (client?.data?.identity as I | undefined);
197
+ /** The gate's identity (undefined without auth), or the 401 for a socket without one. */
198
+ const gate = (client: any): { identity: I | undefined } | { error: Refusal } => {
199
+ if (!auth) return { identity: undefined };
200
+ const gated = auth.gate(client);
201
+ return isAuthError(gated) ? { error: { code: 401, message: gated.error } } : { identity: gated };
202
+ };
203
+ const tracked = new Map<string, DocState>();
204
+ /**
205
+ * The names this listener holds open in the database (`_delta_held`, todo
206
+ * #73), each its hold's query: a write's fan-out asks only the names held
207
+ * somewhere. A name is held from before its first open here reads it, until
208
+ * nobody here has it open or is opening it (`opening`). Its holds and lets
209
+ * go run on the pool in the order asked, one name's beside another's: a hold
210
+ * that waits for a write holds up no other name. The listener's connection
211
+ * keeps the lock that says this holder is alive.
212
+ */
213
+ const holds = new Map<string, Promise<unknown>>();
214
+ const opening = new Map<string, number>();
215
+ const queued = new Map<string, Promise<unknown>>();
216
+ let holder: number | null = null;
217
+ /** `sql` for `docName`, after what was asked for it before. */
218
+ function inOrder(docName: string, sql: string): Promise<unknown> {
219
+ const run = (queued.get(docName) ?? Promise.resolve()).then(() => pool.query(sql, [holder, docName]));
220
+ const done = run.catch(() => {});
221
+ queued.set(docName, done);
222
+ void done.then(() => { if (queued.get(docName) === done) queued.delete(docName); });
223
+ return run;
224
+ }
225
+
226
+ // The custom documents (../custom): who holds each, who may hear it, what a
227
+ // write tells it -- and who may still hear any document (todo #17): a change
228
+ // told asks the gate and `owns` again of each socket it is told to (`owns`
229
+ // once per identity per change), so what a change costs is the identities a
230
+ // document has open, not its sockets. Without auth nothing is asked. The
231
+ // longest prefix answers a name: a type's longer than a custom doc's wins.
232
+ const custom = createCustomDocs<I, Pool>({
233
+ defs: opts?.custom ?? [], handle: pool, auth, publish: (channel, data) => ws.publish(channel, data), log,
234
+ answers: (docName, length) => (resolveDoc(docName)?.type.prefix.length ?? 0) <= length,
235
+ });
236
+ const ownsOf = (docName: string): Owns<I> | undefined => {
237
+ const type = resolveDoc(docName)?.type;
238
+ return type?.owns ? (identity, name) => type.owns!(identity, name) : undefined;
239
+ };
240
+
241
+ // Single LISTEN connection with auto-reconnect
242
+ const fetchSql = await fetchQuery(pool);
243
+ let listener: PoolClient = await pool.connect();
244
+ let destroyed = false;
245
+ let reconnecting = false;
246
+
247
+ let errorHandler: ((err: Error) => void) | null = null;
248
+
249
+ // Detach our handlers before releasing a connection — released connections
250
+ // are recycled by the pool, and a stale listener would fire against this
251
+ // closure's state (and spuriously trigger reconnect) on a future checkout.
252
+ function detachListener(client: any) {
253
+ try { client.removeListener?.("notification", onNotification); } catch {}
254
+ if (errorHandler) {
255
+ try { client.removeListener?.("error", errorHandler); } catch {}
256
+ }
257
+ }
258
+
259
+ /** LISTEN on `client`, and keep this holder's lock on it. */
260
+ async function setupListener(client: any): Promise<void> {
261
+ await client.query("LISTEN delta_changes");
262
+ client.on("notification", onNotification);
263
+ errorHandler = (err: Error) => {
264
+ log.error(`listener error: ${err.message}`);
265
+ if (!destroyed) reconnect();
266
+ };
267
+ client.on("error", errorHandler);
268
+ holder = (await client.query("SELECT _delta_holder_start($1) AS holder", [holder])).rows[0].holder;
269
+ }
270
+
271
+ async function reconnect() {
272
+ if (reconnecting || destroyed) return; // a single error event runs the loop
273
+ reconnecting = true;
274
+ // `reconnecting` stays true for the WHOLE operation, including the resync,
275
+ // so an error fired by the freshly-connected listener during resync can't
276
+ // spawn a second concurrent reconnect that releases the connection resync
277
+ // is using. The finally guarantees the flag is always cleared.
278
+ try {
279
+ log.info("listener reconnecting...");
280
+ detachListener(listener);
281
+ try { listener.release(); } catch {}
282
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
283
+ for (let attempt = 1; !destroyed; attempt++) {
284
+ try {
285
+ await delay(Math.min(attempt * 1000, 10000));
286
+ listener = await pool.connect();
287
+ await setupListener(listener);
288
+ log.info("listener reconnected");
289
+ await keepHolds();
290
+ // Catch up: ops committed while we were disconnected were NOTIFY'd to
291
+ // nobody, so re-drain every tracked doc from its last seen version.
292
+ await resyncTracked();
293
+ return;
294
+ } catch (err) {
295
+ log.error(`reconnect attempt ${attempt} failed: ${errMsg(err)}`);
296
+ }
297
+ }
298
+ } finally {
299
+ reconnecting = false;
300
+ }
301
+ }
302
+
303
+ // Re-drain all tracked docs (used after a reconnect to recover missed ops).
304
+ async function resyncTracked() {
305
+ for (const [docName, state] of tracked) {
306
+ state.until = Number.MAX_SAFE_INTEGER; // the notifications while away are gone: read to the end
307
+ if (state.notifying) { state.pending = true; continue; }
308
+ state.notifying = true;
309
+ try { await drainDoc(docName, state); }
310
+ catch (err) { log.error(`resync ${docName}: ${errMsg(err)}`); }
311
+ finally { state.notifying = false; }
312
+ }
313
+ }
314
+
315
+ try {
316
+ await setupListener(listener);
317
+ } catch (err) {
318
+ detachListener(listener);
319
+ listener.release();
320
+ throw err;
321
+ }
322
+
323
+ // Notification handler — fans out LISTEN/NOTIFY to WS subscribers, plus
324
+ // custom-doc cross-pollination when any predicate-based doc is registered.
325
+ async function onNotification(msg: any) {
326
+ if (destroyed) return;
327
+ if (msg.channel !== "delta_changes" || !msg.payload) return;
328
+ try {
329
+ const { doc: docName, v } = JSON.parse(msg.payload);
330
+ let state = tracked.get(docName);
331
+
332
+ // Even without direct subscribers, we still process notifications if any
333
+ // custom doc is registered — a watched collection's rows may need fan-out.
334
+ if (!state && custom.size === 0) return;
335
+ if (!state) {
336
+ // Initialize at v-1 so we fetch only the current notification's row.
337
+ // Custom docs loaded their initial state via `def.query` on open, so
338
+ // pre-existing ops are already reflected — replaying history would
339
+ // duplicate them. Same logic protects close-then-reopen.
340
+ state = { version: Math.max(0, v - 1), until: v, subscribers: new Set(), notifying: false, pending: false };
341
+ tracked.set(docName, state);
342
+ }
343
+
344
+ if (v <= state.version) return;
345
+ state.until = Math.max(state.until, v);
346
+ if (state.notifying) {
347
+ // A fetch is already running for this doc. Coalesce instead of dropping:
348
+ // dropping here lost ops when a concurrent write's NOTIFY landed mid-fetch.
349
+ state.pending = true;
350
+ return;
351
+ }
352
+
353
+ state.notifying = true;
354
+ try {
355
+ await drainDoc(docName, state);
356
+ } finally {
357
+ state.notifying = false;
358
+ }
359
+ } catch (err) {
360
+ log.error(`notify error: ${errMsg(err)}`);
361
+ }
362
+ }
363
+
364
+ // Drain a doc's ops to its subscribers. Loops while a full page comes back
365
+ // (a backlog larger than the fetch's LIMIT) and re-runs if another
366
+ // notification arrived mid-drain (the coalesced `pending` flag). Caller owns
367
+ // the `notifying` guard.
368
+ async function drainDoc(docName: string, state: DocState) {
369
+ do {
370
+ state.pending = false;
371
+ let more = true;
372
+ while (more && state.version < state.until) {
373
+ const page = (await pool.query(fetchSql, [docName, state.version])).rows;
374
+ // only what a notification has announced (`until`): the rest is heard with its own
375
+ const rows = page.filter((row: any) => Number(row.version) <= state.until);
376
+ more = page.length >= FETCH_PAGE && rows.length === page.length;
377
+ // Told to those who may still hear it (todo #17): the document's own
378
+ // subscribers, and the custom documents' that watch what it changed.
379
+ if (auth && rows.length > 0) {
380
+ await custom.letGo(docName, state.subscribers, ownsOf(docName));
381
+ await custom.letGoWatching(rows.flatMap((row: any) => (row.applied ?? row.ops) as DeltaOp[]));
382
+ }
383
+ for (const row of rows) {
384
+ if (state.subscribers.size > 0) {
385
+ // `v` lets the client validate the per-doc sequence and re-open on
386
+ // a gap. delta_fetch_ops rows are contiguous and ascending per doc.
387
+ // Number(): pg returns a top-level BIGINT column as a string, but
388
+ // the open snapshot's `_v` (from JSONB) is a number — the client
389
+ // compares them strictly, so both must be numbers.
390
+ ws.publish(docName, { doc: docName, ops: shownOps(resolveDoc(docName)?.type.def, row.ops), v: Number(row.version) });
391
+ }
392
+ state.version = Number(row.version);
393
+ // A custom document hears each write once, as it was applied: the
394
+ // writer's entry carries it (`applied`) where the writer was told
395
+ // otherwise, and null where it was told the write as applied; each
396
+ // other told document's entry of the write carries none ([]). Told,
397
+ // a row that left a document is a remove, which is not a delete, and
398
+ // a row told to two documents would be heard twice (todo #44). An
399
+ // entry logged some other way carries null too: heard as told.
400
+ custom.tell((row.applied ?? row.ops) as DeltaOp[]);
401
+ }
402
+ }
403
+ } while (state.pending);
404
+ if (state.until > state.version) state.until = state.version; // after a resync: from here, as announced
405
+
406
+ pruneDoc(docName);
407
+ log.debug(`notify ${docName} v${state.version}`);
408
+ }
409
+
410
+ function pruneDoc(docName: string) {
411
+ const state = tracked.get(docName);
412
+ if (!state) return;
413
+ for (const sub of state.subscribers) {
414
+ if (sub.readyState !== undefined && sub.readyState !== 1) {
415
+ state.subscribers.delete(sub);
416
+ }
417
+ }
418
+ if (state.subscribers.size === 0) { tracked.delete(docName); release(docName); }
419
+ }
420
+
421
+ function hold(docName: string): Promise<unknown> {
422
+ let held = holds.get(docName);
423
+ if (!held) {
424
+ held = inOrder(docName, "SELECT _delta_hold($1, $2)");
425
+ held.catch(() => { if (holds.get(docName) === held) holds.delete(docName); });
426
+ holds.set(docName, held);
427
+ }
428
+ return held;
429
+ }
430
+
431
+ /** Let go of a name nobody here has open or is opening. */
432
+ function release(docName: string): void {
433
+ if (tracked.get(docName)?.subscribers.size || opening.has(docName) || !holds.delete(docName)) return;
434
+ inOrder(docName, "SELECT _delta_let_go($1, $2)")
435
+ .catch((err) => log.error(`let go of ${docName}: ${errMsg(err)}`));
436
+ }
437
+
438
+ /**
439
+ * Back from a lost connection: a name cleared while the listener was away
440
+ * (another took it for gone) may have missed a write. Each socket is let go
441
+ * of such a document, and opens it again; an open reading it reads again.
442
+ */
443
+ async function keepHolds(): Promise<void> {
444
+ await Promise.allSettled(holds.values());
445
+ const { rows } = await pool.query("SELECT doc_name FROM _delta_held WHERE holder = $1", [holder]);
446
+ const still = new Set(rows.map((r: any) => r.doc_name as string));
447
+ for (const docName of [...holds.keys()]) {
448
+ if (still.has(docName)) continue;
449
+ holds.delete(docName);
450
+ for (const client of tracked.get(docName)?.subscribers ?? []) {
451
+ try { trackUnsubscribe(client, docName); } catch { /* the socket may be closing */ }
452
+ sendTo(client, { doc: docName, error: { code: 500, message: "the server lost the database for a while: open it again" } });
453
+ }
454
+ tracked.delete(docName);
455
+ }
456
+ }
457
+
458
+ // Transport-level teardown (socket drop / logout via dropClientSubscriptions):
459
+ // pruneDoc only runs when a doc drains, so a doc whose subscribers all
460
+ // vanished without further writes stayed tracked, and custom-doc caches
461
+ // (which pruneDoc never touches) lived until an explicit `close` action
462
+ // that a dropped socket never sends. Mirrors the sqlite backend's hook.
463
+ function releaseClient(client: any): void {
464
+ for (const [docName, state] of tracked) {
465
+ if (!state.subscribers.delete(client)) continue;
466
+ if (state.subscribers.size === 0) { tracked.delete(docName); release(docName); }
467
+ }
468
+ custom.leave(client);
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // withDoc — authenticate (if configured), validate doc name, resolve handler,
473
+ // wrap errors. Each WS branch supplies only its own body.
474
+ // ---------------------------------------------------------------------------
475
+
476
+ type DocCtx = {
477
+ docName: string;
478
+ type: DocType<any, I>;
479
+ ctx: any;
480
+ msg: any;
481
+ client: any;
482
+ respond: (r: any) => void;
483
+ identity: I | undefined;
484
+ };
485
+
486
+ // `owned: false` for close alone: letting go of a document never needs the owner's say.
487
+ function withDoc(label: string, fn: (dc: DocCtx) => Promise<void> | void, { owned = true } = {}) {
488
+ return async (msg: any, client: any, respond: (r: any) => void) => {
489
+ const g = gate(client);
490
+ if ("error" in g) return respond(g);
491
+ const { identity } = g;
492
+ const docName = msg.doc as string;
493
+ if (!docName) {
494
+ return respond({ error: { code: 400, message: "doc is required" } });
495
+ }
496
+ const found = resolveDoc(docName);
497
+ if (!found) {
498
+ return respond({ error: { code: 404, message: `No handler for ${docName}` } });
499
+ }
500
+ try {
501
+ // The document's name is its channel: one this identity does not own is
502
+ // not there for it (404, as a missing one), so it never subscribes.
503
+ if (owned && auth && found.type.owns && !(await found.type.owns(identity as I, docName))) {
504
+ return respond({ error: NOT_FOUND });
505
+ }
506
+ await fn({ docName, type: found.type, ctx: found.ctx, msg, client, respond, identity });
507
+ } catch (err) {
508
+ const m = errMsg(err);
509
+ log.error(`${label} failed: ${m}`);
510
+ respond({ error: { code: wireCode(err), message: m } });
511
+ }
512
+ };
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // WS branches — custom docs first (short-circuit before standard withDoc)
517
+ // ---------------------------------------------------------------------------
518
+
519
+ if (custom.size > 0) {
520
+ ws.on("open", async (msg: any, client: any, respond: (r: any) => void) => {
521
+ const docName = msg.doc as string;
522
+ if (!docName) return;
523
+ const match = custom.find(docName);
524
+ if (!match) return; // fall through to standard
525
+
526
+ // Gate custom docs exactly like standard docs (withDoc). Without this the
527
+ // custom open handler — which registers before and short-circuits the
528
+ // standard gated handler — let an unauthenticated client read scoped data.
529
+ const g = gate(client);
530
+ if ("error" in g) return respond(g);
531
+ const { identity } = g;
532
+
533
+ try {
534
+ // As withDoc asks a type's owns: a name this identity does not own is not there for it.
535
+ if (auth && match.def.owns && !(await match.def.owns(identity as I, docName))) return respond({ error: NOT_FOUND });
536
+ const doc = await custom.open(client, docName, match, identity);
537
+ if (doc == null) return respond({ error: NOT_FOUND });
538
+ trackSubscribe(client, docName);
539
+ onClientDrop(client, releaseClient);
540
+ respond({ result: doc });
541
+ log.info(`opened ${docName} (custom${match.def.recompute ? ", recompute" : ""})`);
542
+ } catch (err) {
543
+ log.error(`open custom ${docName} failed: ${errMsg(err)}`);
544
+ respond({ error: { code: 500, message: errMsg(err) } });
545
+ }
546
+ });
547
+
548
+ ws.on("delta", (msg: any, _client: any, respond: (r: any) => void) => {
549
+ const docName = msg.doc as string;
550
+ if (!docName) return;
551
+ if (!custom.find(docName)) return; // fall through to standard
552
+ respond({ error: { code: 403, message: "Custom docs are read-only; write through the source doc." } });
553
+ });
554
+
555
+ ws.on("close", (msg: any, client: any, respond: (r: any) => void) => {
556
+ const docName = msg.doc as string;
557
+ if (!docName) return;
558
+ if (!custom.find(docName)) return; // fall through to standard
559
+ trackUnsubscribe(client, docName);
560
+ custom.leave(client, docName);
561
+ respond({ result: { ack: true } });
562
+ log.debug(`closed ${docName} (custom)`);
563
+ });
564
+ }
565
+
566
+ ws.on("open", withDoc("open", async ({ docName, type, ctx, msg, client, respond, identity }) => {
567
+ opening.set(docName, (opening.get(docName) ?? 0) + 1);
568
+ try {
569
+ // held before the read: a write from now is told to this name; one lost
570
+ // while it read (keepHolds) is held and read again
571
+ let result;
572
+ for (let held = hold(docName); ; held = hold(docName)) {
573
+ await held;
574
+ result = await type.open(ctx, docName, msg, identity);
575
+ if (holds.get(docName) === held) break;
576
+ }
577
+ if (!result) return respond({ error: NOT_FOUND });
578
+
579
+ pruneDoc(docName);
580
+ let state = tracked.get(docName);
581
+ if (!state) {
582
+ state = { version: Number(result.version), until: Number(result.version), subscribers: new Set(), notifying: false, pending: false };
583
+ tracked.set(docName, state);
584
+ } else {
585
+ state.version = Math.max(state.version, Number(result.version));
586
+ state.until = Math.max(state.until, state.version);
587
+ }
588
+
589
+ state.subscribers.add(client);
590
+ trackSubscribe(client, docName);
591
+ onClientDrop(client, releaseClient);
592
+ respond({ result: shownDoc(type, docName, result.result) });
593
+ log.info(`opened ${docName} v${state.version}`);
594
+ } finally {
595
+ const left = opening.get(docName)! - 1;
596
+ if (left > 0) opening.set(docName, left); else opening.delete(docName);
597
+ release(docName);
598
+ }
599
+ }));
600
+
601
+ ws.on("delta", withDoc("delta", async ({ docName, type, ctx, msg, client, respond, identity }) => {
602
+ const writer = writerOf(identity, client);
603
+ const by = ledger ? { who: whoOf(writer), cursor: cursorOf(msg, client, writer), undoable: msg.undoable !== false } : undefined;
604
+ const result = shownAnswer(type.def, await type.apply(ctx, docName, msg.ops, identity, by, declared));
605
+ respond({ result: ledger ? { ack: true, version: Number(result.version), ops: result.ops, inverse: result.inverse, entry: result.entry == null ? undefined : Number(result.entry) } : { ack: true, version: Number(result.version), ...(msg.inverse ? { ops: result.ops } : {}) } });
606
+ log.info(`delta ${docName} v${result.version}`);
607
+ }));
608
+
609
+ if (ledger) {
610
+ /**
611
+ * Undo or redo: the cursor's next entry, walked in the database (001g
612
+ * `delta_walk`) by its guarded plan and recorded as walking it; a conflict
613
+ * changes nothing, answers `conflict`, and moves the cursor on. `dry: true`
614
+ * answers the plan; `entry: id` walks only that entry. `change: id` walks
615
+ * one change of the cursor's instead (`_delta_change_tip`), and a conflict
616
+ * then records nothing.
617
+ */
618
+ const walk = (way: "undo" | "redo") => async (msg: any, client: any, respond: (r: any) => void) => {
619
+ const change = msg.change ?? null;
620
+ if (change !== null && !isEntryId(change)) return respond({ error: { code: 400, message: "change must be an entry's id: a whole number from 1" } });
621
+ const g = gate(client);
622
+ if ("error" in g) return respond(g);
623
+ const { identity } = g;
624
+ const db = await pool.connect();
625
+ try {
626
+ const writer = writerOf(identity, client);
627
+ const cursor = cursorOf(msg, client, writer);
628
+ await db.query("BEGIN");
629
+ // A walk writes to its entry's document, so it asks owns as delta does:
630
+ // under the cursor's lock (delta_walk takes it again), so the entry
631
+ // asked about is the one walked.
632
+ if (auth && cursor !== null) {
633
+ await db.query("SELECT pg_advisory_xact_lock(hashtext('delta-cursor:' || $1::text))", [cursor]);
634
+ const tip = (change === null
635
+ ? await db.query("SELECT doc_name FROM _delta_ledger_tip($1, $2)", [cursor, way === "undo"])
636
+ : await db.query("SELECT doc_name FROM _delta_change_tip($1, $2, $3)", [cursor, change, way === "undo"])).rows[0]?.doc_name;
637
+ const found = tip ? resolveDoc(tip) : null;
638
+ if (found?.type.owns && !(await found.type.owns(identity as I, tip))) {
639
+ await db.query("ROLLBACK");
640
+ return respond({ error: NOT_FOUND });
641
+ }
642
+ }
643
+ // a change named takes 001g's newer forms; none, the ones a database on 0.10.0's SQL has too
644
+ const args = [cursor, whoOf(writer), way === "undo", msg.dry === true, msg.entry ?? null, ...(change === null ? [] : [change])];
645
+ const walked = (await callAs(db, "delta_walk", args, auth, identity, declared)) ?? null;
646
+ const def = walked?.doc ? resolveDoc(walked.doc)?.type.def : undefined;
647
+ const result = walked?.doc ? shownAnswer(def, await showAnswer(db, walked.doc, walked, def)) : walked; // as its document shows them
648
+ await db.query("COMMIT");
649
+ respond({ result: result && { ...result, ...(result.version != null ? { version: Number(result.version) } : {}), entry: result.entry == null ? undefined : Number(result.entry) } });
650
+ } catch (err) {
651
+ try { await db.query("ROLLBACK"); } catch { /* the connection may be gone */ }
652
+ log.error(`${way} failed: ${errMsg(err)}`);
653
+ respond({ error: { code: wireCode(err), message: errMsg(err) } });
654
+ } finally {
655
+ db.release();
656
+ }
657
+ };
658
+ ws.on("undo", walk("undo"));
659
+ ws.on("redo", walk("redo"));
660
+ // A document's recent history, to whoever may open it: each entry says `mine`, never who wrote it.
661
+ ws.on("history", withDoc("history", async ({ docName, type, ctx, msg, client, respond, identity }) => {
662
+ if (!(await type.open(ctx, docName, msg, identity))) return respond({ error: NOT_FOUND });
663
+ const { rows } = await pool.query("SELECT delta_history($1, $2, $3) AS entries", [docName, cursorOf(msg, client, writerOf(identity, client)), msg.limit ?? 50]);
664
+ respond({ result: await Promise.all((rows[0]?.entries ?? []).map(async (e: any) => shownAnswer(type.def, await showAnswer(pool, docName, e, type.def)))) });
665
+ }));
666
+ }
667
+
668
+ ws.on("open_at", withDoc("open_at", async ({ docName, type, ctx, msg, respond, identity }) => {
669
+ const at = msg.at as string;
670
+ if (!at) return respond({ error: { code: 400, message: "at is required" } });
671
+ if (!type.openAt) {
672
+ return respond({ error: { code: 400, message: `open_at not supported for ${docName}` } });
673
+ }
674
+ const doc = await type.openAt(ctx, docName, at, identity);
675
+ if (!doc) return respond({ error: NOT_FOUND });
676
+ respond({ result: shownDoc(type, docName, doc) });
677
+ log.info(`open_at ${docName} @ ${at}`);
678
+ }));
679
+
680
+ ws.on("close", withDoc("close", ({ docName, client, respond }) => {
681
+ trackUnsubscribe(client, docName);
682
+ const state = tracked.get(docName);
683
+ if (state) {
684
+ state.subscribers.delete(client);
685
+ if (state.subscribers.size === 0) { tracked.delete(docName); release(docName); }
686
+ }
687
+ respond({ result: { ack: true } });
688
+ log.debug(`closed ${docName}`);
689
+ }, { owned: false }));
690
+
691
+ return {
692
+ /** Answer for one more type; one of a prefix it answers for already is replaced (`bun --hot`). */
693
+ register: registry.register as (type: DocType<any, I>) => void,
694
+ async destroy() {
695
+ destroyed = true;
696
+ try {
697
+ await listener.query("UNLISTEN *");
698
+ await listener.query("SELECT _delta_holder_stop($1)", [holder]);
699
+ } catch {}
700
+ // Released connections are recycled by the pool; stale listeners would
701
+ // fire against this closure's state on future pooled connections.
702
+ detachListener(listener);
703
+ try { listener.release(); } catch {}
704
+ },
705
+ };
706
+ }