@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,550 @@
1
+ // Db — the repository surface handed to handlers, wrapping the DO's in-process
2
+ // SqlStorage. This is the single ACL chokepoint — all reads go through the read
3
+ // engine: every find/insert/update/delete resolves a scope for the
4
+ // caller's identity and is denied, row-filtered, or field-projected accordingly.
5
+ //
6
+ // Generic over the app's schema S: method inputs and results are typed against
7
+ // the entity definitions (sdk/infer.ts). Types are erased at runtime — the body
8
+ // works in terms of plain strings and Rows.
9
+ //
10
+ // Every Db also records the tables it touched during one handler run (`touched`),
11
+ // which the live-query layer uses to decide which subscriptions to re-check.
12
+ // Create a fresh Db per handler run so identity and `touched` are scoped.
13
+
14
+ import {
15
+ AclDenied,
16
+ ALLOW_ALL,
17
+ effectiveFields,
18
+ projectRow,
19
+ resolveRelationScope,
20
+ resolveScope,
21
+ resolveWriteRules,
22
+ type AclContext,
23
+ type Scope,
24
+ } from "./acl";
25
+ import type { Validator } from "../sdk/acl";
26
+ import {
27
+ and,
28
+ cmp,
29
+ compileAggregate,
30
+ compileCount,
31
+ compileExpr,
32
+ compileSelect,
33
+ compileWhere,
34
+ eq,
35
+ inList,
36
+ or,
37
+ TRUE,
38
+ type AggFn,
39
+ type OrderBy,
40
+ type SqlExpr,
41
+ } from "./read-engine";
42
+ import { BadRequest } from "./errors";
43
+ import type { Dialect, Driver } from "./driver";
44
+ import type { EntityFields, FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
45
+ import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereInput } from "../sdk/infer";
46
+
47
+ type Row = Record<string, unknown>;
48
+ type Action = "read" | "create" | "update" | "delete";
49
+ type Id = string | number | bigint;
50
+ type Selected = Partial<Record<string, true>> | undefined;
51
+
52
+ const DEFAULT_PAGE_SIZE = 50;
53
+
54
+ function normalizeOrder(orderBy: unknown): OrderBy[] | undefined {
55
+ if (!orderBy) return undefined;
56
+ return (Array.isArray(orderBy) ? orderBy : [orderBy]) as OrderBy[];
57
+ }
58
+
59
+ type OrderSpec<S extends SchemaDef, T extends keyof S> = {
60
+ column: keyof FieldsOf<S[T]> & string;
61
+ dir?: "asc" | "desc";
62
+ };
63
+
64
+ export interface FindSpec<S extends SchemaDef, T extends keyof S> {
65
+ from: T;
66
+ where?: WhereInput<FieldsOf<S[T]>>;
67
+ orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
68
+ limit?: number;
69
+ offset?: number;
70
+ /** Eager-load relations. Each loaded relation is independently ACL-checked. */
71
+ with?: Partial<Record<keyof RelationsOf<S[T]> & string, true>>;
72
+ }
73
+
74
+ /** Cursor (keyset) pagination input — `after` is an opaque cursor from a prior page. */
75
+ export interface PageSpec<S extends SchemaDef, T extends keyof S> {
76
+ from: T;
77
+ where?: WhereInput<FieldsOf<S[T]>>;
78
+ orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
79
+ limit?: number;
80
+ after?: string;
81
+ with?: Partial<Record<keyof RelationsOf<S[T]> & string, true>>;
82
+ }
83
+
84
+ export interface Page<R> {
85
+ items: R[];
86
+ /** Opaque cursor for the last item — pass as `after` to fetch the next page. */
87
+ cursor: string | null;
88
+ hasMore: boolean;
89
+ }
90
+
91
+ /** The aggregations map of an aggregate spec, keyed by output column name. */
92
+ type Aggregations<F extends EntityFields> = Record<string, { fn: AggFn; column?: keyof F & string }>;
93
+
94
+ export interface AggregateSpec<S extends SchemaDef, T extends keyof S> {
95
+ from: T;
96
+ where?: WhereInput<FieldsOf<S[T]>>;
97
+ groupBy?: (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[];
98
+ aggregations: Aggregations<FieldsOf<S[T]>>;
99
+ }
100
+
101
+ /** One aggregate result row: loosely typed (any output column -> value). */
102
+ export type AggregateRow = Record<string, number | string | null>;
103
+
104
+ // --- precise aggregate result inference (groupBy keys + per-aggregation values) ---
105
+
106
+ type GroupKeys<G> = G extends readonly (infer K)[] ? K : G;
107
+
108
+ /** The value type of a single aggregation: count -> number; min/max -> the column's
109
+ * own type (nullable); sum/avg -> number | null. */
110
+ type AggValue<Fn extends AggFn, Col, F extends EntityFields> = Fn extends "count"
111
+ ? number
112
+ : Fn extends "min" | "max"
113
+ ? Col extends keyof F
114
+ ? Cell<F[Col]> | null
115
+ : number | null
116
+ : number | null;
117
+
118
+ /** A result row inferred from a (groupBy, aggregations) spec: group columns keep
119
+ * their schema type, each aggregation gets its computed value type. */
120
+ export type AggregateResult<F extends EntityFields, G, A extends Aggregations<F>> = {
121
+ [K in Extract<GroupKeys<G>, keyof F & string>]: Cell<F[K]>;
122
+ } & { [K in keyof A]: AggValue<A[K]["fn"], A[K]["column"], F> };
123
+
124
+ function encodeCursor(order: OrderBy[], row: Row): string {
125
+ const vals = order.map((o) => row[o.column]);
126
+ return btoa(JSON.stringify(vals)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
127
+ }
128
+
129
+ function decodeCursor(s: string): unknown[] {
130
+ try {
131
+ const arr = JSON.parse(atob(s.replace(/-/g, "+").replace(/_/g, "/")));
132
+ if (!Array.isArray(arr)) throw new Error("not an array");
133
+ return arr;
134
+ } catch {
135
+ throw new BadRequest("invalid cursor");
136
+ }
137
+ }
138
+
139
+ // Strictly-after predicate for a composite key: lexicographic comparison,
140
+ // e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <.
141
+ function keysetAfter(order: OrderBy[], values: unknown[]): SqlExpr {
142
+ const ors: SqlExpr[] = [];
143
+ for (let i = 0; i < order.length; i++) {
144
+ const parts: SqlExpr[] = [];
145
+ for (let j = 0; j < i; j++) parts.push(eq(order[j]!.column, values[j]));
146
+ const o = order[i]!;
147
+ parts.push(o.dir === "desc" ? cmp("<", o.column, values[i]) : cmp(">", o.column, values[i]));
148
+ ors.push(parts.length === 1 ? parts[0]! : and(...parts));
149
+ }
150
+ return ors.length === 1 ? ors[0]! : or(...ors);
151
+ }
152
+
153
+ export class Db<S extends SchemaDef = SchemaDef> {
154
+ /** Tables read or written during this Db's lifetime. */
155
+ readonly touched = new Set<string>();
156
+ private readonly dialect: Dialect;
157
+
158
+ constructor(
159
+ private readonly driver: Driver,
160
+ private readonly acl: AclContext,
161
+ private readonly schema: SchemaDef,
162
+ ) {
163
+ this.dialect = driver.dialect;
164
+ }
165
+
166
+ /** Resolve the ACL scope for an operation, or grant everything in SYSTEM mode. */
167
+ private scopeFor(entity: string, action: Action): Scope {
168
+ if (this.acl.system) return ALLOW_ALL;
169
+ return resolveScope(this.acl, entity, action);
170
+ }
171
+
172
+ /** Forced `set` values + validators for a write (empty in SYSTEM mode). The two
173
+ * halves are applied separately so the cell-level field check can run AFTER `set`
174
+ * (so a conditional `when` sees forced columns) but BEFORE `validate`. */
175
+ private writeRules(entity: string, action: "create" | "update"): { set: Row; validators: Validator[] } {
176
+ if (this.acl.system) return { set: {}, validators: [] };
177
+ return resolveWriteRules(this.acl, entity, action);
178
+ }
179
+
180
+ /** Run write validators against the final values; a throw surfaces as a 400. */
181
+ private runValidators(validators: Validator[], values: Row): void {
182
+ for (const validate of validators) {
183
+ try {
184
+ validate({ identity: this.acl.identity, values });
185
+ } catch (e) {
186
+ throw new BadRequest(e instanceof Error ? e.message : "validation failed");
187
+ }
188
+ }
189
+ }
190
+
191
+ /** Enforce field-level (incl. cell-level) write permission for one row. `setCols`
192
+ * are server-forced values that bypass the restriction. `evalRow` is the row the
193
+ * per-row grants are evaluated against (candidate on insert, post-merge on update). */
194
+ private checkWriteFields(
195
+ table: string,
196
+ action: "create" | "update",
197
+ scope: Scope,
198
+ writtenCols: string[],
199
+ evalRow: Row,
200
+ setCols: Set<string>,
201
+ ): void {
202
+ const allowed = effectiveFields(scope, evalRow, this.acl.identity);
203
+ if (!allowed) return; // all fields permitted for this row
204
+ for (const c of writtenCols) {
205
+ if (!setCols.has(c) && !allowed.includes(c)) throw new AclDenied(table, action, c);
206
+ }
207
+ }
208
+
209
+ /** Reject ordering by a column the caller cannot read (closes an info-leak: order
210
+ * and the keyset cursor would otherwise expose a hidden column's values). Columns
211
+ * granted only conditionally are NOT orderable. */
212
+ private assertReadableCols(from: string, scope: Scope, cols: string[]): void {
213
+ if (scope.fields === null) return;
214
+ for (const c of cols) if (!scope.fields.includes(c)) throw new AclDenied(from, "read", c);
215
+ }
216
+
217
+ /** Structured read; ACL row-scope is AND-ed in, permitted fields projected.
218
+ * Selected relations are eager-loaded, each independently ACL-checked. */
219
+ async find<T extends keyof S & string>(
220
+ spec: FindSpec<S, T>,
221
+ ): Promise<(InferRow<FieldsOf<S[T]>> & RelationsResult<S, T>)[]> {
222
+ const from = spec.from as string;
223
+ this.touched.add(from);
224
+ const scope = this.scopeFor(from, "read");
225
+ if (!scope.allowed) throw new AclDenied(from, "read");
226
+
227
+ const where = this.readWhere(spec.where, scope);
228
+ const orderBy = normalizeOrder(spec.orderBy);
229
+ if (orderBy) this.assertReadableCols(from, scope, orderBy.map((o) => o.column));
230
+ const raw = await this.selectRaw(from, where, orderBy, spec.limit, spec.offset);
231
+ return (await this.finishRows(from, raw, scope, spec.with as Selected)) as (InferRow<FieldsOf<S[T]>> &
232
+ RelationsResult<S, T>)[];
233
+ }
234
+
235
+ /** Cursor (keyset) pagination. Stable under inserts/deletes; the PK is appended
236
+ * to `orderBy` as a tiebreaker so the keyset is unique. Returns the page plus an
237
+ * opaque `cursor` (pass back as `after`) and whether more rows remain. */
238
+ async page<T extends keyof S & string>(
239
+ spec: PageSpec<S, T>,
240
+ ): Promise<Page<InferRow<FieldsOf<S[T]>> & RelationsResult<S, T>>> {
241
+ const from = spec.from as string;
242
+ this.touched.add(from);
243
+ const scope = this.scopeFor(from, "read");
244
+ if (!scope.allowed) throw new AclDenied(from, "read");
245
+
246
+ const order = this.orderWithPk(from, spec.orderBy);
247
+ this.assertReadableCols(from, scope, order.map((o) => o.column)); // order + cursor must not leak hidden cols
248
+ let where = this.readWhere(spec.where, scope);
249
+ if (spec.after != null) where = and(where, keysetAfter(order, decodeCursor(spec.after)));
250
+
251
+ const limit = spec.limit ?? DEFAULT_PAGE_SIZE;
252
+ const raw = await this.selectRaw(from, where, order, limit + 1); // +1 to detect a next page
253
+ const hasMore = raw.length > limit;
254
+ if (hasMore) raw.length = limit;
255
+
256
+ const last = raw[raw.length - 1];
257
+ const cursor = last ? encodeCursor(order, last) : null; // from raw row (has all order cols)
258
+ const items = (await this.finishRows(from, raw, scope, spec.with as Selected)) as (InferRow<FieldsOf<S[T]>> &
259
+ RelationsResult<S, T>)[];
260
+ return { items, cursor, hasMore };
261
+ }
262
+
263
+ /** Count rows visible to the caller (ACL read scope applied). */
264
+ async count<T extends keyof S & string>(spec: { from: T; where?: WhereInput<FieldsOf<S[T]>> }): Promise<number> {
265
+ const from = spec.from as string;
266
+ this.touched.add(from);
267
+ const scope = this.scopeFor(from, "read");
268
+ if (!scope.allowed) throw new AclDenied(from, "read");
269
+ const where = this.readWhere(spec.where, scope);
270
+ const { sql, params } = compileCount(from, this.dialect, where);
271
+ const rows = (await this.driver.exec(sql, params)) as { n: number }[];
272
+ return Number(rows[0]?.n ?? 0);
273
+ }
274
+
275
+ /** Grouped aggregation (count/sum/avg/min/max). ACL read scope is applied, and
276
+ * every referenced column must be readable under field permissions. The result
277
+ * row type is inferred from the spec: group columns keep their schema type and
278
+ * each aggregation gets its computed value type. */
279
+ async aggregate<
280
+ T extends keyof S & string,
281
+ A extends Aggregations<FieldsOf<S[T]>>,
282
+ G extends (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[] = never,
283
+ >(spec: {
284
+ from: T;
285
+ where?: WhereInput<FieldsOf<S[T]>>;
286
+ groupBy?: G;
287
+ aggregations: A;
288
+ }): Promise<AggregateResult<FieldsOf<S[T]>, G, A>[]> {
289
+ const from = spec.from as string;
290
+ this.touched.add(from);
291
+ const scope = this.scopeFor(from, "read");
292
+ if (!scope.allowed) throw new AclDenied(from, "read");
293
+
294
+ const groupBy = (spec.groupBy ? (Array.isArray(spec.groupBy) ? spec.groupBy : [spec.groupBy]) : []) as string[];
295
+
296
+ // A json/fileRef cell is JSON; grouping/min/max over it would return the raw
297
+ // string (the codec only runs on row reads), so reject it rather than leak/lie.
298
+ const jsonCols = new Set(this.jsonColsOf(from));
299
+ for (const c of groupBy) if (jsonCols.has(c)) throw new BadRequest(`cannot group by a json column: ${c}`);
300
+ for (const agg of Object.values(spec.aggregations)) {
301
+ if (agg.column && jsonCols.has(agg.column as string)) {
302
+ throw new BadRequest(`cannot aggregate a json column: ${agg.column as string}`);
303
+ }
304
+ }
305
+
306
+ if (scope.fields) {
307
+ const refs = new Set<string>(groupBy);
308
+ for (const agg of Object.values(spec.aggregations)) if (agg.column) refs.add(agg.column as string);
309
+ for (const c of refs) if (!scope.fields.includes(c)) throw new AclDenied(from, "read", c);
310
+ }
311
+
312
+ const where = this.readWhere(spec.where, scope);
313
+ const { sql, params } = compileAggregate({ from, where, groupBy, aggregations: spec.aggregations }, this.dialect);
314
+ return (await this.driver.exec(sql, params)) as AggregateResult<FieldsOf<S[T]>, G, A>[];
315
+ }
316
+
317
+ // --- read internals shared by find/page ---
318
+
319
+ private readWhere(userWhere: unknown, scope: Scope): SqlExpr {
320
+ const userExpr: SqlExpr = userWhere ? compileWhere(userWhere as Row) : TRUE;
321
+ return scope.where ? and(userExpr, scope.where) : userExpr;
322
+ }
323
+
324
+ private async selectRaw(from: string, where: SqlExpr, orderBy?: OrderBy[], limit?: number, offset?: number): Promise<Row[]> {
325
+ const { sql, params } = compileSelect({ from, where, orderBy, limit, offset }, this.dialect);
326
+ return this.decodeRows(from, await this.driver.exec(sql, params));
327
+ }
328
+
329
+ // --- JSON codec: a `json` or `fileRef` column is stored as a JSON TEXT cell but
330
+ // handlers see/write the parsed value. Decode on read, encode (stringify) on write. ---
331
+
332
+ private jsonColsOf(table: string): string[] {
333
+ const fields = this.schema[table]?.fields;
334
+ if (!fields) return [];
335
+ return Object.entries(fields)
336
+ .filter(([, f]) => (f as FieldDef).type === "json" || (f as FieldDef).type === "fileRef")
337
+ .map(([n]) => n);
338
+ }
339
+
340
+ private decodeRows(table: string, rows: Row[]): Row[] {
341
+ const cols = this.jsonColsOf(table);
342
+ if (cols.length === 0) return rows;
343
+ for (const row of rows) {
344
+ for (const c of cols) {
345
+ const v = row[c];
346
+ if (typeof v === "string") {
347
+ try {
348
+ row[c] = JSON.parse(v);
349
+ } catch {
350
+ /* leave a non-JSON value as-is */
351
+ }
352
+ }
353
+ }
354
+ }
355
+ return rows;
356
+ }
357
+
358
+ private decodeRow(table: string, row: Row | undefined): Row | undefined {
359
+ return row ? this.decodeRows(table, [row])[0] : row;
360
+ }
361
+
362
+ /** Encode one write cell: JSON-stringify a json/fileRef value, then dialect-encode. */
363
+ private encodeCell(jsonCols: Set<string>, col: string, v: unknown): unknown {
364
+ if (v != null && jsonCols.has(col)) return this.dialect.encode(JSON.stringify(v));
365
+ return this.dialect.encode(v);
366
+ }
367
+
368
+ /** Fetch one row by id within an ACL row-scope (for per-row write evaluation). */
369
+ private async fetchOne(from: string, id: Id, scopeWhere: SqlExpr | null): Promise<Row | undefined> {
370
+ const where = scopeWhere ? and(eq("id", id), scopeWhere) : eq("id", id);
371
+ const { sql, params } = compileSelect({ from, where, limit: 1 }, this.dialect);
372
+ return this.decodeRow(from, (await this.driver.exec(sql, params))[0] as Row | undefined);
373
+ }
374
+
375
+ private async finishRows(from: string, raw: Row[], scope: Scope, withSel: Selected): Promise<Row[]> {
376
+ const relNames = withSel ? Object.keys(withSel).filter((k) => withSel[k]) : [];
377
+ for (const relName of relNames) await this.loadRelation(from, raw, relName);
378
+ if (scope.fields === null) return raw; // base unrestricted -> no per-row narrowing possible
379
+ return raw.map((r) => {
380
+ const projected = projectRow(r, effectiveFields(scope, r, this.acl.identity));
381
+ for (const relName of relNames) projected[relName] = r[relName]; // relations survive projection
382
+ return projected;
383
+ });
384
+ }
385
+
386
+ private orderWithPk(from: string, orderBy: unknown): OrderBy[] {
387
+ const out = (normalizeOrder(orderBy) ?? []).map((o) => ({ column: o.column, dir: o.dir }));
388
+ const pk = this.pkOf(from);
389
+ if (!out.some((o) => o.column === pk)) out.push({ column: pk, dir: out[out.length - 1]?.dir ?? "asc" });
390
+ return out;
391
+ }
392
+
393
+ private pkOf(from: string): string {
394
+ const fields = this.schema[from]?.fields;
395
+ if (fields) for (const [name, f] of Object.entries(fields)) if ((f as FieldDef).primaryKey) return name;
396
+ return "id";
397
+ }
398
+
399
+ /** Eager-load one relation onto `rows` (mutates them). Traversal is ACL-checked
400
+ * via resolveRelationScope: the related read scope OR a parent directAccess grant. */
401
+ private async loadRelation(parentEntity: string, rows: Row[], relName: string): Promise<void> {
402
+ const rel = this.schema[parentEntity]?.relations?.[relName] as RelationDef | undefined;
403
+ if (!rel) throw new Error(`unknown relation: ${parentEntity}.${relName}`);
404
+
405
+ this.touched.add(rel.target);
406
+ const scope = this.acl.system ? ALLOW_ALL : resolveRelationScope(this.acl, parentEntity, relName, rel.target);
407
+ if (!scope.allowed) throw new AclDenied(rel.target, "read");
408
+
409
+ const project = (row: Row): Row => projectRow(row, effectiveFields(scope, row, this.acl.identity));
410
+ // One IN query per relation (no N+1). Match column before projecting (which
411
+ // may drop the join column).
412
+ const fetchBy = async (col: string, values: unknown[]): Promise<Array<{ key: unknown; row: Row }>> => {
413
+ if (values.length === 0) return [];
414
+ const where = scope.where ? and(inList(col, values), scope.where) : inList(col, values);
415
+ const { sql, params } = compileSelect({ from: rel.target, where }, this.dialect);
416
+ const rows = this.decodeRows(rel.target, await this.driver.exec(sql, params));
417
+ return rows.map((row) => ({ key: row[col], row: project(row) }));
418
+ };
419
+
420
+ if (rel.kind === "belongsTo") {
421
+ // parent[column] -> target.id
422
+ const keys = [...new Set(rows.map((r) => r[rel.column]).filter((v) => v != null))];
423
+ const byId = new Map<unknown, Row>();
424
+ for (const { key, row } of await fetchBy("id", keys)) byId.set(key, row);
425
+ for (const r of rows) r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
426
+ } else {
427
+ // hasMany: target[column] -> parent.id
428
+ const ids = [...new Set(rows.map((r) => r.id).filter((v) => v != null))];
429
+ const grouped = new Map<unknown, Row[]>();
430
+ for (const { key, row } of await fetchBy(rel.column, ids)) {
431
+ const bucket = grouped.get(key) ?? grouped.set(key, []).get(key)!;
432
+ bucket.push(row);
433
+ }
434
+ for (const r of rows) r[relName] = grouped.get(r.id) ?? [];
435
+ }
436
+ }
437
+
438
+ /** Insert a single row, returning the persisted row. */
439
+ async insert<T extends keyof S & string>(
440
+ table: T,
441
+ values: InferInsert<FieldsOf<S[T]>>,
442
+ ): Promise<InferRow<FieldsOf<S[T]>>> {
443
+ this.touched.add(table);
444
+ const scope = this.scopeFor(table, "create");
445
+ if (!scope.allowed) throw new AclDenied(table, "create");
446
+ const vals = { ...(values as Row) };
447
+ const { set, validators } = this.writeRules(table, "create");
448
+ Object.assign(vals, set); // forced server values first, so a conditional `when` can see them
449
+ this.checkWriteFields(table, "create", scope, Object.keys(vals), vals, new Set(Object.keys(set)));
450
+ this.runValidators(validators, vals);
451
+
452
+ const cols = Object.keys(vals);
453
+ const jsonCols = new Set(this.jsonColsOf(table));
454
+ const colList = cols.map((c) => this.dialect.id(c)).join(", ");
455
+ const phs = cols.map((_, i) => this.dialect.placeholder(i + 1)).join(", ");
456
+ const params = cols.map((c) => this.encodeCell(jsonCols, c, vals[c]));
457
+ const sql = `INSERT INTO ${this.dialect.id(table)} (${colList}) VALUES (${phs})${this.returningClause("*")}`;
458
+ const rows = await this.driver.exec(sql, params);
459
+ return this.projectWrite(table, this.decodeRow(table, rows[0])!, cols) as InferRow<FieldsOf<S[T]>>;
460
+ }
461
+
462
+ /** Project a mutation's RETURNING row so the echo never reveals more than a read
463
+ * would: the caller's readable fields for this row, PLUS the columns they just
464
+ * wrote (which they already know) and the primary key (so a write-only caller
465
+ * still gets the generated id). Full read access -> the whole row; SYSTEM -> as-is.
466
+ * This makes create/update echoes field-ACL-safe without ever collapsing to {}. */
467
+ private projectWrite(table: string, row: Row, writtenCols: string[]): Row {
468
+ if (this.acl.system) return row;
469
+ const visible = new Set<string>([this.pkOf(table), ...writtenCols]);
470
+ const readScope = this.scopeFor(table, "read");
471
+ if (readScope.allowed) {
472
+ const readable = effectiveFields(readScope, row, this.acl.identity);
473
+ if (readable === null) return row; // unrestricted read -> echo everything
474
+ for (const f of readable) visible.add(f);
475
+ }
476
+ return projectRow(row, [...visible]);
477
+ }
478
+
479
+ /** Update a row by id. ACL row-scope is AND-ed into the WHERE, so a caller can
480
+ * only update rows within scope; returns undefined if none matched. */
481
+ async update<T extends keyof S & string>(
482
+ table: T,
483
+ id: Id,
484
+ patch: InferUpdate<FieldsOf<S[T]>>,
485
+ ): Promise<InferRow<FieldsOf<S[T]>> | undefined> {
486
+ this.touched.add(table);
487
+ const scope = this.scopeFor(table, "update");
488
+ if (!scope.allowed) throw new AclDenied(table, "update");
489
+ const p = { ...(patch as Row) };
490
+ const { set, validators } = this.writeRules(table, "update");
491
+ Object.assign(p, set); // forced server values first
492
+ const cols = Object.keys(p);
493
+ if (cols.length === 0) return undefined;
494
+
495
+ // Per-row field permission is evaluated against the FINAL (post-merge) row, so
496
+ // fetch the existing row within update scope when any cell-level rule applies.
497
+ let evalRow: Row = p;
498
+ if (scope.fields !== null && (scope.conditional.length > 0 || scope.fieldsFns.length > 0)) {
499
+ const existing = await this.fetchOne(table, id, scope.where);
500
+ if (!existing) return undefined; // out of update scope -> no-op
501
+ evalRow = { ...existing, ...p };
502
+ }
503
+ this.checkWriteFields(table, "update", scope, cols, evalRow, new Set(Object.keys(set)));
504
+ this.runValidators(validators, p);
505
+
506
+ const params: unknown[] = [];
507
+ const jsonCols = new Set(this.jsonColsOf(table));
508
+ const assignments = cols
509
+ .map((c) => {
510
+ params.push(this.encodeCell(jsonCols, c, p[c]));
511
+ return `${this.dialect.id(c)} = ${this.dialect.placeholder(params.length)}`;
512
+ })
513
+ .join(", ");
514
+ params.push(this.dialect.encode(id));
515
+ let sql = `UPDATE ${this.dialect.id(table)} SET ${assignments} WHERE ${this.dialect.id("id")} = ${this.dialect.placeholder(params.length)}`;
516
+ sql += this.scopeClause(scope.where, params);
517
+ sql += this.returningClause("*");
518
+ const updated = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
519
+ return (updated ? this.projectWrite(table, updated, cols) : undefined) as InferRow<FieldsOf<S[T]>> | undefined;
520
+ }
521
+
522
+ /** Delete a row by id within scope. Returns whether a row was deleted. */
523
+ async delete<T extends keyof S & string>(table: T, id: Id): Promise<boolean> {
524
+ this.touched.add(table);
525
+ const scope = this.scopeFor(table, "delete");
526
+ if (!scope.allowed) throw new AclDenied(table, "delete");
527
+ const params: unknown[] = [this.dialect.encode(id)];
528
+ let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id("id")} = ${this.dialect.placeholder(1)}`;
529
+ sql += this.scopeClause(scope.where, params);
530
+ sql += this.returningClause("id");
531
+ return (await this.driver.exec(sql, params)).length > 0;
532
+ }
533
+
534
+ /** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
535
+ async exec(sql: string, ...params: unknown[]): Promise<Row[]> {
536
+ return this.driver.exec(sql, params.map((p) => this.dialect.encode(p)));
537
+ }
538
+
539
+ // RETURNING is supported on SQLite/Postgres; a dialect without it (MySQL) would
540
+ // need an insert-then-select-back path — not implemented in this spike.
541
+ private returningClause(cols: string): string {
542
+ return this.dialect.returning ? ` RETURNING ${cols}` : "";
543
+ }
544
+
545
+ private scopeClause(where: SqlExpr | null, params: unknown[]): string {
546
+ if (!where) return "";
547
+ const compiled = compileExpr(where, this.dialect, params);
548
+ return compiled.sql === "1" ? "" : ` AND (${compiled.sql})`;
549
+ }
550
+ }
@@ -0,0 +1,67 @@
1
+ // DDL generation — CREATE TABLE for a new entity and the additive ALTER fragment
2
+ // for a new column. Runs in TS inside the isolate; see runtime/migrate.ts for how
3
+ // these are applied.
4
+
5
+ import type { DefaultValue, EntityFields, FieldDef } from "../sdk/schema";
6
+
7
+ // SQLite has no boolean type; store as INTEGER 0/1. json + fileRef are stored as
8
+ // TEXT (JSON). Exported for the migrator, which compares declared column types
9
+ // (and CASTs on a type change).
10
+ export const sqlType = (f: FieldDef): string =>
11
+ f.type === "boolean" ? "INTEGER" : f.type === "json" || f.type === "fileRef" ? "TEXT" : f.type.toUpperCase();
12
+
13
+ /** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1. */
14
+ function defaultLiteral(v: DefaultValue): string {
15
+ if (v === null) return "NULL";
16
+ if (typeof v === "boolean") return v ? "1" : "0";
17
+ if (typeof v === "number") return String(v);
18
+ return `'${v.replace(/'/g, "''")}'`;
19
+ }
20
+
21
+ /** The ` DEFAULT x` fragment for a column, or "" when it has no default. UNIQUE/
22
+ * index are NOT inline — they're emitted as separate index statements so the same
23
+ * code path serves both CREATE TABLE and ALTER TABLE ADD COLUMN. */
24
+ function defaultSql(f: FieldDef): string {
25
+ return f.default !== undefined ? ` DEFAULT ${defaultLiteral(f.default)}` : "";
26
+ }
27
+
28
+ function columnSql(name: string, f: FieldDef): string {
29
+ let s = `${name} ${sqlType(f)}`;
30
+ if (f.primaryKey) s += " PRIMARY KEY";
31
+ if (f.autoIncrement) s += " AUTOINCREMENT";
32
+ if (f.notNull && !f.primaryKey) s += " NOT NULL";
33
+ s += defaultSql(f);
34
+ return s;
35
+ }
36
+
37
+ export function createTableSql(table: string, def: { fields: EntityFields }): string {
38
+ const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
39
+ return `CREATE TABLE IF NOT EXISTS ${table} (${cols.join(", ")})`;
40
+ }
41
+
42
+ /** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
43
+ * NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
44
+ * a populated table); a DEFAULT alone backfills existing rows. */
45
+ export function addColumnSql(name: string, f: FieldDef): string {
46
+ let s = `${name} ${sqlType(f)}`;
47
+ if (f.notNull && f.default !== undefined) s += " NOT NULL";
48
+ s += defaultSql(f);
49
+ return s;
50
+ }
51
+
52
+ /** Index name for a column's unique/index constraint. */
53
+ export function indexName(table: string, col: string): string {
54
+ return `pramen_idx_${table}_${col}`;
55
+ }
56
+
57
+ /** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
58
+ * via IF NOT EXISTS). Unique wins if a column declares both. */
59
+ export function indexStatements(table: string, def: { fields: EntityFields }): string[] {
60
+ const out: string[] = [];
61
+ for (const [col, f] of Object.entries(def.fields)) {
62
+ if (!f.unique && !f.index) continue;
63
+ const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
64
+ out.push(`CREATE ${kind} IF NOT EXISTS ${indexName(table, col)} ON ${table} (${col})`);
65
+ }
66
+ return out;
67
+ }
@@ -0,0 +1,31 @@
1
+ // Stable digest of a query result, for row-level change detection. Two results
2
+ // that are deeply equal (modulo object key order) hash the same, so a mutation
3
+ // that doesn't change a given subscription's visible rows produces no push.
4
+ //
5
+ // Re-running the query is cheap (in-process SQLite); the digest gates the
6
+ // expensive part — the network push and the client re-render.
7
+
8
+ function canonical(v: unknown): string {
9
+ if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
10
+ if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
11
+ const obj = v as Record<string, unknown>;
12
+ return (
13
+ "{" +
14
+ Object.keys(obj)
15
+ .sort()
16
+ .map((k) => JSON.stringify(k) + ":" + canonical(obj[k]))
17
+ .join(",") +
18
+ "}"
19
+ );
20
+ }
21
+
22
+ export function digest(result: unknown): string {
23
+ const s = canonical(result);
24
+ // FNV-1a (32-bit).
25
+ let h = 0x811c9dc5;
26
+ for (let i = 0; i < s.length; i++) {
27
+ h ^= s.charCodeAt(i);
28
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
29
+ }
30
+ return h.toString(16);
31
+ }