dzql 0.6.37 → 0.8.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.
Files changed (45) hide show
  1. package/README.md +16 -0
  2. package/docs/README.md +1 -1
  3. package/docs/for_ai.md +39 -9
  4. package/package.json +12 -5
  5. package/src/cli/codegen/notification.ts +3 -3
  6. package/src/cli/codegen/sql/core.ts +21 -5
  7. package/src/cli/codegen/sql/delete.ts +5 -7
  8. package/src/cli/codegen/sql/get.ts +9 -11
  9. package/src/cli/codegen/sql/index.ts +7 -4
  10. package/src/cli/codegen/sql/lookup.ts +4 -5
  11. package/src/cli/codegen/sql/save.ts +58 -39
  12. package/src/cli/codegen/sql/search.ts +44 -10
  13. package/src/cli/codegen/sql/utils.ts +16 -1
  14. package/src/cli/codegen/subscribable_sql.ts +116 -124
  15. package/src/cli/compile.ts +263 -0
  16. package/src/cli/compiler/path-validator.ts +115 -83
  17. package/src/cli/compiler/paths.ts +299 -379
  18. package/src/cli/compiler/permissions.ts +26 -26
  19. package/src/cli/index.ts +17 -219
  20. package/src/client/ws.ts +20 -10
  21. package/src/postgres/auth-jwt-sql.ts +32 -0
  22. package/src/postgres/auth-jwt.ts +209 -0
  23. package/src/postgres/auth.ts +50 -0
  24. package/src/postgres/bootstrap.ts +55 -0
  25. package/src/postgres/carry.ts +40 -0
  26. package/src/postgres/codegen.ts +172 -0
  27. package/src/postgres/index.ts +58 -0
  28. package/src/postgres/listener.ts +706 -0
  29. package/src/postgres/migrate.ts +143 -0
  30. package/src/postgres/pglite.ts +142 -0
  31. package/src/postgres/registry.ts +253 -0
  32. package/src/postgres/schema.ts +171 -0
  33. package/src/postgres/sql.ts +42 -0
  34. package/src/runtime/auth.ts +33 -2
  35. package/src/runtime/index.ts +47 -6
  36. package/src/runtime/manifest_loader.ts +10 -0
  37. package/src/runtime/projection.ts +19 -0
  38. package/src/sql/001a-delta-meta.sql +204 -0
  39. package/src/sql/001b-delta-scope.sql +619 -0
  40. package/src/sql/001c-delta-read.sql +506 -0
  41. package/src/sql/001d-delta-write.sql +1088 -0
  42. package/src/sql/001e-delta-ops.sql +142 -0
  43. package/src/sql/001f-delta-as.sql +41 -0
  44. package/src/sql/001g-delta-ledger.sql +564 -0
  45. package/src/sql/auth-jwt.sql +62 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Postgres auth helpers — RLS session context for delta-doc queries.
