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 +29 -13
- package/package.json +2 -2
- package/src/compile/cascades.ts +151 -0
- package/src/compile/columns.ts +143 -0
- package/src/compile/holders.ts +66 -0
- package/src/compile/holds.ts +90 -0
- package/src/compile/ledger.ts +72 -0
- package/src/compile/lookups.ts +118 -0
- package/src/compile/opens.ts +69 -23
- package/src/compile/resolve.ts +75 -0
- package/src/compile/root.ts +61 -0
- package/src/compile/rows.ts +123 -0
- package/src/compile/scope.ts +88 -0
- package/src/compile/told.ts +95 -0
- package/src/postgres/auth-jwt-sql.ts +1 -1
- package/src/postgres/auth-jwt.ts +2 -2
- package/src/postgres/bootstrap.ts +2 -2
- package/src/postgres/codegen.ts +13 -2
- package/src/postgres/index.ts +2 -2
- package/src/postgres/listener.ts +50 -35
- package/src/postgres/pglite.ts +5 -2
- package/src/sql/001a-delta-meta.sql +55 -15
- package/src/sql/001b-delta-scope.sql +19 -451
- package/src/sql/001c-delta-read.sql +18 -296
- package/src/sql/001d-delta-write.sql +104 -330
- package/src/sql/001e-delta-ops.sql +4 -62
- package/src/sql/001g-delta-ledger.sql +51 -30
- package/src/sql/auth-jwt.sql +1 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled write scope: may a document write a row? "You may write what you
|
|
3
|
+
* may read": every row-addressed op in a write is asked first (`delta_apply`,
|
|
4
|
+
* `_delta_row_in_scope`), and a row it may not read is answered as one that is
|
|
5
|
+
* not there.
|
|
6
|
+
*
|
|
7
|
+
* `compileInScope(schema, docs)` writes `dzql_in_scope__<slug>(name,
|
|
8
|
+
* collection, id)` for each document, answering what the framework's
|
|
9
|
+
* `_delta_row_in_scope` answers, from its definition: a list document holds
|
|
10
|
+
* its included collections whole and its root rows by the name's conditions;
|
|
11
|
+
* a single document holds a row whose parent chain, walked up through the
|
|
12
|
+
* tables as they stand, reaches its root, which meets the rest of its name's
|
|
13
|
+
* scope. And `_delta_row_in_scope` itself, which sends each document to its
|
|
14
|
+
* own and refuses a name of none (`no doc def for`: the framework that read the rest
|
|
15
|
+
* retired, todo #26).
|
|
16
|
+
*/
|
|
17
|
+
import { q, type DocDef, type Schema } from "@blueshed/delta/backend";
|
|
18
|
+
import { lit, named, need, noDoc, scopeOf, currentOnly } from "./opens";
|
|
19
|
+
|
|
20
|
+
/** A document's write-scope function: `dzql_in_scope__<slug>`. */
|
|
21
|
+
export const inScopeFunction = (prefix: string): string => named("in_scope", prefix);
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
/** One document's write-scope function. */
|
|
25
|
+
function inScopeOf(schema: Schema, doc: DocDef): string {
|
|
26
|
+
const root = need(schema, doc, doc.root);
|
|
27
|
+
const scope = scopeOf(schema, doc); // a value its columns cannot take: refused, as the framework's resolver refuses it
|
|
28
|
+
const conds = scope.conds ? ` AND ${scope.conds}` : "";
|
|
29
|
+
// the root row of a single document: its id, and (where the name scopes more than the id) the rest of its scope
|
|
30
|
+
const others = Object.keys(doc.scope ?? {}).some((k) => k !== "id");
|
|
31
|
+
const rootMeets = (id: string) => others
|
|
32
|
+
? `COALESCE(${id} = v_id, FALSE) AND EXISTS (SELECT 1 FROM ${q(root.name)} t WHERE t.id = v_id${currentOnly(root, "t")}${conds})`
|
|
33
|
+
: `COALESCE(${id} = v_id, FALSE)`;
|
|
34
|
+
const included = new Set(doc.include);
|
|
35
|
+
|
|
36
|
+
const singles = Object.entries(schema.tables).map(([coll, table]) => {
|
|
37
|
+
if (coll === doc.root) return ` WHEN ${lit(coll)} THEN RETURN ${rootMeets("p_id")};`;
|
|
38
|
+
// unparented: held whole where included; else it ends a chain short of the root
|
|
39
|
+
if (!table.parent) return ` WHEN ${lit(coll)} THEN RETURN ${included.has(coll) ? "TRUE" : "FALSE"};`;
|
|
40
|
+
let id = "p_id";
|
|
41
|
+
let t = table;
|
|
42
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
43
|
+
id = `(SELECT x.${q(t.parent!.fkColumn)} FROM ${q(t.name)} x WHERE x.id = ${id}${currentOnly(t, "x")})`;
|
|
44
|
+
const parentKey = t.parent!.collection;
|
|
45
|
+
if (parentKey === doc.root) return ` WHEN ${lit(coll)} THEN RETURN ${rootMeets(id)};`;
|
|
46
|
+
const parent = schema.tables[parentKey];
|
|
47
|
+
if (!parent?.parent) return ` WHEN ${lit(coll)} THEN RETURN FALSE;`; // the chain ends short of the root
|
|
48
|
+
t = parent;
|
|
49
|
+
}
|
|
50
|
+
return ` WHEN ${lit(coll)} THEN RETURN FALSE;`;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return `CREATE OR REPLACE FUNCTION ${inScopeFunction(doc.prefix)}(p_name TEXT, p_coll TEXT, p_id BIGINT)
|
|
54
|
+
RETURNS BOOLEAN AS $$
|
|
55
|
+
DECLARE
|
|
56
|
+
p_at TIMESTAMPTZ;
|
|
57
|
+
v_single BOOLEAN;
|
|
58
|
+
v_id BIGINT;
|
|
59
|
+
${scope.declare.join("\n ")}
|
|
60
|
+
BEGIN
|
|
61
|
+
IF p_id IS NULL THEN RETURN FALSE; END IF;
|
|
62
|
+
${scope.body}
|
|
63
|
+
IF NOT v_single THEN
|
|
64
|
+
-- a list holds its included collections whole; its root rows by the name's conditions
|
|
65
|
+
IF p_coll IS DISTINCT FROM ${lit(doc.root)} THEN RETURN TRUE; END IF;
|
|
66
|
+
RETURN EXISTS (SELECT 1 FROM ${q(root.name)} t WHERE t.id = p_id${currentOnly(root, "t")}${conds});
|
|
67
|
+
END IF;
|
|
68
|
+
CASE p_coll
|
|
69
|
+
${singles.join("\n")}
|
|
70
|
+
ELSE RETURN FALSE;
|
|
71
|
+
END CASE;
|
|
72
|
+
END;
|
|
73
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The SQL that answers a write's scope for each of `docs`: a function per document, and `_delta_row_in_scope` sending each to its own. */
|
|
77
|
+
export function compileInScope(schema: Schema, docs: DocDef[]): string {
|
|
78
|
+
const dispatch = `CREATE OR REPLACE FUNCTION _delta_row_in_scope(p_def _delta_docs, p_doc_name TEXT, p_collection_key TEXT, p_id BIGINT)
|
|
79
|
+
RETURNS BOOLEAN AS $$
|
|
80
|
+
BEGIN
|
|
81
|
+
CASE p_def.prefix
|
|
82
|
+
${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${inScopeFunction(d.prefix)}(p_doc_name, p_collection_key, p_id);`).join("\n")}
|
|
83
|
+
ELSE ${noDoc("p_doc_name")}
|
|
84
|
+
END CASE;
|
|
85
|
+
END;
|
|
86
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
87
|
+
return [...docs.map((d) => inScopeOf(schema, d)), dispatch, ""].join("\n\n");
|
|
88
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled telling: what a document is told of a write. Asked by `_delta_tell`
|
|
3
|
+
* of the writer and of every document that held a row the write touched, or
|
|
4
|
+
* holds it now, with the write's touches and their holders.
|
|
5
|
+
*
|
|
6
|
+
* `compileTold(schema, docs)` writes `dzql_told__<slug>(name, rows)` for each
|
|
7
|
+
* document, answering what the framework's `_delta_told` answers, from its
|
|
8
|
+
* definition: whether the name makes it one row (`scopeOf`), its root, the
|
|
9
|
+
* columns it shows of each collection it names them for (`columns`, the row's
|
|
10
|
+
* id and parent key with them), and the empty root an implied one opens with
|
|
11
|
+
* when its root leaves. And `_delta_told` itself, which sends each document to
|
|
12
|
+
* its own and refuses a name of none (`no doc def for`: the framework that read the rest
|
|
13
|
+
* retired, todo #26).
|
|
14
|
+
*/
|
|
15
|
+
import type { DocDef, Schema } from "@blueshed/delta/backend";
|
|
16
|
+
import { lit, named, need, noDoc, scopeOf } from "./opens";
|
|
17
|
+
|
|
18
|
+
/** A document's telling function: `dzql_told__<slug>`. */
|
|
19
|
+
export const toldFunction = (prefix: string): string => named("told", prefix);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A row (SQL `row`) of collection (SQL `coll`) as the document shows it, as
|
|
23
|
+
* `_delta_show` does: where it names the collection's columns, those, the id
|
|
24
|
+
* and the parent key; else whole.
|
|
25
|
+
*/
|
|
26
|
+
function shown(schema: Schema, doc: DocDef, coll: string, row: string): string {
|
|
27
|
+
const named = Object.entries(doc.columns ?? {});
|
|
28
|
+
if (named.length === 0) return row;
|
|
29
|
+
const branches = named.map(([c, cols]) => {
|
|
30
|
+
const fk = schema.tables[c]?.parent?.fkColumn;
|
|
31
|
+
const keep = [...new Set(["id", ...(fk ? [fk] : []), ...cols])].map(lit).join(", ");
|
|
32
|
+
return `WHEN ${lit(c)} THEN COALESCE((SELECT jsonb_object_agg(f, v) FROM jsonb_each(${row}) AS e(f, v) WHERE f IN (${keep})), '{}'::jsonb)`;
|
|
33
|
+
});
|
|
34
|
+
return `(CASE WHEN jsonb_typeof(${row}) IS DISTINCT FROM 'object' THEN ${row} ELSE CASE ${coll} ${branches.join(" ")} ELSE ${row} END END)`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One document's telling function. */
|
|
38
|
+
function toldOf(schema: Schema, doc: DocDef): string {
|
|
39
|
+
need(schema, doc, doc.root);
|
|
40
|
+
const scope = scopeOf(schema, doc); // a name its columns cannot take: refused, as the framework's resolver refuses it
|
|
41
|
+
const after = shown(schema, doc, "v_t->>'coll'", "v_t->'after'");
|
|
42
|
+
const gone = doc.implied
|
|
43
|
+
? shown(schema, doc, "v_t->>'coll'", `_delta_implied_root((SELECT d FROM _delta_docs d WHERE d.prefix = ${lit(doc.prefix)}), (v_t->>'id')::BIGINT)`)
|
|
44
|
+
: "'null'::jsonb";
|
|
45
|
+
return `CREATE OR REPLACE FUNCTION ${toldFunction(doc.prefix)}(p_name TEXT, p_rows JSONB)
|
|
46
|
+
RETURNS JSONB AS $$
|
|
47
|
+
DECLARE
|
|
48
|
+
p_at TIMESTAMPTZ;
|
|
49
|
+
v_single BOOLEAN;
|
|
50
|
+
v_id BIGINT;
|
|
51
|
+
v_t JSONB;
|
|
52
|
+
v_told JSONB := '[]'::jsonb;
|
|
53
|
+
v_was BOOLEAN;
|
|
54
|
+
v_is BOOLEAN;
|
|
55
|
+
v_root BOOLEAN;
|
|
56
|
+
v_path TEXT;
|
|
57
|
+
${scope.declare.join("\n ")}
|
|
58
|
+
BEGIN
|
|
59
|
+
${scope.body}
|
|
60
|
+
FOR v_t IN SELECT jsonb_array_elements(p_rows) LOOP
|
|
61
|
+
v_was := (v_t->'before') ? p_name;
|
|
62
|
+
v_is := (v_t->'holders') ? p_name;
|
|
63
|
+
CONTINUE WHEN NOT v_was AND NOT v_is;
|
|
64
|
+
CONTINUE WHEN v_was AND v_is AND (v_t ? 'carried'); -- carried along, and its either way: unchanged for it
|
|
65
|
+
v_root := v_single AND (v_t->>'coll') = ${lit(doc.root)};
|
|
66
|
+
v_path := CASE WHEN v_root THEN _delta_build_path(v_t->>'coll') ELSE _delta_build_path(v_t->>'coll', v_t->>'id') END;
|
|
67
|
+
IF v_was AND v_is THEN -- stayed: what changed
|
|
68
|
+
v_told := v_told || COALESCE((SELECT jsonb_agg(jsonb_build_object('op', 'replace', 'path', v_path || _delta_build_path(f), 'value', v) ORDER BY f COLLATE "C")
|
|
69
|
+
FROM jsonb_each(${after}) AS e(f, v) WHERE v IS DISTINCT FROM v_t->'was'->f), '[]'::jsonb);
|
|
70
|
+
ELSIF v_is THEN
|
|
71
|
+
v_told := v_told || jsonb_build_array(jsonb_build_object('op', CASE WHEN v_root THEN 'replace' ELSE 'add' END, 'path', v_path, 'value', ${after}));
|
|
72
|
+
ELSIF v_root THEN -- gone: ${doc.implied ? "the empty root it then opens with" : "null"}
|
|
73
|
+
v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'replace', 'path', v_path, 'value', ${gone}));
|
|
74
|
+
ELSE
|
|
75
|
+
v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'remove', 'path', v_path));
|
|
76
|
+
END IF;
|
|
77
|
+
END LOOP;
|
|
78
|
+
RETURN v_told;
|
|
79
|
+
END;
|
|
80
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The SQL that answers what each of `docs` is told of a write: a function per document, and `_delta_told` sending each to its own. */
|
|
84
|
+
export function compileTold(schema: Schema, docs: DocDef[]): string {
|
|
85
|
+
const dispatch = `CREATE OR REPLACE FUNCTION _delta_told(p_def _delta_docs, p_target TEXT, p_rows JSONB)
|
|
86
|
+
RETURNS JSONB AS $$
|
|
87
|
+
BEGIN
|
|
88
|
+
CASE p_def.prefix
|
|
89
|
+
${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${toldFunction(d.prefix)}(p_target, p_rows);`).join("\n")}
|
|
90
|
+
ELSE ${noDoc("p_target")}
|
|
91
|
+
END CASE;
|
|
92
|
+
END;
|
|
93
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
94
|
+
return [...docs.map((d) => toldOf(schema, d)), dispatch, ""].join("\n\n");
|
|
95
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These are split out of `auth-jwt.ts` so the `delta init` CLI (which only
|
|
5
5
|
* needs the file path) can run in projects that haven't installed jose.
|
|
6
|
-
* Consumers of the
|
|
6
|
+
* Consumers of the `dzql/auth-jwt` subpath still get these via
|
|
7
7
|
* re-export from `auth-jwt.ts`.
|
|
8
8
|
*/
|
|
9
9
|
import type { Pool } from "pg";
|
package/src/postgres/auth-jwt.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Auth-JWT — reference `DeltaAuth` implementation using JWTs (jose) and
|
|
3
3
|
* Postgres stored functions for credential verification.
|
|
4
4
|
*
|
|
5
|
-
* Ship this as an opt-in subpath:
|
|
5
|
+
* Ship this as an opt-in subpath: `dzql/auth-jwt`. Users who want
|
|
6
6
|
* a different auth scheme (sessions, OAuth, magic links, none) implement
|
|
7
7
|
* their own `DeltaAuth` instead.
|
|
8
8
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Both are overridable via the `loginSql` / `registerSql` options.
|
|
15
15
|
*
|
|
16
|
-
* import { jwtAuth } from "
|
|
16
|
+
* import { jwtAuth } from "dzql/auth-jwt";
|
|
17
17
|
*
|
|
18
18
|
* const auth = jwtAuth({
|
|
19
19
|
* pool,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* consumer's `init_db/` directory.
|
|
5
5
|
*
|
|
6
6
|
* import { Pool } from "pg";
|
|
7
|
-
* import { applyFramework } from "
|
|
7
|
+
* import { applyFramework } from "dzql/postgres";
|
|
8
8
|
*
|
|
9
9
|
* const pool = new Pool({ connectionString: process.env.PG_URL });
|
|
10
10
|
* await applyFramework(pool);
|
|
@@ -23,7 +23,7 @@ const FRAMEWORK_SQL_DIR = join(import.meta.dir, "..", "sql");
|
|
|
23
23
|
|
|
24
24
|
/** Absolute paths to the framework SQL files, in apply-order.
|
|
25
25
|
* Filters to `001*-*.sql` — siblings like `auth-jwt.sql` are applied
|
|
26
|
-
* separately (see
|
|
26
|
+
* separately (see `dzql/auth-jwt` → `applyAuthJwtSchema`). */
|
|
27
27
|
export function frameworkSqlFiles(): string[] {
|
|
28
28
|
return readdirSync(FRAMEWORK_SQL_DIR)
|
|
29
29
|
.filter((f) => /^001[a-z]?-.+\.sql$/.test(f))
|
package/src/postgres/codegen.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Drive it from your project's `types.ts`:
|
|
16
16
|
*
|
|
17
|
-
* import { generateSql } from "
|
|
17
|
+
* import { generateSql } from "dzql/postgres";
|
|
18
18
|
* import { schema, docs } from "./types";
|
|
19
19
|
* await Bun.write("init_db/003-tables.sql", generateSql(schema, docs));
|
|
20
20
|
*
|
|
@@ -27,6 +27,17 @@ 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
29
|
import { compileOpens } from "../compile/opens";
|
|
30
|
+
import { compileHolds } from "../compile/holds";
|
|
31
|
+
import { compileInScope } from "../compile/scope";
|
|
32
|
+
import { compileHolders } from "../compile/holders";
|
|
33
|
+
import { compileResolve } from "../compile/resolve";
|
|
34
|
+
import { compileRootThere } from "../compile/root";
|
|
35
|
+
import { compileColumns } from "../compile/columns";
|
|
36
|
+
import { compileRows } from "../compile/rows";
|
|
37
|
+
import { compileCascades } from "../compile/cascades";
|
|
38
|
+
import { compileLedger } from "../compile/ledger";
|
|
39
|
+
import { compileLookups } from "../compile/lookups";
|
|
40
|
+
import { compileTold } from "../compile/told";
|
|
30
41
|
|
|
31
42
|
export interface GenerateSqlOptions {
|
|
32
43
|
/** Header comment included at the top of the file. */
|
|
@@ -170,6 +181,6 @@ export function generateSql(
|
|
|
170
181
|
lines.push("");
|
|
171
182
|
|
|
172
183
|
// each document opened by SQL compiled for it (dzql), which docTypeFromDef calls
|
|
173
|
-
lines.push("", compileOpens(schema, docs));
|
|
184
|
+
lines.push("", compileOpens(schema, docs), "", compileHolds(schema, docs), "", compileInScope(schema, docs), "", compileTold(schema, docs), "", compileHolders(schema, docs), "", compileResolve(schema, docs), "", compileRootThere(schema, docs), "", compileColumns(schema, docs), "", compileRows(schema), "", compileCascades(schema), "", compileLedger(schema, docs), "", compileLookups(schema, docs));
|
|
174
185
|
return lines.join("\n");
|
|
175
186
|
}
|
package/src/postgres/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* dzql/postgres — delta's Postgres backend, compiled from its definitions.
|
|
3
3
|
*
|
|
4
4
|
* Schema + stored functions + LISTEN dispatch. Tables are generated from
|
|
5
5
|
* a TypeScript schema definition; all mutations go through stored functions
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* import {
|
|
10
10
|
* defineSchema, defineDoc,
|
|
11
11
|
* createDocListener, docTypeFromDef,
|
|
12
|
-
* } from "
|
|
12
|
+
* } from "dzql/postgres";
|
|
13
13
|
*
|
|
14
14
|
* The stored functions are `src/sql/001*.sql`: `applyFramework(pool)` applies
|
|
15
15
|
* them, or `bunx @blueshed/delta init <dir>` copies them for a migration tool
|
package/src/postgres/listener.ts
CHANGED
|
@@ -89,12 +89,15 @@ function errMsg(err: unknown): string {
|
|
|
89
89
|
* entry that is not the cursor's next (409). And 40P01, a write that met another
|
|
90
90
|
* at once and gave way to it (two adds of temporal ids in opposite orders wait
|
|
91
91
|
* on each other's row locks, todo #16): nothing was written, and the same write
|
|
92
|
-
* sent again goes through, or meets the rows the other made (409).
|
|
93
|
-
*
|
|
92
|
+
* sent again goes through, or meets the rows the other made (409). A document
|
|
93
|
+
* type that refuses with a wire code of its own (a number, 400 to 499: a
|
|
94
|
+
* read-only document's 403) is answered with it. Anything else is the
|
|
95
|
+
* server's (500).
|
|
94
96
|
*/
|
|
95
97
|
const CODE_OF_SQLSTATE: Record<string, number> = { "22023": 400, "22P02": 400, "22007": 400, "22008": 400, "23502": 400, "25006": 403, P0002: 404, "23505": 409, "40001": 409, "40P01": 409 };
|
|
96
98
|
function wireCode(err: unknown): number {
|
|
97
99
|
const state = (err as { code?: unknown } | null)?.code;
|
|
100
|
+
if (typeof state === "number" && state >= 400 && state < 500) return state;
|
|
98
101
|
return (typeof state === "string" && CODE_OF_SQLSTATE[state]) || 500;
|
|
99
102
|
}
|
|
100
103
|
|
|
@@ -122,6 +125,15 @@ interface DocState {
|
|
|
122
125
|
/** _delta_fetch_log (delta_fetch_ops' rows) caps each call at this many; a full page means there may be more. */
|
|
123
126
|
const FETCH_PAGE = 1000;
|
|
124
127
|
|
|
128
|
+
/**
|
|
129
|
+
* What a custom document hears of a log entry: each write once, as it was applied. The writer's
|
|
130
|
+
* entry carries it (`applied`) where the writer was told otherwise, and null where it was told the
|
|
131
|
+
* write as applied; each other told document's entry of the write carries none ([]). Told, a row
|
|
132
|
+
* that left a document is a remove, which is not a delete, and a row told to two documents would be
|
|
133
|
+
* heard twice (todo #44). An entry logged some other way carries null too: heard as told.
|
|
134
|
+
*/
|
|
135
|
+
const appliedOf = (row: any): DeltaOp[] => (row.applied ?? row.ops) as DeltaOp[];
|
|
136
|
+
|
|
125
137
|
/**
|
|
126
138
|
* How the listener reads a document's log. `_delta_fetch_log` (001e) gives each
|
|
127
139
|
* entry with the write as applied, for custom documents (todo #44). Framework
|
|
@@ -250,7 +262,7 @@ export async function createDocListener<I = unknown>(
|
|
|
250
262
|
// are recycled by the pool, and a stale listener would fire against this
|
|
251
263
|
// closure's state (and spuriously trigger reconnect) on a future checkout.
|
|
252
264
|
function detachListener(client: any) {
|
|
253
|
-
try { client.removeListener?.("notification",
|
|
265
|
+
try { client.removeListener?.("notification", heard); } catch {}
|
|
254
266
|
if (errorHandler) {
|
|
255
267
|
try { client.removeListener?.("error", errorHandler); } catch {}
|
|
256
268
|
}
|
|
@@ -259,7 +271,7 @@ export async function createDocListener<I = unknown>(
|
|
|
259
271
|
/** LISTEN on `client`, and keep this holder's lock on it. */
|
|
260
272
|
async function setupListener(client: any): Promise<void> {
|
|
261
273
|
await client.query("LISTEN delta_changes");
|
|
262
|
-
client.on("notification",
|
|
274
|
+
client.on("notification", heard);
|
|
263
275
|
errorHandler = (err: Error) => {
|
|
264
276
|
log.error(`listener error: ${err.message}`);
|
|
265
277
|
if (!destroyed) reconnect();
|
|
@@ -306,7 +318,7 @@ export async function createDocListener<I = unknown>(
|
|
|
306
318
|
state.until = Number.MAX_SAFE_INTEGER; // the notifications while away are gone: read to the end
|
|
307
319
|
if (state.notifying) { state.pending = true; continue; }
|
|
308
320
|
state.notifying = true;
|
|
309
|
-
try { await drainDoc(docName, state); }
|
|
321
|
+
try { await drainDoc(docName, state, true); }
|
|
310
322
|
catch (err) { log.error(`resync ${docName}: ${errMsg(err)}`); }
|
|
311
323
|
finally { state.notifying = false; }
|
|
312
324
|
}
|
|
@@ -320,28 +332,38 @@ export async function createDocListener<I = unknown>(
|
|
|
320
332
|
throw err;
|
|
321
333
|
}
|
|
322
334
|
|
|
323
|
-
//
|
|
324
|
-
//
|
|
335
|
+
// A document's own subscribers need only its own order, so each document drains on its own
|
|
336
|
+
// (onNotification), and none waits on another's. A custom document hears writes made through
|
|
337
|
+
// every document, so it is told them one after another, as Postgres delivers the notifications
|
|
338
|
+
// (in commit order): told from each document's drain, a slow read of one told it a later write,
|
|
339
|
+
// made through another, first.
|
|
340
|
+
let inCommitOrder: Promise<void> = Promise.resolve();
|
|
341
|
+
function heard(msg: any) {
|
|
342
|
+
void onNotification(msg);
|
|
343
|
+
if (custom.size > 0) inCommitOrder = inCommitOrder.then(() => hearCustom(msg));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The custom documents told the one write a notification announces: its document's entry at that version. */
|
|
347
|
+
async function hearCustom(msg: any) {
|
|
348
|
+
if (destroyed || msg.channel !== "delta_changes" || !msg.payload) return;
|
|
349
|
+
try {
|
|
350
|
+
const { doc: docName, v } = JSON.parse(msg.payload);
|
|
351
|
+
const rows = (await pool.query(fetchSql, [docName, v - 1])).rows.filter((row: any) => Number(row.version) === v);
|
|
352
|
+
if (auth && rows.length > 0) await custom.letGoWatching(rows.flatMap(appliedOf));
|
|
353
|
+
for (const row of rows) custom.tell(appliedOf(row));
|
|
354
|
+
} catch (err) {
|
|
355
|
+
log.error(`notify error (custom): ${errMsg(err)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Notification handler — fans out LISTEN/NOTIFY to the document's WS subscribers.
|
|
325
360
|
async function onNotification(msg: any) {
|
|
326
361
|
if (destroyed) return;
|
|
327
362
|
if (msg.channel !== "delta_changes" || !msg.payload) return;
|
|
328
363
|
try {
|
|
329
364
|
const { doc: docName, v } = JSON.parse(msg.payload);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// Even without direct subscribers, we still process notifications if any
|
|
333
|
-
// custom doc is registered — a watched collection's rows may need fan-out.
|
|
334
|
-
if (!state && custom.size === 0) return;
|
|
335
|
-
if (!state) {
|
|
336
|
-
// Initialize at v-1 so we fetch only the current notification's row.
|
|
337
|
-
// Custom docs loaded their initial state via `def.query` on open, so
|
|
338
|
-
// pre-existing ops are already reflected — replaying history would
|
|
339
|
-
// duplicate them. Same logic protects close-then-reopen.
|
|
340
|
-
state = { version: Math.max(0, v - 1), until: v, subscribers: new Set(), notifying: false, pending: false };
|
|
341
|
-
tracked.set(docName, state);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
if (v <= state.version) return;
|
|
365
|
+
const state = tracked.get(docName);
|
|
366
|
+
if (!state || v <= state.version) return;
|
|
345
367
|
state.until = Math.max(state.until, v);
|
|
346
368
|
if (state.notifying) {
|
|
347
369
|
// A fetch is already running for this doc. Coalesce instead of dropping:
|
|
@@ -364,8 +386,9 @@ export async function createDocListener<I = unknown>(
|
|
|
364
386
|
// Drain a doc's ops to its subscribers. Loops while a full page comes back
|
|
365
387
|
// (a backlog larger than the fetch's LIMIT) and re-runs if another
|
|
366
388
|
// notification arrived mid-drain (the coalesced `pending` flag). Caller owns
|
|
367
|
-
// the `notifying` guard.
|
|
368
|
-
|
|
389
|
+
// the `notifying` guard. After a reconnect (`resync`) the custom documents
|
|
390
|
+
// are told what it reads too: the notifications while away are gone.
|
|
391
|
+
async function drainDoc(docName: string, state: DocState, resync = false) {
|
|
369
392
|
do {
|
|
370
393
|
state.pending = false;
|
|
371
394
|
let more = true;
|
|
@@ -374,11 +397,10 @@ export async function createDocListener<I = unknown>(
|
|
|
374
397
|
// only what a notification has announced (`until`): the rest is heard with its own
|
|
375
398
|
const rows = page.filter((row: any) => Number(row.version) <= state.until);
|
|
376
399
|
more = page.length >= FETCH_PAGE && rows.length === page.length;
|
|
377
|
-
// Told to those who may still hear it (todo #17): the document's own
|
|
378
|
-
// subscribers, and the custom documents' that watch what it changed.
|
|
400
|
+
// Told to those who may still hear it (todo #17): the document's own subscribers
|
|
379
401
|
if (auth && rows.length > 0) {
|
|
380
402
|
await custom.letGo(docName, state.subscribers, ownsOf(docName));
|
|
381
|
-
await custom.letGoWatching(rows.flatMap(
|
|
403
|
+
if (resync) await custom.letGoWatching(rows.flatMap(appliedOf));
|
|
382
404
|
}
|
|
383
405
|
for (const row of rows) {
|
|
384
406
|
if (state.subscribers.size > 0) {
|
|
@@ -390,14 +412,7 @@ export async function createDocListener<I = unknown>(
|
|
|
390
412
|
ws.publish(docName, { doc: docName, ops: shownOps(resolveDoc(docName)?.type.def, row.ops), v: Number(row.version) });
|
|
391
413
|
}
|
|
392
414
|
state.version = Number(row.version);
|
|
393
|
-
|
|
394
|
-
// writer's entry carries it (`applied`) where the writer was told
|
|
395
|
-
// otherwise, and null where it was told the write as applied; each
|
|
396
|
-
// other told document's entry of the write carries none ([]). Told,
|
|
397
|
-
// a row that left a document is a remove, which is not a delete, and
|
|
398
|
-
// a row told to two documents would be heard twice (todo #44). An
|
|
399
|
-
// entry logged some other way carries null too: heard as told.
|
|
400
|
-
custom.tell((row.applied ?? row.ops) as DeltaOp[]);
|
|
415
|
+
if (resync) custom.tell(appliedOf(row));
|
|
401
416
|
}
|
|
402
417
|
}
|
|
403
418
|
} while (state.pending);
|
package/src/postgres/pglite.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The same stored functions, the same listener, the same documents as a
|
|
5
5
|
* Postgres server, on PGlite (Postgres compiled to WASM): no database to run,
|
|
6
6
|
* the data in a directory (or in memory). `openPglite()` answers the slice of
|
|
7
|
-
* `pg`'s Pool delta uses, so everything in
|
|
7
|
+
* `pg`'s Pool delta uses, so everything in `dzql/postgres` runs on
|
|
8
8
|
* it unchanged:
|
|
9
9
|
*
|
|
10
10
|
* const pool = await openPglite("./data"); // or openPglite() in memory
|
|
@@ -137,6 +137,9 @@ export async function openPglite(dir?: string): Promise<Pool> {
|
|
|
137
137
|
} catch {
|
|
138
138
|
throw new Error("[delta/pglite] @electric-sql/pglite is not installed -- bun add @electric-sql/pglite");
|
|
139
139
|
}
|
|
140
|
-
|
|
140
|
+
// pgcrypto, which dzql/auth-jwt hashes passwords with: PGlite has an extension only when it is
|
|
141
|
+
// given one at start (loaded when a database creates it)
|
|
142
|
+
const { pgcrypto } = await import("@electric-sql/pglite/contrib/pgcrypto");
|
|
143
|
+
const db: PGliteish = await PGlite.create(dir, { extensions: { pgcrypto } });
|
|
141
144
|
return pglitePool(db);
|
|
142
145
|
}
|
|
@@ -30,6 +30,49 @@ ALTER TABLE _delta_docs ADD COLUMN IF NOT EXISTS implied BOOLEAN NOT NULL DEFAUL
|
|
|
30
30
|
-- collection not named, whole. It is opened, told and written by those alone.
|
|
31
31
|
ALTER TABLE _delta_docs ADD COLUMN IF NOT EXISTS held JSONB NOT NULL DEFAULT '{}';
|
|
32
32
|
|
|
33
|
+
-- The framework that read the definitions at run time retired (todo #26): each
|
|
34
|
+
-- document and collection is compiled by generateSql (dzql/compile). Dropped
|
|
35
|
+
-- where an earlier version made them.
|
|
36
|
+
DROP FUNCTION IF EXISTS _delta_collection_framework(text);
|
|
37
|
+
DROP FUNCTION IF EXISTS _delta_assert_fields_framework(text, jsonb, text, jsonb);
|
|
38
|
+
DROP FUNCTION IF EXISTS _delta_temporal_where(timestamptz, text);
|
|
39
|
+
DROP FUNCTION IF EXISTS _delta_find_doc_framework(text);
|
|
40
|
+
DROP FUNCTION IF EXISTS _delta_resolve_scope_framework(_delta_docs, text, boolean);
|
|
41
|
+
DROP FUNCTION IF EXISTS _delta_row_in_scope_framework(_delta_docs, text, text, bigint);
|
|
42
|
+
DROP FUNCTION IF EXISTS _delta_chain_root(text, jsonb, text);
|
|
43
|
+
DROP FUNCTION IF EXISTS _delta_doc_holds_framework(_delta_docs, text, text, jsonb);
|
|
44
|
+
DROP FUNCTION IF EXISTS _delta_holders_framework(text, jsonb, text, text[]);
|
|
45
|
+
DROP FUNCTION IF EXISTS _delta_load_collection(text, text, bigint, timestamptz);
|
|
46
|
+
DROP FUNCTION IF EXISTS _delta_load_collection_all(text, timestamptz);
|
|
47
|
+
DROP FUNCTION IF EXISTS _delta_read_list(_delta_docs, text, timestamptz);
|
|
48
|
+
DROP FUNCTION IF EXISTS _delta_show_doc(_delta_docs, jsonb, boolean);
|
|
49
|
+
DROP FUNCTION IF EXISTS _delta_open_framework(text, boolean);
|
|
50
|
+
DROP FUNCTION IF EXISTS _delta_serial_past_framework(text, bigint);
|
|
51
|
+
DROP FUNCTION IF EXISTS _delta_serial_new_framework(text, bigint);
|
|
52
|
+
DROP FUNCTION IF EXISTS _delta_cascade_remove_framework(text, bigint);
|
|
53
|
+
DROP FUNCTION IF EXISTS _delta_cascade_rows_framework(text, bigint);
|
|
54
|
+
DROP FUNCTION IF EXISTS _delta_hanging_rows_framework(text, bigint);
|
|
55
|
+
DROP FUNCTION IF EXISTS _delta_told_framework(_delta_docs, text, jsonb);
|
|
56
|
+
DROP FUNCTION IF EXISTS _delta_row_there_framework(text, bigint);
|
|
57
|
+
DROP FUNCTION IF EXISTS _delta_held_columns(_delta_docs, text, jsonb);
|
|
58
|
+
DROP FUNCTION IF EXISTS _delta_root_there_framework(_delta_docs, text, jsonb, text, bigint);
|
|
59
|
+
DROP FUNCTION IF EXISTS _delta_write_columns_framework(_delta_docs, text, jsonb);
|
|
60
|
+
DROP FUNCTION IF EXISTS _delta_row_lock_framework(text, text, boolean, bigint);
|
|
61
|
+
DROP FUNCTION IF EXISTS _delta_row_put_framework(text, text, boolean, bigint, jsonb, timestamptz);
|
|
62
|
+
DROP FUNCTION IF EXISTS _delta_next_id_framework(text, text);
|
|
63
|
+
DROP FUNCTION IF EXISTS _delta_row_clear_moment_framework(text, text, bigint, timestamptz);
|
|
64
|
+
DROP FUNCTION IF EXISTS _delta_row_add_framework(text, text, jsonb);
|
|
65
|
+
DROP FUNCTION IF EXISTS _delta_row_read_framework(text, text, bigint);
|
|
66
|
+
DROP FUNCTION IF EXISTS _delta_open_at_framework(text, timestamptz);
|
|
67
|
+
DROP FUNCTION IF EXISTS _delta_same_framework(text, text, jsonb, jsonb);
|
|
68
|
+
DROP FUNCTION IF EXISTS _delta_row_standing_framework(text, bigint);
|
|
69
|
+
|
|
70
|
+
-- _delta_collection: a collection's definition (NULL fields: there is none).
|
|
71
|
+
-- generateSql writes each collection in (compileLookups); before it is applied,
|
|
72
|
+
-- there is none.
|
|
73
|
+
CREATE OR REPLACE FUNCTION _delta_collection(p_key TEXT)
|
|
74
|
+
RETURNS _delta_collections LANGUAGE sql STABLE AS $$ SELECT NULL::_delta_collections; $$;
|
|
75
|
+
|
|
33
76
|
CREATE TABLE IF NOT EXISTS _delta_versions (
|
|
34
77
|
doc_name TEXT PRIMARY KEY,
|
|
35
78
|
version BIGINT NOT NULL DEFAULT 0
|
|
@@ -135,8 +178,10 @@ $$;
|
|
|
135
178
|
-- not have, or gives a column a value not of its type (SQLSTATE 22023 → 400).
|
|
136
179
|
-- It used to be merged into the broadcast row, acked, and dropped (or cast) by
|
|
137
180
|
-- jsonb_populate_record: stored otherwise than told. id, the parent key and
|
|
138
|
-
-- the temporal columns are the row's own.
|
|
139
|
-
|
|
181
|
+
-- the temporal columns are the row's own. By the columns given (`p_columns`): a
|
|
182
|
+
-- set the compiler did not write in (another version's, `delta.declared`; a
|
|
183
|
+
-- walk's, as its entry was stored) is checked here.
|
|
184
|
+
CREATE OR REPLACE FUNCTION _delta_assert_fields_declared(
|
|
140
185
|
p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB
|
|
141
186
|
) RETURNS void LANGUAGE plpgsql IMMUTABLE AS $$
|
|
142
187
|
DECLARE
|
|
@@ -178,9 +223,16 @@ BEGIN
|
|
|
178
223
|
END;
|
|
179
224
|
$$;
|
|
180
225
|
|
|
226
|
+
-- The checks every write asks: generateSql (`compileColumns`) replaces this
|
|
227
|
+
-- with one that checks each column set it compiled by its own statements, and
|
|
228
|
+
-- any other by the columns given.
|
|
229
|
+
CREATE OR REPLACE FUNCTION _delta_assert_fields(
|
|
230
|
+
p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB
|
|
231
|
+
) RETURNS void LANGUAGE sql IMMUTABLE AS $$ SELECT _delta_assert_fields_declared(p_collection, p_columns, p_parent_fk, p_value); $$;
|
|
232
|
+
|
|
181
233
|
-- Pick the source relation for a collection.
|
|
182
234
|
-- temporal + no timestamp → current_<table> view
|
|
183
|
-
-- temporal + timestamp → base <table> (caller filters
|
|
235
|
+
-- temporal + timestamp → base <table> (the caller filters by its time)
|
|
184
236
|
-- non-temporal → base <table>
|
|
185
237
|
CREATE OR REPLACE FUNCTION _delta_source_view(
|
|
186
238
|
p_table_name TEXT, p_temporal BOOLEAN, p_at TIMESTAMPTZ DEFAULT NULL
|
|
@@ -190,15 +242,3 @@ CREATE OR REPLACE FUNCTION _delta_source_view(
|
|
|
190
242
|
ELSE p_table_name
|
|
191
243
|
END;
|
|
192
244
|
$$;
|
|
193
|
-
|
|
194
|
-
-- Temporal WHERE fragment for embedding into format()-built dynamic SQL.
|
|
195
|
-
-- Inlines the timestamp as a %L literal so callers don't need extra param binding.
|
|
196
|
-
-- Aliases the row with `p_alias` (default `t`).
|
|
197
|
-
CREATE OR REPLACE FUNCTION _delta_temporal_where(
|
|
198
|
-
p_at TIMESTAMPTZ, p_alias TEXT DEFAULT 't'
|
|
199
|
-
) RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
200
|
-
SELECT format(
|
|
201
|
-
'%s.valid_from <= %L AND (%s.valid_to IS NULL OR %s.valid_to > %L)',
|
|
202
|
-
p_alias, p_at, p_alias, p_alias, p_at
|
|
203
|
-
);
|
|
204
|
-
$$;
|