dzql 0.9.0 → 0.11.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,88 @@
1
+ /**
2
+ * Compiled write scope: may a document write a row? "You may write what you
3
+ * may read": every row-addressed op in a write is asked first (`delta_apply`,
4
+ * `_delta_row_in_scope`), and a row it may not read is answered as one that is
5
+ * not there.
6
+ *
7
+ * `compileInScope(schema, docs)` writes `dzql_in_scope__<slug>(name,
8
+ * collection, id)` for each document, answering what the framework's
9
+ * `_delta_row_in_scope` answers, from its definition: a list document holds
10
+ * its included collections whole and its root rows by the name's conditions;
11
+ * a single document holds a row whose parent chain, walked up through the
12
+ * tables as they stand, reaches its root, which meets the rest of its name's
13
+ * scope. And `_delta_row_in_scope` itself, which sends each document to its
14
+ * own and refuses a name of none (`no doc def for`: the framework that read the rest
15
+ * retired, todo #26).
16
+ */
17
+ import { q, type DocDef, type Schema } from "@blueshed/delta/backend";
18
+ import { lit, named, need, noDoc, scopeOf, currentOnly } from "./opens";
19
+
20
+ /** A document's write-scope function: `dzql_in_scope__<slug>`. */
21
+ export const inScopeFunction = (prefix: string): string => named("in_scope", prefix);
22
+
23
+
24
+ /** One document's write-scope function. */
25
+ function inScopeOf(schema: Schema, doc: DocDef): string {
26
+ const root = need(schema, doc, doc.root);
27
+ const scope = scopeOf(schema, doc); // a value its columns cannot take: refused, as the framework's resolver refuses it
28
+ const conds = scope.conds ? ` AND ${scope.conds}` : "";
29
+ // the root row of a single document: its id, and (where the name scopes more than the id) the rest of its scope
30
+ const others = Object.keys(doc.scope ?? {}).some((k) => k !== "id");
31
+ const rootMeets = (id: string) => others
32
+ ? `COALESCE(${id} = v_id, FALSE) AND EXISTS (SELECT 1 FROM ${q(root.name)} t WHERE t.id = v_id${currentOnly(root, "t")}${conds})`
33
+ : `COALESCE(${id} = v_id, FALSE)`;
34
+ const included = new Set(doc.include);
35
+
36
+ const singles = Object.entries(schema.tables).map(([coll, table]) => {
37
+ if (coll === doc.root) return ` WHEN ${lit(coll)} THEN RETURN ${rootMeets("p_id")};`;
38
+ // unparented: held whole where included; else it ends a chain short of the root
39
+ if (!table.parent) return ` WHEN ${lit(coll)} THEN RETURN ${included.has(coll) ? "TRUE" : "FALSE"};`;
40
+ let id = "p_id";
41
+ let t = table;
42
+ for (let depth = 0; depth < 32; depth++) {
43
+ id = `(SELECT x.${q(t.parent!.fkColumn)} FROM ${q(t.name)} x WHERE x.id = ${id}${currentOnly(t, "x")})`;
44
+ const parentKey = t.parent!.collection;
45
+ if (parentKey === doc.root) return ` WHEN ${lit(coll)} THEN RETURN ${rootMeets(id)};`;
46
+ const parent = schema.tables[parentKey];
47
+ if (!parent?.parent) return ` WHEN ${lit(coll)} THEN RETURN FALSE;`; // the chain ends short of the root
48
+ t = parent;
49
+ }
50
+ return ` WHEN ${lit(coll)} THEN RETURN FALSE;`;
51
+ });
52
+
53
+ return `CREATE OR REPLACE FUNCTION ${inScopeFunction(doc.prefix)}(p_name TEXT, p_coll TEXT, p_id BIGINT)
54
+ RETURNS BOOLEAN AS $$
55
+ DECLARE
56
+ p_at TIMESTAMPTZ;
57
+ v_single BOOLEAN;
58
+ v_id BIGINT;
59
+ ${scope.declare.join("\n ")}
60
+ BEGIN
61
+ IF p_id IS NULL THEN RETURN FALSE; END IF;
62
+ ${scope.body}
63
+ IF NOT v_single THEN
64
+ -- a list holds its included collections whole; its root rows by the name's conditions
65
+ IF p_coll IS DISTINCT FROM ${lit(doc.root)} THEN RETURN TRUE; END IF;
66
+ RETURN EXISTS (SELECT 1 FROM ${q(root.name)} t WHERE t.id = p_id${currentOnly(root, "t")}${conds});
67
+ END IF;
68
+ CASE p_coll
69
+ ${singles.join("\n")}
70
+ ELSE RETURN FALSE;
71
+ END CASE;
72
+ END;
73
+ $$ LANGUAGE plpgsql STABLE;`;
74
+ }
75
+
76
+ /** The SQL that answers a write's scope for each of `docs`: a function per document, and `_delta_row_in_scope` sending each to its own. */
77
+ export function compileInScope(schema: Schema, docs: DocDef[]): string {
78
+ const dispatch = `CREATE OR REPLACE FUNCTION _delta_row_in_scope(p_def _delta_docs, p_doc_name TEXT, p_collection_key TEXT, p_id BIGINT)
79
+ RETURNS BOOLEAN AS $$
80
+ BEGIN
81
+ CASE p_def.prefix
82
+ ${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${inScopeFunction(d.prefix)}(p_doc_name, p_collection_key, p_id);`).join("\n")}
83
+ ELSE ${noDoc("p_doc_name")}
84
+ END CASE;
85
+ END;
86
+ $$ LANGUAGE plpgsql STABLE;`;
87
+ return [...docs.map((d) => inScopeOf(schema, d)), dispatch, ""].join("\n\n");
88
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Compiled telling: what a document is told of a write. Asked by `_delta_tell`
3
+ * of the writer and of every document that held a row the write touched, or
4
+ * holds it now, with the write's touches and their holders.
5
+ *
6
+ * `compileTold(schema, docs)` writes `dzql_told__<slug>(name, rows)` for each
7
+ * document, answering what the framework's `_delta_told` answers, from its
8
+ * definition: whether the name makes it one row (`scopeOf`), its root, the
9
+ * columns it shows of each collection it names them for (`columns`, the row's
10
+ * id and parent key with them), and the empty root an implied one opens with
11
+ * when its root leaves. And `_delta_told` itself, which sends each document to
12
+ * its own and refuses a name of none (`no doc def for`: the framework that read the rest
13
+ * retired, todo #26).
14
+ */
15
+ import type { DocDef, Schema } from "@blueshed/delta/backend";
16
+ import { lit, named, need, noDoc, scopeOf } from "./opens";
17
+
18
+ /** A document's telling function: `dzql_told__<slug>`. */
19
+ export const toldFunction = (prefix: string): string => named("told", prefix);
20
+
21
+ /**
22
+ * A row (SQL `row`) of collection (SQL `coll`) as the document shows it, as
23
+ * `_delta_show` does: where it names the collection's columns, those, the id
24
+ * and the parent key; else whole.
25
+ */
26
+ function shown(schema: Schema, doc: DocDef, coll: string, row: string): string {
27
+ const named = Object.entries(doc.columns ?? {});
28
+ if (named.length === 0) return row;
29
+ const branches = named.map(([c, cols]) => {
30
+ const fk = schema.tables[c]?.parent?.fkColumn;
31
+ const keep = [...new Set(["id", ...(fk ? [fk] : []), ...cols])].map(lit).join(", ");
32
+ return `WHEN ${lit(c)} THEN COALESCE((SELECT jsonb_object_agg(f, v) FROM jsonb_each(${row}) AS e(f, v) WHERE f IN (${keep})), '{}'::jsonb)`;
33
+ });
34
+ return `(CASE WHEN jsonb_typeof(${row}) IS DISTINCT FROM 'object' THEN ${row} ELSE CASE ${coll} ${branches.join(" ")} ELSE ${row} END END)`;
35
+ }
36
+
37
+ /** One document's telling function. */
38
+ function toldOf(schema: Schema, doc: DocDef): string {
39
+ need(schema, doc, doc.root);
40
+ const scope = scopeOf(schema, doc); // a name its columns cannot take: refused, as the framework's resolver refuses it
41
+ const after = shown(schema, doc, "v_t->>'coll'", "v_t->'after'");
42
+ const gone = doc.implied
43
+ ? shown(schema, doc, "v_t->>'coll'", `_delta_implied_root((SELECT d FROM _delta_docs d WHERE d.prefix = ${lit(doc.prefix)}), (v_t->>'id')::BIGINT)`)
44
+ : "'null'::jsonb";
45
+ return `CREATE OR REPLACE FUNCTION ${toldFunction(doc.prefix)}(p_name TEXT, p_rows JSONB)
46
+ RETURNS JSONB AS $$
47
+ DECLARE
48
+ p_at TIMESTAMPTZ;
49
+ v_single BOOLEAN;
50
+ v_id BIGINT;
51
+ v_t JSONB;
52
+ v_told JSONB := '[]'::jsonb;
53
+ v_was BOOLEAN;
54
+ v_is BOOLEAN;
55
+ v_root BOOLEAN;
56
+ v_path TEXT;
57
+ ${scope.declare.join("\n ")}
58
+ BEGIN
59
+ ${scope.body}
60
+ FOR v_t IN SELECT jsonb_array_elements(p_rows) LOOP
61
+ v_was := (v_t->'before') ? p_name;
62
+ v_is := (v_t->'holders') ? p_name;
63
+ CONTINUE WHEN NOT v_was AND NOT v_is;
64
+ CONTINUE WHEN v_was AND v_is AND (v_t ? 'carried'); -- carried along, and its either way: unchanged for it
65
+ v_root := v_single AND (v_t->>'coll') = ${lit(doc.root)};
66
+ v_path := CASE WHEN v_root THEN _delta_build_path(v_t->>'coll') ELSE _delta_build_path(v_t->>'coll', v_t->>'id') END;
67
+ IF v_was AND v_is THEN -- stayed: what changed
68
+ v_told := v_told || COALESCE((SELECT jsonb_agg(jsonb_build_object('op', 'replace', 'path', v_path || _delta_build_path(f), 'value', v) ORDER BY f COLLATE "C")
69
+ FROM jsonb_each(${after}) AS e(f, v) WHERE v IS DISTINCT FROM v_t->'was'->f), '[]'::jsonb);
70
+ ELSIF v_is THEN
71
+ v_told := v_told || jsonb_build_array(jsonb_build_object('op', CASE WHEN v_root THEN 'replace' ELSE 'add' END, 'path', v_path, 'value', ${after}));
72
+ ELSIF v_root THEN -- gone: ${doc.implied ? "the empty root it then opens with" : "null"}
73
+ v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'replace', 'path', v_path, 'value', ${gone}));
74
+ ELSE
75
+ v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'remove', 'path', v_path));
76
+ END IF;
77
+ END LOOP;
78
+ RETURN v_told;
79
+ END;
80
+ $$ LANGUAGE plpgsql STABLE;`;
81
+ }
82
+
83
+ /** The SQL that answers what each of `docs` is told of a write: a function per document, and `_delta_told` sending each to its own. */
84
+ export function compileTold(schema: Schema, docs: DocDef[]): string {
85
+ const dispatch = `CREATE OR REPLACE FUNCTION _delta_told(p_def _delta_docs, p_target TEXT, p_rows JSONB)
86
+ RETURNS JSONB AS $$
87
+ BEGIN
88
+ CASE p_def.prefix
89
+ ${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${toldFunction(d.prefix)}(p_target, p_rows);`).join("\n")}
90
+ ELSE ${noDoc("p_target")}
91
+ END CASE;
92
+ END;
93
+ $$ LANGUAGE plpgsql STABLE;`;
94
+ return [...docs.map((d) => toldOf(schema, d)), dispatch, ""].join("\n\n");
95
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * These are split out of `auth-jwt.ts` so the `delta init` CLI (which only
5
5
  * needs the file path) can run in projects that haven't installed jose.
6
- * Consumers of the `@blueshed/delta/auth-jwt` subpath still get these via
6
+ * Consumers of the `dzql/auth-jwt` subpath still get these via
7
7
  * re-export from `auth-jwt.ts`.
8
8
  */
9
9
  import type { Pool } from "pg";
@@ -2,7 +2,7 @@
2
2
  * Auth-JWT — reference `DeltaAuth` implementation using JWTs (jose) and
3
3
  * Postgres stored functions for credential verification.
4
4
  *
5
- * Ship this as an opt-in subpath: `@blueshed/delta/auth-jwt`. Users who want
5
+ * Ship this as an opt-in subpath: `dzql/auth-jwt`. Users who want
6
6
  * a different auth scheme (sessions, OAuth, magic links, none) implement
7
7
  * their own `DeltaAuth` instead.
8
8
  *
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Both are overridable via the `loginSql` / `registerSql` options.
15
15
  *
16
- * import { jwtAuth } from "@blueshed/delta/auth-jwt";
16
+ * import { jwtAuth } from "dzql/auth-jwt";
17
17
  *
18
18
  * const auth = jwtAuth({
19
19
  * pool,
@@ -4,7 +4,7 @@
4
4
  * consumer's `init_db/` directory.
5
5
  *
6
6
  * import { Pool } from "pg";
7
- * import { applyFramework } from "@blueshed/delta/postgres";
7
+ * import { applyFramework } from "dzql/postgres";
8
8
  *
9
9
  * const pool = new Pool({ connectionString: process.env.PG_URL });
10
10
  * await applyFramework(pool);
@@ -23,7 +23,7 @@ const FRAMEWORK_SQL_DIR = join(import.meta.dir, "..", "sql");
23
23
 
24
24
  /** Absolute paths to the framework SQL files, in apply-order.
25
25
  * Filters to `001*-*.sql` — siblings like `auth-jwt.sql` are applied
26
- * separately (see `@blueshed/delta/auth-jwt` → `applyAuthJwtSchema`). */
26
+ * separately (see `dzql/auth-jwt` → `applyAuthJwtSchema`). */
27
27
  export function frameworkSqlFiles(): string[] {
28
28
  return readdirSync(FRAMEWORK_SQL_DIR)
29
29
  .filter((f) => /^001[a-z]?-.+\.sql$/.test(f))
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Drive it from your project's `types.ts`:
16
16
  *
17
- * import { generateSql } from "@blueshed/delta/postgres";
17
+ * import { generateSql } from "dzql/postgres";
18
18
  * import { schema, docs } from "./types";
19
19
  * await Bun.write("init_db/003-tables.sql", generateSql(schema, docs));
20
20
  *
@@ -27,6 +27,17 @@ import { lit, columnSqlType, sqlDefault, findCascadeOn } from "./sql";
27
27
  import { defaultForType, q } from "@blueshed/delta/backend";
28
28
  import { assertScopeKeys } from "@blueshed/delta/backend";
29
29
  import { compileOpens } from "../compile/opens";
30
+ import { compileHolds } from "../compile/holds";
31
+ import { compileInScope } from "../compile/scope";
32
+ import { compileHolders } from "../compile/holders";
33
+ import { compileResolve } from "../compile/resolve";
34
+ import { compileRootThere } from "../compile/root";
35
+ import { compileColumns } from "../compile/columns";
36
+ import { compileRows } from "../compile/rows";
37
+ import { compileCascades } from "../compile/cascades";
38
+ import { compileLedger } from "../compile/ledger";
39
+ import { compileLookups } from "../compile/lookups";
40
+ import { compileTold } from "../compile/told";
30
41
 
31
42
  export interface GenerateSqlOptions {
32
43
  /** Header comment included at the top of the file. */
@@ -170,6 +181,6 @@ export function generateSql(
170
181
  lines.push("");
171
182
 
172
183
  // each document opened by SQL compiled for it (dzql), which docTypeFromDef calls
173
- lines.push("", compileOpens(schema, docs));
184
+ lines.push("", compileOpens(schema, docs), "", compileHolds(schema, docs), "", compileInScope(schema, docs), "", compileTold(schema, docs), "", compileHolders(schema, docs), "", compileResolve(schema, docs), "", compileRootThere(schema, docs), "", compileColumns(schema, docs), "", compileRows(schema), "", compileCascades(schema), "", compileLedger(schema, docs), "", compileLookups(schema, docs));
174
185
  return lines.join("\n");
175
186
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @blueshed/delta/postgres — Postgres backend for delta-doc.
2
+ * dzql/postgres — delta's Postgres backend, compiled from its definitions.
3
3
  *
4
4
  * Schema + stored functions + LISTEN dispatch. Tables are generated from
5
5
  * a TypeScript schema definition; all mutations go through stored functions
@@ -9,7 +9,7 @@
9
9
  * import {
10
10
  * defineSchema, defineDoc,
11
11
  * createDocListener, docTypeFromDef,
12
- * } from "@blueshed/delta/postgres";
12
+ * } from "dzql/postgres";
13
13
  *
14
14
  * The stored functions are `src/sql/001*.sql`: `applyFramework(pool)` applies
15
15
  * them, or `bunx @blueshed/delta init <dir>` copies them for a migration tool
@@ -89,12 +89,15 @@ function errMsg(err: unknown): string {
89
89
  * entry that is not the cursor's next (409). And 40P01, a write that met another
90
90
  * at once and gave way to it (two adds of temporal ids in opposite orders wait
91
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).
92
+ * sent again goes through, or meets the rows the other made (409). A document
93
+ * type that refuses with a wire code of its own (a number, 400 to 499: a
94
+ * read-only document's 403) is answered with it. Anything else is the
95
+ * server's (500).
94
96
  */
95
97
  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
98
  function wireCode(err: unknown): number {
97
99
  const state = (err as { code?: unknown } | null)?.code;
100
+ if (typeof state === "number" && state >= 400 && state < 500) return state;
98
101
  return (typeof state === "string" && CODE_OF_SQLSTATE[state]) || 500;
99
102
  }
100
103
 
@@ -122,6 +125,15 @@ interface DocState {
122
125
  /** _delta_fetch_log (delta_fetch_ops' rows) caps each call at this many; a full page means there may be more. */
123
126
  const FETCH_PAGE = 1000;
124
127
 
128
+ /**
129
+ * What a custom document hears of a log entry: each write once, as it was applied. The writer's
130
+ * entry carries it (`applied`) where the writer was told otherwise, and null where it was told the
131
+ * write as applied; each other told document's entry of the write carries none ([]). Told, a row
132
+ * that left a document is a remove, which is not a delete, and a row told to two documents would be
133
+ * heard twice (todo #44). An entry logged some other way carries null too: heard as told.
134
+ */
135
+ const appliedOf = (row: any): DeltaOp[] => (row.applied ?? row.ops) as DeltaOp[];
136
+
125
137
  /**
126
138
  * How the listener reads a document's log. `_delta_fetch_log` (001e) gives each
127
139
  * entry with the write as applied, for custom documents (todo #44). Framework
@@ -250,7 +262,7 @@ export async function createDocListener<I = unknown>(
250
262
  // are recycled by the pool, and a stale listener would fire against this
251
263
  // closure's state (and spuriously trigger reconnect) on a future checkout.
252
264
  function detachListener(client: any) {
253
- try { client.removeListener?.("notification", onNotification); } catch {}
265
+ try { client.removeListener?.("notification", heard); } catch {}
254
266
  if (errorHandler) {
255
267
  try { client.removeListener?.("error", errorHandler); } catch {}
256
268
  }
@@ -259,7 +271,7 @@ export async function createDocListener<I = unknown>(
259
271
  /** LISTEN on `client`, and keep this holder's lock on it. */
260
272
  async function setupListener(client: any): Promise<void> {
261
273
  await client.query("LISTEN delta_changes");
262
- client.on("notification", onNotification);
274
+ client.on("notification", heard);
263
275
  errorHandler = (err: Error) => {
264
276
  log.error(`listener error: ${err.message}`);
265
277
  if (!destroyed) reconnect();
@@ -306,7 +318,7 @@ export async function createDocListener<I = unknown>(
306
318
  state.until = Number.MAX_SAFE_INTEGER; // the notifications while away are gone: read to the end
307
319
  if (state.notifying) { state.pending = true; continue; }
308
320
  state.notifying = true;
309
- try { await drainDoc(docName, state); }
321
+ try { await drainDoc(docName, state, true); }
310
322
  catch (err) { log.error(`resync ${docName}: ${errMsg(err)}`); }
311
323
  finally { state.notifying = false; }
312
324
  }
@@ -320,28 +332,38 @@ export async function createDocListener<I = unknown>(
320
332
  throw err;
321
333
  }
322
334
 
323
- // Notification handler — fans out LISTEN/NOTIFY to WS subscribers, plus
324
- // custom-doc cross-pollination when any predicate-based doc is registered.
335
+ // A document's own subscribers need only its own order, so each document drains on its own
336
+ // (onNotification), and none waits on another's. A custom document hears writes made through
337
+ // every document, so it is told them one after another, as Postgres delivers the notifications
338
+ // (in commit order): told from each document's drain, a slow read of one told it a later write,
339
+ // made through another, first.
340
+ let inCommitOrder: Promise<void> = Promise.resolve();
341
+ function heard(msg: any) {
342
+ void onNotification(msg);
343
+ if (custom.size > 0) inCommitOrder = inCommitOrder.then(() => hearCustom(msg));
344
+ }
345
+
346
+ /** The custom documents told the one write a notification announces: its document's entry at that version. */
347
+ async function hearCustom(msg: any) {
348
+ if (destroyed || msg.channel !== "delta_changes" || !msg.payload) return;
349
+ try {
350
+ const { doc: docName, v } = JSON.parse(msg.payload);
351
+ const rows = (await pool.query(fetchSql, [docName, v - 1])).rows.filter((row: any) => Number(row.version) === v);
352
+ if (auth && rows.length > 0) await custom.letGoWatching(rows.flatMap(appliedOf));
353
+ for (const row of rows) custom.tell(appliedOf(row));
354
+ } catch (err) {
355
+ log.error(`notify error (custom): ${errMsg(err)}`);
356
+ }
357
+ }
358
+
359
+ // Notification handler — fans out LISTEN/NOTIFY to the document's WS subscribers.
325
360
  async function onNotification(msg: any) {
326
361
  if (destroyed) return;
327
362
  if (msg.channel !== "delta_changes" || !msg.payload) return;
328
363
  try {
329
364
  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;
365
+ const state = tracked.get(docName);
366
+ if (!state || v <= state.version) return;
345
367
  state.until = Math.max(state.until, v);
346
368
  if (state.notifying) {
347
369
  // A fetch is already running for this doc. Coalesce instead of dropping:
@@ -364,8 +386,9 @@ export async function createDocListener<I = unknown>(
364
386
  // Drain a doc's ops to its subscribers. Loops while a full page comes back
365
387
  // (a backlog larger than the fetch's LIMIT) and re-runs if another
366
388
  // notification arrived mid-drain (the coalesced `pending` flag). Caller owns
367
- // the `notifying` guard.
368
- async function drainDoc(docName: string, state: DocState) {
389
+ // the `notifying` guard. After a reconnect (`resync`) the custom documents
390
+ // are told what it reads too: the notifications while away are gone.
391
+ async function drainDoc(docName: string, state: DocState, resync = false) {
369
392
  do {
370
393
  state.pending = false;
371
394
  let more = true;
@@ -374,11 +397,10 @@ export async function createDocListener<I = unknown>(
374
397
  // only what a notification has announced (`until`): the rest is heard with its own
375
398
  const rows = page.filter((row: any) => Number(row.version) <= state.until);
376
399
  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.
400
+ // Told to those who may still hear it (todo #17): the document's own subscribers
379
401
  if (auth && rows.length > 0) {
380
402
  await custom.letGo(docName, state.subscribers, ownsOf(docName));
381
- await custom.letGoWatching(rows.flatMap((row: any) => (row.applied ?? row.ops) as DeltaOp[]));
403
+ if (resync) await custom.letGoWatching(rows.flatMap(appliedOf));
382
404
  }
383
405
  for (const row of rows) {
384
406
  if (state.subscribers.size > 0) {
@@ -390,14 +412,7 @@ export async function createDocListener<I = unknown>(
390
412
  ws.publish(docName, { doc: docName, ops: shownOps(resolveDoc(docName)?.type.def, row.ops), v: Number(row.version) });
391
413
  }
392
414
  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[]);
415
+ if (resync) custom.tell(appliedOf(row));
401
416
  }
402
417
  }
403
418
  } while (state.pending);
@@ -4,7 +4,7 @@
4
4
  * The same stored functions, the same listener, the same documents as a
5
5
  * Postgres server, on PGlite (Postgres compiled to WASM): no database to run,
6
6
  * the data in a directory (or in memory). `openPglite()` answers the slice of
7
- * `pg`'s Pool delta uses, so everything in `@blueshed/delta/postgres` runs on
7
+ * `pg`'s Pool delta uses, so everything in `dzql/postgres` runs on
8
8
  * it unchanged:
9
9
  *
10
10
  * const pool = await openPglite("./data"); // or openPglite() in memory
@@ -137,6 +137,9 @@ export async function openPglite(dir?: string): Promise<Pool> {
137
137
  } catch {
138
138
  throw new Error("[delta/pglite] @electric-sql/pglite is not installed -- bun add @electric-sql/pglite");
139
139
  }
140
- const db: PGliteish = dir ? await PGlite.create(dir) : await PGlite.create();
140
+ // pgcrypto, which dzql/auth-jwt hashes passwords with: PGlite has an extension only when it is
141
+ // given one at start (loaded when a database creates it)
142
+ const { pgcrypto } = await import("@electric-sql/pglite/contrib/pgcrypto");
143
+ const db: PGliteish = await PGlite.create(dir, { extensions: { pgcrypto } });
141
144
  return pglitePool(db);
142
145
  }
@@ -30,6 +30,49 @@ ALTER TABLE _delta_docs ADD COLUMN IF NOT EXISTS implied BOOLEAN NOT NULL DEFAUL
30
30
  -- collection not named, whole. It is opened, told and written by those alone.
31
31
  ALTER TABLE _delta_docs ADD COLUMN IF NOT EXISTS held JSONB NOT NULL DEFAULT '{}';
32
32
 
33
+ -- The framework that read the definitions at run time retired (todo #26): each
34
+ -- document and collection is compiled by generateSql (dzql/compile). Dropped
35
+ -- where an earlier version made them.
36
+ DROP FUNCTION IF EXISTS _delta_collection_framework(text);
37
+ DROP FUNCTION IF EXISTS _delta_assert_fields_framework(text, jsonb, text, jsonb);
38
+ DROP FUNCTION IF EXISTS _delta_temporal_where(timestamptz, text);
39
+ DROP FUNCTION IF EXISTS _delta_find_doc_framework(text);
40
+ DROP FUNCTION IF EXISTS _delta_resolve_scope_framework(_delta_docs, text, boolean);
41
+ DROP FUNCTION IF EXISTS _delta_row_in_scope_framework(_delta_docs, text, text, bigint);
42
+ DROP FUNCTION IF EXISTS _delta_chain_root(text, jsonb, text);
43
+ DROP FUNCTION IF EXISTS _delta_doc_holds_framework(_delta_docs, text, text, jsonb);
44
+ DROP FUNCTION IF EXISTS _delta_holders_framework(text, jsonb, text, text[]);
45
+ DROP FUNCTION IF EXISTS _delta_load_collection(text, text, bigint, timestamptz);
46
+ DROP FUNCTION IF EXISTS _delta_load_collection_all(text, timestamptz);
47
+ DROP FUNCTION IF EXISTS _delta_read_list(_delta_docs, text, timestamptz);
48
+ DROP FUNCTION IF EXISTS _delta_show_doc(_delta_docs, jsonb, boolean);
49
+ DROP FUNCTION IF EXISTS _delta_open_framework(text, boolean);
50
+ DROP FUNCTION IF EXISTS _delta_serial_past_framework(text, bigint);
51
+ DROP FUNCTION IF EXISTS _delta_serial_new_framework(text, bigint);
52
+ DROP FUNCTION IF EXISTS _delta_cascade_remove_framework(text, bigint);
53
+ DROP FUNCTION IF EXISTS _delta_cascade_rows_framework(text, bigint);
54
+ DROP FUNCTION IF EXISTS _delta_hanging_rows_framework(text, bigint);
55
+ DROP FUNCTION IF EXISTS _delta_told_framework(_delta_docs, text, jsonb);
56
+ DROP FUNCTION IF EXISTS _delta_row_there_framework(text, bigint);
57
+ DROP FUNCTION IF EXISTS _delta_held_columns(_delta_docs, text, jsonb);
58
+ DROP FUNCTION IF EXISTS _delta_root_there_framework(_delta_docs, text, jsonb, text, bigint);
59
+ DROP FUNCTION IF EXISTS _delta_write_columns_framework(_delta_docs, text, jsonb);
60
+ DROP FUNCTION IF EXISTS _delta_row_lock_framework(text, text, boolean, bigint);
61
+ DROP FUNCTION IF EXISTS _delta_row_put_framework(text, text, boolean, bigint, jsonb, timestamptz);
62
+ DROP FUNCTION IF EXISTS _delta_next_id_framework(text, text);
63
+ DROP FUNCTION IF EXISTS _delta_row_clear_moment_framework(text, text, bigint, timestamptz);
64
+ DROP FUNCTION IF EXISTS _delta_row_add_framework(text, text, jsonb);
65
+ DROP FUNCTION IF EXISTS _delta_row_read_framework(text, text, bigint);
66
+ DROP FUNCTION IF EXISTS _delta_open_at_framework(text, timestamptz);
67
+ DROP FUNCTION IF EXISTS _delta_same_framework(text, text, jsonb, jsonb);
68
+ DROP FUNCTION IF EXISTS _delta_row_standing_framework(text, bigint);
69
+
70
+ -- _delta_collection: a collection's definition (NULL fields: there is none).
71
+ -- generateSql writes each collection in (compileLookups); before it is applied,
72
+ -- there is none.
73
+ CREATE OR REPLACE FUNCTION _delta_collection(p_key TEXT)
74
+ RETURNS _delta_collections LANGUAGE sql STABLE AS $$ SELECT NULL::_delta_collections; $$;
75
+
33
76
  CREATE TABLE IF NOT EXISTS _delta_versions (
34
77
  doc_name TEXT PRIMARY KEY,
35
78
  version BIGINT NOT NULL DEFAULT 0
@@ -135,8 +178,10 @@ $$;
135
178
  -- not have, or gives a column a value not of its type (SQLSTATE 22023 → 400).
136
179
  -- It used to be merged into the broadcast row, acked, and dropped (or cast) by
137
180
  -- jsonb_populate_record: stored otherwise than told. id, the parent key and
138
- -- the temporal columns are the row's own.
139
- CREATE OR REPLACE FUNCTION _delta_assert_fields(
181
+ -- the temporal columns are the row's own. By the columns given (`p_columns`): a
182
+ -- set the compiler did not write in (another version's, `delta.declared`; a
183
+ -- walk's, as its entry was stored) is checked here.
184
+ CREATE OR REPLACE FUNCTION _delta_assert_fields_declared(
140
185
  p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB
141
186
  ) RETURNS void LANGUAGE plpgsql IMMUTABLE AS $$
142
187
  DECLARE
@@ -178,9 +223,16 @@ BEGIN
178
223
  END;
179
224
  $$;
180
225
 
226
+ -- The checks every write asks: generateSql (`compileColumns`) replaces this
227
+ -- with one that checks each column set it compiled by its own statements, and
228
+ -- any other by the columns given.
229
+ CREATE OR REPLACE FUNCTION _delta_assert_fields(
230
+ p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB
231
+ ) RETURNS void LANGUAGE sql IMMUTABLE AS $$ SELECT _delta_assert_fields_declared(p_collection, p_columns, p_parent_fk, p_value); $$;
232
+
181
233
  -- Pick the source relation for a collection.
182
234
  -- temporal + no timestamp → current_<table> view
183
- -- temporal + timestamp → base <table> (caller filters with _delta_temporal_where)
235
+ -- temporal + timestamp → base <table> (the caller filters by its time)
184
236
  -- non-temporal → base <table>
185
237
  CREATE OR REPLACE FUNCTION _delta_source_view(
186
238
  p_table_name TEXT, p_temporal BOOLEAN, p_at TIMESTAMPTZ DEFAULT NULL
@@ -190,15 +242,3 @@ CREATE OR REPLACE FUNCTION _delta_source_view(
190
242
  ELSE p_table_name
191
243
  END;
192
244
  $$;
193
-
194
- -- Temporal WHERE fragment for embedding into format()-built dynamic SQL.
195
- -- Inlines the timestamp as a %L literal so callers don't need extra param binding.
196
- -- Aliases the row with `p_alias` (default `t`).
197
- CREATE OR REPLACE FUNCTION _delta_temporal_where(
198
- p_at TIMESTAMPTZ, p_alias TEXT DEFAULT 't'
199
- ) RETURNS text LANGUAGE sql IMMUTABLE AS $$
200
- SELECT format(
201
- '%s.valid_from <= %L AND (%s.valid_to IS NULL OR %s.valid_to > %L)',
202
- p_alias, p_at, p_alias, p_alias, p_at
203
- );
204
- $$;