@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/index.ts CHANGED
@@ -76,6 +76,12 @@ export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
76
76
  export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
77
77
  export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
78
78
 
79
+ // --- queue (ctx.queue — Cloudflare Queues) ---
80
+ export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
81
+ export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
82
+ export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
83
+ export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
84
+
79
85
  // --- errors ---
80
86
  export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
81
87
 
package/src/pramen.ts CHANGED
@@ -16,6 +16,7 @@ import { makeWorker, type Env } from "./worker";
16
16
  import { pramenDO, type DoEnv } from "./durable-object";
17
17
  import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
18
18
  import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
19
+ import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
19
20
  import type { Role } from "./sdk/acl";
20
21
 
21
22
  /** Injected into a public route's handler — forward a privileged mutation into the
@@ -48,6 +49,10 @@ export interface PramenApp {
48
49
  /** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
49
50
  * mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
50
51
  tasks?: AppTaskMap;
52
+ /** Cloudflare Queues consumers keyed by queue name — process messages produced via
53
+ * `ctx.queue.send(...)`. Dispatched by `createPramen(app).queue` (a consumer is
54
+ * Worker-level: no `ctx.db`, reach a tenant via `ctx.callPrivileged`). */
55
+ queues?: AppQueueMap;
51
56
  }
52
57
 
53
58
  export type { Env, DoEnv };
@@ -58,9 +63,10 @@ export type { Env, DoEnv };
58
63
  export function createPramen(app: PramenApp): {
59
64
  fetch: (request: Request, env: Env) => Promise<Response>;
60
65
  scheduled: (event: unknown, env: Env) => Promise<void>;
66
+ queue: (batch: QueueBatch, env: Env) => Promise<void>;
61
67
  PramenDO: ReturnType<typeof pramenDO>;
62
68
  } {
63
69
  validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
64
70
  const worker = makeWorker(app);
65
- return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
71
+ return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
66
72
  }
@@ -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
 
@@ -3,6 +3,7 @@
3
3
  // these are applied.
4
4
 
5
5
  import type { DefaultValue, EntityFields, FieldDef } from "../sdk/schema";
6
+ import { quoteIdent } from "./driver";
6
7
 
7
8
  // SQLite has no boolean type; store as INTEGER 0/1. json + fileRef + uuid are
8
9
  // stored as TEXT. Exported for the migrator, which compares declared column types
@@ -14,14 +15,27 @@ export const sqlType = (f: FieldDef): string =>
14
15
  ? "TEXT"
15
16
  : f.type.toUpperCase();
16
17
 
17
- /** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1. */
18
- 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 {
19
22
  if (v === null) return "NULL";
20
23
  if (typeof v === "boolean") return v ? "1" : "0";
21
24
  if (typeof v === "number") return String(v);
22
25
  return `'${v.replace(/'/g, "''")}'`;
23
26
  }
24
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
+
25
39
  /** The ` DEFAULT x` fragment for a column, or "" when it has no default. A raw-SQL
26
40
  * `defaultExpr` (e.g. `datetime('now')`) is emitted UNQUOTED; a literal `default` is
27
41
  * quote-escaped. UNIQUE/index are NOT inline — they're emitted as separate index
@@ -35,7 +49,7 @@ function defaultSql(f: FieldDef): string {
35
49
  }
36
50
 
37
51
  function columnSql(name: string, f: FieldDef): string {
38
- let s = `${name} ${sqlType(f)}`;
52
+ let s = `${quoteIdent(name)} ${sqlType(f)}`;
39
53
  if (f.primaryKey) s += " PRIMARY KEY";
40
54
  if (f.autoIncrement) s += " AUTOINCREMENT";
41
55
  if (f.notNull && !f.primaryKey) s += " NOT NULL";
@@ -45,14 +59,14 @@ function columnSql(name: string, f: FieldDef): string {
45
59
 
46
60
  export function createTableSql(table: string, def: { fields: EntityFields }): string {
47
61
  const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
48
- return `CREATE TABLE IF NOT EXISTS ${table} (${cols.join(", ")})`;
62
+ return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${cols.join(", ")})`;
49
63
  }
50
64
 
51
65
  /** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
52
66
  * NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
53
67
  * a populated table); a DEFAULT alone backfills existing rows. */
