@pramen/server 0.0.1

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.
Files changed (76) hide show
  1. package/dist/auth.d.ts +35 -0
  2. package/dist/auth.js +189 -0
  3. package/dist/durable-object.d.ts +48 -0
  4. package/dist/durable-object.js +282 -0
  5. package/dist/index.d.ts +14 -0
  6. package/dist/index.js +20 -0
  7. package/dist/pramen.d.ts +42 -0
  8. package/dist/pramen.js +19 -0
  9. package/dist/runtime/acl.d.ts +62 -0
  10. package/dist/runtime/acl.js +289 -0
  11. package/dist/runtime/db.d.ts +139 -0
  12. package/dist/runtime/db.js +425 -0
  13. package/dist/runtime/ddl.d.ts +16 -0
  14. package/dist/runtime/ddl.js +64 -0
  15. package/dist/runtime/digest.d.ts +1 -0
  16. package/dist/runtime/digest.js +29 -0
  17. package/dist/runtime/dispatch.d.ts +12 -0
  18. package/dist/runtime/dispatch.js +37 -0
  19. package/dist/runtime/driver.d.ts +45 -0
  20. package/dist/runtime/driver.js +70 -0
  21. package/dist/runtime/errors.d.ts +34 -0
  22. package/dist/runtime/errors.js +43 -0
  23. package/dist/runtime/kv.d.ts +23 -0
  24. package/dist/runtime/kv.js +41 -0
  25. package/dist/runtime/migrate.d.ts +22 -0
  26. package/dist/runtime/migrate.js +158 -0
  27. package/dist/runtime/protocol.d.ts +40 -0
  28. package/dist/runtime/protocol.js +12 -0
  29. package/dist/runtime/read-engine.d.ts +73 -0
  30. package/dist/runtime/read-engine.js +219 -0
  31. package/dist/runtime/schema-diff.d.ts +14 -0
  32. package/dist/runtime/schema-diff.js +41 -0
  33. package/dist/runtime/storage.d.ts +74 -0
  34. package/dist/runtime/storage.js +0 -0
  35. package/dist/sdk/acl.d.ts +130 -0
  36. package/dist/sdk/acl.js +55 -0
  37. package/dist/sdk/app.d.ts +7 -0
  38. package/dist/sdk/app.js +11 -0
  39. package/dist/sdk/files.d.ts +51 -0
  40. package/dist/sdk/files.js +4 -0
  41. package/dist/sdk/handlers.d.ts +36 -0
  42. package/dist/sdk/handlers.js +11 -0
  43. package/dist/sdk/infer.d.ts +79 -0
  44. package/dist/sdk/infer.js +5 -0
  45. package/dist/sdk/schema.d.ts +112 -0
  46. package/dist/sdk/schema.js +56 -0
  47. package/dist/worker-entry.d.ts +3 -0
  48. package/dist/worker-entry.js +8 -0
  49. package/dist/worker.d.ts +41 -0
  50. package/dist/worker.js +213 -0
  51. package/package.json +43 -0
  52. package/src/auth.ts +215 -0
  53. package/src/durable-object.ts +346 -0
  54. package/src/index.ts +77 -0
  55. package/src/pramen.ts +58 -0
  56. package/src/runtime/acl.ts +362 -0
  57. package/src/runtime/db.ts +550 -0
  58. package/src/runtime/ddl.ts +67 -0
  59. package/src/runtime/digest.ts +31 -0
  60. package/src/runtime/dispatch.ts +65 -0
  61. package/src/runtime/driver.ts +95 -0
  62. package/src/runtime/errors.ts +56 -0
  63. package/src/runtime/kv.ts +47 -0
  64. package/src/runtime/migrate.ts +193 -0
  65. package/src/runtime/protocol.ts +46 -0
  66. package/src/runtime/read-engine.ts +243 -0
  67. package/src/runtime/schema-diff.ts +57 -0
  68. package/src/runtime/storage.ts +0 -0
  69. package/src/sdk/acl.ts +196 -0
  70. package/src/sdk/app.ts +25 -0
  71. package/src/sdk/files.ts +53 -0
  72. package/src/sdk/handlers.ts +65 -0
  73. package/src/sdk/infer.ts +105 -0
  74. package/src/sdk/schema.ts +122 -0
  75. package/src/worker-entry.ts +9 -0
  76. package/src/worker.ts +253 -0
