@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.
Files changed (53) hide show
  1. package/dist/auth.d.ts +17 -2
  2. package/dist/auth.js +26 -7
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +311 -0
  5. package/dist/durable-object.d.ts +22 -4
  6. package/dist/durable-object.js +121 -55
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.js +3 -0
  9. package/dist/pramen.d.ts +6 -0
  10. package/dist/pramen.js +1 -1
  11. package/dist/runtime/acl.js +28 -7
  12. package/dist/runtime/db.d.ts +6 -0
  13. package/dist/runtime/db.js +86 -10
  14. package/dist/runtime/ddl.d.ts +16 -3
  15. package/dist/runtime/ddl.js +28 -8
  16. package/dist/runtime/dispatch.js +2 -0
  17. package/dist/runtime/driver.d.ts +41 -7
  18. package/dist/runtime/driver.js +38 -11
  19. package/dist/runtime/migrate.d.ts +1 -1
  20. package/dist/runtime/migrate.js +222 -33
  21. package/dist/runtime/outbox.js +28 -6
  22. package/dist/runtime/queue-consumer.d.ts +71 -0
  23. package/dist/runtime/queue-consumer.js +63 -0
  24. package/dist/runtime/queue.d.ts +72 -0
  25. package/dist/runtime/queue.js +110 -0
  26. package/dist/runtime/read-engine.js +7 -2
  27. package/dist/runtime/schema-diff.d.ts +28 -5
  28. package/dist/runtime/schema-diff.js +111 -19
  29. package/dist/runtime/storage.d.ts +7 -0
  30. package/dist/runtime/storage.js +0 -0
  31. package/dist/sdk/handlers.d.ts +7 -0
  32. package/dist/worker.d.ts +36 -0
  33. package/dist/worker.js +128 -18
  34. package/package.json +6 -2
  35. package/src/auth.ts +64 -21
  36. package/src/cli.ts +336 -0
  37. package/src/durable-object.ts +118 -52
  38. package/src/index.ts +6 -0
  39. package/src/pramen.ts +7 -1
  40. package/src/runtime/acl.ts +25 -5
  41. package/src/runtime/db.ts +80 -9
  42. package/src/runtime/ddl.ts +26 -8
  43. package/src/runtime/dispatch.ts +2 -0
  44. package/src/runtime/driver.ts +52 -9
  45. package/src/runtime/migrate.ts +246 -34
  46. package/src/runtime/outbox.ts +30 -7
  47. package/src/runtime/queue-consumer.ts +116 -0
  48. package/src/runtime/queue.ts +155 -0
  49. package/src/runtime/read-engine.ts +7 -2
  50. package/src/runtime/schema-diff.ts +137 -23
  51. package/src/runtime/storage.ts +0 -0
  52. package/src/sdk/handlers.ts +7 -0
  53. 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();
@@ -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
- interface SocketState {
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.setState(server, { identity, tenant: this.tenant, partition: this.partition, subs: [] });
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
- if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
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
- private taskCtx(): HandlerContext {
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
- return {
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
- private async drainTasks(): Promise<Awaited<ReturnType<typeof drainOutbox>>> {
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
- return drainOutbox(this.driver, bindTasks(this.app.tasks, this.taskCtx()), Date.now());
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
- const { nextRunAt } = await this.drainTasks();
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.getState(ws);
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
- const state = this.getState(ws);
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 state = this.getState(ws);
348
+ const att = this.getAttachment(ws);
349
+ const subs = this.getSubs(ws);
320
350
  try {
321
- const replacing = state.subs.some((s) => s.id === id);
322
- if (!replacing && state.subs.length >= MAX_SUBSCRIPTIONS) {
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(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), name, input);
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 subs = state.subs.filter((s) => s.id !== id);
330
- subs.push({ id, name, input, tables: touched, digest: digest(result) });
331
- this.setState(ws, { ...state, subs });
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 state = this.getState(ws);
369
+ const att = this.getAttachment(ws);
370
+ let outcome: Awaited<ReturnType<typeof dispatch>>;
340
371
  try {
341
- const { result, kind, touched, enqueued } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), name, input);
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
- const state = this.getState(ws);
356
- let dirty = false;
357
- for (const sub of state.subs) {
358
- if (!sub.tables.some((t) => written.has(t))) continue;
359
- try {
360
- const { result } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), sub.name, sub.input);
361
- const next = digest(result);
362
- if (next === sub.digest) continue; // result unchanged for this subscription
363
- sub.digest = next;
364
- dirty = true;
365
- this.send(ws, { type: "data", id: sub.id, result });
366
- } catch (err) {
367
- this.send(ws, toWsError(sub.id, err));
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
- result = (await db.find({ from: table, where: { id: b.id }, limit: 1 }))[0] ?? null;
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
- private getState(ws: WebSocket): SocketState {
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 SocketState | null) ?? {
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 setState(ws: WebSocket, state: SocketState): void {
539
- ws.serializeAttachment(state);
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 {