54
68
  export function addColumnSql(name: string, f: FieldDef): string {
55
- let s = `${name} ${sqlType(f)}`;
69
+ let s = `${quoteIdent(name)} ${sqlType(f)}`;
56
70
  if (f.notNull && f.default !== undefined) s += " NOT NULL";
57
71
  s += defaultSql(f);
58
72
  return s;
@@ -64,13 +78,17 @@ export function indexName(table: string, col: string): string {
64
78
  }
65
79
 
66
80
  /** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
67
- * via IF NOT EXISTS). Unique wins if a column declares both. */
68
- 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[] {
69
86
  const out: string[] = [];
70
87
  for (const [col, f] of Object.entries(def.fields)) {
71
88
  if (!f.unique && !f.index) continue;
89
+ if (skipCols?.has(col)) continue;
72
90
  const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
73
- out.push(`CREATE ${kind} IF NOT EXISTS ${indexName(table, col)} ON ${table} (${col})`);
91
+ out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
74
92
  }
75
93
  return out;
76
94
  }
@@ -13,6 +13,7 @@ import { warmup, type AclContext } from "./acl";
13
13
  import { BadRequest, Forbidden } from "./errors";
14
14
  import { enqueueTask, type TaskMap } from "./outbox";
15
15
  import { createMail } from "./mail";
16
+ import { createQueue } from "./queue";
16
17
  import type { Driver } from "./driver";
17
18
  import type { Kv } from "./kv";
18
19
  import type { Files } from "../sdk/files";
@@ -91,6 +92,7 @@ export async function dispatch(
91
92
  identity: acl.identity,
92
93
  tasks: tasksFacade(driver, () => enqueued++),
93
94
  mail: createMail(env, kv),
95
+ queue: createQueue(env),
94
96
  };
95
97
 
96
98
  const result =
@@ -33,10 +33,20 @@ function checkIdent(name: string): string {
33
33
  return name;
34
34
  }
35
35
 
36
- /** SQLite (DO SQLite and D1 both speak this). Bare identifiers, `?` placeholders,
37
- * booleans stored as INTEGER 0/1, RETURNING supported. */
36
+ /** Render an identifier as a standard double-quoted name (`"order"`), guarding its
37
+ * shape first. SQLite (DO SQLite + D1) and Postgres all accept double-quoted
38
+ * identifiers, so a column/table named after a reserved word (`order`, `group`, …)
39
+ * is safe and case is preserved. The single source of truth for both dialects and
40
+ * the DDL generator — keep every emitted identifier going through this. */
41
+ export function quoteIdent(name: string): string {
42
+ return `"${checkIdent(name)}"`;
43
+ }
44
+
45
+ /** SQLite (DO SQLite and D1 both speak this). Double-quoted identifiers (so reserved
46
+ * words like `order` work), `?` placeholders, booleans stored as INTEGER 0/1,
47
+ * RETURNING supported. */
38
48
  export const sqliteDialect: Dialect = {
39
- id: checkIdent,
49
+ id: quoteIdent,
40
50
  placeholder: () => "?",
41
51
  returning: true,
42
52
  encode: (v) => (typeof v === "boolean" ? (v ? 1 : 0) : v),
@@ -46,7 +56,7 @@ export const sqliteDialect: Dialect = {
46
56
  * `ownerId` doesn't fold to `ownerid`), `$n` placeholders, native booleans,
47
57
  * RETURNING supported. */
48
58
  export const postgresDialect: Dialect = {
49
- id: (name) => `"${checkIdent(name)}"`,
59
+ id: quoteIdent,
50
60
  placeholder: (n) => `$${n}`,
51
61
  returning: true,
52
62
  encode: (v) => v, // the pg driver handles type encoding
@@ -76,19 +86,52 @@ export class DoSqliteDriver implements Driver {
76
86
  }
77
87
  }
78
88
 
79
- /** D1 SQLite over RPC. Async by nature. D1 has no interactive transactions, so
80
- * `transaction()` runs `fn` without one (a documented limitation: mutations don't
81
- * roll back on throw the way they do on a DO). Use a DO when you need that. */
89
+ /** How a D1Driver's session is anchored (passed to `db.withSession`):
90
+ * - `"first-primary"` first query hits the primary (current data), the rest
91
+ * read replicas consistent with the session bookmark. Use
92
+ * for a MUTATION (reads must see current data; writes go
93
+ * to primary anyway).
94
+ * - `"first-unconstrained"` — first query may hit the nearest replica. Use for a QUERY.
95
+ * - a bookmark string — anchor at a prior write's bookmark for read-your-writes
96
+ * (the client carries it forward via a header).
97
+ * A bookmark always wins over a constraint when one is supplied. */
98
+ export type D1SessionStart = "first-primary" | "first-unconstrained" | (string & {});
99
+
100
+ /** D1 — SQLite over RPC. Async by nature.
101
+ *
102
+ * Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
103
+ * runs all `exec` through it. Writes in a session always land on the primary; the
104
+ * `start` only chooses where the FIRST read may begin. The session maintains a
105
+ * bookmark (`getBookmark()`) so later reads are sequentially consistent with earlier
106
+ * writes — read-your-writes when the bookmark is threaded across requests.
107
+ *
108
+ * ATOMICITY LIMIT (intentional): D1 has NO interactive transactions — a session can't
109
+ * read mid-`batch()`, and pramen mutations interleave reads + writes + RETURNING +
110
+ * trigger-into-outbox inside one `transaction()`. So `transaction(fn) = fn()`: each
111
+ * statement auto-commits on its own, and a multi-statement mutation does NOT roll back
112
+ * on throw the way it does on a DO. Single-statement mutations are atomic; anything
113
+ * multi-statement is not. Use the DO store when you need atomic mutations. */
82
114
  export class D1Driver implements Driver {
83
115
  readonly dialect = sqliteDialect;
84
- constructor(private readonly db: D1Database) {}
116
+ private readonly session: D1DatabaseSession;
117
+ constructor(db: D1Database, opts?: { start?: D1SessionStart }) {
118
+ this.session = db.withSession(opts?.start ?? "first-unconstrained");
119
+ }
85
120
 
86
121
  async exec(sql: string, params: unknown[]): Promise<Row[]> {
87
- const stmt = params.length ? this.db.prepare(sql).bind(...params) : this.db.prepare(sql);
122
+ const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
88
123
  const { results } = await stmt.all<Row>();
89
124
  return results ?? [];
90
125
  }
91
126
 
127
+ /** The session's latest bookmark (null before any query). Threaded back to the client
128
+ * via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
129
+ * fresh session at it and read its own writes. */
130
+ getBookmark(): string | null {
131
+ return this.session.getBookmark();
132
+ }
133
+
134
+ // D1 has no interactive/atomic transactions — see the class doc. Run `fn` as-is.
92
135
  transaction<T>(fn: () => Promise<T>): Promise<T> {
93
136
  return fn();
94
137
  }