@pramen/server 0.0.3 → 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.
@@ -0,0 +1,88 @@
1
+ // DO registry — the source of truth for which `(tenant, partition)` Durable Objects
2
+ // exist. A `DurableObjectNamespace` has NO list/enumerate API (only idFromName /
3
+ // idFromString / newUniqueId / get), so the platform cannot tell us which DOs were
4
+ // ever instantiated. The only way to "work with all DOs" (migrate, recover, browse)
5
+ // is this registry we maintain ourselves: each DO self-registers once (durable-object.ts
6
+ // `ensureRegistered`), and admin ops enumerate via `listDOs`.
7
+ //
8
+ // This file owns the KV key scheme so the Worker and the DO agree on the format. It
9
+ // is deliberately free of `cloudflare:workers` imports — it takes a `KVNamespace`
10
+ // param, mirroring runtime/kv.ts.
11
+ //
12
+ // KEY SCHEME (hard backward-compat requirement):
13
+ // - default partition → BARE `tenant:<t>` (NO `:default` suffix)
14
+ // - non-default → `tenant:<t>:<p>`
15
+ // The bare-key-for-default rule keeps existing registry entries and DO routing keys
16
+ // unchanged for single-partition apps. Adding a `:default` suffix would orphan all
17
+ // existing data, so it must never appear in a key.
18
+ //
19
+ // NAME RULE: tenant and partition names MUST NOT contain `:`. The key format is
20
+ // `tenant:<t>` / `tenant:<t>:<p>`, so a `:` in a name would make parsing ambiguous
21
+ // (we couldn't tell where the tenant ends and the partition begins). Names are
22
+ // validated at the boundary (here, when building a key) and rejected otherwise.
23
+ import { DEFAULT_PARTITION } from "../sdk/schema";
24
+ const KEY_PREFIX = "tenant:";
25
+ /** Reject a tenant/partition name that would make a registry key ambiguous. A name
26
+ * may not be empty and may not contain `:` (the key separator). Throws on violation. */
27
+ export function assertValidName(kind, name) {
28
+ if (name.length === 0) {
29
+ throw new Error(`pramen: ${kind} name must not be empty`);
30
+ }
31
+ if (name.includes(":")) {
32
+ throw new Error(`pramen: ${kind} name "${name}" must not contain ':' (it is the registry key separator)`);
33
+ }
34
+ }
35
+ /** Build the registry KV key for a `(tenant, partition)`. The default partition keeps
36
+ * the bare `tenant:<t>` key (backward-compat); any other partition is `tenant:<t>:<p>`.
37
+ * Rejects names containing `:` so the key parses unambiguously. */
38
+ export function registryKey(tenant, partition = DEFAULT_PARTITION) {
39
+ assertValidName("tenant", tenant);
40
+ assertValidName("partition", partition);
41
+ return partition === DEFAULT_PARTITION ? `${KEY_PREFIX}${tenant}` : `${KEY_PREFIX}${tenant}:${partition}`;
42
+ }
43
+ /** Build the Durable Object NAME for a `(tenant, partition)` — the string passed to
44
+ * `idFromName`. This is the same default/non-default rule as `registryKey` but WITHOUT
45
+ * the KV `tenant:` prefix: the DO namespace and the KV registry are distinct keyspaces.
46
+ * Default partition keeps the BARE `tenant` name (byte-for-byte the pre-partition DO
47
+ * name — a hard backward-compat requirement: changing it would orphan existing DOs);
48
+ * any other partition is `${tenant}:${partition}`. Keeping it next to `registryKey`
49
+ * keeps routing and the registry derived from one place. */
50
+ export function partitionDoName(tenant, partition = DEFAULT_PARTITION) {
51
+ assertValidName("tenant", tenant);
52
+ assertValidName("partition", partition);
53
+ return partition === DEFAULT_PARTITION ? tenant : `${tenant}:${partition}`;
54
+ }
55
+ /** Parse a registry KV key back into a `(tenant, partition)`. A bare `tenant:<t>`
56
+ * key yields partition `"default"`; `tenant:<t>:<p>` yields `<p>`. Returns null if
57
+ * the key is not a registry key (missing the `tenant:` prefix). */
58
+ export function parseRegistryKey(key) {
59
+ if (!key.startsWith(KEY_PREFIX))
60
+ return null;
61
+ const rest = key.slice(KEY_PREFIX.length);
62
+ // At most one `:` remains (names exclude `:`), separating tenant from partition.
63
+ const sep = rest.indexOf(":");
64
+ if (sep === -1)
65
+ return { tenant: rest, partition: DEFAULT_PARTITION };
66
+ return { tenant: rest.slice(0, sep), partition: rest.slice(sep + 1) };
67
+ }
68
+ /** Enumerate every registered `(tenant, partition)` pair from the registry KV.
69
+ * Paginates over the full listing (cursor / list_complete) — never truncates at the
70
+ * 1000-key page limit. */
71
+ export async function listDOs(kv) {
72
+ const out = [];
73
+ let cursor;
74
+ for (;;) {
75
+ const res = await kv.list({ prefix: KEY_PREFIX, cursor });
76
+ for (const k of res.keys) {
77
+ const ref = parseRegistryKey(k.name);
78
+ if (ref)
79
+ out.push(ref);
80
+ }
81
+ if (res.list_complete)
82
+ break;
83
+ cursor = res.cursor;
84
+ if (!cursor)
85
+ break;
86
+ }
87
+ return out;
88
+ }
package/dist/sdk/app.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // const { query, mutation } = createApp(schema);
6
6
  // const listNotes = query((ctx) => ctx.db.find({ from: "notes" })); // typed!
