dzql 0.8.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/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.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,7 +17,8 @@
17
17
  "./namespace": "./src/runtime/namespace.ts",
18
18
  "./postgres": "./src/postgres/index.ts",
19
19
  "./pglite": "./src/postgres/pglite.ts",
20
- "./auth-jwt": "./src/postgres/auth-jwt.ts"
20
+ "./auth-jwt": "./src/postgres/auth-jwt.ts",
21
+ "./compile": "./src/compile/opens.ts"
21
22
  },
22
23
  "files": [
23
24
  "src",
@@ -29,7 +30,8 @@
29
30
  "scripts": {
30
31
  "test": "bun run test:setup && bun test; status=$?; bun run test:teardown; exit $status",
31
32
  "test:setup": "docker compose down -v && docker compose up -d --wait",
32
- "test:teardown": "docker compose down -v"
33
+ "test:teardown": "docker compose down -v",
34
+ "bench": "bun run bench/run.ts"
33
35
  },
34
36
  "keywords": [
35
37
  "postgresql",
@@ -48,7 +50,6 @@
48
50
  "author": "Peter Bunyan",
49
51
  "license": "MIT",
50
52
  "dependencies": {
51
- "@blueshed/delta": "^0.16.0",
52
53
  "@electric-sql/pglite": "^0.5.4",
53
54
  "dotenv": "^16.4.5",
54
55
  "jose": "^6.2.4",
@@ -56,14 +57,17 @@
56
57
  "postgres": "^3.4.3"
57
58
  },
58
59
  "devDependencies": {
60
+ "@blueshed/railroad": "^0.16.0",
59
61
  "@types/bun": "latest",
60
62
  "@types/pg": "^8.20.0",
61
63
  "pinia": "^3.0.4",
62
64
  "typescript": "^5.0.0",
63
- "vue": "^3.5.26"
65
+ "vue": "^3.5.26",
66
+ "@blueshed/delta": "^1.0.0"
64
67
  },
65
68
  "peerDependencies": {
66
- "typescript": "^5.0.0"
69
+ "typescript": "^5.0.0",
70
+ "@blueshed/delta": "^1.0.0"
67
71
  },
68
72
  "engines": {
69
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,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));
@@ -26,6 +26,7 @@ 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";
29
30
 
30
31
  export interface GenerateSqlOptions {
31
32
  /** Header comment included at the top of the file. */
@@ -168,5 +169,7 @@ export function generateSql(
168
169
  }
169
170
  lines.push("");
170
171
 
172
+ // each document opened by SQL compiled for it (dzql), which docTypeFromDef calls
173
+ lines.push("", compileOpens(schema, docs));
171
174
  return lines.join("\n");
172
175
  }
@@ -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
  }