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 +16 -0
- package/package.json +11 -4
- 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/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
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.
|
|
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
|
-
"
|
|
51
|
+
"@blueshed/delta": "^0.16.0",
|
|
52
|
+
"@electric-sql/pglite": "^0.5.4",
|
|
49
53
|
"dotenv": "^16.4.5",
|
|
50
|
-
"jose": "^
|
|
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";
|