@pramen/server 0.0.14 → 0.0.16

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.
@@ -35,14 +35,17 @@ import type { HandlerContext } from "./sdk/handlers";
35
35
  import type { PramenApp } from "./pramen";
36
36
  import type { ClientMsg, ServerMsg, Subscription } from "./runtime/protocol";
37
37
 
38
- 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 {
39
43
  identity: Identity | null;
40
44
  /** Tenant fixed at connect time (survives hibernation via the attachment). */
41
45
  tenant: string;
42
46
  /** Partition fixed at connect time (read from x-pramen-partition at upgrade);
43
47
  * survives hibernation via the attachment, like `tenant`. */
44
48
  partition: string;
45
- subs: Subscription[];
46
49
  }
47
50
 
48
51
  export interface DoEnv {
@@ -84,6 +87,14 @@ export class PramenDOBase extends DurableObject<DoEnv> {
84
87
  * task context — a DO can't introspect its own idFromName. */
85
88
  private identityPersisted = false;
86
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[]>();
87
98
 
88
99
  constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp) {
89
100
  super(ctx, env);
@@ -161,7 +172,8 @@ export class PramenDOBase extends DurableObject<DoEnv> {
161
172
  if (request.headers.get("Upgrade") === "websocket") {
162
173
  const { 0: client, 1: server } = new WebSocketPair();
163
174
  this.ctx.acceptWebSocket(server); // hibernatable
164
- 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, []);
165
177
  return new Response(null, { status: 101, webSocket: client });
166
178
  }
167
179
 
@@ -183,8 +195,11 @@ export class PramenDOBase extends DurableObject<DoEnv> {
183
195
  name,
184
196
  input,
185
197
  );
186
- 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).
187
201
  if (enqueued > 0) await this.armDrain();
202
+ if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
188
203
  return Response.json({ ok: true, result });
189
204
  } catch (err) {
190
205
  const { status, body } = toResponse(err);
@@ -199,15 +214,17 @@ export class PramenDOBase extends DurableObject<DoEnv> {
199
214
  }
200
215
 
201
216
  /** A privileged, system-scoped context for running task handlers (outside a request).
202
- * Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks. */
203
- 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 } {
204
221
  const identity: Identity = { roles: ["admin"] };
205
222
  const db = new Db(
206
223
  this.driver,
207
224
  { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true },
208
225
  this.app.schema,
209
226
  );
210
- return {
227
+ const ctx: HandlerContext = {
211
228
  db,
212
229
  kv: this.kv,
213
230
  files: this.filesFor(this.tenant),
@@ -217,6 +234,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
217
234
  mail: createMail(this.envBag, this.kv),
218
235
  queue: createQueue(this.envBag),
219
236
  };
237
+ return { ctx, db };
220
238
  }
221
239
 
222
240
  /** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
@@ -237,23 +255,33 @@ export class PramenDOBase extends DurableObject<DoEnv> {
237
255
  }
238
256
 
239
257
  /** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
240
- * (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling. */
241
- 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[] }> {
242
261
  await ensureOutbox(this.driver); // idempotent — the table may predate this instance (cold alarm)
243
- 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] };
244
265
  }
245
266
 
246
267
  override async alarm(): Promise<void> {
247
268
  await this.loadIdentity(); // cold wake: restore tenant/partition before building taskCtx
248
- 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);
249
276
  // Reschedule to the NEXT task's due time (a backed-off retry, or the next batch if
250
277
  // the drain hit its limit) so a failed task can't stall waiting for a new enqueue.
251
- 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));
252
279
  }
253
280
 
254
281
  private async handleDrain(): Promise<Response> {
255
282
  await this.loadIdentity();
256
- const result = await this.drainTasks();
283
+ const { result, touched } = await this.drainTasks();
284
+ if (touched.length > 0) await this.broadcast(touched);
257
285
  // Keep the alarm honest even when drained manually: ensure a backed-off retry wakes.
258
286
  if (result.nextRunAt != null) await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
259
287
  return Response.json({ ok: true, result });
@@ -282,7 +310,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
282
310
  // socket's (tenant, partition) — fixed at connect time, survives via the attachment
283
311
  // — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
284
312
  // (the `migrated` flag), so a no-op after the first call.
285
- const { tenant, partition } = this.getState(ws);
313
+ const { tenant, partition } = this.getAttachment(ws);
286
314
  this.tenant = tenant;
287
315
  this.partition = partition;
288
316
  await this.ensureMigrated();
@@ -290,11 +318,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
290
318
  switch (msg.type) {
291
319
  case "subscribe":
292
320
  return this.onSubscribe(ws, msg.id, msg.name, msg.input);
293
- case "unsubscribe": {
294
- const state = this.getState(ws);
295
- 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));
296
323
  return;
297
- }
298
324
  case "call":
