@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,219 @@
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
+ // In-process value coercion for evalExpr (SQLite-style booleans). SQL-side encoding
9
+ // goes through the active Dialect; this mirrors it for the cell-ACL `when` evaluator.
10
+ function bind(v) {
11
+ return typeof v === "boolean" ? (v ? 1 : 0) : v;
12
+ }
13
+ export const TRUE = { t: "true" };
14
+ export const FALSE = { t: "false" };
15
+ export const cmp = (op, col, value) => ({ t: "cmp", op, col, value });
16
+ export const isNull = (col, negate = false) => ({ t: "null", col, negate });
17
+ export const inList = (col, values, negate = false) => ({ t: "in", col, values, negate });
18
+ export const and = (...parts) => ({ t: "and", parts });
19
+ export const or = (...parts) => ({ t: "or", parts });
20
+ /** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
21
+ export const eq = (col, value) => (value === null ? isNull(col) : cmp("=", col, value));
22
+ /** Compile a structured user predicate into a SqlExpr.
23
+ * Shapes: { col: value } (eq) | { col: { gt, lt, in, like, isNull, … } } | { AND: [...] } | { OR: [...] } */
24
+ export function compileWhere(input) {
25
+ const parts = [];
26
+ for (const [k, v] of Object.entries(input)) {
27
+ if (k === "AND") {
28
+ parts.push(and(...v.map(compileWhere)));
29
+ }
30
+ else if (k === "OR") {
31
+ parts.push(or(...v.map(compileWhere)));
32
+ }
33
+ else {
34
+ parts.push(columnPredicate(k, v));
35
+ }
36
+ }
37
+ return parts.length ? and(...parts) : TRUE;
38
+ }
39
+ function columnPredicate(col, v) {
40
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
41
+ const ops = [];
42
+ for (const [op, val] of Object.entries(v)) {
43
+ switch (op) {
44
+ case "eq":
45
+ ops.push(eq(col, val));
46
+ break;
47
+ case "ne":
48
+ ops.push(val === null ? isNull(col, true) : cmp("!=", col, val));
49
+ break;
50
+ case "gt":
51
+ ops.push(cmp(">", col, val));
52
+ break;
53
+ case "gte":
54
+ ops.push(cmp(">=", col, val));
55
+ break;
56
+ case "lt":
57
+ ops.push(cmp("<", col, val));
58
+ break;
59
+ case "lte":
60
+ ops.push(cmp("<=", col, val));
61
+ break;
62
+ case "like":
63
+ ops.push(cmp("LIKE", col, val));
64
+ break;
65
+ case "in":
66
+ ops.push(inList(col, val));
67
+ break;
68
+ case "notIn":
69
+ ops.push(inList(col, val, true));
70
+ break;
71
+ case "isNull":
72
+ ops.push(isNull(col, !val));
73
+ break; // isNull:true => IS NULL
74
+ default: throw new Error(`unknown operator: ${op}`);
75
+ }
76
+ }
77
+ return ops.length ? and(...ops) : TRUE;
78
+ }
79
+ return eq(col, v);
80
+ }
81
+ export function compileExpr(expr, dialect, params = []) {
82
+ switch (expr.t) {
83
+ case "true":
84
+ return { sql: "1", params };
85
+ case "false":
86
+ return { sql: "0", params };
87
+ case "cmp":
88
+ if (expr.value === null)
89
+ return { sql: `${dialect.id(expr.col)} IS NULL`, params };
90
+ params.push(dialect.encode(expr.value));
91
+ return { sql: `${dialect.id(expr.col)} ${expr.op} ${dialect.placeholder(params.length)}`, params };
92
+ case "null":
93
+ return { sql: `${dialect.id(expr.col)} IS ${expr.negate ? "NOT " : ""}NULL`, params };
94
+ case "in": {
95
+ if (expr.values.length === 0)
96
+ 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)
103
+ return { sql: expr.t === "and" ? "1" : "0", params };
104
+ const sep = expr.t === "and" ? " AND " : " OR ";
105
+ const sql = expr.parts.map((p) => compileExpr(p, dialect, params).sql).join(sep);
106
+ return { sql: expr.parts.length > 1 ? `(${sql})` : sql, params };
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) {
113
+ let re = "";
114
+ for (const ch of pattern) {
115
+ if (ch === "%")
116
+ re += ".*";
117
+ else if (ch === "_")
118
+ re += ".";
119
+ else
120
+ re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
121
+ }
122
+ return new RegExp(`^${re}$`, "is");
123
+ }
124
+ /** Evaluate a compiled predicate against an in-memory row. Mirrors compileExpr's
125
+ * semantics (bound-boolean coercion, NULL compares false, empty-IN, LIKE) so the
126
+ * declarative cell-ACL `when` path can decide per-row field visibility without a
127
+ * round-trip to SQLite. */
128
+ export function evalExpr(expr, row) {
129
+ switch (expr.t) {
130
+ case "true":
131
+ return true;
132
+ case "false":
133
+ return false;
134
+ case "cmp": {
135
+ const left = bind(row[expr.col]);
136
+ if (expr.value === null)
137
+ return left === null || left === undefined;
138
+ if (left === null || left === undefined)
139
+ return false; // NULL compared to a value -> false
140
+ const right = bind(expr.value);
141
+ switch (expr.op) {
142
+ case "=": return left === right;
143
+ case "!=": return left !== right;
144
+ case ">": return left > right;
145
+ case ">=": return left >= right;
146
+ case "<": return left < right;
147
+ case "<=": return left <= right;
148
+ case "LIKE": return typeof left === "string" && likeToRegex(String(right)).test(left);
149
+ }
150
+ return false;
151
+ }
152
+ case "null": {
153
+ const v = row[expr.col];
154
+ const isNullVal = v === null || v === undefined;
155
+ return expr.negate ? !isNullVal : isNullVal;
156
+ }
157
+ case "in": {
158
+ const left = bind(row[expr.col]);
159
+ if (left === null || left === undefined)
160
+ return false; // can't demonstrate membership
161
+ if (expr.values.length === 0)
162
+ return expr.negate; // empty: notIn=>all, in=>none
163
+ const found = expr.values.some((v) => bind(v) === left);
164
+ return expr.negate ? !found : found;
165
+ }
166
+ case "and":
167
+ return expr.parts.every((p) => evalExpr(p, row));
168
+ case "or":
169
+ return expr.parts.some((p) => evalExpr(p, row));
170
+ }
171
+ }
172
+ const AGG_SQL = { count: "COUNT", sum: "SUM", avg: "AVG", min: "MIN", max: "MAX" };
173
+ export function compileCount(from, dialect, where) {
174
+ const params = [];
175
+ let sql = `SELECT COUNT(*) AS n FROM ${dialect.id(from)}`;
176
+ if (where && where.t !== "true")
177
+ sql += ` WHERE ${compileExpr(where, dialect, params).sql}`;
178
+ return { sql, params };
179
+ }
180
+ export function compileAggregate(spec, dialect) {
181
+ const params = [];
182
+ const cols = [];
183
+ for (const g of spec.groupBy ?? [])
184
+ cols.push(dialect.id(g));
185
+ for (const [key, agg] of Object.entries(spec.aggregations)) {
186
+ const target = agg.column != null ? dialect.id(agg.column) : "*";
187
+ cols.push(`${AGG_SQL[agg.fn]}(${target}) AS ${dialect.id(key)}`);
188
+ }
189
+ let sql = `SELECT ${cols.join(", ")} FROM ${dialect.id(spec.from)}`;
190
+ if (spec.where && spec.where.t !== "true")
191
+ sql += ` WHERE ${compileExpr(spec.where, dialect, params).sql}`;
192
+ if (spec.groupBy && spec.groupBy.length > 0)
193
+ sql += ` GROUP BY ${spec.groupBy.map((g) => dialect.id(g)).join(", ")}`;
194
+ return { sql, params };
195
+ }
196
+ export function compileSelect(spec, dialect) {
197
+ const params = [];
198
+ let sql = `SELECT * FROM ${dialect.id(spec.from)}`;
199
+ if (spec.where && spec.where.t !== "true") {
200
+ const { sql: where } = compileExpr(spec.where, dialect, params);
201
+ sql += ` WHERE ${where}`;
202
+ }
203
+ if (spec.orderBy && spec.orderBy.length > 0) {
204
+ const cols = spec.orderBy.map((o) => `${dialect.id(o.column)} ${o.dir === "desc" ? "DESC" : "ASC"}`);
205
+ sql += ` ORDER BY ${cols.join(", ")}`;
206
+ }
207
+ if (spec.limit != null) {
208
+ params.push(spec.limit);
209
+ sql += ` LIMIT ${dialect.placeholder(params.length)}`;
210
+ }
211
+ else if (spec.offset != null) {
212
+ sql += " LIMIT -1"; // SQLite requires a LIMIT before OFFSET (offset-only is a SQLite-ism)
213
+ }
214
+ if (spec.offset != null) {
215
+ params.push(spec.offset);
216
+ sql += ` OFFSET ${dialect.placeholder(params.length)}`;
217
+ }
218
+ return { sql, params };
219
+ }
@@ -0,0 +1,14 @@
1
+ import type { SchemaDef } from "../sdk/schema";
2
+ /** table -> column -> field type. The comparable surface of a schema. */
3
+ export type SchemaShape = Record<string, Record<string, string>>;
4
+ export declare function schemaShape(schema: SchemaDef): SchemaShape;
5
+ export interface SchemaChange {
6
+ kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type";
7
+ table: string;
8
+ column?: string;
9
+ detail?: string;
10
+ /** true = rebuilds the table and may lose data (drop / type change); false =
11
+ * additive, no data loss. All changes are auto-applied on the next DO boot. */
12
+ destructive: boolean;
13
+ }
14
+ export declare function diffSchemaShape(prev: SchemaShape, next: SchemaShape): SchemaChange[];
@@ -0,0 +1,41 @@
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
+ export function schemaShape(schema) {
7
+ const out = {};
8
+ for (const [table, def] of Object.entries(schema)) {
9
+ const cols = {};
10
+ for (const [col, f] of Object.entries(def.fields))
11
+ cols[col] = f.type;
12
+ out[table] = cols;
13
+ }
14
+ return out;
15
+ }
16
+ export function diffSchemaShape(prev, next) {
17
+ const changes = [];
18
+ for (const table of Object.keys(next)) {
19
+ if (!(table in prev)) {
20
+ changes.push({ kind: "add-table", table, destructive: false });
21
+ continue;
22
+ }
23
+ for (const col of Object.keys(next[table])) {
24
+ if (!(col in prev[table])) {
25
+ changes.push({ kind: "add-column", table, column: col, destructive: false });
26
+ }
27
+ else if (prev[table][col] !== next[table][col]) {
28
+ changes.push({ kind: "change-type", table, column: col, detail: `${prev[table][col]} → ${next[table][col]}`, destructive: true });
29
+ }
30
+ }
31
+ for (const col of Object.keys(prev[table])) {
32
+ if (!(col in next[table]))
33
+ changes.push({ kind: "drop-column", table, column: col, destructive: true });
34
+ }
35
+ }
36
+ for (const table of Object.keys(prev)) {
37
+ if (!(table in next))
38
+ changes.push({ kind: "drop-table", table, destructive: true });
39
+ }
40
+ return changes;
41
+ }
@@ -0,0 +1,74 @@
1
+ import type { Files, HeadResult } from "../sdk/files";
2
+ export type { Files, FileRef, HeadResult, SignDownloadOpts, SignUploadOpts } from "../sdk/files";
3
+ export interface PutResult {
4
+ key: string;
5
+ size: number;
6
+ etag?: string;
7
+ }
8
+ export interface GetResult {
9
+ body: ReadableStream;
10
+ size: number;
11
+ contentType?: string;
12
+ }
13
+ export interface StorageAdapter {
14
+ put(key: string, body: ReadableStream | ArrayBuffer | Uint8Array | null, opts?: {
15
+ contentType?: string;
16
+ }): Promise<PutResult>;
17
+ get(key: string): Promise<GetResult | null>;
18
+ head(key: string): Promise<HeadResult | null>;
19
+ delete(key: string): Promise<void>;
20
+ }
21
+ /** R2 — the Cloudflare default. Wraps an R2 bucket binding. Streaming: bytes flow
22
+ * directly between the client and R2 in the Worker, never through the DO. */
23
+ export declare class R2Adapter implements StorageAdapter {
24
+ private readonly bucket;
25
+ constructor(bucket: R2Bucket);
26
+ put(key: string, body: ReadableStream | ArrayBuffer | Uint8Array | null, opts?: {
27
+ contentType?: string;
28
+ }): Promise<PutResult>;
29
+ get(key: string): Promise<GetResult | null>;
30
+ head(key: string): Promise<HeadResult | null>;
31
+ delete(key: string): Promise<void>;
32
+ }
33
+ /** In-memory store for unit tests / substrates without an object store. Not durable. */
34
+ export declare class MemoryAdapter implements StorageAdapter {
35
+ private readonly store;
36
+ put(key: string, body: ReadableStream | ArrayBuffer | Uint8Array | null, opts?: {
37
+ contentType?: string;
38
+ }): Promise<PutResult>;
39
+ get(key: string): Promise<GetResult | null>;
40
+ head(key: string): Promise<HeadResult | null>;
41
+ delete(key: string): Promise<void>;
42
+ }
43
+ interface FileToken {
44
+ /** tenant */ t: string;
45
+ /** key */ k: string;
46
+ /** op */ op: "get" | "put";
47
+ /** expiry (epoch seconds) */ exp: number;
48
+ /** content-type (put: enforced; get: disposition hint) */ ct?: string;
49
+ /** max size in bytes (put only) */ max?: number;
50
+ /** filename (download disposition) */ fn?: string;
51
+ }
52
+ /** Verify a file token's signature + expiry; returns the payload or null. */
53
+ export declare function verifyToken(raw: string, secret: string): Promise<FileToken | null>;
54
+ export interface FilesConfig {
55
+ tenant: string;
56
+ secret: string;
57
+ adapter: StorageAdapter;
58
+ /** Worker base path for file endpoints (default "/files"). */
59
+ basePath?: string;
60
+ }
61
+ /** A file token secret must be present and non-trivial, else upload/download tokens
62
+ * would be forgeable (HMAC over an empty/weak key). Below this, file storage is
63
+ * treated as unconfigured — fail closed rather than mint forgeable urls. The dev
64
+ * defaults satisfy it; production should set a strong, random FILES_SECRET. */
65
+ export declare const MIN_FILES_SECRET_LEN = 16;
66
+ export declare function isUsableFilesSecret(secret: string | undefined | null): secret is string;
67
+ /** Construct the per-tenant `ctx.files` facade. */
68
+ export declare function createFiles(cfg: FilesConfig): Files;
69
+ /** Serve the file endpoints. Returns a Response for any `/files/*` path, or null
70
+ * if the request is not a file request (so the caller can keep routing). */
71
+ export declare function handleFileRequest(request: Request, opts: {
72
+ adapter: StorageAdapter;
73
+ secret: string;
74
+ }): Promise<Response | null>;
Binary file
@@ -0,0 +1,130 @@
1
+ export type Action = "read" | "create" | "update" | "delete";
2
+ /** Runtime identity. Augment with your own properties (userId, tier, …). */
3
+ export interface Identity {
4
+ role?: string;
5
+ roles?: string[];
6
+ [key: string]: unknown;
7
+ }
8
+ declare const IDENTITY_MARKER: unique symbol;
9
+ export interface IdentityMarker {
10
+ readonly [IDENTITY_MARKER]: true;
11
+ readonly path: string;
12
+ }
13
+ /** Reference an identity property in a policy `where`, resolved per request. */
14
+ export declare function $identity(path: string): IdentityMarker;
15
+ export declare function isIdentityMarker(v: unknown): v is IdentityMarker;
16
+ declare const INPUT_MARKER: unique symbol;
17
+ export interface InputMarker {
18
+ readonly [INPUT_MARKER]: true;
19
+ readonly path: string;
20
+ }
21
+ /** Reference a request-input field in a policy `where`, resolved per request. The
22
+ * grant matches only the row(s) whose column equals the supplied value — so a
23
+ * caller can read a row only by presenting its unguessable key, without being able
24
+ * to enumerate. An absent input value makes the rule match nothing (safe deny). */
25
+ export declare function $input(path: string): InputMarker;
26
+ export declare function isInputMarker(v: unknown): v is InputMarker;
27
+ export interface AllowMarker {
28
+ readonly kind: "allow";
29
+ }
30
+ export interface DenyMarker {
31
+ readonly kind: "deny";
32
+ }
33
+ export declare function allow(): AllowMarker;
34
+ export declare function deny(): DenyMarker;
35
+ /** A where rule: column -> value, where value may be a literal or an $identity marker. */
36
+ export type WhereRule = Record<string, unknown | IdentityMarker>;
37
+ /** A per-row (cell-level) field grant: `fields` are permitted only for rows that
38
+ * match `when`. Additive over the policy's flat `fields` — a conditional grant can
39
+ * only ever ADD fields, never remove them. */
40
+ export interface ConditionalFields {
41
+ fields: string[];
42
+ /** Row-predicate, same surface as `where` (operators, AND/OR, $identity markers). */
43
+ when: WhereRule;
44
+ }
45
+ /** Escape hatch for cell-level ACL: a late per-row resolver. Given the identity and
46
+ * the fetched (or candidate, on write) row, returns the extra permitted fields —
47
+ * additive over `fields`; `null` means all fields for that row. */
48
+ export type FieldsFn = (identity: Identity | null, row: Record<string, unknown>) => string[] | null;
49
+ /** Per-relation ACL inside a parent read policy. */
50
+ export interface RelationAclRule {
51
+ /** Permit traversal to the related entity via this relation even if it has no
52
+ * flat read grant (directAccess). */
53
+ directAccess?: boolean;
54
+ /** Extra row-level predicate applied when traversing. */
55
+ where?: WhereRule;
56
+ /** Restrict fields visible through the relation. */
57
+ fields?: string[];
58
+ /** Per-row field grants applied to traversed rows. Additive over `fields`. */
59
+ conditionalFields?: ConditionalFields[];
60
+ /** Late per-row field resolver for traversed rows. Additive over `fields`. */
61
+ fieldsFn?: FieldsFn;
62
+ }
63
+ /** A forced column value on write: a literal, or computed from the identity. */
64
+ export type SetValue = unknown | ((identity: Identity | null) => unknown);
65
+ /** Server-side validation on write; throw to reject. Runs on the final values. */
66
+ export type Validator = (args: {
67
+ identity: Identity | null;
68
+ values: Record<string, unknown>;
69
+ }) => void;
70
+ export interface PolicyRules {
71
+ /** Row-level predicate (AND of equalities). Omit/empty = all rows. */
72
+ where?: WhereRule;
73
+ /** Permitted fields. Omit = all fields. On read = projection; on write = settable columns. */
74
+ fields?: string[];
75
+ /** Cell-level (per-row) field grants applied only to rows matching `when`.
76
+ * Additive over `fields`. On read = projection; on write = settable columns —
77
+ * evaluated against the candidate (insert) or post-merge (update) row. */
78
+ conditionalFields?: ConditionalFields[];
79
+ /** Escape hatch: a late per-row field resolver. Additive over `fields`. */
80
+ fieldsFn?: FieldsFn;
81
+ /** Per-relation traversal rules (see RelationAclRule). */
82
+ relations?: Record<string, RelationAclRule>;
83
+ /** Columns forced to server-controlled values on write (override client input,
84
+ * bypass field restriction). E.g. `{ ownerId: (i) => i?.userId }`. */
85
+ set?: Record<string, SetValue>;
86
+ /** Server-side validation; throw to reject. Sees the final (post-`set`) values. */
87
+ validate?: Validator;
88
+ }
89
+ /** Read surface given to a resolver — runs in SYSTEM mode (bypasses ACL), so a
90
+ * resolver can consult the DB to decide access without recursing into itself. */
91
+ export interface ResolverDb {
92
+ find(spec: {
93
+ from: string;
94
+ where?: Record<string, unknown>;
95
+ orderBy?: {
96
+ column: string;
97
+ dir?: "asc" | "desc";
98
+ };
99
+ limit?: number;
100
+ }): Promise<Array<Record<string, unknown>>>;
101
+ }
102
+ export interface ResolverContext {
103
+ readonly identity: Identity | null;
104
+ readonly db: ResolverDb;
105
+ }
106
+ export type ResolverFn = (ctx: ResolverContext) => PolicyRule | Promise<PolicyRule>;
107
+ export interface ResolverMarker {
108
+ readonly kind: "resolver";
109
+ readonly id: number;
110
+ readonly fn: ResolverFn;
111
+ }
112
+ /** A policy rule evaluated once per request during warmup; returns allow/deny/rules. */
113
+ export declare function resolve(fn: ResolverFn): ResolverMarker;
114
+ export type PolicyRule = AllowMarker | DenyMarker | PolicyRules | ResolverMarker;
115
+ export interface Policy {
116
+ readonly name: string;
117
+ readonly entity: string;
118
+ readonly action: Action;
119
+ readonly rule: PolicyRule;
120
+ }
121
+ export declare function policy(name: string, entity: string, action: Action, rule: PolicyRule): Policy;
122
+ export interface Role {
123
+ readonly name: string;
124
+ readonly policies: Policy[];
125
+ }
126
+ export declare function role(name: string, policies: Policy[]): Role;
127
+ export declare function isAllow(r: PolicyRule): r is AllowMarker;
128
+ export declare function isDeny(r: PolicyRule): r is DenyMarker;
129
+ export declare function isResolver(r: PolicyRule): r is ResolverMarker;
130
+ export {};
@@ -0,0 +1,55 @@
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
+ // --- $identity markers: reference an identity property inside a where rule ---
9
+ const IDENTITY_MARKER = Symbol.for("pramen.identityMarker");
10
+ /** Reference an identity property in a policy `where`, resolved per request. */
11
+ export function $identity(path) {
12
+ return { [IDENTITY_MARKER]: true, path };
13
+ }
14
+ export function isIdentityMarker(v) {
15
+ return typeof v === "object" && v !== null && v[IDENTITY_MARKER] === true;
16
+ }
17
+ // --- $input markers: reference a request-input field inside a where rule, for a
18
+ // capability / by-unguessable-key grant (possessing the value IS the authorization).
19
+ const INPUT_MARKER = Symbol.for("pramen.inputMarker");
20
+ /** Reference a request-input field in a policy `where`, resolved per request. The
21
+ * grant matches only the row(s) whose column equals the supplied value — so a
22
+ * caller can read a row only by presenting its unguessable key, without being able
23
+ * to enumerate. An absent input value makes the rule match nothing (safe deny). */
24
+ export function $input(path) {
25
+ return { [INPUT_MARKER]: true, path };
26
+ }
27
+ export function isInputMarker(v) {
28
+ return typeof v === "object" && v !== null && v[INPUT_MARKER] === true;
29
+ }
30
+ export function allow() {
31
+ return { kind: "allow" };
32
+ }
33
+ export function deny() {
34
+ return { kind: "deny" };
35
+ }
36
+ let resolverCounter = 0;
37
+ /** A policy rule evaluated once per request during warmup; returns allow/deny/rules. */
38
+ export function resolve(fn) {
39
+ return { kind: "resolver", id: resolverCounter++, fn };
40
+ }
41
+ export function policy(name, entity, action, rule) {
42
+ return { name, entity, action, rule };
43
+ }
44
+ export function role(name, policies) {
45
+ return { name, policies };
46
+ }
47
+ export function isAllow(r) {
48
+ return r.kind === "allow";
49
+ }
50
+ export function isDeny(r) {
51
+ return r.kind === "deny";
52
+ }
53
+ export function isResolver(r) {
54
+ return r.kind === "resolver";
55
+ }
@@ -0,0 +1,7 @@
1
+ import type { Handler, HandlerContext, HandlerOpts } from "./handlers";
2
+ import type { SchemaDef } from "./schema";
3
+ export declare function createApp<S extends SchemaDef>(schema: S): {
4
+ schema: S;
5
+ query: <I = unknown, O = unknown>(run: (ctx: HandlerContext<S>, input: I) => O | Promise<O>, opts?: HandlerOpts<I>) => Handler<I, O>;
6
+ mutation: <I = unknown, O = unknown>(run: (ctx: HandlerContext<S>, input: I) => O | Promise<O>, opts?: HandlerOpts<I>) => Handler<I, O>;
7
+ };
@@ -0,0 +1,11 @@
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
+ 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 });
10
+ return { schema, query, mutation };
11
+ }
@@ -0,0 +1,51 @@
1
+ /** What a `fileRef` column holds: small JSON metadata, never the bytes. */
2
+ export interface FileRef {
3
+ /** Object-store key (tenant-scoped, e.g. "acme/ab12…"). */
4
+ key: string;
5
+ /** Size in bytes (0 until the upload is confirmed via files.head()). */
6
+ size: number;
7
+ contentType: string;
8
+ /** Original upload filename, used for Content-Disposition on download. */
9
+ filename?: string;
10
+ /** Epoch ms the ref was last written/confirmed. */
11
+ uploadedAt?: number;
12
+ }
13
+ /** Object-store metadata for a stored blob. */
14
+ export interface HeadResult {
15
+ size: number;
16
+ contentType?: string;
17
+ }
18
+ export interface SignUploadOpts {
19
+ contentType: string;
20
+ filename?: string;
21
+ /** Max accepted upload size in bytes (enforced at the Worker). */
22
+ maxSize?: number;
23
+ /** URL lifetime in seconds (default 900 = 15 min). */
24
+ expiresIn?: number;
25
+ /** Optional key prefix segment under the tenant (e.g. "avatars"). */
26
+ prefix?: string;
27
+ }
28
+ export interface SignDownloadOpts {
29
+ /** URL lifetime in seconds (default 3600 = 1 hour). */
30
+ expiresIn?: number;
31
+ /** Force a download (Content-Disposition: attachment) vs inline. */
32
+ download?: boolean;
33
+ }
34
+ /** The per-tenant file facade handed to handlers as `ctx.files`. */
35
+ export interface Files {
36
+ /** Mint a tenant-scoped key + a signed PUT url for a direct-to-store upload. */
37
+ signUpload(opts: SignUploadOpts): Promise<{
38
+ url: string;
39
+ ref: FileRef;
40
+ }>;
41
+ /** Mint a signed GET url for an existing blob. Call only after an ACL'd read of
42
+ * the owning row — knowing a key is not, by itself, authorization. */
43
+ signDownload(ref: FileRef | string, opts?: SignDownloadOpts): Promise<{
44
+ url: string;
45
+ expiresAt: number;
46
+ }>;
47
+ /** Object-store metadata (size/contentType), or null if the blob is absent. */
48
+ head(key: string): Promise<HeadResult | null>;
49
+ /** Delete a blob (lifecycle / cascade). */
50
+ delete(key: string): Promise<void>;
51
+ }
@@ -0,0 +1,4 @@
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
+ export {};
@@ -0,0 +1,36 @@
1
+ import type { Db } from "../runtime/db";
2
+ import type { Kv } from "../runtime/kv";
3
+ import type { Identity } from "./acl";
4
+ import type { Files } from "./files";
5
+ import type { SchemaDef } from "./schema";
6
+ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
7
+ /** Schema-typed repository: find/insert/update/delete inferred from S. */
8
+ readonly db: Db<S>;
9
+ /** Project KV — global (cross-tenant) config/flags/cache. Not per-tenant
10
+ * (that's db) and not transactional. */
11
+ readonly kv: Kv;
12
+ /** Per-tenant file storage: mint signed upload/download urls, head/delete blobs.
13
+ * Bytes flow through the Worker /files/* route, never through the DO. */
14
+ readonly files: Files;
15
+ /** The Worker/DO environment — bindings (KV, R2, DB, …) plus vars and secrets
16
+ * (AUTH_SECRET, plus anything in wrangler.jsonc / .dev.vars / `wrangler secret`).
17
+ * Use it to call external APIs from handlers (Stripe, Resend, …). Loosely typed;
18
+ * cast a value at the use site, e.g. `ctx.env.STRIPE_SECRET_KEY as string`. */
19
+ readonly env: Readonly<Record<string, unknown>>;
20
+ /** Resolved identity for this request (null = anonymous). */
21
+ readonly identity: Identity | null;
22
+ }
23
+ export type HandlerKind = "query" | "mutation";
24
+ export interface Handler<I = unknown, O = unknown> {
25
+ readonly kind: HandlerKind;
26
+ readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
27
+ /** Optional boundary validator: parse/validate the raw request input, throwing
28
+ * to reject (surfaced as a 400). Its return type fixes the handler's input. */
29
+ readonly input?: (raw: unknown) => unknown;
30
+ }
31
+ export interface HandlerOpts<I> {
32
+ input?: (raw: unknown) => I;
33
+ }
34
+ export declare function query<I = unknown, O = unknown>(run: (ctx: HandlerContext, input: I) => O | Promise<O>, opts?: HandlerOpts<I>): Handler<I, O>;
35
+ export declare function mutation<I = unknown, O = unknown>(run: (ctx: HandlerContext, input: I) => O | Promise<O>, opts?: HandlerOpts<I>): Handler<I, O>;
36
+ export type HandlerMap = Record<string, Handler<any, any>>;
@@ -0,0 +1,11 @@
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
+ // Standalone (schema-agnostic) handler factories. Prefer createApp(schema) for a
5
+ // typed ctx.db; these remain for untyped/ad-hoc use.
6
+ export function query(run, opts) {
7
+ return { kind: "query", run, input: opts?.input };
8
+ }
9
+ export function mutation(run, opts) {
10
+ return { kind: "mutation", run, input: opts?.input };
11
+ }