@pramen/server 0.0.35 → 0.0.36

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.
@@ -1,5 +1,8 @@
1
1
  import type { Dialect } from "./driver";
2
2
  export type CmpOp = "=" | "!=" | ">" | ">=" | "<" | "<=" | "LIKE";
3
+ /** Substring match mode for the structured string operators (auto-escaping, so the
4
+ * needle's `%`/`_` are literal). Case-insensitive, matching SQLite's default LIKE. */
5
+ export type StrMode = "contains" | "prefix" | "suffix";
3
6
  export type SqlExpr = {
4
7
  t: "true";
5
8
  } | {
@@ -18,12 +21,20 @@ export type SqlExpr = {
18
21
  t: "null";
19
22
  col: string;
20
23
  negate: boolean;
24
+ } | {
25
+ t: "strmatch";
26
+ col: string;
27
+ needle: string;
28
+ mode: StrMode;
21
29
  } | {
22
30
  t: "and";
23
31
  parts: SqlExpr[];
24
32
  } | {
25
33
  t: "or";
26
34
  parts: SqlExpr[];
35
+ } | {
36
+ t: "not";
37
+ expr: SqlExpr;
27
38
  } | {
28
39
  t: "sub";
29
40
  outerCol: string;
@@ -37,8 +48,10 @@ export declare const FALSE: SqlExpr;
37
48
  export declare const cmp: (op: CmpOp, col: string, value: unknown) => SqlExpr;
38
49
  export declare const isNull: (col: string, negate?: boolean) => SqlExpr;
39
50
  export declare const inList: (col: string, values: unknown[], negate?: boolean) => SqlExpr;
51
+ export declare const strMatch: (col: string, needle: string, mode: StrMode) => SqlExpr;
40
52
  export declare const and: (...parts: SqlExpr[]) => SqlExpr;
41
53
  export declare const or: (...parts: SqlExpr[]) => SqlExpr;
54
+ export declare const not: (expr: SqlExpr) => SqlExpr;
42
55
  /** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
43
56
  export declare const eq: (col: string, value: unknown) => SqlExpr;
44
57
  /** Compile a structured user predicate into a SqlExpr.
@@ -15,8 +15,10 @@ export const FALSE = { t: "false" };
15
15
  export const cmp = (op, col, value) => ({ t: "cmp", op, col, value });
16
16
  export const isNull = (col, negate = false) => ({ t: "null", col, negate });
17
17
  export const inList = (col, values, negate = false) => ({ t: "in", col, values, negate });
18
+ export const strMatch = (col, needle, mode) => ({ t: "strmatch", col, needle, mode });
18
19
  export const and = (...parts) => ({ t: "and", parts });
19
20
  export const or = (...parts) => ({ t: "or", parts });
21
+ export const not = (expr) => ({ t: "not", expr });
20
22
  /** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
21
23
  export const eq = (col, value) => (value === null ? isNull(col) : cmp("=", col, value));
22
24
  /** Compile a structured user predicate into a SqlExpr.
@@ -30,6 +32,9 @@ export function compileWhere(input) {
30
32
  else if (k === "OR") {
31
33
  parts.push(or(...v.map(compileWhere)));
32
34
  }
35
+ else if (k === "NOT") {
36
+ parts.push(not(compileWhere(v)));
37
+ }
33
38
  else {
34
39
  parts.push(columnPredicate(k, v));
35
40
  }
@@ -62,6 +67,15 @@ function columnPredicate(col, v) {
62
67
  case "like":
63
68
  ops.push(cmp("LIKE", col, val));
64
69
  break;
70
+ case "contains":
71
+ ops.push(strMatch(col, String(val), "contains"));
72
+ break;
73
+ case "startsWith":
74
+ ops.push(strMatch(col, String(val), "prefix"));
75
+ break;
76
+ case "endsWith":
77
+ ops.push(strMatch(col, String(val), "suffix"));
78
+ break;
65
79
  case "in":
66
80
  ops.push(inList(col, val));
67
81
  break;
@@ -96,6 +110,15 @@ export function compileExpr(expr, dialect, params = []) {
96
110
  return { sql: `${dialect.id(expr.col)} ${expr.op} ${dialect.placeholder(params.length)}`, params };
97
111
  case "null":
98
112
  return { sql: `${dialect.id(expr.col)} IS ${expr.negate ? "NOT " : ""}NULL`, params };
113
+ case "strmatch": {
114
+ // Escape the LIKE metacharacters in the needle (\, %, _) so they match literally,
115
+ // then wrap with wildcards per mode. ESCAPE '\' declares the escape char (SQLite +
116
+ // Postgres both support it). LIKE is ASCII-case-insensitive by default.
117
+ const esc = expr.needle.replace(/[\\%_]/g, "\\$&");
118
+ const pattern = expr.mode === "contains" ? `%${esc}%` : expr.mode === "prefix" ? `${esc}%` : `%${esc}`;
119
+ params.push(dialect.encode(pattern));
120
+ return { sql: `${dialect.id(expr.col)} LIKE ${dialect.placeholder(params.length)} ESCAPE '\\'`, params };
121
+ }
99
122
  case "in": {
100
123
  if (expr.values.length === 0)
101
124
  return { sql: expr.negate ? "1" : "0", params }; // empty: notIn=>all, in=>none
@@ -110,6 +133,8 @@ export function compileExpr(expr, dialect, params = []) {
110
133
  const sql = expr.parts.map((p) => compileExpr(p, dialect, params).sql).join(sep);
111
134
  return { sql: expr.parts.length > 1 ? `(${sql})` : sql, params };
112
135
  }
136
+ case "not":
137
+ return { sql: `NOT (${compileExpr(expr.expr, dialect, params).sql})`, params };
113
138
  case "sub": {
114
139
  // Inner predicate shares `params`, so placeholder numbering stays correct
115
140
  // across dialects (? and $n alike).
@@ -169,6 +194,14 @@ export function evalExpr(expr, row) {
169
194
  const isNullVal = v === null || v === undefined;
170
195
  return expr.negate ? !isNullVal : isNullVal;
171
196
  }
197
+ case "strmatch": {
198
+ const left = row[expr.col];
199
+ if (typeof left !== "string")
200
+ return false;
201
+ const s = left.toLowerCase();
202
+ const n = expr.needle.toLowerCase(); // CI, mirroring SQLite's default LIKE
203
+ return expr.mode === "contains" ? s.includes(n) : expr.mode === "prefix" ? s.startsWith(n) : s.endsWith(n);
204
+ }
172
205
  case "in": {
173
206
  const left = bind(row[expr.col]);
174
207
  if (left === null || left === undefined)
@@ -182,6 +215,8 @@ export function evalExpr(expr, row) {
182
215
  return expr.parts.every((p) => evalExpr(p, row));
183
216
  case "or":
184
217
  return expr.parts.some((p) => evalExpr(p, row));
218
+ case "not":
219
+ return !evalExpr(expr.expr, row);
185
220
  case "sub":
186
221
  // Relation traversal needs a SQL round-trip; it isn't supported in the
187
222
  // in-memory cell-ACL `when` evaluator (those predicates must be single-table).
@@ -24,7 +24,9 @@ export type InferRow<F extends EntityFields> = {
24
24
  export type ProjectedRow<F extends EntityFields> = {
25
25
  [K in keyof F]?: Cell<F[K]>;
26
26
  };
27
- /** Operators available on a column predicate. `like` is string-only. */
27
+ /** Operators available on a column predicate. String ops (`like`/`contains`/
28
+ * `startsWith`/`endsWith`) are string-only; `contains`/`startsWith`/`endsWith` escape
29
+ * the needle's wildcards (unlike `like`, where the caller writes `%`/`_`). */
28
30
  export interface WhereOps<V> {
29
31
  eq?: V | null;
30
32
  ne?: V | null;
@@ -35,15 +37,19 @@ export interface WhereOps<V> {
35
37
  in?: V[];
36
38
  notIn?: V[];
37
39
  like?: V extends string ? string : never;
40
+ contains?: V extends string ? string : never;
41
+ startsWith?: V extends string ? string : never;
42
+ endsWith?: V extends string ? string : never;
38
43
  isNull?: boolean;
39
44
  }
40
45
  /** Predicate input: per-column equality shorthand or an operator object, plus
41
- * nestable AND/OR groups. */
46
+ * nestable AND/OR/NOT groups. */
42
47
  export type WhereInput<F extends EntityFields> = {
43
48
  [K in keyof F]?: FieldTsType<F[K]> | null | WhereOps<FieldTsType<F[K]>>;
44
49
  } & {
45
50
  AND?: WhereInput<F>[];
46
51
  OR?: WhereInput<F>[];
52
+ NOT?: WhereInput<F>;
47
53
  };
48
54
  type PrevDepth = [never, 0, 1, 2, 3, 4, 5];
49
55
  type RelTargetTable<S extends SchemaDef, R> = R extends {
@@ -80,10 +86,10 @@ export type FieldsOf<E> = E extends EntityDef<infer F, RelationDefs> ? F : never
80
86
  /** Extract a schema entry's relations. */
81
87
  export type RelationsOf<E> = E extends EntityDef<EntityFields, infer R> ? R : Record<string, never>;
82
88
  type RelValue<S extends SchemaDef, Rel> = Rel extends {
83
- kind: "belongsTo";
89
+ kind: "belongsTo" | "oneHasOne" | "oneHasOneInverse";
84
90
  target: infer Tg;
85
91
  } ? Tg extends keyof S ? InferRow<FieldsOf<S[Tg]>> | null : never : Rel extends {
86
- kind: "hasMany";
92
+ kind: "hasMany" | "manyToMany";
87
93
  target: infer Tg;
88
94
  } ? Tg extends keyof S ? InferRow<FieldsOf<S[Tg]>>[] : never : never;
89
95
  /** The relation properties added to a row by `with`. Optional (present only when selected). */
@@ -74,11 +74,18 @@ declare const builders: {
74
74
  };
75
75
  export type FieldBuilders = typeof builders;
76
76
  export type EntityFields = Record<string, FieldDef>;
77
+ /** FK ON DELETE behavior for an owning relation's real foreign key. `restrict` (the
78
+ * default) blocks deleting a referenced row; `cascade` deletes the referencing rows;
79
+ * `setNull` nulls the FK column (which must be nullable). Enforced by the SQLite engine
80
+ * at runtime on both DO and D1. */
81
+ export type OnDelete = "cascade" | "setNull" | "restrict";
77
82
  export interface BelongsToDef<T extends string = string> {
78
83
  readonly kind: "belongsTo";
79
84
  readonly target: T;
80
- /** Local column holding the target's primary key. */
85
+ /** Local column holding the target's primary key (a real FK: REFERENCES target(pk)). */
81
86
  readonly column: string;
87
+ /** ON DELETE action for the FK; omitted ⇒ `restrict` (SQLite default). */
88
+ readonly onDelete?: OnDelete;
82
89
  }
83
90
  export interface HasManyDef<T extends string = string> {
84
91
  readonly kind: "hasMany";
@@ -86,19 +93,73 @@ export interface HasManyDef<T extends string = string> {
86
93
  /** Column on the target referring back to this entity's primary key. */
87
94
  readonly column: string;
88
95
  }
89
- export type RelationDef = BelongsToDef | HasManyDef;
96
+ /** Many-to-many via an explicit junction entity. Logical (no FK constraints), like the
97
+ * other relation kinds: `through` is a normal entity you define and write to directly;
98
+ * `sourceColumn`/`targetColumn` are its columns holding this entity's and the target's
99
+ * primary keys. Source, junction, and target must share a partition (single-DO traversal). */
100
+ export interface ManyToManyDef<T extends string = string> {
101
+ readonly kind: "manyToMany";
102
+ readonly target: T;
103
+ readonly through: string;
104
+ readonly sourceColumn: string;
105
+ readonly targetColumn: string;
106
+ }
107
+ /** One-to-one (owning side): THIS entity holds `column` = the target's primary key, and
108
+ * the pairing is 1:1 — mark `column` `unique()` for the DB-enforced guarantee. Reads as a
109
+ * single target (like belongsTo); FK-capable via `onDelete`. */
110
+ export interface OneHasOneDef<T extends string = string> {
111
+ readonly kind: "oneHasOne";
112
+ readonly target: T;
113
+ readonly column: string;
114
+ readonly onDelete?: OnDelete;
115
+ }
116
+ /** One-to-one (inverse side): the TARGET holds `column` referencing THIS entity's primary
117
+ * key. Reads as a single target (the reverse of a oneHasOne), or null. */
118
+ export interface OneHasOneInverseDef<T extends string = string> {
119
+ readonly kind: "oneHasOneInverse";
120
+ readonly target: T;
121
+ readonly column: string;
122
+ }
123
+ export type RelationDef = BelongsToDef | HasManyDef | ManyToManyDef | OneHasOneDef | OneHasOneInverseDef;
90
124
  export type RelationDefs = Record<string, RelationDef>;
91
125
  declare const relationBuilders: {
92
- belongsTo: <T extends string>(target: T, column: string) => {
126
+ belongsTo: <T extends string>(target: T, column: string, opts?: {
127
+ onDelete?: OnDelete;
128
+ }) => {
93
129
  readonly kind: "belongsTo";
94
130
  readonly target: T;
95
131
  readonly column: string;
132
+ readonly onDelete: OnDelete | undefined;
96
133
  };
97
134
  hasMany: <T extends string>(target: T, column: string) => {
98
135
  readonly kind: "hasMany";
99
136
  readonly target: T;
100
137
  readonly column: string;
101
138
  };
139
+ oneHasOne: <T extends string>(target: T, column: string, opts?: {
140
+ onDelete?: OnDelete;
141
+ }) => {
142
+ readonly kind: "oneHasOne";
143
+ readonly target: T;
144
+ readonly column: string;
145
+ readonly onDelete: OnDelete | undefined;
146
+ };
147
+ oneHasOneInverse: <T extends string>(target: T, column: string) => {
148
+ readonly kind: "oneHasOneInverse";
149
+ readonly target: T;
150
+ readonly column: string;
151
+ };
152
+ manyToMany: <T extends string>(target: T, opts: {
153
+ through: string;
154
+ sourceColumn: string;
155
+ targetColumn: string;
156
+ }) => {
157
+ readonly kind: "manyToMany";
158
+ readonly target: T;
159
+ readonly through: string;
160
+ readonly sourceColumn: string;
161
+ readonly targetColumn: string;
162
+ };
102
163
  };
103
164
  export type RelationBuilders = typeof relationBuilders;
104
165
  /** The default partition name for entities that don't declare one. */
@@ -134,10 +195,15 @@ export interface EntityDef<F extends EntityFields = EntityFields, R extends Rela
134
195
  readonly partition: string;
135
196
  /** Declarative write-triggers (see TriggerDef). Always an array (possibly empty). */
136
197
  readonly triggers: readonly TriggerDef[];
198
+ /** Composite (multi-column) UNIQUE constraints, each a tuple of column names, enforced
199
+ * via a managed unique index. Single-column uniqueness stays on the field (`unique()`).
200
+ * Always an array (possibly empty). */
201
+ readonly uniques: readonly (readonly string[])[];
137
202
  }
138
203
  export declare function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(build: (t: FieldBuilders) => F, relations?: (r: RelationBuilders) => R, opts?: {
139
204
  partition?: string;
140
205
  triggers?: readonly TriggerDef[];
206
+ unique?: readonly (readonly string[])[];
141
207
  }): EntityDef<F, R>;
142
208
  /** The triggers declared on an entity (empty if none / unknown entity). */
143
209
  export declare function triggersOf(schema: SchemaDef, entity: string): readonly TriggerDef[];
@@ -25,8 +25,11 @@ const builders = {
25
25
  uuid: () => ({ type: "uuid" }),
26
26
  };
27
27
  const relationBuilders = {
28
- belongsTo: (target, column) => ({ kind: "belongsTo", target, column }),
28
+ belongsTo: (target, column, opts) => ({ kind: "belongsTo", target, column, onDelete: opts?.onDelete }),
29
29
  hasMany: (target, column) => ({ kind: "hasMany", target, column }),
30
+ oneHasOne: (target, column, opts) => ({ kind: "oneHasOne", target, column, onDelete: opts?.onDelete }),
31
+ oneHasOneInverse: (target, column) => ({ kind: "oneHasOneInverse", target, column }),
32
+ manyToMany: (target, opts) => ({ kind: "manyToMany", target, through: opts.through, sourceColumn: opts.sourceColumn, targetColumn: opts.targetColumn }),
30
33
  };
31
34
  /** The default partition name for entities that don't declare one. */
32
35
  export const DEFAULT_PARTITION = "default";
@@ -53,6 +56,7 @@ export function Entity(build, relations, opts) {
53
56
  relations: (relations ? relations(relationBuilders) : {}),
54
57
  partition: opts?.partition ?? DEFAULT_PARTITION,
55
58
  triggers: opts?.triggers ?? [],
59
+ uniques: opts?.unique ?? [],
56
60
  };
57
61
  }
58
62
  /** The triggers declared on an entity (empty if none / unknown entity). */
@@ -185,6 +189,21 @@ export function validateSchema(schema) {
185
189
  `'${pE}' but target '${rel.target}' is in '${pT}'. Relations cannot cross partitions — ` +
186
190
  `put both entities in the same partition or drop the relation.`);
187
191
  }
192
+ if (rel.kind === "manyToMany") {
193
+ const through = schema[rel.through];
194
+ if (!through) {
195
+ throw new Error(`relation '${entity}.${relName}' names an unknown junction entity '${rel.through}'.`);
196
+ }
197
+ for (const [label, col] of [["sourceColumn", rel.sourceColumn], ["targetColumn", rel.targetColumn]]) {
198
+ if (!(col in through.fields)) {
199
+ throw new Error(`relation '${entity}.${relName}' ${label} '${col}' is not a column of junction '${rel.through}'.`);
200
+ }
201
+ }
202
+ if (partitionOf(schema, rel.through) !== pE) {
203
+ throw new Error(`relation '${entity}.${relName}' junction '${rel.through}' is in a different partition than '${entity}' — ` +
204
+ `the source, junction, and target must share a partition (traversal is single-DO).`);
205
+ }
206
+ }
188
207
  }
189
208
  for (const t of def.triggers) {
190
209
  if (!t.task)
@@ -199,5 +218,15 @@ export function validateSchema(schema) {
199
218
  }
200
219
  }
201
220
  }
221
+ for (const cols of def.uniques) {
222
+ if (cols.length < 2) {
223
+ throw new Error(`composite unique on '${entity}' needs at least two columns (use unique() for one).`);
224
+ }
225
+ for (const c of cols) {
226
+ if (!(c in def.fields)) {
227
+ throw new Error(`composite unique on '${entity}' references unknown column '${c}'.`);
228
+ }
229
+ }
230
+ }
202
231
  }
