@pramen/server 0.0.3 → 0.0.5

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.
@@ -24,6 +24,8 @@ import { compileAcl, type AclContext, type CompiledAcl } from "./runtime/acl";
24
24
  import { DoSqliteDriver, type Driver } from "./runtime/driver";
25
25
  import { BadRequest, toResponse, toWsError } from "./runtime/errors";
26
26
  import { Kv } from "./runtime/kv";
27
+ import { registryKey } from "./runtime/registry";
28
+ import { DEFAULT_PARTITION } from "./sdk/schema";
27
29
  import { createFiles, R2Adapter, type Files } from "./runtime/storage";
28
30
  import type { Identity } from "./sdk/acl";
29
31
  import type { PramenApp } from "./pramen";
@@ -33,6 +35,9 @@ interface SocketState {
33
35
  identity: Identity | null;
34
36
  /** Tenant fixed at connect time (survives hibernation via the attachment). */
35
37
  tenant: string;
38
+ /** Partition fixed at connect time (read from x-pramen-partition at upgrade);
39
+ * survives hibernation via the attachment, like `tenant`. */
40
+ partition: string;
36
41
  subs: Subscription[];
37
42
  }
38
43
 
@@ -62,6 +67,13 @@ export class PramenDOBase extends DurableObject<DoEnv> {
62
67
  /** Tenant this DO serves (one per idFromName). Learned from the Worker-forwarded
63
68
  * x-pramen-tenant header; defaults to "main". */
64
69
  private tenant = "main";
70
+ /** Partition this DO serves (one per idFromName(partitionDoName)). Learned from the
71
+ * Worker-forwarded x-pramen-partition header on the first fetch; defaults to the
72
+ * default partition (a single-partition app, or a stray request with no header). */
73
+ private partition = DEFAULT_PARTITION;
74
+ /** Boot migration runs on the FIRST fetch (once the partition is known), not in the
75
+ * constructor — see `ensureMigrated`. Guards against re-running. */
76
+ private migrated = false;
65
77
  private files?: Files;
66
78
 
67
79
  constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp) {
@@ -74,19 +86,46 @@ export class PramenDOBase extends DurableObject<DoEnv> {
74
86
  // the D1 substrate (D1Driver) lives in the Worker (the "Worker + D1, no DO" path).
75
87
  this.driver = new DoSqliteDriver(ctx.storage);
76
88
 
77
- // Reconcile the store with the schema before any request is served (create/alter
78
- // tables; destructive changes rebuild the table). Wrapped in a transaction so a
79
- // partial migration can't leave a half-rebuilt table.
80
- const allowDestructive = env.PRAMEN_ALLOW_DESTRUCTIVE === "true";
81
- ctx.blockConcurrencyWhile(() =>
82
- this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive }).then(() => {})),
83
- );
89
+ // NOTE: the boot migration is NOT run here. The constructor cannot know which
90
+ // partition this DO serves that arrives in the x-pramen-partition header on the
91
+ // first request, after construction. Migrating the full schema here would create
92
+ // OTHER partitions' tables in this DO (defeating partition isolation), so we defer
93
+ // the partition-scoped migrate() to the first fetch (`ensureMigrated`), guarded by
94
+ // ctx.blockConcurrencyWhile so concurrent first requests can't double-migrate.
95
+ }
96
+
97
+ // Reconcile this partition's tables with the schema before the first request is
98
+ // served. Runs once per DO lifetime: the `migrated` flag + blockConcurrencyWhile
99
+ // serialize concurrent first fetches (the platform queues other requests while the
100
+ // block runs), so two in-flight first requests can't both migrate. Scoped to
101
+ // this.partition — only this partition's tables are created/altered (migrate()
102
+ // never touches other partitions' tables). Wrapped in a transaction so a partial
103
+ // migration can't leave a half-rebuilt table. This preserves the single-partition
104
+ // (default) behavior exactly: a default DO migrates its full default-partition
105
+ // schema, just lazily on first touch instead of in the constructor.
106
+ private async ensureMigrated(): Promise<void> {
107
+ if (this.migrated) return;
108
+ const allowDestructive = this.env.PRAMEN_ALLOW_DESTRUCTIVE === "true";
109
+ const partition = this.partition;
110
+ await this.ctx.blockConcurrencyWhile(async () => {
111
+ if (this.migrated) return; // a concurrent first request already migrated
112
+ await this.driver.transaction(() =>
113
+ migrate(this.driver, this.app.schema, { allowDestructive, partition }).then(() => {}),
114
+ );
115
+ this.migrated = true;
116
+ });
84
117
  }
