@pramen/server 0.0.2 → 0.0.4

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.
package/src/sdk/infer.ts CHANGED
@@ -20,7 +20,9 @@ export type FieldTsType<D extends FieldDef> = D["type"] extends "text"
20
20
  ? JsonValue
21
21
  : D["type"] extends "fileRef"
22
22
  ? FileRef
23
- : number; // integer | real
23
+ : D["type"] extends "uuid"
24
+ ? string
25
+ : number; // integer | real
24
26
 
25
27
  /** A column is non-null iff it's NOT NULL or a primary key. */
26
28
  type IsNotNull<D extends FieldDef> = D extends { notNull: true }
@@ -62,6 +64,27 @@ export type WhereInput<F extends EntityFields> = {
62
64
  OR?: WhereInput<F>[];
63
65
  };
64
66
 
67
+ // --- partition boundary: runtime-only by decision (Issue 08) ---
68
+ //
69
+ // A relation cannot cross a partition (Durable Object) boundary. That invariant is
70
+ // enforced at runtime/boot by `validateSchema` (sdk/schema.ts) — it throws before
71
+ // migrate if any relation's source and target live in different partitions — and is
72
+ // proven end-to-end by the e2e suite (Issue 09).
73
+ //
74
+ // Issue 08 explored ALSO surfacing the boundary at compile time (dropping cross-
75
+ // partition relation keys out of `WhereClause`'s relation half and `RelationsResult`,
76
+ // so a cross-partition `with`/relation-`where` wouldn't even typecheck). That requires
77
+ // the entity's `partition` to survive inference as a string LITERAL ("audit" vs
78
+ // "default") so a conditional type can compare two literals. It does NOT: `EntityDef`
79
+ // declares `readonly partition: string` and the `Entity()` factory returns
80
+ // `EntityDef<F, R>` with `opts.partition?: string` — the literal is widened to `string`
81
+ // at the factory boundary and is unrecoverable here. Carrying it would mean threading a
82
+ // `P extends string` generic through `EntityDef` / `Entity` / `SchemaDef` and every
83
+ // consumer (FieldsOf, RelationsOf, RelValue, …) in sdk/schema.ts, destabilizing the
84
+ // already depth-bounded `WhereClause`/`RelationsResult`. Per the issue's explicit
85
+ // bail-out, that is disproportionate for optional polish over an already-enforced
86
+ // invariant. Decision: the partition boundary is enforced at runtime/boot only.
87
+ //
65
88
  // --- relation-aware where (a relation key takes a nested clause over its target,
66
89
  // compiled to a security-scoped subquery). Depth-bounded so the type stays finite
67
90
  // under cyclic relations (e.g. user.notes ↔ note.owner). The bound matches the
@@ -76,21 +99,31 @@ export type WhereClause<S extends SchemaDef, T extends keyof S, D extends number
76
99
  ([D] extends [never]
77
100
  ? object
78
101
  : {
79
- [K in keyof RelationsOf<S[T]>]?: WhereClause<S, RelTargetTable<S, RelationsOf<S[T]>[K]>, PrevDepth[D]>;
102
+ // Remap away the `string`/`number` index keys so a relationless entity
103
+ // (RelationsOf = Record<string, never>) contributes `{}`, not an index
104
+ // signature that would reject every column key in the WhereInput half.
105
+ [K in keyof RelationsOf<S[T]> as string extends K ? never : number extends K ? never : K]?: WhereClause<
106
+ S,
107
+ RelTargetTable<S, RelationsOf<S[T]>[K]>,
108
+ PrevDepth[D]
109
+ >;
80
110
  });
81
111
 
82
112
  /** Patch input for updates: every column optional, value typed (nullable). */
83
113
  export type InferUpdate<F extends EntityFields> = Partial<{ [K in keyof F]: FieldTsType<F[K]> | null }>;
84
114
 
