@fougere/adapter-sql 0.3.0-alpha.0 → 0.4.0-alpha.0
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/README.md +1 -1
- package/dist/crud.d.ts +3 -3
- package/dist/ddl.d.ts.map +1 -1
- package/dist/ddl.js +2 -2
- package/dist/ddl.js.map +1 -1
- package/dist/dialect.d.ts +8 -0
- package/dist/dialect.d.ts.map +1 -1
- package/dist/dialect.js +10 -0
- package/dist/dialect.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +2 -2
- package/dist/diff.js.map +1 -1
- package/dist/fields.d.ts +22 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +2 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/step.js +3 -3
- package/dist/step.js.map +1 -1
- package/dist/table.d.ts +18 -9
- package/dist/table.d.ts.map +1 -1
- package/dist/table.js +22 -17
- package/dist/table.js.map +1 -1
- package/package.json +7 -5
- package/src/check.ts +76 -0
- package/src/crud.ts +570 -0
- package/src/ddl.ts +242 -0
- package/src/dialect.ts +204 -0
- package/src/diff.ts +196 -0
- package/src/fields.ts +24 -0
- package/src/index.ts +40 -0
- package/src/setup.ts +63 -0
- package/src/sqlite.ts +43 -0
- package/src/step.ts +287 -0
- package/src/table.ts +447 -0
- package/src/values.ts +105 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export { createTableSQL, createIndexSQL, indexSQL, addForeignKeyConstraintSQL, generateSQL, autoMigrate, compiler } from './ddl.js';
|
|
2
|
+
export type { GenerateOptions, SqlSink } from './ddl.js';
|
|
3
|
+
export { toTable, toTables, toTableName, toSnakeCase, isKeyed, orderTables } from './table.js';
|
|
4
|
+
export type {
|
|
5
|
+
TableDef,
|
|
6
|
+
ColumnDef,
|
|
7
|
+
ColumnShape,
|
|
8
|
+
ColumnReference,
|
|
9
|
+
RelationResolve,
|
|
10
|
+
FkEdge,
|
|
11
|
+
TableOrder,
|
|
12
|
+
EntityEntry,
|
|
13
|
+
FrondLike,
|
|
14
|
+
AppLike,
|
|
15
|
+
} from './table.js';
|
|
16
|
+
export {
|
|
17
|
+
columnTypeFor,
|
|
18
|
+
dialects,
|
|
19
|
+
resolveDialect,
|
|
20
|
+
sqliteDialect,
|
|
21
|
+
pgDialect,
|
|
22
|
+
mysqlDialect,
|
|
23
|
+
mssqlDialect,
|
|
24
|
+
} from './dialect.js';
|
|
25
|
+
export type { Dialect, DialectName } from './dialect.js';
|
|
26
|
+
export type { SqlField, SqlFields } from './fields.js';
|
|
27
|
+
export { SqlEntityOrm, createOrmFactory } from './crud.js';
|
|
28
|
+
export type { OrmFactoryOptions } from './crud.js';
|
|
29
|
+
export { codecFor, codecsOf } from './values.js';
|
|
30
|
+
export type { ValueCodec } from './values.js';
|
|
31
|
+
// The driver this package owns is NOT here: `better-sqlite3` is native and `node:fs` is a
|
|
32
|
+
// builtin, and an index that re-exported them made the whole adapter unreachable from a
|
|
33
|
+
// runtime that has neither. It lives at `@fougere/adapter-sql/sqlite`.
|
|
34
|
+
export { setupKysely, sqlSink } from './setup.js';
|
|
35
|
+
export type { Setup, SetupOptions } from './setup.js';
|
|
36
|
+
export { actualState, desiredTables, delta, orderChanges, changeSQL, planMigration, migrate } from './diff.js';
|
|
37
|
+
export type { SchemaState, Change } from './diff.js';
|
|
38
|
+
// The non-additive half — realised only from a step a human wrote down.
|
|
39
|
+
export { planStep, collapseChain, stepSQL, applyStep } from './step.js';
|
|
40
|
+
export type { Plan, PlanOptions, Refusal, StepChange } from './step.js';
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage setup — the shape every engine answers, and no driver at all.
|
|
3
|
+
*
|
|
4
|
+
* Fougere has no business choosing a driver (`pg`, `mysql2`, `tedious`, `better-sqlite3`,
|
|
5
|
+
* a D1 binding), so the caller builds the Kysely dialect and hands it over — no dynamic
|
|
6
|
+
* import, no optional dependency. This file therefore reaches for nothing: it is what a
|
|
7
|
+
* runtime with no filesystem imports. The one driver this package does own lives behind
|
|
8
|
+
* `@fougere/adapter-sql/sqlite`.
|
|
9
|
+
*/
|
|
10
|
+
import { Kysely, sql, type Dialect as KyselyDialect } from 'kysely';
|
|
11
|
+
import { createOrmFactory, type OrmFactoryOptions } from './crud.js';
|
|
12
|
+
import type { DialectName } from './dialect.js';
|
|
13
|
+
import type { SqlSink } from './ddl.js';
|
|
14
|
+
|
|
15
|
+
export interface SetupOptions {
|
|
16
|
+
/** Override naming for specific entities (e.g. better-auth wants singular table names). */
|
|
17
|
+
ormFactoryOptions?: OrmFactoryOptions;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Setup {
|
|
21
|
+
dialect: DialectName;
|
|
22
|
+
ormFactory: ReturnType<typeof createOrmFactory>;
|
|
23
|
+
/** Runs raw statements — what `autoMigrate` writes through. */
|
|
24
|
+
sink: SqlSink;
|
|
25
|
+
/**
|
|
26
|
+
* The Kysely instance, for what precedes any entity: `migrate(app, setup)` writes the
|
|
27
|
+
* schema through it, and a script may need it before a container exists.
|
|
28
|
+
*
|
|
29
|
+
* It is not the way to reach data from inside an app — that is the injected `EntityOrm`,
|
|
30
|
+
* whose `client` gives the same handle while keeping the scope of its entity.
|
|
31
|
+
*/
|
|
32
|
+
db: Kysely<any>;
|
|
33
|
+
/**
|
|
34
|
+
* Run `fn` inside one transaction of this engine, with an ORM factory bound to it.
|
|
35
|
+
*
|
|
36
|
+
* The transaction belongs to the engine, so obtaining one is a gesture on the engine and
|
|
37
|
+
* nowhere else. Nothing new is handed back: `Transaction<DB> extends Kysely<DB>` and
|
|
38
|
+
* `SqlEntityOrm` takes a `Kysely<any>`, so the SAME ORM is rebuilt over the substituted
|
|
39
|
+
* connection — which is why a frame needs no support in this package.
|
|
40
|
+
*/
|
|
41
|
+
transacted<R>(fn: (ormFactory: ReturnType<typeof createOrmFactory>) => Promise<R>): Promise<R>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A sink that runs statements through Kysely — works on every engine. */
|
|
45
|
+
export function sqlSink(db: Kysely<any>): SqlSink {
|
|
46
|
+
return { execute: (statement: string) => sql.raw(statement).execute(db) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Wrap any Kysely dialect — Postgres, MySQL, SQL Server. */
|
|
50
|
+
export function setupKysely(
|
|
51
|
+
kyselyDialect: KyselyDialect,
|
|
52
|
+
dialect: DialectName,
|
|
53
|
+
opts: SetupOptions = {},
|
|
54
|
+
): Setup {
|
|
55
|
+
const db = new Kysely<any>({ dialect: kyselyDialect });
|
|
56
|
+
return {
|
|
57
|
+
db,
|
|
58
|
+
dialect,
|
|
59
|
+
ormFactory: createOrmFactory(db, opts.ormFactoryOptions, dialect),
|
|
60
|
+
sink: sqlSink(db),
|
|
61
|
+
transacted: (fn) => db.transaction().execute((trx) => fn(createOrmFactory(trx, opts.ormFactoryOptions, dialect))),
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/sqlite.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite on a file — the convention a first run meets, and the only driver this package owns.
|
|
3
|
+
*
|
|
4
|
+
* It sits behind its own subpath because `better-sqlite3` is a NATIVE module and `node:fs`
|
|
5
|
+
* is a builtin: a bundler cannot prune a module that imports them, so re-exporting this
|
|
6
|
+
* from the index made the whole adapter unreachable from a runtime that has neither. Same
|
|
7
|
+
* cut as `@fougere/transport-http/receive`, and for the same reason — share the projection,
|
|
8
|
+
* never the plumbing.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync } from 'node:fs';
|
|
11
|
+
import { dirname } from 'node:path';
|
|
12
|
+
import { Kysely, SqliteDialect } from 'kysely';
|
|
13
|
+
import Database from 'better-sqlite3';
|
|
14
|
+
import { createOrmFactory } from './crud.js';
|
|
15
|
+
import { sqlSink, type Setup, type SetupOptions } from './setup.js';
|
|
16
|
+
|
|
17
|
+
export interface SqliteSetupOptions extends SetupOptions {
|
|
18
|
+
/** Filesystem path to the database. Defaults to a project-local file. */
|
|
19
|
+
path?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SqliteSetup extends Setup {
|
|
23
|
+
/** The raw handle, for pragmas and synchronous exec. */
|
|
24
|
+
sqlite: Database.Database;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function setupSqlite(opts: SqliteSetupOptions = {}): SqliteSetup {
|
|
28
|
+
const path = opts.path ?? 'fougere.db';
|
|
29
|
+
// A file-backed DB needs its directory — SQLite won't create it.
|
|
30
|
+
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
|
31
|
+
const sqlite = new Database(path);
|
|
32
|
+
sqlite.pragma('journal_mode = WAL');
|
|
33
|
+
sqlite.pragma('foreign_keys = ON');
|
|
34
|
+
const db = new Kysely<any>({ dialect: new SqliteDialect({ database: sqlite }) });
|
|
35
|
+
return {
|
|
36
|
+
db,
|
|
37
|
+
sqlite,
|
|
38
|
+
dialect: 'sqlite',
|
|
39
|
+
ormFactory: createOrmFactory(db, opts.ormFactoryOptions, 'sqlite'),
|
|
40
|
+
sink: sqlSink(db),
|
|
41
|
+
transacted: (fn) => db.transaction().execute((trx) => fn(createOrmFactory(trx, opts.ormFactoryOptions, 'sqlite'))),
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/step.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half `delta()` refuses — realised from an intention that was written down.
|
|
3
|
+
*
|
|
4
|
+
* `diff.ts` states its own guarantee: additive only, because "a rename is not even
|
|
5
|
+
* detectable from a diff (it reads as a drop plus an add)". That still holds, and this
|
|
6
|
+
* file does not weaken it. What changed is upstream: a frozen step (`fougere freeze`)
|
|
7
|
+
* carries a rename because a human declared it at the moment they made it. The intention
|
|
8
|
+
* exists now, so it can be realised — and only what the step actually says.
|
|
9
|
+
*
|
|
10
|
+
* A drop and a rename both touch live data, so this is the only place either can come
|
|
11
|
+
* from: never introspection, never a guess.
|
|
12
|
+
*/
|
|
13
|
+
import { sql, type Kysely } from 'kysely';
|
|
14
|
+
import type { Change as ShapeChange, SetDiff } from '@fougere/schema';
|
|
15
|
+
import { dequal } from 'dequal';
|
|
16
|
+
import { compiler } from './ddl.js';
|
|
17
|
+
import { type DialectName } from './dialect.js';
|
|
18
|
+
import { toSnakeCase, toTableName, type TableDef } from './table.js';
|
|
19
|
+
import type { SchemaState } from './diff.js';
|
|
20
|
+
|
|
21
|
+
/** What a step asks of the tables — beyond what an additive pass already covers. */
|
|
22
|
+
export type StepChange =
|
|
23
|
+
| { kind: 'renameColumn'; table: string; from: string; to: string }
|
|
24
|
+
| { kind: 'dropColumn'; table: string; column: string };
|
|
25
|
+
|
|
26
|
+
/** Something the step asks and the DDL will not do, naming why and what fixes it. */
|
|
27
|
+
export interface Refusal {
|
|
28
|
+
entity: string;
|
|
29
|
+
field: string;
|
|
30
|
+
reason: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Plan {
|
|
34
|
+
changes: StepChange[];
|
|
35
|
+
/**
|
|
36
|
+
* Empty means the step is realisable whole. Anything here is a decision the DDL may
|
|
37
|
+
* not take alone — reported together so one run names every one of them.
|
|
38
|
+
*/
|
|
39
|
+
refusals: Refusal[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PlanOptions {
|
|
43
|
+
/** Entity key → table name. Same resolver `desiredTables` takes. */
|
|
44
|
+
tableName?: (name: string) => string;
|
|
45
|
+
/**
|
|
46
|
+
* What the database actually holds, from `actualState`. Given, a change already
|
|
47
|
+
* realised is skipped.
|
|
48
|
+
*
|
|
49
|
+
* Idempotence by OBSERVATION and not by bookkeeping — the same choice `delta` makes.
|
|
50
|
+
* A ledger of applied steps would be a second record of a fact the columns already
|
|
51
|
+
* carry, and the two would disagree the day someone renamed a column by hand.
|
|
52
|
+
*/
|
|
53
|
+
actual?: SchemaState;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Collapse a chain of steps into one, following each field through its renames.
|
|
58
|
+
*
|
|
59
|
+
* A step judges itself on two names — old gone, new here — so an intermediate rename is
|
|
60
|
+
* unrecognisable once its target has been renamed again: both names are absent and it is
|
|
61
|
+
* proposed forever. Composing the chain first asks the question about the name the field
|
|
62
|
+
* ENDS on, which is the only one the tables can answer.
|
|
63
|
+
*/
|
|
64
|
+
export function collapseChain(steps: readonly SetDiff[]): SetDiff {
|
|
65
|
+
const entities: SetDiff['entities'] = {};
|
|
66
|
+
const added: string[] = [];
|
|
67
|
+
const removed: string[] = [];
|
|
68
|
+
|
|
69
|
+
for (const step of steps) {
|
|
70
|
+
added.push(...step.entitiesAdded);
|
|
71
|
+
removed.push(...step.entitiesRemoved);
|
|
72
|
+
for (const [entity, answer] of Object.entries(step.entities)) {
|
|
73
|
+
const held = (entities[entity] ??= { changes: [], ambiguous: [] });
|
|
74
|
+
held.ambiguous.push(...answer.ambiguous);
|
|
75
|
+
for (const change of answer.changes) compose(held.changes, change);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { entities, entitiesAdded: [...new Set(added)], entitiesRemoved: [...new Set(removed)] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Add one change to what the chain has said so far, rewriting rather than appending when
|
|
84
|
+
* it continues a field already moved. A rename back to its origin cancels: the tables
|
|
85
|
+
* never held the name in between, so there is nothing for them to do.
|
|
86
|
+
*/
|
|
87
|
+
function compose(held: ShapeChange[], change: ShapeChange): void {
|
|
88
|
+
if (change.kind === 'renamed') {
|
|
89
|
+
const at = held.findIndex((each) => each.kind === 'renamed' && each.to === change.from);
|
|
90
|
+
if (at === -1) {
|
|
91
|
+
held.push(change);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const first = held[at] as Extract<ShapeChange, { kind: 'renamed' }>;
|
|
95
|
+
if (first.from === change.to) held.splice(at, 1);
|
|
96
|
+
else held[at] = { ...first, to: change.to };
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Anything else names a field; if the chain renamed it earlier, the tables know it
|
|
101
|
+
// under the name it started with.
|
|
102
|
+
const field = 'field' in change ? change.field : undefined;
|
|
103
|
+
const at = held.findIndex((each) => each.kind === 'renamed' && each.to === field);
|
|
104
|
+
if (at === -1) {
|
|
105
|
+
held.push(change);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const origin = held[at] as Extract<ShapeChange, { kind: 'renamed' }>;
|
|
110
|
+
// A field the chain ends by dropping is dropped under its original name, and the
|
|
111
|
+
// renames that led there are work nobody has to do.
|
|
112
|
+
if (change.kind === 'removed') held.splice(at, 1);
|
|
113
|
+
held.push({ ...change, field: origin.from });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Turn a frozen step into what the tables must do, and what nobody may decide for you.
|
|
118
|
+
*
|
|
119
|
+
* Pure, like `delta` — the comparison is one thing, running it is another.
|
|
120
|
+
*/
|
|
121
|
+
export function planStep(step: SetDiff, tables: TableDef[], options: PlanOptions = {}): Plan {
|
|
122
|
+
const resolve = options.tableName ?? toTableName;
|
|
123
|
+
const actual = options.actual;
|
|
124
|
+
const byName = new Map(tables.map((table) => [table.name, table]));
|
|
125
|
+
const changes: StepChange[] = [];
|
|
126
|
+
const refusals: Refusal[] = [];
|
|
127
|
+
|
|
128
|
+
for (const [entity, answer] of Object.entries(step.entities)) {
|
|
129
|
+
const table = resolve(entity);
|
|
130
|
+
// A step for an entity this app no longer projects has nothing to act on. Saying so
|
|
131
|
+
// beats emitting SQL against a table that is not there.
|
|
132
|
+
if (!byName.has(table)) {
|
|
133
|
+
refusals.push({ entity, field: '*', reason: `no table '${table}' in this app — did the entity move?` });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const change of answer.changes) {
|
|
138
|
+
const decided = realise(entity, table, change, byName.get(table)!);
|
|
139
|
+
if ('reason' in decided) refusals.push(decided);
|
|
140
|
+
else if (decided.change && !done(decided.change, actual?.get(table))) changes.push(decided.change);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { changes, refusals };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One shape change: a column instruction, nothing to do, or a refusal that names itself. */
|
|
148
|
+
function realise(
|
|
149
|
+
entity: string,
|
|
150
|
+
table: string,
|
|
151
|
+
change: ShapeChange,
|
|
152
|
+
target: TableDef,
|
|
153
|
+
): { change?: StepChange } | Refusal {
|
|
154
|
+
switch (change.kind) {
|
|
155
|
+
case 'renamed':
|
|
156
|
+
// The one thing introspection could never infer, and the reason a step exists.
|
|
157
|
+
return { change: { kind: 'renameColumn', table, from: toSnakeCase(change.from), to: toSnakeCase(change.to) } };
|
|
158
|
+
|
|
159
|
+
case 'removed':
|
|
160
|
+
return { change: { kind: 'dropColumn', table, column: toSnakeCase(change.field) } };
|
|
161
|
+
|
|
162
|
+
case 'added': {
|
|
163
|
+
// The additive pass adds it — unless it cannot: a NOT NULL column with no default
|
|
164
|
+
// fails on a table that already holds rows, and `addColumn` silently leaves it
|
|
165
|
+
// nullable instead. Two guarantees for one entity, decided by whether the table
|
|
166
|
+
// existed yesterday. Refusing here is what makes the declaration true either way.
|
|
167
|
+
if (!change.required) return {};
|
|
168
|
+
const column = target.columns.find((each) => each.field === change.field);
|
|
169
|
+
if (column?.default !== undefined) return {};
|
|
170
|
+
return {
|
|
171
|
+
entity,
|
|
172
|
+
field: change.field,
|
|
173
|
+
reason: `required with no default — existing rows have nothing to hold. Declare one: default(…)`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case 'required':
|
|
178
|
+
if (!change.to) return {}; // Loosening is the engine's business, and no row is at risk.
|
|
179
|
+
return {
|
|
180
|
+
entity,
|
|
181
|
+
field: change.field,
|
|
182
|
+
reason: `became required — rows written before it may hold nothing. Declare a default, or keep it optional`,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
case 'retyped':
|
|
186
|
+
return {
|
|
187
|
+
entity,
|
|
188
|
+
field: change.field,
|
|
189
|
+
reason: `type moved ${change.from.join('|')} → ${change.to.join('|')} — no conversion is derivable, write the migration`,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
case 'reshaped':
|
|
193
|
+
// A tightened bound is a CHECK, and altering one on a live table is engine-specific
|
|
194
|
+
// AND may be refused by rows already stored. The judge still enforces it at the door.
|
|
195
|
+
return {
|
|
196
|
+
entity,
|
|
197
|
+
field: change.field,
|
|
198
|
+
reason: `bounds moved — the door enforces them, the table keeps its old CHECK until you migrate it`,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
case 'restated':
|
|
202
|
+
return restated(entity, change);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* An axis other than shape moved. Only some of it is a fact about the table.
|
|
208
|
+
*
|
|
209
|
+
* `boundary` never is — it says who may read or write, which no column holds. `lifecycle`
|
|
210
|
+
* is one only through the DEFAULT clause. `role` is one three times over, and two of those
|
|
211
|
+
* are constraints a live table may already contradict.
|
|
212
|
+
*/
|
|
213
|
+
function restated(entity: string, change: Extract<ShapeChange, { kind: 'restated' }>): { change?: StepChange } | Refusal {
|
|
214
|
+
const refuse = (reason: string): Refusal => ({ entity, field: change.field, reason });
|
|
215
|
+
|
|
216
|
+
if (change.axis === 'boundary') return {};
|
|
217
|
+
|
|
218
|
+
if (change.axis === 'lifecycle') {
|
|
219
|
+
const was = literalOf(change.from);
|
|
220
|
+
const is = literalOf(change.to);
|
|
221
|
+
if (was === is) return {};
|
|
222
|
+
return refuse(`default moved ${show(was)} → ${show(is)} — the table keeps the old one, and nothing here alters a DEFAULT`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const from = change.from ?? {};
|
|
226
|
+
const to = change.to ?? {};
|
|
227
|
+
if (from.primary !== to.primary) return refuse(`primary moved — a key is not something a step may take from live rows`);
|
|
228
|
+
if (!dequal(from.unique, to.unique)) {
|
|
229
|
+
// The same reason `delta` cannot add one: CREATE UNIQUE INDEX fails on a table that
|
|
230
|
+
// already holds duplicates, so it is a decision about the rows, not about the DDL.
|
|
231
|
+
return refuse(`unique group moved — rows already stored may contradict it, so it is declared and applied by hand`);
|
|
232
|
+
}
|
|
233
|
+
if (!dequal(from.relation, to.relation)) return refuse(`relation moved — a foreign key is a constraint, and nothing here alters one`);
|
|
234
|
+
// What is left is the index, and only its appearance: the additive pass proposes every
|
|
235
|
+
// declared index at every boot, and nothing has ever dropped one.
|
|
236
|
+
if (from.index && !to.index) return refuse(`index gone — nothing drops an index today, so the table keeps it`);
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
/** The value a lifecycle declares at create, when it declares one — what reaches DEFAULT. */
|
|
242
|
+
function literalOf(rules: { create?: unknown } | undefined): unknown {
|
|
243
|
+
const create = rules?.create;
|
|
244
|
+
return create && typeof create === 'object' && 'value' in create ? (create as { value: unknown }).value : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const show = (value: unknown): string => (value === undefined ? 'none' : JSON.stringify(value));
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Has the table already moved? Read off the columns themselves.
|
|
251
|
+
*
|
|
252
|
+
* A rename whose old name is gone and whose new one is there has happened; a drop whose
|
|
253
|
+
* column is absent has happened. Unknown state (no introspection given) answers no, so a
|
|
254
|
+
* plan built without it proposes everything the step says.
|
|
255
|
+
*/
|
|
256
|
+
function done(change: StepChange, columns: Set<string> | undefined): boolean {
|
|
257
|
+
if (!columns) return false;
|
|
258
|
+
return change.kind === 'renameColumn'
|
|
259
|
+
? !columns.has(change.from) && columns.has(change.to)
|
|
260
|
+
: !columns.has(change.column);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** One statement per change — the same rule `migrate` follows: no driver here batches. */
|
|
264
|
+
export function stepSQL(change: StepChange, dialectName: DialectName = 'sqlite'): string {
|
|
265
|
+
const alter = compiler(dialectName).schema.alterTable(change.table);
|
|
266
|
+
return change.kind === 'renameColumn'
|
|
267
|
+
? alter.renameColumn(change.from, change.to).compile().sql
|
|
268
|
+
: alter.dropColumn(change.column).compile().sql;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Run a step. Refuses whole rather than part-way: a plan with any refusal in it is a
|
|
273
|
+
* plan someone has to read, and half a rename is worse than none.
|
|
274
|
+
*/
|
|
275
|
+
export async function applyStep(plan: Plan, db: Kysely<any>, dialectName: DialectName = 'sqlite'): Promise<string[]> {
|
|
276
|
+
if (plan.refusals.length > 0) {
|
|
277
|
+
const named = plan.refusals.map((one) => ` ${one.entity}.${one.field} — ${one.reason}`).join('\n');
|
|
278
|
+
throw new Error(`This step cannot be realised as it stands:\n${named}`);
|
|
279
|
+
}
|
|
280
|
+
const run: string[] = [];
|
|
281
|
+
for (const change of plan.changes) {
|
|
282
|
+
const statement = stepSQL(change, dialectName);
|
|
283
|
+
await sql.raw(statement).execute(db);
|
|
284
|
+
run.push(statement);
|
|
285
|
+
}
|
|
286
|
+
return run;
|
|
287
|
+
}
|