85
118
 
86
119
  override async fetch(request: Request): Promise<Response> {
87
- await this.ensureRegistered(request);
88
120
  const tenantHeader = request.headers.get("x-pramen-tenant");
89
121
  if (tenantHeader) this.tenant = tenantHeader;
122
+ // Learn the partition before the boot migration so it migrates the right subset.
123
+ // Absent header (shouldn't happen post-routing) -> default partition.
124
+ const partitionHeader = request.headers.get("x-pramen-partition");
125
+ if (partitionHeader) this.partition = partitionHeader;
126
+
127
+ await this.ensureMigrated();
128
+ await this.ensureRegistered(request);
90
129
 
91
130
  const path = new URL(request.url).pathname;
92
131
  if (path === "/__recover") return this.handleRecover(request);
@@ -98,7 +137,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
98
137
  if (request.headers.get("Upgrade") === "websocket") {
99
138
  const { 0: client, 1: server } = new WebSocketPair();
100
139
  this.ctx.acceptWebSocket(server); // hibernatable
101
- this.setState(server, { identity, tenant: this.tenant, subs: [] });
140
+ this.setState(server, { identity, tenant: this.tenant, partition: this.partition, subs: [] });
102
141
  return new Response(null, { status: 101, webSocket: client });
103
142
  }
104
143
 
@@ -138,6 +177,16 @@ export class PramenDOBase extends DurableObject<DoEnv> {
138
177
  return this.send(ws, { type: "error", id: "", error: "invalid JSON" });
139
178
  }
140
179
 
180
+ // A hibernated DO can be reconstructed and routed here WITHOUT fetch() running
181
+ // again, so the boot migration may not have run on this fresh instance. Adopt this
182
+ // socket's (tenant, partition) — fixed at connect time, survives via the attachment
183
+ // — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
184
+ // (the `migrated` flag), so a no-op after the first call.
185
+ const { tenant, partition } = this.getState(ws);
186
+ this.tenant = tenant;
187
+ this.partition = partition;
188
+ await this.ensureMigrated();
189
+
141
190
  switch (msg.type) {
142
191
  case "subscribe":
143
192
  return this.onSubscribe(ws, msg.id, msg.name, msg.input);
@@ -175,7 +224,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
175
224
  if (!replacing && state.subs.length >= MAX_SUBSCRIPTIONS) {
176
225
  return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
177
226
  }
178
- const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity), name, input);
227
+ const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), name, input);
179
228
  if (kind !== "query") {
180
229
  return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
181
230
  }
@@ -191,7 +240,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
191
240
  private async onCall(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
192
241
  const state = this.getState(ws);
193
242
  try {
194
- const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity), name, input);
243
+ const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), name, input);
195
244
  this.send(ws, { type: "result", id, result });
196
245
  if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
197
246
  } catch (err) {
@@ -209,7 +258,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
209
258
  for (const sub of state.subs) {
210
259
  if (!sub.tables.some((t) => written.has(t))) continue;
211
260
  try {
212
- const { result } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity), sub.name, sub.input);
261
+ const { result } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), sub.name, sub.input);
213
262
  const next = digest(result);
214
263
  if (next === sub.digest) continue; // result unchanged for this subscription
215
264
  sub.digest = next;
@@ -239,7 +288,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
239
288
  this.registered = true;
240
289
  return;
241
290
  }