85
- // Insert: a NOT NULL column is required unless it's auto-generated (autoIncrement)
86
- // or has a DEFAULT (the DB fills it); everything else is optional.
115
+ // Insert: a NOT NULL column is required unless it's auto-generated (autoIncrement,
116
+ // or a `generated()` uuid the runtime mints) or has a DEFAULT (the DB fills it);
117
+ // everything else is optional.
87
118
  type RequiredInsertKeys<F extends EntityFields> = {
88
119
  [K in keyof F]: IsNotNull<F[K]> extends true
89
120
  ? F[K] extends { autoIncrement: true }
90
121
  ? never
91
- : F[K] extends { default: DefaultValue }
122
+ : F[K] extends { generated: true }
92
123
  ? never
93
- : K
124
+ : F[K] extends { default: DefaultValue }
125
+ ? never
126
+ : K
94
127
  : never;
95
128
  }[keyof F];
96
129
  type OptionalInsertKeys<F extends EntityFields> = Exclude<keyof F, RequiredInsertKeys<F>>;
package/src/sdk/schema.ts CHANGED
@@ -9,8 +9,9 @@
9
9
 
10
10
  // "json" and "fileRef" are logical types stored as TEXT (JSON). The value a handler
11
11
  // reads/writes is the parsed value (a JsonValue, or a FileRef) — db.ts codecs it
12
- // to/from the column, and infer.ts types it accordingly.
13
- export type FieldType = "text" | "integer" | "real" | "boolean" | "json" | "fileRef";
12
+ // to/from the column, and infer.ts types it accordingly. "uuid" is a TEXT column
13
+ // typed as `string`; wrap it with `generated()` to auto-mint a v4 on insert.
14
+ export type FieldType = "text" | "integer" | "real" | "boolean" | "json" | "fileRef" | "uuid";
14
15
 
15
16
  /** A SQL DEFAULT literal (used by the migrator + DDL). */
16
17
  export type DefaultValue = string | number | boolean | null;
@@ -24,6 +25,10 @@ export interface FieldDef {
24
25
  readonly unique?: boolean;
25
26
  /** A (non-unique) index on this column. */
26
27
  readonly index?: boolean;
28
+ /** Auto-generate the value on insert when the caller omits it (uuid columns only,
29
+ * minted via crypto.randomUUID()). Set by the `generated()` modifier; makes the
30
+ * column optional on insert. */
31
+ readonly generated?: boolean;
27
32
  /** A column DEFAULT (a literal). Makes the column optional on insert. */
28
33
  readonly default?: DefaultValue;
29
34
  /** Migration hint: this column was previously named X. On boot the migrator
@@ -45,6 +50,10 @@ const builders = {
45
50
  /** A reference to a stored file (R2 object). Holds JSON metadata (a FileRef),
46
51
  * not the bytes — upload/download go through ctx.files + the Worker /files/* route. */
47
52
  fileRef: () => ({ type: "fileRef" }) as const,
53
+ /** A UUID stored in a TEXT column (typed as `string`). Wrap with `generated()` to
54
+ * auto-mint a v4 on insert, and/or `primaryKey()` to use it as the PK — the kvalt
55
+ * pattern `id: primaryKey(generated(t.uuid()))`. A provided value is validated. */
56
+ uuid: () => ({ type: "uuid" }) as const,
48
57
  };
49
58
 
50
59
  export type FieldBuilders = typeof builders;
@@ -73,16 +82,27 @@ const relationBuilders = {
73
82
  };
74
83
  export type RelationBuilders = typeof relationBuilders;
75
84
 
85
+ /** The default partition name for entities that don't declare one. */
86
+ export const DEFAULT_PARTITION = "default";
87
+
76
88
  export interface EntityDef<F extends EntityFields = EntityFields, R extends RelationDefs = Record<string, never>> {
77
89
  readonly fields: F;
78
90
  readonly relations: R;
91
+ /** The partition (Durable Object class) this entity lives in. Always populated;
92
+ * defaults to `"default"` so downstream code never branches on `undefined`. */
93
+ readonly partition: string;
79
94
  }
