dzql 0.9.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.
package/README.md CHANGED
@@ -1,20 +1,15 @@
1
1
  # DZQL
2
2
 
3
- Database-first real-time framework with TypeScript support.
3
+ **delta's compiled Postgres step.**
4
4
 
5
- ## Quick Start
6
-
7
- ```bash
8
- bun create dzql my-app
9
- cd my-app
10
- bun install
11
- bun run db:rebuild
12
- bun run dev
13
- ```
5
+ An app built on [@blueshed/delta](https://github.com/blueshed/delta) keeps its definitions
6
+ (`defineSchema`, `defineDoc`), its client and its wire. On Postgres, dzql keeps its documents and
7
+ compiles those definitions to SQL, each piece held equal to delta's own cases. Why, and where it
8
+ is going: [philo.md](https://github.com/blueshed/dzql/blob/main/philo.md).
14
9
 
15
10
  ## delta's Postgres step
16
11
 
17
- dzql is becoming the Postgres step for apps built on [@blueshed/delta](https://github.com/blueshed/delta):
12
+ dzql is the Postgres step for apps built on [@blueshed/delta](https://github.com/blueshed/delta):
18
13
  a delta app grows from a JSON file to SQLite to Postgres, and on Postgres its documents are kept by
19
14
  dzql. Since 0.8.0 dzql carries delta's Postgres backend, with the same names, so moving is an
20
15
  import change:
@@ -25,8 +20,29 @@ import { openPglite } from "dzql/pglite"; // Postgres in this process
25
20
  import { jwtAuth } from "dzql/auth-jwt";
26
21
  ```
27
22
 
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`.
23
+ Its tests run delta's own cases (`@blueshed/delta/testing`) against it. Compiled SQL is replacing
24
+ the framework SQL a piece at a time: `generateSql` writes functions for each of your documents
25
+ (opening it, reading its name, who holds a row, a write's scope, what each document is told, the
26
+ fan-out), each held equal to the framework's; documents it did not compile go to the framework. Apply `generateSql`
27
+ after the framework, every time (`docs/delta/postgres.md`, "Compiled SQL"). The plan is in
28
+ Workshop's `docs/postgres-split-plan.md`.
29
+
30
+ ## The whole-stack dzql (retiring)
31
+
32
+ Before delta, dzql was a framework of its own: a `domain.ts` compiled to SQL, a typed client and
33
+ Pinia stores, started with `bun create dzql`. It is kept working until step 5 of the plan retires
34
+ it (the runtime, the client, the stores and `create-dzql`); nothing new is added to it. A new app
35
+ starts from delta and moves to dzql when it moves to Postgres.
36
+
37
+ ### Quick start
38
+
39
+ ```bash
40
+ bun create dzql my-app
41
+ cd my-app
42
+ bun install
43
+ bun run db:rebuild
44
+ bun run dev
45
+ ```
30
46
 
31
47
  ## Documentation
32
48
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.9.0",
4
- "description": "Database-first real-time framework with TypeScript support",
3
+ "version": "0.10.0",
4
+ "description": "delta's compiled Postgres step: an app's delta definitions compiled to SQL",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/blueshed/dzql"
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Compiled cascades: what a remove takes with it, and what a moved row
3
+ * carries. The framework walks `_delta_collections` at run time for a
4
+ * collection's children and the collections that cascade on it, and builds
5
+ * each query from their names; here the schema says them, and each is written.
6
+ *
7
+ * `compileCascades(schema)` writes, for every collection of the schema:
8
+ * - `dzql_cascade_remove__<slug>(id)`: the row closed (temporal) or deleted, and
9
+ * only if it was there, each child collection's rows under it, then each
10
+ * collection that cascades on it -- the removes, in that order;
11
+ * - `dzql_cascade_rows__<slug>(id)`: the rows that remove would take, read
12
+ * before, in the order it takes them;
13
+ * - `dzql_hanging_rows__<slug>(id)`: the rows that hang from it, and on down,
14
+ * parent first.
15
+ * And `_delta_cascade_remove`, `_delta_cascade_rows` and `_delta_hanging_rows`,
16
+ * sending each collection to its own and any other to the framework's
17
+ * (`_framework`). The order is the framework's: child collections by key, then
18
+ * the collections that cascade on it by key and key column, each in the
19
+ * database's byte order (`COLLATE "C"`), and each one's rows by id.
20
+ */
21
+ import { q, type Schema } from "@blueshed/delta/backend";
22
+ import { lit, named, standingRows } from "./opens";
23
+
24
+ /** A collection's function: `dzql_<what>__<slug>`. */
25
+ const fn = (what: string, coll: string): string => named(what, coll);
26
+
27
+ /** As `COLLATE "C"` orders text: by its bytes. */
28
+ const bytes = (a: string, b: string): number => Buffer.compare(Buffer.from(a), Buffer.from(b));
29
+
30
+ /** A collection's children, and the collections that cascade on it, in the framework's order: [collection, key column]. */
31
+ function reached(schema: Schema, coll: string): { children: [string, string][]; refs: [string, string][] } {
32
+ const table = schema.tables[coll]!;
33
+ const children = [...table.children].sort(bytes).map((c): [string, string] => [c, schema.tables[c]!.parent!.fkColumn]);
34
+ const refs = table.referencedBy
35
+ .filter((r) => r.collection !== coll) // as generateSql's cascade_on: never a collection's own
36
+ .map((r): [string, string] => [r.collection, r.fkColumn])
37
+ .sort((a, b) => bytes(a[0], b[0]) || bytes(a[1], b[1]));
38
+ return { children, refs };
39
+ }
40
+
41
+ /** Every collection of `schema` its own slug: two that would share a function name are refused. */
42
+ function assertSlugs(schema: Schema): void {
43
+ const seen = new Map<string, string>();
44
+ for (const coll of Object.keys(schema.tables)) {
45
+ const slug = fn("", coll);
46
+ const other = seen.get(slug);
47
+ if (other) throw new Error(`[dzql compile] collections "${other}" and "${coll}" would share the function name ${slug}: rename one`);
48
+ seen.set(slug, coll);
49
+ }
50
+ }
51
+
52
+ function removeOf(schema: Schema, coll: string): string {
53
+ const t = schema.tables[coll]!;
54
+ const { children, refs } = reached(schema, coll);
55
+ const walk = [...children, ...refs].map(([c, fk]) =>
56
+ ` FOR v_id IN SELECT id FROM ${standingRows(schema.tables[c]!)} WHERE ${q(fk)} = p_id ORDER BY id LOOP
57
+ v_ops := v_ops || ${fn("cascade_remove", c)}(v_id);
58
+ END LOOP;`);
59
+ return `CREATE OR REPLACE FUNCTION ${fn("cascade_remove", coll)}(p_id BIGINT)
60
+ RETURNS JSONB AS $$
61
+ DECLARE
62
+ v_affected BIGINT;
63
+ v_id BIGINT;
64
+ v_ops JSONB;
65
+ BEGIN
66
+ ${t.temporal ? `UPDATE ${q(t.name)} SET valid_to = NOW() WHERE id = p_id AND valid_to IS NULL;` : `DELETE FROM ${q(t.name)} WHERE id = p_id;`}
67
+ GET DIAGNOSTICS v_affected = ROW_COUNT;
68
+ IF v_affected = 0 THEN RETURN '[]'::jsonb; END IF; -- only a removal that happened (closed already, or RLS filtered it)
69
+ v_ops := jsonb_build_array(jsonb_build_object('op', 'remove', 'path', _delta_build_path(${lit(coll)}, p_id::text)));
70
+ ${walk.join("\n")}
71
+ RETURN v_ops;
72
+ END;
73
+ $$ LANGUAGE plpgsql;`;
74
+ }
75
+
76
+ function rowsOf(schema: Schema, coll: string): string {
77
+ const t = schema.tables[coll]!;
78
+ const { children, refs } = reached(schema, coll);
79
+ const walk = [...children, ...refs].map(([c, fk]) =>
80
+ ` FOR v_id IN SELECT id FROM ${standingRows(schema.tables[c]!)} WHERE ${q(fk)} = p_id ORDER BY id LOOP
81
+ v_rows := v_rows || ${fn("cascade_rows", c)}(v_id);
82
+ END LOOP;`);
83
+ return `CREATE OR REPLACE FUNCTION ${fn("cascade_rows", coll)}(p_id BIGINT)
84
+ RETURNS JSONB AS $$
85
+ DECLARE
86
+ v_row JSONB;
87
+ v_id BIGINT;
88
+ v_rows JSONB;
89
+ BEGIN
90
+ SELECT to_jsonb(t.*) INTO v_row FROM ${standingRows(t)} t WHERE t.id = p_id;
91
+ IF v_row IS NULL THEN RETURN '[]'::jsonb; END IF;
92
+ v_rows := jsonb_build_array(jsonb_build_object('coll', ${lit(coll)}, 'id', p_id, 'row', _delta_strip_temporal(v_row)));
93
+ ${walk.join("\n")}
94
+ RETURN v_rows;
95
+ END;
96
+ $$ LANGUAGE plpgsql STABLE;`;
97
+ }
98
+
99
+ function hangingOf(schema: Schema, coll: string): string {
100
+ const { children } = reached(schema, coll);
101
+ const parts = children.map(([c, fk]) => {
102
+ const below = schema.tables[c]!.children.length ? `${fn("hanging_rows", c)}(t.id)` : "'[]'::jsonb";
103
+ return ` SELECT COALESCE(jsonb_agg(e ORDER BY t.id, n), '[]'::jsonb) INTO v_part FROM ${standingRows(schema.tables[c]!)} t,
104
+ jsonb_array_elements(jsonb_build_array(jsonb_build_object('coll', ${lit(c)}, 'id', t.id, 'row', _delta_strip_temporal(to_jsonb(t.*)))) || ${below})
105
+ WITH ORDINALITY AS x(e, n)
106
+ WHERE t.${q(fk)} = p_id;
107
+ v_rows := v_rows || v_part;`;
108
+ });
109
+ return `CREATE OR REPLACE FUNCTION ${fn("hanging_rows", coll)}(p_id BIGINT)
110
+ RETURNS JSONB AS $$
111
+ DECLARE
112
+ v_part JSONB;
113
+ v_rows JSONB := '[]'::jsonb;
114
+ BEGIN
115
+ ${parts.join("\n")}
116
+ RETURN v_rows;
117
+ END;
118
+ $$ LANGUAGE plpgsql STABLE;`;
119
+ }
120
+
121
+ /** A dispatcher: each collection of `schema` to its own, any other to the framework's. */
122
+ function dispatch(schema: Schema, name: string, what: string, volatility: string): string {
123
+ const colls = Object.keys(schema.tables);
124
+ const body = colls.length
125
+ ? ` CASE p_collection_key
126
+ ${colls.map((c) => ` WHEN ${lit(c)} THEN RETURN ${fn(what, c)}(p_id);`).join("\n")}
127
+ ELSE RETURN ${name}_framework(p_collection_key, p_id);
128
+ END CASE;`
129
+ : ` RETURN ${name}_framework(p_collection_key, p_id);`;
130
+ return `CREATE OR REPLACE FUNCTION ${name}(p_collection_key TEXT, p_id BIGINT)
131
+ RETURNS JSONB AS $$
132
+ BEGIN
133
+ ${body}
134
+ END;
135
+ $$ LANGUAGE plpgsql${volatility};`;
136
+ }
137
+
138
+ /** The cascades and hanging rows of every collection of `schema`, and the dispatch to the framework's for any other. */
139
+ export function compileCascades(schema: Schema): string {
140
+ assertSlugs(schema);
141
+ const colls = Object.keys(schema.tables);
142
+ return [
143
+ ...colls.map((c) => removeOf(schema, c)),
144
+ ...colls.map((c) => rowsOf(schema, c)),
145
+ ...colls.map((c) => hangingOf(schema, c)),
146
+ dispatch(schema, "_delta_cascade_remove", "cascade_remove", ""),
147
+ dispatch(schema, "_delta_cascade_rows", "cascade_rows", " STABLE"),
148
+ dispatch(schema, "_delta_hanging_rows", "hanging_rows", " STABLE"),
149
+ "",
150
+ ].join("\n\n");
151
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Compiled columns and their checks: what a write may name, and what each
3
+ * value must be. Asked of every op of a write (`delta_apply`).
4
+ *
5
+ * The columns are the writing process's: `delta.declared`, set for the call by
6
+ * its listener, else as the database last declared them (the schema is the
7
+ * contract: two versions on one database each write by their own). So they
8
+ * stay read at run time, as the framework reads them, and what is compiled is
9
+ * what follows from the definitions:
10
+ *
11
+ * - `dzql_columns__<slug>(collection, columns_def)` for each document: the
12
+ * columns it writes, the ones it names (`columns`) where it names some,
13
+ * from the list written in. `_delta_write_columns` sends each compiled
14
+ * document to its own, any other to the framework
15
+ * (`_delta_write_columns_framework`).
16
+ * - `_delta_assert_fields` itself: for each collection, each set of columns
17
+ * the definitions give it (all of them, and each document's own), checked
18
+ * by written statements -- the fields there are, the parent key, each
19
+ * column's type -- with the framework's messages, put together in the
20
+ * framework's order (`ORDER BY` in SQL, so the database's collation orders
21
+ * both alike). Columns of another set (a walk's, another version's) are
22
+ * checked by the framework's (`_delta_assert_fields_framework`).
23
+ */
24
+ import type { ColumnDef, DocDef, ResolvedTable, Schema } from "@blueshed/delta/backend";
25
+ import { lit, named, need } from "./opens";
26
+
27
+ /** A document's columns function: `dzql_columns__<slug>`. */
28
+ export const columnsFunction = (prefix: string): string => named("columns", prefix);
29
+
30
+ /** A set of columns as the database keeps it (`_delta_collections.columns_def`, generateSql's). */
31
+ const asStored = (columns: Record<string, ColumnDef>): string => `${lit(JSON.stringify(columns))}::jsonb`;
32
+
33
+ /** One document's columns function. */
34
+ function columnsOf(schema: Schema, doc: DocDef): string {
35
+ const declared = "COALESCE(NULLIF(current_setting('delta.declared', true), '')::jsonb -> p_coll, p_columns_def)";
36
+ const named = Object.entries(doc.columns ?? {});
37
+ for (const [coll] of named) need(schema, doc, coll);
38
+ const body = named.length === 0
39
+ ? ` RETURN ${declared};`
40
+ : ` v_cols := ${declared};
41
+ CASE p_coll
42
+ ${named.map(([coll, cols]) => ` WHEN ${lit(coll)} THEN RETURN ${cols.length
43
+ ? `COALESCE((SELECT jsonb_object_agg(k, v) FROM jsonb_each(v_cols) AS e(k, v) WHERE k IN (${cols.map(lit).join(", ")})), '{}'::jsonb)`
44
+ : "'{}'::jsonb"};`).join("\n")}
45
+ ELSE RETURN v_cols;
46
+ END CASE;`;
47
+ return `CREATE OR REPLACE FUNCTION ${columnsFunction(doc.prefix)}(p_coll TEXT, p_columns_def JSONB)
48
+ RETURNS JSONB AS $$
49
+ DECLARE
50
+ v_cols JSONB;
51
+ BEGIN
52
+ ${body}
53
+ END;
54
+ $$ LANGUAGE plpgsql STABLE;`;
55
+ }
56
+
57
+ /** What a column's value must be, as the framework says it; none for a type it does not check. */
58
+ const MUST: Record<string, [string, (v: string) => string]> = {
59
+ text: ["a string", (v) => `jsonb_typeof(${v}) = 'string'`],
60
+ integer: ["an integer", (v) => `jsonb_typeof(${v}) = 'number' AND ${v}::text::numeric = trunc(${v}::text::numeric)`],
61
+ real: ["a number", (v) => `jsonb_typeof(${v}) = 'number'`],
62
+ boolean: ["a boolean", (v) => `jsonb_typeof(${v}) = 'boolean'`],
63
+ timestamptz: ["an ISO-8601 time", (v) =>
64
+ `jsonb_typeof(${v}) = 'string' AND (${v} #>> '{}') ~ '^\\d{4}-\\d{2}-\\d{2}([T ]([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,6})?)?(Z|[+-]([01]\\d|2[0-3])(:?[0-5]\\d)?)?)?$'`],
65
+ };
66
+
67
+ /** The checks of one set of a collection's columns, as written statements. */
68
+ function checksOf(table: ResolvedTable, columns: Record<string, ColumnDef>): string {
69
+ const fk = table.parent?.fkColumn;
70
+ const known = ["id", "valid_from", "valid_to", ...(fk ? [fk] : []), ...Object.keys(columns)];
71
+ const typed = Object.entries(columns).filter(([, d]) => MUST[d.type]);
72
+ const lines = [
73
+ `IF p_value IS NULL OR jsonb_typeof(p_value) <> 'object' THEN
74
+ RAISE EXCEPTION 'a row value for % must be an object', p_collection USING ERRCODE = '22023';
75
+ END IF;`,
76
+ `SELECT string_agg(k, ', ' ORDER BY k) INTO v_bad FROM jsonb_object_keys(p_value) AS k WHERE k NOT IN (${[...new Set(known)].map(lit).join(", ")});
77
+ IF v_bad IS NOT NULL THEN
78
+ RAISE EXCEPTION 'Unknown field: % (not a column of %)', v_bad, p_collection USING ERRCODE = '22023';
79
+ END IF;`,
80
+ ];
81
+ if (fk) {
82
+ const v = `p_value->${lit(fk)}`;
83
+ lines.push(`IF p_value ? ${lit(fk)} AND jsonb_typeof(${v}) <> 'null'
84
+ AND NOT (jsonb_typeof(${v}) = 'string' OR (jsonb_typeof(${v}) = 'number' AND (p_value->>${lit(fk)})::numeric = trunc((p_value->>${lit(fk)})::numeric))) THEN
85
+ RAISE EXCEPTION '% must be an id: an integer', ${lit(fk)} USING ERRCODE = '22023';
86
+ END IF;`);
87
+ }
88
+ if (typed.length) {
89
+ const rows = typed.map(([col, d]) => {
90
+ const v = `(p_value->${lit(col)})`; // in parentheses: `::` binds tighter than `->`
91
+ const [must, ok] = MUST[d.type]!;
92
+ return `(${lit(col)}, CASE WHEN jsonb_typeof(${v}) <> 'null' AND NOT (${ok(v)}) THEN ${lit(`${col} must be ${must}`)} END)`;
93
+ });
94
+ lines.push(`SELECT string_agg(m, '; ' ORDER BY k) INTO v_bad FROM (VALUES
95
+ ${rows.join(",\n ")}) AS x(k, m) WHERE m IS NOT NULL;
96
+ IF v_bad IS NOT NULL THEN RAISE EXCEPTION '%', v_bad USING ERRCODE = '22023'; END IF;`);
97
+ }
98
+ return [...lines, "RETURN;"].join("\n ");
99
+ }
100
+
101
+ /** The SQL that gives and checks each of `docs`' columns: a function per document, `_delta_write_columns`, and `_delta_assert_fields`. */
102
+ export function compileColumns(schema: Schema, docs: DocDef[]): string {
103
+ const dispatch = `CREATE OR REPLACE FUNCTION _delta_write_columns(p_def _delta_docs, p_coll TEXT, p_columns_def JSONB)
104
+ RETURNS JSONB AS $$
105
+ BEGIN
106
+ CASE p_def.prefix
107
+ ${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${columnsFunction(d.prefix)}(p_coll, p_columns_def);`).join("\n")}
108
+ ELSE RETURN _delta_write_columns_framework(p_def, p_coll, p_columns_def);
109
+ END CASE;
110
+ END;
111
+ $$ LANGUAGE plpgsql STABLE;`;
112
+
113
+ // each collection's sets of columns: all of them, and each document's own
114
+ const branches = Object.entries(schema.tables).map(([coll, table]) => {
115
+ const sets = new Map<string, Record<string, ColumnDef>>([[JSON.stringify(table.columns), table.columns]]);
116
+ for (const doc of docs) {
117
+ const named = doc.columns?.[coll];
118
+ if (!named) continue;
119
+ const own = Object.fromEntries(Object.entries(table.columns).filter(([c]) => named.includes(c)));
120
+ sets.set(JSON.stringify(own), own);
121
+ }
122
+ const fk = table.parent?.fkColumn;
123
+ return ` WHEN ${lit(coll)} THEN
124
+ IF p_parent_fk IS NOT DISTINCT FROM ${fk ? lit(fk) : "NULL"} THEN
125
+ ${[...sets.values()].map((cols) => ` IF p_columns = ${asStored(cols)} THEN
126
+ ${checksOf(table, cols)}
127
+ END IF;`).join("\n")}
128
+ END IF;`;
129
+ });
130
+ const checks = `CREATE OR REPLACE FUNCTION _delta_assert_fields(p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB)
131
+ RETURNS void AS $$
132
+ DECLARE
133
+ v_bad TEXT;
134
+ BEGIN
135
+ CASE p_collection
136
+ ${branches.join("\n")}
137
+ ELSE NULL;
138
+ END CASE;
139
+ -- another set of columns (a walk's, another version's): the framework's
140
+ PERFORM _delta_assert_fields_framework(p_collection, p_columns, p_parent_fk, p_value);
141
+ END;
142
+ $$ LANGUAGE plpgsql IMMUTABLE;`;
143
+ return [...docs.map((d) => columnsOf(schema, d)), dispatch, checks, ""].join("\n\n");
144
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Compiled fan-out: which open documents hold a row. Asked by every write of
3
+ * every row it touches, as the row was before and as it is after
4
+ * (`_delta_holders`), and what each is told follows from it.
5
+ *
6
+ * `compileHolders(schema, docs)` writes `_delta_holders` itself, answering
7
+ * what the framework's answers, from the definitions: for the row's
8
+ * collection, each document that holds it, in turn -- its prefix's lock,
9
+ * shared; its open names, found as the framework finds them (by the root the
10
+ * row hangs from, its chain written as lookups, where the document is one row
11
+ * of an unscoped definition; else every open name of its prefix), and the
12
+ * writer's; each name its own prefix owns and whose document holds the row
13
+ * (`dzql_holds__<slug>`). Any other document (registered by hand, or by another
14
+ * `generateSql`) is asked by the framework's (`_delta_holders_framework`), told
15
+ * which prefixes were asked already. SECURITY DEFINER, with the search path it
16
+ * was applied with and pg_temp last, as the framework's.
17
+ */
18
+ import type { DocDef, Schema } from "@blueshed/delta/backend";
19
+ import { chainRoot, holdsFunction } from "./holds";
20
+ import { lit, need } from "./opens";
21
+
22
+ /** One document's names that hold the row, for a collection it holds. */
23
+ function askOf(schema: Schema, doc: DocDef, coll: string): string {
24
+ const table = need(schema, doc, coll);
25
+ const all = Object.keys(doc.scope ?? {}).length > 0 || (coll !== doc.root && !table.parent);
26
+ const prefix = lit(doc.prefix);
27
+ return ` -- ${doc.prefix}
28
+ ${all ? "" : ` v_rid := ${coll === doc.root ? "(p_row->>'id')::BIGINT" : chainRoot(schema, doc, coll)};\n`} PERFORM pg_advisory_xact_lock_shared(hashtext('_delta_hold'), hashtext(${prefix}));
29
+ FOR v_cand IN
30
+ SELECT doc_name FROM _delta_held WHERE ${all ? `starts_with(doc_name, ${prefix})` : `doc_name IN (${prefix}, ${prefix} || v_rid::text)`}
31
+ UNION
32
+ SELECT p_writer WHERE p_writer IS NOT NULL AND starts_with(p_writer, ${prefix})
33
+ LOOP
34
+ IF NOT (v_cand = ANY(v_names))
35
+ AND NOT EXISTS (SELECT 1 FROM _delta_docs d WHERE length(d.prefix) > ${doc.prefix.length} AND starts_with(v_cand, d.prefix)) -- the longest prefix owns the name
36
+ AND ${holdsFunction(doc.prefix)}(v_cand, ${lit(coll)}, p_row) THEN
37
+ v_names := v_names || v_cand;
38
+ END IF;
39
+ END LOOP;`;
40
+ }
41
+
42
+ /** The SQL that answers who holds a row: `_delta_holders`, each of `docs` asked by its own lookups, any other by the framework's. */
43
+ export function compileHolders(schema: Schema, docs: DocDef[]): string {
44
+ const branches = Object.keys(schema.tables).flatMap((coll) => {
45
+ const holding = docs.filter((d) => d.root === coll || d.include.includes(coll));
46
+ return holding.length ? [` WHEN ${lit(coll)} THEN\n${holding.map((d) => askOf(schema, d, coll)).join("\n")}`] : [];
47
+ });
48
+ const asked = `ARRAY[${docs.map((d) => lit(d.prefix)).join(", ")}]::text[]`;
49
+ return `CREATE OR REPLACE FUNCTION _delta_holders(p_coll TEXT, p_row JSONB, p_writer TEXT)
50
+ RETURNS TEXT[] AS $$
51
+ DECLARE
52
+ v_names TEXT[] := ARRAY[]::text[];
53
+ v_rid BIGINT;
54
+ v_cand TEXT;
55
+ BEGIN
56
+ IF current_setting('transaction_isolation') <> 'read committed' THEN
57
+ RAISE EXCEPTION 'a delta write runs at READ COMMITTED, not %: a document opened while it ran would not be told of it',
58
+ upper(current_setting('transaction_isolation')) USING ERRCODE = '0A000';
59
+ END IF;
60
+ IF p_row IS NULL THEN RETURN v_names; END IF;
61
+ ${branches.length ? ` CASE p_coll\n${branches.join("\n")}\n ELSE NULL;\n END CASE;\n` : ""} -- the documents not compiled here, as the framework asks them
62
+ IF EXISTS (SELECT 1 FROM _delta_docs WHERE (root_collection = p_coll OR p_coll = ANY(include)) AND NOT (prefix = ANY(${asked}))) THEN
63
+ v_names := v_names || _delta_holders_framework(p_coll, p_row, p_writer, ${asked});
64
+ END IF;
65
+ RETURN v_names;
66
+ END;
67
+ $$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
68
+ DO $$ BEGIN
69
+ EXECUTE format('ALTER FUNCTION _delta_holders(TEXT, JSONB, TEXT) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
70
+ END $$;
71
+ `;
72
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Compiled holds: does a document hold a row? Asked of every open document by
3
+ * every write (`_delta_holders`, the fan-out), on the row as it was before the
4
+ * write and as it is after.
5
+ *
6
+ * `compileHolds(schema, docs)` writes one function per document --
7
+ * `dzql_holds__<slug>(name, collection, row)` -- answering what the
8
+ * framework's `_delta_doc_holds` answers for it, from its definition: which
9
+ * collections it holds, its name's conditions tested on the row's own values
10
+ * (`scopeOf`, a name its columns cannot take holding nothing), a temporal row
11
+ * not told to a document named with a time past, and an included row's chain
12
+ * of parents up to the root, as written lookups. And `_delta_doc_holds`
13
+ * itself, which sends each compiled document to its function and any other
14
+ * (registered by hand, or by another `generateSql`) to the framework's
15
+ * (`_delta_doc_holds_framework`).
16
+ */
17
+ import { q, type DocDef, type ResolvedTable, type Schema } from "@blueshed/delta/backend";
18
+ import { lit, named, need, scopeOf , currentOnly } from "./opens";
19
+
20
+ /** A document's holds function: `dzql_holds__<slug>`. */
21
+ export const holdsFunction = (prefix: string): string => named("holds", prefix);
22
+
23
+
24
+ /**
25
+ * The id of the root row an included row hangs from, as `_delta_chain_root`
26
+ * finds it: its parent key, then each parent's, through the tables as they
27
+ * stand; NULL where the chain ends short of the root.
28
+ */
29
+ export function chainRoot(schema: Schema, doc: DocDef, coll: string): string {
30
+ let table = need(schema, doc, coll);
31
+ let id = `(p_row->>${lit(table.parent!.fkColumn)})::BIGINT`;
32
+ for (let depth = 0; depth < 32; depth++) {
33
+ const parentKey = table.parent!.collection;
34
+ if (parentKey === doc.root) return id;
35
+ const parent = need(schema, doc, parentKey);
36
+ if (!parent.parent) return "NULL::BIGINT";
37
+ id = `(SELECT p.${q(parent.parent.fkColumn)} FROM ${q(parent.name)} p WHERE p.id = ${id}${currentOnly(parent, "p")})`;
38
+ table = parent;
39
+ }
40
+ return "NULL::BIGINT";
41
+ }
42
+
43
+ /** One document's holds function. */
44
+ function holdsOf(schema: Schema, doc: DocDef): string {
45
+ const root = need(schema, doc, doc.root);
46
+ // a name its columns cannot take (one opened before a rule refused it) holds nothing
47
+ const scope = scopeOf(schema, doc, () => "RETURN FALSE;");
48
+ const colls = [doc.root, ...doc.include.filter((c) => c !== doc.root)];
49
+ const pastTime = (table: ResolvedTable) => (table.temporal ? `\n IF p_at <= now() THEN RETURN FALSE; END IF;` : "");
50
+ const branches = colls.map((coll) => {
51
+ const table = need(schema, doc, coll);
52
+ if (coll === doc.root) {
53
+ return ` WHEN ${lit(coll)} THEN${pastTime(table)}
54
+ IF v_single AND (p_row->>'id')::BIGINT IS DISTINCT FROM v_id THEN RETURN FALSE; END IF;
55
+ RETURN EXISTS (SELECT 1 FROM jsonb_populate_record(null::${q(root.name)}, p_row) t WHERE ${scope.conds || "TRUE"});`;
56
+ }
57
+ if (!table.parent) return ` WHEN ${lit(coll)} THEN${pastTime(table)}\n RETURN TRUE;`;
58
+ return ` WHEN ${lit(coll)} THEN${pastTime(table)}
59
+ IF NOT v_single THEN RETURN TRUE; END IF;
60
+ RETURN ${chainRoot(schema, doc, coll)} IS NOT DISTINCT FROM v_id;`;
61
+ });
62
+ return `CREATE OR REPLACE FUNCTION ${holdsFunction(doc.prefix)}(p_name TEXT, p_coll TEXT, p_row JSONB)
63
+ RETURNS BOOLEAN AS $$
64
+ DECLARE
65
+ p_at TIMESTAMPTZ;
66
+ v_single BOOLEAN;
67
+ v_id BIGINT;
68
+ ${scope.declare.join("\n ")}
69
+ BEGIN
70
+ IF p_row IS NULL OR p_coll NOT IN (${colls.map(lit).join(", ")}) THEN RETURN FALSE; END IF;
71
+ ${scope.body}
72
+ CASE p_coll
73
+ ${branches.join("\n")}
74
+ END CASE;
75
+ END;
76
+ $$ LANGUAGE plpgsql STABLE;`;
77
+ }
78
+
79
+ /** The SQL that answers who holds a row for each of `docs`: a function per document, and `_delta_doc_holds` sending each to its own. */
80
+ export function compileHolds(schema: Schema, docs: DocDef[]): string {
81
+ const dispatch = `CREATE OR REPLACE FUNCTION _delta_doc_holds(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
82
+ RETURNS BOOLEAN AS $$
83
+ BEGIN
84
+ CASE p_def.prefix
85
+ ${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${holdsFunction(d.prefix)}(p_doc_name, p_coll, p_row);`).join("\n")}
86
+ ELSE RETURN _delta_doc_holds_framework(p_def, p_doc_name, p_coll, p_row);
87
+ END CASE;
88
+ END;
89
+ $$ LANGUAGE plpgsql STABLE;`;
90
+ return [...docs.map((d) => holdsOf(schema, d)), dispatch, ""].join("\n\n");
91
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Compiled ledger reads: what the ledger reads to write an entry's inverse and
3
+ * to guard a walk of it (undo, redo). The framework read the document whole
4
+ * (`_delta_open(name, TRUE)`), a field's type and the rows beyond the document
5
+ * from the definitions at run time; here each is written from them.
6
+ *
7
+ * `compileLedger(schema, docs)` writes:
8
+ * - `dzql_open_whole__<slug>(name)` for each document: its open with every
9
+ * column of every row, whatever columns it names (`readOf`, whole), and
10
+ * `_delta_open` itself, sending each compiled document to its own open, shown
11
+ * (`dzql_open__<slug>`) or whole, and any other to the framework's
12
+ * (`_delta_open_framework`);
13
+ * - `_delta_same(collection, field, a, b)`: two values of a field the same, a
14
+ * time by the instant it names. The field's type is the writing process's
15
+ * (`delta.declared`), else the schema's, written in for each collection;
16
+ * any other collection is the framework's (`_delta_same_framework`);
17
+ * - `_delta_row_standing(collection, id)`: a row as it stands, as `{row}`, for
18
+ * every collection of the schema; any other the framework's.
19
+ */
20
+ import type { DocDef, Schema } from "@blueshed/delta/backend";
21
+ import { lit, openFunction, openWholeFunction, readOf, standingRows } from "./opens";
22
+
23
+ /** The SQL the ledger reads by, for `schema` and each of `docs`. */
24
+ export function compileLedger(schema: Schema, docs: DocDef[]): string {
25
+ const whole = docs.map((d) => readOf(schema, d, false, true));
26
+
27
+ const open = `CREATE OR REPLACE FUNCTION _delta_open(p_doc_name TEXT, p_whole BOOLEAN)
28
+ RETURNS JSONB AS $$
29
+ DECLARE
30
+ v_def _delta_docs := _delta_find_doc(p_doc_name);
31
+ BEGIN
32
+ CASE v_def.prefix
33
+ ${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN CASE WHEN p_whole THEN ${openWholeFunction(d.prefix)}(p_doc_name) ELSE ${openFunction(d.prefix)}(p_doc_name) END;`).join("\n")}
34
+ ELSE RETURN _delta_open_framework(p_doc_name, p_whole); -- a name no document compiled here holds (none at all: the framework refuses it)
35
+ END CASE;
36
+ END;
37
+ $$ LANGUAGE plpgsql;`;
38
+
39
+ const colls = Object.entries(schema.tables);
40
+ const same = `CREATE OR REPLACE FUNCTION _delta_same(p_coll TEXT, p_field TEXT, a JSONB, b JSONB)
41
+ RETURNS BOOLEAN AS $$
42
+ DECLARE
43
+ v_declared JSONB;
44
+ v_type TEXT;
45
+ BEGIN
46
+ IF jsonb_typeof(a) = 'string' AND jsonb_typeof(b) = 'string' THEN
47
+ CASE p_coll
48
+ ${colls.map(([coll, t]) => {
49
+ const times = Object.entries(t.columns).filter(([, d]) => d.type === "timestamptz").map(([c]) => c);
50
+ return ` WHEN ${lit(coll)} THEN
51
+ v_declared := NULLIF(current_setting('delta.declared', true), '')::jsonb -> ${lit(coll)};
52
+ v_type := CASE WHEN v_declared IS NOT NULL THEN v_declared -> p_field ->> 'type'${times.length ? ` WHEN p_field IN (${times.map(lit).join(", ")}) THEN 'timestamptz'` : ""} END;`;
53
+ }).join("\n")}
54
+ ELSE RETURN _delta_same_framework(p_coll, p_field, a, b);
55
+ END CASE;
56
+ IF v_type = 'timestamptz' THEN RETURN (a #>> '{}')::timestamptz = (b #>> '{}')::timestamptz; END IF;
57
+ END IF;
58
+ RETURN COALESCE(a, 'null'::jsonb) = COALESCE(b, 'null'::jsonb);
59
+ END;
60
+ $$ LANGUAGE plpgsql STABLE;`;
61
+
62
+ const standing = `CREATE OR REPLACE FUNCTION _delta_row_standing(p_coll TEXT, p_id BIGINT)
63
+ RETURNS JSONB AS $$
64
+ BEGIN
65
+ CASE p_coll
66
+ ${colls.map(([coll, t]) => ` WHEN ${lit(coll)} THEN RETURN jsonb_build_object('row', (SELECT _delta_strip_temporal(to_jsonb(t.*)) FROM ${standingRows(t)} t WHERE t.id = p_id));`).join("\n")}
67
+ ELSE RETURN _delta_row_standing_framework(p_coll, p_id);
68
+ END CASE;
69
+ END;
70
+ $$ LANGUAGE plpgsql STABLE;`;
71
+
72
+ return [...whole, open, same, standing, ""].join("\n\n");
73
+ }