@pramen/server 0.0.48 → 0.0.49

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 (49) hide show
  1. package/dist/auth.d.ts +4 -3
  2. package/dist/cli.js +15 -9
  3. package/dist/durable-object.d.ts +1 -0
  4. package/dist/durable-object.js +10 -5
  5. package/dist/index.d.ts +3 -3
  6. package/dist/pramen.d.ts +4 -2
  7. package/dist/runtime/acl.d.ts +6 -5
  8. package/dist/runtime/acl.js +1 -5
  9. package/dist/runtime/db.d.ts +3 -2
  10. package/dist/runtime/dispatch.d.ts +3 -1
  11. package/dist/runtime/driver.d.ts +8 -5
  12. package/dist/runtime/mail.d.ts +2 -1
  13. package/dist/runtime/protocol.d.ts +4 -3
  14. package/dist/runtime/queue-consumer.d.ts +4 -2
  15. package/dist/runtime/queue.d.ts +3 -2
  16. package/dist/runtime/read-engine.d.ts +11 -9
  17. package/dist/runtime/read-engine.js +4 -1
  18. package/dist/runtime/registry.d.ts +4 -1
  19. package/dist/runtime/registry.js +0 -3
  20. package/dist/runtime/schema-diff.d.ts +6 -6
  21. package/dist/runtime/schema-diff.js +4 -4
  22. package/dist/sdk/acl.d.ts +18 -11
  23. package/dist/sdk/handlers.d.ts +9 -3
  24. package/dist/sdk/infer.d.ts +17 -0
  25. package/dist/worker.d.ts +2 -1
  26. package/dist/worker.js +16 -10
  27. package/package.json +1 -1
  28. package/src/auth.ts +7 -6
  29. package/src/cli.ts +21 -9
  30. package/src/durable-object.ts +15 -8
  31. package/src/index.ts +6 -2
  32. package/src/pramen.ts +4 -2
  33. package/src/runtime/acl.ts +37 -31
  34. package/src/runtime/db.ts +14 -12
  35. package/src/runtime/dispatch.ts +5 -3
  36. package/src/runtime/driver.ts +11 -7
  37. package/src/runtime/mail.ts +2 -1
  38. package/src/runtime/migrate.ts +2 -1
  39. package/src/runtime/outbox.ts +2 -1
  40. package/src/runtime/protocol.ts +5 -3
  41. package/src/runtime/queue-consumer.ts +4 -2
  42. package/src/runtime/queue.ts +4 -2
  43. package/src/runtime/read-engine.ts +27 -21
  44. package/src/runtime/registry.ts +5 -1
  45. package/src/runtime/schema-diff.ts +14 -14
  46. package/src/sdk/acl.ts +29 -14
  47. package/src/sdk/handlers.ts +10 -3
  48. package/src/sdk/infer.ts +21 -0
  49. package/src/worker.ts +20 -11