7
7
  export function createApp(schema) {
8
- const query = (run, opts) => ({ kind: "query", run: run, input: opts?.input });
9
- const mutation = (run, opts) => ({ kind: "mutation", run: run, input: opts?.input });
8
+ const query = (run, opts) => ({ kind: "query", run: run, input: opts?.input, partition: opts?.partition });
9
+ const mutation = (run, opts) => ({ kind: "mutation", run: run, input: opts?.input, partition: opts?.partition });
10
10
  return { schema, query, mutation };
11
11
  }
@@ -27,9 +27,15 @@ export interface Handler<I = unknown, O = unknown> {
27
27
  /** Optional boundary validator: parse/validate the raw request input, throwing
28
28
  * to reject (surfaced as a 400). Its return type fixes the handler's input. */
29
29
  readonly input?: (raw: unknown) => unknown;
30
+ /** Optional DO partition this handler runs in (static, server-side). The Worker
31
+ * routes the request to the matching partition-DO before dispatch. Absent ⇒ the
32
+ * default partition (routed to the bare tenant key). */
33
+ readonly partition?: string;
30
34
  }
31
35
  export interface HandlerOpts<I> {
32
36
  input?: (raw: unknown) => I;
37
+ /** DO partition this handler runs in. Absent ⇒ the default partition. */
38
+ partition?: string;
33
39
  }
34
40
  export declare function query<I = unknown, O = unknown>(run: (ctx: HandlerContext, input: I) => O | Promise<O>, opts?: HandlerOpts<I>): Handler<I, O>;
35
41
  export declare function mutation<I = unknown, O = unknown>(run: (ctx: HandlerContext, input: I) => O | Promise<O>, opts?: HandlerOpts<I>): Handler<I, O>;
@@ -4,8 +4,8 @@
4
4
  // Standalone (schema-agnostic) handler factories. Prefer createApp(schema) for a
5
5
  // typed ctx.db; these remain for untyped/ad-hoc use.
6
6
  export function query(run, opts) {
7
- return { kind: "query", run, input: opts?.input };
7
+ return { kind: "query", run, input: opts?.input, partition: opts?.partition };
8
8
  }
9
9
  export function mutation(run, opts) {
10
- return { kind: "mutation", run, input: opts?.input };
10
+ return { kind: "mutation", run, input: opts?.input, partition: opts?.partition };
11
11
  }
@@ -6,7 +6,7 @@ export type JsonValue = string | number | boolean | null | JsonValue[] | {
6
6
  [key: string]: JsonValue;
7
7
  };
8
8
  /** SQL field type -> TypeScript value type. */
9
- 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 : number;
9
+ 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
10
  /** A column is non-null iff it's NOT NULL or a primary key. */
