dzql 0.10.0 → 0.12.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 +6 -6
- package/package.json +4 -3
- package/src/cli/index.ts +2 -2
- package/src/compile/cascades.ts +6 -6
- package/src/compile/columns.ts +7 -8
- package/src/compile/holders.ts +5 -11
- package/src/compile/holds.ts +4 -5
- package/src/compile/ledger.ts +7 -8
- package/src/compile/lookups.ts +118 -0
- package/src/compile/opens.ts +6 -0
- package/src/compile/resolve.ts +4 -4
- package/src/compile/root.ts +4 -4
- package/src/compile/rows.ts +27 -18
- package/src/compile/scope.ts +5 -4
- package/src/compile/told.ts +5 -4
- package/src/postgres/auth-link.ts +53 -0
- package/src/postgres/cli.ts +44 -4
- package/src/postgres/codegen.ts +2 -1
- package/src/postgres/listener.ts +50 -35
- package/src/postgres/pglite.ts +4 -1
- package/src/sql/001a-delta-meta.sql +52 -19
- package/src/sql/001b-delta-scope.sql +18 -477
- package/src/sql/001c-delta-read.sql +14 -291
- package/src/sql/001d-delta-write.sql +64 -379
- package/src/sql/001e-delta-ops.sql +4 -62
- package/src/sql/001g-delta-ledger.sql +6 -29
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link sign-in on Postgres: the `LinkStore` delta's `auth-link` keeps its links and sessions in
|
|
3
|
+
* (`@blueshed/delta/auth-link`; Workshop's docs/link-sign-in-plan.md). The tables are its own,
|
|
4
|
+
* `_auth_links` and `_auth_sessions`, beside delta's `_delta_*`: hashes, the person's id, times. A
|
|
5
|
+
* link is used in one statement, so two taps at once make one session, on one server or several.
|
|
6
|
+
*
|
|
7
|
+
* import { linkAuth } from "@blueshed/delta/auth-link";
|
|
8
|
+
* import { postgresLinkStore } from "dzql/auth-link";
|
|
9
|
+
*
|
|
10
|
+
* const auth = linkAuth({ store: await postgresLinkStore(pool), personOf, people });
|
|
11
|
+
*/
|
|
12
|
+
import type { Pool } from "pg";
|
|
13
|
+
import type { KeptLink, KeptSession, LinkStore } from "@blueshed/delta/auth-link";
|
|
14
|
+
|
|
15
|
+
export { linkAuth, makeLink } from "@blueshed/delta/auth-link";
|
|
16
|
+
|
|
17
|
+
/** The links and sessions in Postgres, in tables of their own, made here (once: IF NOT EXISTS). */
|
|
18
|
+
export async function postgresLinkStore(pool: Pool): Promise<LinkStore> {
|
|
19
|
+
await pool.query(`CREATE TABLE IF NOT EXISTS _auth_links (hash TEXT PRIMARY KEY, person BIGINT NOT NULL, opens TEXT, expires TIMESTAMPTZ NOT NULL, used TIMESTAMPTZ)`);
|
|
20
|
+
await pool.query(`CREATE TABLE IF NOT EXISTS _auth_sessions (hash TEXT PRIMARY KEY, person BIGINT NOT NULL, expires TIMESTAMPTZ NOT NULL)`);
|
|
21
|
+
// Times as delta's store has them: ISO strings; a person's id a number (pg reads BIGINT as text)
|
|
22
|
+
const iso = (t: Date | string | null) => (t === null ? null : new Date(t).toISOString());
|
|
23
|
+
const SESSION = "SELECT person::int AS person, expires FROM _auth_sessions WHERE hash = $1";
|
|
24
|
+
return {
|
|
25
|
+
async addLink(hash, l) {
|
|
26
|
+
await pool.query("INSERT INTO _auth_links (hash, person, opens, expires) VALUES ($1, $2, $3, $4)", [hash, l.person, l.opens, l.expires]);
|
|
27
|
+
},
|
|
28
|
+
async useLink(hash, now) {
|
|
29
|
+
const { rows } = await pool.query(
|
|
30
|
+
"UPDATE _auth_links SET used = $1 WHERE hash = $2 AND used IS NULL AND expires > $1 RETURNING person::int AS person, opens",
|
|
31
|
+
[now, hash],
|
|
32
|
+
);
|
|
33
|
+
return rows[0] ?? null;
|
|
34
|
+
},
|
|
35
|
+
async link(hash): Promise<KeptLink | null> {
|
|
36
|
+
const { rows } = await pool.query("SELECT person::int AS person, opens, expires, used FROM _auth_links WHERE hash = $1", [hash]);
|
|
37
|
+
return rows[0] ? { ...rows[0], expires: iso(rows[0].expires)!, used: iso(rows[0].used) } : null;
|
|
38
|
+
},
|
|
39
|
+
async addSession(hash, s) {
|
|
40
|
+
await pool.query("INSERT INTO _auth_sessions (hash, person, expires) VALUES ($1, $2, $3)", [hash, s.person, s.expires]);
|
|
41
|
+
},
|
|
42
|
+
async session(hash): Promise<KeptSession | null> {
|
|
43
|
+
const { rows } = await pool.query(SESSION, [hash]);
|
|
44
|
+
return rows[0] ? { person: rows[0].person, expires: iso(rows[0].expires)! } : null;
|
|
45
|
+
},
|
|
46
|
+
async renewSession(hash, expires) {
|
|
47
|
+
await pool.query("UPDATE _auth_sessions SET expires = $1 WHERE hash = $2", [expires, hash]);
|
|
48
|
+
},
|
|
49
|
+
async endSession(hash) {
|
|
50
|
+
await pool.query("DELETE FROM _auth_sessions WHERE hash = $1", [hash]);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/postgres/cli.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* dzql's Postgres commands, as delta's CLI had them: `dzql sql`, `dzql init
|
|
4
|
-
*
|
|
5
|
-
* `dzql <command>` (src/cli/index.ts hands these
|
|
3
|
+
* dzql's Postgres commands, as delta's CLI had them: `dzql sql`, `dzql init`,
|
|
4
|
+
* `dzql migrate` (a postgres:// URL or a PGlite directory), and `dzql link`. Run through
|
|
5
|
+
* `dzql <command>` (src/cli/index.ts hands these four here).
|
|
6
6
|
*/
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
8
|
import { resolve, basename, join, dirname } from "node:path";
|
|
@@ -26,6 +26,8 @@ const USAGE =
|
|
|
26
26
|
` dzql init <dir> [--with-auth] [--upgrade]\n` +
|
|
27
27
|
` dzql migrate <database> [--app <name>] [--dir <migrations>] [--status] [--json]\n` +
|
|
28
28
|
` <database>: a PGlite directory or a postgres:// URL; --dir defaults to ./migrations\n` +
|
|
29
|
+
` dzql link <database> <person> [--origin <url>] [--opens <path>] [--json]\n` +
|
|
30
|
+
` a link to sign in as someone (link sign-in): the first person's, on the server\n` +
|
|
29
31
|
`\n` +
|
|
30
32
|
`Exit codes: 0 done, 1 an error, 2 a bad argument.\n`;
|
|
31
33
|
|
|
@@ -299,6 +301,41 @@ async function cmdMigrate(target: string | undefined, values: Record<string, unk
|
|
|
299
301
|
if (!r.fresh && r.ran.length === 0) process.stdout.write("nothing to run\n");
|
|
300
302
|
}
|
|
301
303
|
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// dzql link
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A link to sign in as `person` (link sign-in, `dzql/auth-link`), made in the app's Postgres: how
|
|
310
|
+
* the first person signs in, before anyone can send them one. The site is --origin, else ORIGIN,
|
|
311
|
+
* else Railway's domain. The database as `migrate` takes it. A SQLite file is delta's (`delta link`).
|
|
312
|
+
*/
|
|
313
|
+
async function cmdLink(target: string | undefined, person: string | undefined, values: Record<string, unknown>) {
|
|
314
|
+
if (!target || !person) usageError("link takes a <database> and a <person>: dzql link postgres://… 1", true);
|
|
315
|
+
if (!/^\d+$/.test(person)) usageError(`<person> is a person's id, a number: ${person}`);
|
|
316
|
+
const isUrl = /^postgres(ql)?:\/\//.test(target);
|
|
317
|
+
const path = resolve(process.cwd(), target);
|
|
318
|
+
if (!isUrl && (!existsSync(path) || !statSync(path).isDirectory())) usageError(`no PGlite database at ${target} (a SQLite file is delta's: bunx @blueshed/delta link)`);
|
|
319
|
+
const { makeLink, siteOrigin } = await import("@blueshed/delta/auth-link");
|
|
320
|
+
let origin: string;
|
|
321
|
+
try { origin = (values.origin as string | undefined) ?? siteOrigin(); }
|
|
322
|
+
catch (err) { usageError(`${(err as Error).message}, or pass --origin`); }
|
|
323
|
+
const { postgresLinkStore } = await import("./auth-link");
|
|
324
|
+
let pool: any;
|
|
325
|
+
if (isUrl) {
|
|
326
|
+
const { Pool } = await import("pg");
|
|
327
|
+
pool = new Pool({ connectionString: target });
|
|
328
|
+
} else {
|
|
329
|
+
const { openPglite } = await import("./pglite");
|
|
330
|
+
pool = await openPglite(path);
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const made = await makeLink(await postgresLinkStore(pool), Number(person), { origin, opens: values.opens as string | undefined });
|
|
334
|
+
if (json) return print(made);
|
|
335
|
+
process.stdout.write(`${made.url}\n(used once, until ${made.expires})\n`);
|
|
336
|
+
} finally { await pool.end(); }
|
|
337
|
+
}
|
|
338
|
+
|
|
302
339
|
// ---------------------------------------------------------------------------
|
|
303
340
|
// main
|
|
304
341
|
// ---------------------------------------------------------------------------
|
|
@@ -318,6 +355,8 @@ export async function main(argv: string[]) {
|
|
|
318
355
|
dir: { type: "string" },
|
|
319
356
|
app: { type: "string" },
|
|
320
357
|
status: { type: "boolean", default: false },
|
|
358
|
+
origin: { type: "string" },
|
|
359
|
+
opens: { type: "string" },
|
|
321
360
|
help: { type: "boolean", short: "h", default: false },
|
|
322
361
|
},
|
|
323
362
|
allowPositionals: true,
|
|
@@ -327,11 +366,12 @@ export async function main(argv: string[]) {
|
|
|
327
366
|
const { values, positionals } = parsed;
|
|
328
367
|
json = !!values.json;
|
|
329
368
|
if (values.help) { process.stdout.write(USAGE); return; }
|
|
330
|
-
const [cmd, arg] = positionals;
|
|
369
|
+
const [cmd, arg, arg2] = positionals;
|
|
331
370
|
switch (cmd) {
|
|
332
371
|
case "sql": return cmdSql(arg, values);
|
|
333
372
|
case "init": return cmdInit(arg, values);
|
|
334
373
|
case "migrate": return cmdMigrate(arg, values);
|
|
374
|
+
case "link": return cmdLink(arg, arg2, values);
|
|
335
375
|
default: usageError(cmd ? `no command ${cmd}` : "a command, please", true);
|
|
336
376
|
}
|
|
337
377
|
}
|
package/src/postgres/codegen.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { compileColumns } from "../compile/columns";
|
|
|
36
36
|
import { compileRows } from "../compile/rows";
|
|
37
37
|
import { compileCascades } from "../compile/cascades";
|
|
38
38
|
import { compileLedger } from "../compile/ledger";
|
|
39
|
+
import { compileLookups } from "../compile/lookups";
|
|
39
40
|
import { compileTold } from "../compile/told";
|
|
40
41
|
|
|
41
42
|
export interface GenerateSqlOptions {
|
|
@@ -180,6 +181,6 @@ export function generateSql(
|
|
|
180
181
|
lines.push("");
|
|
181
182
|
|
|
182
183
|
// each document opened by SQL compiled for it (dzql), which docTypeFromDef calls
|
|
183
|
-
lines.push("", compileOpens(schema, docs), "", compileHolds(schema, docs), "", compileInScope(schema, docs), "", compileTold(schema, docs), "", compileHolders(schema, docs), "", compileResolve(schema, docs), "", compileRootThere(schema, docs), "", compileColumns(schema, docs), "", compileRows(schema), "", compileCascades(schema), "", compileLedger(schema, docs));
|
|
184
|
+
lines.push("", compileOpens(schema, docs), "", compileHolds(schema, docs), "", compileInScope(schema, docs), "", compileTold(schema, docs), "", compileHolders(schema, docs), "", compileResolve(schema, docs), "", compileRootThere(schema, docs), "", compileColumns(schema, docs), "", compileRows(schema), "", compileCascades(schema), "", compileLedger(schema, docs), "", compileLookups(schema, docs));
|
|
184
185
|
return lines.join("\n");
|
|
185
186
|
}
|
package/src/postgres/listener.ts
CHANGED
|
@@ -89,12 +89,15 @@ function errMsg(err: unknown): string {
|
|
|
89
89
|
* entry that is not the cursor's next (409). And 40P01, a write that met another
|
|
90
90
|
* at once and gave way to it (two adds of temporal ids in opposite orders wait
|
|
91
91
|
* on each other's row locks, todo #16): nothing was written, and the same write
|
|
92
|
-
* sent again goes through, or meets the rows the other made (409).
|
|
93
|
-
*
|
|
92
|
+
* sent again goes through, or meets the rows the other made (409). A document
|
|
93
|
+
* type that refuses with a wire code of its own (a number, 400 to 499: a
|
|
94
|
+
* read-only document's 403) is answered with it. Anything else is the
|
|
95
|
+
* server's (500).
|
|
94
96
|
*/
|
|
95
97
|
const CODE_OF_SQLSTATE: Record<string, number> = { "22023": 400, "22P02": 400, "22007": 400, "22008": 400, "23502": 400, "25006": 403, P0002: 404, "23505": 409, "40001": 409, "40P01": 409 };
|
|
96
98
|
function wireCode(err: unknown): number {
|
|
97
99
|
const state = (err as { code?: unknown } | null)?.code;
|
|
100
|
+
if (typeof state === "number" && state >= 400 && state < 500) return state;
|
|
98
101
|
return (typeof state === "string" && CODE_OF_SQLSTATE[state]) || 500;
|
|
99
102
|
}
|
|
100
103
|
|
|
@@ -122,6 +125,15 @@ interface DocState {
|
|
|
122
125
|
/** _delta_fetch_log (delta_fetch_ops' rows) caps each call at this many; a full page means there may be more. */
|
|
123
126
|
const FETCH_PAGE = 1000;
|
|
124
127
|
|
|
128
|
+
/**
|
|
129
|
+
* What a custom document hears of a log entry: each write once, as it was applied. The writer's
|
|
130
|
+
* entry carries it (`applied`) where the writer was told otherwise, and null where it was told the
|
|
131
|
+
* write as applied; each other told document's entry of the write carries none ([]). Told, a row
|
|
132
|
+
* that left a document is a remove, which is not a delete, and a row told to two documents would be
|
|
133
|
+
* heard twice (todo #44). An entry logged some other way carries null too: heard as told.
|
|
134
|
+
*/
|
|
135
|
+
const appliedOf = (row: any): DeltaOp[] => (row.applied ?? row.ops) as DeltaOp[];
|
|
136
|
+
|
|
125
137
|
/**
|
|
126
138
|
* How the listener reads a document's log. `_delta_fetch_log` (001e) gives each
|
|
127
139
|
* entry with the write as applied, for custom documents (todo #44). Framework
|
|
@@ -250,7 +262,7 @@ export async function createDocListener<I = unknown>(
|
|
|
250
262
|
// are recycled by the pool, and a stale listener would fire against this
|
|
251
263
|
// closure's state (and spuriously trigger reconnect) on a future checkout.
|
|
252
264
|
function detachListener(client: any) {
|
|
253
|
-
try { client.removeListener?.("notification",
|
|
265
|
+
try { client.removeListener?.("notification", heard); } catch {}
|
|
254
266
|
if (errorHandler) {
|
|
255
267
|
try { client.removeListener?.("error", errorHandler); } catch {}
|
|
256
268
|
}
|
|
@@ -259,7 +271,7 @@ export async function createDocListener<I = unknown>(
|
|
|
259
271
|
/** LISTEN on `client`, and keep this holder's lock on it. */
|
|
260
272
|
async function setupListener(client: any): Promise<void> {
|
|
261
273
|
await client.query("LISTEN delta_changes");
|
|
262
|
-
client.on("notification",
|
|
274
|
+
client.on("notification", heard);
|
|
263
275
|
errorHandler = (err: Error) => {
|
|
264
276
|
log.error(`listener error: ${err.message}`);
|
|
265
277
|
if (!destroyed) reconnect();
|
|
@@ -306,7 +318,7 @@ export async function createDocListener<I = unknown>(
|
|
|
306
318
|
state.until = Number.MAX_SAFE_INTEGER; // the notifications while away are gone: read to the end
|
|
307
319
|
if (state.notifying) { state.pending = true; continue; }
|
|
308
320
|
state.notifying = true;
|
|
309
|
-
try { await drainDoc(docName, state); }
|
|
321
|
+
try { await drainDoc(docName, state, true); }
|
|
310
322
|
catch (err) { log.error(`resync ${docName}: ${errMsg(err)}`); }
|
|
311
323
|
finally { state.notifying = false; }
|
|
312
324
|
}
|
|
@@ -320,28 +332,38 @@ export async function createDocListener<I = unknown>(
|
|
|
320
332
|
throw err;
|
|
321
333
|
}
|
|
322
334
|
|
|
323
|
-
//
|
|
324
|
-
//
|
|
335
|
+
// A document's own subscribers need only its own order, so each document drains on its own
|
|
336
|
+
// (onNotification), and none waits on another's. A custom document hears writes made through
|
|
337
|
+
// every document, so it is told them one after another, as Postgres delivers the notifications
|
|
338
|
+
// (in commit order): told from each document's drain, a slow read of one told it a later write,
|
|
339
|
+
// made through another, first.
|
|
340
|
+
let inCommitOrder: Promise<void> = Promise.resolve();
|
|
341
|
+
function heard(msg: any) {
|
|
342
|
+
void onNotification(msg);
|
|
343
|
+
if (custom.size > 0) inCommitOrder = inCommitOrder.then(() => hearCustom(msg));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The custom documents told the one write a notification announces: its document's entry at that version. */
|
|
347
|
+
async function hearCustom(msg: any) {
|
|
348
|
+
if (destroyed || msg.channel !== "delta_changes" || !msg.payload) return;
|
|
349
|
+
try {
|
|
350
|
+
const { doc: docName, v } = JSON.parse(msg.payload);
|
|
351
|
+
const rows = (await pool.query(fetchSql, [docName, v - 1])).rows.filter((row: any) => Number(row.version) === v);
|
|
352
|
+
if (auth && rows.length > 0) await custom.letGoWatching(rows.flatMap(appliedOf));
|
|
353
|
+
for (const row of rows) custom.tell(appliedOf(row));
|
|
354
|
+
} catch (err) {
|
|
355
|
+
log.error(`notify error (custom): ${errMsg(err)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Notification handler — fans out LISTEN/NOTIFY to the document's WS subscribers.
|
|
325
360
|
async function onNotification(msg: any) {
|
|
326
361
|
if (destroyed) return;
|
|
327
362
|
if (msg.channel !== "delta_changes" || !msg.payload) return;
|
|
328
363
|
try {
|
|
329
364
|
const { doc: docName, v } = JSON.parse(msg.payload);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// Even without direct subscribers, we still process notifications if any
|
|
333
|
-
// custom doc is registered — a watched collection's rows may need fan-out.
|
|
334
|
-
if (!state && custom.size === 0) return;
|
|
335
|
-
if (!state) {
|
|
336
|
-
// Initialize at v-1 so we fetch only the current notification's row.
|
|
337
|
-
// Custom docs loaded their initial state via `def.query` on open, so
|
|
338
|
-
// pre-existing ops are already reflected — replaying history would
|
|
339
|
-
// duplicate them. Same logic protects close-then-reopen.
|
|
340
|
-
state = { version: Math.max(0, v - 1), until: v, subscribers: new Set(), notifying: false, pending: false };
|
|
341
|
-
tracked.set(docName, state);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
if (v <= state.version) return;
|
|
365
|
+
const state = tracked.get(docName);
|
|
366
|
+
if (!state || v <= state.version) return;
|
|
345
367
|
state.until = Math.max(state.until, v);
|
|
346
368
|
if (state.notifying) {
|
|
347
369
|
// A fetch is already running for this doc. Coalesce instead of dropping:
|
|
@@ -364,8 +386,9 @@ export async function createDocListener<I = unknown>(
|
|
|
364
386
|
// Drain a doc's ops to its subscribers. Loops while a full page comes back
|
|
365
387
|
// (a backlog larger than the fetch's LIMIT) and re-runs if another
|
|
366
388
|
// notification arrived mid-drain (the coalesced `pending` flag). Caller owns
|
|
367
|
-
// the `notifying` guard.
|
|
368
|
-
|
|
389
|
+
// the `notifying` guard. After a reconnect (`resync`) the custom documents
|
|
390
|
+
// are told what it reads too: the notifications while away are gone.
|
|
391
|
+
async function drainDoc(docName: string, state: DocState, resync = false) {
|
|
369
392
|
do {
|
|
370
393
|
state.pending = false;
|
|
371
394
|
let more = true;
|
|
@@ -374,11 +397,10 @@ export async function createDocListener<I = unknown>(
|
|
|
374
397
|
// only what a notification has announced (`until`): the rest is heard with its own
|
|
375
398
|
const rows = page.filter((row: any) => Number(row.version) <= state.until);
|
|
376
399
|
more = page.length >= FETCH_PAGE && rows.length === page.length;
|
|
377
|
-
// Told to those who may still hear it (todo #17): the document's own
|
|
378
|
-
// subscribers, and the custom documents' that watch what it changed.
|
|
400
|
+
// Told to those who may still hear it (todo #17): the document's own subscribers
|
|
379
401
|
if (auth && rows.length > 0) {
|
|
380
402
|
await custom.letGo(docName, state.subscribers, ownsOf(docName));
|
|
381
|
-
await custom.letGoWatching(rows.flatMap(
|
|
403
|
+
if (resync) await custom.letGoWatching(rows.flatMap(appliedOf));
|
|
382
404
|
}
|
|
383
405
|
for (const row of rows) {
|
|
384
406
|
if (state.subscribers.size > 0) {
|
|
@@ -390,14 +412,7 @@ export async function createDocListener<I = unknown>(
|
|
|
390
412
|
ws.publish(docName, { doc: docName, ops: shownOps(resolveDoc(docName)?.type.def, row.ops), v: Number(row.version) });
|
|
391
413
|
}
|
|
392
414
|
state.version = Number(row.version);
|
|
393
|
-
|
|
394
|
-
// writer's entry carries it (`applied`) where the writer was told
|
|
395
|
-
// otherwise, and null where it was told the write as applied; each
|
|
396
|
-
// other told document's entry of the write carries none ([]). Told,
|
|
397
|
-
// a row that left a document is a remove, which is not a delete, and
|
|
398
|
-
// a row told to two documents would be heard twice (todo #44). An
|
|
399
|
-
// entry logged some other way carries null too: heard as told.
|
|
400
|
-
custom.tell((row.applied ?? row.ops) as DeltaOp[]);
|
|
415
|
+
if (resync) custom.tell(appliedOf(row));
|
|
401
416
|
}
|
|
402
417
|
}
|
|
403
418
|
} while (state.pending);
|
package/src/postgres/pglite.ts
CHANGED
|
@@ -137,6 +137,9 @@ export async function openPglite(dir?: string): Promise<Pool> {
|
|
|
137
137
|
} catch {
|
|
138
138
|
throw new Error("[delta/pglite] @electric-sql/pglite is not installed -- bun add @electric-sql/pglite");
|
|
139
139
|
}
|
|
140
|
-
|
|
140
|
+
// pgcrypto, which dzql/auth-jwt hashes passwords with: PGlite has an extension only when it is
|
|
141
|
+
// given one at start (loaded when a database creates it)
|
|
142
|
+
const { pgcrypto } = await import("@electric-sql/pglite/contrib/pgcrypto");
|
|
143
|
+
const db: PGliteish = await PGlite.create(dir, { extensions: { pgcrypto } });
|
|
141
144
|
return pglitePool(db);
|
|
142
145
|
}
|
|
@@ -30,6 +30,49 @@ ALTER TABLE _delta_docs ADD COLUMN IF NOT EXISTS implied BOOLEAN NOT NULL DEFAUL
|
|
|
30
30
|
-- collection not named, whole. It is opened, told and written by those alone.
|
|
31
31
|
ALTER TABLE _delta_docs ADD COLUMN IF NOT EXISTS held JSONB NOT NULL DEFAULT '{}';
|
|
32
32
|
|
|
33
|
+
-- The framework that read the definitions at run time retired (todo #26): each
|
|
34
|
+
-- document and collection is compiled by generateSql (dzql/compile). Dropped
|
|
35
|
+
-- where an earlier version made them.
|
|
36
|
+
DROP FUNCTION IF EXISTS _delta_collection_framework(text);
|
|
37
|
+
DROP FUNCTION IF EXISTS _delta_assert_fields_framework(text, jsonb, text, jsonb);
|
|
38
|
+
DROP FUNCTION IF EXISTS _delta_temporal_where(timestamptz, text);
|
|
39
|
+
DROP FUNCTION IF EXISTS _delta_find_doc_framework(text);
|
|
40
|
+
DROP FUNCTION IF EXISTS _delta_resolve_scope_framework(_delta_docs, text, boolean);
|
|
41
|
+
DROP FUNCTION IF EXISTS _delta_row_in_scope_framework(_delta_docs, text, text, bigint);
|
|
42
|
+
DROP FUNCTION IF EXISTS _delta_chain_root(text, jsonb, text);
|
|
43
|
+
DROP FUNCTION IF EXISTS _delta_doc_holds_framework(_delta_docs, text, text, jsonb);
|
|
44
|
+
DROP FUNCTION IF EXISTS _delta_holders_framework(text, jsonb, text, text[]);
|
|
45
|
+
DROP FUNCTION IF EXISTS _delta_load_collection(text, text, bigint, timestamptz);
|
|
46
|
+
DROP FUNCTION IF EXISTS _delta_load_collection_all(text, timestamptz);
|
|
47
|
+
DROP FUNCTION IF EXISTS _delta_read_list(_delta_docs, text, timestamptz);
|
|
48
|
+
DROP FUNCTION IF EXISTS _delta_show_doc(_delta_docs, jsonb, boolean);
|
|
49
|
+
DROP FUNCTION IF EXISTS _delta_open_framework(text, boolean);
|
|
50
|
+
DROP FUNCTION IF EXISTS _delta_serial_past_framework(text, bigint);
|
|
51
|
+
DROP FUNCTION IF EXISTS _delta_serial_new_framework(text, bigint);
|
|
52
|
+
DROP FUNCTION IF EXISTS _delta_cascade_remove_framework(text, bigint);
|
|
53
|
+
DROP FUNCTION IF EXISTS _delta_cascade_rows_framework(text, bigint);
|
|
54
|
+
DROP FUNCTION IF EXISTS _delta_hanging_rows_framework(text, bigint);
|
|
55
|
+
DROP FUNCTION IF EXISTS _delta_told_framework(_delta_docs, text, jsonb);
|
|
56
|
+
DROP FUNCTION IF EXISTS _delta_row_there_framework(text, bigint);
|
|
57
|
+
DROP FUNCTION IF EXISTS _delta_held_columns(_delta_docs, text, jsonb);
|
|
58
|
+
DROP FUNCTION IF EXISTS _delta_root_there_framework(_delta_docs, text, jsonb, text, bigint);
|
|
59
|
+
DROP FUNCTION IF EXISTS _delta_write_columns_framework(_delta_docs, text, jsonb);
|
|
60
|
+
DROP FUNCTION IF EXISTS _delta_row_lock_framework(text, text, boolean, bigint);
|
|
61
|
+
DROP FUNCTION IF EXISTS _delta_row_put_framework(text, text, boolean, bigint, jsonb, timestamptz);
|
|
62
|
+
DROP FUNCTION IF EXISTS _delta_next_id_framework(text, text);
|
|
63
|
+
DROP FUNCTION IF EXISTS _delta_row_clear_moment_framework(text, text, bigint, timestamptz);
|
|
64
|
+
DROP FUNCTION IF EXISTS _delta_row_add_framework(text, text, jsonb);
|
|
65
|
+
DROP FUNCTION IF EXISTS _delta_row_read_framework(text, text, bigint);
|
|
66
|
+
DROP FUNCTION IF EXISTS _delta_open_at_framework(text, timestamptz);
|
|
67
|
+
DROP FUNCTION IF EXISTS _delta_same_framework(text, text, jsonb, jsonb);
|
|
68
|
+
DROP FUNCTION IF EXISTS _delta_row_standing_framework(text, bigint);
|
|
69
|
+
|
|
70
|
+
-- _delta_collection: a collection's definition (NULL fields: there is none).
|
|
71
|
+
-- generateSql writes each collection in (compileLookups); before it is applied,
|
|
72
|
+
-- there is none.
|
|
73
|
+
CREATE OR REPLACE FUNCTION _delta_collection(p_key TEXT)
|
|
74
|
+
RETURNS _delta_collections LANGUAGE sql STABLE AS $$ SELECT NULL::_delta_collections; $$;
|
|
75
|
+
|
|
33
76
|
CREATE TABLE IF NOT EXISTS _delta_versions (
|
|
34
77
|
doc_name TEXT PRIMARY KEY,
|
|
35
78
|
version BIGINT NOT NULL DEFAULT 0
|
|
@@ -135,8 +178,10 @@ $$;
|
|
|
135
178
|
-- not have, or gives a column a value not of its type (SQLSTATE 22023 → 400).
|
|
136
179
|
-- It used to be merged into the broadcast row, acked, and dropped (or cast) by
|
|
137
180
|
-- jsonb_populate_record: stored otherwise than told. id, the parent key and
|
|
138
|
-
-- the temporal columns are the row's own.
|
|
139
|
-
|
|
181
|
+
-- the temporal columns are the row's own. By the columns given (`p_columns`): a
|
|
182
|
+
-- set the compiler did not write in (another version's, `delta.declared`; a
|
|
183
|
+
-- walk's, as its entry was stored) is checked here.
|
|
184
|
+
CREATE OR REPLACE FUNCTION _delta_assert_fields_declared(
|
|
140
185
|
p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB
|
|
141
186
|
) RETURNS void LANGUAGE plpgsql IMMUTABLE AS $$
|
|
142
187
|
DECLARE
|
|
@@ -178,16 +223,16 @@ BEGIN
|
|
|
178
223
|
END;
|
|
179
224
|
$$;
|
|
180
225
|
|
|
181
|
-
-- The checks every write asks:
|
|
182
|
-
--
|
|
183
|
-
--
|
|
226
|
+
-- The checks every write asks: generateSql (`compileColumns`) replaces this
|
|
227
|
+
-- with one that checks each column set it compiled by its own statements, and
|
|
228
|
+
-- any other by the columns given.
|
|
184
229
|
CREATE OR REPLACE FUNCTION _delta_assert_fields(
|
|
185
230
|
p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB
|
|
186
|
-
) RETURNS void LANGUAGE sql IMMUTABLE AS $$ SELECT
|
|
231
|
+
) RETURNS void LANGUAGE sql IMMUTABLE AS $$ SELECT _delta_assert_fields_declared(p_collection, p_columns, p_parent_fk, p_value); $$;
|
|
187
232
|
|
|
188
233
|
-- Pick the source relation for a collection.
|
|
189
234
|
-- temporal + no timestamp → current_<table> view
|
|
190
|
-
-- temporal + timestamp → base <table> (caller filters
|
|
235
|
+
-- temporal + timestamp → base <table> (the caller filters by its time)
|
|
191
236
|
-- non-temporal → base <table>
|
|
192
237
|
CREATE OR REPLACE FUNCTION _delta_source_view(
|
|
193
238
|
p_table_name TEXT, p_temporal BOOLEAN, p_at TIMESTAMPTZ DEFAULT NULL
|
|
@@ -197,15 +242,3 @@ CREATE OR REPLACE FUNCTION _delta_source_view(
|
|
|
197
242
|
ELSE p_table_name
|
|
198
243
|
END;
|
|
199
244
|
$$;
|
|
200
|
-
|
|
201
|
-
-- Temporal WHERE fragment for embedding into format()-built dynamic SQL.
|
|
202
|
-
-- Inlines the timestamp as a %L literal so callers don't need extra param binding.
|
|
203
|
-
-- Aliases the row with `p_alias` (default `t`).
|
|
204
|
-
CREATE OR REPLACE FUNCTION _delta_temporal_where(
|
|
205
|
-
p_at TIMESTAMPTZ, p_alias TEXT DEFAULT 't'
|
|
206
|
-
) RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
207
|
-
SELECT format(
|
|
208
|
-
'%s.valid_from <= %L AND (%s.valid_to IS NULL OR %s.valid_to > %L)',
|
|
209
|
-
p_alias, p_at, p_alias, p_alias, p_at
|
|
210
|
-
);
|
|
211
|
-
$$;
|