@pramen/server 0.0.13 → 0.0.15
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/dist/auth.d.ts +17 -2
- package/dist/auth.js +26 -7
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +311 -0
- package/dist/durable-object.d.ts +22 -4
- package/dist/durable-object.js +121 -55
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/pramen.d.ts +6 -0
- package/dist/pramen.js +1 -1
- package/dist/runtime/acl.js +28 -7
- package/dist/runtime/db.d.ts +6 -0
- package/dist/runtime/db.js +86 -10
- package/dist/runtime/ddl.d.ts +16 -3
- package/dist/runtime/ddl.js +28 -8
- package/dist/runtime/dispatch.js +2 -0
- package/dist/runtime/driver.d.ts +41 -7
- package/dist/runtime/driver.js +38 -11
- package/dist/runtime/migrate.d.ts +1 -1
- package/dist/runtime/migrate.js +222 -33
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +71 -0
- package/dist/runtime/queue-consumer.js +63 -0
- package/dist/runtime/queue.d.ts +72 -0
- package/dist/runtime/queue.js +110 -0
- package/dist/runtime/read-engine.js +7 -2
- package/dist/runtime/schema-diff.d.ts +28 -5
- package/dist/runtime/schema-diff.js +111 -19
- package/dist/runtime/storage.d.ts +7 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/sdk/handlers.d.ts +7 -0
- package/dist/worker.d.ts +36 -0
- package/dist/worker.js +128 -18
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +118 -52
- package/src/index.ts +6 -0
- package/src/pramen.ts +7 -1
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +26 -8
- package/src/runtime/dispatch.ts +2 -0
- package/src/runtime/driver.ts +52 -9
- package/src/runtime/migrate.ts +246 -34
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +116 -0
- package/src/runtime/queue.ts +155 -0
- package/src/runtime/read-engine.ts +7 -2
- package/src/runtime/schema-diff.ts +137 -23
- package/src/runtime/storage.ts +0 -0
- package/src/sdk/handlers.ts +7 -0
- package/src/worker.ts +162 -19
package/src/cli.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// pramen CLI — ships as the `pramen` bin of @pramen/server (see package.json `bin`).
|
|
3
|
+
// In-repo it's invoked via `bun run pramen <command>` (scripts/cli.ts is a thin
|
|
4
|
+
// wrapper around this module); published consumers get it as `pramen <command>`.
|
|
5
|
+
//
|
|
6
|
+
// pramen help
|
|
7
|
+
// pramen init [dir]
|
|
8
|
+
// pramen schema sql print CREATE TABLE for the schema
|
|
9
|
+
// pramen schema hash print the schema hash
|
|
10
|
+
// pramen schema snapshot save the current schema to .pramen/schema.json
|
|
11
|
+
// pramen schema diff compare the schema to the snapshot (safe vs unsafe changes)
|
|
12
|
+
// pramen schema status [--tenant t] [--url u] [--token jwt] compare a deployed tenant to the schema
|
|
13
|
+
// pramen token <sub> [roles...] [--tenant a,b] mint a dev JWT
|
|
14
|
+
//
|
|
15
|
+
// The bin uses a `bun` shebang: the `schema *` commands import your app module (a .ts
|
|
16
|
+
// file), and the built package's dist/ uses extensionless ESM imports — both of which
|
|
17
|
+
// bun resolves out of the box. Under plain Node the extensionless imports don't resolve
|
|
18
|
+
// (a property of the whole @pramen/server dist, not just this file), so run via bun.
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, resolve } from "node:path";
|
|
22
|
+
import { createTableSql } from "./runtime/ddl";
|
|
23
|
+
import { schemaHash } from "./runtime/migrate";
|
|
24
|
+
import { diffSchemaShape, schemaShape, type SchemaShape } from "./runtime/schema-diff";
|
|
25
|
+
import { entitiesInPartition, partitionsOf, type SchemaDef } from "./sdk/schema";
|
|
26
|
+
|
|
27
|
+
/** Mint an HS256 JWT — mirrors what a real auth service would issue, for local
|
|
28
|
+
* dev/testing (`pramen token`, and the default token for `schema status`). Signs
|
|
29
|
+
* with AUTH_SECRET when set, else the dev secret from the scaffolded oblaka.ts. */
|
|
30
|
+
const DEV_SECRET = "dev-secret-change-me";
|
|
31
|
+
|
|
32
|
+
function bytesToB64url(bytes: Uint8Array): string {
|
|
33
|
+
let bin = "";
|
|
34
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
35
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
36
|
+
}
|
|
37
|
+
const strToB64url = (s: string) => bytesToB64url(new TextEncoder().encode(s));
|
|
38
|
+
|
|
39
|
+
async function sign(payload: Record<string, unknown>): Promise<string> {
|
|
40
|
+
const secret = process.env.AUTH_SECRET || DEV_SECRET;
|
|
41
|
+
const now = Math.floor(Date.now() / 1000);
|
|
42
|
+
const header = strToB64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
43
|
+
const body = strToB64url(JSON.stringify({ iat: now, exp: now + 3600, ...payload }));
|
|
44
|
+
const data = `${header}.${body}`;
|
|
45
|
+
const key = await crypto.subtle.importKey(
|
|
46
|
+
"raw",
|
|
47
|
+
new TextEncoder().encode(secret),
|
|
48
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
49
|
+
false,
|
|
50
|
+
["sign"],
|
|
51
|
+
);
|
|
52
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
53
|
+
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The sub-schema of a single partition (used to mirror the DO's per-partition hash,
|
|
57
|
+
* which migrate() computes over exactly this subset). For a single-partition app the
|
|
58
|
+
* subset equals the whole schema, so the hash is identical to the unpartitioned case. */
|
|
59
|
+
function partitionSchema(schema: SchemaDef, partition: string): SchemaDef {
|
|
60
|
+
return Object.fromEntries(entitiesInPartition(schema, partition).map((t) => [t, schema[t]!]));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const argv = process.argv.slice(2);
|
|
64
|
+
|
|
65
|
+
function flag(name: string): string | undefined {
|
|
66
|
+
const i = argv.indexOf(`--${name}`);
|
|
67
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
68
|
+
}
|
|
69
|
+
function positionals(args: string[]): string[] {
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
for (let i = 0; i < args.length; i++) {
|
|
72
|
+
if (args[i]!.startsWith("--")) i++; // skip flag + its value
|
|
73
|
+
else out.push(args[i]!);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fail(msg: string): never {
|
|
79
|
+
console.error(`pramen: ${msg}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loadApp(): Promise<{ schema: SchemaDef }> {
|
|
84
|
+
const explicit = flag("app");
|
|
85
|
+
const candidates = explicit ? [explicit] : ["./app.ts", "./example/app.ts"];
|
|
86
|
+
for (const c of candidates) {
|
|
87
|
+
const p = resolve(process.cwd(), c);
|
|
88
|
+
if (existsSync(p)) {
|
|
89
|
+
const mod = (await import(p)) as { app?: { schema?: SchemaDef } };
|
|
90
|
+
if (mod.app?.schema) return mod.app as { schema: SchemaDef };
|
|
91
|
+
fail(`${c} does not export { app }`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return fail(`no app found (looked for ${candidates.join(", ")}); pass --app <path>`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const HELP = `pramen — reactive backend on Cloudflare
|
|
98
|
+
|
|
99
|
+
Usage: pramen <command>
|
|
100
|
+
|
|
101
|
+
help show this help
|
|
102
|
+
init [dir] scaffold a new project (app.ts + worker.ts + oblaka.ts)
|
|
103
|
+
schema sql print CREATE TABLE statements for the schema
|
|
104
|
+
schema hash print the schema hash
|
|
105
|
+
schema snapshot save the schema shape to .pramen/schema.json
|
|
106
|
+
schema diff compare the schema to the snapshot (safe vs unsafe)
|
|
107
|
+
schema status compare a deployed tenant's schema to the local schema
|
|
108
|
+
[--tenant t] [--url u] [--token jwt]
|
|
109
|
+
token <sub> [roles...] mint a dev JWT [--tenant a,b]
|
|
110
|
+
|
|
111
|
+
Flags: --app <path> to point at your app module (default ./app.ts or ./example/app.ts).`;
|
|
112
|
+
|
|
113
|
+
async function schemaCmd(sub: string | undefined): Promise<void> {
|
|
114
|
+
const snapshotPath = resolve(process.cwd(), ".pramen/schema.json");
|
|
115
|
+
|
|
116
|
+
if (sub === "sql") {
|
|
117
|
+
const { schema } = await loadApp();
|
|
118
|
+
for (const [table, def] of Object.entries(schema)) console.log(createTableSql(table, def) + ";");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (sub === "hash") {
|
|
122
|
+
const { schema } = await loadApp();
|
|
123
|
+
console.log(schemaHash(schema));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (sub === "snapshot") {
|
|
127
|
+
const { schema } = await loadApp();
|
|
128
|
+
mkdirSync(dirname(snapshotPath), { recursive: true });
|
|
129
|
+
const snap = { hash: schemaHash(schema), shape: schemaShape(schema) };
|
|
130
|
+
writeFileSync(snapshotPath, JSON.stringify(snap, null, 2) + "\n");
|
|
131
|
+
console.log(`wrote ${snapshotPath} (${Object.keys(snap.shape).length} tables)`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (sub === "diff") {
|
|
135
|
+
const { schema } = await loadApp();
|
|
136
|
+
const next = schemaShape(schema);
|
|
137
|
+
if (!existsSync(snapshotPath)) {
|
|
138
|
+
console.log("no snapshot — run `pramen schema snapshot` to set a baseline.");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const prev = (JSON.parse(readFileSync(snapshotPath, "utf8")) as { shape: SchemaShape }).shape;
|
|
142
|
+
const changes = diffSchemaShape(prev, next);
|
|
143
|
+
if (changes.length === 0) {
|
|
144
|
+
console.log("no changes since snapshot.");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const c of changes) {
|
|
148
|
+
const where = c.column ? `${c.table}.${c.column}` : c.table;
|
|
149
|
+
const note = c.detail ? ` (${c.detail})` : "";
|
|
150
|
+
const glyph = c.destructive ? "⚠" : !c.appliesOnBoot ? "•" : "+";
|
|
151
|
+
const tags =
|
|
152
|
+
(c.destructive ? " [destructive]" : "") + (!c.appliesOnBoot ? " [NOT applied on boot]" : "");
|
|
153
|
+
console.log(` ${glyph} ${c.kind} ${where}${note}${tags}`);
|
|
154
|
+
}
|
|
155
|
+
console.log(
|
|
156
|
+
"\nOn the next DO boot: additive changes (new table/column) auto-apply; destructive changes\n" +
|
|
157
|
+
"(drops, type changes, table rebuilds) are SKIPPED unless the deploy sets PRAMEN_ALLOW_DESTRUCTIVE=true\n" +
|
|
158
|
+
"(the schema hash is then left unwritten so a later opt-in deploy retries).",
|
|
159
|
+
);
|
|
160
|
+
if (changes.some((c) => c.destructive))
|
|
161
|
+
console.log(
|
|
162
|
+
"⚠ destructive changes rebuild the table and CAN lose data. A drop+add is applied as such unless\n" +
|
|
163
|
+
" the column declares `renamedFrom` (which migrates the data).",
|
|
164
|
+
);
|
|
165
|
+
if (changes.some((c) => !c.appliesOnBoot))
|
|
166
|
+
console.log(
|
|
167
|
+
"• [NOT applied on boot] a partition move — the entity's data lives in a different Durable Object,\n" +
|
|
168
|
+
" which needs a manual cross-DO data migration. The boot migrator won't move it; apply it yourself.\n" +
|
|
169
|
+
" (Modifier/constraint changes on an existing column ARE applied on boot now — a tightening one\n" +
|
|
170
|
+
" like adding NOT NULL/UNIQUE is gated by PRAMEN_ALLOW_DESTRUCTIVE and skipped if it can't apply.)",
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (sub === "status") {
|
|
175
|
+
const { schema } = await loadApp();
|
|
176
|
+
const url = flag("url") ?? "http://localhost:8787";
|
|
177
|
+
const tenant = flag("tenant") ?? "main";
|
|
178
|
+
const token = flag("token") ?? (await sign({ sub: "cli", roles: ["admin"] }));
|
|
179
|
+
// Each partition is a distinct Durable Object class, migrated and hashed
|
|
180
|
+
// independently — so compare them one at a time, fetching each partition's applied
|
|
181
|
+
// schema from its DO. A single-(default-)partition app loops exactly once and reads
|
|
182
|
+
// identically to before. The default partition is addressed with no partition param.
|
|
183
|
+
const partitions = partitionsOf(schema);
|
|
184
|
+
console.log(`tenant: ${tenant}`);
|
|
185
|
+
for (const partition of partitions) {
|
|
186
|
+
if (partitions.length > 1) console.log(`\npartition: ${partition}`);
|
|
187
|
+
const qs = partition === "default" ? "" : `&partition=${encodeURIComponent(partition)}`;
|
|
188
|
+
const res = await fetch(`${url}/admin/schema?tenant=${encodeURIComponent(tenant)}${qs}`, {
|
|
189
|
+
headers: { authorization: `Bearer ${token}` },
|
|
190
|
+
});
|
|
191
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
192
|
+
ok?: boolean;
|
|
193
|
+
result?: { hash: string | null; tables: Record<string, string[]> };
|
|
194
|
+
error?: string;
|
|
195
|
+
};
|
|
196
|
+
if (!res.ok || !body.ok || !body.result) fail(`status failed (partition ${partition}): ${body.error ?? res.status}`);
|
|
197
|
+
const live = body.result!;
|
|
198
|
+
// The DO hashes only its partition's entities — mirror that here so the compared
|
|
199
|
+
// hashes line up (for a single partition this equals the whole-schema hash).
|
|
200
|
+
const subset = partitionSchema(schema, partition);
|
|
201
|
+
const current = schemaHash(subset);
|
|
202
|
+
const upToDate = live.hash === current;
|
|
203
|
+
console.log(`live: ${live.hash ?? "(none)"}`);
|
|
204
|
+
console.log(`current: ${current}`);
|
|
205
|
+
console.log(upToDate ? "✓ up to date" : "⚠ BEHIND — migrates on the tenant's next boot");
|
|
206
|
+
const want = schemaShape(subset);
|
|
207
|
+
for (const table of Object.keys(want)) {
|
|
208
|
+
const liveCols = new Set(live.tables[table] ?? []);
|
|
209
|
+
const missing = Object.keys(want[table]!.columns).filter((col) => !liveCols.has(col));
|
|
210
|
+
if (!live.tables[table]) console.log(` + table ${table} (not yet created live)`);
|
|
211
|
+
else if (missing.length) console.log(` + ${table}: ${missing.join(", ")} (not yet added live)`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
console.log(HELP);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function tokenCmd(args: string[]): Promise<void> {
|
|
220
|
+
const pos = positionals(args);
|
|
221
|
+
const sub = pos[0];
|
|
222
|
+
if (!sub) fail("token: <sub> required");
|
|
223
|
+
const roles = pos.slice(1);
|
|
224
|
+
const tenants = flag("tenant")?.split(",");
|
|
225
|
+
console.log(await sign({ sub, roles: roles.length ? roles : ["admin"], ...(tenants ? { tenants } : {}) }));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function initCmd(args: string[]): void {
|
|
229
|
+
const dir = resolve(process.cwd(), positionals(args)[0] ?? ".");
|
|
230
|
+
mkdirSync(dir, { recursive: true });
|
|
231
|
+
const write = (name: string, content: string) => {
|
|
232
|
+
const p = resolve(dir, name);
|
|
233
|
+
if (existsSync(p)) return void console.log(` skip ${name} (exists)`);
|
|
234
|
+
writeFileSync(p, content);
|
|
235
|
+
console.log(` + ${name}`);
|
|
236
|
+
};
|
|
237
|
+
write("app.ts", APP_TEMPLATE);
|
|
238
|
+
write("worker.ts", WORKER_TEMPLATE);
|
|
239
|
+
write("oblaka.ts", OBLAKA_TEMPLATE);
|
|
240
|
+
console.log(`\nScaffolded a pramen project in ${dir}.\n`);
|
|
241
|
+
console.log("Next steps:");
|
|
242
|
+
console.log(" 1. Install deps: bun add @pramen/server && bun add -d oblaka-iac wrangler");
|
|
243
|
+
console.log(" 2. Generate config: bunx oblaka oblaka.ts (writes wrangler.jsonc)");
|
|
244
|
+
console.log(" 3. Run locally: bunx wrangler dev (serves http://localhost:8787)");
|
|
245
|
+
console.log("");
|
|
246
|
+
console.log("First request: POST http://localhost:8787/rpc/listNotes returns [] (not 403) —");
|
|
247
|
+
console.log("the scaffold's ACL grants the anonymous role read+create on `notes`. Tighten it");
|
|
248
|
+
console.log("in app.ts before shipping (see the comments there).");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const APP_TEMPLATE = `import { Entity, defineSchema, createApp, role, policy, allow } from "@pramen/server";
|
|
252
|
+
|
|
253
|
+
const schema = defineSchema({
|
|
254
|
+
notes: Entity((t) => ({ id: t.id(), title: t.text(), body: t.text(), createdAt: t.int() })),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const { query, mutation } = createApp(schema);
|
|
258
|
+
|
|
259
|
+
const handlers = {
|
|
260
|
+
listNotes: query((ctx) => ctx.db.find({ from: "notes", orderBy: { column: "id", dir: "desc" } })),
|
|
261
|
+
createNote: mutation((ctx, input: { title: string; body: string }) =>
|
|
262
|
+
ctx.db.insert("notes", { title: input.title, body: input.body, createdAt: Date.now() }),
|
|
263
|
+
),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// ACL — deny-by-default; roles only GRANT. A caller with no verified token is the
|
|
267
|
+
// \`anonymous\` role, so this grants the scaffold's handlers on the first request (no
|
|
268
|
+
// token needed). TIGHTEN THIS before shipping: gate writes behind an authenticated
|
|
269
|
+
// role and scope reads with \`$identity(...)\` (see @pramen/auth and the pramen docs).
|
|
270
|
+
const acl = [
|
|
271
|
+
role("anonymous", [
|
|
272
|
+
policy("anon:notes:read", "notes", "read", allow()),
|
|
273
|
+
policy("anon:notes:create", "notes", "create", allow()),
|
|
274
|
+
]),
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
export const app = { schema, handlers, acl };
|
|
278
|
+
`;
|
|
279
|
+
|
|
280
|
+
const WORKER_TEMPLATE = `// The whole server entry: hand your app to createPramen and re-export the pair.
|
|
281
|
+
import { createPramen } from "@pramen/server/worker";
|
|
282
|
+
import { app } from "./app";
|
|
283
|
+
|
|
284
|
+
const pramen = createPramen(app);
|
|
285
|
+
|
|
286
|
+
export default { fetch: pramen.fetch };
|
|
287
|
+
export const PramenDO = pramen.PramenDO; // wrangler binds this by class_name
|
|
288
|
+
`;
|
|
289
|
+
|
|
290
|
+
const OBLAKA_TEMPLATE = `import { define, DurableObject, KVNamespace, R2Bucket, Worker } from "oblaka-iac";
|
|
291
|
+
|
|
292
|
+
const PROJECT = "my-pramen-app"; // unique per project — namespaces all CF resources
|
|
293
|
+
|
|
294
|
+
export default define(({ env }) => {
|
|
295
|
+
const vars =
|
|
296
|
+
env === "local" ? { AUTH_SECRET: "dev-secret-change-me", FILES_SECRET: "dev-files-secret-change-me" } : {};
|
|
297
|
+
return new Worker({
|
|
298
|
+
dir: ".",
|
|
299
|
+
name: PROJECT,
|
|
300
|
+
main: "./worker.ts",
|
|
301
|
+
compatibility_date: "2026-06-19",
|
|
302
|
+
compatibility_flags: ["nodejs_compat"],
|
|
303
|
+
observability: { enabled: true },
|
|
304
|
+
bindings: {
|
|
305
|
+
PRAMEN: new DurableObject({ name: PROJECT + "-store", className: "PramenDO" }),
|
|
306
|
+
KV: new KVNamespace({ name: PROJECT + "-kv" }),
|
|
307
|
+
FILES: new R2Bucket({ name: PROJECT + "-files" }),
|
|
308
|
+
},
|
|
309
|
+
vars,
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
async function main(): Promise<void> {
|
|
315
|
+
const cmd = argv[0];
|
|
316
|
+
switch (cmd) {
|
|
317
|
+
case undefined:
|
|
318
|
+
case "help":
|
|
319
|
+
case "-h":
|
|
320
|
+
case "--help":
|
|
321
|
+
console.log(HELP);
|
|
322
|
+
return;
|
|
323
|
+
case "init":
|
|
324
|
+
return initCmd(argv.slice(1));
|
|
325
|
+
case "schema":
|
|
326
|
+
return schemaCmd(argv[1]);
|
|
327
|
+
case "token":
|
|
328
|
+
return tokenCmd(argv.slice(1));
|
|
329
|
+
default:
|
|
330
|
+
console.error(`pramen: unknown command "${cmd}"\n`);
|
|
331
|
+
console.log(HELP);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await main();
|
package/src/durable-object.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { DurableObject } from "cloudflare:workers";
|
|
|
19
19
|
import { migrate } from "./runtime/migrate";
|
|
20
20
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
21
21
|
import { createMail } from "./runtime/mail";
|
|
22
|
+
import { createQueue } from "./runtime/queue";
|
|
22
23
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
23
24
|
import { Db } from "./runtime/db";
|
|
24
25
|
import { digest } from "./runtime/digest";
|
|
@@ -34,14 +35,17 @@ import type { HandlerContext } from "./sdk/handlers";
|
|
|
34
35
|
import type { PramenApp } from "./pramen";
|
|
35
36
|
import type { ClientMsg, ServerMsg, Subscription } from "./runtime/protocol";
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
/** Durable per-socket state — kept SMALL and stable, since it rides the WebSocket
|
|
39
|
+
* attachment which workerd caps at ~2 KB. Only auth/routing identity lives here so it
|
|
40
|
+
* survives hibernation; the (potentially large) subscription list does NOT — see
|
|
41
|
+
* `subsBySocket`. */
|
|
42
|
+
interface SocketAttachment {
|
|
38
43
|
identity: Identity | null;
|
|
39
44
|
/** Tenant fixed at connect time (survives hibernation via the attachment). */
|
|
40
45
|
tenant: string;
|
|
41
46
|
/** Partition fixed at connect time (read from x-pramen-partition at upgrade);
|
|
42
47
|
* survives hibernation via the attachment, like `tenant`. */
|
|
43
48
|
partition: string;
|
|
44
|
-
subs: Subscription[];
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
export interface DoEnv {
|
|
@@ -83,6 +87,14 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
83
87
|
* task context — a DO can't introspect its own idFromName. */
|
|
84
88
|
private identityPersisted = false;
|
|
85
89
|
private identityLoaded = false;
|
|
90
|
+
/** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
|
|
91
|
+
* attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
|
|
92
|
+
* JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
|
|
93
|
+
* tradeoff: this map is lost on DO hibernation/eviction, so a woken socket has no
|
|
94
|
+
* entry and is treated as having no active subscriptions — acceptable because the
|
|
95
|
+
* client replays its subscriptions on (re)connect. Keyed by the WebSocket object;
|
|
96
|
+
* cleaned up in webSocketClose. */
|
|
97
|
+
private readonly subsBySocket = new Map<WebSocket, Subscription[]>();
|
|
86
98
|
|
|
87
99
|
constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp) {
|
|
88
100
|
super(ctx, env);
|
|
@@ -160,7 +172,8 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
160
172
|
if (request.headers.get("Upgrade") === "websocket") {
|
|
161
173
|
const { 0: client, 1: server } = new WebSocketPair();
|
|
162
174
|
this.ctx.acceptWebSocket(server); // hibernatable
|
|
163
|
-
this.
|
|
175
|
+
this.setAttachment(server, { identity, tenant: this.tenant, partition: this.partition });
|
|
176
|
+
this.subsBySocket.set(server, []);
|
|
164
177
|
return new Response(null, { status: 101, webSocket: client });
|
|
165
178
|
}
|
|
166
179
|
|
|
@@ -182,8 +195,11 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
182
195
|
name,
|
|
183
196
|
input,
|
|
184
197
|
);
|
|
185
|
-
|
|
198
|
+
// Arm the drain BEFORE broadcasting so enqueued tasks are always scheduled even
|
|
199
|
+
// if broadcast has trouble (broadcast is best-effort and never throws — a failed
|
|
200
|
+
// push must not 500 a COMMITTED write nor skip the alarm).
|
|
186
201
|
if (enqueued > 0) await this.armDrain();
|
|
202
|
+
if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
|
|
187
203
|
return Response.json({ ok: true, result });
|
|
188
204
|
} catch (err) {
|
|
189
205
|
const { status, body } = toResponse(err);
|
|
@@ -198,15 +214,17 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
198
214
|
}
|
|
199
215
|
|
|
200
216
|
/** A privileged, system-scoped context for running task handlers (outside a request).
|
|
201
|
-
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks.
|
|
202
|
-
|
|
217
|
+
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks.
|
|
218
|
+
* Returns the `db` alongside the ctx so the drainer can broadcast the tables the task
|
|
219
|
+
* handlers touched (live queries would otherwise go stale after deferred/trigger work). */
|
|
220
|
+
private taskCtx(): { ctx: HandlerContext; db: Db } {
|
|
203
221
|
const identity: Identity = { roles: ["admin"] };
|
|
204
222
|
const db = new Db(
|
|
205
223
|
this.driver,
|
|
206
224
|
{ acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true },
|
|
207
225
|
this.app.schema,
|
|
208
226
|
);
|
|
209
|
-
|
|
227
|
+
const ctx: HandlerContext = {
|
|
210
228
|
db,
|
|
211
229
|
kv: this.kv,
|
|
212
230
|
files: this.filesFor(this.tenant),
|
|
@@ -214,7 +232,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
214
232
|
identity,
|
|
215
233
|
tasks: tasksFacade(this.driver),
|
|
216
234
|
mail: createMail(this.envBag, this.kv),
|
|
235
|
+
queue: createQueue(this.envBag),
|
|
217
236
|
};
|
|
237
|
+
return { ctx, db };
|
|
218
238
|
}
|
|
219
239
|
|
|
220
240
|
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
|
@@ -235,23 +255,33 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
235
255
|
}
|
|
236
256
|
|
|
237
257
|
/** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
|
|
238
|
-
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling
|
|
239
|
-
|
|
258
|
+
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling, plus
|
|
259
|
+
* the union of tables the drained task handlers touched (for a post-commit broadcast). */
|
|
260
|
+
private async drainTasks(): Promise<{ result: Awaited<ReturnType<typeof drainOutbox>>; touched: string[] }> {
|
|
240
261
|
await ensureOutbox(this.driver); // idempotent — the table may predate this instance (cold alarm)
|
|
241
|
-
|
|
262
|
+
const { ctx, db } = this.taskCtx();
|
|
263
|
+
const result = await drainOutbox(this.driver, bindTasks(this.app.tasks, ctx), Date.now());
|
|
264
|
+
return { result, touched: [...db.touched] };
|
|
242
265
|
}
|
|
243
266
|
|
|
244
267
|
override async alarm(): Promise<void> {
|
|
245
268
|
await this.loadIdentity(); // cold wake: restore tenant/partition before building taskCtx
|
|
246
|
-
|
|
269
|
+
// A post-deploy cold alarm may run against the old schema — reconcile it first, or a
|
|
270
|
+
// task handler writing a new column dead-letters. loadIdentity() restored the partition.
|
|
271
|
+
await this.ensureMigrated();
|
|
272
|
+
const { result, touched } = await this.drainTasks();
|
|
273
|
+
// Deferred/triggered writes are invisible to live queries unless we broadcast the
|
|
274
|
+
// tables the task handlers touched (post-commit — the drain has already committed).
|
|
275
|
+
if (touched.length > 0) await this.broadcast(touched);
|
|
247
276
|
// Reschedule to the NEXT task's due time (a backed-off retry, or the next batch if
|
|
248
277
|
// the drain hit its limit) so a failed task can't stall waiting for a new enqueue.
|
|
249
|
-
if (nextRunAt != null) await this.ctx.storage.setAlarm(Math.max(nextRunAt, Date.now() + 250));
|
|
278
|
+
if (result.nextRunAt != null) await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
|
|
250
279
|
}
|
|
251
280
|
|
|
252
281
|
private async handleDrain(): Promise<Response> {
|
|
253
282
|
await this.loadIdentity();
|
|
254
|
-
const result = await this.drainTasks();
|
|
283
|
+
const { result, touched } = await this.drainTasks();
|
|
284
|
+
if (touched.length > 0) await this.broadcast(touched);
|
|
255
285
|
// Keep the alarm honest even when drained manually: ensure a backed-off retry wakes.
|
|
256
286
|
if (result.nextRunAt != null) await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
|
|
257
287
|
return Response.json({ ok: true, result });
|
|
@@ -280,7 +310,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
280
310
|
// socket's (tenant, partition) — fixed at connect time, survives via the attachment
|
|
281
311
|
// — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
|
|
282
312
|
// (the `migrated` flag), so a no-op after the first call.
|
|
283
|
-
const { tenant, partition } = this.
|
|
313
|
+
const { tenant, partition } = this.getAttachment(ws);
|
|
284
314
|
this.tenant = tenant;
|
|
285
315
|
this.partition = partition;
|
|
286
316
|
await this.ensureMigrated();
|
|
@@ -288,11 +318,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
288
318
|
switch (msg.type) {
|
|
289
319
|
case "subscribe":
|
|
290
320
|
return this.onSubscribe(ws, msg.id, msg.name, msg.input);
|
|
291
|
-
case "unsubscribe":
|
|
292
|
-
|
|
293
|
-
this.setState(ws, { ...state, subs: state.subs.filter((s) => s.id !== msg.id) });
|
|
321
|
+
case "unsubscribe":
|
|
322
|
+
this.setSubs(ws, this.getSubs(ws).filter((s) => s.id !== msg.id));
|
|
294
323
|
return;
|
|
295
|
-
}
|
|
296
324
|
case "call":
|
|
297
325
|
return this.onCall(ws, msg.id, msg.name, msg.input);
|
|
298
326
|
default:
|
|
@@ -301,6 +329,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
301
329
|
}
|
|
302
330
|
|
|
303
331
|
override async webSocketClose(ws: WebSocket): Promise<void> {
|
|
332
|
+
this.subsBySocket.delete(ws); // release the in-memory subscription list for this socket
|
|
304
333
|
ws.close();
|
|
305
334
|
}
|
|
306
335
|
|
|
@@ -316,19 +345,20 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
316
345
|
// --- live-query internals ---
|
|
317
346
|
|
|
318
347
|
private async onSubscribe(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
|
|
319
|
-
const
|
|
348
|
+
const att = this.getAttachment(ws);
|
|
349
|
+
const subs = this.getSubs(ws);
|
|
320
350
|
try {
|
|
321
|
-
const replacing =
|
|
322
|
-
if (!replacing &&
|
|
351
|
+
const replacing = subs.some((s) => s.id === id);
|
|
352
|
+
if (!replacing && subs.length >= MAX_SUBSCRIPTIONS) {
|
|
323
353
|
return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
|
|
324
354
|
}
|
|
325
|
-
const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(
|
|
355
|
+
const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(att.tenant), this.envBag, this.ctxFor(att.identity, att.partition), name, input);
|
|
326
356
|
if (kind !== "query") {
|
|
327
357
|
return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
|
|
328
358
|
}
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
this.
|
|
359
|
+
const next = subs.filter((s) => s.id !== id);
|
|
360
|
+
next.push({ id, name, input, tables: touched, digest: digest(result) });
|
|
361
|
+
this.setSubs(ws, next);
|
|
332
362
|
this.send(ws, { type: "data", id, result });
|
|
333
363
|
} catch (err) {
|
|
334
364
|
this.send(ws, toWsError(id, err));
|
|
@@ -336,38 +366,57 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
336
366
|
}
|
|
337
367
|
|
|
338
368
|
private async onCall(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
|
|
339
|
-
const
|
|
369
|
+
const att = this.getAttachment(ws);
|
|
370
|
+
let outcome: Awaited<ReturnType<typeof dispatch>>;
|
|
340
371
|
try {
|
|
341
|
-
|
|
342
|
-
this.send(ws, { type: "result", id, result });
|
|
343
|
-
if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
|
|
344
|
-
if (enqueued > 0) await this.armDrain();
|
|
372
|
+
outcome = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(att.tenant), this.envBag, this.ctxFor(att.identity, att.partition), name, input);
|
|
345
373
|
} catch (err) {
|
|
346
|
-
this.send(ws, toWsError(id, err));
|
|
374
|
+
return this.send(ws, toWsError(id, err));
|
|
347
375
|
}
|
|
376
|
+
// The mutation is committed — send its result FIRST, then run post-commit
|
|
377
|
+
// side-effects that must never turn a committed write into a spurious error frame:
|
|
378
|
+
// arm the drain (independent of broadcast), then broadcast (best-effort, never throws).
|
|
379
|
+
const { result, kind, touched, enqueued } = outcome;
|
|
380
|
+
this.send(ws, { type: "result", id, result });
|
|
381
|
+
if (enqueued > 0) await this.armDrain();
|
|
382
|
+
if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
|
|
348
383
|
}
|
|
349
384
|
|
|
350
385
|
// Re-run every subscription whose read-set intersects the written tables, each
|
|
351
|
-
// under its own socket's identity, and push only when its result changed.
|
|
386
|
+
// under its own socket's identity, and push only when its result changed. Best-effort:
|
|
387
|
+
// a failure for one subscription or socket is logged and skipped — it must NEVER throw,
|
|
388
|
+
// because it runs after a mutation has committed (a throw here would 500 that write).
|
|
352
389
|
private async broadcast(touched: string[]): Promise<void> {
|
|
353
390
|
const written = new Set(touched);
|
|
354
391
|
for (const ws of this.ctx.getWebSockets()) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
392
|
+
try {
|
|
393
|
+
const att = this.getAttachment(ws);
|
|
394
|
+
const subs = this.getSubs(ws);
|
|
395
|
+
let dirty = false;
|
|
396
|
+
for (const sub of subs) {
|
|
397
|
+
if (!sub.tables.some((t) => written.has(t))) continue;
|
|
398
|
+
try {
|
|
399
|
+
const { result } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(att.tenant), this.envBag, this.ctxFor(att.identity, att.partition), sub.name, sub.input);
|
|
400
|
+
const next = digest(result);
|
|
401
|
+
if (next === sub.digest) continue; // result unchanged for this subscription
|
|
402
|
+
sub.digest = next;
|
|
403
|
+
dirty = true;
|
|
404
|
+
this.send(ws, { type: "data", id: sub.id, result });
|
|
405
|
+
} catch (err) {
|
|
406
|
+
// One subscription failing (re-dispatch error, or a send to a dead socket)
|
|
407
|
+
// must not abort the other subs — surface it to that sub, swallow otherwise.
|
|
408
|
+
try {
|
|
409
|
+
this.send(ws, toWsError(sub.id, err));
|
|
410
|
+
} catch {
|
|
411
|
+
/* socket already gone */
|
|
412
|
+
}
|
|
413
|
+
}
|
|
368
414
|
}
|
|
415
|
+
if (dirty) this.setSubs(ws, subs);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
// A bad socket must not stop the loop over the others.
|
|
418
|
+
console.error("pramen: broadcast to a socket failed", err);
|
|
369
419
|
}
|
|
370
|
-
if (dirty) this.setState(ws, state);
|
|
371
420
|
}
|
|
372
421
|
}
|
|
373
422
|
|
|
@@ -466,7 +515,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
466
515
|
result = await db.count({ from: table, where: b.where });
|
|
467
516
|
break;
|
|
468
517
|
case "get":
|
|
469
|
-
|
|
518
|
+
// Resolve the PK from the schema — a custom-PK table (e.g. auth_users keyed on
|
|
519
|
+
// `username`) has no `id` column, so a hardcoded `{ id }` would 500.
|
|
520
|
+
result = (await db.find({ from: table, where: { [db.pkOf(table)]: b.id }, limit: 1 }))[0] ?? null;
|
|
470
521
|
break;
|
|
471
522
|
case "create":
|
|
472
523
|
result = await this.driver.transaction(() => db.insert(table, b.values));
|
|
@@ -483,6 +534,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
483
534
|
default:
|
|
484
535
|
return Response.json({ ok: false, error: `unknown op: ${op}`, code: "bad_request" }, { status: 400 });
|
|
485
536
|
}
|
|
537
|
+
// Admin-data writes fire triggers too — arm the drain if any task was enqueued
|
|
538
|
+
// (independent of broadcast), then broadcast the touched tables to live queries.
|
|
539
|
+
if (mutated && db.taskEnqueues > 0) await this.armDrain();
|
|
486
540
|
if (mutated && db.touched.size > 0) await this.broadcast([...db.touched]);
|
|
487
541
|
return Response.json({ ok: true, result });
|
|
488
542
|
} catch (err) {
|
|
@@ -524,19 +578,31 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
524
578
|
}
|
|
525
579
|
}
|
|
526
580
|
|
|
527
|
-
|
|
581
|
+
/** The durable per-socket auth/routing state (identity + tenant + partition), read
|
|
582
|
+
* from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
|
|
583
|
+
private getAttachment(ws: WebSocket): SocketAttachment {
|
|
528
584
|
return (
|
|
529
|
-
(ws.deserializeAttachment() as
|
|
585
|
+
(ws.deserializeAttachment() as SocketAttachment | null) ?? {
|
|
530
586
|
identity: null,
|
|
531
587
|
tenant: this.tenant,
|
|
532
588
|
partition: this.partition,
|
|
533
|
-
subs: [],
|
|
534
589
|
}
|
|
535
590
|
);
|
|
536
591
|
}
|
|
537
592
|
|
|
538
|
-
private
|
|
539
|
-
ws.serializeAttachment(
|
|
593
|
+
private setAttachment(ws: WebSocket, att: SocketAttachment): void {
|
|
594
|
+
ws.serializeAttachment(att);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** This socket's live subscriptions from the in-memory map (see `subsBySocket`). A
|
|
598
|
+
* hibernated/woken socket has no entry → no active subscriptions until the client
|
|
599
|
+
* replays them on reconnect. */
|
|
600
|
+
private getSubs(ws: WebSocket): Subscription[] {
|
|
601
|
+
return this.subsBySocket.get(ws) ?? [];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private setSubs(ws: WebSocket, subs: Subscription[]): void {
|
|
605
|
+
this.subsBySocket.set(ws, subs);
|
|
540
606
|
}
|
|
541
607
|
|
|
542
608
|
private send(ws: WebSocket, msg: ServerMsg): void {
|