80
95
 
81
96
  export function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(
82
97
  build: (t: FieldBuilders) => F,
83
98
  relations?: (r: RelationBuilders) => R,
99
+ opts?: { partition?: string },
84
100
  ): EntityDef<F, R> {
85
- return { fields: build(builders), relations: (relations ? relations(relationBuilders) : {}) as R };
101
+ return {
102
+ fields: build(builders),
103
+ relations: (relations ? relations(relationBuilders) : {}) as R,
104
+ partition: opts?.partition ?? DEFAULT_PARTITION,
105
+ };
86
106
  }
87
107
 
88
108
  /** Annotate a field as renamed from a previous column name (migration hint). Wraps
@@ -115,8 +135,87 @@ export function defaultTo<F extends FieldDef, D extends DefaultValue>(field: F,
115
135
  return { ...field, default: value };
116
136
  }
117
137
 
138
+ /** Mark a column as the PRIMARY KEY (implies NOT NULL). Composes with any builder,
139
+ * e.g. `id: primaryKey(generated(t.uuid()))` or `code: primaryKey(t.text())`. */
140
+ export function primaryKey<F extends FieldDef>(field: F): F & { readonly primaryKey: true; readonly notNull: true } {
141
+ return { ...field, primaryKey: true, notNull: true };
142
+ }
143
+
144
+ /** Auto-generate the column's value on insert when omitted — uuid only (minted via
145
+ * crypto.randomUUID()). Makes the column optional on insert. Rejected at schema
146
+ * construction on a non-uuid column, since the runtime only knows how to mint uuids. */
147
+ export function generated<F extends FieldDef>(field: F): F & { readonly generated: true } {
148
+ if (field.type !== "uuid") {
149
+ throw new Error(`generated() is only valid on a uuid column (got '${field.type}')`);
150
+ }
151
+ return { ...field, generated: true };
152
+ }
153
+
118
154
  export type SchemaDef = Record<string, EntityDef<EntityFields, RelationDefs>>;
119
155
 
120
156
  export function defineSchema<S extends SchemaDef>(entities: S): S {
121
157
  return entities;
122
158
  }
