dzql 0.10.0 → 0.12.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,53 @@
1
+ /**
2
+ * Link sign-in on Postgres: the `LinkStore` delta's `auth-link` keeps its links and sessions in
3
+ * (`@blueshed/delta/auth-link`; Workshop's docs/link-sign-in-plan.md). The tables are its own,
4
+ * `_auth_links` and `_auth_sessions`, beside delta's `_delta_*`: hashes, the person's id, times. A
5
+ * link is used in one statement, so two taps at once make one session, on one server or several.
6
+ *
7
+ * import { linkAuth } from "@blueshed/delta/auth-link";
8
+ * import { postgresLinkStore } from "dzql/auth-link";
9
+ *
10
+ * const auth = linkAuth({ store: await postgresLinkStore(pool), personOf, people });
11
+ */
12
+ import type { Pool } from "pg";
13
+ import type { KeptLink, KeptSession, LinkStore } from "@blueshed/delta/auth-link";
14
+
15
+ export { linkAuth, makeLink } from "@blueshed/delta/auth-link";
16
+
17
+ /** The links and sessions in Postgres, in tables of their own, made here (once: IF NOT EXISTS). */
18
+ export async function postgresLinkStore(pool: Pool): Promise<LinkStore> {
19
+ await pool.query(`CREATE TABLE IF NOT EXISTS _auth_links (hash TEXT PRIMARY KEY, person BIGINT NOT NULL, opens TEXT, expires TIMESTAMPTZ NOT NULL, used TIMESTAMPTZ)`);
20
+ await pool.query(`CREATE TABLE IF NOT EXISTS _auth_sessions (hash TEXT PRIMARY KEY, person BIGINT NOT NULL, expires TIMESTAMPTZ NOT NULL)`);
21
+ // Times as delta's store has them: ISO strings; a person's id a number (pg reads BIGINT as text)
22
+ const iso = (t: Date | string | null) => (t === null ? null : new Date(t).toISOString());
23
+ const SESSION = "SELECT person::int AS person, expires FROM _auth_sessions WHERE hash = $1";
24
+ return {
25
+ async addLink(hash, l) {
26
+ await pool.query("INSERT INTO _auth_links (hash, person, opens, expires) VALUES ($1, $2, $3, $4)", [hash, l.person, l.opens, l.expires]);
27
+ },
28
+ async useLink(hash, now) {
29
+ const { rows } = await pool.query(
30
+ "UPDATE _auth_links SET used = $1 WHERE hash = $2 AND used IS NULL AND expires > $1 RETURNING person::int AS person, opens",
31
+ [now, hash],
32
+ );
33
+ return rows[0] ?? null;
34
+ },
35
+ async link(hash): Promise<KeptLink | null> {
36
+ const { rows } = await pool.query("SELECT person::int AS person, opens, expires, used FROM _auth_links WHERE hash = $1", [hash]);
37
+ return rows[0] ? { ...rows[0], expires: iso(rows[0].expires)!, used: iso(rows[0].used) } : null;
38
+ },
39
+ async addSession(hash, s) {
40
+ await pool.query("INSERT INTO _auth_sessions (hash, person, expires) VALUES ($1, $2, $3)", [hash, s.person, s.expires]);
41
+ },
42
+ async session(hash): Promise<KeptSession | null> {
43
+ const { rows } = await pool.query(SESSION, [hash]);
44
+ return rows[0] ? { person: rows[0].person, expires: iso(rows[0].expires)! } : null;
45
+ },
46
+ async renewSession(hash, expires) {
47
+ await pool.query("UPDATE _auth_sessions SET expires = $1 WHERE hash = $2", [expires, hash]);
48
+ },
49
+ async endSession(hash) {
50
+ await pool.query("DELETE FROM _auth_sessions WHERE hash = $1", [hash]);
51
+ },
52
+ };
53
+ }
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * dzql's Postgres commands, as delta's CLI had them: `dzql sql`, `dzql init`
4
- * and `dzql migrate` (a postgres:// URL or a PGlite directory). Run through
5
- * `dzql <command>` (src/cli/index.ts hands these three here).
3
+ * dzql's Postgres commands, as delta's CLI had them: `dzql sql`, `dzql init`,
4
+ * `dzql migrate` (a postgres:// URL or a PGlite directory), and `dzql link`. Run through
5
+ * `dzql <command>` (src/cli/index.ts hands these four here).
6
6
  */
7
7
  import { parseArgs } from "node:util";
8
8
  import { resolve, basename, join, dirname } from "node:path";
@@ -26,6 +26,8 @@ const USAGE =
26
26
  ` dzql init <dir> [--with-auth] [--upgrade]\n` +
27
27
  ` dzql migrate <database> [--app <name>] [--dir <migrations>] [--status] [--json]\n` +
28
28
  ` <database>: a PGlite directory or a postgres:// URL; --dir defaults to ./migrations\n` +
29
+ ` dzql link <database> <person> [--origin <url>] [--opens <path>] [--json]\n` +
30
+ ` a link to sign in as someone (link sign-in): the first person's, on the server\n` +
29
31
  `\n` +
30
32
  `Exit codes: 0 done, 1 an error, 2 a bad argument.\n`;
31
33
 
@@ -299,6 +301,41 @@ async function cmdMigrate(target: string | undefined, values: Record<string, unk
299
301
  if (!r.fresh && r.ran.length === 0) process.stdout.write("nothing to run\n");
300
302
  }
301
303
 
304
+ // ---------------------------------------------------------------------------
305
+ // dzql link
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /**
309
+ * A link to sign in as `person` (link sign-in, `dzql/auth-link`), made in the app's Postgres: how
310
+ * the first person signs in, before anyone can send them one. The site is --origin, else ORIGIN,
311
+ * else Railway's domain. The database as `migrate` takes it. A SQLite file is delta's (`delta link`).
312
+ */
313
+ async function cmdLink(target: string | undefined, person: string | undefined, values: Record<string, unknown>) {
314
+ if (!target || !person) usageError("link takes a <database> and a <person>: dzql link postgres://… 1", true);
315
+ if (!/^\d+$/.test(person)) usageError(`<person> is a person's id, a number: ${person}`);
316
+ const isUrl = /^postgres(ql)?:\/\//.test(target);
317
+ const path = resolve(process.cwd(), target);
318
+ if (!isUrl && (!existsSync(path) || !statSync(path).isDirectory())) usageError(`no PGlite database at ${target} (a SQLite file is delta's: bunx @blueshed/delta link)`);
319
+ const { makeLink, siteOrigin } = await import("@blueshed/delta/auth-link");
320
+ let origin: string;
321
+ try { origin = (values.origin as string | undefined) ?? siteOrigin(); }
322
+ catch (err) { usageError(`${(err as Error).message}, or pass --origin`); }
323
+ const { postgresLinkStore } = await import("./auth-link");
324
+ let pool: any;
325
+ if (isUrl) {
326
+ const { Pool } = await import("pg");
327
+ pool = new Pool({ connectionString: target });
328
+ } else {
329
+ const { openPglite } = await import("./pglite");
330
+ pool = await openPglite(path);
331
+ }
332
+ try {
333
+ const made = await makeLink(await postgresLinkStore(pool), Number(person), { origin, opens: values.opens as string | undefined });
334
+ if (json) return print(made);
335
+ process.stdout.write(`${made.url}\n(used once, until ${made.expires})\n`);
336
+ } finally { await pool.end(); }
337
+ }
338
+
302
339
  // ---------------------------------------------------------------------------
303
340
  // main
304
341
  // ---------------------------------------------------------------------------
@@ -318,6 +355,8 @@ export async function main(argv: string[]) {
318
355
  dir: { type: "string" },
319
356
  app: { type: "string" },
320
357
  status: { type: "boolean", default: false },
358
+ origin: { type: "string" },
359
+ opens: { type: "string" },
321
360
  help: { type: "boolean", short: "h", default: false },
322
361
  },
323
362
  allowPositionals: true,
@@ -327,11 +366,12 @@ export async function main(argv: string[]) {
327
366
  const { values, positionals } = parsed;
328
367
  json = !!values.json;
329
368
  if (values.help) { process.stdout.write(USAGE); return; }
330
- const [cmd, arg] = positionals;
369
+ const [cmd, arg, arg2] = positionals;
331
370
  switch (cmd) {
332
371
  case "sql": return cmdSql(arg, values);
333
372
  case "init": return cmdInit(arg, values);
334
373
  case "migrate": return cmdMigrate(arg, values);
374
+ case "link": return cmdLink(arg, arg2, values);
335
375
  default: usageError(cmd ? `no command ${cmd}` : "a command, please", true);
336
376
  }
337
377
  }
@@ -36,6 +36,7 @@ import { compileColumns } from "../compile/columns";
36
36
  import { compileRows } from "../compile/rows";
37
37
  import { compileCascades } from "../compile/cascades";
38
38
  import { compileLedger } from "../compile/ledger";
39
+ import { compileLookups } from "../compile/lookups";
39
40
  import { compileTold } from "../compile/told";
40
41
 
41
42
  export interface GenerateSqlOptions {
@@ -180,6 +181,6 @@ export function generateSql(
180
181
  lines.push("");
181
182
 
182
183
  // each document opened by SQL compiled for it (dzql), which docTypeFromDef calls
183
- 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));
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));
184
185
  return lines.join("\n");