299
325
  return this.onCall(ws, msg.id, msg.name, msg.input);
300
326
  default:
@@ -303,6 +329,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
303
329
  }
304
330
 
305
331
  override async webSocketClose(ws: WebSocket): Promise<void> {
332
+ this.subsBySocket.delete(ws); // release the in-memory subscription list for this socket
306
333
  ws.close();
307
334
  }
308
335
 
@@ -318,19 +345,20 @@ export class PramenDOBase extends DurableObject<DoEnv> {
318
345
  // --- live-query internals ---
319
346
 
320
347
  private async onSubscribe(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
321
- const state = this.getState(ws);
348
+ const att = this.getAttachment(ws);
349
+ const subs = this.getSubs(ws);
322
350
  try {
323
- const replacing = state.subs.some((s) => s.id === id);
324
- if (!replacing && state.subs.length >= MAX_SUBSCRIPTIONS) {
351
+ const replacing = subs.some((s) => s.id === id);
352
+ if (!replacing && subs.length >= MAX_SUBSCRIPTIONS) {
325
353
  return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
326
354
  }
327
- 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);
328
356
  if (kind !== "query") {
329
357
  return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
330
358
  }
331
- const subs = state.subs.filter((s) => s.id !== id);
332
- subs.push({ id, name, input, tables: touched, digest: digest(result) });
333
- 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);
334
362
  this.send(ws, { type: "data", id, result });
335
363
  } catch (err) {
336
364
  this.send(ws, toWsError(id, err));
@@ -338,38 +366,57 @@ export class PramenDOBase extends DurableObject<DoEnv> {
338
366
  }
339
367
 
340
368
  private async onCall(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
341
- const state = this.getState(ws);
369
+ const att = this.getAttachment(ws);
370
+ let outcome: Awaited<ReturnType<typeof dispatch>>;
342
371
  try {
343
- 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);
344
- this.send(ws, { type: "result", id, result });
345
- if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
346
- 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);
347
373
  } catch (err) {
348
- this.send(ws, toWsError(id, err));
374
+ return this.send(ws, toWsError(id, err));
349
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);
350
383
  }
351
384
 
352
385
  // Re-run every subscription whose read-set intersects the written tables, each
353
- // 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).
354
389
  private async broadcast(touched: string[]): Promise<void> {
355
390
  const written = new Set(touched);
356
391
  for (const ws of this.ctx.getWebSockets()) {
357
- const state = this.getState(ws);
358
- let dirty = false;
359
- for (const sub of state.subs) {
360
- if (!sub.tables.some((t) => written.has(t))) continue;
361
- try {
362
- 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);
363
- const next = digest(result);
364
- if (next === sub.digest) continue; // result unchanged for this subscription
365
- sub.digest = next;
366
- dirty = true;
367
- this.send(ws, { type: "data", id: sub.id, result });
368
- } catch (err) {
369
- 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
+ }
370
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);
371
419
  }
372
- if (dirty) this.setState(ws, state);
373
420
  }
374
421
  }
375
422
 
@@ -468,7 +515,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
468
515
  result = await db.count({ from: table, where: b.where });
469
516
  break;
470
517
  case "get":
471
- 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;
472
521
  break;
473
522
  case "create":
474
523
  result = await this.driver.transaction(() => db.insert(table, b.values));
@@ -485,6 +534,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
485
534
  default:
486
535
  return Response.json({ ok: false, error: `unknown op: ${op}`, code: "bad_request" }, { status: 400 });
487
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();
488
540
  if (mutated && db.touched.size > 0) await this.broadcast([...db.touched]);
