dzql 0.7.0 → 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 CHANGED
@@ -12,6 +12,22 @@ bun run db:rebuild
12
12
  bun run dev
13
13
  ```
14
14
 
15
+ ## delta's Postgres step
16
+
17
+ dzql is becoming the Postgres step for apps built on [@blueshed/delta](https://github.com/blueshed/delta):
18
+ a delta app grows from a JSON file to SQLite to Postgres, and on Postgres its documents are kept by
19
+ dzql. Since 0.8.0 dzql carries delta's Postgres backend, with the same names, so moving is an
20
+ import change:
21
+
22
+ ```ts
23
+ import { applyFramework, applySql, createDocListener, docTypeFromDef, generateSql } from "dzql/postgres";
24
+ import { openPglite } from "dzql/pglite"; // Postgres in this process
25
+ import { jwtAuth } from "dzql/auth-jwt";
26
+ ```
27
+
28
+ Its tests run delta's own cases (`@blueshed/delta/testing`) against it. Compiled SQL replaces the
29
+ framework SQL in later releases; the plan is in Workshop's `docs/postgres-split-plan.md`.
30
+
15
31
  ## Documentation
16
32
 
17
33
  See the [full documentation](./docs/README.md) for:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,7 +14,10 @@
14
14
  ".": "./src/runtime/index.ts",
15
15
  "./client": "./src/client/index.ts",
16
16
  "./compiler": "./src/cli/index.ts",
17
- "./namespace": "./src/runtime/namespace.ts"
17
+ "./namespace": "./src/runtime/namespace.ts",
18
+ "./postgres": "./src/postgres/index.ts",
19
+ "./pglite": "./src/postgres/pglite.ts",
20
+ "./auth-jwt": "./src/postgres/auth-jwt.ts"
18
21
  },
19
22
  "files": [
20
23
  "src",
@@ -45,12 +48,16 @@
45
48
  "author": "Peter Bunyan",
46
49
  "license": "MIT",
47
50
  "dependencies": {
48
- "postgres": "^3.4.3",
51
+ "@blueshed/delta": "^0.16.0",
52
+ "@electric-sql/pglite": "^0.5.4",
49
53
  "dotenv": "^16.4.5",
50
- "jose": "^5.2.3"
54
+ "jose": "^6.2.4",
55
+ "pg": "^8.22.0",
56
+ "postgres": "^3.4.3"
51
57
  },
52
58
  "devDependencies": {
53
59
  "@types/bun": "latest",
60
+ "@types/pg": "^8.20.0",
54
61
  "pinia": "^3.0.4",
55
62
  "typescript": "^5.0.0",
56
63
  "vue": "^3.5.26"
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Auth-JWT SQL helpers — pure filesystem access, no jose dependency.
3
+ *
4
+ * These are split out of `auth-jwt.ts` so the `delta init` CLI (which only
5
+ * needs the file path) can run in projects that haven't installed jose.
6
+ * Consumers of the `@blueshed/delta/auth-jwt` subpath still get these via
7
+ * re-export from `auth-jwt.ts`.
8
+ */
9
+ import type { Pool } from "pg";
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ const AUTH_JWT_SQL_PATH = join(import.meta.dir, "..", "sql", "auth-jwt.sql");
14
+
15
+ /** Absolute path to the reference auth-jwt SQL file (users + register + login). */
16
+ export function authJwtSqlFile(): string {
17
+ return AUTH_JWT_SQL_PATH;
18
+ }
19
+
20
+ /** The reference auth-jwt SQL as a string. */
21
+ export function authJwtSql(): string {
22
+ return readFileSync(AUTH_JWT_SQL_PATH, "utf8");
23
+ }
24
+
25
+ /**
26
+ * Apply the reference users schema + login/register stored functions to a
27
+ * pool. Idempotent. Call once at boot alongside `applyFramework(pool)`, or
28
+ * use `delta init --with-auth` to copy the file for docker-entrypoint use.
29
+ */
30
+ export async function applyAuthJwtSchema(pool: Pool): Promise<void> {
31
+ await pool.query(authJwtSql());
32
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Auth-JWT — reference `DeltaAuth` implementation using JWTs (jose) and
3
+ * Postgres stored functions for credential verification.
4
+ *
5
+ * Ship this as an opt-in subpath: `@blueshed/delta/auth-jwt`. Users who want
6
+ * a different auth scheme (sessions, OAuth, magic links, none) implement
7
+ * their own `DeltaAuth` instead.
8
+ *
9
+ * Contract with Postgres:
10
+ * - `login(email, password) RETURNS jsonb` — return a user row or NULL.
11
+ * - `register(name, email, password) RETURNS jsonb` — return a user row,
12
+ * raise unique_violation (23505) on duplicate email.
13
+ *
14
+ * Both are overridable via the `loginSql` / `registerSql` options.
15
+ *
16
+ * import { jwtAuth } from "@blueshed/delta/auth-jwt";
17
+ *
18
+ * const auth = jwtAuth({
19
+ * pool,
20
+ * secret: process.env.JWT_SECRET!,
21
+ * });
22
+ *
23
+ * wireAuth(ws, auth);
24
+ * await createDocListener(ws, pool, { auth });
25
+ *
26
+ * SQL-file helpers (`authJwtSqlFile`, `authJwtSql`, `applyAuthJwtSchema`) live
27
+ * in `./auth-jwt-sql.ts` and are re-exported from here for convenience. They
28
+ * have no jose dependency, so the `delta init` CLI can load them without
29
+ * forcing consumers to install jose just to copy SQL files.
30
+ */
31
+ import { SignJWT, jwtVerify, decodeJwt } from "jose";
32
+ import type { Pool } from "pg";
33
+ import { createLogger } from "@blueshed/delta/logger";
34
+ import type { DeltaAuth } from "@blueshed/delta/auth";
35
+ import { dropClientSubscriptions } from "@blueshed/delta/backend";
36
+
37
+ /** Algorithm this module signs with — pinned on verify to prevent alg-confusion. */
38
+ const JWT_ALG = "HS256";
39
+
40
+ export {
41
+ authJwtSqlFile,
42
+ authJwtSql,
43
+ applyAuthJwtSchema,
44
+ } from "./auth-jwt-sql";
45
+
46
+ const log = createLogger("[auth-jwt]");
47
+
48
+ export interface User {
49
+ id: number | string;
50
+ name?: string;
51
+ email?: string;
52
+ [key: string]: unknown;
53
+ }
54
+
55
+ export interface JwtAuthOpts {
56
+ pool: Pool;
57
+ secret: string | Uint8Array;
58
+ expirationTime?: string;
59
+ loginSql?: string;
60
+ registerSql?: string;
61
+ /**
62
+ * Optional re-validation hook for `authenticate`. A JWT is stateless: by
63
+ * default a signed, unexpired token grants access even if the user was
64
+ * since deleted or disabled (the documented trade-off of stateless auth).
65
+ * Provide `verifyUser` to re-check the identity against the database on
66
+ * each `authenticate`; return the (possibly refreshed) user to allow, or
67
+ * `null` to reject. Not called on `login` / `register` (those just hit the DB).
68
+ */
69
+ verifyUser?: (user: User, pool: Pool) => Promise<User | null>;
70
+ }
71
+
72
+ /** Set the socket's identity, tearing down a prior *different* identity's
73
+ * live subscriptions so an identity switch on one socket can't keep
74
+ * receiving the previous user's scoped docs. `expires` is when the token it
75
+ * came from runs out (ms since the epoch); `gate` refuses it after that. */
76
+ function switchIdentity(client: any, user: User, expires: number | undefined): void {
77
+ const prev = client.data?.identity as User | undefined;
78
+ if (prev && String(prev.id) !== String(user.id)) {
79
+ dropClientSubscriptions(client);
80
+ }
81
+ if (!client.data) client.data = {};
82
+ client.data.identity = user;
83
+ client.data.identityExpires = expires;
84
+ }
85
+
86
+ /** When a token's `exp` says it runs out, in ms since the epoch. */
87
+ const expiresOf = (exp: unknown): number | undefined => (typeof exp === "number" ? exp * 1000 : undefined);
88
+
89
+ export function jwtAuth(opts: JwtAuthOpts): DeltaAuth<User> {
90
+ const secret =
91
+ typeof opts.secret === "string"
92
+ ? new TextEncoder().encode(opts.secret)
93
+ : opts.secret;
94
+ const expirationTime = opts.expirationTime ?? "7d";
95
+ const loginSql = opts.loginSql ?? "SELECT login($1, $2) AS result";
96
+ const registerSql = opts.registerSql ?? "SELECT register($1, $2, $3) AS result";
97
+
98
+ async function signToken(user: User): Promise<string> {
99
+ return new SignJWT({
100
+ sub: String(user.id),
101
+ name: user.name,
102
+ email: user.email,
103
+ })
104
+ .setProtectedHeader({ alg: JWT_ALG })
105
+ .setIssuedAt()
106
+ .setExpirationTime(expirationTime)
107
+ .sign(secret);
108
+ }
109
+
110
+ async function verifyToken(token: string): Promise<{ user: User; expires: number | undefined } | null> {
111
+ try {
112
+ // Pin the algorithm allowlist — without it, jose accepts any alg the
113
+ // token header claims, opening an alg-confusion vector.
114
+ const { payload } = await jwtVerify(token, secret, { algorithms: [JWT_ALG] });
115
+ const id = payload.sub;
116
+ if (id === undefined) return null;
117
+ const user: User = {
118
+ id: /^\d+$/.test(String(id)) ? Number(id) : String(id),
119
+ name: payload.name as string | undefined,
120
+ email: payload.email as string | undefined,
121
+ };
122
+ return { user, expires: expiresOf(payload.exp) };
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ return {
129
+ actions: {
130
+ async login(params, client) {
131
+ const { email, password } = params ?? {};
132
+ if (!email || !password) return { error: "email and password required" };
133
+ const { rows } = await opts.pool.query(loginSql, [email, password]);
134
+ const user = rows[0]?.result as User | null;
135
+ if (!user) return { error: "Invalid credentials" };
136
+ const token = await signToken(user);
137
+ switchIdentity(client, user, expiresOf(decodeJwt(token).exp));
138
+ log.info(`login user=${user.id}`);
139
+ return { result: { ...user, token } };
140
+ },
141
+
142
+ async register(params, client) {
143
+ const { name, email, password } = params ?? {};
144
+ if (!name || !email || !password) {
145
+ return { error: "name, email, and password required" };
146
+ }
147
+ try {
148
+ const { rows } = await opts.pool.query(registerSql, [name, email, password]);
149
+ const user = rows[0]?.result as User;
150
+ const token = await signToken(user);
151
+ switchIdentity(client, user, expiresOf(decodeJwt(token).exp));
152
+ log.info(`register user=${user.id}`);
153
+ return { result: { ...user, token } };
154
+ } catch (err: any) {
155
+ if (err.code === "23505") return { error: "Email already registered" };
156
+ throw err;
157
+ }
158
+ },
159
+
160
+ async authenticate(params, client) {
161
+ const { token } = params ?? {};
162
+ if (!token) return { error: "token required" };
163
+ const verified = await verifyToken(token);
164
+ if (!verified) return { error: "Invalid token" };
165
+ let user = verified.user;
166
+ if (opts.verifyUser) {
167
+ const checked = await opts.verifyUser(user, opts.pool);
168
+ if (!checked) return { error: "Invalid token" };
169
+ user = checked;
170
+ }
171
+ switchIdentity(client, user, verified.expires);
172
+ log.info(`authenticate user=${user.id}`);
173
+ return { result: user };
174
+ },
175
+
176
+ async logout(_params, client) {
177
+ const prev = client.data?.identity as User | undefined;
178
+ if (client.data) { delete client.data.identity; delete client.data.identityExpires; }
179
+ // Tear down the prior identity's live doc subscriptions so its scoped
180
+ // ops stop streaming to this socket immediately.
181
+ dropClientSubscriptions(client);
182
+ if (prev) log.info(`logout user=${prev.id}`);
183
+ return { result: { ack: true } };
184
+ },
185
+ },
186
+
187
+ gate(client) {
188
+ // A session lasts as long as its token: once it runs out the socket is
189
+ // signed out (and its documents let go), as a logout would do, and says
190
+ // so until it signs in again -- the listener asks the gate before it
191
+ // tells a socket of a change, too, so that is not always a request.
192
+ const expires = client.data?.identityExpires as number | undefined;
193
+ if (expires !== undefined && Date.now() >= expires) {
194
+ if (client.data.identity !== undefined) {
195
+ delete client.data.identity;
196
+ dropClientSubscriptions(client);
197
+ }
198
+ return { error: "Session expired: authenticate again" };
199
+ }
200
+ const identity = client.data?.identity as User | undefined;
201
+ if (!identity) return { error: "Authentication required" };
202
+ return identity;
203
+ },
204
+
205
+ asSqlArg(identity) {
206
+ return identity.id;
207
+ },
208
+ };
209
+ }
@@ -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";