159
+
160
+ // --- partition helpers — enumerate / resolve the partition (Durable Object class)
161
+ // an entity lives in. Used by the migrator/admin to group tables per DO.
162
+
163
+ /** The partition an entity lives in. Defaults to `"default"` for unknown entities. */
164
+ export function partitionOf(schema: SchemaDef, entity: string): string {
165
+ return schema[entity]?.partition ?? DEFAULT_PARTITION;
166
+ }
167
+
168
+ /** The distinct partition names in a schema, in stable (first-seen) order. */
169
+ export function partitionsOf(schema: SchemaDef): string[] {
170
+ const seen = new Set<string>();
171
+ const out: string[] = [];
172
+ for (const entity of Object.keys(schema)) {
173
+ const partition = partitionOf(schema, entity);
174
+ if (!seen.has(partition)) {
175
+ seen.add(partition);
176
+ out.push(partition);
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /** The table names whose partition matches `partition`, in schema (key) order. */
183
+ export function entitiesInPartition(schema: SchemaDef, partition: string): string[] {
184
+ return Object.keys(schema).filter((entity) => partitionOf(schema, entity) === partition);
185
+ }
186
+
187
+ // --- schema validation — static invariants checked once before migrate (DO boot
188
+ // + the D1 path) and at codegen. Cloudflare-free, so it stays in sdk/.
189
+
190
+ /**
191
+ * Validate a schema's static invariants, throwing on the first violation:
192
+ *
193
+ * - every relation's `target` names an entity that exists in the schema;
194
+ * - no relation crosses a partition boundary — a relation's source and target
195
+ * must live in the same partition (a Durable Object can't reach into another).
196
+ *
197
+ * Relations are static, so these are caught at validation time (boot + codegen),
198
+ * never as a runtime surprise. Runs even for a single (default) partition — it's
199
+ * cheap and catches relation-target typos.
200
+ */
201
+ export function validateSchema(schema: SchemaDef): void {
202
+ for (const [entity, def] of Object.entries(schema)) {
203
+ for (const [relName, rel] of Object.entries(def.relations)) {
204
+ if (!(rel.target in schema)) {
205
+ throw new Error(
206
+ `relation '${entity}.${relName}' targets unknown entity '${rel.target}' — ` +
207
+ `no such entity in the schema. Check the relation target name.`,
208
+ );
209
+ }
210
+ const pE = partitionOf(schema, entity);
211
+ const pT = partitionOf(schema, rel.target);
212
+ if (pE !== pT) {
213
+ throw new Error(
214
+ `relation '${entity}.${relName}' crosses a partition boundary: '${entity}' is in partition ` +
215
+ `'${pE}' but target '${rel.target}' is in '${pT}'. Relations cannot cross partitions — ` +
216
+ `put both entities in the same partition or drop the relation.`,
217
+ );
218
+ }
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,12 @@
1
+ // UUID format validation for `t.uuid()` columns. Platform-agnostic (sdk layer):
2
+ // the runtime validates a provided uuid value on write, and mints new ones via the
3
+ // global crypto.randomUUID() (Workers/Bun/Node) — see runtime/db.ts.
4
+
5
+ // Canonical 8-4-4-4-12 hex form, case-insensitive. Version/variant-agnostic so it
6
+ // accepts v4 (what we generate) as well as v7 and others a caller might supply.
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+
9
+ /** Whether `v` is a syntactically valid UUID string. */
10
+ export function isValidUuid(v: unknown): v is string {
11
+ return typeof v === "string" && UUID_RE.test(v);
12
+ }
package/src/worker.ts CHANGED
@@ -11,8 +11,10 @@ import { compileAcl } from "./runtime/acl";
11
11
  import { D1Driver, type Driver } from "./runtime/driver";
12
12
  import { toResponse } from "./runtime/errors";
13
13
  import { Kv } from "./runtime/kv";
14
+ import { listDOs, partitionDoName } from "./runtime/registry";
14
15
  import { createFiles, handleFileRequest, R2Adapter } from "./runtime/storage";
15
16
  import type { Identity } from "./sdk/acl";
17
+ import { DEFAULT_PARTITION } from "./sdk/schema";
16
18
  import type { PramenApp } from "./pramen";
17
19
 
18
20
  export interface Env {
@@ -72,22 +74,32 @@ function withCors(res: Response, cors: Record<string, string>): Response {
72
74
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
73
75
  }
74
76
 
77
+ /** Resolve the Durable Object stub for a `(tenant, partition)`. The DO name comes from
78
+ * `partitionDoName`, so routing and the registry stay in lockstep: it returns the BARE
79
+ * `tenant` for the default partition (so `idFromName(tenant)` is byte-for-byte unchanged
80
+ * — backward-compat) and `${tenant}:${partition}` for any other partition. */
81
+ function partitionStubFor(env: Env, tenant: string, partition: string = DEFAULT_PARTITION): DurableObjectStub {
82
+ return env.PRAMEN.get(env.PRAMEN.idFromName(partitionDoName(tenant, partition)));
83
+ }
84
+
75
85
  /** Forward a privileged mutation into a tenant's DO from a public route. The
76
86
  * synthetic identity (default `["admin"]`) is trusted because the call originates
77
87
  * in the Worker — the same internal mechanism the admin endpoints use. Returns the
78
88
  * DO's JSON response (`{ ok, result }` / `{ ok: false, … }`). */
79
89
  export async function callPrivileged(
80
90
  env: Env,
81
- opts: { name: string; input?: unknown; tenant?: string; roles?: string[] },
91
+ opts: { name: string; input?: unknown; tenant?: string; roles?: string[]; partition?: string },
82
92
  ): Promise<Response> {
83
93
  const tenant = opts.tenant ?? "main";
84
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
94
+ const partition = opts.partition ?? DEFAULT_PARTITION;
95
+ const stub = partitionStubFor(env, tenant, partition);
85
96
  return stub.fetch(
86
97
  new Request(`https://do/rpc/${opts.name}`, {
87
98
  method: "POST",
88
99
  headers: {
89
100
  "content-type": "application/json",
90
101
  "x-pramen-tenant": tenant,
102
+ "x-pramen-partition": partition,
91
103
  "x-pramen-identity": JSON.stringify({ roles: opts.roles ?? ["admin"] }),
92
104
  },
93
105
  body: JSON.stringify(opts.input ?? {}),
@@ -164,28 +176,33 @@ export function makeWorker(app: PramenApp) {
164
176
  if (qToken && !h.get("authorization")) h.set("authorization", `Bearer ${qToken}`);
165
177
  const qTenant = url.searchParams.get("tenant");
166
178
  if (qTenant && !h.get("x-pramen-tenant")) h.set("x-pramen-tenant", qTenant);
179
+ // A single socket lives in one partition (cross-partition live is out of scope);
180
+ // accept it via ?partition= and default to the default partition.
181
+ const qPartition = url.searchParams.get("partition");
182
+ if (!h.get("x-pramen-partition")) h.set("x-pramen-partition", qPartition || DEFAULT_PARTITION);
167
183
  req = new Request(request, { headers: h });
168
184
  }
169
185
 
170
186
  const identity = await resolveIdentity(req, strategyFor(env));
171
187
 
172
- // --- admin: list known tenants ---
188
+ // --- admin: list known (tenant, partition) DOs from the registry ---
173
189
  if (url.pathname === "/tenants") {
174
190
  if (!isAdmin(identity)) return withCors(forbidden("tenants"), cors);
175
- const list = await env.KV.list({ prefix: "tenant:" });
176
- return withCors(json({ ok: true, result: list.keys.map((k) => k.name.slice("tenant:".length)) }), cors);
191
+ const result = await listDOs(env.KV);
192
+ return withCors(json({ ok: true, result }), cors);
177
193
  }
178
194
 
179
195
  // --- admin: point-in-time recovery for a tenant ---
180
196
  if (url.pathname === "/admin/recover" && request.method === "POST") {
181
197
  if (!isAdmin(identity)) return forbidden("recover");
182
- const body = (await request.json().catch(() => ({}))) as { tenant?: unknown; timestamp?: unknown };
198
+ const body = (await request.json().catch(() => ({}))) as { tenant?: unknown; timestamp?: unknown; partition?: unknown };
183
199
  if (typeof body.tenant !== "string" || !body.tenant) return badRequest("tenant required");
184
200
  if (typeof body.timestamp !== "number" && typeof body.timestamp !== "string") return badRequest("timestamp required");
185
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(body.tenant));
201
+ const partition = typeof body.partition === "string" && body.partition ? body.partition : DEFAULT_PARTITION;
202
+ const stub = partitionStubFor(env, body.tenant, partition);
186
203
  const internal = new Request("https://do/__recover", {
187
204
  method: "POST",
188
- headers: { "content-type": "application/json", "x-pramen-tenant": body.tenant },
205
+ headers: { "content-type": "application/json", "x-pramen-tenant": body.tenant, "x-pramen-partition": partition },
189
206
  body: JSON.stringify({ timestamp: body.timestamp }),
190
207
  });
191
208
  return stub.fetch(internal);
@@ -195,8 +212,11 @@ export function makeWorker(app: PramenApp) {
195
212
  if (url.pathname === "/admin/schema") {
196
213
  if (!isAdmin(identity)) return withCors(forbidden("schema"), cors);
197
214
  const tenant = url.searchParams.get("tenant") ?? "main";
198
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
199
- const res = await stub.fetch(new Request("https://do/__schema", { headers: { "x-pramen-tenant": tenant } }));
215
+ const partition = url.searchParams.get("partition") || DEFAULT_PARTITION;
216
+ const stub = partitionStubFor(env, tenant, partition);
217
+ const res = await stub.fetch(
218
+ new Request("https://do/__schema", { headers: { "x-pramen-tenant": tenant, "x-pramen-partition": partition } }),
219
+ );
200
220
  return withCors(res, cors);
201
221
  }
202
222
 
@@ -205,13 +225,14 @@ export function makeWorker(app: PramenApp) {
205
225
  // in the DO under SYSTEM scope (ACL bypassed) — gated to admins here. ---
206
226
  if (url.pathname === "/admin/data" && request.method === "POST") {
207
227
  if (!isAdmin(identity)) return forbidden("data");
208
- const body = (await request.json().catch(() => ({}))) as { tenant?: unknown };
228
+ const body = (await request.json().catch(() => ({}))) as { tenant?: unknown; partition?: unknown };
209
229
  const tenant = typeof body.tenant === "string" && body.tenant ? body.tenant : "main";
210
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
230
+ const partition = typeof body.partition === "string" && body.partition ? body.partition : DEFAULT_PARTITION;
231
+ const stub = partitionStubFor(env, tenant, partition);
211
232
  const res = await stub.fetch(
212
233
  new Request("https://do/__admin/data", {
213
234
  method: "POST",
214
- headers: { "content-type": "application/json", "x-pramen-tenant": tenant },
235
+ headers: { "content-type": "application/json", "x-pramen-tenant": tenant, "x-pramen-partition": partition },
215
236
  body: JSON.stringify(body),
216
237
  }),
217
238
  );
@@ -225,8 +246,9 @@ export function makeWorker(app: PramenApp) {
225
246
  return new Response(
226
247
  "pramen — POST /rpc/<handler> (JSON body), or WebSocket /live for live queries. " +
227
248
  "Header X-Pramen-Tenant selects the store (default: main). " +
228
- "Admin: GET /tenants, POST /admin/recover {tenant,timestamp}, GET /admin/schema?tenant=, " +
229
- "POST /admin/data {tenant,table,op}.\n",
249
+ "Admin (optional partition selects the partition DO, default: " + DEFAULT_PARTITION + "): " +
250
+ "GET /tenants, POST /admin/recover {tenant,timestamp,partition?}, GET /admin/schema?tenant=&partition=, " +
251
+ "POST /admin/data {tenant,table,op,partition?}.\n",
230
252
  { headers: { "content-type": "text/plain" } },
231
253
  );
232
254
  }
@@ -259,12 +281,24 @@ export function makeWorker(app: PramenApp) {
259
281
  }
260
282
  }
261
283
 
284
+ // Resolve the partition to route to. For /rpc it's declared statically on the
285
+ // handler; for /live it's the socket's partition (already folded into the header
286
+ // from ?partition=). Forward it to the DO in x-pramen-partition either way.
287
+ let partition: string;
288
+ if (isRpc) {
289
+ const name = url.pathname.replace(/^\/rpc\//, "");
290
+ partition = app.handlers[name]?.partition ?? DEFAULT_PARTITION;
291
+ } else {
292
+ partition = req.headers.get("x-pramen-partition") || DEFAULT_PARTITION;
293
+ }
294
+
262
295
  // Forward a trusted identity to the DO (the DO never re-derives it).
263
296
  const headers = new Headers(req.headers);
264
297
  if (identity) headers.set("x-pramen-identity", JSON.stringify(identity as Identity));
265
298
  else headers.delete("x-pramen-identity");
299
+ headers.set("x-pramen-partition", partition);
266
300
 
267
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
301
+ const stub = partitionStubFor(env, tenant, partition);
268
302
  // WebSocket upgrades (101) must be returned untouched; only add CORS to HTTP.
269
303
  const res = await stub.fetch(new Request(req, { headers }));
270
304
  return isWs ? res : withCors(res, cors);