@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 +1 -1
- package/dist/runtime/acl.js +16 -2
- package/dist/runtime/db.js +49 -2
- package/dist/runtime/ddl.d.ts +30 -4
- package/dist/runtime/ddl.js +52 -4
- package/dist/runtime/driver.d.ts +16 -0
- package/dist/runtime/driver.js +10 -0
- package/dist/runtime/migrate.js +212 -17
- package/dist/runtime/read-engine.d.ts +13 -0
- package/dist/runtime/read-engine.js +35 -0
- package/dist/sdk/infer.d.ts +10 -4
- package/dist/sdk/schema.d.ts +69 -3
- package/dist/sdk/schema.js +30 -1
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/runtime/acl.ts +14 -2
- package/src/runtime/db.ts +39 -2
- package/src/runtime/ddl.ts +67 -5
- package/src/runtime/driver.ts +17 -0
- package/src/runtime/migrate.ts +219 -17
- package/src/runtime/read-engine.ts +35 -0
- package/src/sdk/infer.ts +10 -4
- package/src/sdk/schema.ts +77 -4
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";
|
package/dist/runtime/acl.js
CHANGED
|
@@ -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
|
-
|
|
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)`);
|
package/dist/runtime/db.js
CHANGED
|
@@ -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 []).
|
package/dist/runtime/ddl.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
54
|
+
uniques?: readonly (readonly string[])[];
|
|
55
|
+
}, skipCols?: ReadonlySet<string>, skipUniques?: ReadonlySet<string>): string[];
|
package/dist/runtime/ddl.js
CHANGED
|
@@ -57,9 +57,39 @@ function columnSql(name, f) {
|
|
|
57
57
|
s += defaultSql(f);
|
|
58
58
|
return s;
|
|
59
59
|
}
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/runtime/driver.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/runtime/driver.js
CHANGED
|
@@ -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
|
}
|
package/dist/runtime/migrate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
|
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).
|