3
+ *
4
+ * `withAppAuth(pool, sqlArg, fn)` checks out a client, opens a transaction,
5
+ * sets `app.user_id` for the duration of the transaction, runs `fn` with the
6
+ * client, then commits and releases. Row-Level Security policies that read
7
+ * `current_setting('app.user_id', true)` then filter automatically.
8
+ *
9
+ * CREATE POLICY venues_owner ON venues
10
+ * FOR ALL USING (owner_id = current_setting('app.user_id', true)::int);
11
+ *
12
+ * await withAppAuth(pool, user.id, c =>
13
+ * c.query("SELECT delta_open($1) AS doc", [docName]),
14
+ * );
15
+ *
16
+ * The session variable is set via `SET LOCAL` so it reverts on transaction
17
+ * end — safe against pool reuse.
18
+ *
19
+ * Re-exports the `DeltaAuth` contract from `../auth` for convenience.
20
+ */
21
+ import type { Pool, PoolClient } from "pg";
22
+
23
+ export type { DeltaAuth, AuthError, AuthAction } from "@blueshed/delta/auth";
24
+ export { isAuthError, wireAuth, upgradeWithAuth } from "@blueshed/delta/auth";
25
+
26
+ /**
27
+ * Run `fn` inside a transaction with `SET LOCAL app.user_id = <sqlArg>`. All
28
+ * queries issued on the passed client observe RLS policies scoped to that
29
+ * identity.
30
+ */
31
+ export async function withAppAuth<T>(
32
+ pool: Pool,
33
+ sqlArg: string | number,
34
+ fn: (c: PoolClient) => Promise<T>,
35
+ ): Promise<T> {
36
+ const client = await pool.connect();
37
+ try {
38
+ await client.query("BEGIN");
39
+ // SET LOCAL cannot be parameterised — use set_config(..., is_local=true).
40
+ await client.query("SELECT set_config('app.user_id', $1, true)", [String(sqlArg)]);
41
+ const result = await fn(client);
42
+ await client.query("COMMIT");
43
+ return result;
44
+ } catch (err) {
45
+ try { await client.query("ROLLBACK"); } catch { /* pool may already be closed */ }
46
+ throw err;
47
+ } finally {
48
+ client.release();
49
+ }
50
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Bootstrap helpers — apply the delta-doc framework SQL to a Postgres
3
+ * database, programmatically. Alternative to copying the files into a
4
+ * consumer's `init_db/` directory.
5
+ *
6
+ * import { Pool } from "pg";
7
+ * import { applyFramework } from "@blueshed/delta/postgres";
8
+ *
9
+ * const pool = new Pool({ connectionString: process.env.PG_URL });
10
+ * await applyFramework(pool);
11
+ *
12
+ * The framework SQL is idempotent (`CREATE … IF NOT EXISTS`,
13
+ * `ON CONFLICT DO UPDATE`) so it is safe to call on every server boot.
14
+ *
15
+ * For docker-entrypoint-initdb.d workflows, use the `delta init` CLI to
16
+ * copy the same files into a directory the image can mount.
17
+ */
18
+ import type { Pool } from "pg";
19
+ import { readFileSync, readdirSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ const FRAMEWORK_SQL_DIR = join(import.meta.dir, "..", "sql");
23
+
24
+ /** Absolute paths to the framework SQL files, in apply-order.
25
+ * Filters to `001*-*.sql` — siblings like `auth-jwt.sql` are applied
26
+ * separately (see `@blueshed/delta/auth-jwt` → `applyAuthJwtSchema`). */
27
+ export function frameworkSqlFiles(): string[] {
28
+ return readdirSync(FRAMEWORK_SQL_DIR)
29
+ .filter((f) => /^001[a-z]?-.+\.sql$/.test(f))
30
+ .sort()
31
+ .map((f) => join(FRAMEWORK_SQL_DIR, f));
32
+ }
33
+
34
+ /**
35
+ * Apply the framework SQL to the given pool. Runs every `001*` file in
36
+ * `src/sql/` alphabetically — 001a through 001g today. Safe to call
37
+ * repeatedly (idempotent statements).
38
+ */
39
+ export async function applyFramework(pool: Pool): Promise<void> {
40
+ for (const path of frameworkSqlFiles()) {
41
+ const sql = readFileSync(path, "utf8");
42
+ await pool.query(sql);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Apply an arbitrary SQL string to the pool (convenience wrapper). Useful
48
+ * for layering `generateSql(schema, docs)` output on top of the framework:
49
+ *
50
+ * await applyFramework(pool);
51
+ * await applySql(pool, generateSql(schema, docs));
52
+ */
53
+ export async function applySql(pool: Pool, sql: string): Promise<void> {
54
+ if (sql.trim()) await pool.query(sql);
55
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Carrying an app's rows into, and out of, a Postgres database -- a server, or
3
+ * PGlite in this process. The same `Snapshot` every backend gives and takes
4
+ * (`../../schema`), so the data moves along the path with its ids and
5
+ * sequences unchanged.
6
+ */
7
+ import type { Pool } from "pg";
8
+ import { isoTime, lastId, q, type Schema, type Snapshot } from "@blueshed/delta/backend";
9
+
10
+ /** Every row of the schema's tables (every version of a temporal row), and each sequence's last id. */
11
+ export async function exportTables(pool: Pool, schema: Schema): Promise<Snapshot> {
12
+ const tables: Snapshot["tables"] = {};
13
+ const sequences: Record<string, number> = {};
14
+ for (const [key, table] of Object.entries(schema.tables)) {
15
+ const order = table.temporal ? "id, valid_from" : "id";
16
+ const { rows } = await pool.query(`SELECT to_jsonb(t.*) AS row FROM ${q(table.name)} t ORDER BY ${order}`);
17
+ tables[key] = rows.map(({ row }: { row: Record<string, unknown> }) =>
18
+ table.temporal ? { ...row, valid_from: isoTime(row.valid_from), valid_to: isoTime(row.valid_to) } : row,
19
+ );
20
+ const seq = (await pool.query(`SELECT last_value, is_called FROM ${q(`seq_${table.name}`)}`)).rows[0];
21
+ sequences[key] = seq ? Number(seq.last_value) - (seq.is_called ? 0 : 1) : 0;
22
+ }
23
+ return { tables, sequences };
24
+ }
25
+
26
+ /**
27
+ * Put a snapshot's rows into the schema's tables, as they are (ids kept), and
28
+ * set each sequence past them, so the next row the database names follows the
29
+ * last one named where the rows came from. The tables should be empty.
30
+ */
31
+ export async function importTables(pool: Pool, schema: Schema, snapshot: Snapshot): Promise<void> {
32
+ for (const [key, table] of Object.entries(schema.tables)) {
33
+ for (const row of snapshot.tables[key] ?? []) {
34
+ const value = table.temporal ? { valid_from: new Date().toISOString(), valid_to: null, ...row } : row;
35
+ await pool.query(`INSERT INTO ${q(table.name)} SELECT * FROM jsonb_populate_record(null::${q(table.name)}, $1)`, [JSON.stringify(value)]);
36
+ }
37
+ const last = lastId(snapshot, key);
38
+ await pool.query(`SELECT setval($1, $2, $3)`, [`seq_${table.name}`, Math.max(last, 1), last > 0]);
39
+ }
40
+ }
@@ -0,0 +1,172 @@
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
+
30
+ export interface GenerateSqlOptions {
31
+ /** Header comment included at the top of the file. */
32
+ header?: string;
33
+ /** Override the regenerate-command hint printed in the header. */
34
+ regenerate?: string;
35
+ }
36
+
37
+ export function generateSql(
38
+ schema: Schema,
39
+ docs: DocDef[],
40
+ opts: GenerateSqlOptions = {},
41
+ ): string {
42
+ const today = new Date().toISOString().slice(0, 10);
43
+ const header =
44
+ opts.header ?? "GENERATED FROM types.ts — DO NOT EDIT";
45
+ const regen = opts.regenerate ?? "bunx delta sql ./types.ts";
46
+
47
+ const lines: string[] = [
48
+ "-- =========================================================================",
49
+ `-- ${header}`,
50
+ `-- Generated: ${today}`,
51
+ `-- Regenerate: ${regen}`,
52
+ "-- =========================================================================",
53
+ "",
54
+ ];
55
+
56
+ for (const [key, table] of Object.entries(schema.tables)) {
57
+ const seqName = `seq_${table.name}`;
58
+ lines.push(`-- ${key}`);
59
+ lines.push(`CREATE SEQUENCE IF NOT EXISTS ${q(seqName)};`);
60
+ lines.push("");
61
+
62
+ const cols: string[] = [
63
+ `id BIGINT NOT NULL DEFAULT nextval(${lit(seqName)})`,
64
+ ];
65
+ if (table.parent) {
66
+ cols.push(`${q(table.parent.fkColumn)} BIGINT NOT NULL`);
67
+ }
68
+
69
+ for (const [col, def] of Object.entries(table.columns)) {
70
+ const sqlType = columnSqlType(def);
71
+ const notNull = def.nullable ? "" : " NOT NULL";
72
+ const defaultVal =
73
+ def.default !== undefined ? ` DEFAULT ${sqlDefault(def.default)}` : "";
74
+ cols.push(`${q(col)} ${sqlType}${notNull}${defaultVal}`);
75
+ }
76
+
77
+ if (table.temporal) {
78
+ cols.push("valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW()");
79
+ cols.push("valid_to TIMESTAMPTZ");
80
+ cols.push("PRIMARY KEY (id, valid_from)");
81
+ } else {
82
+ cols.push("PRIMARY KEY (id)");
83
+ }
84
+
85
+ lines.push(`CREATE TABLE IF NOT EXISTS ${q(table.name)} (`);
86
+ lines.push(cols.map((c) => ` ${c}`).join(",\n"));
87
+ lines.push(");");
88
+ // A column a later schema declares, added to a table made before it (SQLite's migrateSchema): a
89
+ // no-op on a table just made. Nullable, with its default or its type's, as migrateSchema adds it.
90
+ if (table.parent) lines.push(`ALTER TABLE ${q(table.name)} ADD COLUMN IF NOT EXISTS ${q(table.parent.fkColumn)} BIGINT;`);
91
+ for (const [col, def] of Object.entries(table.columns)) {
92
+ const fill = def.default !== undefined ? def.default : def.nullable ? undefined : defaultForType(def.type);
93
+ lines.push(`ALTER TABLE ${q(table.name)} ADD COLUMN IF NOT EXISTS ${q(col)} ${columnSqlType(def)}${fill !== undefined ? ` DEFAULT ${sqlDefault(fill)}` : ""};`);
94
+ }
95
+ // Own the sequence to the table.id so `TRUNCATE RESTART IDENTITY`
96
+ // actually resets the counter — without this, re-seeds in tests and
97
+ // demo scripts drift by the stale sequence value.
98
+ lines.push(`ALTER SEQUENCE ${q(seqName)} OWNED BY ${q(table.name)}.id;`);
99
+
100
+ if (table.temporal) {
101
+ // security_invoker (Postgres 15+): the view reads as the caller, so the table's RLS holds through it
102
+ lines.push(
103
+ `CREATE OR REPLACE VIEW ${q("current_" + table.name)} WITH (security_invoker = true) AS SELECT * FROM ${q(table.name)} WHERE valid_to IS NULL;`,
104
+ );
105
+ lines.push(
106
+ `CREATE INDEX IF NOT EXISTS ${q("idx_" + table.name + "_id_valid")} ON ${q(table.name)} (id, valid_to);`,
107
+ );
108
+ }
109
+ if (table.parent) {
110
+ lines.push(
111
+ `CREATE INDEX IF NOT EXISTS ${q("idx_" + table.name + "_" + table.parent.fkColumn)} ON ${q(table.name)} (${q(table.parent.fkColumn)});`,
112
+ );
113
+ }
114
+ lines.push("");
115
+ }
116
+
117
+ // Collection metadata
118
+ lines.push("-- Collection metadata");
119
+ for (const [key, table] of Object.entries(schema.tables)) {
120
+ const cascadeOn = lit(JSON.stringify(findCascadeOn(key, schema)));
121
+ const colsDef = lit(JSON.stringify(table.columns));
122
+ const parent = table.parent ? lit(table.parent.collection) : "NULL";
123
+ const parentFk = table.parent ? lit(table.parent.fkColumn) : "NULL";
124
+ lines.push(
125
+ `INSERT INTO _delta_collections (collection_key, table_name, columns_def, parent_collection, parent_fk, temporal, cascade_on)`,
126
+ );
127
+ lines.push(
128
+ ` VALUES (${lit(key)}, ${lit(table.name)}, ${colsDef}, ${parent}, ${parentFk}, ${table.temporal}, ${cascadeOn})`,
129
+ );
130
+ lines.push(
131
+ ` ON CONFLICT (collection_key) DO UPDATE SET table_name = EXCLUDED.table_name, columns_def = EXCLUDED.columns_def,`,
132
+ );
133
+ lines.push(
134
+ ` parent_collection = EXCLUDED.parent_collection, parent_fk = EXCLUDED.parent_fk, temporal = EXCLUDED.temporal, cascade_on = EXCLUDED.cascade_on;`,
135
+ );
136
+ }
137
+ lines.push("");
138
+
139
+ // Doc definitions. `implied` is written when a document is implied (and
140
+ // then for each, so one that stops being implied is reset), and `held` when
141
+ // one names its columns (todo #70): SQL for an app with neither needs no
142
+ // framework SQL newer than it.
143
+ lines.push("-- Doc definitions");
144
+ const implied = docs.some((d) => d.implied);
145
+ const held = docs.some((d) => d.columns);
146
+ for (const doc of docs) assertScopeKeys(doc, schema); // its scope's keys and the columns it names are its collections' columns
147
+ for (const doc of docs) {
148
+ // Build a proper Postgres text[] array literal: each element is wrapped in
149
+ // double quotes with embedded `"` and `\` backslash-escaped, so a comma or
150
+ // quote inside a collection key can't corrupt the array.
151
+ const include = doc.include.length
152
+ ? lit(
153
+ `{${doc.include
154
+ .map((e) => `"${e.replace(/(["\\])/g, "\\$1")}"`)
155
+ .join(",")}}`,
156
+ )
157
+ : "'{}'";
158
+ const scope = lit(JSON.stringify(doc.scope));
159
+ lines.push(
160
+ `INSERT INTO _delta_docs (prefix, root_collection, include, scope${implied ? ", implied" : ""}${held ? ", held" : ""})`,
161
+ );
162
+ lines.push(
163
+ ` VALUES (${lit(doc.prefix)}, ${lit(doc.root)}, ${include}, ${scope}${implied ? `, ${!!doc.implied}` : ""}${held ? `, ${lit(JSON.stringify(doc.columns ?? {}))}` : ""})`,
164
+ );
165
+ lines.push(
166
+ ` 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" : ""};`,
167
+ );
168
+ }
169
+ lines.push("");
170
+
171
+ return lines.join("\n");
172
+ }
@@ -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";