@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.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger } from "./sdk/schema";
2
2
  export type { TriggerDef, TriggerOp } from "./sdk/schema";
3
3
  export { isValidUuid } from "./sdk/uuid";
4
- export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
4
+ export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, ManyToManyDef, OneHasOneDef, OneHasOneInverseDef, OnDelete, } from "./sdk/schema";
5
5
  export { createApp } from "./sdk/app";
6
6
  export { query, mutation, authorizeHandler } from "./sdk/handlers";
7
7
  export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
@@ -3,7 +3,7 @@
3
3
  // granted, the row-level predicate to merge into the query, and any field
4
4
  // restriction. Deny-by-default; grants OR-merge across the identity's roles.
5
5
  import { deny, isAllow, isDeny, isIdentityMarker, isInputMarker, isResolver, } from "../sdk/acl";
6
- import { and, compileWhere, evalExpr, FALSE, or, TRUE } from "./read-engine";
6
+ import { and, compileWhere, evalExpr, FALSE, not, or, TRUE } from "./read-engine";
7
7
  import { BadRequest, PramenError } from "./errors";
8
8
  export class AclDenied extends PramenError {
9
9
  entity;
@@ -183,6 +183,9 @@ function assertReadableRelationWhere(where, target, fields, ctx) {
183
183
  for (const g of v)
184
184
  assertReadableRelationWhere(g, target, fields, ctx);
185
185
  }
186
+ else if (k === "NOT") {
187
+ assertReadableRelationWhere(v, target, fields, ctx);
188
+ }
186
189
  else if (targetRels[k]) {
187
190
  continue;
188
191
  }
@@ -224,7 +227,15 @@ function relationPredicate(rel, nested, parentEntity, ctx, depth) {
224
227
  }
225
228
  // belongsTo: parent.<fk> IN (SELECT <target pk> FROM target WHERE inner)
226
229
  // hasMany: parent.<pk> IN (SELECT <target fk> FROM target WHERE inner)
227
- return rel.kind === "belongsTo"
230
+ // manyToMany: parent.<pk> IN (SELECT <sourceCol> FROM through
231
+ // WHERE <targetCol> IN (SELECT <target pk> FROM target WHERE inner))
232
+ if (rel.kind === "manyToMany") {
233
+ const targetSub = { t: "sub", outerCol: rel.targetColumn, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false };
234
+ return { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.through, selectCol: rel.sourceColumn, where: targetSub, negate: false };
235
+ }
236
+ // oneHasOne mirrors belongsTo (this row's FK → target pk); oneHasOneInverse mirrors
237
+ // hasMany (target's column → this row's pk).
238
+ return rel.kind === "belongsTo" || rel.kind === "oneHasOne"
228
239
  ? { t: "sub", outerCol: rel.column, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false }
229
240
  : { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.target, selectCol: rel.column, where: inner, negate: false };
230
241
  }
@@ -254,6 +265,9 @@ export function compileScopedWhere(rule, entity, ctx, depth = 0, allowRelations
254
265
  const groups = v.map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
255
266
  parts.push(k === "AND" ? and(...groups) : or(...groups));
256
267
  }
