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.
package/README.md CHANGED
@@ -12,6 +12,22 @@ bun run db:rebuild
12
12
  bun run dev
13
13
  ```
14
14
 
15
+ ## delta's Postgres step
16
+
17
+ dzql is becoming the Postgres step for apps built on [@blueshed/delta](https://github.com/blueshed/delta):
18
+ a delta app grows from a JSON file to SQLite to Postgres, and on Postgres its documents are kept by
19
+ dzql. Since 0.8.0 dzql carries delta's Postgres backend, with the same names, so moving is an
20
+ import change:
21
+
22
+ ```ts
23
+ import { applyFramework, applySql, createDocListener, docTypeFromDef, generateSql } from "dzql/postgres";
24
+ import { openPglite } from "dzql/pglite"; // Postgres in this process
25
+ import { jwtAuth } from "dzql/auth-jwt";
26
+ ```
27
+
28
+ Its tests run delta's own cases (`@blueshed/delta/testing`) against it. Compiled SQL replaces the
29
+ framework SQL in later releases; the plan is in Workshop's `docs/postgres-split-plan.md`.
30
+
15
31
  ## Documentation
16
32
 
17
33
  See the [full documentation](./docs/README.md) for:
package/docs/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # DZQL: Realtime Apps Without the Boilerplate
2
2
 
3
+ > **The whole-stack dzql.** This describes dzql as it stood at 0.7: `domain.ts`, its runtime, the
4
+ > typed client and the Pinia stores. They keep working until step 5 of Workshop's
5
+ > `docs/postgres-split-plan.md` retires them. dzql is now delta's compiled Postgres step: a new app
6
+ > starts on [@blueshed/delta](https://github.com/blueshed/delta) and comes to dzql through
7
+ > `dzql/postgres` (`philo.md`, at the repo's root).
8
+
3
9
  DZQL compiles your data model into a complete realtime backend. Define entities and permissions in TypeScript, get a PostgreSQL database with CRUD operations, WebSocket sync, and type-safe client SDK - all generated.
4
10
 
5
11
  ## Why DZQL?
package/docs/for_ai.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # DZQL Domain Modeling Guide
2
2
 
3
+ > **The whole-stack dzql.** This describes dzql as it stood at 0.7: `domain.ts`, its runtime, the
4
+ > typed client and the Pinia stores. They keep working until step 5 of Workshop's
5
+ > `docs/postgres-split-plan.md` retires them. dzql is now delta's compiled Postgres step: a new app
6
+ > starts on [@blueshed/delta](https://github.com/blueshed/delta) and comes to dzql through
7
+ > `dzql/postgres` (`philo.md`, at the repo's root).
8
+
3
9
  This guide defines patterns for generating valid DZQL domain definitions.
4
10
 
5
11
  ## Quick Reference
@@ -1,5 +1,11 @@
1
1
  # DZQL Project Setup Guide
2
2
 
3
+ > **The whole-stack dzql.** This describes dzql as it stood at 0.7: `domain.ts`, its runtime, the
4
+ > typed client and the Pinia stores. They keep working until step 5 of Workshop's
5
+ > `docs/postgres-split-plan.md` retires them. dzql is now delta's compiled Postgres step: a new app
6
+ > starts on [@blueshed/delta](https://github.com/blueshed/delta) and comes to dzql through
7
+ > `dzql/postgres` (`philo.md`, at the repo's root).
8
+
3
9
  Complete guide for setting up a Vue/Pinia client project with DZQL.
4
10
 
5
11
  ## Quick Start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,7 +14,11 @@
14
14
  ".": "./src/runtime/index.ts",
15
15
  "./client": "./src/client/index.ts",
16
16
  "./compiler": "./src/cli/index.ts",
17
- "./namespace": "./src/runtime/namespace.ts"
17
+ "./namespace": "./src/runtime/namespace.ts",
18
+ "./postgres": "./src/postgres/index.ts",
19
+ "./pglite": "./src/postgres/pglite.ts",
20
+ "./auth-jwt": "./src/postgres/auth-jwt.ts",
21
+ "./compile": "./src/compile/opens.ts"
18
22
  },
19
23
  "files": [
20
24
  "src",
@@ -26,7 +30,8 @@
26
30
  "scripts": {
27
31
  "test": "bun run test:setup && bun test; status=$?; bun run test:teardown; exit $status",
28
32
  "test:setup": "docker compose down -v && docker compose up -d --wait",
29
- "test:teardown": "docker compose down -v"
33
+ "test:teardown": "docker compose down -v",
34
+ "bench": "bun run bench/run.ts"
30
35
  },
31
36
  "keywords": [
32
37
  "postgresql",
@@ -45,18 +50,24 @@
45
50
  "author": "Peter Bunyan",
46
51
  "license": "MIT",
47
52
  "dependencies": {
48
- "postgres": "^3.4.3",
53
+ "@electric-sql/pglite": "^0.5.4",
49
54
  "dotenv": "^16.4.5",
50
- "jose": "^5.2.3"
55
+ "jose": "^6.2.4",
56
+ "pg": "^8.22.0",
57
+ "postgres": "^3.4.3"
51
58
  },
52
59
  "devDependencies": {
60
+ "@blueshed/railroad": "^0.16.0",
53
61
  "@types/bun": "latest",
62
+ "@types/pg": "^8.20.0",
54
63
  "pinia": "^3.0.4",
55
64
  "typescript": "^5.0.0",
56
- "vue": "^3.5.26"
65
+ "vue": "^3.5.26",
66
+ "@blueshed/delta": "^1.0.0"
57
67
  },
58
68
  "peerDependencies": {
59
- "typescript": "^5.0.0"
69
+ "typescript": "^5.0.0",
70
+ "@blueshed/delta": "^1.0.0"
60
71
  },
61
72
  "engines": {
62
73
  "bun": ">=1.0.0"
package/src/cli/index.ts CHANGED
@@ -14,6 +14,13 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
14
14
  const VERSION = packageJson.version;
15
15
 
16
16
  const args = process.argv.slice(2);
17
+
18
+ // delta's Postgres commands (dzql is delta's Postgres step): sql, init, migrate
19
+ if (["sql", "init", "migrate"].includes(args[0] ?? "")) {
20
+ const { main } = await import("../postgres/cli.ts");
21
+ await main(args);
22
+ process.exit(0);
23
+ }
17
24
  let command = args[0];
18
25
  let input = args[1];
19
26
  let outputDir = "dist"; // Default output directory
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Compiled opens: delta's documents, read by SQL written for each one.
3
+ *
4
+ * `compileOpens(schema, docs)` turns an app's `defineSchema` / `defineDoc`
5
+ * into the reads of each document -- `dzql_open__<slug>(name)` and
6
+ * `dzql_open_at__<slug>(name, at)`, each with an `_as` for a signed-in caller --
7
+ * which the listener opens that document's names through (`docTypeFromDef`). Each answers what `delta_open`
8
+ * answers (the listener then shows it by the same schema), but its queries
9
+ * are written out: which
10
+ * table, which columns, how a collection hangs from the root, whether a row
11
+ * is temporal, what an implied root opens with. Nothing reads
12
+ * `_delta_collections` to find them.
13
+ *
14
+ * A name's scope is compiled too (`scopeOf`): its values read by position,
15
+ * each checked as its column takes it with the framework's own messages
16
+ * (`_delta_resolve_scope`), and its conditions written as typed SQL, so no
17
+ * query is built from text at run time. The version is still the
18
+ * framework's (`_delta_versions`).
19
+ *
20
+ * A pure function: the same definitions give the same text, byte for byte.
21
+ * A definition it cannot compile is an error naming the document, never SQL
22
+ * that half works.
23
+ */
24
+ import { q, type ColumnDef, type DocDef, type ResolvedTable, type Schema } from "@blueshed/delta/backend";
25
+ import { columnSqlType } from "../postgres/sql";
26
+
27
+ /** A string as an SQL literal. */
28
+ const lit = (s: string): string => `'${s.replace(/'/g, "''")}'`;
29
+
30
+ /** A document's function name: its prefix, lower case, every other character `_`. */
31
+ export function openFunction(prefix: string): string {
32
+ return `dzql_open__${prefix.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
33
+ }
34
+
35
+ /**
36
+ * A row as a document reads it: as stored (a temporal row without its
37
+ * validity), so a column a later version added is there for the process that
38
+ * declares it -- each listener shows its own schema's (the schema is the
39
+ * contract; a database may hold more). Where the document names the columns it
40
+ * holds, those, its id and its parent key.
41
+ */
42
+ function rowOf(table: ResolvedTable, doc: DocDef, coll: string, alias: string): string {
43
+ const held = doc.columns?.[coll];
44
+ if (held) {
45
+ const cols = ["id", ...(table.parent ? [table.parent.fkColumn] : []), ...Object.keys(table.columns).filter((c) => held.includes(c))];
46
+ return `jsonb_build_object(${cols.map((c) => `${lit(c)}, ${alias}.${q(c)}`).join(", ")})`;
47
+ }
48
+ return table.temporal ? `(to_jsonb(${alias}.*) - 'valid_from' - 'valid_to' - 'override')` : `to_jsonb(${alias}.*)`;
49
+ }
50
+
51
+ /** The rows of a temporal table that stand now, or at `at` (a time in the name): ` AND <condition>`; nothing for a table that is not temporal. */
52
+ const standing = (table: ResolvedTable, alias: string, at = "p_at"): string =>
53
+ table.temporal
54
+ ? ` AND (CASE WHEN ${at} IS NULL THEN ${alias}.valid_to IS NULL ELSE ${alias}.valid_from <= ${at} AND (${alias}.valid_to IS NULL OR ${alias}.valid_to > ${at}) END)`
55
+ : "";
56
+
57
+ /** An implied document's root before its row is there: its id and each column's default, as `_delta_implied_root` gives it. */
58
+ function impliedRoot(table: ResolvedTable, doc: DocDef): string {
59
+ const held = doc.columns?.[doc.root];
60
+ const value = (d: ColumnDef): unknown =>
61
+ d.default !== undefined && d.default !== null ? d.default
62
+ : d.nullable ? null
63
+ : d.type === "text" ? ""
64
+ : d.type === "integer" || d.type === "real" ? 0
65
+ : d.type === "boolean" ? false
66
+ : null;
67
+ const fields = Object.entries(table.columns).filter(([c]) => !held || held.includes(c)).map(([c, d]) => [c, value(d)]);
68
+ return `jsonb_build_object('id', v_id) || ${lit(JSON.stringify(Object.fromEntries(fields)))}::jsonb`;
69
+ }
70
+
71
+ /**
72
+ * A collection a single document includes, held under its root row `v_id`:
73
+ * its rows joined up their parent chain to the root. A collection with no
74
+ * parent is held whole; one whose chain ends short of the root holds nothing.
75
+ */
76
+ function includedSingle(schema: Schema, doc: DocDef, coll: string): string {
77
+ const table = need(schema, doc, coll);
78
+ if (!table.parent) return includedList(schema, doc, coll);
79
+ const joins: string[] = [];
80
+ let where = "";
81
+ let child = table;
82
+ let alias = "t";
83
+ for (let hop = 1; ; hop++) {
84
+ const parentKey = child.parent!.collection;
85
+ if (parentKey === doc.root) {
86
+ where = `${alias}.${q(child.parent!.fkColumn)} = v_id${standing(table, "t")}`;
87
+ break;
88
+ }
89
+ const parent = need(schema, doc, parentKey);
90
+ if (!parent.parent) return `'{}'::jsonb`; // the chain ends short of the root: nothing of it is in the document
91
+ const p = `p${hop}`;
92
+ joins.push(`JOIN ${q(parent.name)} ${p} ON ${p}.id = ${alias}.${q(child.parent!.fkColumn)}${standing(parent, p)}`);
93
+ child = parent;
94
+ alias = p;
95
+ }
96
+ return `(SELECT COALESCE(jsonb_object_agg(t.id, ${rowOf(table, doc, coll, "t")}), '{}'::jsonb) FROM ${[`${q(table.name)} t`, ...joins].join(" ")} WHERE ${where})`;
97
+ }
98
+
99
+ /** A collection a list document includes: every row of it, as `_delta_load_collection_all` reads it. */
100
+ function includedList(schema: Schema, doc: DocDef, coll: string): string {
101
+ const table = need(schema, doc, coll);
102
+ return `(SELECT COALESCE(jsonb_object_agg(t.id, ${rowOf(table, doc, coll, "t")}), '{}'::jsonb) FROM ${q(table.name)} t${standing(table, "t").replace(" AND", " WHERE")})`;
103
+ }
104
+
105
+ function need(schema: Schema, doc: DocDef, coll: string): ResolvedTable {
106
+ const table = schema.tables[coll];
107
+ if (!table) throw new Error(`[dzql compile] document ${doc.prefix} names a collection the schema does not have: ${coll}`);
108
+ return table;
109
+ }
110
+
111
+ /** The scope's parts: how a name's values are read and checked, and the conditions they set. */
112
+ interface CompiledScope {
113
+ /** plpgsql variables. */
114
+ declare: string[];
115
+ /** plpgsql statements: read the name's values, check each, set v_single, v_id and p_at. */
116
+ body: string;
117
+ /** The conditions on the root's rows, as SQL over alias t ("" when none). */
118
+ conds: string;
119
+ /** Whether a name can make it one row: no scope (a name with an id), or an `id` equality. */
120
+ single: boolean;
121
+ }
122
+
123
+ const REAL = String.raw`^\s*[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)(e[+-]?[0-9]+)?\s*$`;
124
+ const WHOLE = String.raw`^\s*[+-]?[0-9]+\s*$`;
125
+ const SAFE = String.raw`^\s*[+-]?0*[0-9]{1,16}\s*$`;
126
+ const DAY = String.raw`^\s*([0-9]{4})-([0-9]{2})-([0-9]{2})\s*$`;
127
+
128
+ /** Refuse a name's value: the framework's message, as a 400 (22P02). */
129
+ const refuse = (message: string, value: string) => `RAISE EXCEPTION '${message.replace(/'/g, "''")}', ${value} USING ERRCODE = '22P02';`;
130
+
131
+ /**
132
+ * A document's scope, compiled from its definition as `_delta_resolve_scope`
133
+ * reads it at run time: the name after the prefix split on ":" into its
134
+ * params' values (`id` first, the rest in alphabetical order), each binding
135
+ * taken in the order Postgres keeps a JSON object's keys (shorter first), a
136
+ * value its column cannot take refused with the same message, and an `id`
137
+ * equality making the document one row (single mode).
138
+ */
139
+ function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
140
+ const root = need(schema, doc, doc.root);
141
+ const entries = Object.entries(doc.scope ?? {});
142
+ if (entries.length === 0) {
143
+ return {
144
+ declare: ["v_rest TEXT;"],
145
+ body: `v_rest := substring(p_name FROM ${doc.prefix.length + 1});
146
+ v_single := v_rest <> '';
147
+ IF v_single THEN v_id := v_rest::BIGINT; END IF;`,
148
+ conds: "",
149
+ single: true,
150
+ };
151
+ }
152
+ const parsed = entries
153
+ .map(([key, binding]) => {
154
+ let m: RegExpExecArray | null;
155
+ if ((m = /^at:(.+)$/.exec(binding))) return { key, op: "at", param: m[1]! };
156
+ if ((m = /^like:(.+)$/.exec(binding))) return { key, op: "like", param: m[1]! };
157
+ if ((m = /^([<>=!]+):(.+)$/.exec(binding))) {
158
+ if (!["=", ">=", "<=", ">", "<", "!="].includes(m[1]!)) throw new Error(`[dzql compile] document ${doc.prefix}: invalid scope operator "${m[1]}" (valid: =, >=, <=, >, <, !=, like, at)`);
159
+ return { key, op: m[1]!, param: m[2]! };
160
+ }
161
+ return { key, op: "=", param: binding.replace(/^:+/, "") };
162
+ })
163
+ .sort((a, b) => a.key.length - b.key.length || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); // jsonb's order of keys
164
+ const params = [...new Set(parsed.map((p) => p.param))].sort((a, b) => (a === "id" ? -1 : b === "id" ? 1 : a < b ? -1 : a > b ? 1 : 0));
165
+ const declare = ["v_parts TEXT[];", "v_date TEXT[];"];
166
+ const body: string[] = [`v_parts := string_to_array(substring(p_name FROM ${doc.prefix.length + 1}), ':');`, "v_single := FALSE;"];
167
+ const conds: string[] = [];
168
+ parsed.forEach(({ key, op, param }, i) => {
169
+ const v = `v_s${i + 1}`;
170
+ declare.push(`${v} TEXT;`);
171
+ body.push(`${v} := NULLIF(v_parts[${params.indexOf(param) + 1}], '');`);
172
+ const isKey = op !== "at" && (key === "id" || key === root.parent?.fkColumn);
173
+ const def = root.columns[key];
174
+ if (op === "at") {
175
+ body.push(dayOf(v, key), `IF ${v} IS NOT NULL THEN p_at := ${v}::TIMESTAMPTZ; END IF;`);
176
+ return;
177
+ }
178
+ if (!isKey && !def) throw new Error(`[dzql compile] document ${doc.prefix}: scope key "${key}" is not a column of "${doc.root}"`);
179
+ if (op === "like") {
180
+ conds.push(`(${v} IS NULL OR t.${q(key)} ILIKE ${v} || '%')`);
181
+ return;
182
+ }
183
+ const sqlOp = op === "!=" ? "<>" : op;
184
+ if (isKey) {
185
+ const n = `v_n${i + 1}`;
186
+ declare.push(`${n} BOOLEAN;`);
187
+ body.push(`IF ${v} IS NOT NULL THEN
188
+ IF ${v} ~ '${WHOLE}' THEN
189
+ IF ${v} !~ '${SAFE}' THEN ${refuse(`${key} % is past 2^53 - 1, which no number holds exactly`, v)}
190
+ ELSIF abs(${v}::numeric) > 9007199254740991 THEN ${refuse(`${key} % is past 2^53 - 1, which no number holds exactly`, v)}
191
+ END IF;
192
+ ${n} := TRUE;
193
+ ELSIF ${v} ~* '${REAL}' THEN
194
+ ${refuse(`${key} must be an id -- a whole number, or text -- not "%"`, v)}
195
+ ELSE
196
+ ${n} := FALSE;
197
+ END IF;
198
+ END IF;`);
199
+ // text no row has as an id: after every number, as SQLite orders it
200
+ const asText = ["!=", "<", "<="].includes(op) ? `t.${q(key)} IS NOT NULL` : "FALSE";
201
+ conds.push(`(${v} IS NULL OR (CASE WHEN ${n} THEN t.${q(key)} ${sqlOp} ${v}::BIGINT ELSE ${asText} END))`);
202
+ if (key === "id" && op === "=") body.push(`IF ${v} IS NOT NULL THEN v_single := TRUE; v_id := ${v}::BIGINT; END IF;`);
203
+ return;
204
+ }
205
+ if (def!.type === "integer") {
206
+ body.push(`IF ${v} IS NOT NULL THEN
207
+ IF ${v} !~ '${SAFE}' THEN ${refuse(`${key} must be an integer, not "%"`, v)}
208
+ ELSIF abs(${v}::numeric) > 9007199254740991 THEN ${refuse(`${key} must be an integer, not "%"`, v)}
209
+ END IF;
210
+ END IF;`);
211
+ } else if (def!.type === "real") {
212
+ body.push(`IF ${v} IS NOT NULL THEN
213
+ IF ${v} !~* '${REAL}' THEN ${refuse(`${key} must be a number, not "%"`, v)} END IF;
214
+ BEGIN
215
+ PERFORM ${v}::float8;
216
+ EXCEPTION WHEN numeric_value_out_of_range THEN
217
+ ${refuse(`${key} must be a number, not "%"`, v)}
218
+ END;
219
+ END IF;`);
220
+ } else if (def!.type === "timestamptz") {
221
+ body.push(dayOf(v, key));
222
+ }
223
+ conds.push(`(${v} IS NULL OR t.${q(key)} ${sqlOp} ${v}::${columnSqlType(def!)})`);
224
+ });
225
+ return { declare, body: body.join("\n "), conds: conds.join(" AND "), single: parsed.some((p) => p.key === "id" && p.op === "=") };
226
+ }
227
+
228
+ /** A date, YYYY-MM-DD, that the calendar has, read as the instant midnight UTC begins it; anything else refused. */
229
+ function dayOf(v: string, key: string): string {
230
+ return `IF ${v} IS NOT NULL THEN
231
+ v_date := regexp_match(${v}, '${DAY}');
232
+ IF v_date IS NULL OR v_date[1]::int < 1 OR v_date[2]::int NOT BETWEEN 1 AND 12 THEN
233
+ ${refuse(`${key} must be a date, YYYY-MM-DD, not "%"`, v)}
234
+ ELSIF v_date[3]::int NOT BETWEEN 1 AND extract(day FROM make_date(v_date[1]::int, v_date[2]::int, 1) + interval '1 month - 1 day')::int THEN
235
+ ${refuse(`${key} must be a date, YYYY-MM-DD, not "%"`, v)}
236
+ END IF;
237
+ ${v} := format('%s-%s-%s 00:00:00+00', v_date[1], v_date[2], v_date[3]);
238
+ END IF;`;
239
+ }
240
+
241
+ /** A document's function for `open_at`: the document as it stood at a time the caller gives. */
242
+ export const openAtFunction = (prefix: string): string => openFunction(prefix).replace(/^dzql_open__/, "dzql_open_at__");
243
+
244
+ /**
245
+ * One document's read, as `open` (`delta_open`: the time is the name's, an implied
246
+ * root opens with its defaults, the version is kept) or as `open_at`
247
+ * (`delta_open_at`: the time is the caller's, a root not there is null, no version).
248
+ */
249
+ function readOf(schema: Schema, doc: DocDef, at: boolean): string {
250
+ const root = need(schema, doc, doc.root);
251
+ const rootJson = rowOf(root, doc, doc.root, "t");
252
+ const fields = (included: (coll: string) => string) =>
253
+ doc.include.map((c) => ` || jsonb_build_object(${lit(c)}, ${included(c)})`).join("");
254
+ const emptyIncludes = doc.include.map((c) => ` || jsonb_build_object(${lit(c)}, '{}'::jsonb)`).join("");
255
+ const fn = at ? openAtFunction(doc.prefix) : openFunction(doc.prefix);
256
+ const scope = scopeOf(schema, doc);
257
+ const where = (first: string[]) => [...first, ...(scope.conds ? [scope.conds] : [])].join(" AND ") || "TRUE";
258
+ const params = at ? "p_name TEXT, p_given TIMESTAMPTZ" : "p_name TEXT";
259
+ const missing = !at && doc.implied ? `v_doc := jsonb_build_object(${lit(doc.root)}, ${impliedRoot(root, doc)})${emptyIncludes};` : "RETURN NULL;";
260
+ const done = at
261
+ ? "RETURN v_doc;"
262
+ : `INSERT INTO _delta_versions (doc_name, version) VALUES (p_name, 0) ON CONFLICT (doc_name) DO NOTHING;
263
+ SELECT version INTO v_version FROM _delta_versions WHERE doc_name = p_name;
264
+ RETURN v_doc || jsonb_build_object('_version', v_version);`;
265
+ return `CREATE OR REPLACE FUNCTION ${fn}(${params})
266
+ RETURNS JSONB AS $$
267
+ DECLARE
268
+ p_at TIMESTAMPTZ;
269
+ v_single BOOLEAN;
270
+ v_id BIGINT;
271
+ v_rows JSONB;
272
+ v_root JSONB;
273
+ v_doc JSONB;
274
+ v_version BIGINT;
275
+ ${scope.declare.join("\n ")}
276
+ BEGIN
277
+ ${scope.body}${at ? "\n p_at := p_given;" : ""}
278
+ ${scope.single ? "IF NOT v_single THEN\n " : ""}SELECT COALESCE(jsonb_object_agg(t.id, ${rootJson}), '{}'::jsonb) INTO v_rows FROM ${q(root.name)} t WHERE ${where([])}${standing(root, "t")};
279
+ ${scope.single ? " " : ""}v_doc := jsonb_build_object(${lit(doc.root)}, v_rows)${fields((c) => includedList(schema, doc, c))};${scope.single ? `
280
+ ELSE
281
+ SELECT ${rootJson} INTO v_root FROM ${q(root.name)} t WHERE ${where(["t.id = v_id"])}${standing(root, "t")};
282
+ IF v_root IS NULL THEN
283
+ ${missing}
284
+ ELSE
285
+ v_doc := jsonb_build_object(${lit(doc.root)}, v_root)${fields((c) => includedSingle(schema, doc, c))};
286
+ END IF;
287
+ END IF;` : ""}
288
+ ${done}
289
+ END;
290
+ $$ LANGUAGE plpgsql;
291
+
292
+ CREATE OR REPLACE FUNCTION ${fn}_as(p_user_id TEXT, ${params})
293
+ RETURNS JSONB AS $$
294
+ BEGIN
295
+ PERFORM set_config('app.user_id', p_user_id, true);
296
+ RETURN ${fn}(${at ? "p_name, p_given" : "p_name"});
297
+ END
298
+ $$ LANGUAGE plpgsql;`;
299
+ }
300
+
301
+ /** One document's reads: its open and its open_at, each with its `_as`. */
302
+ function openOf(schema: Schema, doc: DocDef): string {
303
+ return `-- ${doc.prefix}: root ${doc.root}${doc.include.length ? `, includes ${doc.include.join(", ")}` : ""}${doc.implied ? ", implied" : ""}${doc.columns ? `, holds ${Object.entries(doc.columns).map(([c, cs]) => `${c}(${cs.join(", ")})`).join(" ")}` : ""}
304
+ ${readOf(schema, doc, false)}
305
+
306
+ ${readOf(schema, doc, true)}`;
307
+ }
308
+
309
+ /** The SQL that reads each of `docs` over `schema`: its open and its open_at, each with an `_as` for a signed-in caller. */
310
+ export function compileOpens(schema: Schema, docs: DocDef[]): string {
311
+ const names = new Map<string, string>();
312
+ for (const doc of docs) {
313
+ const fn = openFunction(doc.prefix);
314
+ const other = names.get(fn);
315
+ if (other !== undefined) throw new Error(`[dzql compile] documents ${other} and ${doc.prefix} would share the function ${fn}`);
316
+ names.set(fn, doc.prefix);
317
+ }
318
+ return [
319
+ "-- Compiled by dzql from the app's defineSchema / defineDoc. Do not edit: compile again.",
320
+ ...docs.map((d) => openOf(schema, d)),
321
+ "",
322
+ ].join("\n\n");
323
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Auth-JWT SQL helpers — pure filesystem access, no jose dependency.
3
+ *
4
+ * These are split out of `auth-jwt.ts` so the `delta init` CLI (which only
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
7
+ * re-export from `auth-jwt.ts`.
8
+ */
9
+ import type { Pool } from "pg";
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ const AUTH_JWT_SQL_PATH = join(import.meta.dir, "..", "sql", "auth-jwt.sql");
14
+
15
+ /** Absolute path to the reference auth-jwt SQL file (users + register + login). */
16
+ export function authJwtSqlFile(): string {
17
+ return AUTH_JWT_SQL_PATH;
18
+ }
19
+
20
+ /** The reference auth-jwt SQL as a string. */
21
+ export function authJwtSql(): string {
22
+ return readFileSync(AUTH_JWT_SQL_PATH, "utf8");
23
+ }
24
+
25
+ /**
26
+ * Apply the reference users schema + login/register stored functions to a
27
+ * pool. Idempotent. Call once at boot alongside `applyFramework(pool)`, or
28
+ * use `delta init --with-auth` to copy the file for docker-entrypoint use.
29
+ */
30
+ export async function applyAuthJwtSchema(pool: Pool): Promise<void> {
31
+ await pool.query(authJwtSql());
32
+ }