242
- await this.env.KV.put(`tenant:${name}`, JSON.stringify({ firstSeen: Date.now() }));
291
+ // Record under this DO's real (tenant, partition): the default partition keeps the
292
+ // bare `tenant:<name>` key (backward-compat), a non-default one is `tenant:<name>:<p>`.
293
+ await this.env.KV.put(registryKey(name, this.partition), JSON.stringify({ firstSeen: Date.now() }));
243
294
  await this.driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES ('registered', ?)`, [name]);
244
295
  this.registered = true;
245
296
  }
@@ -279,7 +330,11 @@ export class PramenDOBase extends DurableObject<DoEnv> {
279
330
  // Introspection: this tenant's applied schema hash + live table/column shape
280
331
  // (admin-gated at the Worker). Powers the CLI's `schema status`.
281
332
  private async handleSchema(): Promise<Response> {
282
- const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = 'schema_hash'`, [])) as {
333
+ // The applied-schema hash is stored per-partition (migrate keys it
334
+ // `schema_hash:<partition>` whenever a partition is scoped, which the DO always
335
+ // does). Read this DO's partition's key.
336
+ const hashKey = `schema_hash:${this.partition}`;
337
+ const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = ?`, [hashKey])) as {
283
338
  value: string;
284
339
  }[];
285
340
  const tableRows = (await this.driver.exec(
@@ -304,7 +359,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
304
359
  return Response.json({ ok: false, error: "unknown table", code: "bad_request" }, { status: 400 });
305
360
  }
306
361
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
307
- const db = new Db(this.driver, { acl: this.acl, identity: null, system: true }, this.app.schema) as any;
362
+ const db = new Db(this.driver, { acl: this.acl, identity: null, system: true, partition: this.partition }, this.app.schema) as any;
308
363
  try {
309
364
  let result: unknown;
310
365
  let mutated = false;
@@ -341,10 +396,11 @@ export class PramenDOBase extends DurableObject<DoEnv> {
341
396
  }
342
397
  }
343
398
 
344
- private ctxFor(identity: Identity | null): AclContext {
399
+ private ctxFor(identity: Identity | null, partition: string = this.partition): AclContext {
345
400
  // Carry the schema so any consumer of this context (not just Db) can compile
346
- // relation-aware `where` rules into subqueries.
347
- return { acl: this.acl, identity, schema: this.app.schema };
401
+ // relation-aware `where` rules into subqueries, and the active partition so Db's
402
+ // table-access guard rejects any table outside this DO's partition.
403
+ return { acl: this.acl, identity, schema: this.app.schema, partition };
348
404
  }
349
405
 
350
406
  // The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
@@ -374,7 +430,14 @@ export class PramenDOBase extends DurableObject<DoEnv> {
374
430
  }
375
431
 
376
432
  private getState(ws: WebSocket): SocketState {
377
- return (ws.deserializeAttachment() as SocketState | null) ?? { identity: null, tenant: this.tenant, subs: [] };
433
+ return (
434
+ (ws.deserializeAttachment() as SocketState | null) ?? {
435
+ identity: null,
436
+ tenant: this.tenant,
437
+ partition: this.partition,
438
+ subs: [],
439
+ }
440
+ );
378
441
  }
379
442
 
380
443
  private setState(ws: WebSocket, state: SocketState): void {
package/src/index.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  // and codegen load an app.ts for its schema without dragging in the DO runtime.
9
9
 
10
10
  // --- schema authoring ---
11
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo } from "./sdk/schema";
11
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
12
+ export { isValidUuid } from "./sdk/uuid";
12
13
  export type {
13
14
  DefaultValue,
14
15
  FieldType,
@@ -60,6 +60,10 @@ export interface AclContext {
60
60
  /** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
61
61
  * compiled to a subquery with the related entity's read scope AND-merged in. */
62
62
  readonly schema?: SchemaDef;
63
+ /** The partition this DO serves. When set, Db rejects any access to a table that
64
+ * lives in a different partition (a partition-DO only owns its own tables). Unset
65
+ * (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
66
+ readonly partition?: string;
63
67
  }
64
68
 
65
69
  /** Evaluate every resolver reachable by the identity's roles, once per request.
package/src/runtime/db.ts CHANGED
@@ -41,7 +41,8 @@ import {
41
41
  } from "./read-engine";
42
42
  import { BadRequest } from "./errors";
43
43
  import type { Dialect, Driver } from "./driver";
44
- import type { EntityFields, FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
44
+ import { partitionOf, type EntityFields, type FieldDef, type RelationDef, type SchemaDef } from "../sdk/schema";
45
+ import { isValidUuid } from "../sdk/uuid";
45
46
  import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
46
47
 
47
48
  type Row = Record<string, unknown>;
@@ -168,6 +169,21 @@ export class Db<S extends SchemaDef = SchemaDef> {
168
169
  this.dialect = driver.dialect;
169
170
  }
170
171
 
172
+ /** Partition guard. When this Db's context carries an active partition (a
173
+ * partition-DO knows which partition it serves), reject any access to a table
174
+ * that lives in a different partition — a partition-DO only owns its own tables,
175
+ * so touching another partition's table is a routing bug, not an empty result.
176
+ * Unset partition (the D1/Worker shared-store path, or a single-partition app on
177
+ * the default DO with no header) makes this a no-op. */
178
+ private assertInPartition(table: string): void {
179
+ const self = this.acl.partition;
180
+ if (self === undefined) return;
181
+ const tablePartition = partitionOf(this.schema, table);
182
+ if (tablePartition !== self) {
183
+ throw new BadRequest(`table '${table}' is in partition '${tablePartition}', not this partition '${self}'`);
184
+ }
185
+ }
186
+
171
187
  /** Resolve the ACL scope for an operation, or grant everything in SYSTEM mode. */
172
188
  private scopeFor(entity: string, action: Action): Scope {
173
189
  if (this.acl.system) return ALLOW_ALL;
@@ -226,6 +242,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
226
242
  ): Promise<(InferRow<FieldsOf<S[T]>> & RelationsResult<S, T>)[]> {
227
243
  const from = spec.from as string;
228
244
  this.touched.add(from);
245
+ this.assertInPartition(from);
229
246
  const scope = this.scopeFor(from, "read");
230
247
  if (!scope.allowed) throw new AclDenied(from, "read");
231
248
 
@@ -245,6 +262,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
245
262
  ): Promise<Page<InferRow<FieldsOf<S[T]>> & RelationsResult<S, T>>> {
246
263
  const from = spec.from as string;
247
264
  this.touched.add(from);
265
+ this.assertInPartition(from);
248
266
  const scope = this.scopeFor(from, "read");
249
267
  if (!scope.allowed) throw new AclDenied(from, "read");
250
268
 
@@ -269,6 +287,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
269
287
  async count<T extends keyof S & string>(spec: { from: T; where?: WhereClause<S, T> }): Promise<number> {
270
288
  const from = spec.from as string;
271
289
  this.touched.add(from);
290
+ this.assertInPartition(from);
272
291
  const scope = this.scopeFor(from, "read");
273
292
  if (!scope.allowed) throw new AclDenied(from, "read");
274
293
  const where = this.readWhere(from, spec.where, scope);
@@ -293,6 +312,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
293
312
  }): Promise<AggregateResult<FieldsOf<S[T]>, G, A>[]> {
294
313
  const from = spec.from as string;
295
314
  this.touched.add(from);
315
+ this.assertInPartition(from);
296
316
  const scope = this.scopeFor(from, "read");
297
317
  if (!scope.allowed) throw new AclDenied(from, "read");
298
318
 
@@ -365,6 +385,35 @@ export class Db<S extends SchemaDef = SchemaDef> {
365
385
  .map(([n]) => n);
366
386
  }
367
387
 
388
+ /** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
389
+ * `vals`. Returns the columns it filled — server-minted, so the insert path treats
390
+ * them like forced `set` values (bypassing the writable-field ACL check). */
391
+ private fillGeneratedUuids(table: string, vals: Row): string[] {
392
+ const fields = this.schema[table]?.fields;
393
+ if (!fields) return [];
394
+ const minted: string[] = [];
395
+ for (const [name, f] of Object.entries(fields)) {
396
+ const fd = f as FieldDef;
397
+ if (fd.type === "uuid" && fd.generated && vals[name] == null) {
398
+ vals[name] = crypto.randomUUID();
399
+ minted.push(name);
400
+ }
401
+ }
402
+ return minted;
403
+ }
404
+
405
+ /** Reject a malformed value on any uuid column present in `row` (mirrors kvalt's
406
+ * write-time isValidUuid). Absent columns are not checked. */
407
+ private assertValidUuids(table: string, row: Row): void {
408
+ const fields = this.schema[table]?.fields;
409
+ if (!fields) return;
410
+ for (const [name, f] of Object.entries(fields)) {
411
+ if ((f as FieldDef).type !== "uuid") continue;
412
+ const v = row[name];
413
+ if (v != null && !isValidUuid(v)) throw new BadRequest(`invalid UUID for '${name}'`);
414
+ }
415
+ }
416
+
368
417
  private decodeRows(table: string, rows: Row[]): Row[] {
369
418
  const cols = this.jsonColsOf(table);
370
419
  if (cols.length === 0) return rows;
@@ -469,13 +518,18 @@ export class Db<S extends SchemaDef = SchemaDef> {
469
518
  values: InferInsert<FieldsOf<S[T]>>,
470
519
  ): Promise<InferRow<FieldsOf<S[T]>>> {
471
520
  this.touched.add(table);
521
+ this.assertInPartition(table);
472
522
  const scope = this.scopeFor(table, "create");
473
523
  if (!scope.allowed) throw new AclDenied(table, "create");
474
524
  const vals = { ...(values as Row) };
475
525
  const { set, validators } = this.writeRules(table, "create");
476
526
  Object.assign(vals, set); // forced server values first, so a conditional `when` can see them
477
- this.checkWriteFields(table, "create", scope, Object.keys(vals), vals, new Set(Object.keys(set)));
527
+ // Auto-mint generated() uuid columns the caller omitted; server-minted, so they
528
+ // join `set` in the bypass-list for the writable-field check.
529
+ const generatedCols = this.fillGeneratedUuids(table, vals);
530
+ this.checkWriteFields(table, "create", scope, Object.keys(vals), vals, new Set([...Object.keys(set), ...generatedCols]));
478
531
  this.runValidators(validators, vals);
532
+ this.assertValidUuids(table, vals);
479
533
 
480
534
  const cols = Object.keys(vals);
481
535
  const jsonCols = new Set(this.jsonColsOf(table));
@@ -512,6 +566,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
512
566
  patch: InferUpdate<FieldsOf<S[T]>>,
513
567
  ): Promise<InferRow<FieldsOf<S[T]>> | undefined> {
514
568
  this.touched.add(table);
569
+ this.assertInPartition(table);
515
570
  const scope = this.scopeFor(table, "update");
516
571
  if (!scope.allowed) throw new AclDenied(table, "update");
517
572
  const p = { ...(patch as Row) };
@@ -530,6 +585,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
530
585
  }
531
586
  this.checkWriteFields(table, "update", scope, cols, evalRow, new Set(Object.keys(set)));
532
587
  this.runValidators(validators, p);
588
+ this.assertValidUuids(table, p);
533
589
 
534
590
  const params: unknown[] = [];
535
591
  const jsonCols = new Set(this.jsonColsOf(table));
@@ -550,6 +606,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
550
606
  /** Delete a row by id within scope. Returns whether a row was deleted. */
551
607
  async delete<T extends keyof S & string>(table: T, id: Id): Promise<boolean> {
552
608
  this.touched.add(table);
609
+ this.assertInPartition(table);
553
610
  const scope = this.scopeFor(table, "delete");
554
611
  if (!scope.allowed) throw new AclDenied(table, "delete");
555
612
  const params: unknown[] = [this.dialect.encode(id)];
@@ -4,11 +4,15 @@
4
4
 
5
5
  import type { DefaultValue, EntityFields, FieldDef } from "../sdk/schema";
6
6
 
7
- // SQLite has no boolean type; store as INTEGER 0/1. json + fileRef are stored as
8
- // TEXT (JSON). Exported for the migrator, which compares declared column types
7
+ // SQLite has no boolean type; store as INTEGER 0/1. json + fileRef + uuid are
8
+ // stored as TEXT. Exported for the migrator, which compares declared column types
9
9
  // (and CASTs on a type change).
10
10
  export const sqlType = (f: FieldDef): string =>
11
- f.type === "boolean" ? "INTEGER" : f.type === "json" || f.type === "fileRef" ? "TEXT" : f.type.toUpperCase();
11
+ f.type === "boolean"
12
+ ? "INTEGER"
13
+ : f.type === "json" || f.type === "fileRef" || f.type === "uuid"
14
+ ? "TEXT"
15
+ : f.type.toUpperCase();
12
16
 
13
17
  /** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1. */
14
18
  function defaultLiteral(v: DefaultValue): string {
@@ -18,10 +22,15 @@ function defaultLiteral(v: DefaultValue): string {
18
22
  return `'${v.replace(/'/g, "''")}'`;
