@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
@@ -18,6 +18,7 @@ import { DurableObject } from "cloudflare:workers";
18
18
  import { migrate } from "./runtime/migrate";
19
19
  import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
20
20
  import { createMail } from "./runtime/mail";
21
+ import { createQueue } from "./runtime/queue";
21
22
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
22
23
  import { Db } from "./runtime/db";
23
24
  import { digest } from "./runtime/digest";
@@ -52,6 +53,14 @@ export class PramenDOBase extends DurableObject {
52
53
  * task context — a DO can't introspect its own idFromName. */
53
54
  identityPersisted = false;
54
55
  identityLoaded = false;
56
+ /** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
57
+ * attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
58
+ * JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
59
+ * tradeoff: this map is lost on DO hibernation/eviction, so a woken socket has no
60
+ * entry and is treated as having no active subscriptions — acceptable because the
61
+ * client replays its subscriptions on (re)connect. Keyed by the WebSocket object;
62
+ * cleaned up in webSocketClose. */
63
+ subsBySocket = new Map();
55
64
  constructor(ctx, env, app) {
56
65
  super(ctx, env);
57
66
  this.app = app;
@@ -124,7 +133,8 @@ export class PramenDOBase extends DurableObject {
124
133
  if (request.headers.get("Upgrade") === "websocket") {
125
134
  const { 0: client, 1: server } = new WebSocketPair();
126
135
  this.ctx.acceptWebSocket(server); // hibernatable
127
- this.setState(server, { identity, tenant: this.tenant, partition: this.partition, subs: [] });
136
+ this.setAttachment(server, { identity, tenant: this.tenant, partition: this.partition });
137
+ this.subsBySocket.set(server, []);
128
138
  return new Response(null, { status: 101, webSocket: client });
129
139
  }
130
140
  const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
@@ -134,10 +144,13 @@ export class PramenDOBase extends DurableObject {
134
144
  }
135
145
  try {
136
146
  const { result, kind, touched, enqueued } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(this.tenant), this.envBag, this.ctxFor(identity), name, input);
137
- if (kind === "mutation" && touched.length > 0)
138
- await this.broadcast(touched);
147
+ // Arm the drain BEFORE broadcasting so enqueued tasks are always scheduled even
148
+ // if broadcast has trouble (broadcast is best-effort and never throws — a failed
149
+ // push must not 500 a COMMITTED write nor skip the alarm).
139
150
  if (enqueued > 0)
140
151
  await this.armDrain();
152
+ if (kind === "mutation" && touched.length > 0)
153
+ await this.broadcast(touched);
141
154
  return Response.json({ ok: true, result });
142
155
  }
143
156
  catch (err) {
@@ -151,11 +164,13 @@ export class PramenDOBase extends DurableObject {
151
164
  await this.ctx.storage.setAlarm(Date.now() + 50);
152
165
  }
153
166
  /** A privileged, system-scoped context for running task handlers (outside a request).
154
- * Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks. */
167
+ * Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks.
168
+ * Returns the `db` alongside the ctx so the drainer can broadcast the tables the task
169
+ * handlers touched (live queries would otherwise go stale after deferred/trigger work). */
155
170
  taskCtx() {
156
171
  const identity = { roles: ["admin"] };
157
172
  const db = new Db(this.driver, { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true }, this.app.schema);
158
- return {
173
+ const ctx = {
159
174
  db,
160
175
  kv: this.kv,
161
176
  files: this.filesFor(this.tenant),
@@ -163,7 +178,9 @@ export class PramenDOBase extends DurableObject {
163
178
  identity,
164
179
  tasks: tasksFacade(this.driver),
165
180
  mail: createMail(this.envBag, this.kv),
181
+ queue: createQueue(this.envBag),
166
182
  };
183
+ return { ctx, db };
167
184
  }
168
185
  /** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
169
186
  * task context is scoped correctly. No-op once loaded/persisted this instance. */
@@ -182,22 +199,34 @@ export class PramenDOBase extends DurableObject {
182
199
  this.identityLoaded = true;
183
200
  }
184
201
  /** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
185
- * (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling. */
202
+ * (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling, plus
203
+ * the union of tables the drained task handlers touched (for a post-commit broadcast). */
186
204
  async drainTasks() {
187
205
  await ensureOutbox(this.driver); // idempotent — the table may predate this instance (cold alarm)
188
- return drainOutbox(this.driver, bindTasks(this.app.tasks, this.taskCtx()), Date.now());
206
+ const { ctx, db } = this.taskCtx();
207
+ const result = await drainOutbox(this.driver, bindTasks(this.app.tasks, ctx), Date.now());
208
+ return { result, touched: [...db.touched] };
189
209
  }
190
210
  async alarm() {
191
211
  await this.loadIdentity(); // cold wake: restore tenant/partition before building taskCtx
192
- const { nextRunAt } = await this.drainTasks();
212
+ // A post-deploy cold alarm may run against the old schema — reconcile it first, or a
213
+ // task handler writing a new column dead-letters. loadIdentity() restored the partition.
214
+ await this.ensureMigrated();
215
+ const { result, touched } = await this.drainTasks();
216
+ // Deferred/triggered writes are invisible to live queries unless we broadcast the
217
+ // tables the task handlers touched (post-commit — the drain has already committed).
218
+ if (touched.length > 0)
219
+ await this.broadcast(touched);
193
220
  // Reschedule to the NEXT task's due time (a backed-off retry, or the next batch if
194
221
  // the drain hit its limit) so a failed task can't stall waiting for a new enqueue.
195
- if (nextRunAt != null)
196
- await this.ctx.storage.setAlarm(Math.max(nextRunAt, Date.now() + 250));
222
+ if (result.nextRunAt != null)
223
+ await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
197
224
  }
198
225
  async handleDrain() {
199
226
  await this.loadIdentity();
200
- const result = await this.drainTasks();
227
+ const { result, touched } = await this.drainTasks();
228
+ if (touched.length > 0)
229
+ await this.broadcast(touched);
201
230
  // Keep the alarm honest even when drained manually: ensure a backed-off retry wakes.
202
231
  if (result.nextRunAt != null)
203
232
  await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
@@ -224,18 +253,16 @@ export class PramenDOBase extends DurableObject {
224
253
  // socket's (tenant, partition) — fixed at connect time, survives via the attachment
225
254
  // — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
226
255
  // (the `migrated` flag), so a no-op after the first call.
227
- const { tenant, partition } = this.getState(ws);
256
+ const { tenant, partition } = this.getAttachment(ws);
228
257
  this.tenant = tenant;
229
258
  this.partition = partition;
230
259
  await this.ensureMigrated();
231
260
  switch (msg.type) {
232
261
  case "subscribe":
233
262
  return this.onSubscribe(ws, msg.id, msg.name, msg.input);
234
- case "unsubscribe": {
235
- const state = this.getState(ws);
236
- this.setState(ws, { ...state, subs: state.subs.filter((s) => s.id !== msg.id) });
263
+ case "unsubscribe":
264
+ this.setSubs(ws, this.getSubs(ws).filter((s) => s.id !== msg.id));
237
265
  return;
238
- }
239
266
  case "call":
240
267
  return this.onCall(ws, msg.id, msg.name, msg.input);
241
268
  default:
@@ -243,6 +270,7 @@ export class PramenDOBase extends DurableObject {
243
270
  }
244
271
  }
245
272
  async webSocketClose(ws) {
273
+ this.subsBySocket.delete(ws); // release the in-memory subscription list for this socket
246
274
  ws.close();
247
275
  }
248
276
  async webSocketError(ws, error) {
@@ -256,19 +284,20 @@ export class PramenDOBase extends DurableObject {
256
284
  }
257
285
  // --- live-query internals ---
258
286
  async onSubscribe(ws, id, name, input) {
259
- const state = this.getState(ws);
287
+ const att = this.getAttachment(ws);
288
+ const subs = this.getSubs(ws);
260
289
  try {
261
- const replacing = state.subs.some((s) => s.id === id);
262
- if (!replacing && state.subs.length >= MAX_SUBSCRIPTIONS) {
290
+ const replacing = subs.some((s) => s.id === id);
291
+ if (!replacing && subs.length >= MAX_SUBSCRIPTIONS) {
263
292
  return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
264
293
  }
265
- 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);
294
+ 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);
266
295
  if (kind !== "query") {
267
296
  return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
268
297
  }
269
- const subs = state.subs.filter((s) => s.id !== id);
270
- subs.push({ id, name, input, tables: touched, digest: digest(result) });
271
- this.setState(ws, { ...state, subs });
298
+ const next = subs.filter((s) => s.id !== id);
299
+ next.push({ id, name, input, tables: touched, digest: digest(result) });
300
+ this.setSubs(ws, next);
272
301
  this.send(ws, { type: "data", id, result });
273
302
  }
274
303
  catch (err) {
@@ -276,44 +305,65 @@ export class PramenDOBase extends DurableObject {
276
305
  }
277
306
  }
278
307
  async onCall(ws, id, name, input) {
279
- const state = this.getState(ws);
308
+ const att = this.getAttachment(ws);
309
+ let outcome;
280
310
  try {
281
- 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);
282
- this.send(ws, { type: "result", id, result });
283
- if (kind === "mutation" && touched.length > 0)
284
- await this.broadcast(touched);
285
- if (enqueued > 0)
286
- await this.armDrain();
311
+ 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);
287
312
  }
288
313
  catch (err) {
289
- this.send(ws, toWsError(id, err));
314
+ return this.send(ws, toWsError(id, err));
290
315
  }
316
+ // The mutation is committed — send its result FIRST, then run post-commit
317
+ // side-effects that must never turn a committed write into a spurious error frame:
318
+ // arm the drain (independent of broadcast), then broadcast (best-effort, never throws).
319
+ const { result, kind, touched, enqueued } = outcome;
320
+ this.send(ws, { type: "result", id, result });
321
+ if (enqueued > 0)
322
+ await this.armDrain();
323
+ if (kind === "mutation" && touched.length > 0)
324
+ await this.broadcast(touched);
291
325
  }
292
326
  // Re-run every subscription whose read-set intersects the written tables, each
293
- // under its own socket's identity, and push only when its result changed.
327
+ // under its own socket's identity, and push only when its result changed. Best-effort:
328
+ // a failure for one subscription or socket is logged and skipped — it must NEVER throw,
329
+ // because it runs after a mutation has committed (a throw here would 500 that write).
294
330
  async broadcast(touched) {
295
331
  const written = new Set(touched);
296
332
  for (const ws of this.ctx.getWebSockets()) {
297
- const state = this.getState(ws);
298
- let dirty = false;
299
- for (const sub of state.subs) {
300
- if (!sub.tables.some((t) => written.has(t)))
301
- continue;
302
- try {
303
- 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);
304
- const next = digest(result);
305
- if (next === sub.digest)
306
- continue; // result unchanged for this subscription
307
- sub.digest = next;
308
- dirty = true;
309
- this.send(ws, { type: "data", id: sub.id, result });
310
- }
311
- catch (err) {
312
- this.send(ws, toWsError(sub.id, err));
333
+ try {
334
+ const att = this.getAttachment(ws);
335
+ const subs = this.getSubs(ws);
336
+ let dirty = false;
337
+ for (const sub of subs) {
338
+ if (!sub.tables.some((t) => written.has(t)))
339
+ continue;
340
+ try {
341
+ 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);
342
+ const next = digest(result);
343
+ if (next === sub.digest)
344
+ continue; // result unchanged for this subscription
345
+ sub.digest = next;
346
+ dirty = true;
347
+ this.send(ws, { type: "data", id: sub.id, result });
348
+ }
349
+ catch (err) {
350
+ // One subscription failing (re-dispatch error, or a send to a dead socket)
351
+ // must not abort the other subs — surface it to that sub, swallow otherwise.
352
+ try {
353
+ this.send(ws, toWsError(sub.id, err));
354
+ }
355
+ catch {
356
+ /* socket already gone */
357
+ }
358
+ }
313
359
  }
360
+ if (dirty)
361
+ this.setSubs(ws, subs);
362
+ }
363
+ catch (err) {
364
+ // A bad socket must not stop the loop over the others.
365
+ console.error("pramen: broadcast to a socket failed", err);
314
366
  }
315
- if (dirty)
316
- this.setState(ws, state);
317
367
  }
318
368
  }
319
369
  // --- helpers ---
@@ -401,7 +451,9 @@ export class PramenDOBase extends DurableObject {
401
451
  result = await db.count({ from: table, where: b.where });
402
452
  break;
403
453
  case "get":
404
- result = (await db.find({ from: table, where: { id: b.id }, limit: 1 }))[0] ?? null;
454
+ // Resolve the PK from the schema a custom-PK table (e.g. auth_users keyed on
455
+ // `username`) has no `id` column, so a hardcoded `{ id }` would 500.
456
+ result = (await db.find({ from: table, where: { [db.pkOf(table)]: b.id }, limit: 1 }))[0] ?? null;
405
457
  break;
406
458
  case "create":
407
459
  result = await this.driver.transaction(() => db.insert(table, b.values));
@@ -418,6 +470,10 @@ export class PramenDOBase extends DurableObject {
418
470
  default:
419
471
  return Response.json({ ok: false, error: `unknown op: ${op}`, code: "bad_request" }, { status: 400 });
420
472
  }
473
+ // Admin-data writes fire triggers too — arm the drain if any task was enqueued
474
+ // (independent of broadcast), then broadcast the touched tables to live queries.
475
+ if (mutated && db.taskEnqueues > 0)
476
+ await this.armDrain();
421
477
  if (mutated && db.touched.size > 0)
422
478
  await this.broadcast([...db.touched]);
423
479
  return Response.json({ ok: true, result });
@@ -458,16 +514,26 @@ export class PramenDOBase extends DurableObject {
458
514
  return null;
459
515
  }
460
516
  }
461
- getState(ws) {
517
+ /** The durable per-socket auth/routing state (identity + tenant + partition), read
518
+ * from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
519
+ getAttachment(ws) {
462
520
  return (ws.deserializeAttachment() ?? {
463
521
  identity: null,
464
522
  tenant: this.tenant,
465
523
  partition: this.partition,
466
- subs: [],
467
524
  });
468
525
  }
469
- setState(ws, state) {
470
- ws.serializeAttachment(state);
526
+ setAttachment(ws, att) {
527
+ ws.serializeAttachment(att);
528
+ }
529
+ /** This socket's live subscriptions from the in-memory map (see `subsBySocket`). A
530
+ * hibernated/woken socket has no entry → no active subscriptions until the client
531
+ * replays them on reconnect. */
532
+ getSubs(ws) {
533
+ return this.subsBySocket.get(ws) ?? [];
534
+ }
535
+ setSubs(ws, subs) {
536
+ this.subsBySocket.set(ws, subs);
471
537
  }
472
538
  send(ws, msg) {
473
539
  ws.send(JSON.stringify(msg));
package/dist/index.d.ts CHANGED
@@ -13,6 +13,10 @@ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runt
13
13
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
14
14
  export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
15
15
  export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
16
+ export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
17
+ export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
18
+ export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
19
+ export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
16
20
  export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
17
21
  export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
18
22
  export type { Driver, Dialect, Row } from "./runtime/driver";
package/dist/index.js CHANGED
@@ -17,6 +17,9 @@ export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny,
17
17
  export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
18
18
  // --- mail (ctx.mail) ---
19
19
  export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
20
+ // --- queue (ctx.queue — Cloudflare Queues) ---
21
+ export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
22
+ export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
20
23
  // --- errors ---
21
24
  export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
22
25
  // --- substrate seam (advanced: bring your own SQL backend) ---
package/dist/pramen.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type Env } from "./worker";
2
2
  import { pramenDO, type DoEnv } from "./durable-object";
3
3
  import { type SchemaDef } from "./sdk/schema";
4
4
  import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
5
+ import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
5
6
  import type { Role } from "./sdk/acl";
6
7
  /** Injected into a public route's handler — forward a privileged mutation into the
7
8
  * tenant's DO without the handler importing any deploy-side code (so app.ts stays
@@ -36,6 +37,10 @@ export interface PramenApp {
36
37
  /** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
37
38
  * mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
38
39
  tasks?: AppTaskMap;
40
+ /** Cloudflare Queues consumers keyed by queue name — process messages produced via
41
+ * `ctx.queue.send(...)`. Dispatched by `createPramen(app).queue` (a consumer is
42
+ * Worker-level: no `ctx.db`, reach a tenant via `ctx.callPrivileged`). */
43
+ queues?: AppQueueMap;
39
44
  }
40
45
  export type { Env, DoEnv };
41
46
  /** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
@@ -44,5 +49,6 @@ export type { Env, DoEnv };
44
49
  export declare function createPramen(app: PramenApp): {
45
50
  fetch: (request: Request, env: Env) => Promise<Response>;
46
51
  scheduled: (event: unknown, env: Env) => Promise<void>;
52
+ queue: (batch: QueueBatch, env: Env) => Promise<void>;
47
53
  PramenDO: ReturnType<typeof pramenDO>;
48
54
  };
package/dist/pramen.js CHANGED
@@ -20,5 +20,5 @@ import { validateTriggerTasks } from "./sdk/schema";
20
20
  export function createPramen(app) {
21
21
  validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
22
22
  const worker = makeWorker(app);
23
- return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
23
+ return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
24
24
  }
@@ -145,6 +145,13 @@ function resolveMarkers(rule, identity, input) {
145
145
  const rv = resolveValue(v, identity, input);
146
146
  if (rv === UNRESOLVED)
147
147
  return null;
148
+ // A bare-value marker must only ever produce an EQUALITY comparison. If it
149
+ // resolves to a caller-controlled non-primitive (object/array), compileWhere
150
+ // would read it as an operator predicate (`{"gte":""}` → full-table
151
+ // enumeration), defeating the intended equality. Treat that as unresolvable so
152
+ // the branch matches nothing (the safe-deny path). Primitives resolve as today.
153
+ if (isMarker && rv !== null && typeof rv === "object")
154
+ return null;
148
155
  out[key] = rv;
149
156
  }
150
157
  }
@@ -164,6 +171,26 @@ function pkOf(schema, entity) {
164
171
  return n;
165
172
  return "id";
166
173
  }
174
+ /** Reject a relation `where` that filters the target on a column it can't read —
175
+ * anywhere in the clause, including inside nested AND/OR groups (else a hidden/
176
+ * unreadable column is LIKE-oracle'able through the subquery). Nested relation keys
177
+ * are skipped: they're re-scoped against THEIR own target's read scope downstream.
178
+ * Mirrors Db.assertReadableWhere's recursion for the top-level user `where`. */
179
+ function assertReadableRelationWhere(where, target, fields, ctx) {
180
+ const targetRels = (ctx.schema?.[target]?.relations ?? {});
181
+ for (const [k, v] of Object.entries(where)) {
182
+ if (k === "AND" || k === "OR") {
183
+ for (const g of v)
184
+ assertReadableRelationWhere(g, target, fields, ctx);
185
+ }
186
+ else if (targetRels[k]) {
187
+ continue;
188
+ }
189
+ else if (!fields.includes(k)) {
190
+ throw new AclDenied(target, "read", k);
191
+ }
192
+ }
193
+ }
167
194
  /** Compile a relation predicate `{ rel: { … } }` to a subquery, AND-merging the
168
195
  * related entity's read scope (and rejecting filters on fields it can't read) so
169
196
  * traversal can never widen access beyond a direct read of the target. */
@@ -189,13 +216,7 @@ function relationPredicate(rel, nested, parentEntity, ctx, depth) {
189
216
  }
190
217
  else {
191
218
  if (tScope.fields !== null) {
192
- const targetRels = (ctx.schema?.[rel.target]?.relations ?? {});
193
- for (const k of Object.keys(nested)) {
194
- if (k === "AND" || k === "OR" || targetRels[k])
195
- continue;
196
- if (!tScope.fields.includes(k))
197
- throw new AclDenied(rel.target, "read", k);
198
- }
219
+ assertReadableRelationWhere(nested, rel.target, tScope.fields, ctx);
199
220
  }
200
221
  if (tScope.where)
201
222
  inner = and(inner, tScope.where);
@@ -115,6 +115,9 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
115
115
  aggregations: A;
116
116
  }): Promise<AggregateResult<FieldsOf<S[T]>, G, A>[]>;
117
117
  private readWhere;
118
+ /** Walk a compiled predicate and add every relation-subquery target table to
119
+ * `touched` (the live-query read-set). Recurses into nested subqueries. */
120
+ private addTouchedTables;
118
121
  /** Reject a user `where` that filters on a column the caller cannot read (closes
119
122
  * the same info-leak as ordering by a hidden column: a filter is an oracle for a
120
123
  * hidden field's values). Mirrors `assertReadableCols`. Relation keys are skipped
@@ -124,6 +127,9 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
124
127
  private assertReadableWhere;
125
128
  private selectRaw;
126
129
  private jsonColsOf;
130
+ /** Boolean columns — stored as INTEGER 0/1 (SQLite has no boolean), decoded back to
131
+ * true/false on read so handlers see the `boolean` the InferRow type promises. */
132
+ private boolColsOf;
127
133
  /** Columns marked `hidden()` — never projected on an ORM read (even SYSTEM/full). */
128
134
  private hiddenColsOf;
129
135
  /** Drop hidden columns from a row (copying only if any are present). */
@@ -11,7 +11,7 @@
11
11
  // which the live-query layer uses to decide which subscriptions to re-check.
12
12
  // Create a fresh Db per handler run so identity and `touched` are scoped.
13
13
  import { AclDenied, ALLOW_ALL, compileScopedWhere, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
14
- import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, eq, inList, or, TRUE, } from "./read-engine";
14
+ import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, eq, FALSE, inList, isNull, or, TRUE, } from "./read-engine";
15
15
  import { BadRequest } from "./errors";
16
16
  import { enqueueTask } from "./outbox";
17
17
  import { partitionOf, triggersOf, triggerFires } from "../sdk/schema";
@@ -48,16 +48,29 @@ function decodeCursor(s) {
48
48
  throw new BadRequest("invalid cursor");
49
49
  }
50
50
  }
51
+ // Strictly-after predicate for a single order column, NULL-aware. SQLite sorts
52
+ // NULLs FIRST for ASC and LAST for DESC, so a naive `col > v` (which is NULL, never
53
+ // TRUE, when col or v is NULL) would re-match already-seen NULL rows forever.
54
+ // ASC: v null -> any non-null row is after it (col IS NOT NULL)
55
+ // v non-null-> col > v (NULL cols excluded, they sort before)
56
+ // DESC: v null -> nothing is strictly after a null (FALSE; PK tiebreak advances)
57
+ // v non-null-> col < v OR col IS NULL (nulls sort after all non-nulls)
58
+ function keysetCmp(o, value) {
59
+ const desc = o.dir === "desc";
60
+ if (value === null)
61
+ return desc ? FALSE : isNull(o.column, true);
62
+ return desc ? or(cmp("<", o.column, value), isNull(o.column)) : cmp(">", o.column, value);
63
+ }
51
64
  // Strictly-after predicate for a composite key: lexicographic comparison,
52
- // e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <.
65
+ // e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <. Each
66
+ // column's comparison and the eq-tiebreaker are NULL-aware (eq() maps null→IS NULL).
53
67
  function keysetAfter(order, values) {
54
68
  const ors = [];
55
69
  for (let i = 0; i < order.length; i++) {
56
70
  const parts = [];
57
71
  for (let j = 0; j < i; j++)
58
72
  parts.push(eq(order[j].column, values[j]));
59
- const o = order[i];
60
- parts.push(o.dir === "desc" ? cmp("<", o.column, values[i]) : cmp(">", o.column, values[i]));
73
+ parts.push(keysetCmp(order[i], values[i]));
61
74
  ors.push(parts.length === 1 ? parts[0] : and(...parts));
62
75
  }
63
76
  return ors.length === 1 ? ors[0] : or(...ors);
@@ -140,6 +153,12 @@ export class Db {
140
153
  * and the keyset cursor would otherwise expose a hidden column's values). Columns
141
154
  * granted only conditionally are NOT orderable. */
142
155
  assertReadableCols(from, scope, cols) {
156
+ // Hidden columns are never readable through the ORM — not orderable either,
157
+ // regardless of scope (else the order/keyset cursor leaks the hidden value).
158
+ const hidden = new Set(this.hiddenColsOf(from));
159
+ for (const c of cols)
160
+ if (hidden.has(c))
161
+ throw new AclDenied(from, "read", c);
143
162
  if (scope.fields === null)
144
163
  return;
145
164
  for (const c of cols)
@@ -223,6 +242,17 @@ export class Db {
223
242
  throw new BadRequest(`cannot aggregate a json column: ${agg.column}`);
224
243
  }
225
244
  }
245
+ // Hidden columns are never readable through the ORM — reject group-by / aggregating
246
+ // them UNCONDITIONALLY (independent of scope.fields, which is null under full/SYSTEM
247
+ // read), else min/max/groupBy over a hidden column exposes its values.
248
+ const hiddenCols = new Set(this.hiddenColsOf(from));
249
+ for (const c of groupBy)
250
+ if (hiddenCols.has(c))
251
+ throw new AclDenied(from, "read", c);
252
+ for (const agg of Object.values(spec.aggregations)) {
253
+ if (agg.column && hiddenCols.has(agg.column))
254
+ throw new AclDenied(from, "read", agg.column);
255
+ }
226
256
  if (scope.fields) {
227
257
  const refs = new Set(groupBy);
228
258
  for (const agg of Object.values(spec.aggregations))
@@ -243,7 +273,30 @@ export class Db {
243
273
  if (userWhere)
244
274
  this.assertReadableWhere(from, scope, userWhere);
245
275
  const userExpr = userWhere ? compileScopedWhere(userWhere, from, this.acl) : TRUE;
246
- return scope.where ? and(userExpr, scope.where) : userExpr;
276
+ const where = scope.where ? and(userExpr, scope.where) : userExpr;
277
+ // A relation-traversal `where` (or a relation-traversing ACL scope) compiles to a
278
+ // `sub` node over another table. Record those tables in `touched` so the live-query
279
+ // layer re-checks the subscription when the traversed table changes — else a write
280
+ // there never intersects the sub's read-set and the client stays stale.
281
+ this.addTouchedTables(where);
282
+ return where;
283
+ }
284
+ /** Walk a compiled predicate and add every relation-subquery target table to
285
+ * `touched` (the live-query read-set). Recurses into nested subqueries. */
286
+ addTouchedTables(expr) {
287
+ if (!expr)
288
+ return;
289
+ switch (expr.t) {
290
+ case "sub":
291
+ this.touched.add(expr.from);
292
+ this.addTouchedTables(expr.where);
293
+ break;
294
+ case "and":
295
+ case "or":
296
+ for (const p of expr.parts)
297
+ this.addTouchedTables(p);
298
+ break;
299
+ }
247
300
  }
248
301
  /** Reject a user `where` that filters on a column the caller cannot read (closes
249
302
  * the same info-leak as ordering by a hidden column: a filter is an oracle for a
@@ -282,6 +335,16 @@ export class Db {
282
335
  .filter(([, f]) => f.type === "json" || f.type === "fileRef")
283
336
  .map(([n]) => n);
284
337
  }
338
+ /** Boolean columns — stored as INTEGER 0/1 (SQLite has no boolean), decoded back to
339
+ * true/false on read so handlers see the `boolean` the InferRow type promises. */
340
+ boolColsOf(table) {
341
+ const fields = this.schema[table]?.fields;
342
+ if (!fields)
343
+ return [];
344
+ return Object.entries(fields)
345
+ .filter(([, f]) => f.type === "boolean")
346
+ .map(([n]) => n);
347
+ }
285
348
  /** Columns marked `hidden()` — never projected on an ORM read (even SYSTEM/full). */
286
349
  hiddenColsOf(table) {
287
350
  const fields = this.schema[table]?.fields;
@@ -369,7 +432,8 @@ export class Db {
369
432
  }
370
433
  decodeRows(table, rows) {
371
434
  const cols = this.jsonColsOf(table);
372
- if (cols.length === 0)
435
+ const boolCols = this.boolColsOf(table);
436
+ if (cols.length === 0 && boolCols.length === 0)
373
437
  return rows;
374
438
  for (const row of rows) {
375
439
  for (const c of cols) {
@@ -383,6 +447,14 @@ export class Db {
383
447
  }
384
448
  }
385
449
  }
450
+ // INTEGER 0/1 -> boolean (leave NULL as null for a nullable bool column).
451
+ for (const c of boolCols) {
452
+ const v = row[c];
453
+ if (typeof v === "number")
454
+ row[c] = v !== 0;
455
+ else if (typeof v === "bigint")
456
+ row[c] = v !== 0n;
457
+ }
386
458
  }
387
459
  return rows;
388
460
  }
@@ -441,7 +513,9 @@ export class Db {
441
513
  const scope = this.acl.system ? ALLOW_ALL : resolveRelationScope(this.acl, parentEntity, relName, rel.target);
442
514
  if (!scope.allowed)
443
515
  throw new AclDenied(rel.target, "read");
444
- const project = (row) => projectRow(row, effectiveFields(scope, row, this.acl.identity));
516
+ // Strip hidden() columns from the relation load, like every other read path —
517
+ // projectRow alone keeps them under a full/allow()/SYSTEM scope (fields === null).
518
+ const project = (row) => this.stripHidden(rel.target, projectRow(row, effectiveFields(scope, row, this.acl.identity)));
445
519
  // One IN query per relation (no N+1). Match column before projecting (which
446
520
  // may drop the join column).
447
521
  const fetchBy = async (col, values) => {
@@ -462,15 +536,17 @@ export class Db {
462
536
  r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
463
537
  }
464
538
  else {
465
- // hasMany: target[column] -> parent.id
466
- const ids = [...new Set(rows.map((r) => r.id).filter((v) => v != null))];
539
+ // hasMany: target[column] -> parent.<pk> (NOT hardcoded `id` — a parent keyed by
540
+ // slug/username would otherwise join on an undefined `r.id` and get []).
541
+ const pk = this.pkOf(parentEntity);
542
+ const ids = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
467
543
  const grouped = new Map();
468
544
  for (const { key, row } of await fetchBy(rel.column, ids)) {
469
545
  const bucket = grouped.get(key) ?? grouped.set(key, []).get(key);
470
546
  bucket.push(row);
471
547
  }
472
548
  for (const r of rows)
473
- r[relName] = grouped.get(r.id) ?? [];
549
+ r[relName] = grouped.get(r[pk]) ?? [];
474
550
  }
475
551
  }
476
552
  /** Insert a single row, returning the persisted row. */