@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.
@@ -22,8 +22,16 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
22
22
  /** Tenant this DO serves (one per idFromName). Learned from the Worker-forwarded
23
23
  * x-pramen-tenant header; defaults to "main". */
24
24
  private tenant;
25
+ /** Partition this DO serves (one per idFromName(partitionDoName)). Learned from the
26
+ * Worker-forwarded x-pramen-partition header on the first fetch; defaults to the
27
+ * default partition (a single-partition app, or a stray request with no header). */
28
+ private partition;
29
+ /** Boot migration runs on the FIRST fetch (once the partition is known), not in the
30
+ * constructor — see `ensureMigrated`. Guards against re-running. */
31
+ private migrated;
25
32
  private files?;
26
33
  constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
34
+ private ensureMigrated;
27
35
  fetch(request: Request): Promise<Response>;
28
36
  webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void>;
29
37
  webSocketClose(ws: WebSocket): Promise<void>;
@@ -23,6 +23,8 @@ import { compileAcl } from "./runtime/acl";
23
23
  import { DoSqliteDriver } from "./runtime/driver";
24
24
  import { BadRequest, toResponse, toWsError } from "./runtime/errors";
25
25
  import { Kv } from "./runtime/kv";
26
+ import { registryKey } from "./runtime/registry";
27
+ import { DEFAULT_PARTITION } from "./sdk/schema";
26
28
  import { createFiles, R2Adapter } from "./runtime/storage";
27
29
  /** Per-socket subscription cap — bounds memory and per-mutation re-run cost. */
28
30
  const MAX_SUBSCRIPTIONS = 64;
@@ -35,6 +37,13 @@ export class PramenDOBase extends DurableObject {
35
37
  /** Tenant this DO serves (one per idFromName). Learned from the Worker-forwarded
36
38
  * x-pramen-tenant header; defaults to "main". */
37
39
  tenant = "main";
40
+ /** Partition this DO serves (one per idFromName(partitionDoName)). Learned from the
41
+ * Worker-forwarded x-pramen-partition header on the first fetch; defaults to the
42
+ * default partition (a single-partition app, or a stray request with no header). */
43
+ partition = DEFAULT_PARTITION;
44
+ /** Boot migration runs on the FIRST fetch (once the partition is known), not in the
45
+ * constructor — see `ensureMigrated`. Guards against re-running. */
46
+ migrated = false;
38
47
  files;
39
48
  constructor(ctx, env, app) {
40
49
  super(ctx, env);
@@ -44,17 +53,45 @@ export class PramenDOBase extends DurableObject {
44
53
  // The data layer runs over a Driver. The DO's store is its own in-process SQLite;
45
54
  // the D1 substrate (D1Driver) lives in the Worker (the "Worker + D1, no DO" path).
46
55
  this.driver = new DoSqliteDriver(ctx.storage);
47
- // Reconcile the store with the schema before any request is served (create/alter
48
- // tables; destructive changes rebuild the table). Wrapped in a transaction so a
49
- // partial migration can't leave a half-rebuilt table.
50
- const allowDestructive = env.PRAMEN_ALLOW_DESTRUCTIVE === "true";
51
- ctx.blockConcurrencyWhile(() => this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive }).then(() => { })));
56
+ // NOTE: the boot migration is NOT run here. The constructor cannot know which
57
+ // partition this DO serves that arrives in the x-pramen-partition header on the
58
+ // first request, after construction. Migrating the full schema here would create
59
+ // OTHER partitions' tables in this DO (defeating partition isolation), so we defer
60
+ // the partition-scoped migrate() to the first fetch (`ensureMigrated`), guarded by
61
+ // ctx.blockConcurrencyWhile so concurrent first requests can't double-migrate.
62
+ }
63
+ // Reconcile this partition's tables with the schema before the first request is
64
+ // served. Runs once per DO lifetime: the `migrated` flag + blockConcurrencyWhile
65
+ // serialize concurrent first fetches (the platform queues other requests while the
66
+ // block runs), so two in-flight first requests can't both migrate. Scoped to
67
+ // this.partition — only this partition's tables are created/altered (migrate()
68
+ // never touches other partitions' tables). Wrapped in a transaction so a partial
69
+ // migration can't leave a half-rebuilt table. This preserves the single-partition
70
+ // (default) behavior exactly: a default DO migrates its full default-partition
71
+ // schema, just lazily on first touch instead of in the constructor.
72
+ async ensureMigrated() {
73
+ if (this.migrated)
74
+ return;
75
+ const allowDestructive = this.env.PRAMEN_ALLOW_DESTRUCTIVE === "true";
76
+ const partition = this.partition;
77
+ await this.ctx.blockConcurrencyWhile(async () => {
78
+ if (this.migrated)
79
+ return; // a concurrent first request already migrated
80
+ await this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive, partition }).then(() => { }));
81
+ this.migrated = true;
82
+ });
52
83
  }