268
+ else if (k === "NOT") {
269
+ parts.push(not(compileScopedWhere(v, entity, ctx, depth, allowRelations)));
270
+ }
257
271
  else if (relations[k]) {
258
272
  if (!allowRelations) {
259
273
  throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
@@ -296,6 +296,9 @@ export class Db {
296
296
  for (const p of expr.parts)
297
297
  this.addTouchedTables(p);
298
298
  break;
299
+ case "not":
300
+ this.addTouchedTables(expr.expr);
301
+ break;
299
302
  }
300
303
  }
301
304
  /** Reject a user `where` that filters on a column the caller cannot read (closes
@@ -313,6 +316,9 @@ export class Db {
313
316
  for (const g of v)
314
317
  this.assertReadableWhere(from, scope, g);
315
318
  }
319
+ else if (k === "NOT") {
320
+ this.assertReadableWhere(from, scope, v);
321
+ }
316
322
  else if (relations[k]) {
317
323
  continue; // relation traversal: enforced against the target's scope downstream
318
324
  }
@@ -526,8 +532,8 @@ export class Db {
526
532
  const rows = this.decodeRows(rel.target, await this.driver.exec(sql, params));
527
533
  return rows.map((row) => ({ key: row[col], row: project(row) }));
528
534
  };
529
- if (rel.kind === "belongsTo") {
530
- // parent[column] -> target.id
535
+ if (rel.kind === "belongsTo" || rel.kind === "oneHasOne") {
536
+ // parent[column] -> target.id (single-valued; oneHasOne is belongsTo + a 1:1 unique)
531
537
  const keys = [...new Set(rows.map((r) => r[rel.column]).filter((v) => v != null))];
532
538
  const byId = new Map();
533
539
  for (const { key, row } of await fetchBy(this.pkOf(rel.target), keys))
@@ -535,6 +541,47 @@ export class Db {
535
541
  for (const r of rows)
536
542
  r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
537
543
  }
544
+ else if (rel.kind === "oneHasOneInverse") {
545
+ // inverse 1:1 — target[column] -> parent.<pk>, single object (or null)
546
+ const pk = this.pkOf(parentEntity);
547
+ const ids = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
548
+ const byParent = new Map();
549
+ for (const { key, row } of await fetchBy(rel.column, ids))
550
+ if (!byParent.has(key))
551
+ byParent.set(key, row);
552
+ for (const r of rows)
553
+ r[relName] = byParent.get(r[pk]) ?? null;
554
+ }
555
+ else if (rel.kind === "manyToMany") {
556
+ // parent.<pk> -> junction(sourceColumn -> targetColumn) -> target.<pk>. The junction
557
+ // is read for just its two link columns (its own ACL isn't applied — like hasMany's
558
+ // intermediate); the target rows ARE scope-filtered by fetchBy, so an unreadable
559
+ // target simply drops out of the list.
560
+ const pk = this.pkOf(parentEntity);
561
+ const parentIds = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
562
+ if (parentIds.length === 0) {
563
+ for (const r of rows)
564
+ r[relName] = [];
565
+ return;
566
+ }
567
+ this.touched.add(rel.through);
568
+ const linkSel = compileSelect({ from: rel.through, where: inList(rel.sourceColumn, parentIds) }, this.dialect);
569
+ const links = this.decodeRows(rel.through, await this.driver.exec(linkSel.sql, linkSel.params));
570
+ const targetIds = [...new Set(links.map((l) => l[rel.targetColumn]).filter((v) => v != null))];
571
+ const byTarget = new Map();
572
+ for (const { key, row } of await fetchBy(this.pkOf(rel.target), targetIds))
573
+ byTarget.set(key, row);
574
+ const grouped = new Map();
575
+ for (const l of links) {
576
+ const t = byTarget.get(l[rel.targetColumn]);
577
+ if (!t)
578
+ continue; // target unreadable or missing -> excluded from the list
579
+ const src = l[rel.sourceColumn];
580
+ (grouped.get(src) ?? grouped.set(src, []).get(src)).push(t);
581
+ }
582
+ for (const r of rows)
583
+ r[relName] = grouped.get(r[pk]) ?? [];
584
+ }
538
585
  else {
539
586
  // hasMany: target[column] -> parent.<pk> (NOT hardcoded `id` — a parent keyed by
540
587
  // slug/username would otherwise join on an undefined `r.id` and get []).
@@ -1,4 +1,4 @@
1
- import type { DefaultValue, EntityFields, FieldDef } from "../sdk/schema";
1
+ import type { DefaultValue, EntityFields, FieldDef, RelationDefs } from "../sdk/schema";
2
2
  export declare const sqlType: (f: FieldDef) => string;
3
3
  /** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1.
4
4
  * Exported for the migrator, which reconstructs a column's expected DEFAULT text to
@@ -10,20 +10,46 @@ export declare function defaultLiteral(v: DefaultValue): string;
10
10
  * by the migrator both to detect a default add/change on an existing column and to
11
11
  * COALESCE-backfill a NOT NULL column during a rebuild. */
12
12
  export declare function defaultSqlValue(f: FieldDef): string | null;
13
+ /** Table-level FOREIGN KEY clauses for an entity's owning relations. A real FK is emitted
14
+ * ONLY for a `belongsTo`/`oneHasOne` that declares `onDelete` — so FKs are opt-in and
15
+ * pre-existing logical relations are unaffected (no retroactive constraint on data). `pkOf`
16
+ * resolves the referenced entity's primary-key column. `skip` omits specific FK columns —
17
+ * the migrator uses it to drop an FK whose existing data has orphaned references. */
18
+ export declare function foreignKeyClauses(def: {
19
+ relations?: RelationDefs;
20
+ }, pkOf: (entity: string) => string, skip?: ReadonlySet<string>): string[];
21
+ /** The FK columns an entity declares (belongsTo with onDelete) → their {target, action}.
22
+ * Used by the migrator to compare declared FKs against the live `foreign_key_list`. */
23
+ export declare function declaredForeignKeys(def: {
24
+ relations?: RelationDefs;
25
+ }): Map<string, {
26
+ target: string;
27
+ onDelete: string;
28
+ }>;
13
29
  export declare function createTableSql(table: string, def: {
14
30
  fields: EntityFields;
15
- }): string;
31
+ relations?: RelationDefs;
32
+ }, pkOf?: (entity: string) => string, skipFks?: ReadonlySet<string>): string;
16
33
  /** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
17
34
  * NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
18
35
  * a populated table); a DEFAULT alone backfills existing rows. */
19
36
  export declare function addColumnSql(name: string, f: FieldDef): string;
20
37
  /** Index name for a column's unique/index constraint. */
21
38
  export declare function indexName(table: string, col: string): string;
