@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.
- package/dist/durable-object.d.ts +8 -0
- package/dist/durable-object.js +75 -17
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/runtime/acl.d.ts +4 -0
- package/dist/runtime/db.d.ts +15 -1
- package/dist/runtime/db.js +61 -1
- package/dist/runtime/ddl.js +7 -3
- package/dist/runtime/dispatch.js +2 -2
- package/dist/runtime/migrate.d.ts +8 -0
- package/dist/runtime/migrate.js +30 -6
- package/dist/runtime/registry.d.ts +28 -0
- package/dist/runtime/registry.js +88 -0
- package/dist/sdk/app.js +2 -2
- package/dist/sdk/handlers.d.ts +6 -0
- package/dist/sdk/handlers.js +2 -2
- package/dist/sdk/infer.d.ts +3 -1
- package/dist/sdk/schema.d.ts +49 -2
- package/dist/sdk/schema.js +79 -2
- package/dist/sdk/uuid.d.ts +2 -0
- package/dist/sdk/uuid.js +10 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +45 -13
- package/package.json +1 -1
- package/src/durable-object.ts +82 -19
- package/src/index.ts +2 -1
- package/src/runtime/acl.ts +4 -0
- package/src/runtime/db.ts +59 -2
- package/src/runtime/ddl.ts +7 -3
- package/src/runtime/dispatch.ts +2 -2
- package/src/runtime/migrate.ts +40 -6
- package/src/runtime/registry.ts +96 -0
- package/src/sdk/app.ts +2 -2
- package/src/sdk/handlers.ts +8 -2
- package/src/sdk/infer.ts +31 -5
- package/src/sdk/schema.ts +102 -3
- package/src/sdk/uuid.ts +12 -0
- package/src/worker.ts +50 -16
package/dist/durable-object.d.ts
CHANGED
|
@@ -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>;
|
package/dist/durable-object.js
CHANGED
|
@@ -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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() ?? {
|
|
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";
|
package/dist/runtime/acl.d.ts
CHANGED
|
@@ -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. */
|
package/dist/runtime/db.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/runtime/db.js
CHANGED
|
@@ -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
|
-
|
|
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");
|
package/dist/runtime/ddl.js
CHANGED
|
@@ -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
|
|
5
|
-
// TEXT
|
|
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"
|
|
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)
|
package/dist/runtime/dispatch.js
CHANGED
|
@@ -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>;
|
package/dist/runtime/migrate.js
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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[]>;
|