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