11
11
  type IsNotNull<D extends FieldDef> = D extends {
12
12
  notNull: true;
@@ -61,6 +61,8 @@ export type InferUpdate<F extends EntityFields> = Partial<{
61
61
  type RequiredInsertKeys<F extends EntityFields> = {
62
62
  [K in keyof F]: IsNotNull<F[K]> extends true ? F[K] extends {
63
63
  autoIncrement: true;
64
+ } ? never : F[K] extends {
65
+ generated: true;
64
66
  } ? never : F[K] extends {
65
67
  default: DefaultValue;
66
68
  } ? never : K : never;
@@ -1,4 +1,4 @@
1
- export type FieldType = "text" | "integer" | "real" | "boolean" | "json" | "fileRef";
1
+ export type FieldType = "text" | "integer" | "real" | "boolean" | "json" | "fileRef" | "uuid";
2
2
  /** A SQL DEFAULT literal (used by the migrator + DDL). */
3
3
  export type DefaultValue = string | number | boolean | null;
4
4
  export interface FieldDef {
@@ -10,6 +10,10 @@ export interface FieldDef {
10
10
  readonly unique?: boolean;
11
11
  /** A (non-unique) index on this column. */
12
12
  readonly index?: boolean;
13
+ /** Auto-generate the value on insert when the caller omits it (uuid columns only,
14
+ * minted via crypto.randomUUID()). Set by the `generated()` modifier; makes the
15
+ * column optional on insert. */
16
+ readonly generated?: boolean;
13
17
  /** A column DEFAULT (a literal). Makes the column optional on insert. */
14
18
  readonly default?: DefaultValue;
15
19
  /** Migration hint: this column was previously named X. On boot the migrator
@@ -51,6 +55,12 @@ declare const builders: {
51
55
  fileRef: () => {
52
56
  readonly type: "fileRef";
53
57
  };
58
+ /** A UUID stored in a TEXT column (typed as `string`). Wrap with `generated()` to
59
+ * auto-mint a v4 on insert, and/or `primaryKey()` to use it as the PK — the kvalt
60
+ * pattern `id: primaryKey(generated(t.uuid()))`. A provided value is validated. */
61
+ uuid: () => {
62
+ readonly type: "uuid";
63
+ };
54
64
  };
55
65
  export type FieldBuilders = typeof builders;
56
66
  export type EntityFields = Record<string, FieldDef>;
@@ -81,11 +91,18 @@ declare const relationBuilders: {
81
91
  };
82
92
  };
83
93
  export type RelationBuilders = typeof relationBuilders;
94
+ /** The default partition name for entities that don't declare one. */
95
+ export declare const DEFAULT_PARTITION = "default";
84
96
  export interface EntityDef<F extends EntityFields = EntityFields, R extends RelationDefs = Record<string, never>> {
85
97
  readonly fields: F;
86
98
  readonly relations: R;
99
+ /** The partition (Durable Object class) this entity lives in. Always populated;
100
+ * defaults to `"default"` so downstream code never branches on `undefined`. */
101
+ readonly partition: string;
87
102
  }
88
- export declare function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(build: (t: FieldBuilders) => F, relations?: (r: RelationBuilders) => R): EntityDef<F, R>;
103
+ export declare function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(build: (t: FieldBuilders) => F, relations?: (r: RelationBuilders) => R, opts?: {
104
+ partition?: string;
105
+ }): EntityDef<F, R>;
89
106
  /** Annotate a field as renamed from a previous column name (migration hint). Wraps
90
107
  * a builder result, preserving its literal field type: `title: renamedFrom(t.text(), "name")`. */
91
108
  export declare function renamedFrom<F extends FieldDef>(field: F, from: string): F & {
@@ -107,6 +124,36 @@ export declare function indexed<F extends FieldDef>(field: F): F & {
107
124
  export declare function defaultTo<F extends FieldDef, D extends DefaultValue>(field: F, value: D): F & {
108
125
  readonly default: D;
109
126
  };
127
+ /** Mark a column as the PRIMARY KEY (implies NOT NULL). Composes with any builder,
128
+ * e.g. `id: primaryKey(generated(t.uuid()))` or `code: primaryKey(t.text())`. */
129
+ export declare function primaryKey<F extends FieldDef>(field: F): F & {
130
+ readonly primaryKey: true;
131
+ readonly notNull: true;
132
+ };
133
+ /** Auto-generate the column's value on insert when omitted — uuid only (minted via
134
+ * crypto.randomUUID()). Makes the column optional on insert. Rejected at schema
135
+ * construction on a non-uuid column, since the runtime only knows how to mint uuids. */
136
+ export declare function generated<F extends FieldDef>(field: F): F & {
137
+ readonly generated: true;
138
+ };
110
139
  export type SchemaDef = Record<string, EntityDef<EntityFields, RelationDefs>>;
111
140
  export declare function defineSchema<S extends SchemaDef>(entities: S): S;
141
+ /** The partition an entity lives in. Defaults to `"default"` for unknown entities. */
142
+ export declare function partitionOf(schema: SchemaDef, entity: string): string;
143
+ /** The distinct partition names in a schema, in stable (first-seen) order. */
144
+ export declare function partitionsOf(schema: SchemaDef): string[];
145
+ /** The table names whose partition matches `partition`, in schema (key) order. */
146
+ export declare function entitiesInPartition(schema: SchemaDef, partition: string): string[];
147
+ /**
148
+ * Validate a schema's static invariants, throwing on the first violation:
149
+ *
150
+ * - every relation's `target` names an entity that exists in the schema;
151
+ * - no relation crosses a partition boundary — a relation's source and target
152
+ * must live in the same partition (a Durable Object can't reach into another).
153
+ *
154
+ * Relations are static, so these are caught at validation time (boot + codegen),
155
+ * never as a runtime surprise. Runs even for a single (default) partition — it's
156
+ * cheap and catches relation-target typos.
157
+ */
158
+ export declare function validateSchema(schema: SchemaDef): void;
112
159
  export {};
@@ -19,13 +19,23 @@ const builders = {
19
19
  /** A reference to a stored file (R2 object). Holds JSON metadata (a FileRef),
20
20
  * not the bytes — upload/download go through ctx.files + the Worker /files/* route. */
21
21
  fileRef: () => ({ type: "fileRef" }),
22
+ /** A UUID stored in a TEXT column (typed as `string`). Wrap with `generated()` to
23
+ * auto-mint a v4 on insert, and/or `primaryKey()` to use it as the PK — the kvalt
24
+ * pattern `id: primaryKey(generated(t.uuid()))`. A provided value is validated. */
25
+ uuid: () => ({ type: "uuid" }),
22
26
  };
23
27
  const relationBuilders = {
24
28
  belongsTo: (target, column) => ({ kind: "belongsTo", target, column }),
25
29
  hasMany: (target, column) => ({ kind: "hasMany", target, column }),
26
30
  };
27
- export function Entity(build, relations) {
28
- return { fields: build(builders), relations: (relations ? relations(relationBuilders) : {}) };
31
+ /** The default partition name for entities that don't declare one. */
32
+ export const DEFAULT_PARTITION = "default";
33
+ export function Entity(build, relations, opts) {
34
+ return {
35
+ fields: build(builders),
36
+ relations: (relations ? relations(relationBuilders) : {}),
37
+ partition: opts?.partition ?? DEFAULT_PARTITION,
38
+ };
29
39
  }
30
40
  /** Annotate a field as renamed from a previous column name (migration hint). Wraps
31
41
  * a builder result, preserving its literal field type: `title: renamedFrom(t.text(), "name")`. */
@@ -51,6 +61,73 @@ export function indexed(field) {
51
61
  export function defaultTo(field, value) {
52
62
  return { ...field, default: value };
53
63
  }
64
+ /** Mark a column as the PRIMARY KEY (implies NOT NULL). Composes with any builder,
65
+ * e.g. `id: primaryKey(generated(t.uuid()))` or `code: primaryKey(t.text())`. */
66
+ export function primaryKey(field) {
67
+ return { ...field, primaryKey: true, notNull: true };
68
+ }
69
+ /** Auto-generate the column's value on insert when omitted — uuid only (minted via
70
+ * crypto.randomUUID()). Makes the column optional on insert. Rejected at schema
71
+ * construction on a non-uuid column, since the runtime only knows how to mint uuids. */
72
+ export function generated(field) {
73
+ if (field.type !== "uuid") {
74
+ throw new Error(`generated() is only valid on a uuid column (got '${field.type}')`);
75
+ }
76
+ return { ...field, generated: true };
77
+ }
54
78
  export function defineSchema(entities) {
55
79
  return entities;
56
80
  }
81
+ // --- partition helpers — enumerate / resolve the partition (Durable Object class)
82
+ // an entity lives in. Used by the migrator/admin to group tables per DO.
83
+ /** The partition an entity lives in. Defaults to `"default"` for unknown entities. */
84
+ export function partitionOf(schema, entity) {
85
+ return schema[entity]?.partition ?? DEFAULT_PARTITION;
86
+ }
87
+ /** The distinct partition names in a schema, in stable (first-seen) order. */
88
+ export function partitionsOf(schema) {
89
+ const seen = new Set();
90
+ const out = [];
91
+ for (const entity of Object.keys(schema)) {
92
+ const partition = partitionOf(schema, entity);
93
+ if (!seen.has(partition)) {
94
+ seen.add(partition);
95
+ out.push(partition);
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+ /** The table names whose partition matches `partition`, in schema (key) order. */
101
+ export function entitiesInPartition(schema, partition) {
102
+ return Object.keys(schema).filter((entity) => partitionOf(schema, entity) === partition);
103
+ }
104
+ // --- schema validation — static invariants checked once before migrate (DO boot
105
+ // + the D1 path) and at codegen. Cloudflare-free, so it stays in sdk/.
106
+ /**
107
+ * Validate a schema's static invariants, throwing on the first violation:
108
+ *
109
+ * - every relation's `target` names an entity that exists in the schema;
110
+ * - no relation crosses a partition boundary — a relation's source and target
111
+ * must live in the same partition (a Durable Object can't reach into another).
112
+ *
113
+ * Relations are static, so these are caught at validation time (boot + codegen),
114
+ * never as a runtime surprise. Runs even for a single (default) partition — it's
115
+ * cheap and catches relation-target typos.
116
+ */
117
+ export function validateSchema(schema) {
118
+ for (const [entity, def] of Object.entries(schema)) {
119
+ for (const [relName, rel] of Object.entries(def.relations)) {
120
+ if (!(rel.target in schema)) {
121
+ throw new Error(`relation '${entity}.${relName}' targets unknown entity '${rel.target}' — ` +
122
+ `no such entity in the schema. Check the relation target name.`);
123
+ }
124
+ const pE = partitionOf(schema, entity);
125
+ const pT = partitionOf(schema, rel.target);
126
+ if (pE !== pT) {
127
+ throw new Error(`relation '${entity}.${relName}' crosses a partition boundary: '${entity}' is in partition ` +
128
+ `'${pE}' but target '${rel.target}' is in '${pT}'. Relations cannot cross partitions — ` +
129
+ `put both entities in the same partition or drop the relation.`);
130
+ }
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,2 @@
1
+ /** Whether `v` is a syntactically valid UUID string. */
2
+ export declare function isValidUuid(v: unknown): v is string;
@@ -0,0 +1,10 @@
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
+ // Canonical 8-4-4-4-12 hex form, case-insensitive. Version/variant-agnostic so it
5
+ // accepts v4 (what we generate) as well as v7 and others a caller might supply.
6
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ /** Whether `v` is a syntactically valid UUID string. */
8
+ export function isValidUuid(v) {
9
+ return typeof v === "string" && UUID_RE.test(v);
10
+ }
package/dist/worker.d.ts CHANGED
@@ -33,6 +33,7 @@ export declare function callPrivileged(env: Env, opts: {
33
33
  input?: unknown;
34
34
  tenant?: string;
35
35
  roles?: string[];
36
+ partition?: string;
36
37
  }): Promise<Response>;
37
38
  /** Build the Worker fetch handler for an app. State (the JWKS cache, the D1
38
39
  * compiled-ACL + one-time migration) is per-app, held in this closure. */
package/dist/worker.js CHANGED
@@ -10,7 +10,9 @@ import { compileAcl } from "./runtime/acl";
10
10
  import { D1Driver } from "./runtime/driver";
11
11
  import { toResponse } from "./runtime/errors";
12
12
  import { Kv } from "./runtime/kv";
13
+ import { listDOs, partitionDoName } from "./runtime/registry";
13
14
  import { createFiles, handleFileRequest, R2Adapter } from "./runtime/storage";
15
+ import { DEFAULT_PARTITION } from "./sdk/schema";
14
16
  /** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
15
17
  * else AUTH_SECRET (so HS256 setups work out of the box). */
16
18
  const filesSecret = (env) => env.FILES_SECRET || env.AUTH_SECRET;
@@ -42,18 +44,27 @@ function withCors(res, cors) {
42
44
  headers.set(k, v);
43
45
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
44
46
  }
47
+ /** Resolve the Durable Object stub for a `(tenant, partition)`. The DO name comes from
48
+ * `partitionDoName`, so routing and the registry stay in lockstep: it returns the BARE
49
+ * `tenant` for the default partition (so `idFromName(tenant)` is byte-for-byte unchanged
50
+ * — backward-compat) and `${tenant}:${partition}` for any other partition. */
51
+ function partitionStubFor(env, tenant, partition = DEFAULT_PARTITION) {
52
+ return env.PRAMEN.get(env.PRAMEN.idFromName(partitionDoName(tenant, partition)));
53
+ }
45
54
  /** Forward a privileged mutation into a tenant's DO from a public route. The
46
55
  * synthetic identity (default `["admin"]`) is trusted because the call originates
47
56
  * in the Worker — the same internal mechanism the admin endpoints use. Returns the
48
57
  * DO's JSON response (`{ ok, result }` / `{ ok: false, … }`). */
49
58
  export async function callPrivileged(env, opts) {
50
59
  const tenant = opts.tenant ?? "main";
51
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
60
+ const partition = opts.partition ?? DEFAULT_PARTITION;
61
+ const stub = partitionStubFor(env, tenant, partition);
52
62
  return stub.fetch(new Request(`https://do/rpc/${opts.name}`, {
53
63
  method: "POST",
54
64
  headers: {
55
65
  "content-type": "application/json",
56
66
  "x-pramen-tenant": tenant,
67
+ "x-pramen-partition": partition,
57
68
  "x-pramen-identity": JSON.stringify({ roles: opts.roles ?? ["admin"] }),
58
69
  },
59
70
  body: JSON.stringify(opts.input ?? {}),
@@ -125,15 +136,20 @@ export function makeWorker(app) {
125
136
  const qTenant = url.searchParams.get("tenant");
126
137
  if (qTenant && !h.get("x-pramen-tenant"))
127
138
  h.set("x-pramen-tenant", qTenant);
139
+ // A single socket lives in one partition (cross-partition live is out of scope);
140
+ // accept it via ?partition= and default to the default partition.
141
+ const qPartition = url.searchParams.get("partition");
142
+ if (!h.get("x-pramen-partition"))
143
+ h.set("x-pramen-partition", qPartition || DEFAULT_PARTITION);
128
144
  req = new Request(request, { headers: h });
129
145
  }
130
146
  const identity = await resolveIdentity(req, strategyFor(env));
131
- // --- admin: list known tenants ---
147
+ // --- admin: list known (tenant, partition) DOs from the registry ---
132
148
  if (url.pathname === "/tenants") {
133
149
  if (!isAdmin(identity))
134
150
  return withCors(forbidden("tenants"), cors);
135
- const list = await env.KV.list({ prefix: "tenant:" });
136
- return withCors(json({ ok: true, result: list.keys.map((k) => k.name.slice("tenant:".length)) }), cors);
151
+ const result = await listDOs(env.KV);
152
+ return withCors(json({ ok: true, result }), cors);
137
153
  }
138
154
  // --- admin: point-in-time recovery for a tenant ---
139
155
  if (url.pathname === "/admin/recover" && request.method === "POST") {
@@ -144,10 +160,11 @@ export function makeWorker(app) {
144
160
  return badRequest("tenant required");
145
161
  if (typeof body.timestamp !== "number" && typeof body.timestamp !== "string")
146
162
  return badRequest("timestamp required");
147
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(body.tenant));
163
+ const partition = typeof body.partition === "string" && body.partition ? body.partition : DEFAULT_PARTITION;
164
+ const stub = partitionStubFor(env, body.tenant, partition);
148
165
  const internal = new Request("https://do/__recover", {
149
166
  method: "POST",
150
- headers: { "content-type": "application/json", "x-pramen-tenant": body.tenant },
167
+ headers: { "content-type": "application/json", "x-pramen-tenant": body.tenant, "x-pramen-partition": partition },
151
168
  body: JSON.stringify({ timestamp: body.timestamp }),
152
169
  });
153
170
  return stub.fetch(internal);
@@ -157,8 +174,9 @@ export function makeWorker(app) {
157
174
  if (!isAdmin(identity))
158
175
  return withCors(forbidden("schema"), cors);
159
176
  const tenant = url.searchParams.get("tenant") ?? "main";
160
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
161
- const res = await stub.fetch(new Request("https://do/__schema", { headers: { "x-pramen-tenant": tenant } }));
177
+ const partition = url.searchParams.get("partition") || DEFAULT_PARTITION;
178
+ const stub = partitionStubFor(env, tenant, partition);
179
+ const res = await stub.fetch(new Request("https://do/__schema", { headers: { "x-pramen-tenant": tenant, "x-pramen-partition": partition } }));
162
180
  return withCors(res, cors);
163
181
  }
164
182
  // --- admin: generic data ops over a tenant's tables (browse/edit any row).
@@ -169,10 +187,11 @@ export function makeWorker(app) {
169
187
  return forbidden("data");
170
188
  const body = (await request.json().catch(() => ({})));
171
189
  const tenant = typeof body.tenant === "string" && body.tenant ? body.tenant : "main";
172
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
190
+ const partition = typeof body.partition === "string" && body.partition ? body.partition : DEFAULT_PARTITION;
191
+ const stub = partitionStubFor(env, tenant, partition);
173
192
  const res = await stub.fetch(new Request("https://do/__admin/data", {
174
193
  method: "POST",
175
- headers: { "content-type": "application/json", "x-pramen-tenant": tenant },
194
+ headers: { "content-type": "application/json", "x-pramen-tenant": tenant, "x-pramen-partition": partition },
176
195
  body: JSON.stringify(body),
177
196
  }));
178
197
  return withCors(res, cors);
@@ -182,8 +201,9 @@ export function makeWorker(app) {
182
201
  if (!isRpc && !(isLive && isWs)) {
183
202
  return new Response("pramen — POST /rpc/<handler> (JSON body), or WebSocket /live for live queries. " +
184
203
  "Header X-Pramen-Tenant selects the store (default: main). " +
185
- "Admin: GET /tenants, POST /admin/recover {tenant,timestamp}, GET /admin/schema?tenant=, " +
186
- "POST /admin/data {tenant,table,op}.\n", { headers: { "content-type": "text/plain" } });
204
+ "Admin (optional partition selects the partition DO, default: " + DEFAULT_PARTITION + "): " +
205
+ "GET /tenants, POST /admin/recover {tenant,timestamp,partition?}, GET /admin/schema?tenant=&partition=, " +
206
+ "POST /admin/data {tenant,table,op,partition?}.\n", { headers: { "content-type": "text/plain" } });
187
207
  }
188
208
  // Authorize the tenant against the identity before reaching the DO, so a
189
209
  // caller can't address (or register) tenants they have no claim to.
@@ -216,13 +236,25 @@ export function makeWorker(app) {
216
236
  return withCors(json(body, status), cors);
217
237
  }
218
238
  }
239
+ // Resolve the partition to route to. For /rpc it's declared statically on the
240
+ // handler; for /live it's the socket's partition (already folded into the header
241
+ // from ?partition=). Forward it to the DO in x-pramen-partition either way.
242
+ let partition;
243
+ if (isRpc) {
244
+ const name = url.pathname.replace(/^\/rpc\//, "");
245
+ partition = app.handlers[name]?.partition ?? DEFAULT_PARTITION;
246
+ }
247
+ else {
248
+ partition = req.headers.get("x-pramen-partition") || DEFAULT_PARTITION;
249
+ }
219
250
  // Forward a trusted identity to the DO (the DO never re-derives it).
220
251
  const headers = new Headers(req.headers);
221
252
  if (identity)
222
253
  headers.set("x-pramen-identity", JSON.stringify(identity));
223
254
  else
224
255
  headers.delete("x-pramen-identity");
225
- const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
256
+ headers.set("x-pramen-partition", partition);
257
+ const stub = partitionStubFor(env, tenant, partition);
226
258
  // WebSocket upgrades (101) must be returned untouched; only add CORS to HTTP.
227
259
  const res = await stub.fetch(new Request(req, { headers }));
228
260
  return isWs ? res : withCors(res, cors);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
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": {