39
+ /** Index name for a composite (multi-column) UNIQUE constraint. The `pramen_uidx_`
40
+ * prefix distinguishes managed composite uniques from single-column `pramen_idx_` ones,
41
+ * so the migrator can enumerate and reconcile just the ones it owns. */
42
+ export declare function compositeUniqueName(table: string, cols: readonly string[]): string;
43
+ /** Canonical key for a composite-unique column tuple (order-significant, matching the
44
+ * index definition). Used to compare declared vs live composite uniques. */
45
+ export declare function compositeKey(cols: readonly string[]): string;
22
46
  /** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
23
47
  * via IF NOT EXISTS). Unique wins if a column declares both. `skipCols` omits specific
24
48
  * columns — the migrator uses it to avoid emitting a UNIQUE index that would throw
25
49
  * (duplicate values present on a column that just gained `unique()`); that delta is
26
- * reported as skipped instead. */
50
+ * reported as skipped instead. Entity-level composite uniques (`def.uniques`) are
51
+ * emitted too; `skipUniques` omits specific tuples (keyed by {@link compositeKey}). */
27
52
  export declare function indexStatements(table: string, def: {
28
53
  fields: EntityFields;
29
- }, skipCols?: ReadonlySet<string>): string[];
54
+ uniques?: readonly (readonly string[])[];
55
+ }, skipCols?: ReadonlySet<string>, skipUniques?: ReadonlySet<string>): string[];
@@ -57,9 +57,39 @@ function columnSql(name, f) {
57
57
  s += defaultSql(f);
58
58
  return s;
59
59
  }
60
- export function createTableSql(table, def) {
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(def, pkOf, skip) {
66
+ const out = [];
67
+ for (const rel of Object.values(def.relations ?? {})) {
68
+ if ((rel.kind !== "belongsTo" && rel.kind !== "oneHasOne") || rel.onDelete === undefined)
69
+ continue; // FK only when onDelete is declared
70
+ if (skip?.has(rel.column))
71
+ continue;
72
+ const action = rel.onDelete === "cascade" ? "CASCADE" : rel.onDelete === "setNull" ? "SET NULL" : "RESTRICT";
73
+ out.push(`FOREIGN KEY (${quoteIdent(rel.column)}) REFERENCES ${quoteIdent(rel.target)}(${quoteIdent(pkOf(rel.target))}) ON DELETE ${action}`);
74
+ }
75
+ return out;
76
+ }
77
+ /** The FK columns an entity declares (belongsTo with onDelete) → their {target, action}.
78
+ * Used by the migrator to compare declared FKs against the live `foreign_key_list`. */
79
+ export function declaredForeignKeys(def) {
80
+ const out = new Map();
81
+ for (const rel of Object.values(def.relations ?? {})) {
82
+ if ((rel.kind !== "belongsTo" && rel.kind !== "oneHasOne") || rel.onDelete === undefined)
83
+ continue;
84
+ const action = rel.onDelete === "cascade" ? "CASCADE" : rel.onDelete === "setNull" ? "SET NULL" : "RESTRICT";
85
+ out.set(rel.column, { target: rel.target, onDelete: action });
86
+ }
87
+ return out;
88
+ }
89
+ export function createTableSql(table, def, pkOf, skipFks) {
61
90
  const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
62
- return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${cols.join(", ")})`;
91
+ const fks = pkOf ? foreignKeyClauses(def, pkOf, skipFks) : [];
92
+ return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${[...cols, ...fks].join(", ")})`;
63
93
  }