53
84
  async fetch(request) {
54
- await this.ensureRegistered(request);
55
85
  const tenantHeader = request.headers.get("x-pramen-tenant");
56
86
  if (tenantHeader)
57
87
  this.tenant = tenantHeader;
88
+ // Learn the partition before the boot migration so it migrates the right subset.
89
+ // Absent header (shouldn't happen post-routing) -> default partition.
90
+ const partitionHeader = request.headers.get("x-pramen-partition");
91
+ if (partitionHeader)
92
+ this.partition = partitionHeader;
93
+ await this.ensureMigrated();
94
+ await this.ensureRegistered(request);
58
95
  const path = new URL(request.url).pathname;
59
96
  if (path === "/__recover")
60
97
  return this.handleRecover(request);
@@ -66,7 +103,7 @@ export class PramenDOBase extends DurableObject {
66
103
  if (request.headers.get("Upgrade") === "websocket") {
67
104
  const { 0: client, 1: server } = new WebSocketPair();
68
105
  this.ctx.acceptWebSocket(server); // hibernatable
69
- this.setState(server, { identity, tenant: this.tenant, subs: [] });
106
+ this.setState(server, { identity, tenant: this.tenant, partition: this.partition, subs: [] });
70
107
  return new Response(null, { status: 101, webSocket: client });
71
108
  }
72
109
  const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
@@ -94,6 +131,15 @@ export class PramenDOBase extends DurableObject {
94
131
  catch {
95
132
  return this.send(ws, { type: "error", id: "", error: "invalid JSON" });
96
133
  }
134
+ // A hibernated DO can be reconstructed and routed here WITHOUT fetch() running
135
+ // again, so the boot migration may not have run on this fresh instance. Adopt this
136
+ // socket's (tenant, partition) — fixed at connect time, survives via the attachment
137
+ // — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
138
+ // (the `migrated` flag), so a no-op after the first call.
139
+ const { tenant, partition } = this.getState(ws);
140
+ this.tenant = tenant;
141
+ this.partition = partition;
142
+ await this.ensureMigrated();
97
143
  switch (msg.type) {
98
144
  case "subscribe":
99
145
  return this.onSubscribe(ws, msg.id, msg.name, msg.input);
@@ -128,7 +174,7 @@ export class PramenDOBase extends DurableObject {
128
174
  if (!replacing && state.subs.length >= MAX_SUBSCRIPTIONS) {
129
175
  return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
130
176
  }
131
- 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);
177
+ 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);
132
178
  if (kind !== "query") {
133
179
  return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
134
180
  }
@@ -144,7 +190,7 @@ export class PramenDOBase extends DurableObject {
144
190
  async onCall(ws, id, name, input) {
145
191
  const state = this.getState(ws);
146
192
  try {
147
- 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);
193
+ 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);
148
194
  this.send(ws, { type: "result", id, result });
149
195
  if (kind === "mutation" && touched.length > 0)
150
196
  await this.broadcast(touched);
@@ -164,7 +210,7 @@ export class PramenDOBase extends DurableObject {
164
210
  if (!sub.tables.some((t) => written.has(t)))
165
211
  continue;
166
212
  try {
167
- 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);
213
+ 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);
168
214
  const next = digest(result);
169
215
  if (next === sub.digest)
170
216
  continue; // result unchanged for this subscription
@@ -196,7 +242,9 @@ export class PramenDOBase extends DurableObject {
196
242
  this.registered = true;
197
243
  return;
198
244
  }
199
- await this.env.KV.put(`tenant:${name}`, JSON.stringify({ firstSeen: Date.now() }));
245
+ // Record under this DO's real (tenant, partition): the default partition keeps the
246
+ // bare `tenant:<name>` key (backward-compat), a non-default one is `tenant:<name>:<p>`.
247
+ await this.env.KV.put(registryKey(name, this.partition), JSON.stringify({ firstSeen: Date.now() }));
200
248
  await this.driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES ('registered', ?)`, [name]);
201
249
  this.registered = true;
202
250
  }
@@ -227,7 +275,11 @@ export class PramenDOBase extends DurableObject {
227
275
  // Introspection: this tenant's applied schema hash + live table/column shape
228
276
  // (admin-gated at the Worker). Powers the CLI's `schema status`.
229
277
  async handleSchema() {
230
- const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = 'schema_hash'`, []));
278
+ // The applied-schema hash is stored per-partition (migrate keys it
279
+ // `schema_hash:<partition>` whenever a partition is scoped, which the DO always
280
+ // does). Read this DO's partition's key.
281
+ const hashKey = `schema_hash:${this.partition}`;
282
+ const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = ?`, [hashKey]));
231
283
  const tableRows = (await this.driver.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_pramen_meta'`, []));