19
23
  }
20
24
 
21
- /** The ` DEFAULT x` fragment for a column, or "" when it has no default. UNIQUE/
22
- * index are NOT inline — they're emitted as separate index statements so the same
23
- * code path serves both CREATE TABLE and ALTER TABLE ADD COLUMN. */
25
+ /** The ` DEFAULT x` fragment for a column, or "" when it has no default. A raw-SQL
26
+ * `defaultExpr` (e.g. `datetime('now')`) is emitted UNQUOTED; a literal `default` is
27
+ * quote-escaped. UNIQUE/index are NOT inline they're emitted as separate index
28
+ * statements so the same code path serves both CREATE TABLE and ALTER TABLE ADD COLUMN. */
24
29
  function defaultSql(f: FieldDef): string {
30
+ // A raw-SQL default is parenthesized: SQLite's column-DEFAULT grammar only takes a
31
+ // bare literal/keyword, so a function call (e.g. datetime('now')) must be wrapped —
32
+ // `DEFAULT (datetime('now'))`. Parens are harmless around a keyword too.
33
+ if (f.defaultExpr !== undefined) return ` DEFAULT (${f.defaultExpr})`;
25
34
  return f.default !== undefined ? ` DEFAULT ${defaultLiteral(f.default)}` : "";
26
35
  }
