dzql 0.9.0 → 0.11.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. `generateSql` writes the
24
+ SQL for each of your documents and collections, compiled from their definitions (opening a
25
+ document, reading its name, who holds a row, a write's scope and rows, what each document is told,
26
+ the fan-out, the ledger's reads): nothing interprets a definition at run time. Apply it after the
27
+ framework, every time, one app per database (`docs/delta/postgres.md`, "Compiled SQL"). The plan
28
+ is in 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.11.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; a collection there is not has nothing
17
+ * to take. The order is the one the framework's had: 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 has nothing to take. */
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 '[]'::jsonb; -- a collection there is not: nothing to take, nothing hangs from it
128
+ END CASE;`
129
+ : ` RETURN '[]'::jsonb;`;
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 their dispatch. */
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,143 @@
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 document to
14
+ * its own, and refuses a name of none.
15
+ * - `_delta_assert_fields` itself: for each collection, each set of columns
16
+ * the definitions give it (all of them, and each document's own), checked
17
+ * by written statements -- the fields there are, the parent key, each
18
+ * column's type -- with the framework's messages, put together in the
19
+ * framework's order (`ORDER BY` in SQL, so the database's collation orders
20
+ * both alike). Columns of another set (a walk's, another version's) are
21
+ * checked by the columns given (`_delta_assert_fields_declared`).
22
+ */
23
+ import type { ColumnDef, DocDef, ResolvedTable, Schema } from "@blueshed/delta/backend";
24
+ import { lit, named, need, noDoc } from "./opens";
25
+
26
+ /** A document's columns function: `dzql_columns__<slug>`. */
27
+ export const columnsFunction = (prefix: string): string => named("columns", prefix);
28
+
29
+ /** A set of columns as the database keeps it (`_delta_collections.columns_def`, generateSql's). */
30
+ const asStored = (columns: Record<string, ColumnDef>): string => `${lit(JSON.stringify(columns))}::jsonb`;
31
+
32
+ /** One document's columns function. */
33
+ function columnsOf(schema: Schema, doc: DocDef): string {
34
+ const declared = "COALESCE(NULLIF(current_setting('delta.declared', true), '')::jsonb -> p_coll, p_columns_def)";
35
+ const named = Object.entries(doc.columns ?? {});
36
+ for (const [coll] of named) need(schema, doc, coll);
37
+ const body = named.length === 0
38
+ ? ` RETURN ${declared};`
39
+ : ` v_cols := ${declared};
40
+ CASE p_coll
41
+ ${named.map(([coll, cols]) => ` WHEN ${lit(coll)} THEN RETURN ${cols.length
42
+ ? `COALESCE((SELECT jsonb_object_agg(k, v) FROM jsonb_each(v_cols) AS e(k, v) WHERE k IN (${cols.map(lit).join(", ")})), '{}'::jsonb)`
43
+ : "'{}'::jsonb"};`).join("\n")}
44
+ ELSE RETURN v_cols;
45
+ END CASE;`;
46
+ return `CREATE OR REPLACE FUNCTION ${columnsFunction(doc.prefix)}(p_coll TEXT, p_columns_def JSONB)
47
+ RETURNS JSONB AS $$
48
+ DECLARE
49
+ v_cols JSONB;
50
+ BEGIN
51
+ ${body}
52
+ END;
53
+ $$ LANGUAGE plpgsql STABLE;`;
54
+ }
55
+
56
+ /** What a column's value must be, as the framework says it; none for a type it does not check. */
57
+ const MUST: Record<string, [string, (v: string) => string]> = {
58
+ text: ["a string", (v) => `jsonb_typeof(${v}) = 'string'`],
59
+ integer: ["an integer", (v) => `jsonb_typeof(${v}) = 'number' AND ${v}::text::numeric = trunc(${v}::text::numeric)`],
60
+ real: ["a number", (v) => `jsonb_typeof(${v}) = 'number'`],
61
+ boolean: ["a boolean", (v) => `jsonb_typeof(${v}) = 'boolean'`],
62
+ timestamptz: ["an ISO-8601 time", (v) =>
63
+ `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)?)?)?$'`],
64
+ };
65
+
66
+ /** The checks of one set of a collection's columns, as written statements. */
67
+ function checksOf(table: ResolvedTable, columns: Record<string, ColumnDef>): string {
68
+ const fk = table.parent?.fkColumn;
69
+ const known = ["id", "valid_from", "valid_to", ...(fk ? [fk] : []), ...Object.keys(columns)];
70
+ const typed = Object.entries(columns).filter(([, d]) => MUST[d.type]);
71
+ const lines = [
72
+ `IF p_value IS NULL OR jsonb_typeof(p_value) <> 'object' THEN
73
+ RAISE EXCEPTION 'a row value for % must be an object', p_collection USING ERRCODE = '22023';
74
+ END IF;`,
75
+ `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(", ")});
76
+ IF v_bad IS NOT NULL THEN
77
+ RAISE EXCEPTION 'Unknown field: % (not a column of %)', v_bad, p_collection USING ERRCODE = '22023';
78
+ END IF;`,
79
+ ];
80
+ if (fk) {
81
+ const v = `p_value->${lit(fk)}`;
82
+ lines.push(`IF p_value ? ${lit(fk)} AND jsonb_typeof(${v}) <> 'null'
83
+ AND NOT (jsonb_typeof(${v}) = 'string' OR (jsonb_typeof(${v}) = 'number' AND (p_value->>${lit(fk)})::numeric = trunc((p_value->>${lit(fk)})::numeric))) THEN
84
+ RAISE EXCEPTION '% must be an id: an integer', ${lit(fk)} USING ERRCODE = '22023';
85
+ END IF;`);
86
+ }
87
+ if (typed.length) {
88
+ const rows = typed.map(([col, d]) => {
89
+ const v = `(p_value->${lit(col)})`; // in parentheses: `::` binds tighter than `->`
90
+ const [must, ok] = MUST[d.type]!;
91
+ return `(${lit(col)}, CASE WHEN jsonb_typeof(${v}) <> 'null' AND NOT (${ok(v)}) THEN ${lit(`${col} must be ${must}`)} END)`;
92
+ });
93
+ lines.push(`SELECT string_agg(m, '; ' ORDER BY k) INTO v_bad FROM (VALUES
94
+ ${rows.join(",\n ")}) AS x(k, m) WHERE m IS NOT NULL;
95
+ IF v_bad IS NOT NULL THEN RAISE EXCEPTION '%', v_bad USING ERRCODE = '22023'; END IF;`);
96
+ }
97
+ return [...lines, "RETURN;"].join("\n ");
98
+ }
99
+
100
+ /** The SQL that gives and checks each of `docs`' columns: a function per document, `_delta_write_columns`, and `_delta_assert_fields`. */
101
+ export function compileColumns(schema: Schema, docs: DocDef[]): string {
102
+ const dispatch = `CREATE OR REPLACE FUNCTION _delta_write_columns(p_def _delta_docs, p_coll TEXT, p_columns_def JSONB)
103
+ RETURNS JSONB AS $$
104
+ BEGIN
105
+ CASE p_def.prefix
106
+ ${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${columnsFunction(d.prefix)}(p_coll, p_columns_def);`).join("\n")}
107
+ ELSE ${noDoc("p_def.prefix")}
108
+ END CASE;
109
+ END;
110
+ $$ LANGUAGE plpgsql STABLE;`;
111
+
112
+ // each collection's sets of columns: all of them, and each document's own
113
+ const branches = Object.entries(schema.tables).map(([coll, table]) => {
114
+ const sets = new Map<string, Record<string, ColumnDef>>([[JSON.stringify(table.columns), table.columns]]);
115
+ for (const doc of docs) {
116
+ const named = doc.columns?.[coll];
117
+ if (!named) continue;
118
+ const own = Object.fromEntries(Object.entries(table.columns).filter(([c]) => named.includes(c)));
119
+ sets.set(JSON.stringify(own), own);
120
+ }
121
+ const fk = table.parent?.fkColumn;
122
+ return ` WHEN ${lit(coll)} THEN
123
+ IF p_parent_fk IS NOT DISTINCT FROM ${fk ? lit(fk) : "NULL"} THEN
124
+ ${[...sets.values()].map((cols) => ` IF p_columns = ${asStored(cols)} THEN
125
+ ${checksOf(table, cols)}
126
+ END IF;`).join("\n")}
127
+ END IF;`;
128
+ });
129
+ const checks = `CREATE OR REPLACE FUNCTION _delta_assert_fields(p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB)
130
+ RETURNS void AS $$
131
+ DECLARE
132
+ v_bad TEXT;
133
+ BEGIN
134
+ CASE p_collection
135
+ ${branches.join("\n")}
136
+ ELSE NULL;
137
+ END CASE;
138
+ -- another set of columns (a walk's, another version's): by the columns given
139
+ PERFORM _delta_assert_fields_declared(p_collection, p_columns, p_parent_fk, p_value);
140
+ END;
141
+ $$ LANGUAGE plpgsql IMMUTABLE;`;
142
+ return [...docs.map((d) => columnsOf(schema, d)), dispatch, checks, ""].join("\n\n");
143
+ }
@@ -0,0 +1,66 @@
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>`). There is no other document: the framework that asked
14
+ * the rest retired (todo #26). SECURITY DEFINER, with the search path it was
15
+ * applied with and pg_temp last, as the framework's was.
16
+ */
17
+ import type { DocDef, Schema } from "@blueshed/delta/backend";
18
+ import { chainRoot, holdsFunction } from "./holds";
19
+ import { lit, need } from "./opens";
20
+
21
+ /** One document's names that hold the row, for a collection it holds. */
22
+ function askOf(schema: Schema, doc: DocDef, coll: string): string {
23
+ const table = need(schema, doc, coll);
24
+ const all = Object.keys(doc.scope ?? {}).length > 0 || (coll !== doc.root && !table.parent);
25
+ const prefix = lit(doc.prefix);
26
+ return ` -- ${doc.prefix}
27
+ ${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}));
28
+ FOR v_cand IN
29
+ SELECT doc_name FROM _delta_held WHERE ${all ? `starts_with(doc_name, ${prefix})` : `doc_name IN (${prefix}, ${prefix} || v_rid::text)`}
30
+ UNION
31
+ SELECT p_writer WHERE p_writer IS NOT NULL AND starts_with(p_writer, ${prefix})
32
+ LOOP
33
+ IF NOT (v_cand = ANY(v_names))
34
+ 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
35
+ AND ${holdsFunction(doc.prefix)}(v_cand, ${lit(coll)}, p_row) THEN
36
+ v_names := v_names || v_cand;
37
+ END IF;
38
+ END LOOP;`;
39
+ }
40
+
41
+ /** The SQL that answers who holds a row: `_delta_holders`, each of `docs` asked by its own lookups. */
42
+ export function compileHolders(schema: Schema, docs: DocDef[]): string {
43
+ const branches = Object.keys(schema.tables).flatMap((coll) => {
44
+ const holding = docs.filter((d) => d.root === coll || d.include.includes(coll));
45
+ return holding.length ? [` WHEN ${lit(coll)} THEN\n${holding.map((d) => askOf(schema, d, coll)).join("\n")}`] : [];
46
+ });
47
+ return `CREATE OR REPLACE FUNCTION _delta_holders(p_coll TEXT, p_row JSONB, p_writer TEXT)
48
+ RETURNS TEXT[] AS $$
49
+ DECLARE
50
+ v_names TEXT[] := ARRAY[]::text[];
51
+ v_rid BIGINT;
52
+ v_cand TEXT;
53
+ BEGIN
54
+ IF current_setting('transaction_isolation') <> 'read committed' THEN
55
+ RAISE EXCEPTION 'a delta write runs at READ COMMITTED, not %: a document opened while it ran would not be told of it',
56
+ upper(current_setting('transaction_isolation')) USING ERRCODE = '0A000';
57
+ END IF;
58
+ IF p_row IS NULL THEN RETURN v_names; END IF;
59
+ ${branches.length ? ` CASE p_coll\n${branches.join("\n")}\n ELSE NULL;\n END CASE;\n` : ""} RETURN v_names;
60
+ END;
61
+ $$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
62
+ DO $$ BEGIN
63
+ EXECUTE format('ALTER FUNCTION _delta_holders(TEXT, JSONB, TEXT) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
64
+ END $$;
65
+ `;
66
+ }
@@ -0,0 +1,90 @@
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 document to its function and refuses a name of none (`no doc def for`: the framework that read the rest
14
+ * retired, todo #26).
15
+ */
16
+ import { q, type DocDef, type ResolvedTable, type Schema } from "@blueshed/delta/backend";
17
+ import { lit, named, need, noDoc, scopeOf, currentOnly } from "./opens";
18
+
19
+ /** A document's holds function: `dzql_holds__<slug>`. */
20
+ export const holdsFunction = (prefix: string): string => named("holds", prefix);
21
+
22
+
23
+ /**
24
+ * The id of the root row an included row hangs from, as `_delta_chain_root`
25
+ * finds it: its parent key, then each parent's, through the tables as they
26
+ * stand; NULL where the chain ends short of the root.
27
+ */
28
+ export function chainRoot(schema: Schema, doc: DocDef, coll: string): string {
29
+ let table = need(schema, doc, coll);
30
+ let id = `(p_row->>${lit(table.parent!.fkColumn)})::BIGINT`;
31
+ for (let depth = 0; depth < 32; depth++) {
32
+ const parentKey = table.parent!.collection;
33
+ if (parentKey === doc.root) return id;
34
+ const parent = need(schema, doc, parentKey);
35
+ if (!parent.parent) return "NULL::BIGINT";
36
+ id = `(SELECT p.${q(parent.parent.fkColumn)} FROM ${q(parent.name)} p WHERE p.id = ${id}${currentOnly(parent, "p")})`;
37
+ table = parent;
38
+ }
39
+ return "NULL::BIGINT";
40
+ }
41
+
42
+ /** One document's holds function. */
43
+ function holdsOf(schema: Schema, doc: DocDef): string {
44
+ const root = need(schema, doc, doc.root);
45
+ // a name its columns cannot take (one opened before a rule refused it) holds nothing
46
+ const scope = scopeOf(schema, doc, () => "RETURN FALSE;");
47
+ const colls = [doc.root, ...doc.include.filter((c) => c !== doc.root)];
48
+ const pastTime = (table: ResolvedTable) => (table.temporal ? `\n IF p_at <= now() THEN RETURN FALSE; END IF;` : "");
49
+ const branches = colls.map((coll) => {
50
+ const table = need(schema, doc, coll);
51
+ if (coll === doc.root) {
52
+ return ` WHEN ${lit(coll)} THEN${pastTime(table)}
53
+ IF v_single AND (p_row->>'id')::BIGINT IS DISTINCT FROM v_id THEN RETURN FALSE; END IF;
54
+ RETURN EXISTS (SELECT 1 FROM jsonb_populate_record(null::${q(root.name)}, p_row) t WHERE ${scope.conds || "TRUE"});`;
55
+ }
56
+ if (!table.parent) return ` WHEN ${lit(coll)} THEN${pastTime(table)}\n RETURN TRUE;`;
57
+ return ` WHEN ${lit(coll)} THEN${pastTime(table)}
58
+ IF NOT v_single THEN RETURN TRUE; END IF;
59
+ RETURN ${chainRoot(schema, doc, coll)} IS NOT DISTINCT FROM v_id;`;
60
+ });
61
+ return `CREATE OR REPLACE FUNCTION ${holdsFunction(doc.prefix)}(p_name TEXT, p_coll TEXT, p_row JSONB)
62
+ RETURNS BOOLEAN AS $$
63
+ DECLARE
64
+ p_at TIMESTAMPTZ;
65
+ v_single BOOLEAN;
66
+ v_id BIGINT;
67
+ ${scope.declare.join("\n ")}
68
+ BEGIN
69
+ IF p_row IS NULL OR p_coll NOT IN (${colls.map(lit).join(", ")}) THEN RETURN FALSE; END IF;
70
+ ${scope.body}
71
+ CASE p_coll
72
+ ${branches.join("\n")}
73
+ END CASE;
74
+ END;
75
+ $$ LANGUAGE plpgsql STABLE;`;
76
+ }
77
+
78
+ /** 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. */
79
+ export function compileHolds(schema: Schema, docs: DocDef[]): string {
80
+ const dispatch = `CREATE OR REPLACE FUNCTION _delta_doc_holds(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
81
+ RETURNS BOOLEAN AS $$
82
+ BEGIN
83
+ CASE p_def.prefix
84
+ ${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${holdsFunction(d.prefix)}(p_doc_name, p_coll, p_row);`).join("\n")}
85
+ ELSE ${noDoc("p_doc_name")}
86
+ END CASE;
87
+ END;
88
+ $$ LANGUAGE plpgsql STABLE;`;
89
+ return [...docs.map((d) => holdsOf(schema, d)), dispatch, ""].join("\n\n");
90
+ }
@@ -0,0 +1,72 @@
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 refusing a name of none;
12
+ * - `_delta_same(collection, field, a, b)`: two values of a field the same, a
13
+ * time by the instant it names. The field's type is the writing process's
14
+ * (`delta.declared`), else the schema's, written in for each collection;
15
+ * a collection there is not, the same as JSON;
16
+ * - `_delta_row_standing(collection, id)`: a row as it stands, as `{row}`, for
17
+ * every collection of the schema; NULL for one there is not.
18
+ */
19
+ import type { DocDef, Schema } from "@blueshed/delta/backend";
20
+ import { lit, noDoc, openFunction, openWholeFunction, readOf, standingRows } from "./opens";
21
+
22
+ /** The SQL the ledger reads by, for `schema` and each of `docs`. */
23
+ export function compileLedger(schema: Schema, docs: DocDef[]): string {
24
+ const whole = docs.map((d) => readOf(schema, d, false, true));
25
+
26
+ const open = `CREATE OR REPLACE FUNCTION _delta_open(p_doc_name TEXT, p_whole BOOLEAN)
27
+ RETURNS JSONB AS $$
28
+ DECLARE
29
+ v_def _delta_docs := _delta_find_doc(p_doc_name);
30
+ BEGIN
31
+ CASE v_def.prefix
32
+ ${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")}
33
+ ELSE ${noDoc("p_doc_name")}
34
+ END CASE;
35
+ END;
36
+ $$ LANGUAGE plpgsql;`;
37
+
38
+ const colls = Object.entries(schema.tables);
39
+ const same = `CREATE OR REPLACE FUNCTION _delta_same(p_coll TEXT, p_field TEXT, a JSONB, b JSONB)
40
+ RETURNS BOOLEAN AS $$
41
+ DECLARE
42
+ v_declared JSONB;
43
+ v_type TEXT;
44
+ BEGIN
45
+ IF jsonb_typeof(a) = 'string' AND jsonb_typeof(b) = 'string' THEN
46
+ CASE p_coll
47
+ ${colls.map(([coll, t]) => {
48
+ const times = Object.entries(t.columns).filter(([, d]) => d.type === "timestamptz").map(([c]) => c);
49
+ return ` WHEN ${lit(coll)} THEN
50
+ v_declared := NULLIF(current_setting('delta.declared', true), '')::jsonb -> ${lit(coll)};
51
+ 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;`;
52
+ }).join("\n")}
53
+ ELSE NULL; -- a collection there is not: its values the same as JSON
54
+ END CASE;
55
+ IF v_type = 'timestamptz' THEN RETURN (a #>> '{}')::timestamptz = (b #>> '{}')::timestamptz; END IF;
56
+ END IF;
57
+ RETURN COALESCE(a, 'null'::jsonb) = COALESCE(b, 'null'::jsonb);
58
+ END;
59
+ $$ LANGUAGE plpgsql STABLE;`;
60
+
61
+ const standing = `CREATE OR REPLACE FUNCTION _delta_row_standing(p_coll TEXT, p_id BIGINT)
62
+ RETURNS JSONB AS $$
63
+ BEGIN
64
+ CASE p_coll
65
+ ${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")}
66
+ ELSE RETURN NULL; -- a collection there is not
67
+ END CASE;
68
+ END;
69
+ $$ LANGUAGE plpgsql STABLE;`;
70
+
71
+ return [...whole, open, same, standing, ""].join("\n\n");
72
+ }