232
284
  const tables = {};
233
285
  for (const { name } of tableRows) {
@@ -246,7 +298,7 @@ export class PramenDOBase extends DurableObject {
246
298
  return Response.json({ ok: false, error: "unknown table", code: "bad_request" }, { status: 400 });
247
299
  }
248
300
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
249
- const db = new Db(this.driver, { acl: this.acl, identity: null, system: true }, this.app.schema);
301
+ const db = new Db(this.driver, { acl: this.acl, identity: null, system: true, partition: this.partition }, this.app.schema);
250
302
  try {
251
303
  let result;
252
304
  let mutated = false;
@@ -284,10 +336,11 @@ export class PramenDOBase extends DurableObject {
284
336
  return Response.json(body, { status });
285
337
  }
286
338
  }
287
- ctxFor(identity) {
339
+ ctxFor(identity, partition = this.partition) {
288
340
  // Carry the schema so any consumer of this context (not just Db) can compile
289
- // relation-aware `where` rules into subqueries.
290
- return { acl: this.acl, identity, schema: this.app.schema };
341
+ // relation-aware `where` rules into subqueries, and the active partition so Db's
342
+ // table-access guard rejects any table outside this DO's partition.
343
+ return { acl: this.acl, identity, schema: this.app.schema, partition };
291
344
  }
292
345
  // The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
293
346
  // typed at the boundary so handlers can read any var/secret without a DoEnv cast.
@@ -315,7 +368,12 @@ export class PramenDOBase extends DurableObject {
315
368
  }
316
369
  }
317
370
  getState(ws) {
318
- return ws.deserializeAttachment() ?? { identity: null, tenant: this.tenant, subs: [] };
371
+ return (ws.deserializeAttachment() ?? {
372
+ identity: null,
373
+ tenant: this.tenant,
374
+ partition: this.partition,
375
+ subs: [],
376
+ });
319
377
  }
320
378
  setState(ws, state) {
321
379
  ws.serializeAttachment(state);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo } from "./sdk/schema";
1
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated } from "./sdk/schema";
2
+ export { isValidUuid } from "./sdk/uuid";
2
3
  export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
3
4
  export { createApp } from "./sdk/app";
4
5
  export { query, mutation } from "./sdk/handlers";
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@
7
7
  // which only exists in the Workers runtime; keeping it separate lets the CLI, tests,
8
8
  // and codegen load an app.ts for its schema without dragging in the DO runtime.
9
9
  // --- schema authoring ---
10
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo } from "./sdk/schema";
10
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated } from "./sdk/schema";
11
+ export { isValidUuid } from "./sdk/uuid";
11
12
  // --- app + handlers ---
12
13
  export { createApp } from "./sdk/app";
