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
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* dzql's Postgres commands, as delta's CLI had them: `dzql sql`, `dzql init`
|
|
4
|
+
* and `dzql migrate` (a postgres:// URL or a PGlite directory). Run through
|
|
5
|
+
* `dzql <command>` (src/cli/index.ts hands these three here).
|
|
6
|
+
*/
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { resolve, basename, join, dirname } from "node:path";
|
|
9
|
+
import { mkdirSync, copyFileSync, existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
10
|
+
import { generateSql } from "./codegen";
|
|
11
|
+
import { frameworkSqlFiles } from "./bootstrap";
|
|
12
|
+
import { authJwtSqlFile } from "./auth-jwt-sql";
|
|
13
|
+
import type { Schema, DocDef } from "./index";
|
|
14
|
+
|
|
15
|
+
const PKG_VERSION = (() => {
|
|
16
|
+
try {
|
|
17
|
+
return String(JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8")).version ?? "0.0.0");
|
|
18
|
+
} catch {
|
|
19
|
+
return "0.0.0";
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
const USAGE =
|
|
24
|
+
`Usage:\n` +
|
|
25
|
+
` dzql sql <module> [--out <file>] [--force] [--schema-export <name>] [--docs-export <name>]\n` +
|
|
26
|
+
` dzql init <dir> [--with-auth] [--upgrade]\n` +
|
|
27
|
+
` dzql migrate <database> [--app <name>] [--dir <migrations>] [--status] [--json]\n` +
|
|
28
|
+
` <database>: a PGlite directory or a postgres:// URL; --dir defaults to ./migrations\n` +
|
|
29
|
+
`\n` +
|
|
30
|
+
`Exit codes: 0 done, 1 an error, 2 a bad argument.\n`;
|
|
31
|
+
|
|
32
|
+
let json = process.argv.includes("--json");
|
|
33
|
+
|
|
34
|
+
const print = (result: unknown) => process.stdout.write(JSON.stringify(result ?? null, null, json ? undefined : 2) + "\n");
|
|
35
|
+
|
|
36
|
+
function fail(err: unknown, exit = 1): never {
|
|
37
|
+
const error = { code: 0, message: err instanceof Error ? err.message : String(err) };
|
|
38
|
+
process.stderr.write((json ? JSON.stringify({ error }) : `dzql: ${error.message}`) + "\n");
|
|
39
|
+
process.exit(exit);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function usageError(why: string, show = false): never {
|
|
43
|
+
if (show && !json) process.stderr.write(USAGE + "\n");
|
|
44
|
+
fail(why, 2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Header handling — identifies files written by dzql init (stamped with this
|
|
49
|
+
// package's version) so upgrades can read them back and avoid clobbering
|
|
50
|
+
// user-owned SQL, and a `diff -u` against a later `init --upgrade` shows what changed.
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
// a file `delta init` vendored (stamped @blueshed/delta) is older than any of dzql's: always upgraded
|
|
54
|
+
const HEADER_RE = /^-- @blueshed\/(delta|dzql) ([a-z0-9-]+) v(\d+\.\d+\.\d+)/;
|
|
55
|
+
|
|
56
|
+
function readHeader(path: string): { pkg: string; kind: string; version: string } | null {
|
|
57
|
+
if (!existsSync(path)) return null;
|
|
58
|
+
const first = readFileSync(path, "utf8").split("\n", 1)[0] ?? "";
|
|
59
|
+
const m = first.match(HEADER_RE);
|
|
60
|
+
return m ? { pkg: m[1]!, kind: m[2]!, version: m[3]! } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeWithHeader(src: string, dest: string, kind: "framework" | "auth-jwt"): void {
|
|
64
|
+
const header =
|
|
65
|
+
`-- @blueshed/dzql ${kind} v${PKG_VERSION}\n` +
|
|
66
|
+
`-- Vendored by 'dzql init'. Safe to read; prefer 'dzql init --upgrade' over hand edits.\n\n`;
|
|
67
|
+
writeFileSync(dest, header + readFileSync(src, "utf8"));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Back up `dest` to `dest + ".bak"` without destroying a pre-existing backup.
|
|
72
|
+
* If `dest.bak` already exists, fall back to `.bak.1`, `.bak.2`, … so an
|
|
73
|
+
* earlier backup the user may still need is preserved. Returns the path the
|
|
74
|
+
* backup was written to.
|
|
75
|
+
*/
|
|
76
|
+
function backupFile(dest: string): string {
|
|
77
|
+
let bak = dest + ".bak";
|
|
78
|
+
if (existsSync(bak)) {
|
|
79
|
+
let n = 1;
|
|
80
|
+
while (existsSync(`${dest}.bak.${n}`)) n++;
|
|
81
|
+
bak = `${dest}.bak.${n}`;
|
|
82
|
+
process.stderr.write(
|
|
83
|
+
`Note: ${basename(dest)}.bak already exists; backing up to ${basename(bak)} instead.\n`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
copyFileSync(dest, bak);
|
|
87
|
+
return bak;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// dzql sql
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
// Stable substring of the codegen header (see codegen.ts:
|
|
95
|
+
// "GENERATED FROM types.ts — DO NOT EDIT"). Matching on the leading words
|
|
96
|
+
// avoids depending on the em-dash byte sequence.
|
|
97
|
+
const GENERATED_MARKER = "GENERATED FROM";
|
|
98
|
+
|
|
99
|
+
async function cmdSql(modulePath: string | undefined, values: Record<string, unknown>) {
|
|
100
|
+
if (!modulePath) usageError("sql takes a <module>", true);
|
|
101
|
+
|
|
102
|
+
const abs = resolve(process.cwd(), modulePath);
|
|
103
|
+
let mod: Record<string, unknown>;
|
|
104
|
+
try {
|
|
105
|
+
mod = await import(abs);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Bun surfaces a `ResolveMessage` whose `.message` is a single useful line
|
|
108
|
+
// (e.g. "Cannot find module '…'") but is fronted by the class-name prefix;
|
|
109
|
+
// strip it to a clean one-liner instead of dumping the raw object/stack.
|
|
110
|
+
const raw =
|
|
111
|
+
err instanceof Error ? err.message.split("\n", 1)[0] : String(err);
|
|
112
|
+
const reason = raw.replace(/^(ResolveMessage|BuildMessage|Error):\s*/, "");
|
|
113
|
+
process.stderr.write(`Cannot load module "${modulePath}": ${reason}\n`);
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const schemaExport = values["schema-export"] as string;
|
|
118
|
+
const docsExport = values["docs-export"] as string;
|
|
119
|
+
const schema = mod[schemaExport] as Schema | undefined;
|
|
120
|
+
const docs = mod[docsExport] as DocDef[] | undefined;
|
|
121
|
+
|
|
122
|
+
if (!schema || typeof schema !== "object" || !("tables" in schema)) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`Module at ${modulePath} does not export a Schema as \`${schemaExport}\`.\n` +
|
|
125
|
+
`Export one with: export const ${schemaExport} = defineSchema({ ... });\n`,
|
|
126
|
+
);
|
|
127
|
+
process.exit(2);
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(docs)) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`Module at ${modulePath} does not export a DocDef[] as \`${docsExport}\`.\n` +
|
|
132
|
+
`Export one with: export const ${docsExport} = [defineDoc(...), ...];\n`,
|
|
133
|
+
);
|
|
134
|
+
process.exit(2);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sql = generateSql(schema, docs);
|
|
138
|
+
const out = values.out as string | undefined;
|
|
139
|
+
const force = !!values.force;
|
|
140
|
+
|
|
141
|
+
if (out) {
|
|
142
|
+
const absOut = resolve(out);
|
|
143
|
+
// Guard: never silently clobber a file we didn't generate. A previously
|
|
144
|
+
// generated file carries the codegen header marker and is safe to replace;
|
|
145
|
+
// anything else needs --force.
|
|
146
|
+
if (existsSync(absOut) && !force) {
|
|
147
|
+
const existing = readFileSync(absOut, "utf8");
|
|
148
|
+
if (!existing.includes(GENERATED_MARKER)) {
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
`Refusing to overwrite ${out}: it has no generated-file header ` +
|
|
151
|
+
`(not produced by 'dzql sql').\n` +
|
|
152
|
+
`Pass --force to overwrite it anyway.\n`,
|
|
153
|
+
);
|
|
154
|
+
process.exit(3);
|
|
155
|
+
}
|
|
156
|
+
// It's a previously generated file — back it up before replacing.
|
|
157
|
+
backupFile(absOut);
|
|
158
|
+
}
|
|
159
|
+
mkdirSync(dirname(absOut), { recursive: true });
|
|
160
|
+
await Bun.write(out, sql);
|
|
161
|
+
process.stderr.write(`Wrote ${out} (${sql.length} bytes)\n`);
|
|
162
|
+
} else {
|
|
163
|
+
process.stdout.write(sql);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// dzql init
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
function cmdInit(dir: string | undefined, values: Record<string, unknown>) {
|
|
172
|
+
if (!dir) usageError("init takes a <dir>", true);
|
|
173
|
+
|
|
174
|
+
const absDir = resolve(process.cwd(), dir);
|
|
175
|
+
mkdirSync(absDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
const upgrade = !!values.upgrade;
|
|
178
|
+
const plan: { src: string; dest: string; kind: "framework" | "auth-jwt" }[] =
|
|
179
|
+
frameworkSqlFiles().map((src) => ({ src, dest: join(absDir, basename(src)), kind: "framework" }));
|
|
180
|
+
if (values["with-auth"]) plan.push({ src: authJwtSqlFile(), dest: join(absDir, "002-users.sql"), kind: "auth-jwt" });
|
|
181
|
+
|
|
182
|
+
// On upgrade we refuse to clobber files that don't have our header or that
|
|
183
|
+
// point to a newer version than we ship. The user either wrote that file
|
|
184
|
+
// themselves or has downgraded the package.
|
|
185
|
+
if (upgrade) {
|
|
186
|
+
const conflicts: string[] = [];
|
|
187
|
+
for (const { dest } of plan) {
|
|
188
|
+
if (!existsSync(dest)) continue;
|
|
189
|
+
const hdr = readHeader(dest);
|
|
190
|
+
if (!hdr) conflicts.push(`${basename(dest)}: not stamped by dzql init (or delta init)`);
|
|
191
|
+
else if (hdr.pkg === "dzql" && compareVersion(hdr.version, PKG_VERSION) > 0) {
|
|
192
|
+
conflicts.push(`${basename(dest)}: has v${hdr.version}, package is v${PKG_VERSION}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (conflicts.length) {
|
|
196
|
+
process.stderr.write(
|
|
197
|
+
`Refusing to upgrade:\n ${conflicts.join("\n ")}\n\n` +
|
|
198
|
+
`Resolve these manually (move or rename the offending files) and rerun.\n`,
|
|
199
|
+
);
|
|
200
|
+
process.exit(3);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const created: string[] = [];
|
|
205
|
+
const upgraded: string[] = [];
|
|
206
|
+
const unchanged: string[] = [];
|
|
207
|
+
|
|
208
|
+
for (const { src, dest, kind } of plan) {
|
|
209
|
+
if (existsSync(dest)) {
|
|
210
|
+
const hdr = readHeader(dest);
|
|
211
|
+
if (!upgrade || (hdr?.pkg === "dzql" && hdr.version === PKG_VERSION)) {
|
|
212
|
+
unchanged.push(basename(dest));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const bak = backupFile(dest);
|
|
216
|
+
writeWithHeader(src, dest, kind);
|
|
217
|
+
upgraded.push(`${basename(dest)} (backup at ${basename(bak)})`);
|
|
218
|
+
} else {
|
|
219
|
+
writeWithHeader(src, dest, kind);
|
|
220
|
+
created.push(basename(dest));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const lines: string[] = [];
|
|
225
|
+
if (created.length) lines.push("Created:\n " + created.join("\n "));
|
|
226
|
+
if (upgraded.length) lines.push("Upgraded:\n " + upgraded.join("\n "));
|
|
227
|
+
if (unchanged.length) lines.push("Unchanged:\n " + unchanged.join("\n "));
|
|
228
|
+
process.stderr.write((lines.join("\n\n") || "Nothing to do.") + "\n");
|
|
229
|
+
|
|
230
|
+
if (created.length || upgraded.length) {
|
|
231
|
+
process.stderr.write(
|
|
232
|
+
`\nNext: regenerate your table SQL from types.ts:\n` +
|
|
233
|
+
` bunx dzql sql ./types.ts --out ${dir}/003-tables.sql\n`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function compareVersion(a: string, b: string): number {
|
|
239
|
+
const pa = a.split(".").map(Number);
|
|
240
|
+
const pb = b.split(".").map(Number);
|
|
241
|
+
for (let i = 0; i < 3; i++) {
|
|
242
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
243
|
+
if (d !== 0) return d;
|
|
244
|
+
}
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// dzql migrate
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Run an app's migrations (`--dir`, default ./migrations, each `NNN-name.ts`
|
|
254
|
+
* exporting `up`; `--app` names the app when apps share the database) against
|
|
255
|
+
* its database, or with --status say which ran and which are pending. With no
|
|
256
|
+
* schema to ask, an app with nothing recorded is new only to a database with
|
|
257
|
+
* no table but delta's: the app's own start-up, given its schema, knows better.
|
|
258
|
+
* The database: a postgres:// URL or a PGlite directory that is there (a name
|
|
259
|
+
* mistyped makes no new database). A SQLite file is delta's own command.
|
|
260
|
+
*/
|
|
261
|
+
async function cmdMigrate(target: string | undefined, values: Record<string, unknown>) {
|
|
262
|
+
if (!target) usageError("migrate takes a <database>: a PGlite directory or a postgres:// URL", true);
|
|
263
|
+
const { setLogLevel } = await import("@blueshed/delta/logger");
|
|
264
|
+
setLogLevel(json ? "silent" : "warn");
|
|
265
|
+
const dir = resolve(process.cwd(), (values.dir as string | undefined) ?? "migrations");
|
|
266
|
+
const status = !!values.status;
|
|
267
|
+
const options = { app: (values.app as string | undefined) ?? "" };
|
|
268
|
+
const isUrl = /^postgres(ql)?:\/\//.test(target);
|
|
269
|
+
const path = resolve(process.cwd(), target);
|
|
270
|
+
if (!isUrl && (!existsSync(path) || !statSync(path).isDirectory())) usageError(`no PGlite database at ${target} (a SQLite file is delta's: bunx @blueshed/delta migrate)`);
|
|
271
|
+
if (!existsSync(dir)) usageError(`no migrations folder at ${dir} (--dir)`);
|
|
272
|
+
|
|
273
|
+
let answer: unknown;
|
|
274
|
+
const pg = await import("./migrate");
|
|
275
|
+
let pool: any;
|
|
276
|
+
if (isUrl) {
|
|
277
|
+
const { Pool } = await import("pg");
|
|
278
|
+
pool = new Pool({ connectionString: target });
|
|
279
|
+
} else {
|
|
280
|
+
const { openPglite } = await import("./pglite");
|
|
281
|
+
pool = await openPglite(path);
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const list = await pg.fromFolder(dir);
|
|
285
|
+
answer = status ? await pg.migrationStatus(pool, list, options) : await pg.migrate(pool, list, options);
|
|
286
|
+
} finally { await pool.end(); }
|
|
287
|
+
if (json) return print(answer);
|
|
288
|
+
if (status) {
|
|
289
|
+
const s = answer as import("@blueshed/delta/backend").MigrationStatus;
|
|
290
|
+
if (s.fresh) process.stdout.write("new database: migrate records every migration as run, and runs none\n");
|
|
291
|
+
for (const r of s.ran) process.stdout.write(`ran ${r.id} ${r.ran_at}\n`);
|
|
292
|
+
for (const id of s.pending) process.stdout.write(`pending ${id}\n`);
|
|
293
|
+
for (const id of s.unknown) process.stdout.write(`unknown ${id} (ran here, not in ${dir})\n`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const r = answer as import("@blueshed/delta/backend").MigrateResult;
|
|
297
|
+
if (r.fresh) process.stdout.write("new database: every migration recorded as run, none run\n");
|
|
298
|
+
for (const id of r.ran) process.stdout.write(`ran ${id}\n`);
|
|
299
|
+
if (!r.fresh && r.ran.length === 0) process.stdout.write("nothing to run\n");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// main
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
export async function main(argv: string[]) {
|
|
307
|
+
let parsed;
|
|
308
|
+
try { parsed = parseArgs({
|
|
309
|
+
args: argv,
|
|
310
|
+
options: {
|
|
311
|
+
out: { type: "string", short: "o" },
|
|
312
|
+
force: { type: "boolean", default: false },
|
|
313
|
+
"schema-export": { type: "string", default: "schema" },
|
|
314
|
+
"docs-export": { type: "string", default: "docs" },
|
|
315
|
+
"with-auth": { type: "boolean", default: false },
|
|
316
|
+
upgrade: { type: "boolean", default: false },
|
|
317
|
+
json: { type: "boolean", default: false },
|
|
318
|
+
dir: { type: "string" },
|
|
319
|
+
app: { type: "string" },
|
|
320
|
+
status: { type: "boolean", default: false },
|
|
321
|
+
help: { type: "boolean", short: "h", default: false },
|
|
322
|
+
},
|
|
323
|
+
allowPositionals: true,
|
|
324
|
+
strict: true,
|
|
325
|
+
}); }
|
|
326
|
+
catch (err) { usageError((err as Error).message, true); }
|
|
327
|
+
const { values, positionals } = parsed;
|
|
328
|
+
json = !!values.json;
|
|
329
|
+
if (values.help) { process.stdout.write(USAGE); return; }
|
|
330
|
+
const [cmd, arg] = positionals;
|
|
331
|
+
switch (cmd) {
|
|
332
|
+
case "sql": return cmdSql(arg, values);
|
|
333
|
+
case "init": return cmdInit(arg, values);
|
|
334
|
+
case "migrate": return cmdMigrate(arg, values);
|
|
335
|
+
default: usageError(cmd ? `no command ${cmd}` : "a command, please", true);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (import.meta.main) main(process.argv.slice(2)).catch((err) => fail(err));
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codegen — turn a TypeScript schema (`defineSchema`) and a set of doc
|
|
3
|
+
* definitions (`defineDoc`) into the SQL that registers both with the
|
|
4
|
+
* delta-doc framework.
|
|
5
|
+
*
|
|
6
|
+
* Output includes:
|
|
7
|
+
* - CREATE SEQUENCE / CREATE TABLE (+ current_* view for temporal tables)
|
|
8
|
+
* - Helper indexes
|
|
9
|
+
* - INSERT INTO _delta_collections (columns_def, parent, temporal, cascade_on)
|
|
10
|
+
* - INSERT INTO _delta_docs (prefix, root_collection, include, scope)
|
|
11
|
+
*
|
|
12
|
+
* Everything is idempotent (CREATE … IF NOT EXISTS, ON CONFLICT DO UPDATE),
|
|
13
|
+
* so the same file can be re-applied to a live database after a schema change.
|
|
14
|
+
*
|
|
15
|
+
* Drive it from your project's `types.ts`:
|
|
16
|
+
*
|
|
17
|
+
* import { generateSql } from "@blueshed/delta/postgres";
|
|
18
|
+
* import { schema, docs } from "./types";
|
|
19
|
+
* await Bun.write("init_db/003-tables.sql", generateSql(schema, docs));
|
|
20
|
+
*
|
|
21
|
+
* Or from the bundled CLI:
|
|
22
|
+
*
|
|
23
|
+
* bunx delta sql ./types.ts --out init_db/003-tables.sql
|
|
24
|
+
*/
|
|
25
|
+
import type { Schema, DocDef } from "./schema";
|
|
26
|
+
import { lit, columnSqlType, sqlDefault, findCascadeOn } from "./sql";
|
|
27
|
+
import { defaultForType, q } from "@blueshed/delta/backend";
|
|
28
|
+
import { assertScopeKeys } from "@blueshed/delta/backend";
|
|
29
|
+
import { compileOpens } from "../compile/opens";
|
|
30
|
+
|
|
31
|
+
export interface GenerateSqlOptions {
|
|
32
|
+
/** Header comment included at the top of the file. */
|
|
33
|
+
header?: string;
|
|
34
|
+
/** Override the regenerate-command hint printed in the header. */
|
|
35
|
+
regenerate?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function generateSql(
|
|
39
|
+
schema: Schema,
|
|
40
|
+
docs: DocDef[],
|
|
41
|
+
opts: GenerateSqlOptions = {},
|
|
42
|
+
): string {
|
|
43
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
44
|
+
const header =
|
|
45
|
+
opts.header ?? "GENERATED FROM types.ts — DO NOT EDIT";
|
|
46
|
+
const regen = opts.regenerate ?? "bunx delta sql ./types.ts";
|
|
47
|
+
|
|
48
|
+
const lines: string[] = [
|
|
49
|
+
"-- =========================================================================",
|
|
50
|
+
`-- ${header}`,
|
|
51
|
+
`-- Generated: ${today}`,
|
|
52
|
+
`-- Regenerate: ${regen}`,
|
|
53
|
+
"-- =========================================================================",
|
|
54
|
+
"",
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
for (const [key, table] of Object.entries(schema.tables)) {
|
|
58
|
+
const seqName = `seq_${table.name}`;
|
|
59
|
+
lines.push(`-- ${key}`);
|
|
60
|
+
lines.push(`CREATE SEQUENCE IF NOT EXISTS ${q(seqName)};`);
|
|
61
|
+
lines.push("");
|
|
62
|
+
|
|
63
|
+
const cols: string[] = [
|
|
64
|
+
`id BIGINT NOT NULL DEFAULT nextval(${lit(seqName)})`,
|
|
65
|
+
];
|
|
66
|
+
if (table.parent) {
|
|
67
|
+
cols.push(`${q(table.parent.fkColumn)} BIGINT NOT NULL`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const [col, def] of Object.entries(table.columns)) {
|
|
71
|
+
const sqlType = columnSqlType(def);
|
|
72
|
+
const notNull = def.nullable ? "" : " NOT NULL";
|
|
73
|
+
const defaultVal =
|
|
74
|
+
def.default !== undefined ? ` DEFAULT ${sqlDefault(def.default)}` : "";
|
|
75
|
+
cols.push(`${q(col)} ${sqlType}${notNull}${defaultVal}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (table.temporal) {
|
|
79
|
+
cols.push("valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW()");
|
|
80
|
+
cols.push("valid_to TIMESTAMPTZ");
|
|
81
|
+
cols.push("PRIMARY KEY (id, valid_from)");
|
|
82
|
+
} else {
|
|
83
|
+
cols.push("PRIMARY KEY (id)");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lines.push(`CREATE TABLE IF NOT EXISTS ${q(table.name)} (`);
|
|
87
|
+
lines.push(cols.map((c) => ` ${c}`).join(",\n"));
|
|
88
|
+
lines.push(");");
|
|
89
|
+
// A column a later schema declares, added to a table made before it (SQLite's migrateSchema): a
|
|
90
|
+
// no-op on a table just made. Nullable, with its default or its type's, as migrateSchema adds it.
|
|
91
|
+
if (table.parent) lines.push(`ALTER TABLE ${q(table.name)} ADD COLUMN IF NOT EXISTS ${q(table.parent.fkColumn)} BIGINT;`);
|
|
92
|
+
for (const [col, def] of Object.entries(table.columns)) {
|
|
93
|
+
const fill = def.default !== undefined ? def.default : def.nullable ? undefined : defaultForType(def.type);
|
|
94
|
+
lines.push(`ALTER TABLE ${q(table.name)} ADD COLUMN IF NOT EXISTS ${q(col)} ${columnSqlType(def)}${fill !== undefined ? ` DEFAULT ${sqlDefault(fill)}` : ""};`);
|
|
95
|
+
}
|
|
96
|
+
// Own the sequence to the table.id so `TRUNCATE RESTART IDENTITY`
|
|
97
|
+
// actually resets the counter — without this, re-seeds in tests and
|
|
98
|
+
// demo scripts drift by the stale sequence value.
|
|
99
|
+
lines.push(`ALTER SEQUENCE ${q(seqName)} OWNED BY ${q(table.name)}.id;`);
|
|
100
|
+
|
|
101
|
+
if (table.temporal) {
|
|
102
|
+
// security_invoker (Postgres 15+): the view reads as the caller, so the table's RLS holds through it
|
|
103
|
+
lines.push(
|
|
104
|
+
`CREATE OR REPLACE VIEW ${q("current_" + table.name)} WITH (security_invoker = true) AS SELECT * FROM ${q(table.name)} WHERE valid_to IS NULL;`,
|
|
105
|
+
);
|
|
106
|
+
lines.push(
|
|
107
|
+
`CREATE INDEX IF NOT EXISTS ${q("idx_" + table.name + "_id_valid")} ON ${q(table.name)} (id, valid_to);`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (table.parent) {
|
|
111
|
+
lines.push(
|
|
112
|
+
`CREATE INDEX IF NOT EXISTS ${q("idx_" + table.name + "_" + table.parent.fkColumn)} ON ${q(table.name)} (${q(table.parent.fkColumn)});`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Collection metadata
|
|
119
|
+
lines.push("-- Collection metadata");
|
|
120
|
+
for (const [key, table] of Object.entries(schema.tables)) {
|
|
121
|
+
const cascadeOn = lit(JSON.stringify(findCascadeOn(key, schema)));
|
|
122
|
+
const colsDef = lit(JSON.stringify(table.columns));
|
|
123
|
+
const parent = table.parent ? lit(table.parent.collection) : "NULL";
|
|
124
|
+
const parentFk = table.parent ? lit(table.parent.fkColumn) : "NULL";
|
|
125
|
+
lines.push(
|
|
126
|
+
`INSERT INTO _delta_collections (collection_key, table_name, columns_def, parent_collection, parent_fk, temporal, cascade_on)`,
|
|
127
|
+
);
|
|
128
|
+
lines.push(
|
|
129
|
+
` VALUES (${lit(key)}, ${lit(table.name)}, ${colsDef}, ${parent}, ${parentFk}, ${table.temporal}, ${cascadeOn})`,
|
|
130
|
+
);
|
|
131
|
+
lines.push(
|
|
132
|
+
` ON CONFLICT (collection_key) DO UPDATE SET table_name = EXCLUDED.table_name, columns_def = EXCLUDED.columns_def,`,
|
|
133
|
+
);
|
|
134
|
+
lines.push(
|
|
135
|
+
` parent_collection = EXCLUDED.parent_collection, parent_fk = EXCLUDED.parent_fk, temporal = EXCLUDED.temporal, cascade_on = EXCLUDED.cascade_on;`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
lines.push("");
|
|
139
|
+
|
|
140
|
+
// Doc definitions. `implied` is written when a document is implied (and
|
|
141
|
+
// then for each, so one that stops being implied is reset), and `held` when
|
|
142
|
+
// one names its columns (todo #70): SQL for an app with neither needs no
|
|
143
|
+
// framework SQL newer than it.
|
|
144
|
+
lines.push("-- Doc definitions");
|
|
145
|
+
const implied = docs.some((d) => d.implied);
|
|
146
|
+
const held = docs.some((d) => d.columns);
|
|
147
|
+
for (const doc of docs) assertScopeKeys(doc, schema); // its scope's keys and the columns it names are its collections' columns
|
|
148
|
+
for (const doc of docs) {
|
|
149
|
+
// Build a proper Postgres text[] array literal: each element is wrapped in
|
|
150
|
+
// double quotes with embedded `"` and `\` backslash-escaped, so a comma or
|
|
151
|
+
// quote inside a collection key can't corrupt the array.
|
|
152
|
+
const include = doc.include.length
|
|
153
|
+
? lit(
|
|
154
|
+
`{${doc.include
|
|
155
|
+
.map((e) => `"${e.replace(/(["\\])/g, "\\$1")}"`)
|
|
156
|
+
.join(",")}}`,
|
|
157
|
+
)
|
|
158
|
+
: "'{}'";
|
|
159
|
+
const scope = lit(JSON.stringify(doc.scope));
|
|
160
|
+
lines.push(
|
|
161
|
+
`INSERT INTO _delta_docs (prefix, root_collection, include, scope${implied ? ", implied" : ""}${held ? ", held" : ""})`,
|
|
162
|
+
);
|
|
163
|
+
lines.push(
|
|
164
|
+
` VALUES (${lit(doc.prefix)}, ${lit(doc.root)}, ${include}, ${scope}${implied ? `, ${!!doc.implied}` : ""}${held ? `, ${lit(JSON.stringify(doc.columns ?? {}))}` : ""})`,
|
|
165
|
+
);
|
|
166
|
+
lines.push(
|
|
167
|
+
` ON CONFLICT (prefix) DO UPDATE SET root_collection = EXCLUDED.root_collection, include = EXCLUDED.include, scope = EXCLUDED.scope${implied ? ", implied = EXCLUDED.implied" : ""}${held ? ", held = EXCLUDED.held" : ""};`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
lines.push("");
|
|
171
|
+
|
|
172
|
+
// each document opened by SQL compiled for it (dzql), which docTypeFromDef calls
|
|
173
|
+
lines.push("", compileOpens(schema, docs));
|
|
174
|
+
return lines.join("\n");
|
|
175
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blueshed/delta/postgres — Postgres backend for delta-doc.
|
|
3
|
+
*
|
|
4
|
+
* Schema + stored functions + LISTEN dispatch. Tables are generated from
|
|
5
|
+
* a TypeScript schema definition; all mutations go through stored functions
|
|
6
|
+
* that apply JSON-Patch ops and emit NOTIFY events. The doc-listener fans
|
|
7
|
+
* those events out to WebSocket subscribers.
|
|
8
|
+
*
|
|
9
|
+
* import {
|
|
10
|
+
* defineSchema, defineDoc,
|
|
11
|
+
* createDocListener, docTypeFromDef,
|
|
12
|
+
* } from "@blueshed/delta/postgres";
|
|
13
|
+
*
|
|
14
|
+
* The stored functions are `src/sql/001*.sql`: `applyFramework(pool)` applies
|
|
15
|
+
* them, or `bunx @blueshed/delta init <dir>` copies them for a migration tool
|
|
16
|
+
* (plain idempotent SQL; order is alphabetical).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
defineSchema,
|
|
21
|
+
defineDoc,
|
|
22
|
+
loadDocAt,
|
|
23
|
+
pruneOpsLog,
|
|
24
|
+
validateOps,
|
|
25
|
+
} from "./schema";
|
|
26
|
+
export type {
|
|
27
|
+
ColumnDef,
|
|
28
|
+
TableDef,
|
|
29
|
+
Schema,
|
|
30
|
+
ResolvedTable,
|
|
31
|
+
DocDef,
|
|
32
|
+
ValidationError,
|
|
33
|
+
} from "./schema";
|
|
34
|
+
|
|
35
|
+
export { generateSql } from "./codegen";
|
|
36
|
+
|
|
37
|
+
export { exportTables, importTables } from "./carry";
|
|
38
|
+
export type { Snapshot } from "@blueshed/delta/backend";
|
|
39
|
+
export type { GenerateSqlOptions } from "./codegen";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
applyFramework,
|
|
43
|
+
applySql,
|
|
44
|
+
frameworkSqlFiles,
|
|
45
|
+
} from "./bootstrap";
|
|
46
|
+
|
|
47
|
+
export { migrate, migrationStatus, renameColumn, dropColumn, renameTable, fromFolder } from "./migrate";
|
|
48
|
+
export type { Migration, MigrateOptions, MigrateResult, MigrationStatus } from "./migrate";
|
|
49
|
+
|
|
50
|
+
export { createDocListener, defineCustomDoc } from "./listener";
|
|
51
|
+
export type { CustomDocDef } from "./listener";
|
|
52
|
+
|
|
53
|
+
export { docTypeFromDef } from "./registry";
|
|
54
|
+
export type { DocType, Writer } from "./registry";
|
|
55
|
+
|
|
56
|
+
export { withAppAuth } from "./auth";
|
|
57
|
+
export type { DeltaAuth, AuthError, AuthAction } from "./auth";
|
|
58
|
+
export { isAuthError, wireAuth, upgradeWithAuth } from "./auth";
|