@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
@@ -6,11 +6,14 @@
6
6
  // AND/OR groups) and ACL row-level scopes be merged before compilation. Column
7
7
  // names come from developer code (schema keys); values are always parameterized.
8
8
 
9
+ import type { WhereRule, WhereValue } from "../sdk/acl";
10
+ import type { CellValue, Row } from "../sdk/infer";
11
+
9
12
  import type { Dialect } from "./driver";
10
13
 
11
14
  // In-process value coercion for evalExpr (SQLite-style booleans). SQL-side encoding
12
15
  // goes through the active Dialect; this mirrors it for the cell-ACL `when` evaluator.
13
- function bind(v: unknown): unknown {
16
+ function bind(v: CellValue): CellValue {
14
17
  return typeof v === "boolean" ? (v ? 1 : 0) : v;
15
18
  }
16
19
 
@@ -23,8 +26,8 @@ export type StrMode = "contains" | "prefix" | "suffix";
23
26
  export type SqlExpr =
24
27
  | { t: "true" }
25
28
  | { t: "false" }
26
- | { t: "cmp"; op: CmpOp; col: string; value: unknown }
27
- | { t: "in"; col: string; values: unknown[]; negate: boolean }
29
+ | { t: "cmp"; op: CmpOp; col: string; value: CellValue }
30
+ | { t: "in"; col: string; values: CellValue[]; negate: boolean }
28
31
  | { t: "null"; col: string; negate: boolean }
29
32
  // Structured substring match — the needle is escaped and wrapped, so `%`/`_` in the
30
33
  // input match literally (unlike raw `like`, where the caller controls wildcards).
@@ -39,28 +42,28 @@ export type SqlExpr =
39
42
 
40
43
  export const TRUE: SqlExpr = { t: "true" };
41
44
  export const FALSE: SqlExpr = { t: "false" };
42
- export const cmp = (op: CmpOp, col: string, value: unknown): SqlExpr => ({ t: "cmp", op, col, value });
45
+ export const cmp = (op: CmpOp, col: string, value: CellValue): SqlExpr => ({ t: "cmp", op, col, value });
43
46
  export const isNull = (col: string, negate = false): SqlExpr => ({ t: "null", col, negate });
44
- export const inList = (col: string, values: unknown[], negate = false): SqlExpr => ({ t: "in", col, values, negate });
47
+ export const inList = (col: string, values: CellValue[], negate = false): SqlExpr => ({ t: "in", col, values, negate });
45
48
  export const strMatch = (col: string, needle: string, mode: StrMode): SqlExpr => ({ t: "strmatch", col, needle, mode });
46
49
  export const and = (...parts: SqlExpr[]): SqlExpr => ({ t: "and", parts });
47
50
  export const or = (...parts: SqlExpr[]): SqlExpr => ({ t: "or", parts });
48
51
  export const not = (expr: SqlExpr): SqlExpr => ({ t: "not", expr });
49
52
 
50
53
  /** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
51
- export const eq = (col: string, value: unknown): SqlExpr => (value === null ? isNull(col) : cmp("=", col, value));
54
+ export const eq = (col: string, value: CellValue): SqlExpr => (value === null ? isNull(col) : cmp("=", col, value));
52
55
 
53
56
  /** Compile a structured user predicate into a SqlExpr.
54
57
  * Shapes: { col: value } (eq) | { col: { gt, lt, in, like, isNull, … } } | { AND: [...] } | { OR: [...] } */
55
- export function compileWhere(input: Record<string, unknown>): SqlExpr {
58
+ export function compileWhere(input: WhereRule): SqlExpr {
56
59
  const parts: SqlExpr[] = [];
57
60
  for (const [k, v] of Object.entries(input)) {
58
61
  if (k === "AND") {
59
- parts.push(and(...(v as Record<string, unknown>[]).map(compileWhere)));
62
+ parts.push(and(...(v as WhereRule[]).map(compileWhere)));
60
63
  } else if (k === "OR") {
61
- parts.push(or(...(v as Record<string, unknown>[]).map(compileWhere)));
64
+ parts.push(or(...(v as WhereRule[]).map(compileWhere)));
62
65
  } else if (k === "NOT") {
63
- parts.push(not(compileWhere(v as Record<string, unknown>)));
66
+ parts.push(not(compileWhere(v as WhereRule)));
64
67
  } else {
65
68
  parts.push(columnPredicate(k, v));
66
69
  }
@@ -68,10 +71,13 @@ export function compileWhere(input: Record<string, unknown>): SqlExpr {
68
71
  return parts.length ? and(...parts) : TRUE;
69
72
  }
70
73
 
71
- function columnPredicate(col: string, v: unknown): SqlExpr {
74
+ function columnPredicate(col: string, v: WhereValue): SqlExpr {
72
75
  if (v !== null && typeof v === "object" && !Array.isArray(v)) {
73
76
  const ops: SqlExpr[] = [];
74
- for (const [op, val] of Object.entries(v as Record<string, unknown>)) {
77
+ for (const [op, operand] of Object.entries(v as WhereRule)) {
78
+ // Markers are resolved upstream (runtime/acl.ts), so an operator's operand is a
79
+ // literal cell value by this point.
80
+ const val = operand as CellValue;
75
81
  switch (op) {
76
82
  case "eq": ops.push(eq(col, val)); break;
77
83
  case "ne": ops.push(val === null ? isNull(col, true) : cmp("!=", col, val)); break;
@@ -83,23 +89,23 @@ function columnPredicate(col: string, v: unknown): SqlExpr {
83
89
  case "contains": ops.push(strMatch(col, String(val), "contains")); break;
84
90
  case "startsWith": ops.push(strMatch(col, String(val), "prefix")); break;
85
91
  case "endsWith": ops.push(strMatch(col, String(val), "suffix")); break;
86
- case "in": ops.push(inList(col, val as unknown[])); break;
87
- case "notIn": ops.push(inList(col, val as unknown[], true)); break;
92
+ case "in": ops.push(inList(col, val as CellValue[])); break;
93
+ case "notIn": ops.push(inList(col, val as CellValue[], true)); break;
88
94
  case "isNull": ops.push(isNull(col, !val)); break; // isNull:true => IS NULL
89
95
  default: throw new Error(`unknown operator: ${op}`);
90
96
  }
91
97
  }
92
98
  return ops.length ? and(...ops) : TRUE;
93
99
  }
94
- return eq(col, v);
100
+ return eq(col, v as CellValue);
95
101
  }
96
102
 
97
103
  export interface CompiledSql {
98
104
  readonly sql: string;
99
- readonly params: unknown[];
105
+ readonly params: CellValue[];
100
106
  }
101
107
 
102
- export function compileExpr(expr: SqlExpr, dialect: Dialect, params: unknown[] = []): CompiledSql {
108
+ export function compileExpr(expr: SqlExpr, dialect: Dialect, params: CellValue[] = []): CompiledSql {
103
109
  switch (expr.t) {
104
110
  case "true":
105
111
  return { sql: "1", params };
@@ -168,7 +174,7 @@ function likeToRegex(pattern: string): RegExp {
168
174
  * semantics (bound-boolean coercion, NULL compares false, empty-IN, LIKE) so the
169
175
  * declarative cell-ACL `when` path can decide per-row field visibility without a
170
176
  * round-trip to SQLite. */
171
- export function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean {
177
+ export function evalExpr(expr: SqlExpr, row: Row): boolean {
172
178
  switch (expr.t) {
173
179
  case "true":
174
180
  return true;
@@ -245,7 +251,7 @@ export type AggFn = "count" | "sum" | "avg" | "min" | "max";
245
251
  const AGG_SQL: Record<AggFn, string> = { count: "COUNT", sum: "SUM", avg: "AVG", min: "MIN", max: "MAX" };
246
252
 
247
253
  export function compileCount(from: string, dialect: Dialect, where?: SqlExpr): CompiledSql {
248
- const params: unknown[] = [];
254
+ const params: CellValue[] = [];
249
255
  let sql = `SELECT COUNT(*) AS n FROM ${dialect.id(from)}`;
250
256
  if (where && where.t !== "true") sql += ` WHERE ${compileExpr(where, dialect, params).sql}`;
251
257
  return { sql, params };
@@ -265,7 +271,7 @@ export function compileAggregate(
265
271
  },
266
272
  dialect: Dialect,
267
273
  ): CompiledSql {
268
- const params: unknown[] = [];
274
+ const params: CellValue[] = [];
269
275
  const cols: string[] = [];
270
276
  for (const g of spec.groupBy ?? []) cols.push(dialect.id(g));
271
277
  for (const [key, agg] of Object.entries(spec.aggregations)) {
@@ -279,7 +285,7 @@ export function compileAggregate(
279
285
  }
280
286
 
281
287
  export function compileSelect(spec: QuerySpec, dialect: Dialect): CompiledSql {
282
- const params: unknown[] = [];
288
+ const params: CellValue[] = [];
283
289
  const cols = spec.columns && spec.columns.length > 0 ? spec.columns.map((c) => dialect.id(c)).join(", ") : "*";
284
290
  let sql = `SELECT ${cols} FROM ${dialect.id(spec.from)}`;
285
291
 
@@ -79,7 +79,11 @@ export function parseRegistryKey(key: string): DoRef | null {
79
79
  /** Enumerate every registered `(tenant, partition)` pair from the registry KV.
80
80
  * Paginates over the full listing (cursor / list_complete) — never truncates at the
81
81
  * 1000-key page limit. */
82
- export async function listDOs(kv: KVNamespace): Promise<DoRef[]> {
82
+ /** The slice of KV that DO enumeration needs — narrower than the whole namespace, so
83
+ * callers (and test doubles) only have to provide `list`. */
84
+ export type KvLister = Pick<KVNamespace, "list">;
85
+
86
+ export async function listDOs(kv: KvLister): Promise<DoRef[]> {
83
87
  const out: DoRef[] = [];
84
88
  let cursor: string | undefined;
85
89
  for (;;) {
@@ -21,7 +21,7 @@ import type { FieldDef, SchemaDef } from "../sdk/schema";
21
21
  import { partitionOf } from "../sdk/schema";
22
22
 
23
23
  /** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
24
- export interface ColumnShape {
24
+ export interface ColumnFingerprint {
25
25
  type: string;
26
26
  notNull?: boolean;
27
27
  unique?: boolean;
@@ -33,16 +33,16 @@ export interface ColumnShape {
33
33
  }
34
34
 
35
35
  /** The comparable fingerprint of a table: its partition + each column's shape. */
36
- export interface TableShape {
36
+ export interface TableFingerprint {
37
37
  partition: string;
38
- columns: Record<string, ColumnShape>;
38
+ columns: Record<string, ColumnFingerprint>;
39
39
  }
40
40
 
41
41
  /** table -> table shape. The comparable surface of a schema. */
42
- export type SchemaShape = Record<string, TableShape>;
42
+ export type SchemaFingerprint = Record<string, TableFingerprint>;
43
43
 
44
- function columnShape(f: FieldDef): ColumnShape {
45
- const c: ColumnShape = { type: f.type };
44
+ function columnFingerprint(f: FieldDef): ColumnFingerprint {
45
+ const c: ColumnFingerprint = { type: f.type };
46
46
  if (f.notNull) c.notNull = true;
47
47
  if (f.unique) c.unique = true;
48
48
  if (f.primaryKey) c.primaryKey = true;
@@ -53,27 +53,27 @@ function columnShape(f: FieldDef): ColumnShape {
53
53
  return c;
54
54
  }
55
55
 
56
- export function schemaShape(schema: SchemaDef): SchemaShape {
57
- const out: SchemaShape = {};
56
+ export function schemaFingerprint(schema: SchemaDef): SchemaFingerprint {
57
+ const out: SchemaFingerprint = {};
58
58
  for (const [table, def] of Object.entries(schema)) {
59
- const columns: Record<string, ColumnShape> = {};
60
- for (const [col, f] of Object.entries(def.fields)) columns[col] = columnShape(f as FieldDef);
59
+ const columns: Record<string, ColumnFingerprint> = {};
60
+ for (const [col, f] of Object.entries(def.fields)) columns[col] = columnFingerprint(f as FieldDef);
61
61
  out[table] = { partition: partitionOf(schema, table), columns };
62
62
  }
63
63
  return out;
64
64
  }
65
65
 
66
66
  /** The modifier fields compared for a `change-column` (everything but `type`). */
67
- const MODIFIER_KEYS: (keyof ColumnShape)[] = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
67
+ const MODIFIER_KEYS: (keyof ColumnFingerprint)[] = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
68
68
 
69
69
  /** Does `next` tighten a constraint `prev` lacked (add NOT NULL / UNIQUE / PRIMARY KEY)?
70
70
  * Such a change may require the destructive gate or be skipped when the live data
71
71
  * conflicts (NULL rows / duplicates) — so the diff flags it `destructive`. */
72
- function tightensConstraint(prev: ColumnShape, next: ColumnShape): boolean {
72
+ function tightensConstraint(prev: ColumnFingerprint, next: ColumnFingerprint): boolean {
73
73
  return (!!next.notNull && !prev.notNull) || (!!next.unique && !prev.unique) || (!!next.primaryKey && !prev.primaryKey);
74
74
  }
75
75
 
76
- function modifierDiff(prev: ColumnShape, next: ColumnShape): string | null {
76
+ function modifierDiff(prev: ColumnFingerprint, next: ColumnFingerprint): string | null {
77
77
  const parts: string[] = [];
78
78
  for (const k of MODIFIER_KEYS) {
79
79
  if (prev[k] !== next[k]) parts.push(`${k}: ${fmt(prev[k])} → ${fmt(next[k])}`);
@@ -102,7 +102,7 @@ export interface SchemaChange {
102
102
  appliesOnBoot: boolean;
103
103
  }
104
104
 
105
- export function diffSchemaShape(prev: SchemaShape, next: SchemaShape): SchemaChange[] {
105
+ export function diffSchemaFingerprint(prev: SchemaFingerprint, next: SchemaFingerprint): SchemaChange[] {
106
106
  const changes: SchemaChange[] = [];
107
107
 
108
108
  for (const table of Object.keys(next)) {
package/src/sdk/acl.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  // and/or a set of permitted `fields`. Access is deny-by-default; policies only
7
7
  // ever grant. Across the identity's roles, grants OR-merge (any role can allow).
8
8
 
9
+ import type { CellValue, JsonValue, Row } from "./infer";
10
+
9
11
  export type Action = "read" | "create" | "update" | "delete";
10
12
 
11
13
  /** Runtime identity. Augment with your own properties (userId, tier, …). */
@@ -17,7 +19,7 @@ export interface Identity {
17
19
  * expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
18
20
  * (callPrivileged) identities, which are therefore never treated as expired. */
19
21
  exp?: number;
20
- [key: string]: unknown;
22
+ [key: string]: JsonValue | undefined;
21
23
  }
22
24
 
23
25
  // --- $identity markers: reference an identity property inside a where rule ---
@@ -34,8 +36,8 @@ export function $identity(path: string): IdentityMarker {
34
36
  return { [IDENTITY_MARKER]: true, path };
35
37
  }
36
38
 
37
- export function isIdentityMarker(v: unknown): v is IdentityMarker {
38
- return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[IDENTITY_MARKER] === true;
39
+ export function isIdentityMarker(v: WhereValue): v is IdentityMarker {
40
+ return typeof v === "object" && v !== null && (v as Partial<IdentityMarker>)[IDENTITY_MARKER] === true;
39
41
  }
40
42
 
41
43
  // --- $input markers: reference a request-input field inside a where rule, for a
@@ -56,8 +58,8 @@ export function $input(path: string): InputMarker {
56
58
  return { [INPUT_MARKER]: true, path };
57
59
  }
58
60
 
59
- export function isInputMarker(v: unknown): v is InputMarker {
60
- return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[INPUT_MARKER] === true;
61
+ export function isInputMarker(v: WhereValue): v is InputMarker {
62
+ return typeof v === "object" && v !== null && (v as Partial<InputMarker>)[INPUT_MARKER] === true;
61
63
  }
62
64
 
63
65
  // --- $now markers: the request's evaluation instant inside a where rule, for a
@@ -88,8 +90,8 @@ export function $now(): NowMarker {
88
90
  return { [NOW_MARKER]: true };
89
91
  }
90
92
 
91
- export function isNowMarker(v: unknown): v is NowMarker {
92
- return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[NOW_MARKER] === true;
93
+ export function isNowMarker(v: WhereValue): v is NowMarker {
94
+ return typeof v === "object" && v !== null && (v as Partial<NowMarker>)[NOW_MARKER] === true;
93
95
  }
94
96
 
95
97
  // --- allow / deny markers ---
@@ -110,8 +112,21 @@ export function deny(): DenyMarker {
110
112
 
111
113
  // --- policy rules ---
112
114
 
113
- /** A where rule: column -> value, where value may be a literal or an $identity marker. */
114
- export type WhereRule = Record<string, unknown | IdentityMarker>;
115
+ /** A value inside a `where` rule: a literal cell value, a per-request marker, an
116
+ * operator object (`{ gte: 5 }`), a nested relation predicate, or an AND/OR group. */
117
+ export type WhereValue =
118
+ | CellValue
119
+ | IdentityMarker
120
+ | InputMarker
121
+ | NowMarker
122
+ | WhereRule
123
+ | WhereValue[];
124
+
125
+ /** A where rule: column (or `AND`/`OR`/`NOT`) -> value. An interface so it can recur
126
+ * through `WhereValue` for nested relation predicates and boolean groups. */
127
+ export interface WhereRule {
128
+ [key: string]: WhereValue;
129
+ }
115
130
 
116
131
  /** A per-row (cell-level) field grant: `fields` are permitted only for rows that
117
132
  * match `when`. Additive over the policy's flat `fields` — a conditional grant can
@@ -125,7 +140,7 @@ export interface ConditionalFields {
125
140
  /** Escape hatch for cell-level ACL: a late per-row resolver. Given the identity and
126
141
  * the fetched (or candidate, on write) row, returns the extra permitted fields —
127
142
  * additive over `fields`; `null` means all fields for that row. */
128
- export type FieldsFn = (identity: Identity | null, row: Record<string, unknown>) => string[] | null;
143
+ export type FieldsFn = (identity: Identity | null, row: Row) => string[] | null;
129
144
 
130
145
  /** Per-relation ACL inside a parent read policy. */
131
146
  export interface RelationAclRule {
@@ -143,10 +158,10 @@ export interface RelationAclRule {
143
158
  }
144
159
 
145
160
  /** A forced column value on write: a literal, or computed from the identity. */
146
- export type SetValue = unknown | ((identity: Identity | null) => unknown);
161
+ export type SetValue = CellValue | ((identity: Identity | null) => CellValue);
147
162
 
148
163
  /** Server-side validation on write; throw to reject. Runs on the final values. */
149
- export type Validator = (args: { identity: Identity | null; values: Record<string, unknown> }) => void;
164
+ export type Validator = (args: { identity: Identity | null; values: Row }) => void;
150
165
 
151
166
  export interface PolicyRules {
152
167
  /** Row-level predicate (AND of equalities). Omit/empty = all rows. */
@@ -175,10 +190,10 @@ export interface PolicyRules {
175
190
  export interface ResolverDb {
176
191
  find(spec: {
177
192
  from: string;
178
- where?: Record<string, unknown>;
193
+ where?: WhereRule;
179
194
  orderBy?: { column: string; dir?: "asc" | "desc" };
180
195
  limit?: number;
181
- }): Promise<Array<Record<string, unknown>>>;
196
+ }): Promise<Row[]>;
182
197
  }
183
198
 
184
199
  export interface ResolverContext {
@@ -10,6 +10,13 @@ import type { Queue } from "../runtime/queue";
10
10
  import type { Identity } from "./acl";
11
11
  import type { Files } from "./files";
12
12
  import type { SchemaDef } from "./schema";
13
+ import type { JsonValue } from "./infer";
14
+
15
+ /** The Worker/DO environment as an open, read-only bag: Cloudflare bindings (KV, R2,
16
+ * D1, Queues, …) alongside vars and secrets. Deliberately open and opaque — an app
17
+ * declares its own bindings, so the value type cannot be enumerated here; read a value
18
+ * and narrow it at the use site (`ctx.env.STRIPE_SECRET_KEY as string`). */
19
+ export type EnvBag = Readonly<Record<string, unknown>>;
13
20
 
14
21
  export interface HandlerContext<S extends SchemaDef = SchemaDef> {
15
22
  /** Schema-typed repository: find/insert/update/delete inferred from S. */
@@ -30,7 +37,7 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
30
37
  * Use it to call external services from handlers — Cloudflare bindings (e.g. the
31
38
  * `send_email` binding for Cloudflare Email Sending) or third-party APIs (Stripe, …). Loosely typed;
32
39
  * cast a value at the use site, e.g. `ctx.env.STRIPE_SECRET_KEY as string`. */
33
- readonly env: Readonly<Record<string, unknown>>;
40
+ readonly env: EnvBag;
34
41
  /** Resolved identity for this request (null = anonymous). */
35
42
  readonly identity: Identity | null;
36
43
  /** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
@@ -119,7 +126,7 @@ export interface Handler<I = unknown, O = unknown> {
119
126
  readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
120
127
  /** Optional boundary validator: parse/validate the raw request input, throwing
121
128
  * to reject (surfaced as a 400). Its return type fixes the handler's input. */
122
- readonly input?: (raw: unknown) => unknown;
129
+ readonly input?: (raw: JsonValue) => unknown;
123
130
  /** Optional DO partition this handler runs in (static, server-side). The Worker
124
131
  * routes the request to the matching partition-DO before dispatch. Absent ⇒ the
125
132
  * default partition (routed to the bare tenant key). */
@@ -129,7 +136,7 @@ export interface Handler<I = unknown, O = unknown> {
129
136
  }
130
137
 
131
138
  export interface HandlerOpts<I> {
132
- input?: (raw: unknown) => I;
139
+ input?: (raw: JsonValue) => I;
133
140
  /** DO partition this handler runs in. Absent ⇒ the default partition. */
134
141
  partition?: string;
135
142
  /** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
package/src/sdk/infer.ts CHANGED
@@ -11,6 +11,27 @@ export type { FileRef } from "./files";
11
11
  /** Any JSON-serializable value — the type of a `t.json()` column. */
12
12
  export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
13
13
 
14
+ /** A JSON object — the object arm of `JsonValue`, named so it can be referenced
15
+ * directly (e.g. an identity's claims, a `t.json()` column's object form). */
16
+ export interface JsonObject {
17
+ [key: string]: JsonValue;
18
+ }
19
+
20
+ /** A raw value as the substrate stores/returns it, before pramen's object↔JSON codec.
21
+ * DO SQLite and D1 hand back exactly these; BLOB columns arrive as an ArrayBuffer. */
22
+ export type SqlValue = string | number | bigint | boolean | null | ArrayBuffer;
23
+
24
+ /** A decoded column value as handlers see it at the `Db` chokepoint: any JSON value,
25
+ * a `fileRef` column's metadata, or — for an eager-loaded relation — the related
26
+ * row(s) grafted onto the parent under the relation name. */
27
+ export type CellValue = SqlValue | JsonValue | FileRef | Row | Row[];
28
+
29
+ /** A decoded database row — column name -> decoded value. An interface (not a
30
+ * `Record` alias) so it can recur through `CellValue` for eager-loaded relations. */
31
+ export interface Row {
32
+ [column: string]: CellValue;
33
+ }
34
+
14
35
  /** SQL field type -> TypeScript value type. */
15
36
  export type FieldTsType<D extends FieldDef> = D["type"] extends "text"
16
37
  ? string
package/src/worker.ts CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity, type VerifyOptions, type VerifyStrategy } from "./auth";
8
8
  import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
9
+ import type { EnvBag } from "./sdk/handlers";
10
+ import type { JsonValue } from "./sdk/infer";
9
11
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
10
12
  import { createMail } from "./runtime/mail";
11
13
  import { createQueue, type QueueProducerBinding } from "./runtime/queue";
@@ -23,6 +25,11 @@ import type { HandlerContext } from "./sdk/handlers";
23
25
  import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
24
26
  import type { PramenApp } from "./pramen";
25
27
 
28
+ /** Widen the closed `Env` interface to the open `EnvBag` handlers and services see.
29
+ * Spreading yields an anonymous object type, which TypeScript gives an implicit index
30
+ * signature — so this needs no type assertion. */
31
+ const envBag = (env: Env): EnvBag => ({ ...env });
32
+
26
33
  export interface Env {
27
34
  PRAMEN: DurableObjectNamespace;
28
35
  /** Project KV — tenant registry (`tenant:` keys) + handler ctx.kv (`app:` keys). */
@@ -149,7 +156,7 @@ function partitionStubFor(env: Env, tenant: string, partition: string = DEFAULT_
149
156
  * DO's JSON response (`{ ok, result }` / `{ ok: false, … }`). */
150
157
  export async function callPrivileged(
151
158
  env: Env,
152
- opts: { name: string; input?: unknown; tenant?: string; roles?: string[]; partition?: string },
159
+ opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[]; partition?: string },
153
160
  ): Promise<Response> {
154
161
  const tenant = opts.tenant ?? "main";
155
162
  const partition = opts.partition ?? DEFAULT_PARTITION;
@@ -235,7 +242,8 @@ export function makeWorker(app: PramenApp) {
235
242
  const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
236
243
  const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
237
244
  const kv = new Kv(env.KV);
238
- return { db, kv, files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver), mail: createMail(env as unknown as Record<string, unknown>, kv), queue: createQueue(env as unknown as Record<string, unknown>) };
245
+ const bag = envBag(env);
246
+ return { db, kv, files, env: bag, identity, tasks: tasksFacade(driver), mail: createMail(bag, kv), queue: createQueue(bag) };
239
247
  };
240
248
 
241
249
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
@@ -281,7 +289,7 @@ export function makeWorker(app: PramenApp) {
281
289
  for (const r of app.routes ?? []) {
282
290
  if (request.method === r.method && url.pathname === r.path) {
283
291
  const routeCtx = { callPrivileged: (opts: Parameters<typeof callPrivileged>[1]) => callPrivileged(env, opts) };
284
- return r.handler(request, env as unknown as Record<string, unknown>, routeCtx);
292
+ return r.handler(request, envBag(env), routeCtx);
285
293
  }
286
294
  }
287
295
 
@@ -473,8 +481,9 @@ export function makeWorker(app: PramenApp) {
473
481
  }
474
482
  // (isLive is excluded by useD1Store — live always routes to the DO below.)
475
483
  const name = url.pathname.replace(/^\/rpc\//, "");
476
- let input: unknown;
477
- if (request.method === "POST") input = await request.json().catch(() => undefined);
484
+ // The RPC body is JSON — parse it into the domain type once, here at the boundary.
485
+ let input: JsonValue = null;
486
+ if (request.method === "POST") input = ((await request.json().catch(() => null)) ?? null) as JsonValue;
478
487
 
479
488
  // Pick where the D1 session may start its first read. A mutation ALWAYS pins the
480
489
  // primary (`first-primary` is a superset of read-your-writes) so a read-modify-write
@@ -489,10 +498,10 @@ export function makeWorker(app: PramenApp) {
489
498
 
490
499
  const driver = new D1Driver(env.DB, { start });
491
500
  const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
492
- const envBag = env as unknown as Record<string, unknown>;
501
+ const bag = envBag(env);
493
502
  try {
494
503
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
495
- const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag, { acl: d1Acl, identity }, name, input);
504
+ const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity }, name, input);
496
505
  // Kick an immediate drain in the request tail when this handler enqueued tasks
497
506
  // (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
498
507
  // — up to a full minute. `waitUntil` lets the response return now while the
@@ -567,13 +576,13 @@ export function makeWorker(app: PramenApp) {
567
576
  // handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
568
577
  // (no tenant DO): its ctx carries env/kv/mail/queue + callPrivileged to reach a DO.
569
578
  async queue(batch: QueueBatch, env: Env): Promise<void> {
570
- const envBag = env as unknown as Record<string, unknown>;
579
+ const bag = envBag(env);
571
580
  const kv = new Kv(env.KV);
572
581
  const ctx: QueueContext = {
573
- env: envBag,
582
+ env: bag,
574
583
  kv,
575
- mail: createMail(envBag, kv),
576
- queue: createQueue(envBag),
584
+ mail: createMail(bag, kv),
585
+ queue: createQueue(bag),
577
586
  callPrivileged: (opts) => callPrivileged(env, opts),
578
587
  };
579
588
  await dispatchQueueBatch(app.queues ?? {}, ctx, batch);