185
186
  }
@@ -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);
@@ -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_framework(
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,16 +223,16 @@ BEGIN
178
223
  END;
179
224
  $$;
180
225
 
181
- -- The checks every write asks: the framework's, until dzql's compiler
182
- -- (`compileColumns`) replaces this with one that checks each column set it
183
- -- compiled by its own statements, and any other by the framework's.
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.
184
229
  CREATE OR REPLACE FUNCTION _delta_assert_fields(
185
230
  p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB
186
- ) RETURNS void LANGUAGE sql IMMUTABLE AS $$ SELECT _delta_assert_fields_framework(p_collection, p_columns, p_parent_fk, p_value); $$;
231
+ ) RETURNS void LANGUAGE sql IMMUTABLE AS $$ SELECT _delta_assert_fields_declared(p_collection, p_columns, p_parent_fk, p_value); $$;
187
232
 
188
233
  -- Pick the source relation for a collection.
189
234
  -- temporal + no timestamp → current_<table> view
190
- -- temporal + timestamp → base <table> (caller filters with _delta_temporal_where)
235
+ -- temporal + timestamp → base <table> (the caller filters by its time)
191
236
  -- non-temporal → base <table>
192
237
  CREATE OR REPLACE FUNCTION _delta_source_view(
193
238
  p_table_name TEXT, p_temporal BOOLEAN, p_at TIMESTAMPTZ DEFAULT NULL
@@ -197,15 +242,3 @@ CREATE OR REPLACE FUNCTION _delta_source_view(
197
242
  ELSE p_table_name
198
243
  END;
199
244
  $$;
200
-
201
- -- Temporal WHERE fragment for embedding into format()-built dynamic SQL.
202
- -- Inlines the timestamp as a %L literal so callers don't need extra param binding.
203
- -- Aliases the row with `p_alias` (default `t`).
204
- CREATE OR REPLACE FUNCTION _delta_temporal_where(
205
- p_at TIMESTAMPTZ, p_alias TEXT DEFAULT 't'
206
- ) RETURNS text LANGUAGE sql IMMUTABLE AS $$
207
- SELECT format(
208
- '%s.valid_from <= %L AND (%s.valid_to IS NULL OR %s.valid_to > %L)',
209
- p_alias, p_at, p_alias, p_alias, p_at
210
- );
211
- $$;