@pramen/server 0.0.47 → 0.0.49
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/auth.d.ts +4 -3
- package/dist/cli.js +15 -9
- package/dist/durable-object.d.ts +1 -0
- package/dist/durable-object.js +10 -5
- package/dist/index.d.ts +3 -3
- package/dist/pramen.d.ts +4 -2
- package/dist/runtime/acl.d.ts +6 -5
- package/dist/runtime/acl.js +1 -5
- package/dist/runtime/db.d.ts +3 -2
- package/dist/runtime/dispatch.d.ts +3 -1
- package/dist/runtime/driver.d.ts +8 -5
- package/dist/runtime/mail.d.ts +2 -1
- package/dist/runtime/protocol.d.ts +4 -3
- package/dist/runtime/queue-consumer.d.ts +4 -2
- package/dist/runtime/queue.d.ts +3 -2
- package/dist/runtime/read-engine.d.ts +11 -9
- package/dist/runtime/read-engine.js +4 -1
- package/dist/runtime/registry.d.ts +4 -1
- package/dist/runtime/registry.js +0 -3
- package/dist/runtime/schema-diff.d.ts +6 -6
- package/dist/runtime/schema-diff.js +4 -4
- package/dist/sdk/acl.d.ts +18 -11
- package/dist/sdk/handlers.d.ts +9 -3
- package/dist/sdk/infer.d.ts +17 -0
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +16 -10
- package/package.json +1 -1
- package/src/auth.ts +7 -6
- package/src/cli.ts +21 -9
- package/src/durable-object.ts +15 -8
- package/src/index.ts +6 -2
- package/src/pramen.ts +4 -2
- package/src/runtime/acl.ts +37 -31
- package/src/runtime/db.ts +14 -12
- package/src/runtime/dispatch.ts +5 -3
- package/src/runtime/driver.ts +11 -7
- package/src/runtime/mail.ts +2 -1
- package/src/runtime/migrate.ts +2 -1
- package/src/runtime/outbox.ts +2 -1
- package/src/runtime/protocol.ts +5 -3
- package/src/runtime/queue-consumer.ts +4 -2
- package/src/runtime/queue.ts +4 -2
- package/src/runtime/read-engine.ts +27 -21
- package/src/runtime/registry.ts +5 -1
- package/src/runtime/schema-diff.ts +14 -14
- package/src/sdk/acl.ts +29 -14
- package/src/sdk/handlers.ts +10 -3
- package/src/sdk/infer.ts +21 -0
- package/src/worker.ts +20 -11
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
// AND/OR groups) and ACL row-level scopes be merged before compilation. Column
|
|
7
7
|
// names come from developer code (schema keys); values are always parameterized.
|
|
8
8
|
|
|
9
|
+
import type { WhereRule, WhereValue } from "../sdk/acl";
|
|
10
|
+
import type { CellValue, Row } from "../sdk/infer";
|
|
11
|
+
|
|
9
12
|
import type { Dialect } from "./driver";
|
|
10
13
|
|
|
11
14
|
// In-process value coercion for evalExpr (SQLite-style booleans). SQL-side encoding
|
|
12
15
|
// goes through the active Dialect; this mirrors it for the cell-ACL `when` evaluator.
|
|
13
|
-
function bind(v:
|
|
16
|
+
function bind(v: CellValue): CellValue {
|
|
14
17
|
return typeof v === "boolean" ? (v ? 1 : 0) : v;
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -23,8 +26,8 @@ export type StrMode = "contains" | "prefix" | "suffix";
|
|
|
23
26
|
export type SqlExpr =
|
|
24
27
|
| { t: "true" }
|
|
25
28
|
| { t: "false" }
|
|
26
|
-
| { t: "cmp"; op: CmpOp; col: string; value:
|
|
27
|
-
| { t: "in"; col: string; values:
|
|
29
|
+
| { t: "cmp"; op: CmpOp; col: string; value: CellValue }
|
|
30
|
+
| { t: "in"; col: string; values: CellValue[]; negate: boolean }
|
|
28
31
|
| { t: "null"; col: string; negate: boolean }
|
|
29
32
|
// Structured substring match — the needle is escaped and wrapped, so `%`/`_` in the
|
|
30
33
|
// input match literally (unlike raw `like`, where the caller controls wildcards).
|
|
@@ -39,28 +42,28 @@ export type SqlExpr =
|
|
|
39
42
|
|
|
40
43
|
export const TRUE: SqlExpr = { t: "true" };
|
|
41
44
|
export const FALSE: SqlExpr = { t: "false" };
|
|
42
|
-
export const cmp = (op: CmpOp, col: string, value:
|
|
45
|
+
export const cmp = (op: CmpOp, col: string, value: CellValue): SqlExpr => ({ t: "cmp", op, col, value });
|
|
43
46
|
export const isNull = (col: string, negate = false): SqlExpr => ({ t: "null", col, negate });
|
|
44
|
-
export const inList = (col: string, values:
|
|
47
|
+
export const inList = (col: string, values: CellValue[], negate = false): SqlExpr => ({ t: "in", col, values, negate });
|
|
45
48
|
export const strMatch = (col: string, needle: string, mode: StrMode): SqlExpr => ({ t: "strmatch", col, needle, mode });
|
|
46
49
|
export const and = (...parts: SqlExpr[]): SqlExpr => ({ t: "and", parts });
|
|
47
50
|
export const or = (...parts: SqlExpr[]): SqlExpr => ({ t: "or", parts });
|
|
48
51
|
export const not = (expr: SqlExpr): SqlExpr => ({ t: "not", expr });
|
|
49
52
|
|
|
50
53
|
/** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
|
|
51
|
-
export const eq = (col: string, value:
|
|
54
|
+
export const eq = (col: string, value: CellValue): SqlExpr => (value === null ? isNull(col) : cmp("=", col, value));
|
|
52
55
|
|
|
53
56
|
/** Compile a structured user predicate into a SqlExpr.
|
|
54
57
|
* Shapes: { col: value } (eq) | { col: { gt, lt, in, like, isNull, … } } | { AND: [...] } | { OR: [...] } */
|
|
55
|
-
export function compileWhere(input:
|
|
58
|
+
export function compileWhere(input: WhereRule): SqlExpr {
|
|
56
59
|
const parts: SqlExpr[] = [];
|
|
57
60
|
for (const [k, v] of Object.entries(input)) {
|
|
58
61
|
if (k === "AND") {
|
|
59
|
-
parts.push(and(...(v as
|
|
62
|
+
parts.push(and(...(v as WhereRule[]).map(compileWhere)));
|
|
60
63
|
} else if (k === "OR") {
|
|
61
|
-
parts.push(or(...(v as
|
|
64
|
+
parts.push(or(...(v as WhereRule[]).map(compileWhere)));
|
|
62
65
|
} else if (k === "NOT") {
|
|
63
|
-
parts.push(not(compileWhere(v as
|
|
66
|
+
parts.push(not(compileWhere(v as WhereRule)));
|
|
64
67
|
} else {
|
|
65
68
|
parts.push(columnPredicate(k, v));
|
|
66
69
|
}
|
|
@@ -68,10 +71,13 @@ export function compileWhere(input: Record<string, unknown>): SqlExpr {
|
|
|
68
71
|
return parts.length ? and(...parts) : TRUE;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
function columnPredicate(col: string, v:
|
|
74
|
+
function columnPredicate(col: string, v: WhereValue): SqlExpr {
|
|
72
75
|
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
73
76
|
const ops: SqlExpr[] = [];
|
|
74
|
-
for (const [op,
|
|
77
|
+
for (const [op, operand] of Object.entries(v as WhereRule)) {
|
|
78
|
+
// Markers are resolved upstream (runtime/acl.ts), so an operator's operand is a
|
|
79
|
+
// literal cell value by this point.
|
|
80
|
+
const val = operand as CellValue;
|
|
75
81
|
switch (op) {
|
|
76
82
|
case "eq": ops.push(eq(col, val)); break;
|
|
77
83
|
case "ne": ops.push(val === null ? isNull(col, true) : cmp("!=", col, val)); break;
|
|
@@ -83,23 +89,23 @@ function columnPredicate(col: string, v: unknown): SqlExpr {
|
|
|
83
89
|
case "contains": ops.push(strMatch(col, String(val), "contains")); break;
|
|
84
90
|
case "startsWith": ops.push(strMatch(col, String(val), "prefix")); break;
|
|
85
91
|
case "endsWith": ops.push(strMatch(col, String(val), "suffix")); break;
|
|
86
|
-
case "in": ops.push(inList(col, val as
|
|
87
|
-
case "notIn": ops.push(inList(col, val as
|
|
92
|
+
case "in": ops.push(inList(col, val as CellValue[])); break;
|
|
93
|
+
case "notIn": ops.push(inList(col, val as CellValue[], true)); break;
|
|
88
94
|
case "isNull": ops.push(isNull(col, !val)); break; // isNull:true => IS NULL
|
|
89
95
|
default: throw new Error(`unknown operator: ${op}`);
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
return ops.length ? and(...ops) : TRUE;
|
|
93
99
|
}
|
|
94
|
-
return eq(col, v);
|
|
100
|
+
return eq(col, v as CellValue);
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
export interface CompiledSql {
|
|
98
104
|
readonly sql: string;
|
|
99
|
-
readonly params:
|
|
105
|
+
readonly params: CellValue[];
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
export function compileExpr(expr: SqlExpr, dialect: Dialect, params:
|
|
108
|
+
export function compileExpr(expr: SqlExpr, dialect: Dialect, params: CellValue[] = []): CompiledSql {
|
|
103
109
|
switch (expr.t) {
|
|
104
110
|
case "true":
|
|
105
111
|
return { sql: "1", params };
|
|
@@ -168,7 +174,7 @@ function likeToRegex(pattern: string): RegExp {
|
|
|
168
174
|
* semantics (bound-boolean coercion, NULL compares false, empty-IN, LIKE) so the
|
|
169
175
|
* declarative cell-ACL `when` path can decide per-row field visibility without a
|
|
170
176
|
* round-trip to SQLite. */
|
|
171
|
-
export function evalExpr(expr: SqlExpr, row:
|
|
177
|
+
export function evalExpr(expr: SqlExpr, row: Row): boolean {
|
|
172
178
|
switch (expr.t) {
|
|
173
179
|
case "true":
|
|
174
180
|
return true;
|
|
@@ -245,7 +251,7 @@ export type AggFn = "count" | "sum" | "avg" | "min" | "max";
|
|
|
245
251
|
const AGG_SQL: Record<AggFn, string> = { count: "COUNT", sum: "SUM", avg: "AVG", min: "MIN", max: "MAX" };
|
|
246
252
|
|
|
247
253
|
export function compileCount(from: string, dialect: Dialect, where?: SqlExpr): CompiledSql {
|
|
248
|
-
const params:
|
|
254
|
+
const params: CellValue[] = [];
|
|
249
255
|
let sql = `SELECT COUNT(*) AS n FROM ${dialect.id(from)}`;
|
|
250
256
|
if (where && where.t !== "true") sql += ` WHERE ${compileExpr(where, dialect, params).sql}`;
|
|
251
257
|
return { sql, params };
|
|
@@ -265,7 +271,7 @@ export function compileAggregate(
|
|
|
265
271
|
},
|
|
266
272
|
dialect: Dialect,
|
|
267
273
|
): CompiledSql {
|
|
268
|
-
const params:
|
|
274
|
+
const params: CellValue[] = [];
|
|
269
275
|
const cols: string[] = [];
|
|
270
276
|
for (const g of spec.groupBy ?? []) cols.push(dialect.id(g));
|
|
271
277
|
for (const [key, agg] of Object.entries(spec.aggregations)) {
|
|
@@ -279,7 +285,7 @@ export function compileAggregate(
|
|
|
279
285
|
}
|
|
280
286
|
|
|
281
287
|
export function compileSelect(spec: QuerySpec, dialect: Dialect): CompiledSql {
|
|
282
|
-
const params:
|
|
288
|
+
const params: CellValue[] = [];
|
|
283
289
|
const cols = spec.columns && spec.columns.length > 0 ? spec.columns.map((c) => dialect.id(c)).join(", ") : "*";
|
|
284
290
|
let sql = `SELECT ${cols} FROM ${dialect.id(spec.from)}`;
|
|
285
291
|
|
package/src/runtime/registry.ts
CHANGED
|
@@ -79,7 +79,11 @@ export function parseRegistryKey(key: string): DoRef | null {
|
|
|
79
79
|
/** Enumerate every registered `(tenant, partition)` pair from the registry KV.
|
|
80
80
|
* Paginates over the full listing (cursor / list_complete) — never truncates at the
|
|
81
81
|
* 1000-key page limit. */
|
|
82
|
-
|
|
82
|
+
/** The slice of KV that DO enumeration needs — narrower than the whole namespace, so
|
|
83
|
+
* callers (and test doubles) only have to provide `list`. */
|
|
84
|
+
export type KvLister = Pick<KVNamespace, "list">;
|
|
85
|
+
|
|
86
|
+
export async function listDOs(kv: KvLister): Promise<DoRef[]> {
|
|
83
87
|
const out: DoRef[] = [];
|
|
84
88
|
let cursor: string | undefined;
|
|
85
89
|
for (;;) {
|
|
@@ -21,7 +21,7 @@ import type { FieldDef, SchemaDef } from "../sdk/schema";
|
|
|
21
21
|
import { partitionOf } from "../sdk/schema";
|
|
22
22
|
|
|
23
23
|
/** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
|
|
24
|
-
export interface
|
|
24
|
+
export interface ColumnFingerprint {
|
|
25
25
|
type: string;
|
|
26
26
|
notNull?: boolean;
|
|
27
27
|
unique?: boolean;
|
|
@@ -33,16 +33,16 @@ export interface ColumnShape {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** The comparable fingerprint of a table: its partition + each column's shape. */
|
|
36
|
-
export interface
|
|
36
|
+
export interface TableFingerprint {
|
|
37
37
|
partition: string;
|
|
38
|
-
columns: Record<string,
|
|
38
|
+
columns: Record<string, ColumnFingerprint>;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** table -> table shape. The comparable surface of a schema. */
|
|
42
|
-
export type
|
|
42
|
+
export type SchemaFingerprint = Record<string, TableFingerprint>;
|
|
43
43
|
|
|
44
|
-
function
|
|
45
|
-
const c:
|
|
44
|
+
function columnFingerprint(f: FieldDef): ColumnFingerprint {
|
|
45
|
+
const c: ColumnFingerprint = { type: f.type };
|
|
46
46
|
if (f.notNull) c.notNull = true;
|
|
47
47
|
if (f.unique) c.unique = true;
|
|
48
48
|
if (f.primaryKey) c.primaryKey = true;
|
|
@@ -53,27 +53,27 @@ function columnShape(f: FieldDef): ColumnShape {
|
|
|
53
53
|
return c;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function
|
|
57
|
-
const out:
|
|
56
|
+
export function schemaFingerprint(schema: SchemaDef): SchemaFingerprint {
|
|
57
|
+
const out: SchemaFingerprint = {};
|
|
58
58
|
for (const [table, def] of Object.entries(schema)) {
|
|
59
|
-
const columns: Record<string,
|
|
60
|
-
for (const [col, f] of Object.entries(def.fields)) columns[col] =
|
|
59
|
+
const columns: Record<string, ColumnFingerprint> = {};
|
|
60
|
+
for (const [col, f] of Object.entries(def.fields)) columns[col] = columnFingerprint(f as FieldDef);
|
|
61
61
|
out[table] = { partition: partitionOf(schema, table), columns };
|
|
62
62
|
}
|
|
63
63
|
return out;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** The modifier fields compared for a `change-column` (everything but `type`). */
|
|
67
|
-
const MODIFIER_KEYS: (keyof
|
|
67
|
+
const MODIFIER_KEYS: (keyof ColumnFingerprint)[] = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
|
|
68
68
|
|
|
69
69
|
/** Does `next` tighten a constraint `prev` lacked (add NOT NULL / UNIQUE / PRIMARY KEY)?
|
|
70
70
|
* Such a change may require the destructive gate or be skipped when the live data
|
|
71
71
|
* conflicts (NULL rows / duplicates) — so the diff flags it `destructive`. */
|
|
72
|
-
function tightensConstraint(prev:
|
|
72
|
+
function tightensConstraint(prev: ColumnFingerprint, next: ColumnFingerprint): boolean {
|
|
73
73
|
return (!!next.notNull && !prev.notNull) || (!!next.unique && !prev.unique) || (!!next.primaryKey && !prev.primaryKey);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
function modifierDiff(prev:
|
|
76
|
+
function modifierDiff(prev: ColumnFingerprint, next: ColumnFingerprint): string | null {
|
|
77
77
|
const parts: string[] = [];
|
|
78
78
|
for (const k of MODIFIER_KEYS) {
|
|
79
79
|
if (prev[k] !== next[k]) parts.push(`${k}: ${fmt(prev[k])} → ${fmt(next[k])}`);
|
|
@@ -102,7 +102,7 @@ export interface SchemaChange {
|
|
|
102
102
|
appliesOnBoot: boolean;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
export function
|
|
105
|
+
export function diffSchemaFingerprint(prev: SchemaFingerprint, next: SchemaFingerprint): SchemaChange[] {
|
|
106
106
|
const changes: SchemaChange[] = [];
|
|
107
107
|
|
|
108
108
|
for (const table of Object.keys(next)) {
|
package/src/sdk/acl.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// and/or a set of permitted `fields`. Access is deny-by-default; policies only
|
|
7
7
|
// ever grant. Across the identity's roles, grants OR-merge (any role can allow).
|
|
8
8
|
|
|
9
|
+
import type { CellValue, JsonValue, Row } from "./infer";
|
|
10
|
+
|
|
9
11
|
export type Action = "read" | "create" | "update" | "delete";
|
|
10
12
|
|
|
11
13
|
/** Runtime identity. Augment with your own properties (userId, tier, …). */
|
|
@@ -17,7 +19,7 @@ export interface Identity {
|
|
|
17
19
|
* expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
|
|
18
20
|
* (callPrivileged) identities, which are therefore never treated as expired. */
|
|
19
21
|
exp?: number;
|
|
20
|
-
[key: string]:
|
|
22
|
+
[key: string]: JsonValue | undefined;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
// --- $identity markers: reference an identity property inside a where rule ---
|
|
@@ -34,8 +36,8 @@ export function $identity(path: string): IdentityMarker {
|
|
|
34
36
|
return { [IDENTITY_MARKER]: true, path };
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
export function isIdentityMarker(v:
|
|
38
|
-
return typeof v === "object" && v !== null && (v as
|
|
39
|
+
export function isIdentityMarker(v: WhereValue): v is IdentityMarker {
|
|
40
|
+
return typeof v === "object" && v !== null && (v as Partial<IdentityMarker>)[IDENTITY_MARKER] === true;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// --- $input markers: reference a request-input field inside a where rule, for a
|
|
@@ -56,8 +58,8 @@ export function $input(path: string): InputMarker {
|
|
|
56
58
|
return { [INPUT_MARKER]: true, path };
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
export function isInputMarker(v:
|
|
60
|
-
return typeof v === "object" && v !== null && (v as
|
|
61
|
+
export function isInputMarker(v: WhereValue): v is InputMarker {
|
|
62
|
+
return typeof v === "object" && v !== null && (v as Partial<InputMarker>)[INPUT_MARKER] === true;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// --- $now markers: the request's evaluation instant inside a where rule, for a
|
|
@@ -88,8 +90,8 @@ export function $now(): NowMarker {
|
|
|
88
90
|
return { [NOW_MARKER]: true };
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
export function isNowMarker(v:
|
|
92
|
-
return typeof v === "object" && v !== null && (v as
|
|
93
|
+
export function isNowMarker(v: WhereValue): v is NowMarker {
|
|
94
|
+
return typeof v === "object" && v !== null && (v as Partial<NowMarker>)[NOW_MARKER] === true;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
// --- allow / deny markers ---
|
|
@@ -110,8 +112,21 @@ export function deny(): DenyMarker {
|
|
|
110
112
|
|
|
111
113
|
// --- policy rules ---
|
|
112
114
|
|
|
113
|
-
/** A where rule:
|
|
114
|
-
|
|
115
|
+
/** A value inside a `where` rule: a literal cell value, a per-request marker, an
|
|
116
|
+
* operator object (`{ gte: 5 }`), a nested relation predicate, or an AND/OR group. */
|
|
117
|
+
export type WhereValue =
|
|
118
|
+
| CellValue
|
|
119
|
+
| IdentityMarker
|
|
120
|
+
| InputMarker
|
|
121
|
+
| NowMarker
|
|
122
|
+
| WhereRule
|
|
123
|
+
| WhereValue[];
|
|
124
|
+
|
|
125
|
+
/** A where rule: column (or `AND`/`OR`/`NOT`) -> value. An interface so it can recur
|
|
126
|
+
* through `WhereValue` for nested relation predicates and boolean groups. */
|
|
127
|
+
export interface WhereRule {
|
|
128
|
+
[key: string]: WhereValue;
|
|
129
|
+
}
|
|
115
130
|
|
|
116
131
|
/** A per-row (cell-level) field grant: `fields` are permitted only for rows that
|
|
117
132
|
* match `when`. Additive over the policy's flat `fields` — a conditional grant can
|
|
@@ -125,7 +140,7 @@ export interface ConditionalFields {
|
|
|
125
140
|
/** Escape hatch for cell-level ACL: a late per-row resolver. Given the identity and
|
|
126
141
|
* the fetched (or candidate, on write) row, returns the extra permitted fields —
|
|
127
142
|
* additive over `fields`; `null` means all fields for that row. */
|
|
128
|
-
export type FieldsFn = (identity: Identity | null, row:
|
|
143
|
+
export type FieldsFn = (identity: Identity | null, row: Row) => string[] | null;
|
|
129
144
|
|
|
130
145
|
/** Per-relation ACL inside a parent read policy. */
|
|
131
146
|
export interface RelationAclRule {
|
|
@@ -143,10 +158,10 @@ export interface RelationAclRule {
|
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
/** A forced column value on write: a literal, or computed from the identity. */
|
|
146
|
-
export type SetValue =
|
|
161
|
+
export type SetValue = CellValue | ((identity: Identity | null) => CellValue);
|
|
147
162
|
|
|
148
163
|
/** Server-side validation on write; throw to reject. Runs on the final values. */
|
|
149
|
-
export type Validator = (args: { identity: Identity | null; values:
|
|
164
|
+
export type Validator = (args: { identity: Identity | null; values: Row }) => void;
|
|
150
165
|
|
|
151
166
|
export interface PolicyRules {
|
|
152
167
|
/** Row-level predicate (AND of equalities). Omit/empty = all rows. */
|
|
@@ -175,10 +190,10 @@ export interface PolicyRules {
|
|
|
175
190
|
export interface ResolverDb {
|
|
176
191
|
find(spec: {
|
|
177
192
|
from: string;
|
|
178
|
-
where?:
|
|
193
|
+
where?: WhereRule;
|
|
179
194
|
orderBy?: { column: string; dir?: "asc" | "desc" };
|
|
180
195
|
limit?: number;
|
|
181
|
-
}): Promise<
|
|
196
|
+
}): Promise<Row[]>;
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
export interface ResolverContext {
|
package/src/sdk/handlers.ts
CHANGED
|
@@ -10,6 +10,13 @@ import type { Queue } from "../runtime/queue";
|
|
|
10
10
|
import type { Identity } from "./acl";
|
|
11
11
|
import type { Files } from "./files";
|
|
12
12
|
import type { SchemaDef } from "./schema";
|
|
13
|
+
import type { JsonValue } from "./infer";
|
|
14
|
+
|
|
15
|
+
/** The Worker/DO environment as an open, read-only bag: Cloudflare bindings (KV, R2,
|
|
16
|
+
* D1, Queues, …) alongside vars and secrets. Deliberately open and opaque — an app
|
|
17
|
+
* declares its own bindings, so the value type cannot be enumerated here; read a value
|
|
18
|
+
* and narrow it at the use site (`ctx.env.STRIPE_SECRET_KEY as string`). */
|
|
19
|
+
export type EnvBag = Readonly<Record<string, unknown>>;
|
|
13
20
|
|
|
14
21
|
export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
15
22
|
/** Schema-typed repository: find/insert/update/delete inferred from S. */
|
|
@@ -30,7 +37,7 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
30
37
|
* Use it to call external services from handlers — Cloudflare bindings (e.g. the
|
|
31
38
|
* `send_email` binding for Cloudflare Email Sending) or third-party APIs (Stripe, …). Loosely typed;
|
|
32
39
|
* cast a value at the use site, e.g. `ctx.env.STRIPE_SECRET_KEY as string`. */
|
|
33
|
-
readonly env:
|
|
40
|
+
readonly env: EnvBag;
|
|
34
41
|
/** Resolved identity for this request (null = anonymous). */
|
|
35
42
|
readonly identity: Identity | null;
|
|
36
43
|
/** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
|
|
@@ -119,7 +126,7 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
119
126
|
readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
|
|
120
127
|
/** Optional boundary validator: parse/validate the raw request input, throwing
|
|
121
128
|
* to reject (surfaced as a 400). Its return type fixes the handler's input. */
|
|
122
|
-
readonly input?: (raw:
|
|
129
|
+
readonly input?: (raw: JsonValue) => unknown;
|
|
123
130
|
/** Optional DO partition this handler runs in (static, server-side). The Worker
|
|
124
131
|
* routes the request to the matching partition-DO before dispatch. Absent ⇒ the
|
|
125
132
|
* default partition (routed to the bare tenant key). */
|
|
@@ -129,7 +136,7 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
export interface HandlerOpts<I> {
|
|
132
|
-
input?: (raw:
|
|
139
|
+
input?: (raw: JsonValue) => I;
|
|
133
140
|
/** DO partition this handler runs in. Absent ⇒ the default partition. */
|
|
134
141
|
partition?: string;
|
|
135
142
|
/** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
|
package/src/sdk/infer.ts
CHANGED
|
@@ -11,6 +11,27 @@ export type { FileRef } from "./files";
|
|
|
11
11
|
/** Any JSON-serializable value — the type of a `t.json()` column. */
|
|
12
12
|
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
13
13
|
|
|
14
|
+
/** A JSON object — the object arm of `JsonValue`, named so it can be referenced
|
|
15
|
+
* directly (e.g. an identity's claims, a `t.json()` column's object form). */
|
|
16
|
+
export interface JsonObject {
|
|
17
|
+
[key: string]: JsonValue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A raw value as the substrate stores/returns it, before pramen's object↔JSON codec.
|
|
21
|
+
* DO SQLite and D1 hand back exactly these; BLOB columns arrive as an ArrayBuffer. */
|
|
22
|
+
export type SqlValue = string | number | bigint | boolean | null | ArrayBuffer;
|
|
23
|
+
|
|
24
|
+
/** A decoded column value as handlers see it at the `Db` chokepoint: any JSON value,
|
|
25
|
+
* a `fileRef` column's metadata, or — for an eager-loaded relation — the related
|
|
26
|
+
* row(s) grafted onto the parent under the relation name. */
|
|
27
|
+
export type CellValue = SqlValue | JsonValue | FileRef | Row | Row[];
|
|
28
|
+
|
|
29
|
+
/** A decoded database row — column name -> decoded value. An interface (not a
|
|
30
|
+
* `Record` alias) so it can recur through `CellValue` for eager-loaded relations. */
|
|
31
|
+
export interface Row {
|
|
32
|
+
[column: string]: CellValue;
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
/** SQL field type -> TypeScript value type. */
|
|
15
36
|
export type FieldTsType<D extends FieldDef> = D["type"] extends "text"
|
|
16
37
|
? string
|
package/src/worker.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity, type VerifyOptions, type VerifyStrategy } from "./auth";
|
|
8
8
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
9
|
+
import type { EnvBag } from "./sdk/handlers";
|
|
10
|
+
import type { JsonValue } from "./sdk/infer";
|
|
9
11
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
10
12
|
import { createMail } from "./runtime/mail";
|
|
11
13
|
import { createQueue, type QueueProducerBinding } from "./runtime/queue";
|
|
@@ -23,6 +25,11 @@ import type { HandlerContext } from "./sdk/handlers";
|
|
|
23
25
|
import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
|
|
24
26
|
import type { PramenApp } from "./pramen";
|
|
25
27
|
|
|
28
|
+
/** Widen the closed `Env` interface to the open `EnvBag` handlers and services see.
|
|
29
|
+
* Spreading yields an anonymous object type, which TypeScript gives an implicit index
|
|
30
|
+
* signature — so this needs no type assertion. */
|
|
31
|
+
const envBag = (env: Env): EnvBag => ({ ...env });
|
|
32
|
+
|
|
26
33
|
export interface Env {
|
|
27
34
|
PRAMEN: DurableObjectNamespace;
|
|
28
35
|
/** Project KV — tenant registry (`tenant:` keys) + handler ctx.kv (`app:` keys). */
|
|
@@ -149,7 +156,7 @@ function partitionStubFor(env: Env, tenant: string, partition: string = DEFAULT_
|
|
|
149
156
|
* DO's JSON response (`{ ok, result }` / `{ ok: false, … }`). */
|
|
150
157
|
export async function callPrivileged(
|
|
151
158
|
env: Env,
|
|
152
|
-
opts: { name: string; input?:
|
|
159
|
+
opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[]; partition?: string },
|
|
153
160
|
): Promise<Response> {
|
|
154
161
|
const tenant = opts.tenant ?? "main";
|
|
155
162
|
const partition = opts.partition ?? DEFAULT_PARTITION;
|
|
@@ -235,7 +242,8 @@ export function makeWorker(app: PramenApp) {
|
|
|
235
242
|
const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
236
243
|
const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
237
244
|
const kv = new Kv(env.KV);
|
|
238
|
-
|
|
245
|
+
const bag = envBag(env);
|
|
246
|
+
return { db, kv, files, env: bag, identity, tasks: tasksFacade(driver), mail: createMail(bag, kv), queue: createQueue(bag) };
|
|
239
247
|
};
|
|
240
248
|
|
|
241
249
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|
|
@@ -281,7 +289,7 @@ export function makeWorker(app: PramenApp) {
|
|
|
281
289
|
for (const r of app.routes ?? []) {
|
|
282
290
|
if (request.method === r.method && url.pathname === r.path) {
|
|
283
291
|
const routeCtx = { callPrivileged: (opts: Parameters<typeof callPrivileged>[1]) => callPrivileged(env, opts) };
|
|
284
|
-
return r.handler(request, env
|
|
292
|
+
return r.handler(request, envBag(env), routeCtx);
|
|
285
293
|
}
|
|
286
294
|
}
|
|
287
295
|
|
|
@@ -473,8 +481,9 @@ export function makeWorker(app: PramenApp) {
|
|
|
473
481
|
}
|
|
474
482
|
// (isLive is excluded by useD1Store — live always routes to the DO below.)
|
|
475
483
|
const name = url.pathname.replace(/^\/rpc\//, "");
|
|
476
|
-
|
|
477
|
-
|
|
484
|
+
// The RPC body is JSON — parse it into the domain type once, here at the boundary.
|
|
485
|
+
let input: JsonValue = null;
|
|
486
|
+
if (request.method === "POST") input = ((await request.json().catch(() => null)) ?? null) as JsonValue;
|
|
478
487
|
|
|
479
488
|
// Pick where the D1 session may start its first read. A mutation ALWAYS pins the
|
|
480
489
|
// primary (`first-primary` is a superset of read-your-writes) so a read-modify-write
|
|
@@ -489,10 +498,10 @@ export function makeWorker(app: PramenApp) {
|
|
|
489
498
|
|
|
490
499
|
const driver = new D1Driver(env.DB, { start });
|
|
491
500
|
const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
492
|
-
const
|
|
501
|
+
const bag = envBag(env);
|
|
493
502
|
try {
|
|
494
503
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
495
|
-
const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files,
|
|
504
|
+
const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity }, name, input);
|
|
496
505
|
// Kick an immediate drain in the request tail when this handler enqueued tasks
|
|
497
506
|
// (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
|
|
498
507
|
// — up to a full minute. `waitUntil` lets the response return now while the
|
|
@@ -567,13 +576,13 @@ export function makeWorker(app: PramenApp) {
|
|
|
567
576
|
// handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
|
|
568
577
|
// (no tenant DO): its ctx carries env/kv/mail/queue + callPrivileged to reach a DO.
|
|
569
578
|
async queue(batch: QueueBatch, env: Env): Promise<void> {
|
|
570
|
-
const
|
|
579
|
+
const bag = envBag(env);
|
|
571
580
|
const kv = new Kv(env.KV);
|
|
572
581
|
const ctx: QueueContext = {
|
|
573
|
-
env:
|
|
582
|
+
env: bag,
|
|
574
583
|
kv,
|
|
575
|
-
mail: createMail(
|
|
576
|
-
queue: createQueue(
|
|
584
|
+
mail: createMail(bag, kv),
|
|
585
|
+
queue: createQueue(bag),
|
|
577
586
|
callPrivileged: (opts) => callPrivileged(env, opts),
|
|
578
587
|
};
|
|
579
588
|
await dispatchQueueBatch(app.queues ?? {}, ctx, batch);
|