@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
package/dist/sdk/acl.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CellValue, JsonValue, Row } from "./infer";
1
2
  export type Action = "read" | "create" | "update" | "delete";
2
3
  /** Runtime identity. Augment with your own properties (userId, tier, …). */
3
4
  export interface Identity {
@@ -8,7 +9,7 @@ export interface Identity {
8
9
  * expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
9
10
  * (callPrivileged) identities, which are therefore never treated as expired. */
10
11
  exp?: number;
11
- [key: string]: unknown;
12
+ [key: string]: JsonValue | undefined;
12
13
  }
13
14
  declare const IDENTITY_MARKER: unique symbol;
14
15
  export interface IdentityMarker {
@@ -17,7 +18,7 @@ export interface IdentityMarker {
17
18
  }
18
19
  /** Reference an identity property in a policy `where`, resolved per request. */
19
20
  export declare function $identity(path: string): IdentityMarker;
20
- export declare function isIdentityMarker(v: unknown): v is IdentityMarker;
21
+ export declare function isIdentityMarker(v: WhereValue): v is IdentityMarker;
21
22
  declare const INPUT_MARKER: unique symbol;
22
23
  export interface InputMarker {
23
24
  readonly [INPUT_MARKER]: true;
@@ -28,7 +29,7 @@ export interface InputMarker {
28
29
  * caller can read a row only by presenting its unguessable key, without being able
29
30
  * to enumerate. An absent input value makes the rule match nothing (safe deny). */
30
31
  export declare function $input(path: string): InputMarker;
31
- export declare function isInputMarker(v: unknown): v is InputMarker;
32
+ export declare function isInputMarker(v: WhereValue): v is InputMarker;
32
33
  declare const NOW_MARKER: unique symbol;
33
34
  export interface NowMarker {
34
35
  readonly [NOW_MARKER]: true;
@@ -49,7 +50,7 @@ export interface NowMarker {
49
50
  * `toISOString()` — as the CMS `publish` field does — or compare it against
50
51
  * `expr.now()`-shaped values only. */
51
52
  export declare function $now(): NowMarker;
52
- export declare function isNowMarker(v: unknown): v is NowMarker;
53
+ export declare function isNowMarker(v: WhereValue): v is NowMarker;
53
54
  export interface AllowMarker {
54
55
  readonly kind: "allow";
55
56
  }
@@ -58,8 +59,14 @@ export interface DenyMarker {
58
59
  }
59
60
  export declare function allow(): AllowMarker;
60
61
  export declare function deny(): DenyMarker;
61
- /** A where rule: column -> value, where value may be a literal or an $identity marker. */
62
- export type WhereRule = Record<string, unknown | IdentityMarker>;
62
+ /** A value inside a `where` rule: a literal cell value, a per-request marker, an
63
+ * operator object (`{ gte: 5 }`), a nested relation predicate, or an AND/OR group. */
64
+ export type WhereValue = CellValue | IdentityMarker | InputMarker | NowMarker | WhereRule | WhereValue[];
65
+ /** A where rule: column (or `AND`/`OR`/`NOT`) -> value. An interface so it can recur
66
+ * through `WhereValue` for nested relation predicates and boolean groups. */
67
+ export interface WhereRule {
68
+ [key: string]: WhereValue;
69
+ }
63
70
  /** A per-row (cell-level) field grant: `fields` are permitted only for rows that
64
71
  * match `when`. Additive over the policy's flat `fields` — a conditional grant can
65
72
  * only ever ADD fields, never remove them. */
@@ -71,7 +78,7 @@ export interface ConditionalFields {
71
78
  /** Escape hatch for cell-level ACL: a late per-row resolver. Given the identity and
72
79
  * the fetched (or candidate, on write) row, returns the extra permitted fields —
73
80
  * additive over `fields`; `null` means all fields for that row. */
74
- export type FieldsFn = (identity: Identity | null, row: Record<string, unknown>) => string[] | null;
81
+ export type FieldsFn = (identity: Identity | null, row: Row) => string[] | null;
75
82
  /** Per-relation ACL inside a parent read policy. */
76
83
  export interface RelationAclRule {
77
84
  /** Permit traversal to the related entity via this relation even if it has no
@@ -87,11 +94,11 @@ export interface RelationAclRule {
87
94
  fieldsFn?: FieldsFn;
88
95
  }
89
96
  /** A forced column value on write: a literal, or computed from the identity. */
90
- export type SetValue = unknown | ((identity: Identity | null) => unknown);
97
+ export type SetValue = CellValue | ((identity: Identity | null) => CellValue);
91
98
  /** Server-side validation on write; throw to reject. Runs on the final values. */
92
99
  export type Validator = (args: {
93
100
  identity: Identity | null;
94
- values: Record<string, unknown>;
101
+ values: Row;
95
102
  }) => void;
96
103
  export interface PolicyRules {
97
104
  /** Row-level predicate (AND of equalities). Omit/empty = all rows. */
@@ -117,13 +124,13 @@ export interface PolicyRules {
117
124
  export interface ResolverDb {
118
125
  find(spec: {
119
126
  from: string;
120
- where?: Record<string, unknown>;
127
+ where?: WhereRule;
121
128
  orderBy?: {
122
129
  column: string;
123
130
  dir?: "asc" | "desc";
124
131
  };
125
132
  limit?: number;
126
- }): Promise<Array<Record<string, unknown>>>;
133
+ }): Promise<Row[]>;
127
134
  }
128
135
  export interface ResolverContext {
129
136
  readonly identity: Identity | null;
@@ -6,6 +6,12 @@ import type { Queue } from "../runtime/queue";
6
6
  import type { Identity } from "./acl";
7
7
  import type { Files } from "./files";
8
8
  import type { SchemaDef } from "./schema";
9
+ import type { JsonValue } from "./infer";
10
+ /** The Worker/DO environment as an open, read-only bag: Cloudflare bindings (KV, R2,
11
+ * D1, Queues, …) alongside vars and secrets. Deliberately open and opaque — an app
12
+ * declares its own bindings, so the value type cannot be enumerated here; read a value
13
+ * and narrow it at the use site (`ctx.env.STRIPE_SECRET_KEY as string`). */
14
+ export type EnvBag = Readonly<Record<string, unknown>>;
9
15
  export interface HandlerContext<S extends SchemaDef = SchemaDef> {
10
16
  /** Schema-typed repository: find/insert/update/delete inferred from S. */
11
17
  readonly db: Db<S>;
@@ -25,7 +31,7 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
25
31
  * Use it to call external services from handlers — Cloudflare bindings (e.g. the
26
32
  * `send_email` binding for Cloudflare Email Sending) or third-party APIs (Stripe, …). Loosely typed;
27
33
  * cast a value at the use site, e.g. `ctx.env.STRIPE_SECRET_KEY as string`. */
28
- readonly env: Readonly<Record<string, unknown>>;
34
+ readonly env: EnvBag;
29
35
  /** Resolved identity for this request (null = anonymous). */
30
36
  readonly identity: Identity | null;
31
37
  /** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
@@ -100,7 +106,7 @@ export interface Handler<I = unknown, O = unknown> {
100
106
  readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
101
107
  /** Optional boundary validator: parse/validate the raw request input, throwing
102
108
  * to reject (surfaced as a 400). Its return type fixes the handler's input. */
103
- readonly input?: (raw: unknown) => unknown;
109
+ readonly input?: (raw: JsonValue) => unknown;
104
110
  /** Optional DO partition this handler runs in (static, server-side). The Worker
105
111
  * routes the request to the matching partition-DO before dispatch. Absent ⇒ the
106
112
  * default partition (routed to the bare tenant key). */
@@ -109,7 +115,7 @@ export interface Handler<I = unknown, O = unknown> {
109
115
  readonly auth?: HandlerAuth;
110
116
  }
111
117
  export interface HandlerOpts<I> {
112
- input?: (raw: unknown) => I;
118
+ input?: (raw: JsonValue) => I;
113
119
  /** DO partition this handler runs in. Absent ⇒ the default partition. */
114
120
  partition?: string;
115
121
  /** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
@@ -5,6 +5,23 @@ export type { FileRef } from "./files";
5
5
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
6
6
  [key: string]: JsonValue;
7
7
  };
8
+ /** A JSON object — the object arm of `JsonValue`, named so it can be referenced
9
+ * directly (e.g. an identity's claims, a `t.json()` column's object form). */
10
+ export interface JsonObject {
11
+ [key: string]: JsonValue;
12
+ }
13
+ /** A raw value as the substrate stores/returns it, before pramen's object↔JSON codec.
14
+ * DO SQLite and D1 hand back exactly these; BLOB columns arrive as an ArrayBuffer. */
15
+ export type SqlValue = string | number | bigint | boolean | null | ArrayBuffer;
16
+ /** A decoded column value as handlers see it at the `Db` chokepoint: any JSON value,
17
+ * a `fileRef` column's metadata, or — for an eager-loaded relation — the related
18
+ * row(s) grafted onto the parent under the relation name. */
19
+ export type CellValue = SqlValue | JsonValue | FileRef | Row | Row[];
20
+ /** A decoded database row — column name -> decoded value. An interface (not a
21
+ * `Record` alias) so it can recur through `CellValue` for eager-loaded relations. */
22
+ export interface Row {
23
+ [column: string]: CellValue;
24
+ }
8
25
  /** SQL field type -> TypeScript value type. */
9
26
  export type FieldTsType<D extends FieldDef> = D["type"] extends "text" ? string : D["type"] extends "boolean" ? boolean : D["type"] extends "json" ? JsonValue : D["type"] extends "fileRef" ? FileRef : D["type"] extends "uuid" ? string : number;
10
27
  /** A column is non-null iff it's NOT NULL or a primary key. */
package/dist/worker.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { JsonValue } from "./sdk/infer";
1
2
  import { type QueueProducerBinding } from "./runtime/queue";
2
3
  import { type QueueBatch } from "./runtime/queue-consumer";
3
4
  import type { PramenApp } from "./pramen";
@@ -65,7 +66,7 @@ export declare function useD1Store(opts: {
65
66
  * DO's JSON response (`{ ok, result }` / `{ ok: false, … }`). */
66
67
  export declare function callPrivileged(env: Env, opts: {
67
68
  name: string;
68
- input?: unknown;
69
+ input?: JsonValue;
69
70
  tenant?: string;
70
71
  roles?: string[];
71
72
  partition?: string;
package/dist/worker.js CHANGED
@@ -18,6 +18,10 @@ import { Kv, isSessionDenied } from "./runtime/kv";
18
18
  import { listDOs, partitionDoName } from "./runtime/registry";
19
19
  import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
20
20
  import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
21
+ /** Widen the closed `Env` interface to the open `EnvBag` handlers and services see.
22
+ * Spreading yields an anonymous object type, which TypeScript gives an implicit index
23
+ * signature — so this needs no type assertion. */
24
+ const envBag = (env) => ({ ...env });
21
25
  /** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
22
26
  * else AUTH_SECRET (so HS256 setups work out of the box). */
23
27
  const filesSecret = (env) => env.FILES_SECRET || env.AUTH_SECRET;
@@ -172,7 +176,8 @@ export function makeWorker(app) {
172
176
  const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
173
177
  const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
174
178
  const kv = new Kv(env.KV);
175
- return { db, kv, files, env: env, identity, tasks: tasksFacade(driver), mail: createMail(env, kv), queue: createQueue(env) };
179
+ const bag = envBag(env);
180
+ return { db, kv, files, env: bag, identity, tasks: tasksFacade(driver), mail: createMail(bag, kv), queue: createQueue(bag) };
176
181
  };
177
182
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
178
183
  * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
@@ -216,7 +221,7 @@ export function makeWorker(app) {
216
221
  for (const r of app.routes ?? []) {
217
222
  if (request.method === r.method && url.pathname === r.path) {
218
223
  const routeCtx = { callPrivileged: (opts) => callPrivileged(env, opts) };
219
- return r.handler(request, env, routeCtx);
224
+ return r.handler(request, envBag(env), routeCtx);
220
225
  }
221
226
  }
222
227
  // CORS (opt-in via CORS_ORIGINS) for cross-origin browser clients. Answer the
@@ -396,9 +401,10 @@ export function makeWorker(app) {
396
401
  }
397
402
  // (isLive is excluded by useD1Store — live always routes to the DO below.)
398
403
  const name = url.pathname.replace(/^\/rpc\//, "");
399
- let input;
404
+ // The RPC body is JSON — parse it into the domain type once, here at the boundary.
405
+ let input = null;
400
406
  if (request.method === "POST")
401
- input = await request.json().catch(() => undefined);
407
+ input = ((await request.json().catch(() => null)) ?? null);
402
408
  // Pick where the D1 session may start its first read. A mutation ALWAYS pins the
403
409
  // primary (`first-primary` is a superset of read-your-writes) so a read-modify-write
404
410
  // can't run off a lagging replica — an inbound bookmark must not widen that window.
@@ -414,10 +420,10 @@ export function makeWorker(app) {
414
420
  start = "first-unconstrained";
415
421
  const driver = new D1Driver(env.DB, { start });
416
422
  const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
417
- const envBag = env;
423
+ const bag = envBag(env);
418
424
  try {
419
425
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
420
- const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag, { acl: d1Acl, identity }, name, input);
426
+ const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity }, name, input);
421
427
  // Kick an immediate drain in the request tail when this handler enqueued tasks
422
428
  // (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
423
429
  // — up to a full minute. `waitUntil` lets the response return now while the
@@ -488,13 +494,13 @@ export function makeWorker(app) {
488
494
  // handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
489
495
  // (no tenant DO): its ctx carries env/kv/mail/queue + callPrivileged to reach a DO.
490
496
  async queue(batch, env) {
491
- const envBag = env;
497
+ const bag = envBag(env);
492
498
  const kv = new Kv(env.KV);
493
499
  const ctx = {
494
- env: envBag,
500
+ env: bag,
495
501
  kv,
496
- mail: createMail(envBag, kv),
497
- queue: createQueue(envBag),
502
+ mail: createMail(bag, kv),
503
+ queue: createQueue(bag),
498
504
  callPrivileged: (opts) => callPrivileged(env, opts),
499
505
  };
500
506
  await dispatchQueueBatch(app.queues ?? {}, ctx, batch);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.48",
3
+ "version": "0.0.49",
4
4
  "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/auth.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // non-standard claims pass through.
12
12
 
13
13
  import type { Identity } from "./sdk/acl";
14
+ import type { JsonObject } from "./sdk/infer";
14
15
 
15
16
  const STANDARD_CLAIMS = new Set(["exp", "iat", "nbf", "iss", "aud", "jti", "sub", "role", "roles", "userId"]);
16
17
 
@@ -34,7 +35,7 @@ interface JwtHeader {
34
35
  /** Verifies a JWT and returns its claims, or null if invalid. Implementations
35
36
  * differ only in how they verify the signature. */
36
37
  export interface VerifyStrategy {
37
- verify(token: string): Promise<Record<string, unknown> | null>;
38
+ verify(token: string): Promise<JsonObject | null>;
38
39
  }
39
40
 
40
41
  /** Verify a signature over `${header}.${payload}` for the parsed header. */
@@ -69,7 +70,7 @@ async function verifyJwt(
69
70
  token: string,
70
71
  verifySignature: SignatureVerifier,
71
72
  opts: VerifyOptions = {},
72
- ): Promise<Record<string, unknown> | null> {
73
+ ): Promise<JsonObject | null> {
73
74
  const parts = token.split(".");
74
75
  if (parts.length !== 3) return null;
75
76
  const [h, p, sig] = parts;
@@ -89,7 +90,7 @@ async function verifyJwt(
89
90
  }
90
91
  if (!valid) return null;
91
92
 
92
- let payload: Record<string, unknown>;
93
+ let payload: JsonObject;
93
94
  try {
94
95
  payload = JSON.parse(b64urlToString(p!));
95
96
  } catch {
@@ -113,7 +114,7 @@ export class HmacStrategy implements VerifyStrategy {
113
114
  private readonly opts: VerifyOptions = {},
114
115
  ) {}
115
116
 
116
- verify(token: string): Promise<Record<string, unknown> | null> {
117
+ verify(token: string): Promise<JsonObject | null> {
117
118
  return verifyJwt(
118
119
  token,
119
120
  async (input, signature, header) => {
@@ -152,7 +153,7 @@ export class JwksStrategy implements VerifyStrategy {
152
153
  private readonly opts: VerifyOptions = {},
153
154
  ) {}
154
155
 
155
- verify(token: string): Promise<Record<string, unknown> | null> {
156
+ verify(token: string): Promise<JsonObject | null> {
156
157
  return verifyJwt(
157
158
  token,
158
159
  async (input, signature, header) => {
@@ -219,7 +220,7 @@ export class JwksStrategy implements VerifyStrategy {
219
220
  }
220
221
  }
221
222
 
222
- function toIdentity(claims: Record<string, unknown>): Identity {
223
+ function toIdentity(claims: JsonObject): Identity {
223
224
  const roles = Array.isArray(claims.roles)
224
225
  ? (claims.roles as string[])
225
226
  : typeof claims.role === "string"
package/src/cli.ts CHANGED
@@ -21,7 +21,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
21
  import { dirname, resolve } from "node:path";
22
22
  import { createTableSql } from "./runtime/ddl";
23
23
  import { schemaHash } from "./runtime/migrate";
24
- import { diffSchemaShape, schemaShape, type SchemaShape } from "./runtime/schema-diff";
24
+ import { diffSchemaFingerprint, schemaFingerprint, type SchemaFingerprint } from "./runtime/schema-diff";
25
25
  import { entitiesInPartition, partitionsOf, type SchemaDef } from "./sdk/schema";
26
26
 
27
27
  /** Mint an HS256 JWT — mirrors what a real auth service would issue, for local
@@ -36,6 +36,9 @@ function bytesToB64url(bytes: Uint8Array): string {
36
36
  }
37
37
  const strToB64url = (s: string) => bytesToB64url(new TextEncoder().encode(s));
38
38
 
39
+ /** The dev JWT claims `pramen token` mints. */
40
+ type TokenClaims = { sub: string; roles: string[]; tenants?: string[] };
41
+
39
42
  async function sign(payload: Record<string, unknown>): Promise<string> {
40
43
  const secret = process.env.AUTH_SECRET || DEV_SECRET;
41
44
  const now = Math.floor(Date.now() / 1000);
@@ -102,7 +105,7 @@ Usage: pramen <command>
102
105
  init [dir] scaffold a new project (app.ts + worker.ts + oblaka.ts)
103
106
  schema sql print CREATE TABLE statements for the schema
104
107
  schema hash print the schema hash
105
- schema snapshot save the schema shape to .pramen/schema.json
108
+ schema snapshot save the schema fingerprint to .pramen/schema.json
106
109
  schema diff compare the schema to the snapshot (safe vs unsafe)
107
110
  schema status compare a deployed tenant's schema to the local schema
108
111
  [--tenant t] [--url u] [--token jwt]
@@ -126,20 +129,27 @@ async function schemaCmd(sub: string | undefined): Promise<void> {
126
129
  if (sub === "snapshot") {
127
130
  const { schema } = await loadApp();
128
131
  mkdirSync(dirname(snapshotPath), { recursive: true });
129
- const snap = { hash: schemaHash(schema), shape: schemaShape(schema) };
132
+ const snap = { hash: schemaHash(schema), fingerprint: schemaFingerprint(schema) };
130
133
  writeFileSync(snapshotPath, JSON.stringify(snap, null, 2) + "\n");
131
- console.log(`wrote ${snapshotPath} (${Object.keys(snap.shape).length} tables)`);
134
+ console.log(`wrote ${snapshotPath} (${Object.keys(snap.fingerprint).length} tables)`);
132
135
  return;
133
136
  }
134
137
  if (sub === "diff") {
135
138
  const { schema } = await loadApp();
136
- const next = schemaShape(schema);
139
+ const next = schemaFingerprint(schema);
137
140
  if (!existsSync(snapshotPath)) {
138
141
  console.log("no snapshot — run `pramen schema snapshot` to set a baseline.");
139
142
  return;
140
143
  }
141
- const prev = (JSON.parse(readFileSync(snapshotPath, "utf8")) as { shape: SchemaShape }).shape;
142
- const changes = diffSchemaShape(prev, next);
144
+ // `fingerprint` was called `shape` before; keep reading an existing snapshot so an
145
+ // upgrade doesn't force a re-baseline.
146
+ const snap = JSON.parse(readFileSync(snapshotPath, "utf8")) as {
147
+ fingerprint?: SchemaFingerprint;
148
+ // the pre-rename key, quoted because it names a stored JSON field, not a symbol
149
+ "shape"?: SchemaFingerprint;
150
+ };
151
+ const prev = snap.fingerprint ?? snap["shape"] ?? {};
152
+ const changes = diffSchemaFingerprint(prev, next);
143
153
  if (changes.length === 0) {
144
154
  console.log("no changes since snapshot.");
145
155
  return;
@@ -203,7 +213,7 @@ async function schemaCmd(sub: string | undefined): Promise<void> {
203
213
  console.log(`live: ${live.hash ?? "(none)"}`);
204
214
  console.log(`current: ${current}`);
205
215
  console.log(upToDate ? "✓ up to date" : "⚠ BEHIND — migrates on the tenant's next boot");
206
- const want = schemaShape(subset);
216
+ const want = schemaFingerprint(subset);
207
217
  for (const table of Object.keys(want)) {
208
218
  const liveCols = new Set(live.tables[table] ?? []);
209
219
  const missing = Object.keys(want[table]!.columns).filter((col) => !liveCols.has(col));
@@ -222,7 +232,9 @@ async function tokenCmd(args: string[]): Promise<void> {
222
232
  if (!sub) fail("token: <sub> required");
223
233
  const roles = pos.slice(1);
224
234
  const tenants = flag("tenant")?.split(",");
225
- console.log(await sign({ sub, roles: roles.length ? roles : ["admin"], ...(tenants ? { tenants } : {}) }));
235
+ const claims: TokenClaims = { sub, roles: roles.length ? roles : ["admin"] };
236
+ if (tenants) claims.tenants = tenants;
237
+ console.log(await sign(claims));
226
238
  }
227
239
 
228
240
  function initCmd(args: string[]): void {
@@ -34,6 +34,8 @@ import type { Identity } from "./sdk/acl";
34
34
  import type { HandlerContext } from "./sdk/handlers";
35
35
  import type { PramenApp } from "./pramen";
36
36
  import type { ClientMsg, ServerMsg, Subscription } from "./runtime/protocol";
37
+ import type { EnvBag } from "./sdk/handlers";
38
+ import type { JsonValue } from "./sdk/infer";
37
39
 
38
40
  /** Durable per-socket state — kept SMALL and stable, since it rides the WebSocket
39
41
  * attachment which workerd caps at ~2 KB. Only auth/routing identity lives here so it
@@ -208,9 +210,10 @@ export class PramenDOBase extends DurableObject<DoEnv> {
208
210
  }
209
211
 
210
212
  const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
211
- let input: unknown;
213
+ // The RPC body is JSON — parse it into the domain type once, here at the boundary.
214
+ let input: JsonValue = null;
212
215
  if (request.method === "POST") {
213
- input = await request.json().catch(() => undefined);
216
+ input = ((await request.json().catch(() => null)) ?? null) as JsonValue;
214
217
  }
215
218
 
216
219
  try {
@@ -354,12 +357,12 @@ export class PramenDOBase extends DurableObject<DoEnv> {
354
357
 
355
358
  switch (msg.type) {
356
359
  case "subscribe":
357
- return this.onSubscribe(ws, msg.id, msg.name, msg.input);
360
+ return this.onSubscribe(ws, msg.id, msg.name, msg.input ?? null);
358
361
  case "unsubscribe":
359
362
  this.setSubs(ws, this.getSubs(ws).filter((s) => s.id !== msg.id));
360
363
  return;
361
364
  case "call":
362
- return this.onCall(ws, msg.id, msg.name, msg.input);
365
+ return this.onCall(ws, msg.id, msg.name, msg.input ?? null);
363
366
  default:
364
367
  return this.send(ws, { type: "error", id: "", error: "unknown message type" });
365
368
  }
@@ -381,7 +384,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
381
384
 
382
385
  // --- live-query internals ---
383
386
 
384
- private async onSubscribe(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
387
+ private async onSubscribe(ws: WebSocket, id: string, name: string, input: JsonValue): Promise<void> {
385
388
  const att = this.getAttachment(ws);
386
389
  const subs = this.getSubs(ws);
387
390
  try {
@@ -402,7 +405,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
402
405
  }
403
406
  }
404
407
 
405
- private async onCall(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
408
+ private async onCall(ws: WebSocket, id: string, name: string, input: JsonValue): Promise<void> {
406
409
  const att = this.getAttachment(ws);
407
410
  let outcome: Awaited<ReturnType<typeof dispatch>>;
408
411
  try {
@@ -603,8 +606,12 @@ export class PramenDOBase extends DurableObject<DoEnv> {
603
606
 
604
607
  // The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
605
608
  // typed at the boundary so handlers can read any var/secret without a DoEnv cast.
606
- private get envBag(): Readonly<Record<string, unknown>> {
607
- return this.env as unknown as Record<string, unknown>;
609
+ private widenedEnv: EnvBag | null = null;
610
+ private get envBag(): EnvBag {
611
+ // `this.env` is fixed for the DO's lifetime, so widen it once. This getter is read
612
+ // inside the per-subscription live-query loop, where a copy per read would allocate
613
+ // one whole binding bag per subscription on every write.
614
+ return (this.widenedEnv ??= { ...this.env });
608
615
  }
609
616
 
610
617
  // One Files facade per DO (a DO serves one tenant). Backed by the R2 binding;
package/src/index.ts CHANGED
@@ -31,7 +31,7 @@ export type {
31
31
  // --- app + handlers ---
32
32
  export { createApp } from "./sdk/app";
33
33
  export { query, mutation, authorizeHandler } from "./sdk/handlers";
34
- export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
34
+ export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
35
35
 
36
36
  // --- ACL ---
37
37
  export { $identity, $input, $now, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker, isNowMarker } from "./sdk/acl";
@@ -64,6 +64,10 @@ export type {
64
64
  InferRow,
65
65
  InferUpdate,
66
66
  JsonValue,
67
+ JsonObject,
68
+ SqlValue,
69
+ CellValue,
70
+ Row,
67
71
  ProjectedRow,
68
72
  RelationsOf,
69
73
  RelationsResult,
@@ -95,4 +99,4 @@ export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/erro
95
99
 
96
100
  // --- substrate seam (advanced: bring your own SQL backend) ---
97
101
  export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
98
- export type { Driver, Dialect, Row } from "./runtime/driver";
102
+ export type { Driver, Dialect, DriverRow } from "./runtime/driver";
package/src/pramen.ts CHANGED
@@ -18,12 +18,14 @@ import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
18
18
  import type { AppTaskMap, HandlerMap, BootstrapFn } from "./sdk/handlers";
19
19
  import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
20
20
  import type { Role } from "./sdk/acl";
21
+ import type { EnvBag } from "./sdk/handlers";
22
+ import type { JsonValue } from "./sdk/infer";
21
23
 
22
24
  /** Injected into a public route's handler — forward a privileged mutation into the
23
25
  * tenant's DO without the handler importing any deploy-side code (so app.ts stays
24
26
  * authoring-only). The synthetic identity defaults to the admin role. */
25
27
  export interface RouteContext {
26
- callPrivileged(opts: { name: string; input?: unknown; tenant?: string; roles?: string[] }): Promise<Response>;
28
+ callPrivileged(opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[] }): Promise<Response>;
27
29
  }
28
30
 
29
31
  /** A public, pre-auth route — matched before identity resolution, so it can host a
@@ -36,7 +38,7 @@ export interface PublicRoute {
36
38
  method: string;
37
39
  /** Exact pathname to match (e.g. "/stripe/webhook"). */
38
40
  path: string;
39
- handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteContext) => Response | Promise<Response>;
41
+ handler: (request: Request, env: EnvBag, ctx: RouteContext) => Response | Promise<Response>;
40
42
  }
41
43
 
42
44
  /** The user-facing app: a schema, the handler map, ACL roles, and optional public