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