@@ -15,6 +15,8 @@ import {
15
15
  type ResolverDb,
16
16
  type Role,
17
17
  type Validator,
18
+ type WhereRule,
19
+ type WhereValue,
18
20
  deny,
19
21
  isAllow,
20
22
  isDeny,
@@ -25,7 +27,8 @@ import {
25
27
  } from "../sdk/acl";
26
28
  import { and, compileWhere, evalExpr, FALSE, not, or, TRUE, type SqlExpr } from "./read-engine";
27
29
  import { BadRequest, PramenError } from "./errors";
28
- import type { FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
30
+ import type { CellValue, Row } from "../sdk/infer";
31
+ import type { FieldDef, RelationDef, RelationDefs, SchemaDef } from "../sdk/schema";
29
32
 
30
33
  export class AclDenied extends PramenError {
31
34
  constructor(
@@ -150,7 +153,7 @@ function grantOf(rule: PolicyRules | RelationAclRule, where: SqlExpr | null, ent
150
153
  conditional: (rule.conditionalFields ?? []).map((cf) => ({
151
154
  // Cell-level `when` is evaluated per-row in memory (evalExpr), so it must stay
152
155
  // single-table — `allowRelations: false` rejects a relation key up front.
153
- when: compileScopedWhere(cf.when as Record<string, unknown>, entity, ctx, depth, false),
156
+ when: compileScopedWhere(cf.when as WhereRule, entity, ctx, depth, false),
154
157
  fields: cf.fields,
155
158
  })),
156
159
  fieldsFns: rule.fieldsFn ? [rule.fieldsFn] : [],
@@ -168,7 +171,7 @@ function rolesOf(identity: Identity | null): string[] {
168
171
  }
169
172
 
170
173
  function getPath(obj: unknown, path: string): unknown {
171
- return path.split(".").reduce<unknown>((acc, seg) => (acc == null ? undefined : (acc as Record<string, unknown>)[seg]), obj ?? undefined);
174
+ return path.split(".").reduce<unknown>((acc, seg) => (acc == null ? undefined : (acc as WhereRule)[seg]), obj ?? undefined);
172
175
  }
173
176
 
174
177
  const UNRESOLVED = Symbol("unresolved");
@@ -177,15 +180,18 @@ const UNRESOLVED = Symbol("unresolved");
177
180
  // $input marker (against the request input — a capability/by-key grant), or a
178
181
  // $now marker (the evaluation instant). An unresolvable marker yields UNRESOLVED,
179
182
  // which makes its rule match nothing. $now always resolves.
180
- function resolveValue(v: unknown, identity: Identity | null, input: unknown): unknown {
183
+ /** A resolved where value, or the sentinel meaning "this marker could not resolve". */
184
+ type ResolvedWhereValue = WhereValue | typeof UNRESOLVED;
185
+
186
+ function resolveValue(v: WhereValue, identity: Identity | null, input: unknown): ResolvedWhereValue {
181
187
  if (isNowMarker(v)) return new Date().toISOString();
182
188
  if (isIdentityMarker(v)) {
183
189
  const value = getPath(identity, v.path);
184
- return value === undefined ? UNRESOLVED : value;
190
+ return value === undefined ? UNRESOLVED : (value as WhereValue);
185
191
  }
186
192
  if (isInputMarker(v)) {
187
193
  const value = getPath(input, v.path);
188
- return value === undefined ? UNRESOLVED : value;
194
+ return value === undefined ? UNRESOLVED : (value as WhereValue);
189
195
  }
190
196
  return v;
191
197
  }
@@ -199,22 +205,22 @@ function resolveValue(v: unknown, identity: Identity | null, input: unknown): un
199
205
  // whole rule — so `OR: [{ x: $identity(...) }, { public: true }]` still matches
200
206
  // the `public` branch for a caller whose marker can't resolve. (See the comment
201
207
  // on `compileScopedWhere`.)
202
- function resolveMarkers(rule: Record<string, unknown>, identity: Identity | null, input: unknown): Record<string, unknown> | null {
203
- const out: Record<string, unknown> = {};
208
+ function resolveMarkers(rule: WhereRule, identity: Identity | null, input: unknown): WhereRule | null {
209
+ const out: WhereRule = {};
204
210
  for (const [key, v] of Object.entries(rule)) {
205
211
  const isMarker = isIdentityMarker(v) || isInputMarker(v) || isNowMarker(v);
206
212
  if (v !== null && typeof v === "object" && !isMarker && !Array.isArray(v)) {
207
- const ops: Record<string, unknown> = {};
208
- for (const [op, val] of Object.entries(v as Record<string, unknown>)) {
213
+ const ops: WhereRule = {};
214
+ for (const [op, val] of Object.entries(v as WhereRule)) {
209
215
  if (op === "in" || op === "notIn") {
210
- let arr: unknown;
216
+ let arr: ResolvedWhereValue;
211
217
  if (isIdentityMarker(val) || isInputMarker(val)) {
212
218
  arr = resolveValue(val, identity, input);
213
219
  if (arr === UNRESOLVED) return null;
214
220
  } else {
215
- const mapped = (val as unknown[]).map((x) => resolveValue(x, identity, input));
221
+ const mapped = (val as WhereValue[]).map((x) => resolveValue(x, identity, input));
216
222
  if (mapped.some((x) => x === UNRESOLVED)) return null;
217
- arr = mapped;
223
+ arr = mapped as WhereValue[];
218
224
  }
219
225
  if (!Array.isArray(arr)) return null; // marker must resolve to a list
220
226
  ops[op] = arr;
@@ -258,13 +264,13 @@ function pkOf(schema: SchemaDef | undefined, entity: string): string {
258
264
  * unreadable column is LIKE-oracle'able through the subquery). Nested relation keys
259
265
  * are skipped: they're re-scoped against THEIR own target's read scope downstream.
260
266
  * Mirrors Db.assertReadableWhere's recursion for the top-level user `where`. */
261
- function assertReadableRelationWhere(where: Record<string, unknown>, target: string, fields: string[], ctx: AclContext): void {
262
- const targetRels = (ctx.schema?.[target]?.relations ?? {}) as Record<string, unknown>;
267
+ function assertReadableRelationWhere(where: WhereRule, target: string, fields: string[], ctx: AclContext): void {
268
+ const targetRels: RelationDefs = ctx.schema?.[target]?.relations ?? {};
263
269
  for (const [k, v] of Object.entries(where)) {
264
270
  if (k === "AND" || k === "OR") {
265
- for (const g of v as Record<string, unknown>[]) assertReadableRelationWhere(g, target, fields, ctx);
271
+ for (const g of v as WhereRule[]) assertReadableRelationWhere(g, target, fields, ctx);
266
272
  } else if (k === "NOT") {
267
- assertReadableRelationWhere(v as Record<string, unknown>, target, fields, ctx);
273
+ assertReadableRelationWhere(v as WhereRule, target, fields, ctx);
268
274
  } else if (targetRels[k]) {
269
275
  continue;
270
276
  } else if (!fields.includes(k)) {
@@ -281,7 +287,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
281
287
  if (nested === null || typeof nested !== "object" || Array.isArray(nested)) {
282
288
  throw new BadRequest(`relation filter for '${rel.target}' must be an object`);
283
289
  }
284
- let inner = compileScopedWhere(nested as Record<string, unknown>, rel.target, ctx, depth + 1);
290
+ let inner = compileScopedWhere(nested as WhereRule, rel.target, ctx, depth + 1);
285
291
 
286
292
  // Security: a relation filter must respect the target's read ACL (else it leaks).
287
293
  // Two distinct "no" outcomes, matching how the rest of the read path behaves:
@@ -297,7 +303,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
297
303
  inner = FALSE; // can't filter through a relation you can't read
298
304
  } else {
299
305
  if (tScope.fields !== null) {
300
- assertReadableRelationWhere(nested as Record<string, unknown>, rel.target, tScope.fields, ctx);
306
+ assertReadableRelationWhere(nested as WhereRule, rel.target, tScope.fields, ctx);
301
307
  }
302
308
  if (tScope.where) inner = and(inner, tScope.where);
303
309
  }
@@ -336,7 +342,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
336
342
  * evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
337
343
  * clear authoring error instead of emitting a `sub` node that throws at read time. */
338
344
  export function compileScopedWhere(
339
- rule: Record<string, unknown>,
345
+ rule: WhereRule,
340
346
  entity: string,
341
347
  ctx: AclContext,
342
348
  depth = 0,
@@ -344,13 +350,13 @@ export function compileScopedWhere(
344
350
  ): SqlExpr {
345
351
  const relations = (ctx.schema?.[entity]?.relations ?? {}) as Record<string, RelationDef>;
346
352
  const parts: SqlExpr[] = [];
347
- const plain: Record<string, unknown> = {};
353
+ const plain: WhereRule = {};
348
354
  for (const [k, v] of Object.entries(rule)) {
349
355
  if (k === "AND" || k === "OR") {
350
- const groups = (v as Record<string, unknown>[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
356
+ const groups = (v as WhereRule[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
351
357
  parts.push(k === "AND" ? and(...groups) : or(...groups));
352
358
  } else if (k === "NOT") {
353
- parts.push(not(compileScopedWhere(v as Record<string, unknown>, entity, ctx, depth, allowRelations)));
359
+ parts.push(not(compileScopedWhere(v as WhereRule, entity, ctx, depth, allowRelations)));
354
360
  } else if (relations[k]) {
355
361
  if (!allowRelations) {
356
362
  throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
@@ -420,7 +426,7 @@ export function resolveScope(ctx: AclContext, entity: string, action: Action, de
420
426
  if (isDeny(rule)) continue;
421
427
  if (isAllow(rule)) grants.push(ALLOW_GRANT);
422
428
  else {
423
- const where = compileScopedWhere((rule.where ?? {}) as Record<string, unknown>, entity, ctx, depth);
429
+ const where = compileScopedWhere((rule.where ?? {}) as WhereRule, entity, ctx, depth);
424
430
  grants.push(grantOf(rule, where, entity, ctx, depth));
425
431
  }
426
432
  }
@@ -431,7 +437,7 @@ export function resolveScope(ctx: AclContext, entity: string, action: Action, de
431
437
  * Returns null (all fields) when the base is null or a resolver grants everything. */
432
438
  export function effectiveFields(
433
439
  scope: Scope,
434
- row: Record<string, unknown>,
440
+ row: Row,
435
441
  identity: Identity | null,
436
442
  ): string[] | null {
437
443
  if (scope.fields === null) return null;
@@ -448,18 +454,18 @@ export function effectiveFields(
448
454
  /** Forced values + validators for a write, gathered from matched write policies.
449
455
  * `set` values are resolved against the identity; later policies override earlier. */
450
456
  export interface WriteRules {
451
- set: Record<string, unknown>;
457
+ set: Row;
452
458
  validators: Validator[];
453
459
  }
454
460
 
455
461
  export function resolveWriteRules(ctx: AclContext, entity: string, action: Action): WriteRules {
456
- const set: Record<string, unknown> = {};
462
+ const set: Row = {};
457
463
  const validators: Validator[] = [];
458
464
  for (const rule of matchedRules(ctx, entity, action)) {
459
465
  if (isAllow(rule) || isDeny(rule)) continue;
460
466
  if (rule.set) {
461
467
  for (const [col, v] of Object.entries(rule.set)) {
462
- set[col] = typeof v === "function" ? (v as (i: Identity | null) => unknown)(ctx.identity) : v;
468
+ set[col] = typeof v === "function" ? (v as (i: Identity | null) => CellValue)(ctx.identity) : v;
463
469
  }
464
470
  }
465
471
  if (rule.validate) validators.push(rule.validate);
@@ -487,7 +493,7 @@ export function resolveRelationScope(
487
493
  if (isAllow(rule) || isDeny(rule)) continue;
488
494
  const rel = rule.relations?.[relName];
489
495
  if (rel?.directAccess) {
490
- const relWhere = rel.where ? compileScopedWhere(rel.where as Record<string, unknown>, target, ctx, 0) : null;
496
+ const relWhere = rel.where ? compileScopedWhere(rel.where as WhereRule, target, ctx, 0) : null;
491
497
  grants.push(grantOf(rel, relWhere, target, ctx, 0));
492
498
  }
493
499
  }
@@ -496,9 +502,9 @@ export function resolveRelationScope(
496
502
  }
497
503
 
498
504
  /** Project a row to the permitted fields. null = all. */
499
- export function projectRow(row: Record<string, unknown>, fields: string[] | null): Record<string, unknown> {
505
+ export function projectRow(row: Row, fields: string[] | null): Row {
500
506
  if (!fields) return row;
501
- const out: Record<string, unknown> = {};
507
+ const out: Row = {};
502
508
  for (const f of fields) if (f in row) out[f] = row[f];
503
509
  return out;
504
510
  }
package/src/runtime/db.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  type Scope,
25
25
  } from "./acl";
26
26
  import type { Validator } from "../sdk/acl";
27
+ import type { CellValue, Row as SharedRow } from "../sdk/infer";
28
+ import type { WhereRule } from "../sdk/acl";
27
29
  import {
28
30
  and,
29
31
  cmp,
@@ -48,7 +50,7 @@ import { partitionOf, triggersOf, triggerFires, type EntityFields, type FieldDef
48
50
  import { isValidUuid } from "../sdk/uuid";
49
51
  import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
50
52
 
51
- type Row = Record<string, unknown>;
53
+ type Row = SharedRow;
52
54
  type Action = "read" | "create" | "update" | "delete";
53
55
  type Id = string | number | bigint;
54
56
  type Selected = Partial<Record<string, true>> | undefined;
@@ -174,7 +176,7 @@ function encodeCursor(order: OrderBy[], row: Row): string {
174
176
  return btoa(JSON.stringify(vals)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
175
177
  }
176
178
 
177
- function decodeCursor(s: string): unknown[] {
179
+ function decodeCursor(s: string): CellValue[] {
178
180
  try {
179
181
  const arr = JSON.parse(atob(s.replace(/-/g, "+").replace(/_/g, "/")));
180
182
  if (!Array.isArray(arr)) throw new Error("not an array");
@@ -191,7 +193,7 @@ function decodeCursor(s: string): unknown[] {
191
193
  // v non-null-> col > v (NULL cols excluded, they sort before)
192
194
  // DESC: v null -> nothing is strictly after a null (FALSE; PK tiebreak advances)
193
195
  // v non-null-> col < v OR col IS NULL (nulls sort after all non-nulls)
194
- function keysetCmp(o: OrderBy, value: unknown): SqlExpr {
196
+ function keysetCmp(o: OrderBy, value: CellValue): SqlExpr {
195
197
  const desc = o.dir === "desc";
196
198
  if (value === null) return desc ? FALSE : isNull(o.column, true);
197
199
  return desc ? or(cmp("<", o.column, value), isNull(o.column)) : cmp(">", o.column, value);
@@ -200,7 +202,7 @@ function keysetCmp(o: OrderBy, value: unknown): SqlExpr {
200
202
  // Strictly-after predicate for a composite key: lexicographic comparison,
201
203
  // e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <. Each
202
204
  // column's comparison and the eq-tiebreaker are NULL-aware (eq() maps null→IS NULL).
203
- function keysetAfter(order: OrderBy[], values: unknown[]): SqlExpr {
205
+ function keysetAfter(order: OrderBy[], values: CellValue[]): SqlExpr {
204
206
  const ors: SqlExpr[] = [];
205
207
  for (let i = 0; i < order.length; i++) {
206
208
  const parts: SqlExpr[] = [];
@@ -433,7 +435,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
433
435
  // Compiles the user's where relation-aware (relation keys → security-scoped
434
436
  // subqueries), then AND-merges the entity's own ACL row scope.
435
437
  if (userWhere) this.assertReadableWhere(from, scope, userWhere);
436
- const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as Record<string, unknown>, from, this.acl) : TRUE;
438
+ const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as WhereRule, from, this.acl) : TRUE;
437
439
  const where = scope.where ? and(userExpr, scope.where) : userExpr;
438
440
  // A relation-traversal `where` (or a relation-traversing ACL scope) compiles to a
439
441
  // `sub` node over another table. Record those tables in `touched` so the live-query
@@ -473,7 +475,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
473
475
  const relations = this.schema[from]?.relations ?? {};
474
476
  for (const [k, v] of Object.entries(where as Record<string, unknown>)) {
475
477
  if (k === "AND" || k === "OR") {
476
- for (const g of v as unknown[]) this.assertReadableWhere(from, scope, g);
478
+ for (const g of v as WhereRule[]) this.assertReadableWhere(from, scope, g);
477
479
  } else if (k === "NOT") {
478
480
  this.assertReadableWhere(from, scope, v);
479
481
  } else if (relations[k]) {
@@ -706,7 +708,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
706
708
  }
707
709
 
708
710
  /** Encode one write cell: JSON-stringify a json/fileRef value, then dialect-encode. */
709
- private encodeCell(jsonCols: Set<string>, col: string, v: unknown): unknown {
711
+ private encodeCell(jsonCols: Set<string>, col: string, v: CellValue): CellValue {
710
712
  if (v != null && jsonCols.has(col)) return this.dialect.encode(JSON.stringify(v));
711
713
  return this.dialect.encode(v);
712
714
  }
@@ -767,7 +769,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
767
769
  const project = (row: Row): Row => this.stripHidden(rel.target, projectRow(row, effectiveFields(scope, row, this.acl.identity)));
768
770
  // One IN query per relation (no N+1). Match column before projecting (which
769
771
  // may drop the join column).
770
- const fetchBy = async (col: string, values: unknown[]): Promise<Array<{ key: unknown; row: Row }>> => {
772
+ const fetchBy = async (col: string, values: CellValue[]): Promise<Array<{ key: CellValue; row: Row }>> => {
771
773
  if (values.length === 0) return [];
772
774
  const where = scope.where ? and(inList(col, values), scope.where) : inList(col, values);
773
775
  // Project the target to its readable columns (+ the join `col`, matched before
@@ -920,7 +922,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
920
922
  this.runValidators(validators, p);
921
923
  this.assertValidUuids(table, p);
922
924
 
923
- const params: unknown[] = [];
925
+ const params: CellValue[] = [];
924
926
  const jsonCols = new Set(this.jsonColsOf(table));
925
927
  const assignments = cols
926
928
  .map((c) => {
@@ -943,7 +945,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
943
945
  this.assertInPartition(table);
944
946
  const scope = this.scopeFor(table, "delete");
945
947
  if (!scope.allowed) throw new AclDenied(table, "delete");
946
- const params: unknown[] = [this.dialect.encode(id)];
948
+ const params: CellValue[] = [this.dialect.encode(id)];
947
949
  let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
948
950
  sql += this.scopeClause(scope.where, params);
949
951
  sql += this.returningClause("*");
@@ -953,7 +955,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
953
955
  }
954
956
 
955
957
  /** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
956
- async exec(sql: string, ...params: unknown[]): Promise<Row[]> {
958
+ async exec(sql: string, ...params: CellValue[]): Promise<Row[]> {
957
959
  return this.driver.exec(sql, params.map((p) => this.dialect.encode(p)));
958
960
  }
959
961
 
@@ -963,7 +965,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
963
965
  return this.dialect.returning ? ` RETURNING ${cols}` : "";
964
966
  }
965
967
 
966
- private scopeClause(where: SqlExpr | null, params: unknown[]): string {
968
+ private scopeClause(where: SqlExpr | null, params: CellValue[]): string {
967
969
  if (!where) return "";
968
970
  const compiled = compileExpr(where, this.dialect, params);
969
971
  return compiled.sql === "1" ? "" : ` AND (${compiled.sql})`;
@@ -20,6 +20,8 @@ import type { Files } from "../sdk/files";
20
20
  import type { ResolverDb } from "../sdk/acl";
21
21
  import type { SchemaDef } from "../sdk/schema";
22
22
  import { authorizeHandler, type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
23
+ import type { EnvBag } from "../sdk/handlers";
24
+ import type { JsonValue } from "../sdk/infer";
23
25
 
24
26
  export interface DispatchResult {
25
27
  readonly result: unknown;
@@ -53,10 +55,10 @@ export async function dispatch(
53
55
  driver: Driver,
54
56
  kv: Kv,
55
57
  files: Files,
56
- env: Readonly<Record<string, unknown>>,
58
+ env: EnvBag,
57
59
  acl: AclContext,
58
60
  name: string,
59
- input: unknown,
61
+ input: JsonValue,
60
62
  ): Promise<DispatchResult> {
61
63
  const handler = handlers[name];
62
64
  if (!handler) throw new BadRequest(`unknown handler: ${name}`);
@@ -68,7 +70,7 @@ export async function dispatch(
68
70
  }
69
71
 
70
72
  // Validate/parse the request input at the boundary, if the handler declares it.
71
- let parsed = input;
73
+ let parsed: unknown = input;
72
74
  if (handler.input) {
73
75
  try {
74
76
  parsed = handler.input(input);
@@ -11,7 +11,11 @@
11
11
  // in a Driver, rather than rewriting the engine. Live queries remain a DO-only
12
12
  // capability (they need a single writer + a stateful socket host).
13
13
 
14
- export type Row = Record<string, unknown>;
14
+ /** A raw row exactly as the substrate returns it, before pramen's object↔JSON codec.
15
+ * Distinct from the decoded `Row` handlers see at the `Db` chokepoint. */
16
+ export type DriverRow = Record<string, SqlValue>;
17
+
18
+ import type { CellValue, SqlValue } from "../sdk/infer";
15
19
 
16
20
  export interface Dialect {
17
21
  /** Render an identifier (table/column), quoting as the backend requires. */
@@ -21,7 +25,7 @@ export interface Dialect {
21
25
  /** Whether INSERT/UPDATE/DELETE ... RETURNING is supported (SQLite/Postgres yes; MySQL no). */
22
26
  readonly returning: boolean;
23
27
  /** Coerce a JS value for binding (e.g. boolean → 0/1 on SQLite). */
24
- encode(v: unknown): unknown;
28
+ encode(v: CellValue): CellValue;
25
29
  }
26
30
 
27
31
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -66,7 +70,7 @@ export interface Driver {
66
70
  readonly dialect: Dialect;
67
71
  /** Run a parameterized statement and return the result rows (empty for writes
68
72
  * without RETURNING). Params are already dialect-encoded by the caller. */
69
- exec(sql: string, params: unknown[]): Promise<Row[]>;
73
+ exec(sql: string, params: CellValue[]): Promise<DriverRow[]>;
70
74
  /** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
71
75
  transaction<T>(fn: () => Promise<T>): Promise<T>;
72
76
  /** Run a fixed sequence of write statements ATOMICALLY with FK checks deferred to the
@@ -83,8 +87,8 @@ export class DoSqliteDriver implements Driver {
83
87
  readonly dialect = sqliteDialect;
84
88
  constructor(private readonly storage: DurableObjectStorage) {}
85
89
 
86
- async exec(sql: string, params: unknown[]): Promise<Row[]> {
87
- return this.storage.sql.exec(sql, ...params).toArray() as Row[];
90
+ async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
91
+ return this.storage.sql.exec(sql, ...params).toArray() as DriverRow[];
88
92
  }
89
93
 
90
94
  transaction<T>(fn: () => Promise<T>): Promise<T> {
@@ -124,9 +128,9 @@ export class D1Driver implements Driver {
124
128
  this.session = db.withSession(opts?.start ?? "first-unconstrained");
125
129
  }
126
130
 
127
- async exec(sql: string, params: unknown[]): Promise<Row[]> {
131
+ async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
128
132
  const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
129
- const { results } = await stmt.all<Row>();
133
+ const { results } = await stmt.all<DriverRow>();
130
134
  return results ?? [];
131
135
  }
132
136
 
@@ -11,6 +11,7 @@
11
11
  // in-memory — so handlers work unchanged off-platform.
12
12
 
13
13
  import type { Kv } from "./kv";
14
+ import type { EnvBag } from "../sdk/handlers";
14
15
 
15
16
  export interface MailAddress {
16
17
  email: string;
@@ -120,7 +121,7 @@ export class UnconfiguredMailAdapter implements MailAdapter {
120
121
  * a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
121
122
  * - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
122
123
  * stash security emails in KV). */
123
- export function createMail(env: Readonly<Record<string, unknown>>, kv?: Kv): Mail {
124
+ export function createMail(env: EnvBag, kv?: Kv): Mail {
124
125
  const binding = env.EMAIL as SendEmailBinding | undefined;
125
126
  const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
126
127
  if (binding && fromAddr) {
@@ -43,6 +43,7 @@ import { digest } from "./digest";
43
43
  import { quoteIdent, type Driver } from "./driver";
44
44
  import { entitiesInPartition, partitionOf, validateSchema } from "../sdk/schema";
45
45
  import type { EntityFields, FieldDef, SchemaDef } from "../sdk/schema";
46
+ import type { CellValue } from "../sdk/infer";
46
47
 
47
48
  export interface MigrationReport {
48
49
  changed: boolean;
@@ -306,7 +307,7 @@ async function rebuildTable(
306
307
  // rebuilt table whose FKs momentarily see stale rows), so run the whole sequence
307
308
  // ATOMICALLY: the D1 driver's batch() defers FK checks to the batch commit, and on the
308
309
  // DO the ambient boot transaction (+ defer set at migrate start) already covers it.
309
- const stmts: { sql: string; params: unknown[] }[] = [];
310
+ const stmts: { sql: string; params: CellValue[] }[] = [];
310
311
  // Quarantine tables are bare column lists — untyped, no constraints, no FKs. Values
311
312
  // round-trip verbatim (they were already coerced by the original table's affinity).
312
313
  const bareCopy = (name: string, quotedCols: string[]): string => `CREATE TABLE ${quoteIdent(name)} (${quotedCols.join(", ")})`;
@@ -19,6 +19,7 @@
19
19
  // task `id` as an idempotency key so they can dedupe across the rare retry.
20
20
 
21
21
  import type { Driver } from "./driver";
22
+ import type { CellValue } from "../sdk/infer";
22
23
 
23
24
  export const OUTBOX_TABLE = "_pramen_outbox";
24
25
 
@@ -34,7 +35,7 @@ function backoffMs(attempts: number): number {
34
35
  return Math.min(2 ** attempts * 1000, 5 * 60_000); // 2s, 4s, 8s, … capped at 5min
35
36
  }
36
37
 
37
- const enc = (driver: Driver, params: unknown[]): unknown[] => params.map((p) => driver.dialect.encode(p));
38
+ const enc = (driver: Driver, params: CellValue[]): CellValue[] => params.map((p) => driver.dialect.encode(p));
38
39
 
39
40
  /** Create the outbox table if absent. Idempotent — run on DO boot (and lazily on the
40
41
  * D1 path). Internal table (`_pramen_` prefix), never part of the user schema. */
@@ -10,11 +10,13 @@
10
10
  // { type: "result", id, result } // reply to a one-shot call
11
11
  // { type: "error", id, error }
12
12
 
13
+ import type { JsonValue } from "../sdk/infer";
14
+
13
15
  export interface SubscribeMsg {
14
16
  type: "subscribe";
15
17
  id: string;
16
18
  name: string;
17
- input?: unknown;
19
+ input?: JsonValue;
18
20
  }
19
21
  export interface UnsubscribeMsg {
20
22
  type: "unsubscribe";
@@ -24,7 +26,7 @@ export interface CallMsg {
24
26
  type: "call";
25
27
  id: string;
26
28
  name: string;
27
- input?: unknown;
29
+ input?: JsonValue;
28
30
  }
29
31
 
30
32
  export type ClientMsg = SubscribeMsg | UnsubscribeMsg | CallMsg;
@@ -38,7 +40,7 @@ export type ServerMsg =
38
40
  export interface Subscription {
39
41
  id: string;
40
42
  name: string;
41
- input: unknown;
43
+ input: JsonValue;
42
44
  /** Tables the query read — the coarse prefilter for which writes might matter. */
43
45
  tables: string[];
44
46
  /** Digest of the last result pushed — used to suppress no-op (row-level) pushes. */
@@ -12,6 +12,8 @@
12
12
  import type { Mail } from "./mail";
13
13
  import type { Queue } from "./queue";
14
14
  import type { Kv } from "./kv";
15
+ import type { EnvBag } from "../sdk/handlers";
16
+ import type { JsonValue } from "../sdk/infer";
15
17
 
16
18
  /** One received message (the Cloudflare Queues `Message` shape). */
17
19
  export interface QueueMessage<Body = unknown> {
@@ -40,7 +42,7 @@ export interface QueueBatch<Body = unknown> {
40
42
  * tenant data via `ctx.callPrivileged`. */
41
43
  export interface QueueContext {
42
44
  /** The Worker environment (bindings + vars + secrets). */
43
- readonly env: Readonly<Record<string, unknown>>;
45
+ readonly env: EnvBag;
44
46
  /** Project KV (cross-tenant). */
45
47
  readonly kv: Kv;
46
48
  /** Send email (the notification path). */
@@ -49,7 +51,7 @@ export interface QueueContext {
49
51
  readonly queue: Queue;
50
52
  /** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
51
53
  * The message body should carry the `tenant`. */
52
- callPrivileged(opts: { name: string; input?: unknown; tenant?: string; roles?: string[]; partition?: string }): Promise<Response>;
54
+ callPrivileged(opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[]; partition?: string }): Promise<Response>;
53
55
  }
54
56
 
55
57
  /** A queue consumer handler — runs once per message. Resolving ACKs the message;
@@ -19,6 +19,8 @@
19
19
  // that isn't bound FAILS CLOSED (throws) rather than silently dropping the message —
20
20
  // mirroring how ctx.mail fails closed without a transport.
21
21
 
22
+ import type { EnvBag } from "../sdk/handlers";
23
+
22
24
  /** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
23
25
  * (v8 structured clone). Use "json" for cross-runtime / external consumers. */
24
26
  export type QueueContentType = "text" | "bytes" | "json" | "v8";
@@ -131,7 +133,7 @@ export class MemoryQueueAdapter implements QueueAdapter {
131
133
  /** Discover the Cloudflare Queues producer bindings in an environment: any value that
132
134
  * exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
133
135
  * binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
134
- export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding> {
136
+ export function discoverQueueBindings(env: EnvBag): Record<string, QueueProducerBinding> {
135
137
  const out: Record<string, QueueProducerBinding> = {};
136
138
  for (const [name, value] of Object.entries(env)) {
137
139
  if (
@@ -150,6 +152,6 @@ export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): R
150
152
  * producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
151
153
  * There is no silent capture fallback — declare the `Queue` binding and it exists in
152
154
  * dev (lopata) and miniflare too. */
153
- export function createQueue(env: Readonly<Record<string, unknown>>): Queue {
155
+ export function createQueue(env: EnvBag): Queue {
154
156
  return new Queue(new CloudflareQueueAdapter(discoverQueueBindings(env)));
155
157
  }