13
14
  export { query, mutation } from "./sdk/handlers";
@@ -26,6 +26,10 @@ export interface AclContext {
26
26
  /** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
27
27
  * compiled to a subquery with the related entity's read scope AND-merged in. */
28
28
  readonly schema?: SchemaDef;
29
+ /** The partition this DO serves. When set, Db rejects any access to a table that
30
+ * lives in a different partition (a partition-DO only owns its own tables). Unset
31
+ * (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
32
+ readonly partition?: string;
29
33
  }
30
34
  /** Evaluate every resolver reachable by the identity's roles, once per request.
31
35
  * Resolvers read through a SYSTEM-mode db (ACL bypassed) to avoid recursion. */
@@ -1,7 +1,7 @@
1
1
  import { type AclContext } from "./acl";
2
2
  import { type AggFn } from "./read-engine";
3
3
  import type { Driver } from "./driver";
4
- import type { EntityFields, SchemaDef } from "../sdk/schema";
4
+ import { type EntityFields, type SchemaDef } from "../sdk/schema";
5
5
  import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
6
6
  type Row = Record<string, unknown>;
7
7
  type Id = string | number | bigint;
@@ -65,6 +65,13 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
65
65
  private readonly dialect;
66
66
  private readonly acl;
67
67
  constructor(driver: Driver, acl: AclContext, schema: SchemaDef);
68
+ /** Partition guard. When this Db's context carries an active partition (a
69
+ * partition-DO knows which partition it serves), reject any access to a table
70
+ * that lives in a different partition — a partition-DO only owns its own tables,
71
+ * so touching another partition's table is a routing bug, not an empty result.
72
+ * Unset partition (the D1/Worker shared-store path, or a single-partition app on
73
+ * the default DO with no header) makes this a no-op. */
74
+ private assertInPartition;
68
75
  /** Resolve the ACL scope for an operation, or grant everything in SYSTEM mode. */
69
76
  private scopeFor;
70
77
  /** Forced `set` values + validators for a write (empty in SYSTEM mode). The two
@@ -113,6 +120,13 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
113
120
  private assertReadableWhere;
114
121
  private selectRaw;
115
122
  private jsonColsOf;
123
+ /** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
124
+ * `vals`. Returns the columns it filled — server-minted, so the insert path treats
125
+ * them like forced `set` values (bypassing the writable-field ACL check). */
126
+ private fillGeneratedUuids;
127
+ /** Reject a malformed value on any uuid column present in `row` (mirrors kvalt's
128
+ * write-time isValidUuid). Absent columns are not checked. */
129
+ private assertValidUuids;
116
130
  private decodeRows;
117
131
  private decodeRow;
118
132
  /** Encode one write cell: JSON-stringify a json/fileRef value, then dialect-encode. */
@@ -13,6 +13,8 @@
13
13
  import { AclDenied, ALLOW_ALL, compileScopedWhere, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
14
14
  import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, eq, inList, or, TRUE, } from "./read-engine";
15
15
  import { BadRequest } from "./errors";
16
+ import { partitionOf } from "../sdk/schema";
17
+ import { isValidUuid } from "../sdk/uuid";
16
18
  const DEFAULT_PAGE_SIZE = 50;
17
19
  function normalizeOrder(orderBy) {
18
20
  if (!orderBy)
@@ -64,6 +66,21 @@ export class Db {
64
66
  this.acl = acl.schema ? acl : { ...acl, schema };
65
67
  this.dialect = driver.dialect;
66
68
  }
69
+ /** Partition guard. When this Db's context carries an active partition (a
70
+ * partition-DO knows which partition it serves), reject any access to a table
71
+ * that lives in a different partition — a partition-DO only owns its own tables,
72
+ * so touching another partition's table is a routing bug, not an empty result.
73
+ * Unset partition (the D1/Worker shared-store path, or a single-partition app on
74
+ * the default DO with no header) makes this a no-op. */
75
+ assertInPartition(table) {
76
+ const self = this.acl.partition;
77
+ if (self === undefined)
78
+ return;
79
+ const tablePartition = partitionOf(this.schema, table);
80
+ if (tablePartition !== self) {
81
+ throw new BadRequest(`table '${table}' is in partition '${tablePartition}', not this partition '${self}'`);
82
+ }
83
+ }
67
84
  /** Resolve the ACL scope for an operation, or grant everything in SYSTEM mode. */
68
85
  scopeFor(entity, action) {
69
86
  if (this.acl.system)
@@ -116,6 +133,7 @@ export class Db {
116
133
  async find(spec) {
117
134
  const from = spec.from;
118
135
  this.touched.add(from);
136
+ this.assertInPartition(from);
119
137
  const scope = this.scopeFor(from, "read");
120
138
  if (!scope.allowed)
121
139
  throw new AclDenied(from, "read");
@@ -132,6 +150,7 @@ export class Db {
132
150
  async page(spec) {
133
151
  const from = spec.from;
134
152
  this.touched.add(from);
153
+ this.assertInPartition(from);
135
154
  const scope = this.scopeFor(from, "read");
136
155
  if (!scope.allowed)
137
156
  throw new AclDenied(from, "read");
@@ -154,6 +173,7 @@ export class Db {
154
173
  async count(spec) {
155
174
  const from = spec.from;
156
175
  this.touched.add(from);
176
+ this.assertInPartition(from);
157
177
  const scope = this.scopeFor(from, "read");
158
178
  if (!scope.allowed)
159
179
  throw new AclDenied(from, "read");
@@ -169,6 +189,7 @@ export class Db {
169
189
  async aggregate(spec) {
170
190
  const from = spec.from;
171
191
  this.touched.add(from);
192
+ this.assertInPartition(from);
172
193
  const scope = this.scopeFor(from, "read");
173
194
  if (!scope.allowed)
174
195
  throw new AclDenied(from, "read");
@@ -243,6 +264,37 @@ export class Db {
243
264
  .filter(([, f]) => f.type === "json" || f.type === "fileRef")
244
265
  .map(([n]) => n);
245
266
  }
267
+ /** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
268
+ * `vals`. Returns the columns it filled — server-minted, so the insert path treats
269
+ * them like forced `set` values (bypassing the writable-field ACL check). */
270
+ fillGeneratedUuids(table, vals) {
271
+ const fields = this.schema[table]?.fields;
272
+ if (!fields)
273
+ return [];
274
+ const minted = [];
275
+ for (const [name, f] of Object.entries(fields)) {
276
+ const fd = f;
277
+ if (fd.type === "uuid" && fd.generated && vals[name] == null) {
278
+ vals[name] = crypto.randomUUID();
279
+ minted.push(name);
280
+ }
281
+ }
282
+ return minted;
283
+ }
284
+ /** Reject a malformed value on any uuid column present in `row` (mirrors kvalt's
285
+ * write-time isValidUuid). Absent columns are not checked. */
286
+ assertValidUuids(table, row) {
287
+ const fields = this.schema[table]?.fields;
288
+ if (!fields)
289
+ return;
290
+ for (const [name, f] of Object.entries(fields)) {
291
+ if (f.type !== "uuid")
292
+ continue;
293
+ const v = row[name];
294
+ if (v != null && !isValidUuid(v))
295
+ throw new BadRequest(`invalid UUID for '${name}'`);
296
+ }
297
+ }
246
298
  decodeRows(table, rows) {
247
299
  const cols = this.jsonColsOf(table);
248
300
  if (cols.length === 0)
@@ -350,14 +402,19 @@ export class Db {
350
402
  /** Insert a single row, returning the persisted row. */
351
403
  async insert(table, values) {
352
404
  this.touched.add(table);
405
+ this.assertInPartition(table);
353
406
  const scope = this.scopeFor(table, "create");
354
407
  if (!scope.allowed)
355
408
  throw new AclDenied(table, "create");
356
409
  const vals = { ...values };
357
410
  const { set, validators } = this.writeRules(table, "create");
358
411
  Object.assign(vals, set); // forced server values first, so a conditional `when` can see them
359
- this.checkWriteFields(table, "create", scope, Object.keys(vals), vals, new Set(Object.keys(set)));
412
+ // Auto-mint generated() uuid columns the caller omitted; server-minted, so they
413
+ // join `set` in the bypass-list for the writable-field check.
414
+ const generatedCols = this.fillGeneratedUuids(table, vals);
415
+ this.checkWriteFields(table, "create", scope, Object.keys(vals), vals, new Set([...Object.keys(set), ...generatedCols]));
360
416
  this.runValidators(validators, vals);
417
+ this.assertValidUuids(table, vals);
361
418
  const cols = Object.keys(vals);
362
419
  const jsonCols = new Set(this.jsonColsOf(table));
363
420
  const colList = cols.map((c) => this.dialect.id(c)).join(", ");
@@ -390,6 +447,7 @@ export class Db {
390
447
  * only update rows within scope; returns undefined if none matched. */
391
448
  async update(table, id, patch) {
392
449
  this.touched.add(table);
450
+ this.assertInPartition(table);
393
451
  const scope = this.scopeFor(table, "update");
394
452
  if (!scope.allowed)
395
453
  throw new AclDenied(table, "update");
@@ -410,6 +468,7 @@ export class Db {
410
468
  }
411
469
  this.checkWriteFields(table, "update", scope, cols, evalRow, new Set(Object.keys(set)));
412
470
  this.runValidators(validators, p);
471
+ this.assertValidUuids(table, p);
413
472
  const params = [];
414
473
  const jsonCols = new Set(this.jsonColsOf(table));
415
474
  const assignments = cols
@@ -428,6 +487,7 @@ export class Db {
428
487
  /** Delete a row by id within scope. Returns whether a row was deleted. */
429
488
  async delete(table, id) {
430
489
  this.touched.add(table);
490
+ this.assertInPartition(table);
431
491
  const scope = this.scopeFor(table, "delete");
432
492
  if (!scope.allowed)
433
493
  throw new AclDenied(table, "delete");
@@ -1,10 +1,14 @@
1
1
  // DDL generation — CREATE TABLE for a new entity and the additive ALTER fragment
2
2
  // for a new column. Runs in TS inside the isolate; see runtime/migrate.ts for how
3
3
  // these are applied.
4
- // SQLite has no boolean type; store as INTEGER 0/1. json + fileRef are stored as
5
- // TEXT (JSON). Exported for the migrator, which compares declared column types
4
+ // SQLite has no boolean type; store as INTEGER 0/1. json + fileRef + uuid are
5
+ // stored as TEXT. Exported for the migrator, which compares declared column types
6
6
  // (and CASTs on a type change).
7
- export const sqlType = (f) => f.type === "boolean" ? "INTEGER" : f.type === "json" || f.type === "fileRef" ? "TEXT" : f.type.toUpperCase();
7
+ export const sqlType = (f) => f.type === "boolean"
8
+ ? "INTEGER"
9
+ : f.type === "json" || f.type === "fileRef" || f.type === "uuid"
10
+ ? "TEXT"
11
+ : f.type.toUpperCase();
8
12
  /** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1. */
9
13
  function defaultLiteral(v) {
10
14
  if (v === null)
@@ -26,9 +26,9 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
26
26
  }
27
27
  // Warmup: evaluate dynamic resolvers once, reading through a SYSTEM-mode db
28
28
  // (separate from the handler's db, so its reads don't pollute `touched`).
29
- const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema }, schema);
29
+ const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema, partition: acl.partition }, schema);
30
30
  const resolved = await warmup(acl.acl, acl.identity, systemDb);
31
- const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema }, schema);
31
+ const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema, partition: acl.partition }, schema);
32
32
  const ctx = { db, kv, files, env, identity: acl.identity };
33
33
  const result = handler.kind === "query"
34
34
  ? await handler.run(ctx, parsed)
@@ -17,6 +17,14 @@ export interface MigrateOptions {
17
17
  * — data-loss is gated behind an explicit opt-in (env `PRAMEN_ALLOW_DESTRUCTIVE`).
18
18
  * Additive changes (create table, add column, add index) always apply. */
19
19
  allowDestructive?: boolean;
20
+ /** Scope the migration to a single partition (Durable Object class). When set,
21
+ * migrate operates ONLY on entities whose `partition` matches — it creates/alters
22
+ * just that partition's tables and never drops other partitions' tables (a
23
+ * partition-DO never sees them). The schema hash is stored under a per-partition
24
+ * key so partitions don't thrash each other's drift detection. When unset, all
25
+ * entities are migrated and the legacy single-hash key is used (unchanged — the
26
+ * D1 path and existing callers). */
27
+ partition?: string;
20
28
  }
21
29
  export declare function schemaHash(schema: SchemaDef): string;
22
30
  export declare function migrate(driver: Driver, schema: SchemaDef, opts?: MigrateOptions): Promise<MigrationReport>;
@@ -19,6 +19,7 @@
19
19
  // so it must be declared with `renamedFrom`; otherwise it is applied as drop+add.
20
20
  import { addColumnSql, createTableSql, indexStatements, sqlType } from "./ddl";
21
21
  import { digest } from "./digest";
22
+ import { entitiesInPartition, validateSchema } from "../sdk/schema";
22
23
  /** Internal bookkeeping tables the migrator must never touch — pramen's own, SQLite's,
23
24
  * and the substrate's (D1 keeps `_cf_*` / `d1_*` tables in sqlite_master and forbids
24
25
  * dropping them). Matched case-insensitively. */
@@ -79,17 +80,33 @@ async function rebuildTable(driver, table, def, live) {
79
80
  await driver.exec(`ALTER TABLE ${ident(tmp)} RENAME TO ${ident(table)}`, []);
80
81
  }
81
82
  export async function migrate(driver, schema, opts = {}) {
83
+ // Static schema invariants (relation targets exist, no cross-partition relations) —
84
+ // checked before any DDL so a bad schema fails fast on boot / the D1 path, not mid-migration.
85
+ validateSchema(schema);
82
86
  await driver.exec(`CREATE TABLE IF NOT EXISTS _pramen_meta (key TEXT PRIMARY KEY, value TEXT)`, []);
83
87
  const allowDestructive = opts.allowDestructive ?? false;
84
- const current = schemaHash(schema);
85
- if ((await readMeta(driver, "schema_hash")) === current)
88
+ // When a partition is named, narrow the schema to just that partition's entities —
89
+ // every later pass (create/alter/rebuild/drop/index/hash) iterates this subset, so
90
+ // a partition-DO only ever touches its own tables. Unset ⇒ the whole schema, the
91
+ // legacy (default) behavior.
92
+ const tables = opts.partition === undefined ? Object.keys(schema) : entitiesInPartition(schema, opts.partition);
93
+ const entries = tables.map((table) => [table, schema[table]]);
94
+ const inScope = new Set(tables);
95
+ // The schema hash is computed over the in-scope subset only, and stored under a
96
+ // per-partition meta key, so two partitions of the same app each detect just their
97
+ // own drift and never invalidate the other. The unscoped path keeps the original
98
+ // `schema_hash` key for backward compatibility (existing stores + the D1 path).
99
+ const subset = Object.fromEntries(entries);
100
+ const hashKey = opts.partition === undefined ? "schema_hash" : `schema_hash:${opts.partition}`;
101
+ const current = schemaHash(subset);
102
+ if ((await readMeta(driver, hashKey)) === current)
86
103
  return { changed: false, created: [], added: [], rebuilt: [], droppedTables: [], skipped: [] };
87
104
  const created = [];
88
105
  const added = [];
89
106
  const rebuilt = [];
90
107
  const droppedTables = [];
91
108
  const skipped = [];
92
- for (const [table, def] of Object.entries(schema)) {
109
+ for (const [table, def] of entries) {
93
110
  const existing = await tableColumns(driver, table);
94
111
  if (existing.size === 0) {
95
112
  await driver.exec(createTableSql(table, def), []);
@@ -128,14 +145,21 @@ export async function migrate(driver, schema, opts = {}) {
128
145
  // Ensure unique/index declarations (idempotent, via IF NOT EXISTS). Indexes can be
129
146
  // added to an existing table without a rebuild; a stale index from a removed
130
147
  // declaration is left in place (cleanup is future work).
131
- for (const [table, def] of Object.entries(schema)) {
148
+ for (const [table, def] of entries) {
132
149
  for (const stmt of indexStatements(table, def))
133
150
  await driver.exec(stmt, []);
134
151
  }
135
152
  // Drop tables the schema no longer declares (internal bookkeeping tables skipped).
153
+ // When scoped to a partition, a live table that belongs to ANOTHER partition's
154
+ // entity must NOT be dropped — a partition-DO never owns it, and even when several
155
+ // partitions share a store the other partition's reconciler owns that table. So the
156
+ // drop candidate set is: live tables that are neither in this scope's declared
157
+ // entities nor declared by any other partition. (Unscoped: `otherPartitionTables`
158
+ // is empty and `inScope` is every entity, so this is the original behavior.)
159
+ const otherPartitionTables = opts.partition === undefined ? new Set() : new Set(Object.keys(schema).filter((t) => !inScope.has(t)));
136
160
  const liveTables = (await driver.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`, []));
137
161
  for (const { name } of liveTables) {
138
- if (isInternalTable(name) || name in schema)
162
+ if (isInternalTable(name) || inScope.has(name) || otherPartitionTables.has(name))
139
163
  continue;
140
164
  if (allowDestructive) {
141
165
  await driver.exec(`DROP TABLE ${ident(name)}`, []);
@@ -149,7 +173,7 @@ export async function migrate(driver, schema, opts = {}) {
149
173
  // were skipped, leave the hash so a later deploy (with allowDestructive) retries —
150
174
  // additive work is idempotent, so re-running is safe.
151
175
  if (skipped.length === 0) {
152
- await writeMeta(driver, "schema_hash", current);
176
+ await writeMeta(driver, hashKey, current);
153
177
  }
154
178
  else {
155
179
  console.warn(`pramen: ${skipped.length} destructive migration(s) skipped (set PRAMEN_ALLOW_DESTRUCTIVE=true to apply): ${skipped.join("; ")}`);
@@ -0,0 +1,28 @@
1
+ /** A registered Durable Object identity: a (tenant, partition) pair. */
2
+ export interface DoRef {
3
+ readonly tenant: string;
4
+ readonly partition: string;
5
+ }
6
+ /** Reject a tenant/partition name that would make a registry key ambiguous. A name
7
+ * may not be empty and may not contain `:` (the key separator). Throws on violation. */
8
+ export declare function assertValidName(kind: "tenant" | "partition", name: string): void;
9
+ /** Build the registry KV key for a `(tenant, partition)`. The default partition keeps
10
+ * the bare `tenant:<t>` key (backward-compat); any other partition is `tenant:<t>:<p>`.
11
+ * Rejects names containing `:` so the key parses unambiguously. */
12
+ export declare function registryKey(tenant: string, partition?: string): string;
13
+ /** Build the Durable Object NAME for a `(tenant, partition)` — the string passed to
14
+ * `idFromName`. This is the same default/non-default rule as `registryKey` but WITHOUT
15
+ * the KV `tenant:` prefix: the DO namespace and the KV registry are distinct keyspaces.
16
+ * Default partition keeps the BARE `tenant` name (byte-for-byte the pre-partition DO
17
+ * name — a hard backward-compat requirement: changing it would orphan existing DOs);
18
+ * any other partition is `${tenant}:${partition}`. Keeping it next to `registryKey`
19
+ * keeps routing and the registry derived from one place. */
20
+ export declare function partitionDoName(tenant: string, partition?: string): string;
21
+ /** Parse a registry KV key back into a `(tenant, partition)`. A bare `tenant:<t>`
22
+ * key yields partition `"default"`; `tenant:<t>:<p>` yields `<p>`. Returns null if
23
+ * the key is not a registry key (missing the `tenant:` prefix). */
24
+ export declare function parseRegistryKey(key: string): DoRef | null;
25
+ /** Enumerate every registered `(tenant, partition)` pair from the registry KV.
26
+ * Paginates over the full listing (cursor / list_complete) — never truncates at the
27
+ * 1000-key page limit. */
28
+ export declare function listDOs(kv: KVNamespace): Promise<DoRef[]>;