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.
- package/README.md +16 -0
- package/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +12 -5
- package/src/cli/codegen/notification.ts +3 -3
- package/src/cli/codegen/sql/core.ts +21 -5
- package/src/cli/codegen/sql/delete.ts +5 -7
- package/src/cli/codegen/sql/get.ts +9 -11
- package/src/cli/codegen/sql/index.ts +7 -4
- package/src/cli/codegen/sql/lookup.ts +4 -5
- package/src/cli/codegen/sql/save.ts +58 -39
- package/src/cli/codegen/sql/search.ts +44 -10
- package/src/cli/codegen/sql/utils.ts +16 -1
- package/src/cli/codegen/subscribable_sql.ts +116 -124
- package/src/cli/compile.ts +263 -0
- package/src/cli/compiler/path-validator.ts +115 -83
- package/src/cli/compiler/paths.ts +299 -379
- package/src/cli/compiler/permissions.ts +26 -26
- package/src/cli/index.ts +17 -219
- package/src/client/ws.ts +20 -10
- 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/codegen.ts +172 -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 +253 -0
- package/src/postgres/schema.ts +171 -0
- package/src/postgres/sql.ts +42 -0
- package/src/runtime/auth.ts +33 -2
- package/src/runtime/index.ts +47 -6
- package/src/runtime/manifest_loader.ts +10 -0
- package/src/runtime/projection.ts +19 -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,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres's migrations (PGlite's too; `../migrate.ts` says the rules):
|
|
3
|
+
* `migrate` runs a list at start-up, before `applyFramework` and
|
|
4
|
+
* `applySql(pool, generateSql(…))`; the helpers are the changes people make
|
|
5
|
+
* most, each SQL that keeps delta's scaffolding in step (a temporal table's
|
|
6
|
+
* `current_<table>` view and its grants, the `idx_<table>_…` indexes, the
|
|
7
|
+
* `seq_<table>` serial, `_delta_collections`). Two processes starting at once
|
|
8
|
+
* take turns on an advisory lock, so each migration runs once.
|
|
9
|
+
*/
|
|
10
|
+
import type { Pool, PoolClient } from "pg";
|
|
11
|
+
import { q } from "@blueshed/delta/backend";
|
|
12
|
+
import { appOf, fenced, runMigrations, statusOf, type MigrateOptions, type MigrateResult, type Migration, type MigrationStatus, type Store } from "@blueshed/delta/backend";
|
|
13
|
+
|
|
14
|
+
export { fromFolder, type Migration, type MigrateOptions, type MigrateResult, type MigrationStatus } from "@blueshed/delta/backend";
|
|
15
|
+
|
|
16
|
+
/** What a helper runs its SQL on: the client `up` is given. */
|
|
17
|
+
type Tx = Pick<PoolClient, "query">;
|
|
18
|
+
|
|
19
|
+
/** The advisory lock's key: one per database, for delta's migrations. */
|
|
20
|
+
const LOCK = "hashtext('delta_migrations')";
|
|
21
|
+
|
|
22
|
+
const regclass = async (tx: Tx, name: string): Promise<boolean> =>
|
|
23
|
+
(await tx.query("SELECT to_regclass($1) IS NOT NULL AS there", [q(name)])).rows[0]?.there === true;
|
|
24
|
+
|
|
25
|
+
function store(client: PoolClient, app: string, collections?: string): Store<PoolClient> {
|
|
26
|
+
return {
|
|
27
|
+
async lock() { await client.query(`SELECT pg_advisory_lock(${LOCK})`); },
|
|
28
|
+
async unlock() { await client.query(`SELECT pg_advisory_unlock(${LOCK})`); },
|
|
29
|
+
async exists() { return regclass(client, "delta_migrations"); },
|
|
30
|
+
async tables(names) {
|
|
31
|
+
const out = new Map<string, { columns: string[]; rows: boolean }>();
|
|
32
|
+
const { rows: found } = await client.query(
|
|
33
|
+
`SELECT table_name AS name, array_agg(column_name::text) AS columns FROM information_schema.columns
|
|
34
|
+
WHERE table_schema = current_schema() AND table_name IN (SELECT jsonb_array_elements_text($1::jsonb)) GROUP BY 1`, [JSON.stringify(names)]);
|
|
35
|
+
for (const t of found) {
|
|
36
|
+
const rows = (await client.query(`SELECT EXISTS (SELECT 1 FROM ${q(t.name)}) AS any`)).rows[0]?.any === true;
|
|
37
|
+
out.set(t.name, { columns: Array.isArray(t.columns) ? t.columns : String(t.columns).replace(/^\{|\}$/g, "").split(","), rows });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
},
|
|
41
|
+
async hasAppTables() {
|
|
42
|
+
const { rows } = await client.query(
|
|
43
|
+
"SELECT count(*)::int AS n FROM pg_tables WHERE schemaname = current_schema() AND tablename !~ '^_?delta_'",
|
|
44
|
+
);
|
|
45
|
+
return rows[0]!.n > 0;
|
|
46
|
+
},
|
|
47
|
+
async create() { await client.query("CREATE TABLE IF NOT EXISTS delta_migrations (app TEXT NOT NULL, id TEXT NOT NULL, ran_at TEXT NOT NULL, PRIMARY KEY (app, id))"); },
|
|
48
|
+
async recorded() { return (await client.query("SELECT id, ran_at FROM delta_migrations WHERE app = $1 ORDER BY ran_at, id", [app])).rows; },
|
|
49
|
+
async transaction(fn) {
|
|
50
|
+
await client.query("BEGIN");
|
|
51
|
+
try {
|
|
52
|
+
const out = await fn(client);
|
|
53
|
+
await client.query("COMMIT");
|
|
54
|
+
return out;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
await client.query("ROLLBACK");
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
async has(tx, id) { return (await tx.query("SELECT 1 FROM delta_migrations WHERE app = $1 AND id = $2", [app, id])).rows.length > 0; },
|
|
61
|
+
async record(tx, id, at) { await tx.query("INSERT INTO delta_migrations (app, id, ran_at) VALUES ($1, $2, $3)", [app, id, at]); },
|
|
62
|
+
async fence(tx) {
|
|
63
|
+
if (!(await regclass(tx, "_delta_ledger"))) return;
|
|
64
|
+
if (collections === undefined) return void (await tx.query("UPDATE _delta_ledger SET undoable = FALSE WHERE undoable"));
|
|
65
|
+
// an entry is the app's when an op of it, or of its inverse, names one of the app's collections: /<coll>/...
|
|
66
|
+
await tx.query(`UPDATE _delta_ledger SET undoable = FALSE WHERE undoable AND EXISTS (
|
|
67
|
+
SELECT 1 FROM jsonb_array_elements(ops || inverse) AS o
|
|
68
|
+
WHERE split_part(o->>'path', '/', 2) IN (SELECT jsonb_array_elements_text($1::jsonb)))`, [collections]);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Runs `fn` on a client of its own, holding nothing after. */
|
|
74
|
+
async function withClient<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
|
75
|
+
const client = await pool.connect();
|
|
76
|
+
try { return await fn(client); }
|
|
77
|
+
finally { client.release(); }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Runs each migration of the app's list not yet run here, in order, each in
|
|
82
|
+
* its own transaction on one client (`up(client)`). Call it at start-up, before
|
|
83
|
+
* `applyFramework` and the schema's `generateSql`; name the app and give its
|
|
84
|
+
* schema when apps share the database.
|
|
85
|
+
*/
|
|
86
|
+
export function migrate(pool: Pool, migrations: Migration<PoolClient>[], options: MigrateOptions = {}): Promise<MigrateResult> {
|
|
87
|
+
return withClient(pool, (client) => runMigrations(store(client, appOf(options), fenced(options)), migrations, options));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What of the app's has run here, what would run, and whether the app is new here; changes nothing. */
|
|
91
|
+
export function migrationStatus(pool: Pool, migrations: Migration<PoolClient>[], options: MigrateOptions = {}): Promise<MigrationStatus> {
|
|
92
|
+
return withClient(pool, (client) => statusOf(store(client, appOf(options)), migrations, options));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Helpers: each one change, with delta's scaffolding kept in step. Each takes
|
|
97
|
+
// the client `up` is given.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** Renames a column, every version of every row with it, and in the table's `current_` view; its `idx_<table>_<from>` index goes by the new name. */
|
|
101
|
+
export async function renameColumn(tx: Tx, table: string, from: string, to: string): Promise<void> {
|
|
102
|
+
await tx.query(`ALTER TABLE ${q(table)} RENAME COLUMN ${q(from)} TO ${q(to)}`);
|
|
103
|
+
if (await regclass(tx, `current_${table}`)) await tx.query(`ALTER VIEW ${q(`current_${table}`)} RENAME COLUMN ${q(from)} TO ${q(to)}`);
|
|
104
|
+
await tx.query(`ALTER INDEX IF EXISTS ${q(`idx_${table}_${from}`)} RENAME TO ${q(`idx_${table}_${to}`)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Drops a column and what it held. A temporal table's `current_` view reads
|
|
109
|
+
* every column, so it is made again without it, with the grants it had. Move
|
|
110
|
+
* what must be kept first.
|
|
111
|
+
*/
|
|
112
|
+
export async function dropColumn(tx: Tx, table: string, column: string): Promise<void> {
|
|
113
|
+
const view = `current_${table}`;
|
|
114
|
+
if (!(await regclass(tx, view))) {
|
|
115
|
+
await tx.query(`ALTER TABLE ${q(table)} DROP COLUMN ${q(column)}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const { rows: grants } = await tx.query(
|
|
119
|
+
`SELECT grantee, privilege_type FROM information_schema.role_table_grants
|
|
120
|
+
WHERE table_schema = current_schema() AND table_name = $1 AND grantee <> current_user`, [view]);
|
|
121
|
+
await tx.query(`DROP VIEW ${q(view)}`);
|
|
122
|
+
await tx.query(`ALTER TABLE ${q(table)} DROP COLUMN ${q(column)}`);
|
|
123
|
+
await tx.query(`CREATE VIEW ${q(view)} WITH (security_invoker = true) AS SELECT * FROM ${q(table)} WHERE valid_to IS NULL`);
|
|
124
|
+
for (const g of grants) await tx.query(`GRANT ${g.privilege_type} ON ${q(view)} TO ${g.grantee === "PUBLIC" ? "PUBLIC" : q(g.grantee)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Renames a table, with its `current_<table>` view, its `idx_<table>_…`
|
|
129
|
+
* indexes and primary key, its `seq_<table>` serial (the next id follows on),
|
|
130
|
+
* and the name `_delta_collections` keeps for it.
|
|
131
|
+
*/
|
|
132
|
+
export async function renameTable(tx: Tx, from: string, to: string): Promise<void> {
|
|
133
|
+
await tx.query(`ALTER TABLE ${q(from)} RENAME TO ${q(to)}`);
|
|
134
|
+
await tx.query(`ALTER VIEW IF EXISTS ${q(`current_${from}`)} RENAME TO ${q(`current_${to}`)}`);
|
|
135
|
+
await tx.query(`ALTER SEQUENCE IF EXISTS ${q(`seq_${from}`)} RENAME TO ${q(`seq_${to}`)}`);
|
|
136
|
+
const { rows: indexes } = await tx.query(
|
|
137
|
+
"SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND tablename = $1 AND starts_with(indexname, $2)", [to, `idx_${from}_`]);
|
|
138
|
+
for (const { indexname } of indexes) await tx.query(`ALTER INDEX ${q(indexname)} RENAME TO ${q(`idx_${to}_${indexname.slice(`idx_${from}_`.length)}`)}`);
|
|
139
|
+
const { rows: pkey } = await tx.query(
|
|
140
|
+
"SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass($1) AND conname = $2", [q(to), `${from}_pkey`]);
|
|
141
|
+
if (pkey.length) await tx.query(`ALTER TABLE ${q(to)} RENAME CONSTRAINT ${q(`${from}_pkey`)} TO ${q(`${to}_pkey`)}`);
|
|
142
|
+
if (await regclass(tx, "_delta_collections")) await tx.query("UPDATE _delta_collections SET table_name = $2 WHERE table_name = $1", [from, to]);
|
|
143
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blueshed/delta/pglite — Postgres in this process.
|
|
3
|
+
*
|
|
4
|
+
* The same stored functions, the same listener, the same documents as a
|
|
5
|
+
* Postgres server, on PGlite (Postgres compiled to WASM): no database to run,
|
|
6
|
+
* the data in a directory (or in memory). `openPglite()` answers the slice of
|
|
7
|
+
* `pg`'s Pool delta uses, so everything in `@blueshed/delta/postgres` runs on
|
|
8
|
+
* it unchanged:
|
|
9
|
+
*
|
|
10
|
+
* const pool = await openPglite("./data"); // or openPglite() in memory
|
|
11
|
+
* await applyFramework(pool);
|
|
12
|
+
* await applySql(pool, generateSql(schema, docs));
|
|
13
|
+
* await createDocListener(ws, pool, { types: docs.map((def) => docTypeFromDef(def, pool)), ledger: true });
|
|
14
|
+
*
|
|
15
|
+
* Outgrow it and move to a server: `exportTables(pool, schema)` here,
|
|
16
|
+
* `importTables(server, schema, rows)` there -- the ids and sequences carry.
|
|
17
|
+
* One process owns the directory. Taken from epsilon's `pglite.ts`.
|
|
18
|
+
*
|
|
19
|
+
* Optional, like `pg`: `bun add @electric-sql/pglite` to use it.
|
|
20
|
+
*/
|
|
21
|
+
import type { Pool } from "pg";
|
|
22
|
+
|
|
23
|
+
/** The slice of PGlite this drives (structural: no hard import). */
|
|
24
|
+
interface PGliteish {
|
|
25
|
+
query(text: string, params?: unknown[]): Promise<{ rows: any[] }>;
|
|
26
|
+
exec(text: string): Promise<{ rows: any[] }[]>;
|
|
27
|
+
transaction<T>(fn: (tx: { query(text: string, params?: unknown[]): Promise<{ rows: any[] }>; exec(text: string): Promise<{ rows: any[] }[]> }) => Promise<T>): Promise<T>;
|
|
28
|
+
listen(channel: string, callback: (payload: string) => void): Promise<() => Promise<void>>;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Runner = Pick<PGliteish, "query" | "exec">;
|
|
33
|
+
type Result = { rows: any[]; rowCount: number };
|
|
34
|
+
|
|
35
|
+
/** pg sends a JS object or array as JSON; PGlite binds by type, so give it the JSON text. */
|
|
36
|
+
const bind = (v: unknown): unknown => (v !== null && typeof v === "object" && !(v instanceof Date) ? JSON.stringify(v) : v);
|
|
37
|
+
|
|
38
|
+
/** One statement with parameters, or several without, as `pg`'s query takes them. */
|
|
39
|
+
async function run(on: Runner, text: string, params?: unknown[]): Promise<Result> {
|
|
40
|
+
if (params?.length) {
|
|
41
|
+
const { rows } = await on.query(text, params.map(bind));
|
|
42
|
+
return { rows, rowCount: rows.length };
|
|
43
|
+
}
|
|
44
|
+
const results = await on.exec(text);
|
|
45
|
+
const rows = results[results.length - 1]?.rows ?? [];
|
|
46
|
+
return { rows, rowCount: rows.length };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const verb = (text: string) => text.trim().replace(/;$/, "").toUpperCase();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wrap a PGlite instance in the Pool delta uses. PGlite is one session: a
|
|
53
|
+
* client's `BEGIN` opens a PGlite transaction and holds it until `COMMIT` or
|
|
54
|
+
* `ROLLBACK`, and every other query waits for it (PGlite runs queries and
|
|
55
|
+
* transactions under one lock), so nothing lands inside another's
|
|
56
|
+
* transaction. `LISTEN` on a client is PGlite's `listen`, its payloads the
|
|
57
|
+
* client's `notification` events.
|
|
58
|
+
*/
|
|
59
|
+
export function pglitePool(db: PGliteish): Pool {
|
|
60
|
+
function connect() {
|
|
61
|
+
const handlers = new Set<(msg: { channel: string; payload: string }) => void>();
|
|
62
|
+
const unlisten: (() => Promise<void>)[] = [];
|
|
63
|
+
// The open transaction, if any: its queries, and how to end it.
|
|
64
|
+
let tx: { on: Runner; end: (commit: boolean) => void; done: Promise<unknown> } | null = null;
|
|
65
|
+
|
|
66
|
+
const client = {
|
|
67
|
+
async query(text: string, params?: unknown[]): Promise<Result> {
|
|
68
|
+
const v = verb(text);
|
|
69
|
+
if (v === "BEGIN") {
|
|
70
|
+
if (tx) throw new Error("a transaction is already open on this client");
|
|
71
|
+
let opened!: (on: Runner) => void;
|
|
72
|
+
const ready = new Promise<Runner>((r) => (opened = r));
|
|
73
|
+
let end!: (commit: boolean) => void;
|
|
74
|
+
const ended = new Promise<boolean>((r) => (end = r));
|
|
75
|
+
const done = db.transaction(async (t) => {
|
|
76
|
+
opened(t);
|
|
77
|
+
if (!(await ended)) throw new Rollback();
|
|
78
|
+
}).catch((err) => { if (!(err instanceof Rollback)) throw err; });
|
|
79
|
+
tx = { on: await ready, end, done };
|
|
80
|
+
return { rows: [], rowCount: 0 };
|
|
81
|
+
}
|
|
82
|
+
if (v === "COMMIT" || v === "ROLLBACK") {
|
|
83
|
+
const open = tx;
|
|
84
|
+
tx = null;
|
|
85
|
+
if (!open) return { rows: [], rowCount: 0 };
|
|
86
|
+
open.end(v === "COMMIT");
|
|
87
|
+
await open.done;
|
|
88
|
+
return { rows: [], rowCount: 0 };
|
|
89
|
+
}
|
|
90
|
+
const listen = /^LISTEN\s+"?([\w.]+)"?$/.exec(v);
|
|
91
|
+
if (listen) {
|
|
92
|
+
const channel = text.trim().replace(/;$/, "").split(/\s+/)[1]!.replace(/"/g, "");
|
|
93
|
+
unlisten.push(await db.listen(channel, (payload) => { for (const h of handlers) h({ channel, payload }); }));
|
|
94
|
+
return { rows: [], rowCount: 0 };
|
|
95
|
+
}
|
|
96
|
+
if (v.startsWith("UNLISTEN")) {
|
|
97
|
+
for (const off of unlisten.splice(0)) await off();
|
|
98
|
+
return { rows: [], rowCount: 0 };
|
|
99
|
+
}
|
|
100
|
+
return run(tx ? tx.on : db, text, params);
|
|
101
|
+
},
|
|
102
|
+
on(event: string, handler: (msg: any) => void) {
|
|
103
|
+
if (event === "notification") handlers.add(handler);
|
|
104
|
+
return client;
|
|
105
|
+
},
|
|
106
|
+
removeListener(event: string, handler: (msg: any) => void) {
|
|
107
|
+
if (event === "notification") handlers.delete(handler);
|
|
108
|
+
return client;
|
|
109
|
+
},
|
|
110
|
+
release() {
|
|
111
|
+
// A client let go inside a transaction rolls it back, as pg's would be reset.
|
|
112
|
+
if (tx) { tx.end(false); tx = null; }
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
return client;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pool = {
|
|
119
|
+
query: (text: string, params?: unknown[]) => run(db, text, params),
|
|
120
|
+
connect: async () => connect(),
|
|
121
|
+
end: () => db.close(),
|
|
122
|
+
on: () => pool,
|
|
123
|
+
};
|
|
124
|
+
return pool as unknown as Pool;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class Rollback extends Error {}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Open (or create) an in-process Postgres: in `dir`, or in memory when none
|
|
131
|
+
* is given, as the Pool delta's Postgres backend takes.
|
|
132
|
+
*/
|
|
133
|
+
export async function openPglite(dir?: string): Promise<Pool> {
|
|
134
|
+
let PGlite: any;
|
|
135
|
+
try {
|
|
136
|
+
({ PGlite } = await import("@electric-sql/pglite"));
|
|
137
|
+
} catch {
|
|
138
|
+
throw new Error("[delta/pglite] @electric-sql/pglite is not installed -- bun add @electric-sql/pglite");
|
|
139
|
+
}
|
|
140
|
+
const db: PGliteish = dir ? await PGlite.create(dir) : await PGlite.create();
|
|
141
|
+
return pglitePool(db);
|
|
142
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doc-type registry — single source of truth for how doc names are dispatched.
|
|
3
|
+
*
|
|
4
|
+
* Each doc type owns a prefix and the four lifecycle operations:
|
|
5
|
+
* - parse — does this name belong to me? return ctx or null.
|
|
6
|
+
* - open — load the doc at its current version.
|
|
7
|
+
* - apply — apply delta ops and return the new version.
|
|
8
|
+
* - openAt — optional snapshot-at-timestamp read.
|
|
9
|
+
*
|
|
10
|
+
* Generic docs (delta-doc framework) are made by `docTypeFromDef(def, pool)`.
|
|
11
|
+
* Custom docs (e.g. venue-occasion) export their own `DocType`. Each is given
|
|
12
|
+
* to a listener (`createDocListener(ws, pool, { types })`), which asks its own
|
|
13
|
+
* types for every WS message -- no other place in the codebase interprets
|
|
14
|
+
* doc-name prefixes.
|
|
15
|
+
*/
|
|
16
|
+
import type { Pool } from "pg";
|
|
17
|
+
import type { DocDef } from "./schema";
|
|
18
|
+
import { splitPath, type DeltaOp } from "@blueshed/delta/core";
|
|
19
|
+
import { authless, ownerless, type DeltaAuth } from "@blueshed/delta/auth";
|
|
20
|
+
import { assertOnePrefix } from "@blueshed/delta/backend";
|
|
21
|
+
|
|
22
|
+
/** Who is writing, for the ledger: the identity as the ledger has it, and the cursor undo walks. */
|
|
23
|
+
export type Writer = { who: string | null; cursor: string | null; undoable?: boolean; undoes?: number | null };
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// DocType — the unified handler contract
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A document the listener answers for. `docTypeFromDef` makes one from a
|
|
31
|
+
* `defineDoc`, which the database knows (`_delta_docs`), so a write through
|
|
32
|
+
* any document is told to it wherever it holds a row the write changed.
|
|
33
|
+
* One written by hand is the app's own: the database cannot know what its
|
|
34
|
+
* `open` reads, so it is told only its own writes -- those its `apply` logs
|
|
35
|
+
* with `_delta_bump_and_notify(docName, ops)`. To hear other writes, make it
|
|
36
|
+
* from a `defineDoc` (`docTypeFromDef`), or watch the collections with a
|
|
37
|
+
* custom document (`defineCustomDoc`).
|
|
38
|
+
*/
|
|
39
|
+
export interface DocType<C = any, I = unknown> {
|
|
40
|
+
/** Unique prefix this type owns (e.g. "venue:", "venue-at:"). */
|
|
41
|
+
prefix: string;
|
|
42
|
+
|
|
43
|
+
/** Parse the doc name into a context object, or null if it doesn't match. */
|
|
44
|
+
parse(docName: string): C | null;
|
|
45
|
+
|
|
46
|
+
/** Open the doc at its current version. `identity` is the authenticated
|
|
47
|
+
* identity produced by the `DeltaAuth` gate (undefined if auth disabled). */
|
|
48
|
+
open(
|
|
49
|
+
ctx: C,
|
|
50
|
+
docName: string,
|
|
51
|
+
msg?: any,
|
|
52
|
+
identity?: I,
|
|
53
|
+
): Promise<{ result: any; version: number } | null>;
|
|
54
|
+
|
|
55
|
+
/** Apply delta ops. Returns new version; ops may be omitted since broadcast
|
|
56
|
+
* happens via LISTEN/NOTIFY, but returning them keeps the contract honest.
|
|
57
|
+
* Given `by` (the listener keeps a ledger), the write is recorded with its
|
|
58
|
+
* inverse in the same transaction -- the framework's docs do; a custom type
|
|
59
|
+
* that ignores `by` is simply not on the ledger. */
|
|
60
|
+
apply(
|
|
61
|
+
ctx: C,
|
|
62
|
+
docName: string,
|
|
63
|
+
ops: DeltaOp[],
|
|
64
|
+
identity?: I,
|
|
65
|
+
by?: Writer,
|
|
66
|
+
/** The columns the listener's schema declares, by collection (JSON), for `callAs`: a write by them (`_delta_cols`). */
|
|
67
|
+
declared?: string,
|
|
68
|
+
): Promise<{ version: number; ops?: any[]; inverse?: any[]; entry?: number | null }>;
|
|
69
|
+
|
|
70
|
+
/** Optional: snapshot read at a historical timestamp. Not every doc type
|
|
71
|
+
* supports this (e.g. venue-at: already embeds the timestamp in its name). */
|
|
72
|
+
openAt?(
|
|
73
|
+
ctx: C,
|
|
74
|
+
docName: string,
|
|
75
|
+
at: string,
|
|
76
|
+
identity?: I,
|
|
77
|
+
): Promise<any | null>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* With an `auth` module: may `identity` open `docName`, write through it and
|
|
81
|
+
* hear what is written to it? A document's name is its broadcast channel --
|
|
82
|
+
* whoever has it open hears every write made through it, whatever RLS lets
|
|
83
|
+
* them read -- so this is the check that keeps one identity's writes off
|
|
84
|
+
* another's socket. The listener asks it before open, delta, open_at,
|
|
85
|
+
* history, and an undo or redo of an entry written through the document;
|
|
86
|
+
* false answers 404. It asks it again before it tells a socket of a change,
|
|
87
|
+
* once per identity per change, and lets go of a socket it refuses -- so keep
|
|
88
|
+
* it cheap (a name check, or a membership the app caches). With auth, a type needs it or `shared: true`:
|
|
89
|
+
* `createDocListener` and its `register` refuse one with neither.
|
|
90
|
+
*/
|
|
91
|
+
owns?(identity: I, docName: string): boolean | Promise<boolean>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* With an `auth` module: every identity that passes the gate may open every
|
|
95
|
+
* document of this prefix and hear every write to it -- the author's word
|
|
96
|
+
* for a type with no `owns`.
|
|
97
|
+
*/
|
|
98
|
+
shared?: boolean;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The definition a framework document was made from (`docTypeFromDef`): its
|
|
102
|
+
* writer is answered only what it holds -- a write's, a walk's and its
|
|
103
|
+
* history's ops and inverse (`showAnswer`).
|
|
104
|
+
*/
|
|
105
|
+
def?: DocDef;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A write's, a walk's or a history entry's ops and inverse as the document
|
|
110
|
+
* `doc` (of `def`) shows them: ops of the collections it holds alone -- a
|
|
111
|
+
* removal's cascade into one it does not include is left out -- and, where it
|
|
112
|
+
* names its columns, those (`_delta_show_ops`, 001c; asked only then, which
|
|
113
|
+
* framework SQL older than it cannot have). The ledger keeps them whole.
|
|
114
|
+
*/
|
|
115
|
+
export async function showAnswer<R extends { ops?: unknown; inverse?: unknown }>(q: Pick<Pool, "query">, doc: string, r: R, def: DocDef | undefined): Promise<R> {
|
|
116
|
+
if (!r || !def) return r;
|
|
117
|
+
const held = (ops: unknown) => Array.isArray(ops) ? ops.filter((op) => { const coll = splitPath(op.path)[0]!; return coll === def.root || def.include.includes(coll); }) : ops;
|
|
118
|
+
const out = { ...r, ...(r.ops ? { ops: held(r.ops) } : {}), ...(r.inverse ? { inverse: held(r.inverse) } : {}) };
|
|
119
|
+
if (!def.columns) return out;
|
|
120
|
+
const { rows } = await q.query("SELECT _delta_show_ops($1, $2::jsonb) AS ops, _delta_show_ops($1, $3::jsonb) AS inverse", [doc, JSON.stringify(out.ops ?? null), JSON.stringify(out.inverse ?? null)]);
|
|
121
|
+
return { ...out, ...(out.ops ? { ops: rows[0].ops } : {}), ...(out.inverse ? { inverse: rows[0].inverse } : {}) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// A listener's types -- the longest prefix answers a name
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A listener's document types (`createDocListener`'s `types`), each server its
|
|
130
|
+
* own. With `auth`, each says who owns it (`owns`, or `shared: true`); no two
|
|
131
|
+
* have one prefix, nor one of `taken` (the listener's custom documents, todo
|
|
132
|
+
* #76). `register` adds one later, replacing the type of its prefix: a module
|
|
133
|
+
* run again (`bun --hot`) registers the latest code.
|
|
134
|
+
*/
|
|
135
|
+
export function docTypes(initial: DocType[], taken: string[], auth: boolean) {
|
|
136
|
+
const types: DocType[] = [];
|
|
137
|
+
assertOnePrefix([...initial.map((t) => t.prefix), ...taken]);
|
|
138
|
+
function register(t: DocType): void {
|
|
139
|
+
if (auth && !t.owns && !t.shared) throw ownerless(t.prefix, "createDocListener types");
|
|
140
|
+
assertOnePrefix([t.prefix], (p) => taken.includes(p));
|
|
141
|
+
const earlier = types.findIndex((e) => e.prefix === t.prefix);
|
|
142
|
+
if (earlier >= 0) types.splice(earlier, 1);
|
|
143
|
+
types.push(t);
|
|
144
|
+
types.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
145
|
+
}
|
|
146
|
+
initial.forEach(register);
|
|
147
|
+
return {
|
|
148
|
+
register,
|
|
149
|
+
resolve(docName: string): { type: DocType; ctx: any } | null {
|
|
150
|
+
for (const type of types) {
|
|
151
|
+
const ctx = type.parse(docName);
|
|
152
|
+
if (ctx !== null) return { type, ctx };
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `SELECT fn(args) AS result` on `db`, as `identity` when the auth module
|
|
161
|
+
* says how to name it (`asSqlArg`): `fn_as(user, args)`, which sets
|
|
162
|
+
* `app.user_id` and calls `fn` in one round-trip (001f).
|
|
163
|
+
*/
|
|
164
|
+
export async function callAs<I>(db: { query(sql: string, params: unknown[]): Promise<{ rows: any[] }> }, fn: string, args: unknown[], auth?: DeltaAuth<I>, identity?: I, declared?: string): Promise<any> {
|
|
165
|
+
const as = auth?.asSqlArg && identity !== undefined ? [String(auth.asSqlArg(identity))] : [];
|
|
166
|
+
const all = [...as, ...args];
|
|
167
|
+
const call = `${as.length ? `${fn}_as` : fn}(${all.map((_, i) => `$${i + 1}`).join(", ")})`;
|
|
168
|
+
if (declared === undefined) return (await db.query(`SELECT ${call} AS result`, all)).rows[0]?.result;
|
|
169
|
+
// the columns this process declares, for the call's transaction (`_delta_cols`, 001b): set first, then the call
|
|
170
|
+
const { rows } = await db.query(`WITH d AS MATERIALIZED (SELECT set_config('delta.declared', $${all.length + 1}, true)) SELECT ${call} AS result FROM d`, [...all, declared]);
|
|
171
|
+
return rows[0]?.result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// docTypeFromDef — lift a generic DocDef into a DocType
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Produce a DocType for a framework-generic doc. One DocType per DocDef —
|
|
180
|
+
* this replaces the former "one big generic handler that loops prefixes"
|
|
181
|
+
* pattern so the registry stays flat and each prefix owns itself.
|
|
182
|
+
*
|
|
183
|
+
* When `opts.auth` is provided with `asSqlArg`, the hot path calls the
|
|
184
|
+
* `delta_*_as(user_id, ...)` stored-function variants (see
|
|
185
|
+
* `src/sql/001f-delta-as.sql`). Each op is ONE round-trip — the wrapper
|
|
186
|
+
* runs `set_config('app.user_id', ..., true)` server-side inside the
|
|
187
|
+
* SELECT's implicit transaction, then calls the base function. Compare
|
|
188
|
+
* to the four RTTs `withAppAuth` takes (BEGIN / set_config / call / COMMIT).
|
|
189
|
+
*
|
|
190
|
+
* When auth is omitted, queries run on the bare pool with the base
|
|
191
|
+
* `delta_*` functions.
|
|
192
|
+
*
|
|
193
|
+
* With `auth`, pass `owns(identity, docName)` -- who may open a document of
|
|
194
|
+
* this prefix -- or `shared: true`; without either it throws. A document's
|
|
195
|
+
* name is the channel its writes are broadcast on, so RLS alone does not keep
|
|
196
|
+
* one identity's writes off another identity's socket.
|
|
197
|
+
*/
|
|
198
|
+
export function docTypeFromDef<I = unknown>(
|
|
199
|
+
def: DocDef,
|
|
200
|
+
pool: Pool,
|
|
201
|
+
opts?: {
|
|
202
|
+
auth?: DeltaAuth<I>;
|
|
203
|
+
/** Who may open a document of this prefix (see `DocType.owns`). Required with `auth`, unless `shared`. */
|
|
204
|
+
owns?: (identity: I, docName: string) => boolean | Promise<boolean>;
|
|
205
|
+
/** Every identity that passes the gate may open every document of this prefix and hear every write to it. */
|
|
206
|
+
shared?: boolean;
|
|
207
|
+
},
|
|
208
|
+
): DocType<{}, I> {
|
|
209
|
+
const auth = opts?.auth;
|
|
210
|
+
// Default-deny: RLS filters what `open` reads, not what the document's
|
|
211
|
+
// channel carries, so a name several identities may open would hand each of
|
|
212
|
+
// them every row written through it. Say who owns it, or that it is shared.
|
|
213
|
+
if (auth && !opts?.owns && !opts?.shared) throw ownerless(def.prefix, "docTypeFromDef");
|
|
214
|
+
// owns or shared with no auth module would guard nothing: the listener asks them only with one
|
|
215
|
+
if (!auth && (opts?.owns || opts?.shared)) throw authless(def.prefix, "docTypeFromDef");
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
prefix: def.prefix,
|
|
219
|
+
...(opts?.owns ? { owns: opts.owns } : {}),
|
|
220
|
+
...(opts?.shared ? { shared: true } : {}),
|
|
221
|
+
def,
|
|
222
|
+
|
|
223
|
+
parse(docName) {
|
|
224
|
+
return docName.startsWith(def.prefix) ? {} : null;
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async open(_ctx, docName, _msg, identity) {
|
|
228
|
+
const doc = await callAs(pool, "delta_open", [docName], auth, identity);
|
|
229
|
+
if (!doc) return null;
|
|
230
|
+
const version = doc._version ?? 0;
|
|
231
|
+
delete doc._version;
|
|
232
|
+
// Forward the version to the client as `_v` so it can detect a missed
|
|
233
|
+
// broadcast (gap) and re-open to resync. The client strips `_v` before
|
|
234
|
+
// it reaches `doc.data`. Number() keeps it consistent with the broadcast
|
|
235
|
+
// `v` (which pg yields as a string from a BIGINT column).
|
|
236
|
+
doc._v = Number(version);
|
|
237
|
+
return { result: doc, version };
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async apply(_ctx, docName, ops, identity, by, declared) {
|
|
241
|
+
// on the ledger: delta_apply with its entry, in one transaction (001g)
|
|
242
|
+
const result = by
|
|
243
|
+
? await callAs(pool, "delta_apply_logged", [docName, JSON.stringify(ops), by.who, by.cursor, by.undoable !== false, by.undoes ?? null], auth, identity, declared)
|
|
244
|
+
: await callAs(pool, "delta_apply", [docName, JSON.stringify(ops)], auth, identity, declared);
|
|
245
|
+
if (!result) throw new Error(`delta_apply returned no result for ${docName}`);
|
|
246
|
+
return showAnswer(pool, docName, result, def); // its writer is answered only what it holds
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async openAt(_ctx, docName, at, identity) {
|
|
250
|
+
return (await callAs(pool, "delta_open_at", [docName, at], auth, identity)) ?? null;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|