27
36
 
@@ -50,10 +50,10 @@ export async function dispatch(
50
50
 
51
51
  // Warmup: evaluate dynamic resolvers once, reading through a SYSTEM-mode db
52
52
  // (separate from the handler's db, so its reads don't pollute `touched`).
53
- const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema }, schema);
53
+ const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema, partition: acl.partition }, schema);
54
54
  const resolved = await warmup(acl.acl, acl.identity, systemDb as unknown as ResolverDb);
55
55
 
56
- const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema }, schema);
56
+ const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema, partition: acl.partition }, schema);
57
57
  const ctx: HandlerContext = { db, kv, files, env, identity: acl.identity };
58
58
 
59
59
  const result =
@@ -21,6 +21,7 @@
21
21
  import { addColumnSql, createTableSql, indexStatements, sqlType } from "./ddl";
22
22
  import { digest } from "./digest";
23
23
  import type { Driver } from "./driver";
24
+ import { entitiesInPartition, validateSchema } from "../sdk/schema";
24
25
  import type { EntityFields, FieldDef, SchemaDef } from "../sdk/schema";
25
26
 
26
27
  export interface MigrationReport {
@@ -41,6 +42,14 @@ export interface MigrateOptions {
41
42
  * — data-loss is gated behind an explicit opt-in (env `PRAMEN_ALLOW_DESTRUCTIVE`).
42
43
  * Additive changes (create table, add column, add index) always apply. */
43
44
  allowDestructive?: boolean;
45
+ /** Scope the migration to a single partition (Durable Object class). When set,
46
+ * migrate operates ONLY on entities whose `partition` matches — it creates/alters
47
+ * just that partition's tables and never drops other partitions' tables (a
48
+ * partition-DO never sees them). The schema hash is stored under a per-partition
49
+ * key so partitions don't thrash each other's drift detection. When unset, all
50
+ * entities are migrated and the legacy single-hash key is used (unchanged — the
51
+ * D1 path and existing callers). */
52
+ partition?: string;
44
53
  }
45
54
 
46
55
  /** Internal bookkeeping tables the migrator must never touch — pramen's own, SQLite's,
@@ -110,11 +119,28 @@ async function rebuildTable(driver: Driver, table: string, def: { fields: Entity
110
119
  }
111
120
 
112
121
  export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOptions = {}): Promise<MigrationReport> {
122
+ // Static schema invariants (relation targets exist, no cross-partition relations) —
123
+ // checked before any DDL so a bad schema fails fast on boot / the D1 path, not mid-migration.
124
+ validateSchema(schema);
113
125
  await driver.exec(`CREATE TABLE IF NOT EXISTS _pramen_meta (key TEXT PRIMARY KEY, value TEXT)`, []);
114
126
  const allowDestructive = opts.allowDestructive ?? false;
115
127
 
116
- const current = schemaHash(schema);
117
- if ((await readMeta(driver, "schema_hash")) === current)
128
+ // When a partition is named, narrow the schema to just that partition's entities —
129
+ // every later pass (create/alter/rebuild/drop/index/hash) iterates this subset, so
130
+ // a partition-DO only ever touches its own tables. Unset ⇒ the whole schema, the
131
+ // legacy (default) behavior.
132
+ const tables = opts.partition === undefined ? Object.keys(schema) : entitiesInPartition(schema, opts.partition);
133
+ const entries = tables.map((table) => [table, schema[table]!] as const);
134
+ const inScope = new Set(tables);
135
+
136
+ // The schema hash is computed over the in-scope subset only, and stored under a
137
+ // per-partition meta key, so two partitions of the same app each detect just their
138
+ // own drift and never invalidate the other. The unscoped path keeps the original
139
+ // `schema_hash` key for backward compatibility (existing stores + the D1 path).
140
+ const subset: SchemaDef = Object.fromEntries(entries);
141
+ const hashKey = opts.partition === undefined ? "schema_hash" : `schema_hash:${opts.partition}`;
142
+ const current = schemaHash(subset);
143
+ if ((await readMeta(driver, hashKey)) === current)
118
144
  return { changed: false, created: [], added: [], rebuilt: [], droppedTables: [], skipped: [] };
119
145
 
120
146
  const created: string[] = [];
@@ -123,7 +149,7 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
123
149
  const droppedTables: string[] = [];
124
150
  const skipped: string[] = [];
125
151
 
126
- for (const [table, def] of Object.entries(schema)) {
152
+ for (const [table, def] of entries) {
127
153
  const existing = await tableColumns(driver, table);
128
154
  if (existing.size === 0) {
129
155
  await driver.exec(createTableSql(table, def), []);
@@ -131,8 +157,17 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
131
157
  continue;
132
158
  }
133
159
  // Pass 1 — additive: add any column the schema declares but the table lacks.
160
+ // SQLite forbids ALTER ADD COLUMN with a non-constant DEFAULT (e.g. expr.now()),
161
+ // so such a column is added via a table rebuild instead — which is still additive
162
+ // (no data loss): the rebuild's INSERT omits the new column, so SQLite applies its
163
+ // CREATE TABLE default, backfilling existing rows. Flagged here, done in Pass 2.
164
+ let needsAdditiveRebuild = false;
134
165
  for (const [name, field] of Object.entries(def.fields)) {
135
166
  if (existing.has(name)) continue;
167
+ if ((field as FieldDef).defaultExpr !== undefined) {
168
+ needsAdditiveRebuild = true;
169
+ continue;
170
+ }
136
171
  await driver.exec(`ALTER TABLE ${ident(table)} ADD COLUMN ${addColumnSql(name, field as FieldDef)}`, []);
137
172
  added.push(`${table}.${name}`);
138
173
  }
@@ -150,27 +185,38 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
150
185
  const needsTypeChange = Object.entries(def.fields).some(
151
186
  ([n, f]) => live.has(n) && live.get(n) !== sqlType(f as FieldDef),
152
187
  );
153
- if (needsDrop || needsTypeChange || renamedSources.size > 0) {
154
- if (allowDestructive) {
155
- await rebuildTable(driver, table, def, live);
156
- rebuilt.push(table);
157
- } else {
158
- skipped.push(`rebuild ${table} (drop/type-change/rename)`);
159
- }
188
+ const destructive = needsDrop || needsTypeChange || renamedSources.size > 0;
189
+ if (destructive && !allowDestructive) {
190
+ // The destructive part is gated off — skip the whole rebuild (any pending
191
+ // expr-default column waits until destructive migrations are allowed).
192
+ skipped.push(`rebuild ${table} (drop/type-change/rename)`);
193
+ } else if (destructive || needsAdditiveRebuild) {
194
+ // An additive-only rebuild (just an expr-default column) needs no permission —
195
+ // it loses no data.
196
+ await rebuildTable(driver, table, def, live);
197
+ rebuilt.push(table);
160
198
  }
161
199
  }
162
200
 
163
201
  // Ensure unique/index declarations (idempotent, via IF NOT EXISTS). Indexes can be
164
202
  // added to an existing table without a rebuild; a stale index from a removed
165
203
  // declaration is left in place (cleanup is future work).
166
- for (const [table, def] of Object.entries(schema)) {
204
+ for (const [table, def] of entries) {
167
205
  for (const stmt of indexStatements(table, def)) await driver.exec(stmt, []);
168
206
  }
169
207
 
170
208
  // Drop tables the schema no longer declares (internal bookkeeping tables skipped).
209
+ // When scoped to a partition, a live table that belongs to ANOTHER partition's
210
+ // entity must NOT be dropped — a partition-DO never owns it, and even when several
211
+ // partitions share a store the other partition's reconciler owns that table. So the
212
+ // drop candidate set is: live tables that are neither in this scope's declared
213
+ // entities nor declared by any other partition. (Unscoped: `otherPartitionTables`
214
+ // is empty and `inScope` is every entity, so this is the original behavior.)
215
+ const otherPartitionTables =
216
+ opts.partition === undefined ? new Set<string>() : new Set(Object.keys(schema).filter((t) => !inScope.has(t)));
171
217
  const liveTables = (await driver.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`, [])) as { name: string }[];
172
218
  for (const { name } of liveTables) {
173
- if (isInternalTable(name) || name in schema) continue;
219
+ if (isInternalTable(name) || inScope.has(name) || otherPartitionTables.has(name)) continue;
174
220
  if (allowDestructive) {
175
221
  await driver.exec(`DROP TABLE ${ident(name)}`, []);
176
222
  droppedTables.push(name);
@@ -183,7 +229,7 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
183
229
  // were skipped, leave the hash so a later deploy (with allowDestructive) retries —
184
230
  // additive work is idempotent, so re-running is safe.
185
231
  if (skipped.length === 0) {
186
- await writeMeta(driver, "schema_hash", current);
232
+ await writeMeta(driver, hashKey, current);
187
233
  } else {
188
234
  console.warn(
189
235
  `pramen: ${skipped.length} destructive migration(s) skipped (set PRAMEN_ALLOW_DESTRUCTIVE=true to apply): ${skipped.join("; ")}`,
@@ -0,0 +1,96 @@
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
+
24
+ import { DEFAULT_PARTITION } from "../sdk/schema";
25
+
26
+ const KEY_PREFIX = "tenant:";
27
+
28
+ /** A registered Durable Object identity: a (tenant, partition) pair. */
29
+ export interface DoRef {
30
+ readonly tenant: string;
31
+ readonly partition: string;
32
+ }
33
+
34
+ /** Reject a tenant/partition name that would make a registry key ambiguous. A name
35
+ * may not be empty and may not contain `:` (the key separator). Throws on violation. */
36
+ export function assertValidName(kind: "tenant" | "partition", name: string): void {
37
+ if (name.length === 0) {
38
+ throw new Error(`pramen: ${kind} name must not be empty`);
39
+ }
40
+ if (name.includes(":")) {
41
+ throw new Error(`pramen: ${kind} name "${name}" must not contain ':' (it is the registry key separator)`);
42
+ }
43
+ }
44
+
45
+ /** Build the registry KV key for a `(tenant, partition)`. The default partition keeps
46
+ * the bare `tenant:<t>` key (backward-compat); any other partition is `tenant:<t>:<p>`.
47
+ * Rejects names containing `:` so the key parses unambiguously. */
48
+ export function registryKey(tenant: string, partition: string = DEFAULT_PARTITION): string {
49
+ assertValidName("tenant", tenant);
50
+ assertValidName("partition", partition);
51
+ return partition === DEFAULT_PARTITION ? `${KEY_PREFIX}${tenant}` : `${KEY_PREFIX}${tenant}:${partition}`;
52
+ }
53
+
54
+ /** Build the Durable Object NAME for a `(tenant, partition)` — the string passed to
55
+ * `idFromName`. This is the same default/non-default rule as `registryKey` but WITHOUT
56
+ * the KV `tenant:` prefix: the DO namespace and the KV registry are distinct keyspaces.
57
+ * Default partition keeps the BARE `tenant` name (byte-for-byte the pre-partition DO
58
+ * name — a hard backward-compat requirement: changing it would orphan existing DOs);
59
+ * any other partition is `${tenant}:${partition}`. Keeping it next to `registryKey`
60
+ * keeps routing and the registry derived from one place. */
61
+ export function partitionDoName(tenant: string, partition: string = DEFAULT_PARTITION): string {
62
+ assertValidName("tenant", tenant);
63
+ assertValidName("partition", partition);
64
+ return partition === DEFAULT_PARTITION ? tenant : `${tenant}:${partition}`;
65
+ }
66
+
67
+ /** Parse a registry KV key back into a `(tenant, partition)`. A bare `tenant:<t>`
68
+ * key yields partition `"default"`; `tenant:<t>:<p>` yields `<p>`. Returns null if
69
+ * the key is not a registry key (missing the `tenant:` prefix). */
70
+ export function parseRegistryKey(key: string): DoRef | null {
71
+ if (!key.startsWith(KEY_PREFIX)) return null;
72
+ const rest = key.slice(KEY_PREFIX.length);
73
+ // At most one `:` remains (names exclude `:`), separating tenant from partition.
74
+ const sep = rest.indexOf(":");
75
+ if (sep === -1) return { tenant: rest, partition: DEFAULT_PARTITION };
76
+ return { tenant: rest.slice(0, sep), partition: rest.slice(sep + 1) };
77
+ }
78
+
79
+ /** Enumerate every registered `(tenant, partition)` pair from the registry KV.
80
+ * Paginates over the full listing (cursor / list_complete) — never truncates at the
81
+ * 1000-key page limit. */
82
+ export async function listDOs(kv: KVNamespace): Promise<DoRef[]> {
83
+ const out: DoRef[] = [];
84
+ let cursor: string | undefined;
85
+ for (;;) {
86
+ const res = await kv.list({ prefix: KEY_PREFIX, cursor });
87
+ for (const k of res.keys) {
88
+ const ref = parseRegistryKey(k.name);
89
+ if (ref) out.push(ref);
90
+ }
91
+ if (res.list_complete) break;
92
+ cursor = (res as { cursor?: string }).cursor;
93
+ if (!cursor) break;
94
+ }
95
+ return out;
96
+ }