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