dzql 0.8.0 → 0.10.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,339 @@
1
+ #!/usr/bin/env bun
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).
6
+ */
7
+ import { parseArgs } from "node:util";
8
+ import { resolve, basename, join, dirname } from "node:path";
9
+ import { mkdirSync, copyFileSync, existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
10
+ import { generateSql } from "./codegen";
11
+ import { frameworkSqlFiles } from "./bootstrap";
12
+ import { authJwtSqlFile } from "./auth-jwt-sql";
13
+ import type { Schema, DocDef } from "./index";
14
+
15
+ const PKG_VERSION = (() => {
16
+ try {
17
+ return String(JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8")).version ?? "0.0.0");
18
+ } catch {
19
+ return "0.0.0";
20
+ }
21
+ })();
22
+
23
+ const USAGE =
24
+ `Usage:\n` +
25
+ ` dzql sql <module> [--out <file>] [--force] [--schema-export <name>] [--docs-export <name>]\n` +
26
+ ` dzql init <dir> [--with-auth] [--upgrade]\n` +
27
+ ` dzql migrate <database> [--app <name>] [--dir <migrations>] [--status] [--json]\n` +
28
+ ` <database>: a PGlite directory or a postgres:// URL; --dir defaults to ./migrations\n` +
29
+ `\n` +
30
+ `Exit codes: 0 done, 1 an error, 2 a bad argument.\n`;
31
+
32
+ let json = process.argv.includes("--json");
33
+
34
+ const print = (result: unknown) => process.stdout.write(JSON.stringify(result ?? null, null, json ? undefined : 2) + "\n");
35
+
36
+ function fail(err: unknown, exit = 1): never {
37
+ const error = { code: 0, message: err instanceof Error ? err.message : String(err) };
38
+ process.stderr.write((json ? JSON.stringify({ error }) : `dzql: ${error.message}`) + "\n");
39
+ process.exit(exit);
40
+ }
41
+
42
+ function usageError(why: string, show = false): never {
43
+ if (show && !json) process.stderr.write(USAGE + "\n");
44
+ fail(why, 2);
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Header handling — identifies files written by dzql init (stamped with this
49
+ // package's version) so upgrades can read them back and avoid clobbering
50
+ // user-owned SQL, and a `diff -u` against a later `init --upgrade` shows what changed.
51
+ // ---------------------------------------------------------------------------
52
+
53
+ // a file `delta init` vendored (stamped @blueshed/delta) is older than any of dzql's: always upgraded
54
+ const HEADER_RE = /^-- @blueshed\/(delta|dzql) ([a-z0-9-]+) v(\d+\.\d+\.\d+)/;
55
+
56
+ function readHeader(path: string): { pkg: string; kind: string; version: string } | null {
57
+ if (!existsSync(path)) return null;
58
+ const first = readFileSync(path, "utf8").split("\n", 1)[0] ?? "";
59
+ const m = first.match(HEADER_RE);
60
+ return m ? { pkg: m[1]!, kind: m[2]!, version: m[3]! } : null;
61
+ }
62
+
63
+ function writeWithHeader(src: string, dest: string, kind: "framework" | "auth-jwt"): void {
64
+ const header =
65
+ `-- @blueshed/dzql ${kind} v${PKG_VERSION}\n` +
66
+ `-- Vendored by 'dzql init'. Safe to read; prefer 'dzql init --upgrade' over hand edits.\n\n`;
67
+ writeFileSync(dest, header + readFileSync(src, "utf8"));
68
+ }
69
+
70
+ /**
71
+ * Back up `dest` to `dest + ".bak"` without destroying a pre-existing backup.
72
+ * If `dest.bak` already exists, fall back to `.bak.1`, `.bak.2`, … so an
73
+ * earlier backup the user may still need is preserved. Returns the path the
74
+ * backup was written to.
75
+ */
76
+ function backupFile(dest: string): string {
77
+ let bak = dest + ".bak";
78
+ if (existsSync(bak)) {
79
+ let n = 1;
80
+ while (existsSync(`${dest}.bak.${n}`)) n++;
81
+ bak = `${dest}.bak.${n}`;
82
+ process.stderr.write(
83
+ `Note: ${basename(dest)}.bak already exists; backing up to ${basename(bak)} instead.\n`,
84
+ );
85
+ }
86
+ copyFileSync(dest, bak);
87
+ return bak;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // dzql sql
92
+ // ---------------------------------------------------------------------------
93
+
94
+ // Stable substring of the codegen header (see codegen.ts:
95
+ // "GENERATED FROM types.ts — DO NOT EDIT"). Matching on the leading words
96
+ // avoids depending on the em-dash byte sequence.
97
+ const GENERATED_MARKER = "GENERATED FROM";
98
+
99
+ async function cmdSql(modulePath: string | undefined, values: Record<string, unknown>) {
100
+ if (!modulePath) usageError("sql takes a <module>", true);
101
+
102
+ const abs = resolve(process.cwd(), modulePath);
103
+ let mod: Record<string, unknown>;
104
+ try {
105
+ mod = await import(abs);
106
+ } catch (err) {
107
+ // Bun surfaces a `ResolveMessage` whose `.message` is a single useful line
108
+ // (e.g. "Cannot find module '…'") but is fronted by the class-name prefix;
109
+ // strip it to a clean one-liner instead of dumping the raw object/stack.
110
+ const raw =
111
+ err instanceof Error ? err.message.split("\n", 1)[0] : String(err);
112
+ const reason = raw.replace(/^(ResolveMessage|BuildMessage|Error):\s*/, "");
113
+ process.stderr.write(`Cannot load module "${modulePath}": ${reason}\n`);
114
+ process.exit(2);
115
+ }
116
+
117
+ const schemaExport = values["schema-export"] as string;
118
+ const docsExport = values["docs-export"] as string;
119
+ const schema = mod[schemaExport] as Schema | undefined;
120
+ const docs = mod[docsExport] as DocDef[] | undefined;
121
+
122
+ if (!schema || typeof schema !== "object" || !("tables" in schema)) {
123
+ process.stderr.write(
124
+ `Module at ${modulePath} does not export a Schema as \`${schemaExport}\`.\n` +
125
+ `Export one with: export const ${schemaExport} = defineSchema({ ... });\n`,
126
+ );
127
+ process.exit(2);
128
+ }
129
+ if (!Array.isArray(docs)) {
130
+ process.stderr.write(
131
+ `Module at ${modulePath} does not export a DocDef[] as \`${docsExport}\`.\n` +
132
+ `Export one with: export const ${docsExport} = [defineDoc(...), ...];\n`,
133
+ );
134
+ process.exit(2);
135
+ }
136
+
137
+ const sql = generateSql(schema, docs);
138
+ const out = values.out as string | undefined;
139
+ const force = !!values.force;
140
+
141
+ if (out) {
142
+ const absOut = resolve(out);
143
+ // Guard: never silently clobber a file we didn't generate. A previously
144
+ // generated file carries the codegen header marker and is safe to replace;
145
+ // anything else needs --force.
146
+ if (existsSync(absOut) && !force) {
147
+ const existing = readFileSync(absOut, "utf8");
148
+ if (!existing.includes(GENERATED_MARKER)) {
149
+ process.stderr.write(
150
+ `Refusing to overwrite ${out}: it has no generated-file header ` +
151
+ `(not produced by 'dzql sql').\n` +
152
+ `Pass --force to overwrite it anyway.\n`,
153
+ );
154
+ process.exit(3);
155
+ }
156
+ // It's a previously generated file — back it up before replacing.
157
+ backupFile(absOut);
158
+ }
159
+ mkdirSync(dirname(absOut), { recursive: true });
160
+ await Bun.write(out, sql);
161
+ process.stderr.write(`Wrote ${out} (${sql.length} bytes)\n`);
162
+ } else {
163
+ process.stdout.write(sql);
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // dzql init
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function cmdInit(dir: string | undefined, values: Record<string, unknown>) {
172
+ if (!dir) usageError("init takes a <dir>", true);
173
+
174
+ const absDir = resolve(process.cwd(), dir);
175
+ mkdirSync(absDir, { recursive: true });
176
+
177
+ const upgrade = !!values.upgrade;
178
+ const plan: { src: string; dest: string; kind: "framework" | "auth-jwt" }[] =
179
+ frameworkSqlFiles().map((src) => ({ src, dest: join(absDir, basename(src)), kind: "framework" }));
180
+ if (values["with-auth"]) plan.push({ src: authJwtSqlFile(), dest: join(absDir, "002-users.sql"), kind: "auth-jwt" });
181
+
182
+ // On upgrade we refuse to clobber files that don't have our header or that
183
+ // point to a newer version than we ship. The user either wrote that file
184
+ // themselves or has downgraded the package.
185
+ if (upgrade) {
186
+ const conflicts: string[] = [];
187
+ for (const { dest } of plan) {
188
+ if (!existsSync(dest)) continue;
189
+ const hdr = readHeader(dest);
190
+ if (!hdr) conflicts.push(`${basename(dest)}: not stamped by dzql init (or delta init)`);
191
+ else if (hdr.pkg === "dzql" && compareVersion(hdr.version, PKG_VERSION) > 0) {
192
+ conflicts.push(`${basename(dest)}: has v${hdr.version}, package is v${PKG_VERSION}`);
193
+ }
194
+ }
195
+ if (conflicts.length) {
196
+ process.stderr.write(
197
+ `Refusing to upgrade:\n ${conflicts.join("\n ")}\n\n` +
198
+ `Resolve these manually (move or rename the offending files) and rerun.\n`,
199
+ );
200
+ process.exit(3);
201
+ }
202
+ }
203
+
204
+ const created: string[] = [];
205
+ const upgraded: string[] = [];
206
+ const unchanged: string[] = [];
207
+
208
+ for (const { src, dest, kind } of plan) {
209
+ if (existsSync(dest)) {
210
+ const hdr = readHeader(dest);
211
+ if (!upgrade || (hdr?.pkg === "dzql" && hdr.version === PKG_VERSION)) {
212
+ unchanged.push(basename(dest));
213
+ continue;
214
+ }
215
+ const bak = backupFile(dest);
216
+ writeWithHeader(src, dest, kind);
217
+ upgraded.push(`${basename(dest)} (backup at ${basename(bak)})`);
218
+ } else {
219
+ writeWithHeader(src, dest, kind);
220
+ created.push(basename(dest));
221
+ }
222
+ }
223
+
224
+ const lines: string[] = [];
225
+ if (created.length) lines.push("Created:\n " + created.join("\n "));
226
+ if (upgraded.length) lines.push("Upgraded:\n " + upgraded.join("\n "));
227
+ if (unchanged.length) lines.push("Unchanged:\n " + unchanged.join("\n "));
228
+ process.stderr.write((lines.join("\n\n") || "Nothing to do.") + "\n");
229
+
230
+ if (created.length || upgraded.length) {
231
+ process.stderr.write(
232
+ `\nNext: regenerate your table SQL from types.ts:\n` +
233
+ ` bunx dzql sql ./types.ts --out ${dir}/003-tables.sql\n`,
234
+ );
235
+ }
236
+ }
237
+
238
+ function compareVersion(a: string, b: string): number {
239
+ const pa = a.split(".").map(Number);
240
+ const pb = b.split(".").map(Number);
241
+ for (let i = 0; i < 3; i++) {
242
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
243
+ if (d !== 0) return d;
244
+ }
245
+ return 0;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // dzql migrate
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * Run an app's migrations (`--dir`, default ./migrations, each `NNN-name.ts`
254
+ * exporting `up`; `--app` names the app when apps share the database) against
255
+ * its database, or with --status say which ran and which are pending. With no
256
+ * schema to ask, an app with nothing recorded is new only to a database with
257
+ * no table but delta's: the app's own start-up, given its schema, knows better.
258
+ * The database: a postgres:// URL or a PGlite directory that is there (a name
259
+ * mistyped makes no new database). A SQLite file is delta's own command.
260
+ */
261
+ async function cmdMigrate(target: string | undefined, values: Record<string, unknown>) {
262
+ if (!target) usageError("migrate takes a <database>: a PGlite directory or a postgres:// URL", true);
263
+ const { setLogLevel } = await import("@blueshed/delta/logger");
264
+ setLogLevel(json ? "silent" : "warn");
265
+ const dir = resolve(process.cwd(), (values.dir as string | undefined) ?? "migrations");
266
+ const status = !!values.status;
267
+ const options = { app: (values.app as string | undefined) ?? "" };
268
+ const isUrl = /^postgres(ql)?:\/\//.test(target);
269
+ const path = resolve(process.cwd(), target);
270
+ if (!isUrl && (!existsSync(path) || !statSync(path).isDirectory())) usageError(`no PGlite database at ${target} (a SQLite file is delta's: bunx @blueshed/delta migrate)`);
271
+ if (!existsSync(dir)) usageError(`no migrations folder at ${dir} (--dir)`);
272
+
273
+ let answer: unknown;
274
+ const pg = await import("./migrate");
275
+ let pool: any;
276
+ if (isUrl) {
277
+ const { Pool } = await import("pg");
278
+ pool = new Pool({ connectionString: target });
279
+ } else {
280
+ const { openPglite } = await import("./pglite");
281
+ pool = await openPglite(path);
282
+ }
283
+ try {
284
+ const list = await pg.fromFolder(dir);
285
+ answer = status ? await pg.migrationStatus(pool, list, options) : await pg.migrate(pool, list, options);
286
+ } finally { await pool.end(); }
287
+ if (json) return print(answer);
288
+ if (status) {
289
+ const s = answer as import("@blueshed/delta/backend").MigrationStatus;
290
+ if (s.fresh) process.stdout.write("new database: migrate records every migration as run, and runs none\n");
291
+ for (const r of s.ran) process.stdout.write(`ran ${r.id} ${r.ran_at}\n`);
292
+ for (const id of s.pending) process.stdout.write(`pending ${id}\n`);
293
+ for (const id of s.unknown) process.stdout.write(`unknown ${id} (ran here, not in ${dir})\n`);
294
+ return;
295
+ }
296
+ const r = answer as import("@blueshed/delta/backend").MigrateResult;
297
+ if (r.fresh) process.stdout.write("new database: every migration recorded as run, none run\n");
298
+ for (const id of r.ran) process.stdout.write(`ran ${id}\n`);
299
+ if (!r.fresh && r.ran.length === 0) process.stdout.write("nothing to run\n");
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // main
304
+ // ---------------------------------------------------------------------------
305
+
306
+ export async function main(argv: string[]) {
307
+ let parsed;
308
+ try { parsed = parseArgs({
309
+ args: argv,
310
+ options: {
311
+ out: { type: "string", short: "o" },
312
+ force: { type: "boolean", default: false },
313
+ "schema-export": { type: "string", default: "schema" },
314
+ "docs-export": { type: "string", default: "docs" },
315
+ "with-auth": { type: "boolean", default: false },
316
+ upgrade: { type: "boolean", default: false },
317
+ json: { type: "boolean", default: false },
318
+ dir: { type: "string" },
319
+ app: { type: "string" },
320
+ status: { type: "boolean", default: false },
321
+ help: { type: "boolean", short: "h", default: false },
322
+ },
323
+ allowPositionals: true,
324
+ strict: true,
325
+ }); }
326
+ catch (err) { usageError((err as Error).message, true); }
327
+ const { values, positionals } = parsed;
328
+ json = !!values.json;
329
+ if (values.help) { process.stdout.write(USAGE); return; }
330
+ const [cmd, arg] = positionals;
331
+ switch (cmd) {
332
+ case "sql": return cmdSql(arg, values);
333
+ case "init": return cmdInit(arg, values);
334
+ case "migrate": return cmdMigrate(arg, values);
335
+ default: usageError(cmd ? `no command ${cmd}` : "a command, please", true);
336
+ }
337
+ }
338
+
339
+ if (import.meta.main) main(process.argv.slice(2)).catch((err) => fail(err));
@@ -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
  *
@@ -26,6 +26,17 @@ import type { Schema, DocDef } from "./schema";
26
26
  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
+ 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 { compileTold } from "../compile/told";
29
40
 
30
41
  export interface GenerateSqlOptions {
31
42
  /** Header comment included at the top of the file. */
@@ -168,5 +179,7 @@ export function generateSql(
168
179
  }
169
180
  lines.push("");
170
181
 
182
+ // 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));
171
184
  return lines.join("\n");
172
185
  }
@@ -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
@@ -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
@@ -18,6 +18,7 @@ import type { DocDef } from "./schema";
18
18
  import { splitPath, type DeltaOp } from "@blueshed/delta/core";
19
19
  import { authless, ownerless, type DeltaAuth } from "@blueshed/delta/auth";
20
20
  import { assertOnePrefix } from "@blueshed/delta/backend";
21
+ import { openAtFunction, openFunction } from "../compile/opens";
21
22
 
22
23
  /** Who is writing, for the ledger: the identity as the ledger has it, and the cursor undo walks. */
23
24
  export type Writer = { who: string | null; cursor: string | null; undoable?: boolean; undoes?: number | null };
@@ -214,6 +215,14 @@ export function docTypeFromDef<I = unknown>(
214
215
  // owns or shared with no auth module would guard nothing: the listener asks them only with one
215
216
  if (!auth && (opts?.owns || opts?.shared)) throw authless(def.prefix, "docTypeFromDef");
216
217
 
218
+ // its reads compiled for it (generateSql), or the framework's for a document registered by hand:
219
+ // asked until found, then remembered
220
+ let compiled = false;
221
+ const reads = async (): Promise<{ open: string; openAt: string }> => {
222
+ if (!compiled) compiled = (await pool.query("SELECT to_regprocedure($1) IS NOT NULL AS there", [`${openFunction(def.prefix)}(text)`])).rows[0].there;
223
+ return compiled ? { open: openFunction(def.prefix), openAt: openAtFunction(def.prefix) } : { open: "delta_open", openAt: "delta_open_at" };
224
+ };
225
+
217
226
  return {
218
227
  prefix: def.prefix,
219
228
  ...(opts?.owns ? { owns: opts.owns } : {}),
@@ -225,7 +234,7 @@ export function docTypeFromDef<I = unknown>(
225
234
  },
226
235
 
227
236
  async open(_ctx, docName, _msg, identity) {
228
- const doc = await callAs(pool, "delta_open", [docName], auth, identity);
237
+ const doc = await callAs(pool, (await reads()).open, [docName], auth, identity);
229
238
  if (!doc) return null;
230
239
  const version = doc._version ?? 0;
231
240
  delete doc._version;
@@ -247,7 +256,7 @@ export function docTypeFromDef<I = unknown>(
247
256
  },
248
257
 
249
258
  async openAt(_ctx, docName, at, identity) {
250
- return (await callAs(pool, "delta_open_at", [docName, at], auth, identity)) ?? null;
259
+ return (await callAs(pool, (await reads()).openAt, [docName, at], auth, identity)) ?? null;
251
260
  },
252
261
  };
253
262
  }
@@ -136,7 +136,7 @@ $$;
136
136
  -- It used to be merged into the broadcast row, acked, and dropped (or cast) by
137
137
  -- jsonb_populate_record: stored otherwise than told. id, the parent key and
138
138
  -- the temporal columns are the row's own.
139
- CREATE OR REPLACE FUNCTION _delta_assert_fields(
139
+ CREATE OR REPLACE FUNCTION _delta_assert_fields_framework(
140
140
  p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB
141
141
  ) RETURNS void LANGUAGE plpgsql IMMUTABLE AS $$
142
142
  DECLARE
@@ -178,6 +178,13 @@ BEGIN
178
178
  END;
179
179
  $$;
180
180
 
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.
184
+ CREATE OR REPLACE FUNCTION _delta_assert_fields(
185
+ 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); $$;
187
+
181
188
  -- Pick the source relation for a collection.
182
189
  -- temporal + no timestamp → current_<table> view
183
190
  -- temporal + timestamp → base <table> (caller filters with _delta_temporal_where)
@@ -51,7 +51,7 @@ $$ LANGUAGE sql STABLE;
51
51
  -- how the fan-out reads a name opened before a rule refused it (_delta_doc_holds).
52
52
  -- ---------------------------------------------------------------------------
53
53
 
54
- CREATE OR REPLACE FUNCTION _delta_resolve_scope(p_def _delta_docs, p_doc_name TEXT, p_refuse BOOLEAN)
54
+ CREATE OR REPLACE FUNCTION _delta_resolve_scope_framework(p_def _delta_docs, p_doc_name TEXT, p_refuse BOOLEAN)
55
55
  RETURNS JSONB AS $$
56
56
  DECLARE
57
57
  v_doc_id TEXT;
@@ -285,6 +285,12 @@ BEGIN
285
285
  END;
286
286
  $$ LANGUAGE plpgsql STABLE;
287
287
 
288
+ -- The resolver every caller asks: the framework's, until dzql's compiler
289
+ -- (`compileResolve`) replaces it with one sending each document it compiled
290
+ -- to its own.
291
+ CREATE OR REPLACE FUNCTION _delta_resolve_scope(p_def _delta_docs, p_doc_name TEXT, p_refuse BOOLEAN)
292
+ RETURNS JSONB LANGUAGE sql STABLE AS $$ SELECT _delta_resolve_scope_framework(p_def, p_doc_name, p_refuse); $$;
293
+
288
294
  -- The resolver as every caller but the fan-out asks it: a value refused is raised.
289
295
  CREATE OR REPLACE FUNCTION _delta_resolve_scope(p_def _delta_docs, p_doc_name TEXT)
290
296
  RETURNS JSONB LANGUAGE sql STABLE AS $$
@@ -323,7 +329,7 @@ $$;
323
329
  -- so the gate can't be used to probe for ids.
324
330
  -- ---------------------------------------------------------------------------
325
331
 
326
- CREATE OR REPLACE FUNCTION _delta_row_in_scope(
332
+ CREATE OR REPLACE FUNCTION _delta_row_in_scope_framework(
327
333
  p_def _delta_docs,
328
334
  p_doc_name TEXT,
329
335
  p_collection_key TEXT,
@@ -413,6 +419,12 @@ BEGIN
413
419
  END;
414
420
  $$ LANGUAGE plpgsql STABLE;
415
421
 
422
+ -- _delta_row_in_scope: as the framework reads a definition (above), until dzql's generateSql
423
+ -- compiles the app's documents: it then sends each to its own dzql_in_scope__<prefix>, and any
424
+ -- other (registered by hand, or by another generateSql) here.
425
+ CREATE OR REPLACE FUNCTION _delta_row_in_scope(p_def _delta_docs, p_doc_name TEXT, p_collection_key TEXT, p_id BIGINT)
426
+ RETURNS BOOLEAN LANGUAGE sql STABLE AS $$ SELECT _delta_row_in_scope_framework(p_def, p_doc_name, p_collection_key, p_id); $$;
427
+
416
428
  -- ---------------------------------------------------------------------------
417
429
  -- Who holds a row: the fan-out (todo #28)
418
430
  --
@@ -459,7 +471,7 @@ $$ LANGUAGE plpgsql STABLE;
459
471
  -- _delta_doc_holds: does the document `p_doc_name` (of `p_def`) hold `p_row`
460
472
  -- of `p_coll`? Judged on the row's values, so a row as it was before a write
461
473
  -- is judged as well as one as it is after -- the same rule delta_open reads by.
462
- CREATE OR REPLACE FUNCTION _delta_doc_holds(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
474
+ CREATE OR REPLACE FUNCTION _delta_doc_holds_framework(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
463
475
  RETURNS BOOLEAN AS $$
464
476
  DECLARE
465
477
  v_scope JSONB;
@@ -503,6 +515,12 @@ BEGIN
503
515
  END;
504
516
  $$ LANGUAGE plpgsql STABLE;
505
517
 
518
+ -- _delta_doc_holds: as the framework reads a definition (above), until dzql's generateSql
519
+ -- compiles the app's documents: it then sends each to its own dzql_holds__<prefix>, and any
520
+ -- other (registered by hand, or by another generateSql) here.
521
+ CREATE OR REPLACE FUNCTION _delta_doc_holds(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
522
+ RETURNS BOOLEAN LANGUAGE sql STABLE AS $$ SELECT _delta_doc_holds_framework(p_def, p_doc_name, p_coll, p_row); $$;
523
+
506
524
  -- _delta_holders: the documents that hold `p_row` of `p_coll` -- every one
507
525
  -- open now (a name in _delta_held, todo #73), and the writer's own `p_writer`.
508
526
  -- A single-mode document of an unscoped definition is found by its name,
@@ -527,7 +545,7 @@ $$ LANGUAGE plpgsql STABLE;
527
545
  -- that applied the framework, owning the tables without FORCE ROW LEVEL
528
546
  -- SECURITY, or with BYPASSRLS; applied as the app's own role, it sees what
529
547
  -- the writer sees, as before.
530
- CREATE OR REPLACE FUNCTION _delta_holders(p_coll TEXT, p_row JSONB, p_writer TEXT)
548
+ CREATE OR REPLACE FUNCTION _delta_holders_framework(p_coll TEXT, p_row JSONB, p_writer TEXT, p_skip TEXT[])
531
549
  RETURNS TEXT[] AS $$
532
550
  DECLARE
533
551
  v_def _delta_docs;
@@ -547,7 +565,7 @@ BEGIN
547
565
 
548
566
  FOR v_def IN
549
567
  SELECT * FROM _delta_docs
550
- WHERE root_collection = p_coll OR p_coll = ANY(include)
568
+ WHERE (root_collection = p_coll OR p_coll = ANY(include)) AND NOT (prefix = ANY(p_skip))
551
569
  LOOP
552
570
  v_all := v_def.scope <> '{}'::jsonb
553
571
  OR (p_coll IS DISTINCT FROM v_def.root_collection AND v_coll.parent_collection IS NULL);
@@ -572,6 +590,15 @@ END;
572
590
  $$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
573
591
  -- the search path it was applied with, and pg_temp last: a caller's temporary
574
592
  -- tables never stand in for delta's own (pg_temp is searched first unless named)
593
+ DO $$ BEGIN
594
+ EXECUTE format('ALTER FUNCTION _delta_holders_framework(TEXT, JSONB, TEXT, TEXT[]) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
595
+ END $$;
596
+
597
+ -- `p_skip`: the prefixes asked already, by the compiled `_delta_holders`
598
+ -- (dzql's `compileHolders`), which replaces this one: each document it
599
+ -- compiled asked by its own lookups, the rest by the framework's.
600
+ CREATE OR REPLACE FUNCTION _delta_holders(p_coll TEXT, p_row JSONB, p_writer TEXT)
601
+ RETURNS TEXT[] LANGUAGE sql VOLATILE SECURITY DEFINER AS $$ SELECT _delta_holders_framework(p_coll, p_row, p_writer, ARRAY[]::text[]); $$;
575
602
  DO $$ BEGIN
576
603
  EXECUTE format('ALTER FUNCTION _delta_holders(TEXT, JSONB, TEXT) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
577
604
  END $$;
@@ -212,8 +212,7 @@ BEGIN
212
212
  END IF;
213
213
  SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_def.root_collection;
214
214
  IF NOT FOUND THEN RETURN; END IF;
215
- EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I WHERE id = $1)', _delta_source_view(v_coll.table_name, v_coll.temporal))
216
- INTO v_exists USING p_id;
215
+ v_exists := _delta_row_there(p_def.root_collection, p_id);
217
216
  IF v_exists THEN RETURN; END IF;
218
217
  -- Not there, as the writer reads it: made under the add rule (todo #71) -- an
219
218
  -- id the serial has handed out is taken, whether a row RLS hides holds it or
@@ -225,7 +224,7 @@ BEGIN
225
224
  END IF;
226
225
  v_row := _delta_implied_root(p_def, p_id);
227
226
  IF v_coll.temporal THEN v_row := v_row || jsonb_build_object('valid_from', p_ts, 'valid_to', NULL); END IF;
228
- EXECUTE _delta_insert_sql(v_coll.table_name, v_row) USING v_row;
227
+ PERFORM _delta_row_add(p_def.root_collection, v_coll.table_name, v_row);
229
228
  END;
230
229
  $$ LANGUAGE plpgsql;
231
230
 
@@ -235,17 +234,12 @@ DECLARE
235
234
  v_doc JSONB := COALESCE(_delta_open(p_doc_name, TRUE), '{}'::jsonb);
236
235
  v_def _delta_docs := _delta_find_doc(p_doc_name);
237
236
  v_scope JSONB;
238
- v_coll RECORD;
239
- v_exists BOOLEAN;
240
237
  BEGIN
241
238
  IF NOT COALESCE(v_def.implied, FALSE) THEN RETURN v_doc; END IF;
242
239
  v_scope := _delta_resolve_scope(v_def, p_doc_name);
243
240
  IF (v_scope->>'mode') <> 'single' THEN RETURN v_doc; END IF;
244
- SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = v_def.root_collection;
245
- IF NOT FOUND THEN RETURN v_doc; END IF;
246
- EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I WHERE id = $1)', _delta_source_view(v_coll.table_name, v_coll.temporal))
247
- INTO v_exists USING (v_scope->'values'->>'id')::BIGINT;
248
- IF v_exists THEN RETURN v_doc; END IF;
241
+ -- (its root collection is there: _delta_open raised otherwise)
242
+ IF _delta_row_there(v_def.root_collection, (v_scope->'values'->>'id')::BIGINT) THEN RETURN v_doc; END IF;
249
243
  RETURN v_doc || jsonb_build_object(v_def.root_collection, 'null'::jsonb);
250
244
  END;
251
245
  $$ LANGUAGE plpgsql;
@@ -351,7 +345,7 @@ $$ LANGUAGE plpgsql STABLE;
351
345
  -- whole: what the ledger's inverse and a walk's guard read.
352
346
  -- ---------------------------------------------------------------------------
353
347
 
354
- CREATE OR REPLACE FUNCTION _delta_open(p_doc_name TEXT, p_whole BOOLEAN)
348
+ CREATE OR REPLACE FUNCTION _delta_open_framework(p_doc_name TEXT, p_whole BOOLEAN)
355
349
  RETURNS JSONB AS $$
356
350
  DECLARE
357
351
  v_def _delta_docs;
@@ -445,6 +439,11 @@ BEGIN
445
439
  END;
446
440
  $$ LANGUAGE plpgsql;
447
441
 
442
+ -- the framework's, until dzql's compiler (`compileLedger`) sends each document
443
+ -- it compiled to its own open, shown or whole
444
+ CREATE OR REPLACE FUNCTION _delta_open(p_doc_name TEXT, p_whole BOOLEAN)
445
+ RETURNS JSONB LANGUAGE sql AS $$ SELECT _delta_open_framework(p_doc_name, p_whole); $$;
446
+
448
447
  CREATE OR REPLACE FUNCTION delta_open(p_doc_name TEXT)
449
448
  RETURNS JSONB LANGUAGE sql AS $$ SELECT _delta_open(p_doc_name, FALSE); $$;
450
449