64
94
  /** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
65
95
  * NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
@@ -75,12 +105,24 @@ export function addColumnSql(name, f) {
75
105
  export function indexName(table, col) {
76
106
  return `pramen_idx_${table}_${col}`;
77
107
  }
108
+ /** Index name for a composite (multi-column) UNIQUE constraint. The `pramen_uidx_`
109
+ * prefix distinguishes managed composite uniques from single-column `pramen_idx_` ones,
110
+ * so the migrator can enumerate and reconcile just the ones it owns. */
111
+ export function compositeUniqueName(table, cols) {
112
+ return `pramen_uidx_${table}_${cols.join("_")}`;
113
+ }
114
+ /** Canonical key for a composite-unique column tuple (order-significant, matching the
115
+ * index definition). Used to compare declared vs live composite uniques. */
116
+ export function compositeKey(cols) {
117
+ return cols.join(",");
118
+ }
78
119
  /** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
79
120
  * via IF NOT EXISTS). Unique wins if a column declares both. `skipCols` omits specific
80
121
  * columns — the migrator uses it to avoid emitting a UNIQUE index that would throw
81
122
  * (duplicate values present on a column that just gained `unique()`); that delta is
82
- * reported as skipped instead. */
83
- export function indexStatements(table, def, skipCols) {
123
+ * reported as skipped instead. Entity-level composite uniques (`def.uniques`) are
124
+ * emitted too; `skipUniques` omits specific tuples (keyed by {@link compositeKey}). */
125
+ export function indexStatements(table, def, skipCols, skipUniques) {
84
126
  const out = [];
85
127
  for (const [col, f] of Object.entries(def.fields)) {
86
128
  if (!f.unique && !f.index)
@@ -90,5 +132,11 @@ export function indexStatements(table, def, skipCols) {
90
132
  const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
91
133
  out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
92
134
  }
135
+ for (const cols of def.uniques ?? []) {
136
+ if (cols.length === 0 || skipUniques?.has(compositeKey(cols)))
137
+ continue;
138
+ const colList = cols.map((c) => quoteIdent(c)).join(", ");
139
+ out.push(`CREATE UNIQUE INDEX IF NOT EXISTS ${quoteIdent(compositeUniqueName(table, cols))} ON ${quoteIdent(table)} (${colList})`);
140
+ }
93
141
  return out;
94
142
  }
@@ -30,6 +30,15 @@ export interface Driver {
30
30
  exec(sql: string, params: unknown[]): Promise<Row[]>;
31
31
  /** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
32
32
  transaction<T>(fn: () => Promise<T>): Promise<T>;
33
+ /** Run a fixed sequence of write statements ATOMICALLY with FK checks deferred to the
34
+ * end — for a table rebuild that involves foreign keys (drop + recreate would trip an
35
+ * immediate FK check). Optional: absent on a driver whose migrate already runs inside a
36
+ * transaction (the DO), where the migrator falls back to sequential exec. Provided by the
37
+ * D1 driver (no interactive transactions — uses db.batch(), itself atomic). */
38
+ batch?(statements: ReadonlyArray<{
39
+ sql: string;
40
+ params: unknown[];
41
+ }>): Promise<void>;
33
42
  }
34
43
  /** DO SQLite — the in-process store. `SqlStorage` is synchronous; we wrap it as an
35
44
  * async Driver. Transactions use the DO's atomic `transaction()`. */
@@ -76,4 +85,11 @@ export declare class D1Driver implements Driver {
76
85
  * fresh session at it and read its own writes. */
77
86
  getBookmark(): string | null;
78
87
  transaction<T>(fn: () => Promise<T>): Promise<T>;
88
+ /** D1's one atomic primitive: `session.batch()` runs the statements in a single
89
+ * transaction (rolled back as a unit on failure). Prepend `defer_foreign_keys` so a
90
+ * table rebuild's transient FK violations are checked only at the batch's commit. */
91
+ batch(statements: ReadonlyArray<{
92
+ sql: string;
93
+ params: unknown[];
94
+ }>): Promise<void>;
79
95
  }
@@ -94,4 +94,14 @@ export class D1Driver {
94
94
  transaction(fn) {
95
95
  return fn();
96
96
  }
97
+ /** D1's one atomic primitive: `session.batch()` runs the statements in a single
98
+ * transaction (rolled back as a unit on failure). Prepend `defer_foreign_keys` so a
99
+ * table rebuild's transient FK violations are checked only at the batch's commit. */
100
+ async batch(statements) {
101
+ const prepared = [
102
+ this.session.prepare("PRAGMA defer_foreign_keys = ON"),
103
+ ...statements.map((s) => (s.params.length ? this.session.prepare(s.sql).bind(...s.params) : this.session.prepare(s.sql))),
104
+ ];
105
+ await this.session.batch(prepared);
106
+ }
97
107
  }
@@ -37,7 +37,7 @@
37
37
  // ADD COLUMN is always nullable (SQLite can't add NOT NULL to a populated table).
38
38
  // A rename can't be inferred from a diff (a removed + added column is ambiguous),
39
39
  // so it must be declared with `renamedFrom`; otherwise it is applied as drop+add.
40
- import { addColumnSql, createTableSql, defaultSqlValue, indexName, indexStatements, sqlType } from "./ddl";
40
+ import { addColumnSql, compositeKey, createTableSql, declaredForeignKeys, defaultSqlValue, indexName, indexStatements, sqlType } from "./ddl";
41
41
  import { digest } from "./digest";
42
42
  import { quoteIdent } from "./driver";
43
43
  import { entitiesInPartition, partitionOf, validateSchema } from "../sdk/schema";
@@ -54,8 +54,18 @@ function isInternalTable(name) {
54
54
  }
55
55
  export function schemaHash(schema) {
56
56
  const canon = {};
57
- for (const [table, def] of Object.entries(schema))
58
- canon[table] = def.fields;
57
+ for (const [table, def] of Object.entries(schema)) {
58
+ // Keep the bare-`fields` shape when there are no composite uniques AND no FKs so
59
+ // existing stores' hashes are byte-identical (no spurious migration); fold each in
60
+ // only when present (composite unique / a belongsTo with onDelete).
61
+ const fks = declaredForeignKeys(def);
62
+ const extra = {};
63
+ if (def.uniques.length)
64
+ extra.uniques = def.uniques;
65
+ if (fks.size)
66
+ extra.fks = Object.fromEntries([...fks].map(([c, s]) => [c, `${s.target}:${s.onDelete}`]));
67
+ canon[table] = Object.keys(extra).length ? { fields: def.fields, ...extra } : def.fields;
68
+ }
59
69
  return digest(canon);
60
70
  }
61
71
  /** Live columns of a table -> their declared SQL type (uppercased). Empty if the
@@ -105,6 +115,30 @@ async function columnHasDuplicates(driver, table, col) {
105
115
  const rows = await driver.exec(`SELECT ${quoteIdent(col)} FROM ${quoteIdent(table)} WHERE ${quoteIdent(col)} IS NOT NULL GROUP BY ${quoteIdent(col)} HAVING COUNT(*) > 1 LIMIT 1`, []);
106
116
  return rows.length > 0;
107
117
  }
118
+ /** Managed composite-unique indexes live on `table`: `compositeKey(cols)` → index name.
119
+ * Only `pramen_uidx_`-prefixed multi-column unique indexes are pramen-managed, so the
120
+ * reconciler never touches a hand-created index. */
121
+ async function liveCompositeUniques(driver, table) {
122
+ const idx = (await driver.exec(`PRAGMA index_list(${quoteIdent(table)})`, []));
123
+ const out = new Map();
124
+ for (const i of idx) {
125
+ if (i.unique !== 1 || !i.name.startsWith("pramen_uidx_"))
126
+ continue;
127
+ const cols = (await driver.exec(`PRAGMA index_info(${quoteIdent(i.name)})`, []));
128
+ const names = cols.map((c) => c.name).filter((n) => n != null);
129
+ if (names.length >= 2)
130
+ out.set(names.join(","), i.name);
131
+ }
132
+ return out;
133
+ }
134
+ /** Does the column tuple hold a duplicate all-non-NULL combination? (A new composite
135
+ * UNIQUE over such data can't build its index.) Mirrors {@link columnHasDuplicates}. */
136
+ async function compositeHasDuplicates(driver, table, cols) {
137
+ const notNull = cols.map((c) => `${quoteIdent(c)} IS NOT NULL`).join(" AND ");
138
+ const group = cols.map((c) => quoteIdent(c)).join(", ");
139
+ const rows = await driver.exec(`SELECT 1 FROM ${quoteIdent(table)} WHERE ${notNull} GROUP BY ${group} HAVING COUNT(*) > 1 LIMIT 1`, []);
140
+ return rows.length > 0;
141
+ }
108
142
  /** Normalize a DEFAULT's SQL text for comparison: trim, and strip balanced outer
109
143
  * parens (SQLite reports an expr default with or without the wrapping parens the DDL
110
144
  * emitted — `(datetime('now'))` vs `datetime('now')` — depending on the engine, so the
@@ -140,13 +174,44 @@ async function readMeta(driver, key) {
140
174
  async function writeMeta(driver, key, value) {
141
175
  await driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES (?, ?)`, [key, value]);
142
176
  }
177
+ /** Live tables (excluding internals and `table` itself) holding a foreign key that
178
+ * REFERENCES `table`, with everything needed to drop + faithfully restore them around a
179
+ * rebuild of `table`: their column list, their exact CREATE TABLE DDL, and their index
180
+ * DDL (both verbatim from sqlite_master). Needed because `DROP TABLE parent` performs an
181
+ * implicit `DELETE FROM parent`, and `defer_foreign_keys` defers only violation CHECKS —
182
+ * ON DELETE actions still fire (CASCADE/SET NULL corrupt the holders' rows; RESTRICT
183
+ * aborts immediately). SQLite's official escape (`PRAGMA foreign_keys=OFF`) is
184
+ * unavailable on DO SQLite and D1, so the holders are quarantined instead. */
185
+ async function liveReferencingHolders(driver, table) {
186
+ const tables = (await driver.exec(`SELECT name, sql FROM sqlite_master WHERE type = 'table'`, []));
187
+ const out = [];
188
+ for (const t of tables) {
189
+ if (t.name === table || isInternalTable(t.name) || !t.sql)
190
+ continue;
191
+ const fks = (await driver.exec(`PRAGMA foreign_key_list(${quoteIdent(t.name)})`, []));
192
+ if (!fks.some((fk) => fk.table === table))
193
+ continue;
194
+ const cols = [...(await tableColumns(driver, t.name)).keys()];
195
+ const idx = (await driver.exec(`SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL`, [t.name]));
196
+ out.push({ name: t.name, cols, createSql: t.sql, indexSql: idx.map((r) => r.sql) });
197
+ }
198
+ return out;
199
+ }
143
200
  /** Rebuild a table to exactly the declared schema: create a temp table, copy each
144
201
  * desired column from its source (renamed or same-named live column, CAST on a
145
- * type change; brand-new columns left NULL), drop the old table, rename the temp. */
146
- async function rebuildTable(driver, table, def, live) {
202
+ * type change; brand-new columns left NULL), drop the old table, rename the temp.
203
+ *
204
+ * FK safety: dropping the old table implicit-DELETEs its rows, which fires the ON DELETE
205
+ * actions of any live FK that references it — even under `defer_foreign_keys` (deferral
206
+ * postpones checks, not actions). So every live referencing holder is QUARANTINED first
207
+ * (bare FK-less copy of its rows, table dropped) and restored from its verbatim DDL after
208
+ * the swap — all inside the same atomic step, so the holders' rows can never be cascaded
209
+ * away, nulled, or trip a RESTRICT mid-rebuild. A SELF-referential FK gets the same
210
+ * treatment applied to the rebuilt table itself: the plain tmp+rename swap would give tmp
211
+ * a live FK into the old table right when it's dropped, so the swap goes through a bare
212
+ * (FK-less) copy instead — quarantine out, recreate final, copy back. */
213
+ async function rebuildTable(driver, table, def, live, pkOf, skipFks) {
147
214
  const tmp = `__pramen_rebuild_${table}`;
148
- await driver.exec(`DROP TABLE IF EXISTS ${quoteIdent(tmp)}`, []);
149
- await driver.exec(createTableSql(tmp, def), []);
150
215
  const destCols = [];
151
216
  const srcExprs = [];
152
217
  for (const [name, field] of Object.entries(def.fields)) {
@@ -166,18 +231,107 @@ async function rebuildTable(driver, table, def, live) {
166
231
  destCols.push(quoteIdent(name));
167
232
  srcExprs.push(expr);
168
233
  }
169
- if (destCols.length > 0) {
170
- await driver.exec(`INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, []);
234
+ const holders = await liveReferencingHolders(driver, table);
235
+ // Self-referential FK declared in the new shape (and surviving skipFks) or live on
236
+ // the old table — forces the bare-copy swap for the table itself.
237
+ const declaredSelf = [...declaredForeignKeys(def)].some(([col, s]) => s.target === table && !skipFks?.has(col));
238
+ const liveSelf = [...(await liveForeignKeys(driver, table)).values()].some((s) => s.target === table);
239
+ const selfRef = declaredSelf || liveSelf;
240
+ // The drop+rename would trip an immediate FK check (dropping a referenced table, or a
241
+ // rebuilt table whose FKs momentarily see stale rows), so run the whole sequence
242
+ // ATOMICALLY: the D1 driver's batch() defers FK checks to the batch commit, and on the
243
+ // DO the ambient boot transaction (+ defer set at migrate start) already covers it.
244
+ const stmts = [];
245
+ // Quarantine tables are bare column lists — untyped, no constraints, no FKs. Values
246
+ // round-trip verbatim (they were already coerced by the original table's affinity).
247
+ const bareCopy = (name, quotedCols) => `CREATE TABLE ${quoteIdent(name)} (${quotedCols.join(", ")})`;
248
+ // 1. Quarantine every live holder referencing this table (FK-less row copy, then drop),
249
+ // so the swap's implicit DELETE has no FK actions left to fire into them.
250
+ for (const h of holders) {
251
+ const q = `__pramen_q_${h.name}`;
252
+ const cols = h.cols.map((c) => quoteIdent(c)).join(", ");
253
+ stmts.push({ sql: `DROP TABLE IF EXISTS ${quoteIdent(q)}`, params: [] });
254
+ stmts.push({ sql: bareCopy(q, h.cols.map((c) => quoteIdent(c))), params: [] });
255
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(q)} (${cols}) SELECT ${cols} FROM ${quoteIdent(h.name)}`, params: [] });
256
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(h.name)}`, params: [] });
171
257
  }
172
- await driver.exec(`DROP TABLE ${quoteIdent(table)}`, []);
173
- await driver.exec(`ALTER TABLE ${quoteIdent(tmp)} RENAME TO ${quoteIdent(table)}`, []);
258
+ // 2. Swap the table itself.
259
+ stmts.push({ sql: `DROP TABLE IF EXISTS ${quoteIdent(tmp)}`, params: [] });
260
+ if (selfRef) {
261
+ // Bare-copy swap: out to an FK-less temp, drop, recreate final, copy back. The final
262
+ // INSERT lists only the copied columns, so a new expr-default column still backfills.
263
+ stmts.push({ sql: bareCopy(tmp, destCols), params: [] });
264
+ if (destCols.length > 0) {
265
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, params: [] });
266
+ }
267
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(table)}`, params: [] });
268
+ stmts.push({ sql: createTableSql(table, def, pkOf, skipFks), params: [] });
269
+ if (destCols.length > 0) {
270
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(table)} (${destCols.join(", ")}) SELECT ${destCols.join(", ")} FROM ${quoteIdent(tmp)}`, params: [] });
271
+ }
272
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(tmp)}`, params: [] });
273
+ }
274
+ else {
275
+ stmts.push({ sql: createTableSql(tmp, def, pkOf, skipFks), params: [] });
276
+ if (destCols.length > 0) {
277
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, params: [] });
278
+ }
279
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(table)}`, params: [] });
280
+ stmts.push({ sql: `ALTER TABLE ${quoteIdent(tmp)} RENAME TO ${quoteIdent(table)}`, params: [] });
281
+ }
282
+ // 3. Restore each holder verbatim (its DDL references this table by name, so its FK
283
+ // binds to the rebuilt table). Row order/content unchanged; deferred checks validate
284
+ // the restored FKs once at commit.
285
+ for (const h of holders) {
286
+ const q = `__pramen_q_${h.name}`;
287
+ const cols = h.cols.map((c) => quoteIdent(c)).join(", ");
288
+ stmts.push({ sql: h.createSql, params: [] });
289
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(h.name)} (${cols}) SELECT ${cols} FROM ${quoteIdent(q)}`, params: [] });
290
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(q)}`, params: [] });
291
+ for (const idx of h.indexSql)
292
+ stmts.push({ sql: idx, params: [] });
293
+ }
294
+ if (driver.batch)
295
+ await driver.batch(stmts);
296
+ else
297
+ for (const s of stmts)
298
+ await driver.exec(s.sql, s.params); // DO: already inside the boot txn
299
+ }
300
+ /** Primary-key column of an entity (the `primaryKey()` field), defaulting to `id`. */
301
+ function pkColumnOf(def) {
302
+ if (def)
303
+ for (const [n, f] of Object.entries(def.fields))
304
+ if (f.primaryKey)
305
+ return n;
306
+ return "id";
307
+ }
308
+ /** Live FK columns of a table → {target, onDelete} via PRAGMA foreign_key_list. */
309
+ async function liveForeignKeys(driver, table) {
310
+ const rows = (await driver.exec(`PRAGMA foreign_key_list(${quoteIdent(table)})`, []));
311
+ const out = new Map();
312
+ for (const r of rows)
313
+ out.set(r.from, { target: r.table, onDelete: (r.on_delete || "NO ACTION").toUpperCase() });
314
+ return out;
315
+ }
316
+ /** Does the FK column hold a non-NULL value with no matching target row? (Adding the FK
317
+ * over such data would fail the deferred check at commit.) */
318
+ async function fkColumnHasOrphans(driver, table, col, target, targetPk) {
319
+ const rows = await driver.exec(`SELECT 1 FROM ${quoteIdent(table)} c WHERE c.${quoteIdent(col)} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${quoteIdent(target)} p WHERE p.${quoteIdent(targetPk)} = c.${quoteIdent(col)}) LIMIT 1`, []);
320
+ return rows.length > 0;
174
321
  }
175
322
  export async function migrate(driver, schema, opts = {}) {
176
323
  // Static schema invariants (relation targets exist, no cross-partition relations) —
177
324
  // checked before any DDL so a bad schema fails fast on boot / the D1 path, not mid-migration.
178
325
  validateSchema(schema);
179
326
  await driver.exec(`CREATE TABLE IF NOT EXISTS _pramen_meta (key TEXT PRIMARY KEY, value TEXT)`, []);
327
+ // Defer FK checks to the end of the migration transaction so drop/rebuild steps don't
328
+ // trip an immediate FK violation. On the DO the whole migrate runs in one transaction, so
329
+ // this one PRAGMA covers everything; on D1 (no ambient transaction) rebuilds instead go
330
+ // through driver.batch(), which sets its own defer — so this is a harmless no-op there.
331
+ await driver.exec(`PRAGMA defer_foreign_keys = ON`, []);
180
332
  const allowDestructive = opts.allowDestructive ?? false;
333
+ // Resolve a referenced entity's PK column (for FOREIGN KEY ... REFERENCES emission).
334
+ const pkOf = (entity) => pkColumnOf(schema[entity]);
181
335
  // When a partition is named, narrow the schema to just that partition's entities —
182
336
  // every later pass (create/alter/rebuild/drop/index/hash) iterates this subset, so
183
337
  // a partition-DO only ever touches its own tables. Unset ⇒ the whole schema, the
@@ -209,10 +363,13 @@ export async function migrate(driver, schema, opts = {}) {
209
363
  // Per-table: columns whose new `unique()` can't be indexed (duplicate values) — the
210
364
  // index pass must skip them so it doesn't throw. They're already reported in `skipped`.
211
365
  const uniqueIndexSkip = new Map();
366
+ // Per-table: composite-unique tuples (keyed by compositeKey) whose new index can't be
367
+ // built (duplicate tuples present) — skipped by the index pass, reported in `skipped`.
368
+ const compositeUniqueSkip = new Map();
212
369
  for (const [table, def] of entries) {
213
370
  const existing = await tableColumns(driver, table);
214
371
  if (existing.size === 0) {
215
- await driver.exec(createTableSql(table, def), []);
372
+ await driver.exec(createTableSql(table, def, pkOf), []);
216
373
  created.push(table);
217
374
  continue;
218
375
  }
@@ -305,6 +462,25 @@ export async function migrate(driver, schema, opts = {}) {
305
462
  dropUniqueCols.push(name); // drop the managed unique index (safe — no data loss)
306
463
  }
307
464
  }
465
+ // Foreign keys (belongsTo with onDelete). SQLite can't ALTER a table to add/change an
466
+ // FK, so any FK delta is enacted by a rebuild (safe — no data loss). An FK being ADDED
467
+ // over data with orphaned references is skipped (reported) and left out of the rebuild,
468
+ // mirroring the unique-over-duplicates behavior, so the migration doesn't fail.
469
+ const declaredFks = declaredForeignKeys(def);
470
+ const liveFks = await liveForeignKeys(driver, table);
471
+ const fkSkip = new Set();
472
+ for (const [col, spec] of declaredFks) {
473
+ if (!liveFks.has(col) && (await fkColumnHasOrphans(driver, table, col, spec.target, pkOf(spec.target)))) {
474
+ fkSkip.add(col);
475
+ skipped.push(`add FK ${table}.${col} → ${spec.target} (orphaned references present)`);
476
+ }
477
+ }
478
+ const applyFks = new Map([...declaredFks].filter(([col]) => !fkSkip.has(col)));
479
+ const fkChanged = applyFks.size !== liveFks.size ||
480
+ [...applyFks].some(([col, s]) => {
481
+ const l = liveFks.get(col);
482
+ return !l || l.target !== s.target || l.onDelete !== s.onDelete;
483
+ });
308
484
  const destructive = needsDrop || needsTypeChange || renamedSources.size > 0 || modifierRebuildDestructive;
309
485
  if (destructive && !allowDestructive) {
310
486
  // The destructive part is gated off — skip the whole rebuild (any pending safe
@@ -317,10 +493,10 @@ export async function migrate(driver, schema, opts = {}) {
317
493
  ];
318
494
  skipped.push(`rebuild ${table} (${reasons.join(", ")})`);
319
495
  }
320
- else if (destructive || needsAdditiveRebuild || modifierRebuildSafe) {
321
- // A safe rebuild (expr-default column, default/notNull modifier change) needs no
322
- // permission — it loses no data.
323
- await rebuildTable(driver, table, def, live);
496
+ else if (destructive || needsAdditiveRebuild || modifierRebuildSafe || fkChanged) {
497
+ // A safe rebuild (expr-default column, default/notNull modifier change, FK add/change)
498
+ // needs no permission — it loses no data.
499
+ await rebuildTable(driver, table, def, live, pkOf, fkSkip);
324
500
  rebuilt.push(table);
325
501
  }
326
502
  // Drop the managed unique index for a column that no longer declares `unique()`. A
@@ -330,6 +506,25 @@ export async function migrate(driver, schema, opts = {}) {
330
506
  for (const col of dropUniqueCols) {
331
507
  await driver.exec(`DROP INDEX IF EXISTS ${quoteIdent(indexName(table, col))}`, []);
332
508
  }
509
+ // Composite UNIQUE reconciliation (managed pramen_uidx_ indexes). Drop any live one
510
+ // the schema no longer declares; skip creating a new one whose tuples already have
511
+ // duplicates (the final index pass would otherwise throw). Creation itself is the
512
+ // idempotent index pass below.
513
+ const liveComposite = await liveCompositeUniques(driver, table);
514
+ const declaredComposite = new Set((def.uniques ?? []).map((c) => compositeKey(c)));
515
+ for (const [key, idxName] of liveComposite) {
516
+ if (!declaredComposite.has(key))
517
+ await driver.exec(`DROP INDEX IF EXISTS ${quoteIdent(idxName)}`, []);
518
+ }
519
+ for (const cols of def.uniques ?? []) {
520
+ const key = compositeKey(cols);
521
+ if (liveComposite.has(key))
522
+ continue;
523
+ if (await compositeHasDuplicates(driver, table, cols)) {
524
+ (compositeUniqueSkip.get(table) ?? compositeUniqueSkip.set(table, new Set()).get(table)).add(key);
525
+ skipped.push(`add composite UNIQUE ${table}(${cols.join(", ")}) (duplicate tuples present)`);
526
+ }
527
+ }
333
528
  }
334
529
  // A partition MOVE — an entity that was applied in THIS partition before but the
335
530
  // current schema assigns to a DIFFERENT partition — is NOT auto-migratable: the data
@@ -353,7 +548,7 @@ export async function migrate(driver, schema, opts = {}) {
353
548
  // declaration is dropped above. A column whose new `unique()` has duplicate values is
354
549
  // skipped (reported above) so this doesn't throw.
355
550
  for (const [table, def] of entries) {
356
- for (const stmt of indexStatements(table, def, uniqueIndexSkip.get(table)))
551
+ for (const stmt of indexStatements(table, def, uniqueIndexSkip.get(table), compositeUniqueSkip.get(table)))
357
552
  await driver.exec(stmt, []);
358
553
  }
359
554
  // Drop tables the schema no longer declares (internal bookkeeping tables skipped).