203
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.35",
3
+ "version": "0.0.36",
4
4
  "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -22,6 +22,10 @@ export type {
22
22
  RelationDefs,
23
23
  BelongsToDef,
24
24
  HasManyDef,
25
+ ManyToManyDef,
26
+ OneHasOneDef,
27
+ OneHasOneInverseDef,
28
+ OnDelete,
25
29
  } from "./sdk/schema";
26
30
 
27
31
  // --- app + handlers ---
@@ -22,7 +22,7 @@ import {
22
22
  isInputMarker,
23
23
  isResolver,
24
24
  } from "../sdk/acl";
25
- import { and, compileWhere, evalExpr, FALSE, or, TRUE, type SqlExpr } from "./read-engine";
25
+ import { and, compileWhere, evalExpr, FALSE, not, or, TRUE, type SqlExpr } from "./read-engine";
26
26
  import { BadRequest, PramenError } from "./errors";
27
27
  import type { FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
28
28
 
@@ -260,6 +260,8 @@ function assertReadableRelationWhere(where: Record<string, unknown>, target: str
260
260
  for (const [k, v] of Object.entries(where)) {
261
261
  if (k === "AND" || k === "OR") {
262
262
  for (const g of v as Record<string, unknown>[]) assertReadableRelationWhere(g, target, fields, ctx);
263
+ } else if (k === "NOT") {
264
+ assertReadableRelationWhere(v as Record<string, unknown>, target, fields, ctx);
263
265
  } else if (targetRels[k]) {
264
266
  continue;
265
267
  } else if (!fields.includes(k)) {
@@ -300,7 +302,15 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
300
302
 
301
303
  // belongsTo: parent.<fk> IN (SELECT <target pk> FROM target WHERE inner)
302
304
  // hasMany: parent.<pk> IN (SELECT <target fk> FROM target WHERE inner)
303
- return rel.kind === "belongsTo"
305
+ // manyToMany: parent.<pk> IN (SELECT <sourceCol> FROM through
306
+ // WHERE <targetCol> IN (SELECT <target pk> FROM target WHERE inner))
307
+ if (rel.kind === "manyToMany") {
308
+ const targetSub: SqlExpr = { t: "sub", outerCol: rel.targetColumn, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false };
309
+ return { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.through, selectCol: rel.sourceColumn, where: targetSub, negate: false };
310
+ }
311
+ // oneHasOne mirrors belongsTo (this row's FK → target pk); oneHasOneInverse mirrors
312
+ // hasMany (target's column → this row's pk).
313
+ return rel.kind === "belongsTo" || rel.kind === "oneHasOne"
304
314
  ? { t: "sub", outerCol: rel.column, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false }
305
315
  : { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.target, selectCol: rel.column, where: inner, negate: false };
306
316
  }
@@ -336,6 +346,8 @@ export function compileScopedWhere(
336
346
  if (k === "AND" || k === "OR") {
337
347
  const groups = (v as Record<string, unknown>[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
338
348
  parts.push(k === "AND" ? and(...groups) : or(...groups));
349
+ } else if (k === "NOT") {
350
+ parts.push(not(compileScopedWhere(v as Record<string, unknown>, entity, ctx, depth, allowRelations)));
339
351
  } else if (relations[k]) {
340
352
  if (!allowRelations) {
341
353
  throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
package/src/runtime/db.ts CHANGED
@@ -412,6 +412,9 @@ export class Db<S extends SchemaDef = SchemaDef> {
412
412
  case "or":
413
413
  for (const p of expr.parts) this.addTouchedTables(p);
414
414
  break;
415
+ case "not":
416
+ this.addTouchedTables(expr.expr);
417
+ break;
415
418
  }
416
419
  }
417
420
 
@@ -427,6 +430,8 @@ export class Db<S extends SchemaDef = SchemaDef> {
427
430
  for (const [k, v] of Object.entries(where as Record<string, unknown>)) {
428
431
  if (k === "AND" || k === "OR") {
429
432
  for (const g of v as unknown[]) this.assertReadableWhere(from, scope, g);
433
+ } else if (k === "NOT") {
434
+ this.assertReadableWhere(from, scope, v);
430
435
  } else if (relations[k]) {
431
436
  continue; // relation traversal: enforced against the target's scope downstream
432
437
  } else if (!scope.fields.includes(k)) {
@@ -636,12 +641,44 @@ export class Db<S extends SchemaDef = SchemaDef> {
636
641
  return rows.map((row) => ({ key: row[col], row: project(row) }));
637
642
  };
638
643
 
639
- if (rel.kind === "belongsTo") {
640
- // parent[column] -> target.id
644
+ if (rel.kind === "belongsTo" || rel.kind === "oneHasOne") {
645
+ // parent[column] -> target.id (single-valued; oneHasOne is belongsTo + a 1:1 unique)
641
646
  const keys = [...new Set(rows.map((r) => r[rel.column]).filter((v) => v != null))];
642
647
  const byId = new Map<unknown, Row>();
643
648
  for (const { key, row } of await fetchBy(this.pkOf(rel.target), keys)) byId.set(key, row);
644
649
  for (const r of rows) r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
650
+ } else if (rel.kind === "oneHasOneInverse") {
651
+ // inverse 1:1 — target[column] -> parent.<pk>, single object (or null)
652
+ const pk = this.pkOf(parentEntity);
653
+ const ids = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
654
+ const byParent = new Map<unknown, Row>();
655
+ for (const { key, row } of await fetchBy(rel.column, ids)) if (!byParent.has(key)) byParent.set(key, row);
656
+ for (const r of rows) r[relName] = byParent.get(r[pk]) ?? null;
657
+ } else if (rel.kind === "manyToMany") {
658
+ // parent.<pk> -> junction(sourceColumn -> targetColumn) -> target.<pk>. The junction
659
+ // is read for just its two link columns (its own ACL isn't applied — like hasMany's
660
+ // intermediate); the target rows ARE scope-filtered by fetchBy, so an unreadable
661
+ // target simply drops out of the list.
662
+ const pk = this.pkOf(parentEntity);
663
+ const parentIds = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
664
+ if (parentIds.length === 0) {
665
+ for (const r of rows) r[relName] = [];
666
+ return;
667
+ }
668
+ this.touched.add(rel.through);
669
+ const linkSel = compileSelect({ from: rel.through, where: inList(rel.sourceColumn, parentIds) }, this.dialect);
670
+ const links = this.decodeRows(rel.through, await this.driver.exec(linkSel.sql, linkSel.params));
671
+ const targetIds = [...new Set(links.map((l) => l[rel.targetColumn]).filter((v) => v != null))];
672
+ const byTarget = new Map<unknown, Row>();
673
+ for (const { key, row } of await fetchBy(this.pkOf(rel.target), targetIds)) byTarget.set(key, row);
674
+ const grouped = new Map<unknown, Row[]>();
675
+ for (const l of links) {
676
+ const t = byTarget.get(l[rel.targetColumn]);
677
+ if (!t) continue; // target unreadable or missing -> excluded from the list
678
+ const src = l[rel.sourceColumn];
679
+ (grouped.get(src) ?? grouped.set(src, []).get(src)!).push(t);
680
+ }
681
+ for (const r of rows) r[relName] = grouped.get(r[pk]) ?? [];
645
682
  } else {
646
683
  // hasMany: target[column] -> parent.<pk> (NOT hardcoded `id` — a parent keyed by
647
684
  // slug/username would otherwise join on an undefined `r.id` and get []).
@@ -2,7 +2,7 @@
2
2
  // for a new column. Runs in TS inside the isolate; see runtime/migrate.ts for how
3
3
  // these are applied.
4
4
 
5
- import type { DefaultValue, EntityFields, FieldDef } from "../sdk/schema";
5
+ import type { DefaultValue, EntityFields, FieldDef, RelationDefs } from "../sdk/schema";
6
6
  import { quoteIdent } from "./driver";
7
7
 
8
8
  // SQLite has no boolean type; store as INTEGER 0/1. json + fileRef + uuid are
@@ -57,9 +57,47 @@ function columnSql(name: string, f: FieldDef): string {
57
57
  return s;
58
58
  }
59
59
 
60
- export function createTableSql(table: string, def: { fields: EntityFields }): string {
60
+ /** Table-level FOREIGN KEY clauses for an entity's owning relations. A real FK is emitted
61
+ * ONLY for a `belongsTo`/`oneHasOne` that declares `onDelete` — so FKs are opt-in and
62
+ * pre-existing logical relations are unaffected (no retroactive constraint on data). `pkOf`
63
+ * resolves the referenced entity's primary-key column. `skip` omits specific FK columns —
64
+ * the migrator uses it to drop an FK whose existing data has orphaned references. */
65
+ export function foreignKeyClauses(
66
+ def: { relations?: RelationDefs },
67
+ pkOf: (entity: string) => string,
68
+ skip?: ReadonlySet<string>,
69
+ ): string[] {
70
+ const out: string[] = [];
71
+ for (const rel of Object.values(def.relations ?? {})) {
72
+ if ((rel.kind !== "belongsTo" && rel.kind !== "oneHasOne") || rel.onDelete === undefined) continue; // FK only when onDelete is declared
73
+ if (skip?.has(rel.column)) continue;
74
+ const action = rel.onDelete === "cascade" ? "CASCADE" : rel.onDelete === "setNull" ? "SET NULL" : "RESTRICT";
75
+ out.push(`FOREIGN KEY (${quoteIdent(rel.column)}) REFERENCES ${quoteIdent(rel.target)}(${quoteIdent(pkOf(rel.target))}) ON DELETE ${action}`);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** The FK columns an entity declares (belongsTo with onDelete) → their {target, action}.
81
+ * Used by the migrator to compare declared FKs against the live `foreign_key_list`. */
82
+ export function declaredForeignKeys(def: { relations?: RelationDefs }): Map<string, { target: string; onDelete: string }> {
83
+ const out = new Map<string, { target: string; onDelete: string }>();
84
+ for (const rel of Object.values(def.relations ?? {})) {
85
+ if ((rel.kind !== "belongsTo" && rel.kind !== "oneHasOne") || rel.onDelete === undefined) continue;
86
+ const action = rel.onDelete === "cascade" ? "CASCADE" : rel.onDelete === "setNull" ? "SET NULL" : "RESTRICT";
87
+ out.set(rel.column, { target: rel.target, onDelete: action });
88
+ }
89
+ return out;
90
+ }
91
+
92
+ export function createTableSql(
93
+ table: string,
94
+ def: { fields: EntityFields; relations?: RelationDefs },
95
+ pkOf?: (entity: string) => string,
96
+ skipFks?: ReadonlySet<string>,
97
+ ): string {
61
98
  const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
62
- return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${cols.join(", ")})`;
99
+ const fks = pkOf ? foreignKeyClauses(def, pkOf, skipFks) : [];
100
+ return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${[...cols, ...fks].join(", ")})`;
63
101
  }
64
102
 
65
103
  /** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
@@ -77,12 +115,31 @@ export function indexName(table: string, col: string): string {
77
115
  return `pramen_idx_${table}_${col}`;
78
116
  }
79
117
 
118
+ /** Index name for a composite (multi-column) UNIQUE constraint. The `pramen_uidx_`
119
+ * prefix distinguishes managed composite uniques from single-column `pramen_idx_` ones,
120
+ * so the migrator can enumerate and reconcile just the ones it owns. */
121
+ export function compositeUniqueName(table: string, cols: readonly string[]): string {
122
+ return `pramen_uidx_${table}_${cols.join("_")}`;
123
+ }
124
+
125
+ /** Canonical key for a composite-unique column tuple (order-significant, matching the
126
+ * index definition). Used to compare declared vs live composite uniques. */
127
+ export function compositeKey(cols: readonly string[]): string {
128
+ return cols.join(",");
129
+ }
130
+
80
131
  /** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
81
132
  * via IF NOT EXISTS). Unique wins if a column declares both. `skipCols` omits specific
82
133
  * columns — the migrator uses it to avoid emitting a UNIQUE index that would throw
83
134
  * (duplicate values present on a column that just gained `unique()`); that delta is
84
- * reported as skipped instead. */
85
- export function indexStatements(table: string, def: { fields: EntityFields }, skipCols?: ReadonlySet<string>): string[] {
135
+ * reported as skipped instead. Entity-level composite uniques (`def.uniques`) are
136
+ * emitted too; `skipUniques` omits specific tuples (keyed by {@link compositeKey}). */
137
+ export function indexStatements(
138
+ table: string,
139
+ def: { fields: EntityFields; uniques?: readonly (readonly string[])[] },
140
+ skipCols?: ReadonlySet<string>,
141
+ skipUniques?: ReadonlySet<string>,
142
+ ): string[] {
86
143
  const out: string[] = [];
87
144
  for (const [col, f] of Object.entries(def.fields)) {
88
145
  if (!f.unique && !f.index) continue;
@@ -90,5 +147,10 @@ export function indexStatements(table: string, def: { fields: EntityFields }, sk
90
147
  const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
91
148
  out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
92
149
  }
150
+ for (const cols of def.uniques ?? []) {
151
+ if (cols.length === 0 || skipUniques?.has(compositeKey(cols))) continue;
152
+ const colList = cols.map((c) => quoteIdent(c)).join(", ");
153
+ out.push(`CREATE UNIQUE INDEX IF NOT EXISTS ${quoteIdent(compositeUniqueName(table, cols))} ON ${quoteIdent(table)} (${colList})`);
154
+ }
93
155
  return out;
94
156
  }
@@ -69,6 +69,12 @@ export interface Driver {
69
69
  exec(sql: string, params: unknown[]): Promise<Row[]>;
70
70
  /** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
71
71
  transaction<T>(fn: () => Promise<T>): Promise<T>;
72
+ /** Run a fixed sequence of write statements ATOMICALLY with FK checks deferred to the
73
+ * end — for a table rebuild that involves foreign keys (drop + recreate would trip an
74
+ * immediate FK check). Optional: absent on a driver whose migrate already runs inside a
75
+ * transaction (the DO), where the migrator falls back to sequential exec. Provided by the
76
+ * D1 driver (no interactive transactions — uses db.batch(), itself atomic). */
77
+ batch?(statements: ReadonlyArray<{ sql: string; params: unknown[] }>): Promise<void>;
72
78
  }
73
79
 
74
80
  /** DO SQLite — the in-process store. `SqlStorage` is synchronous; we wrap it as an
@@ -135,4 +141,15 @@ export class D1Driver implements Driver {
135
141
  transaction<T>(fn: () => Promise<T>): Promise<T> {
136
142
  return fn();
137
143
  }
144
+
145
+ /** D1's one atomic primitive: `session.batch()` runs the statements in a single
146
+ * transaction (rolled back as a unit on failure). Prepend `defer_foreign_keys` so a
147
+ * table rebuild's transient FK violations are checked only at the batch's commit. */
148
+ async batch(statements: ReadonlyArray<{ sql: string; params: unknown[] }>): Promise<void> {
149
+ const prepared = [
150
+ this.session.prepare("PRAGMA defer_foreign_keys = ON"),
151
+ ...statements.map((s) => (s.params.length ? this.session.prepare(s.sql).bind(...s.params) : this.session.prepare(s.sql))),
152
+ ];
153
+ await this.session.batch(prepared);
154
+ }
138
155
  }