489
541
  return Response.json({ ok: true, result });
490
542
  } catch (err) {
@@ -526,19 +578,31 @@ export class PramenDOBase extends DurableObject<DoEnv> {
526
578
  }
527
579
  }
528
580
 
529
- 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 {
530
584
  return (
531
- (ws.deserializeAttachment() as SocketState | null) ?? {
585
+ (ws.deserializeAttachment() as SocketAttachment | null) ?? {
532
586
  identity: null,
533
587
  tenant: this.tenant,
534
588
  partition: this.partition,
535
- subs: [],
536
589
  }
537
590
  );
538
591
  }
539
592
 
540
- private setState(ws: WebSocket, state: SocketState): void {
541
- 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);
542
606
  }
543
607
 
544
608
  private send(ws: WebSocket, msg: ServerMsg): void {
@@ -225,6 +225,12 @@ function resolveMarkers(rule: Record<string, unknown>, identity: Identity | null
225
225
  } else {
226
226
  const rv = resolveValue(v, identity, input);
227
227
  if (rv === UNRESOLVED) return null;
228
+ // A bare-value marker must only ever produce an EQUALITY comparison. If it
229
+ // resolves to a caller-controlled non-primitive (object/array), compileWhere
230
+ // would read it as an operator predicate (`{"gte":""}` → full-table
231
+ // enumeration), defeating the intended equality. Treat that as unresolvable so
232
+ // the branch matches nothing (the safe-deny path). Primitives resolve as today.
233
+ if (isMarker && rv !== null && typeof rv === "object") return null;
228
234
  out[key] = rv;
229
235
  }
230
236
  }
@@ -244,6 +250,24 @@ function pkOf(schema: SchemaDef | undefined, entity: string): string {
244
250
  return "id";
245
251
  }
246
252
 
253
+ /** Reject a relation `where` that filters the target on a column it can't read —
254
+ * anywhere in the clause, including inside nested AND/OR groups (else a hidden/
255
+ * unreadable column is LIKE-oracle'able through the subquery). Nested relation keys
256
+ * are skipped: they're re-scoped against THEIR own target's read scope downstream.
257
+ * Mirrors Db.assertReadableWhere's recursion for the top-level user `where`. */
258
+ function assertReadableRelationWhere(where: Record<string, unknown>, target: string, fields: string[], ctx: AclContext): void {
259
+ const targetRels = (ctx.schema?.[target]?.relations ?? {}) as Record<string, unknown>;
260
+ for (const [k, v] of Object.entries(where)) {
261
+ if (k === "AND" || k === "OR") {
262
+ for (const g of v as Record<string, unknown>[]) assertReadableRelationWhere(g, target, fields, ctx);
263
+ } else if (targetRels[k]) {
264
+ continue;
265
+ } else if (!fields.includes(k)) {
266
+ throw new AclDenied(target, "read", k);
267
+ }
268
+ }
269
+ }
270
+
247
271
  /** Compile a relation predicate `{ rel: { … } }` to a subquery, AND-merging the
248
272
  * related entity's read scope (and rejecting filters on fields it can't read) so
249
273
  * traversal can never widen access beyond a direct read of the target. */
@@ -268,11 +292,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
268
292
  inner = FALSE; // can't filter through a relation you can't read
269
293
  } else {
270
294
  if (tScope.fields !== null) {
271
- const targetRels = (ctx.schema?.[rel.target]?.relations ?? {}) as Record<string, unknown>;
272
- for (const k of Object.keys(nested as Record<string, unknown>)) {
273
- if (k === "AND" || k === "OR" || targetRels[k]) continue;
274
- if (!tScope.fields.includes(k)) throw new AclDenied(rel.target, "read", k);
275
- }
295
+ assertReadableRelationWhere(nested as Record<string, unknown>, rel.target, tScope.fields, ctx);
276
296
  }
277
297
  if (tScope.where) inner = and(inner, tScope.where);
278
298
  }
package/src/runtime/db.ts CHANGED
@@ -32,7 +32,9 @@ import {
32
32
  compileExpr,
33
33
  compileSelect,
34
34
  eq,
35
+ FALSE,
35
36
  inList,
37
+ isNull,
36
38
  or,
37
39
  TRUE,
38
40
  type AggFn,
@@ -147,15 +149,28 @@ function decodeCursor(s: string): unknown[] {
147
149
  }
148
150
  }
