dzql 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "delta's compiled Postgres step: an app's delta definitions compiled to SQL",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,6 +18,7 @@
18
18
  "./postgres": "./src/postgres/index.ts",
19
19
  "./pglite": "./src/postgres/pglite.ts",
20
20
  "./auth-jwt": "./src/postgres/auth-jwt.ts",
21
+ "./auth-link": "./src/postgres/auth-link.ts",
21
22
  "./compile": "./src/compile/opens.ts"
22
23
  },
23
24
  "files": [
@@ -63,11 +64,11 @@
63
64
  "pinia": "^3.0.4",
64
65
  "typescript": "^5.0.0",
65
66
  "vue": "^3.5.26",
66
- "@blueshed/delta": "^1.0.0"
67
+ "@blueshed/delta": "^1.1.0"
67
68
  },
68
69
  "peerDependencies": {
69
70
  "typescript": "^5.0.0",
70
- "@blueshed/delta": "^1.0.0"
71
+ "@blueshed/delta": "^1.1.0"
71
72
  },
72
73
  "engines": {
73
74
  "bun": ">=1.0.0"
package/src/cli/index.ts CHANGED
@@ -15,8 +15,8 @@ const VERSION = packageJson.version;
15
15
 
16
16
  const args = process.argv.slice(2);
17
17
 
18
- // delta's Postgres commands (dzql is delta's Postgres step): sql, init, migrate
19
- if (["sql", "init", "migrate"].includes(args[0] ?? "")) {
18
+ // delta's Postgres commands (dzql is delta's Postgres step): sql, init, migrate, link
19
+ if (["sql", "init", "migrate", "link"].includes(args[0] ?? "")) {
20
20
  const { main } = await import("../postgres/cli.ts");
21
21
  await main(args);
22
22
  process.exit(0);
@@ -44,8 +44,16 @@ RETURNS JSONB AS $$
44
44
  DECLARE
45
45
  v_row JSONB;
46
46
  BEGIN
47
- ${dispatch(schema, (_, t) => `SELECT to_jsonb(t.*) INTO v_row FROM ${q(t.name)} t WHERE t.id = p_id${t.temporal ? " AND valid_to IS NULL" : ""} FOR UPDATE;
48
- RETURN v_row;`, noCollection("p_coll"))}
47
+ ${dispatch(schema, (_, t) => {
48
+ const standing = `t.id = p_id${t.temporal ? " AND t.valid_to IS NULL" : ""}`;
49
+ // A write that waited on another's lock finds the row it waited on stored again (a new version,
50
+ // or put again): the row it locked is not the standing one. Asked afresh (each statement sees what
51
+ // has committed since), a standing row is locked in its turn; none standing is not there.
52
+ return `LOOP
53
+ SELECT to_jsonb(t.*) INTO v_row FROM ${q(t.name)} t WHERE ${standing} FOR UPDATE;
54
+ IF v_row IS NOT NULL OR NOT EXISTS (SELECT 1 FROM ${q(t.name)} t WHERE ${standing}) THEN RETURN v_row; END IF;
55
+ END LOOP;`;
56
+ }, noCollection("p_coll"))}
49
57
  END;
50
58
  $$ LANGUAGE plpgsql;`;
51
59
 
@@ -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
+ }
@@ -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
- * and `dzql migrate` (a postgres:// URL or a PGlite directory). Run through
5
- * `dzql <command>` (src/cli/index.ts hands these three here).
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
  }