@@ -0,0 +1,243 @@
1
+ // Read engine — compiles a structured query into parameterized SQL. The "no
2
+ // hand-written SQL in handler code" property is preserved; the long-term plan is
3
+ // to compile this module to WASM so the hot path leaves JS entirely.
4
+ //
5
+ // A small boolean expression AST (SqlExpr) lets user predicates (with operators,
6
+ // AND/OR groups) and ACL row-level scopes be merged before compilation. Column
7
+ // names come from developer code (schema keys); values are always parameterized.
8
+
9
+ import type { Dialect } from "./driver";
10
+
11
+ // In-process value coercion for evalExpr (SQLite-style booleans). SQL-side encoding
12
+ // goes through the active Dialect; this mirrors it for the cell-ACL `when` evaluator.
13
+ function bind(v: unknown): unknown {
14
+ return typeof v === "boolean" ? (v ? 1 : 0) : v;
15
+ }
16
+
17
+ export type CmpOp = "=" | "!=" | ">" | ">=" | "<" | "<=" | "LIKE";
18
+
19
+ export type SqlExpr =
20
+ | { t: "true" }
21
+ | { t: "false" }
22
+ | { t: "cmp"; op: CmpOp; col: string; value: unknown }
23
+ | { t: "in"; col: string; values: unknown[]; negate: boolean }
24
+ | { t: "null"; col: string; negate: boolean }
25
+ | { t: "and"; parts: SqlExpr[] }
26
+ | { t: "or"; parts: SqlExpr[] };
27
+
28
+ export const TRUE: SqlExpr = { t: "true" };
29
+ export const FALSE: SqlExpr = { t: "false" };
30
+ export const cmp = (op: CmpOp, col: string, value: unknown): SqlExpr => ({ t: "cmp", op, col, value });
31
+ export const isNull = (col: string, negate = false): SqlExpr => ({ t: "null", col, negate });
32
+ export const inList = (col: string, values: unknown[], negate = false): SqlExpr => ({ t: "in", col, values, negate });
33
+ export const and = (...parts: SqlExpr[]): SqlExpr => ({ t: "and", parts });
34
+ export const or = (...parts: SqlExpr[]): SqlExpr => ({ t: "or", parts });
35
+
36
+ /** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
37
+ export const eq = (col: string, value: unknown): SqlExpr => (value === null ? isNull(col) : cmp("=", col, value));
38
+
39
+ /** Compile a structured user predicate into a SqlExpr.
40
+ * Shapes: { col: value } (eq) | { col: { gt, lt, in, like, isNull, … } } | { AND: [...] } | { OR: [...] } */
41
+ export function compileWhere(input: Record<string, unknown>): SqlExpr {
42
+ const parts: SqlExpr[] = [];
43
+ for (const [k, v] of Object.entries(input)) {
44
+ if (k === "AND") {
45
+ parts.push(and(...(v as Record<string, unknown>[]).map(compileWhere)));
46
+ } else if (k === "OR") {
47
+ parts.push(or(...(v as Record<string, unknown>[]).map(compileWhere)));
48
+ } else {
49
+ parts.push(columnPredicate(k, v));
50
+ }
51
+ }
52
+ return parts.length ? and(...parts) : TRUE;
53
+ }
54
+
55
+ function columnPredicate(col: string, v: unknown): SqlExpr {
56
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
57
+ const ops: SqlExpr[] = [];
58
+ for (const [op, val] of Object.entries(v as Record<string, unknown>)) {
59
+ switch (op) {
60
+ case "eq": ops.push(eq(col, val)); break;
61
+ case "ne": ops.push(val === null ? isNull(col, true) : cmp("!=", col, val)); break;
62
+ case "gt": ops.push(cmp(">", col, val)); break;
63
+ case "gte": ops.push(cmp(">=", col, val)); break;
64
+ case "lt": ops.push(cmp("<", col, val)); break;
65
+ case "lte": ops.push(cmp("<=", col, val)); break;
66
+ case "like": ops.push(cmp("LIKE", col, val)); break;
67
+ case "in": ops.push(inList(col, val as unknown[])); break;
68
+ case "notIn": ops.push(inList(col, val as unknown[], true)); break;
69
+ case "isNull": ops.push(isNull(col, !val)); break; // isNull:true => IS NULL
70
+ default: throw new Error(`unknown operator: ${op}`);
71
+ }
72
+ }
73
+ return ops.length ? and(...ops) : TRUE;
74
+ }
75
+ return eq(col, v);
76
+ }
77
+
78
+ export interface CompiledSql {
79
+ readonly sql: string;
80
+ readonly params: unknown[];
81
+ }
82
+
83
+ export function compileExpr(expr: SqlExpr, dialect: Dialect, params: unknown[] = []): CompiledSql {
84
+ switch (expr.t) {
85
+ case "true":
86
+ return { sql: "1", params };
87
+ case "false":
88
+ return { sql: "0", params };
89
+ case "cmp":
90
+ if (expr.value === null) return { sql: `${dialect.id(expr.col)} IS NULL`, params };
91
+ params.push(dialect.encode(expr.value));
92
+ return { sql: `${dialect.id(expr.col)} ${expr.op} ${dialect.placeholder(params.length)}`, params };
93
+ case "null":
94
+ return { sql: `${dialect.id(expr.col)} IS ${expr.negate ? "NOT " : ""}NULL`, params };
95
+ case "in": {
96
+ if (expr.values.length === 0) return { sql: expr.negate ? "1" : "0", params }; // empty: notIn=>all, in=>none
97
+ const ph = expr.values.map((v) => (params.push(dialect.encode(v)), dialect.placeholder(params.length))).join(", ");
98
+ return { sql: `${dialect.id(expr.col)} ${expr.negate ? "NOT IN" : "IN"} (${ph})`, params };
99
+ }
100
+ case "and":
101
+ case "or": {
102
+ if (expr.parts.length === 0) return { sql: expr.t === "and" ? "1" : "0", params };
103
+ const sep = expr.t === "and" ? " AND " : " OR ";
104
+ const sql = expr.parts.map((p) => compileExpr(p, dialect, params).sql).join(sep);
105
+ return { sql: expr.parts.length > 1 ? `(${sql})` : sql, params };
106
+ }
107
+ }
108
+ }
109
+
110
+ // SQL LIKE -> RegExp. `%` is any run, `_` is any single char; SQLite LIKE is
111
+ // case-insensitive for ASCII, so the regex is too. Other chars are escaped.
112
+ function likeToRegex(pattern: string): RegExp {
113
+ let re = "";
114
+ for (const ch of pattern) {
115
+ if (ch === "%") re += ".*";
116
+ else if (ch === "_") re += ".";
117
+ else re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
118
+ }
119
+ return new RegExp(`^${re}$`, "is");
120
+ }
121
+
122
+ /** Evaluate a compiled predicate against an in-memory row. Mirrors compileExpr's
123
+ * semantics (bound-boolean coercion, NULL compares false, empty-IN, LIKE) so the
124
+ * declarative cell-ACL `when` path can decide per-row field visibility without a
125
+ * round-trip to SQLite. */
126
+ export function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean {
127
+ switch (expr.t) {
128
+ case "true":
129
+ return true;
130
+ case "false":
131
+ return false;
132
+ case "cmp": {
133
+ const left = bind(row[expr.col]);
134
+ if (expr.value === null) return left === null || left === undefined;
135
+ if (left === null || left === undefined) return false; // NULL compared to a value -> false
136
+ const right = bind(expr.value);
137
+ switch (expr.op) {
138
+ case "=": return left === right;
139
+ case "!=": return left !== right;
140
+ case ">": return (left as never) > (right as never);
141
+ case ">=": return (left as never) >= (right as never);
142
+ case "<": return (left as never) < (right as never);
143
+ case "<=": return (left as never) <= (right as never);
144
+ case "LIKE": return typeof left === "string" && likeToRegex(String(right)).test(left);
145
+ }
146
+ return false;
147
+ }
148
+ case "null": {
149
+ const v = row[expr.col];
150
+ const isNullVal = v === null || v === undefined;
151
+ return expr.negate ? !isNullVal : isNullVal;
152
+ }
153
+ case "in": {
154
+ const left = bind(row[expr.col]);
155
+ if (left === null || left === undefined) return false; // can't demonstrate membership
156
+ if (expr.values.length === 0) return expr.negate; // empty: notIn=>all, in=>none
157
+ const found = expr.values.some((v) => bind(v) === left);
158
+ return expr.negate ? !found : found;
159
+ }
160
+ case "and":
161
+ return expr.parts.every((p) => evalExpr(p, row));
162
+ case "or":
163
+ return expr.parts.some((p) => evalExpr(p, row));
164
+ }
165
+ }
166
+
167
+ export interface OrderBy {
168
+ column: string;
169
+ dir?: "asc" | "desc";
170
+ }
171
+
172
+ export interface QuerySpec {
173
+ readonly from: string;
174
+ readonly where?: SqlExpr;
175
+ readonly orderBy?: OrderBy[];
176
+ readonly limit?: number;
177
+ readonly offset?: number;
178
+ }
179
+
180
+ export type AggFn = "count" | "sum" | "avg" | "min" | "max";
181
+ const AGG_SQL: Record<AggFn, string> = { count: "COUNT", sum: "SUM", avg: "AVG", min: "MIN", max: "MAX" };
182
+
183
+ export function compileCount(from: string, dialect: Dialect, where?: SqlExpr): CompiledSql {
184
+ const params: unknown[] = [];
185
+ let sql = `SELECT COUNT(*) AS n FROM ${dialect.id(from)}`;
186
+ if (where && where.t !== "true") sql += ` WHERE ${compileExpr(where, dialect, params).sql}`;
187
+ return { sql, params };
188
+ }
189
+
190
+ export interface Aggregation {
191
+ fn: AggFn;
192
+ column?: string;
193
+ }
194
+
195
+ export function compileAggregate(
196
+ spec: {
197
+ from: string;
198
+ where?: SqlExpr;
199
+ groupBy?: string[];
200
+ aggregations: Record<string, Aggregation>;
201
+ },
202
+ dialect: Dialect,
203
+ ): CompiledSql {
204
+ const params: unknown[] = [];
205
+ const cols: string[] = [];
206
+ for (const g of spec.groupBy ?? []) cols.push(dialect.id(g));
207
+ for (const [key, agg] of Object.entries(spec.aggregations)) {
208
+ const target = agg.column != null ? dialect.id(agg.column) : "*";
209
+ cols.push(`${AGG_SQL[agg.fn]}(${target}) AS ${dialect.id(key)}`);
210
+ }
211
+ let sql = `SELECT ${cols.join(", ")} FROM ${dialect.id(spec.from)}`;
212
+ if (spec.where && spec.where.t !== "true") sql += ` WHERE ${compileExpr(spec.where, dialect, params).sql}`;
213
+ if (spec.groupBy && spec.groupBy.length > 0) sql += ` GROUP BY ${spec.groupBy.map((g) => dialect.id(g)).join(", ")}`;
214
+ return { sql, params };
215
+ }
216
+
217
+ export function compileSelect(spec: QuerySpec, dialect: Dialect): CompiledSql {
218
+ const params: unknown[] = [];
219
+ let sql = `SELECT * FROM ${dialect.id(spec.from)}`;
220
+
221
+ if (spec.where && spec.where.t !== "true") {
222
+ const { sql: where } = compileExpr(spec.where, dialect, params);
223
+ sql += ` WHERE ${where}`;
224
+ }
225
+
226
+ if (spec.orderBy && spec.orderBy.length > 0) {
227
+ const cols = spec.orderBy.map((o) => `${dialect.id(o.column)} ${o.dir === "desc" ? "DESC" : "ASC"}`);
228
+ sql += ` ORDER BY ${cols.join(", ")}`;
229
+ }
230
+
231
+ if (spec.limit != null) {
232
+ params.push(spec.limit);
233
+ sql += ` LIMIT ${dialect.placeholder(params.length)}`;
234
+ } else if (spec.offset != null) {
235
+ sql += " LIMIT -1"; // SQLite requires a LIMIT before OFFSET (offset-only is a SQLite-ism)
236
+ }
237
+ if (spec.offset != null) {
238
+ params.push(spec.offset);
239
+ sql += ` OFFSET ${dialect.placeholder(params.length)}`;
240
+ }
241
+
242
+ return { sql, params };
243
+ }
@@ -0,0 +1,57 @@
1
+ // Schema shape + diff — powers the CLI's `schema diff`. migrate() applies every
2
+ // change on the next DO boot, additive AND destructive. A diff classifies each as
3
+ // `destructive` (drop / type change — rebuilds the table and CAN lose data) or not
4
+ // (add table/column — no data loss). A rename can't be detected from a shape diff;
5
+ // it shows as drop+add unless declared with `renamedFrom` in the schema.
6
+
7
+ import type { FieldDef, SchemaDef } from "../sdk/schema";
8
+
9
+ /** table -> column -> field type. The comparable surface of a schema. */
10
+ export type SchemaShape = Record<string, Record<string, string>>;
11
+
12
+ export function schemaShape(schema: SchemaDef): SchemaShape {
13
+ const out: SchemaShape = {};
14
+ for (const [table, def] of Object.entries(schema)) {
15
+ const cols: Record<string, string> = {};
16
+ for (const [col, f] of Object.entries(def.fields)) cols[col] = (f as FieldDef).type;
17
+ out[table] = cols;
18
+ }
19
+ return out;
20
+ }
21
+
22
+ export interface SchemaChange {
23
+ kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type";
24
+ table: string;
25
+ column?: string;
26
+ detail?: string;
27
+ /** true = rebuilds the table and may lose data (drop / type change); false =
28
+ * additive, no data loss. All changes are auto-applied on the next DO boot. */
29
+ destructive: boolean;
30
+ }
31
+
32
+ export function diffSchemaShape(prev: SchemaShape, next: SchemaShape): SchemaChange[] {
33
+ const changes: SchemaChange[] = [];
34
+
35
+ for (const table of Object.keys(next)) {
36
+ if (!(table in prev)) {
37
+ changes.push({ kind: "add-table", table, destructive: false });
38
+ continue;
39
+ }
40
+ for (const col of Object.keys(next[table]!)) {
41
+ if (!(col in prev[table]!)) {
42
+ changes.push({ kind: "add-column", table, column: col, destructive: false });
43
+ } else if (prev[table]![col] !== next[table]![col]) {
44
+ changes.push({ kind: "change-type", table, column: col, detail: `${prev[table]![col]} → ${next[table]![col]}`, destructive: true });
45
+ }
46
+ }
47
+ for (const col of Object.keys(prev[table]!)) {
48
+ if (!(col in next[table]!)) changes.push({ kind: "drop-column", table, column: col, destructive: true });
49
+ }
50
+ }
51
+
52
+ for (const table of Object.keys(prev)) {
53
+ if (!(table in next)) changes.push({ kind: "drop-table", table, destructive: true });
54
+ }
55
+
56
+ return changes;
57
+ }
Binary file
package/src/sdk/acl.ts ADDED
@@ -0,0 +1,196 @@
1
+ // ACL primitives — the portable definition layer: role(), policy(), allow(),
2
+ // deny(), $identity(). Resolution semantics live in runtime/acl.ts.
3
+ //
4
+ // Model: an Identity carries one or more roles. A policy grants a (role) access
5
+ // to an (entity, action), optionally restricted by a row-level `where` predicate
6
+ // and/or a set of permitted `fields`. Access is deny-by-default; policies only
7
+ // ever grant. Across the identity's roles, grants OR-merge (any role can allow).
8
+
9
+ export type Action = "read" | "create" | "update" | "delete";
10
+
11
+ /** Runtime identity. Augment with your own properties (userId, tier, …). */
12
+ export interface Identity {
13
+ role?: string;
14
+ roles?: string[];
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ // --- $identity markers: reference an identity property inside a where rule ---
19
+
20
+ const IDENTITY_MARKER = Symbol.for("pramen.identityMarker");
21
+
22
+ export interface IdentityMarker {
23
+ readonly [IDENTITY_MARKER]: true;
24
+ readonly path: string;
25
+ }
26
+
27
+ /** Reference an identity property in a policy `where`, resolved per request. */
28
+ export function $identity(path: string): IdentityMarker {
29
+ return { [IDENTITY_MARKER]: true, path };
30
+ }
31
+
32
+ export function isIdentityMarker(v: unknown): v is IdentityMarker {
33
+ return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[IDENTITY_MARKER] === true;
34
+ }
35
+
36
+ // --- $input markers: reference a request-input field inside a where rule, for a
37
+ // capability / by-unguessable-key grant (possessing the value IS the authorization).
38
+
39
+ const INPUT_MARKER = Symbol.for("pramen.inputMarker");
40
+
41
+ export interface InputMarker {
42
+ readonly [INPUT_MARKER]: true;
43
+ readonly path: string;
44
+ }
45
+
46
+ /** Reference a request-input field in a policy `where`, resolved per request. The
47
+ * grant matches only the row(s) whose column equals the supplied value — so a
48
+ * caller can read a row only by presenting its unguessable key, without being able
49
+ * to enumerate. An absent input value makes the rule match nothing (safe deny). */
50
+ export function $input(path: string): InputMarker {
51
+ return { [INPUT_MARKER]: true, path };
52
+ }
53
+
54
+ export function isInputMarker(v: unknown): v is InputMarker {
55
+ return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[INPUT_MARKER] === true;
56
+ }
57
+
58
+ // --- allow / deny markers ---
59
+
60
+ export interface AllowMarker {
61
+ readonly kind: "allow";
62
+ }
63
+ export interface DenyMarker {
64
+ readonly kind: "deny";
65
+ }
66
+
67
+ export function allow(): AllowMarker {
68
+ return { kind: "allow" };
69
+ }
70
+ export function deny(): DenyMarker {
71
+ return { kind: "deny" };
72
+ }
73
+
74
+ // --- policy rules ---
75
+
76
+ /** A where rule: column -> value, where value may be a literal or an $identity marker. */
77
+ export type WhereRule = Record<string, unknown | IdentityMarker>;
78
+
79
+ /** A per-row (cell-level) field grant: `fields` are permitted only for rows that
80
+ * match `when`. Additive over the policy's flat `fields` — a conditional grant can
81
+ * only ever ADD fields, never remove them. */
82
+ export interface ConditionalFields {
83
+ fields: string[];
84
+ /** Row-predicate, same surface as `where` (operators, AND/OR, $identity markers). */
85
+ when: WhereRule;
86
+ }
87
+
88
+ /** Escape hatch for cell-level ACL: a late per-row resolver. Given the identity and
89
+ * the fetched (or candidate, on write) row, returns the extra permitted fields —
90
+ * additive over `fields`; `null` means all fields for that row. */
91
+ export type FieldsFn = (identity: Identity | null, row: Record<string, unknown>) => string[] | null;
92
+
93
+ /** Per-relation ACL inside a parent read policy. */
94
+ export interface RelationAclRule {
95
+ /** Permit traversal to the related entity via this relation even if it has no
96
+ * flat read grant (directAccess). */
97
+ directAccess?: boolean;
98
+ /** Extra row-level predicate applied when traversing. */
99
+ where?: WhereRule;
100
+ /** Restrict fields visible through the relation. */
101
+ fields?: string[];
102
+ /** Per-row field grants applied to traversed rows. Additive over `fields`. */
103
+ conditionalFields?: ConditionalFields[];
104
+ /** Late per-row field resolver for traversed rows. Additive over `fields`. */
105
+ fieldsFn?: FieldsFn;
106
+ }
107
+
108
+ /** A forced column value on write: a literal, or computed from the identity. */
109
+ export type SetValue = unknown | ((identity: Identity | null) => unknown);
110
+
111
+ /** Server-side validation on write; throw to reject. Runs on the final values. */
112
+ export type Validator = (args: { identity: Identity | null; values: Record<string, unknown> }) => void;
113
+
114
+ export interface PolicyRules {
115
+ /** Row-level predicate (AND of equalities). Omit/empty = all rows. */
116
+ where?: WhereRule;
117
+ /** Permitted fields. Omit = all fields. On read = projection; on write = settable columns. */
118
+ fields?: string[];
119
+ /** Cell-level (per-row) field grants applied only to rows matching `when`.
120
+ * Additive over `fields`. On read = projection; on write = settable columns —
121
+ * evaluated against the candidate (insert) or post-merge (update) row. */
122
+ conditionalFields?: ConditionalFields[];
123
+ /** Escape hatch: a late per-row field resolver. Additive over `fields`. */
124
+ fieldsFn?: FieldsFn;
125
+ /** Per-relation traversal rules (see RelationAclRule). */
126
+ relations?: Record<string, RelationAclRule>;
127
+ /** Columns forced to server-controlled values on write (override client input,
128
+ * bypass field restriction). E.g. `{ ownerId: (i) => i?.userId }`. */
129
+ set?: Record<string, SetValue>;
130
+ /** Server-side validation; throw to reject. Sees the final (post-`set`) values. */
131
+ validate?: Validator;
132
+ }
133
+
134
+ // --- dynamic resolvers: a policy whose rule is computed per request ---
135
+
136
+ /** Read surface given to a resolver — runs in SYSTEM mode (bypasses ACL), so a
137
+ * resolver can consult the DB to decide access without recursing into itself. */
138
+ export interface ResolverDb {
139
+ find(spec: {
140
+ from: string;
141
+ where?: Record<string, unknown>;
142
+ orderBy?: { column: string; dir?: "asc" | "desc" };
143
+ limit?: number;
144
+ }): Promise<Array<Record<string, unknown>>>;
145
+ }
146
+
147
+ export interface ResolverContext {
148
+ readonly identity: Identity | null;
149
+ readonly db: ResolverDb;
150
+ }
151
+
152
+ export type ResolverFn = (ctx: ResolverContext) => PolicyRule | Promise<PolicyRule>;
153
+
154
+ export interface ResolverMarker {
155
+ readonly kind: "resolver";
156
+ readonly id: number;
157
+ readonly fn: ResolverFn;
158
+ }
159
+
160
+ let resolverCounter = 0;
161
+ /** A policy rule evaluated once per request during warmup; returns allow/deny/rules. */
162
+ export function resolve(fn: ResolverFn): ResolverMarker {
163
+ return { kind: "resolver", id: resolverCounter++, fn };
164
+ }
165
+
166
+ export type PolicyRule = AllowMarker | DenyMarker | PolicyRules | ResolverMarker;
167
+
168
+ export interface Policy {
169
+ readonly name: string;
170
+ readonly entity: string;
171
+ readonly action: Action;
172
+ readonly rule: PolicyRule;
173
+ }
174
+
175
+ export function policy(name: string, entity: string, action: Action, rule: PolicyRule): Policy {
176
+ return { name, entity, action, rule };
177
+ }
178
+
179
+ export interface Role {
180
+ readonly name: string;
181
+ readonly policies: Policy[];
182
+ }
183
+
184
+ export function role(name: string, policies: Policy[]): Role {
185
+ return { name, policies };
186
+ }
187
+
188
+ export function isAllow(r: PolicyRule): r is AllowMarker {
189
+ return (r as AllowMarker).kind === "allow";
190
+ }
191
+ export function isDeny(r: PolicyRule): r is DenyMarker {
192
+ return (r as DenyMarker).kind === "deny";
193
+ }
194
+ export function isResolver(r: PolicyRule): r is ResolverMarker {
195
+ return (r as ResolverMarker).kind === "resolver";
196
+ }
package/src/sdk/app.ts ADDED
@@ -0,0 +1,25 @@
1
+ // createApp — binds the handler factories to a concrete schema so `ctx.db` is
2
+ // fully typed (table names, where columns/values, row results, insert shapes).
3
+ //
4
+ // const schema = defineSchema({ notes: Entity(t => ({ ... })) });
5
+ // const { query, mutation } = createApp(schema);
6
+ // const listNotes = query((ctx) => ctx.db.find({ from: "notes" })); // typed!
7
+
8
+ import type { Handler, HandlerContext, HandlerOpts } from "./handlers";
9
+ import type { SchemaDef } from "./schema";
10
+
11
+ export function createApp<S extends SchemaDef>(schema: S) {
12
+ type Ctx = HandlerContext<S>;
13
+
14
+ const query = <I = unknown, O = unknown>(
15
+ run: (ctx: Ctx, input: I) => O | Promise<O>,
16
+ opts?: HandlerOpts<I>,
17
+ ): Handler<I, O> => ({ kind: "query", run: run as Handler<I, O>["run"], input: opts?.input });
18
+
19
+ const mutation = <I = unknown, O = unknown>(
20
+ run: (ctx: Ctx, input: I) => O | Promise<O>,
21
+ opts?: HandlerOpts<I>,
22
+ ): Handler<I, O> => ({ kind: "mutation", run: run as Handler<I, O>["run"], input: opts?.input });
23
+
24
+ return { schema, query, mutation };
25
+ }
@@ -0,0 +1,53 @@
1
+ // File storage — the portable type surface. The `fileRef` column type and the
2
+ // `ctx.files` facade handlers use. Pure types (no platform code); the Cloudflare
3
+ // glue (R2 adapter, signing, the Worker /files endpoint) lives in runtime/storage.ts.
4
+
5
+ /** What a `fileRef` column holds: small JSON metadata, never the bytes. */
6
+ export interface FileRef {
7
+ /** Object-store key (tenant-scoped, e.g. "acme/ab12…"). */
8
+ key: string;
9
+ /** Size in bytes (0 until the upload is confirmed via files.head()). */
10
+ size: number;
11
+ contentType: string;
12
+ /** Original upload filename, used for Content-Disposition on download. */
13
+ filename?: string;
14
+ /** Epoch ms the ref was last written/confirmed. */
15
+ uploadedAt?: number;
16
+ }
17
+
18
+ /** Object-store metadata for a stored blob. */
19
+ export interface HeadResult {
20
+ size: number;
21
+ contentType?: string;
22
+ }
23
+
24
+ export interface SignUploadOpts {
25
+ contentType: string;
26
+ filename?: string;
27
+ /** Max accepted upload size in bytes (enforced at the Worker). */
28
+ maxSize?: number;
29
+ /** URL lifetime in seconds (default 900 = 15 min). */
30
+ expiresIn?: number;
31
+ /** Optional key prefix segment under the tenant (e.g. "avatars"). */
32
+ prefix?: string;
33
+ }
34
+
35
+ export interface SignDownloadOpts {
36
+ /** URL lifetime in seconds (default 3600 = 1 hour). */
37
+ expiresIn?: number;
38
+ /** Force a download (Content-Disposition: attachment) vs inline. */
39
+ download?: boolean;
40
+ }
41
+
42
+ /** The per-tenant file facade handed to handlers as `ctx.files`. */
43
+ export interface Files {
44
+ /** Mint a tenant-scoped key + a signed PUT url for a direct-to-store upload. */
45
+ signUpload(opts: SignUploadOpts): Promise<{ url: string; ref: FileRef }>;
46
+ /** Mint a signed GET url for an existing blob. Call only after an ACL'd read of
47
+ * the owning row — knowing a key is not, by itself, authorization. */
48
+ signDownload(ref: FileRef | string, opts?: SignDownloadOpts): Promise<{ url: string; expiresAt: number }>;
49
+ /** Object-store metadata (size/contentType), or null if the blob is absent. */
50
+ head(key: string): Promise<HeadResult | null>;
51
+ /** Delete a blob (lifecycle / cascade). */
52
+ delete(key: string): Promise<void>;
53
+ }
@@ -0,0 +1,65 @@
1
+ // Handler factories — `query()` and `mutation()`. A query reads; a mutation is
2
+ // wrapped in BEGIN/COMMIT by the dispatcher and
3
+ // rolls back on throw (see runtime/dispatch.ts).
4
+
5
+ import type { Db } from "../runtime/db";
6
+ import type { Kv } from "../runtime/kv";
7
+ import type { Identity } from "./acl";
8
+ import type { Files } from "./files";
9
+ import type { SchemaDef } from "./schema";
10
+
11
+ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
12
+ /** Schema-typed repository: find/insert/update/delete inferred from S. */
13
+ readonly db: Db<S>;
14
+ /** Project KV — global (cross-tenant) config/flags/cache. Not per-tenant
15
+ * (that's db) and not transactional. */
16
+ readonly kv: Kv;
17
+ /** Per-tenant file storage: mint signed upload/download urls, head/delete blobs.
18
+ * Bytes flow through the Worker /files/* route, never through the DO. */
19
+ readonly files: Files;
20
+ /** The Worker/DO environment — bindings (KV, R2, DB, …) plus vars and secrets
21
+ * (AUTH_SECRET, plus anything in wrangler.jsonc / .dev.vars / `wrangler secret`).
22
+ * Use it to call external APIs from handlers (Stripe, Resend, …). Loosely typed;
23
+ * cast a value at the use site, e.g. `ctx.env.STRIPE_SECRET_KEY as string`. */
24
+ readonly env: Readonly<Record<string, unknown>>;
25
+ /** Resolved identity for this request (null = anonymous). */
26
+ readonly identity: Identity | null;
27
+ }
28
+
29
+ export type HandlerKind = "query" | "mutation";
30
+
31
+ export interface Handler<I = unknown, O = unknown> {
32
+ readonly kind: HandlerKind;
33
+ // Stored handlers are schema-agnostic; createApp() binds the typed surface.
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
36
+ /** Optional boundary validator: parse/validate the raw request input, throwing
37
+ * to reject (surfaced as a 400). Its return type fixes the handler's input. */
38
+ readonly input?: (raw: unknown) => unknown;
39
+ }
40
+
41
+ export interface HandlerOpts<I> {
42
+ input?: (raw: unknown) => I;
43
+ }
44
+
45
+ // Standalone (schema-agnostic) handler factories. Prefer createApp(schema) for a
46
+ // typed ctx.db; these remain for untyped/ad-hoc use.
47
+ export function query<I = unknown, O = unknown>(
48
+ run: (ctx: HandlerContext, input: I) => O | Promise<O>,
49
+ opts?: HandlerOpts<I>,
50
+ ): Handler<I, O> {
51
+ return { kind: "query", run, input: opts?.input };
52
+ }
53
+
54
+ export function mutation<I = unknown, O = unknown>(
55
+ run: (ctx: HandlerContext, input: I) => O | Promise<O>,
56
+ opts?: HandlerOpts<I>,
57
+ ): Handler<I, O> {
58
+ return { kind: "mutation", run, input: opts?.input };
59
+ }
60
+
61
+ // Registry of handlers keyed by RPC name. Uses `any` for the per-handler input/
62
+ // output so handlers with concrete, differing signatures remain assignable
63
+ // (a precise union would break under contravariance).
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ export type HandlerMap = Record<string, Handler<any, any>>;