149
151
 
152
+ // Strictly-after predicate for a single order column, NULL-aware. SQLite sorts
153
+ // NULLs FIRST for ASC and LAST for DESC, so a naive `col > v` (which is NULL, never
154
+ // TRUE, when col or v is NULL) would re-match already-seen NULL rows forever.
155
+ // ASC: v null -> any non-null row is after it (col IS NOT NULL)
156
+ // v non-null-> col > v (NULL cols excluded, they sort before)
157
+ // DESC: v null -> nothing is strictly after a null (FALSE; PK tiebreak advances)
158
+ // v non-null-> col < v OR col IS NULL (nulls sort after all non-nulls)
159
+ function keysetCmp(o: OrderBy, value: unknown): SqlExpr {
160
+ const desc = o.dir === "desc";
161
+ if (value === null) return desc ? FALSE : isNull(o.column, true);
162
+ return desc ? or(cmp("<", o.column, value), isNull(o.column)) : cmp(">", o.column, value);
163
+ }
164
+
150
165
  // Strictly-after predicate for a composite key: lexicographic comparison,
151
- // e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <.
166
+ // e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <. Each
167
+ // column's comparison and the eq-tiebreaker are NULL-aware (eq() maps null→IS NULL).
152
168
  function keysetAfter(order: OrderBy[], values: unknown[]): SqlExpr {
153
169
  const ors: SqlExpr[] = [];
154
170
  for (let i = 0; i < order.length; i++) {
155
171
  const parts: SqlExpr[] = [];
156
172
  for (let j = 0; j < i; j++) parts.push(eq(order[j]!.column, values[j]));
157
- const o = order[i]!;
158
- parts.push(o.dir === "desc" ? cmp("<", o.column, values[i]) : cmp(">", o.column, values[i]));
173
+ parts.push(keysetCmp(order[i]!, values[i]));
159
174
  ors.push(parts.length === 1 ? parts[0]! : and(...parts));
160
175
  }
161
176
  return ors.length === 1 ? ors[0]! : or(...ors);
@@ -247,6 +262,10 @@ export class Db<S extends SchemaDef = SchemaDef> {
247
262
  * and the keyset cursor would otherwise expose a hidden column's values). Columns
248
263
  * granted only conditionally are NOT orderable. */
249
264
  private assertReadableCols(from: string, scope: Scope, cols: string[]): void {
265
+ // Hidden columns are never readable through the ORM — not orderable either,
266
+ // regardless of scope (else the order/keyset cursor leaks the hidden value).
267
+ const hidden = new Set(this.hiddenColsOf(from));
268
+ for (const c of cols) if (hidden.has(c)) throw new AclDenied(from, "read", c);
250
269
  if (scope.fields === null) return;
251
270
  for (const c of cols) if (!scope.fields.includes(c)) throw new AclDenied(from, "read", c);
252
271
  }
@@ -344,6 +363,15 @@ export class Db<S extends SchemaDef = SchemaDef> {
344
363
  }
345
364
  }
346
365
 
366
+ // Hidden columns are never readable through the ORM — reject group-by / aggregating
367
+ // them UNCONDITIONALLY (independent of scope.fields, which is null under full/SYSTEM
368
+ // read), else min/max/groupBy over a hidden column exposes its values.
369
+ const hiddenCols = new Set(this.hiddenColsOf(from));
370
+ for (const c of groupBy) if (hiddenCols.has(c)) throw new AclDenied(from, "read", c);
371
+ for (const agg of Object.values(spec.aggregations)) {
372
+ if (agg.column && hiddenCols.has(agg.column as string)) throw new AclDenied(from, "read", agg.column as string);
373
+ }
374
+
347
375
  if (scope.fields) {
348
376
  const refs = new Set<string>(groupBy);
349
377
  for (const agg of Object.values(spec.aggregations)) if (agg.column) refs.add(agg.column as string);
@@ -362,7 +390,29 @@ export class Db<S extends SchemaDef = SchemaDef> {
362
390
  // subqueries), then AND-merges the entity's own ACL row scope.
363
391
  if (userWhere) this.assertReadableWhere(from, scope, userWhere);
364
392
  const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as Record<string, unknown>, from, this.acl) : TRUE;
365
- return scope.where ? and(userExpr, scope.where) : userExpr;
393
+ const where = scope.where ? and(userExpr, scope.where) : userExpr;
394
+ // A relation-traversal `where` (or a relation-traversing ACL scope) compiles to a
395
+ // `sub` node over another table. Record those tables in `touched` so the live-query
396
+ // layer re-checks the subscription when the traversed table changes — else a write
397
+ // there never intersects the sub's read-set and the client stays stale.
398
+ this.addTouchedTables(where);
399
+ return where;
400
+ }
401
+
402
+ /** Walk a compiled predicate and add every relation-subquery target table to
403
+ * `touched` (the live-query read-set). Recurses into nested subqueries. */
404
+ private addTouchedTables(expr: SqlExpr | null | undefined): void {
405
+ if (!expr) return;
406
+ switch (expr.t) {
407
+ case "sub":
408
+ this.touched.add(expr.from);
409
+ this.addTouchedTables(expr.where);
410
+ break;
411
+ case "and":
412
+ case "or":
413
+ for (const p of expr.parts) this.addTouchedTables(p);
414
+ break;
415
+ }
366
416
  }
367
417
 
368
418
  /** Reject a user `where` that filters on a column the caller cannot read (closes
@@ -401,6 +451,16 @@ export class Db<S extends SchemaDef = SchemaDef> {
401
451
  .map(([n]) => n);
402
452
  }
403
453
 
454
+ /** Boolean columns — stored as INTEGER 0/1 (SQLite has no boolean), decoded back to
455
+ * true/false on read so handlers see the `boolean` the InferRow type promises. */
456
+ private boolColsOf(table: string): string[] {
457
+ const fields = this.schema[table]?.fields;
458
+ if (!fields) return [];
459
+ return Object.entries(fields)
460
+ .filter(([, f]) => (f as FieldDef).type === "boolean")
461
+ .map(([n]) => n);
462
+ }
463
+
404
464
  /** Columns marked `hidden()` — never projected on an ORM read (even SYSTEM/full). */
405
465
  private hiddenColsOf(table: string): string[] {
406
466
  const fields = this.schema[table]?.fields;
@@ -487,7 +547,8 @@ export class Db<S extends SchemaDef = SchemaDef> {
487
547
 
488
548
  private decodeRows(table: string, rows: Row[]): Row[] {
489
549
  const cols = this.jsonColsOf(table);
490
- if (cols.length === 0) return rows;
550
+ const boolCols = this.boolColsOf(table);
551
+ if (cols.length === 0 && boolCols.length === 0) return rows;
491
552
  for (const row of rows) {
492
553
  for (const c of cols) {
493
554
  const v = row[c];
@@ -499,6 +560,12 @@ export class Db<S extends SchemaDef = SchemaDef> {
499
560
  }
500
561
  }
501
562
  }
563
+ // INTEGER 0/1 -> boolean (leave NULL as null for a nullable bool column).
564
+ for (const c of boolCols) {
565
+ const v = row[c];
566
+ if (typeof v === "number") row[c] = v !== 0;
567
+ else if (typeof v === "bigint") row[c] = v !== 0n;
568
+ }
502
569
  }
503
570
  return rows;
504
571
  }
@@ -556,7 +623,9 @@ export class Db<S extends SchemaDef = SchemaDef> {
556
623
  const scope = this.acl.system ? ALLOW_ALL : resolveRelationScope(this.acl, parentEntity, relName, rel.target);
557
624
  if (!scope.allowed) throw new AclDenied(rel.target, "read");
558
625
 
559
- const project = (row: Row): Row => projectRow(row, effectiveFields(scope, row, this.acl.identity));
626
+ // Strip hidden() columns from the relation load, like every other read path —
627
+ // projectRow alone keeps them under a full/allow()/SYSTEM scope (fields === null).
628
+ const project = (row: Row): Row => this.stripHidden(rel.target, projectRow(row, effectiveFields(scope, row, this.acl.identity)));
560
629
  // One IN query per relation (no N+1). Match column before projecting (which
561
630
  // may drop the join column).
562
631
  const fetchBy = async (col: string, values: unknown[]): Promise<Array<{ key: unknown; row: Row }>> => {
@@ -574,14 +643,16 @@ export class Db<S extends SchemaDef = SchemaDef> {
574
643
  for (const { key, row } of await fetchBy(this.pkOf(rel.target), keys)) byId.set(key, row);
575
644
  for (const r of rows) r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
576
645
  } else {
577
- // hasMany: target[column] -> parent.id
578
- const ids = [...new Set(rows.map((r) => r.id).filter((v) => v != null))];
646
+ // hasMany: target[column] -> parent.<pk> (NOT hardcoded `id` — a parent keyed by
647
+ // slug/username would otherwise join on an undefined `r.id` and get []).
648
+ const pk = this.pkOf(parentEntity);
649
+ const ids = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
579
650
  const grouped = new Map<unknown, Row[]>();
580
651
  for (const { key, row } of await fetchBy(rel.column, ids)) {
581
652
  const bucket = grouped.get(key) ?? grouped.set(key, []).get(key)!;
582
653
  bucket.push(row);
583
654
  }
584
- for (const r of rows) r[relName] = grouped.get(r.id) ?? [];
655
+ for (const r of rows) r[relName] = grouped.get(r[pk]) ?? [];
585
656
  }
586
657
  }
587
658
 
@@ -15,14 +15,27 @@ export const sqlType = (f: FieldDef): string =>
15
15
  ? "TEXT"
16
16
  : f.type.toUpperCase();
17
17
 
18
- /** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1. */
19
- function defaultLiteral(v: DefaultValue): string {
18
+ /** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1.
19
+ * Exported for the migrator, which reconstructs a column's expected DEFAULT text to
20
+ * compare against the live `PRAGMA table_info.dflt_value`. */
21
+ export function defaultLiteral(v: DefaultValue): string {
20
22
  if (v === null) return "NULL";
21
23
  if (typeof v === "boolean") return v ? "1" : "0";
22
24
  if (typeof v === "number") return String(v);
23
25
  return `'${v.replace(/'/g, "''")}'`;
24
26
  }
25
27
 
28
+ /** The SQL text of a column's DEFAULT value (the part after `DEFAULT `), or null when
29
+ * the column declares no default. A raw-SQL `defaultExpr` is returned unquoted (e.g.
30
+ * `datetime('now')`); a literal `default` is rendered via {@link defaultLiteral}. Used
31
+ * by the migrator both to detect a default add/change on an existing column and to
32
+ * COALESCE-backfill a NOT NULL column during a rebuild. */
33
+ export function defaultSqlValue(f: FieldDef): string | null {
34
+ if (f.defaultExpr !== undefined) return f.defaultExpr;
35
+ if (f.default !== undefined) return defaultLiteral(f.default);
36
+ return null;
37
+ }
38
+
26
39
  /** The ` DEFAULT x` fragment for a column, or "" when it has no default. A raw-SQL
27
40
  * `defaultExpr` (e.g. `datetime('now')`) is emitted UNQUOTED; a literal `default` is
28
41
  * quote-escaped. UNIQUE/index are NOT inline — they're emitted as separate index
@@ -65,11 +78,15 @@ export function indexName(table: string, col: string): string {
65
78
  }
66
79
 
67
80
  /** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
68
- * via IF NOT EXISTS). Unique wins if a column declares both. */
69
- export function indexStatements(table: string, def: { fields: EntityFields }): string[] {
81
+ * via IF NOT EXISTS). Unique wins if a column declares both. `skipCols` omits specific
82
+ * columns the migrator uses it to avoid emitting a UNIQUE index that would throw
83
+ * (duplicate values present on a column that just gained `unique()`); that delta is
84
+ * reported as skipped instead. */
85
+ export function indexStatements(table: string, def: { fields: EntityFields }, skipCols?: ReadonlySet<string>): string[] {
70
86
  const out: string[] = [];
71
87
  for (const [col, f] of Object.entries(def.fields)) {
72
88
  if (!f.unique && !f.index) continue;
89
+ if (skipCols?.has(col)) continue;
73
90
  const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
74
91
  out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
75
92
  }