@fougere/adapter-sql 0.6.0-alpha.0 → 0.8.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 +2 -2
- package/dist/adapter.schema.json +16 -0
- package/dist/check.d.ts +2 -27
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +2 -27
- package/dist/check.js.map +1 -1
- package/dist/crud.d.ts +21 -81
- package/dist/crud.d.ts.map +1 -1
- package/dist/crud.js +58 -102
- package/dist/crud.js.map +1 -1
- package/dist/ddl.d.ts +8 -53
- package/dist/ddl.d.ts.map +1 -1
- package/dist/ddl.js +16 -55
- package/dist/ddl.js.map +1 -1
- package/dist/dialect.d.ts +7 -50
- package/dist/dialect.d.ts.map +1 -1
- package/dist/dialect.js +2 -5
- package/dist/dialect.js.map +1 -1
- package/dist/diff.d.ts +6 -44
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +20 -50
- package/dist/diff.js.map +1 -1
- package/dist/drift.d.ts +29 -0
- package/dist/drift.d.ts.map +1 -0
- package/dist/drift.js +53 -0
- package/dist/drift.js.map +1 -0
- package/dist/fields.d.ts +10 -9
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +8 -1
- package/dist/fields.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/order.d.ts +23 -0
- package/dist/order.d.ts.map +1 -0
- package/dist/order.js +40 -0
- package/dist/order.js.map +1 -0
- package/dist/query.d.ts +2 -17
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +1 -11
- package/dist/query.js.map +1 -1
- package/dist/setup.d.ts +8 -36
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +11 -21
- package/dist/setup.js.map +1 -1
- package/dist/sqlite.d.ts.map +1 -1
- package/dist/sqlite.js +12 -20
- package/dist/sqlite.js.map +1 -1
- package/dist/step.d.ts +4 -33
- package/dist/step.d.ts.map +1 -1
- package/dist/step.js +11 -46
- package/dist/step.js.map +1 -1
- package/dist/table.d.ts +15 -77
- package/dist/table.d.ts.map +1 -1
- package/dist/table.js +34 -146
- package/dist/table.js.map +1 -1
- package/dist/values.d.ts +1 -17
- package/dist/values.d.ts.map +1 -1
- package/dist/values.js +2 -7
- package/dist/values.js.map +1 -1
- package/package.json +4 -4
- package/src/adapter.schema.json +16 -0
- package/src/check.ts +2 -27
- package/src/crud.ts +66 -105
- package/src/ddl.ts +14 -55
- package/src/dialect.ts +7 -50
- package/src/diff.ts +18 -50
- package/src/drift.ts +75 -0
- package/src/fields.ts +17 -8
- package/src/index.ts +5 -3
- package/src/order.ts +63 -0
- package/src/query.ts +2 -17
- package/src/setup.ts +19 -49
- package/src/sqlite.ts +13 -20
- package/src/step.ts +12 -54
- package/src/table.ts +42 -185
- package/src/values.ts +3 -24
package/src/drift.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** What a table PROMISES and what it now holds, when the two stopped agreeing. */
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
|
|
4
|
+
import type { TableDef } from './table.js';
|
|
5
|
+
|
|
6
|
+
/** One column whose declaration moved and whose table did not follow. */
|
|
7
|
+
export interface Drift {
|
|
8
|
+
table: string;
|
|
9
|
+
column: string;
|
|
10
|
+
/** What the entity says now. */
|
|
11
|
+
declared: string;
|
|
12
|
+
/** What the table still enforces. */
|
|
13
|
+
held: string;
|
|
14
|
+
/** What it costs, said where the reader is — a boot, not a migration plan. */
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A migration is ADDITIVE by design: it creates what is missing and never touches a
|
|
20
|
+
* column that exists. That is a promise worth keeping — nothing is dropped behind your
|
|
21
|
+
* back — but it leaves a second fact unsaid, and unsaid is where it hurt: relax a
|
|
22
|
+
* `required` field and the table keeps its NOT NULL, so the write fails on a row, in
|
|
23
|
+
* production, long after the boot that could have named it.
|
|
24
|
+
*
|
|
25
|
+
* What is read here is what `actualState` already receives and throws away. `SchemaState`
|
|
26
|
+
* stays a set of names on purpose: `done()` reads it to decide whether a frozen step was
|
|
27
|
+
* already applied, and a wider one would make a replayed migration answer wrong.
|
|
28
|
+
*/
|
|
29
|
+
export async function drift(db: Kysely<any>, desired: TableDef[]): Promise<Drift[]> {
|
|
30
|
+
const held = new Map(
|
|
31
|
+
(await db.introspection.getTables())
|
|
32
|
+
.filter((table) => !table.isView)
|
|
33
|
+
.map((table) => [table.name, table.columns]),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const found: Drift[] = [];
|
|
37
|
+
for (const table of desired) {
|
|
38
|
+
const columns = held.get(table.name);
|
|
39
|
+
if (!columns) continue;
|
|
40
|
+
|
|
41
|
+
for (const column of table.columns) {
|
|
42
|
+
const live = columns.find((one) => one.name === column.name);
|
|
43
|
+
// A column the table does not have yet is the additive pass's business, not this one.
|
|
44
|
+
if (!live) continue;
|
|
45
|
+
|
|
46
|
+
if (column.nullable && !live.isNullable) {
|
|
47
|
+
found.push({
|
|
48
|
+
table: table.name,
|
|
49
|
+
column: column.name,
|
|
50
|
+
declared: 'optional',
|
|
51
|
+
held: 'NOT NULL',
|
|
52
|
+
reason: 'a write with no value for it fails at the row, not at boot',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (!column.nullable && live.isNullable && !column.primary) {
|
|
56
|
+
found.push({
|
|
57
|
+
table: table.name,
|
|
58
|
+
column: column.name,
|
|
59
|
+
declared: 'required',
|
|
60
|
+
held: 'nullable',
|
|
61
|
+
reason: 'the door refuses what the table would still accept',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The one line a boot has room for, and the detail under it. */
|
|
71
|
+
export const driftReport = (found: Drift[]): string =>
|
|
72
|
+
`${found.length} column(s) declare one thing and the table holds another:\n`
|
|
73
|
+
+ found
|
|
74
|
+
.map((one) => ` ${one.table}.${one.column} — declared ${one.declared}, table keeps ${one.held}: ${one.reason}`)
|
|
75
|
+
.join('\n');
|
package/src/fields.ts
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What an entity may state for THIS adapter, declared from OUTSIDE `@fougere/schema` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* It replaces what the shape would have given, and only for the engine it names: an
|
|
6
|
-
* engine absent here keeps the shape's answer, so the entity still boots on every
|
|
7
|
-
* dialect. What an engine must HONOR is not stated here — it is a decision, and it
|
|
8
|
-
* belongs in `fougere.config.ts` beside `remotes:`, `sources:` and `ports:`.
|
|
2
|
+
* What an entity may state for THIS adapter, declared from OUTSIDE `@fougere/schema` — which names
|
|
3
|
+
* no engine and no column type, and must not learn one to let this exist.
|
|
9
4
|
*/
|
|
5
|
+
import { AdapterFieldValidator, type Shape } from '@fougere/schema';
|
|
6
|
+
import ENTRY_FORMAT from './adapter.schema.json' with { type: 'json' };
|
|
10
7
|
import type { DialectName } from './dialect.js';
|
|
11
8
|
|
|
9
|
+
/** The engines the format names — the one list, read off the file that states it. */
|
|
10
|
+
type Engine = keyof typeof ENTRY_FORMAT.properties.columnType.properties;
|
|
11
|
+
|
|
12
12
|
/** What sql holds, addressed by field — the shape every augmentation of the registry takes. */
|
|
13
13
|
export type SqlFields<K extends string> = Readonly<Partial<Record<K, SqlField>>>;
|
|
14
14
|
|
|
15
15
|
export interface SqlField {
|
|
16
16
|
/** The column type to emit, per engine. An engine absent here keeps the shape's own. */
|
|
17
|
-
readonly columnType?: Readonly<Partial<Record<
|
|
17
|
+
readonly columnType?: Readonly<Partial<Record<Engine, string>>>;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Judges what an entity states under `adapters.sql`, against the format this adapter ships. */
|
|
21
|
+
export const sqlEntries = AdapterFieldValidator.of(ENTRY_FORMAT as Shape);
|
|
22
|
+
|
|
23
|
+
type Assert<T extends true> = T;
|
|
24
|
+
/** A fifth dialect does not compile until `adapter.schema.json` names it. */
|
|
25
|
+
type _EnginesMatchDialects = Assert<
|
|
26
|
+
[Exclude<DialectName, Engine>] extends [never] ? true : false
|
|
27
|
+
>;
|
|
28
|
+
|
|
20
29
|
declare module '@fougere/schema' {
|
|
21
30
|
interface FougereEntityAdapters<K extends string> {
|
|
22
31
|
sql?: SqlFields<K>;
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
export { createTableSQL, createIndexSQL, indexSQL, addForeignKeyConstraintSQL, generateSQL, autoMigrate, compiler } from './ddl.js';
|
|
2
2
|
export { onQuery, logQueries, type QueryEvent, type QuerySink } from './query.js';
|
|
3
|
+
export type { FkEdge, TableOrder } from './order.js';
|
|
3
4
|
export type { GenerateOptions, SqlSink } from './ddl.js';
|
|
4
|
-
export { toTable, toTables, toTableName, toSnakeCase, isKeyed
|
|
5
|
+
export { toTable, toTables, toTableName, toSnakeCase, isKeyed } from './table.js';
|
|
6
|
+
export { orderTables } from './order.js';
|
|
5
7
|
export type {
|
|
6
8
|
TableDef,
|
|
7
9
|
ColumnDef,
|
|
8
10
|
ColumnShape,
|
|
9
11
|
ColumnReference,
|
|
10
12
|
RelationResolve,
|
|
11
|
-
FkEdge,
|
|
12
|
-
TableOrder,
|
|
13
13
|
EntityEntry,
|
|
14
14
|
FrondLike,
|
|
15
15
|
AppLike,
|
|
@@ -34,6 +34,8 @@ export type { ValueCodec } from './values.js';
|
|
|
34
34
|
// runtime that has neither. It lives at `@fougere/adapter-sql/sqlite`.
|
|
35
35
|
export { setupKysely, sqlSink } from './setup.js';
|
|
36
36
|
export type { Setup, SqlSource, SetupOptions } from './setup.js';
|
|
37
|
+
export { drift, driftReport } from './drift.js';
|
|
38
|
+
export type { Drift } from './drift.js';
|
|
37
39
|
export { actualState, desiredTables, delta, orderChanges, changeSQL, planMigration, migrate } from './diff.js';
|
|
38
40
|
export type { SchemaState, Change } from './diff.js';
|
|
39
41
|
// The non-additive half — realised only from a step a human wrote down.
|
package/src/order.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In what order a batch of tables is created — the one question that reads a `TableDef` and
|
|
3
|
+
* knows nothing above it: no entity, no axis, no schema.
|
|
4
|
+
*/
|
|
5
|
+
import type { ColumnDef, TableDef } from './table.js';
|
|
6
|
+
|
|
7
|
+
export interface FkEdge {
|
|
8
|
+
table: TableDef;
|
|
9
|
+
column: ColumnDef;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TableOrder {
|
|
13
|
+
/** Tables in dependency order — a `ref()` target always precedes its referrer. */
|
|
14
|
+
ordered: TableDef[];
|
|
15
|
+
/** FK columns whose target could not be ordered first — a cycle. Constrain after creation. */
|
|
16
|
+
deferred: FkEdge[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Order a table set so a `ref()`'s target always exists before the table that points at it —
|
|
21
|
+
* required by every engine except SQLite, which resolves FK targets lazily and accepts any order
|
|
22
|
+
* (and has no `ALTER TABLE ADD CONSTRAINT` to close a cycle with — a caller on that dialect skips
|
|
23
|
+
* this function entirely).
|
|
24
|
+
*/
|
|
25
|
+
export function orderTables(tables: TableDef[]): TableOrder {
|
|
26
|
+
const byName = new Map(tables.map((table) => [table.name, table]));
|
|
27
|
+
const needs = new Map(
|
|
28
|
+
tables.map((table) => [
|
|
29
|
+
table.name,
|
|
30
|
+
new Set(
|
|
31
|
+
table.columns
|
|
32
|
+
.filter((c) => c.references && c.references.table !== table.name && byName.has(c.references.table))
|
|
33
|
+
.map((c) => c.references!.table),
|
|
34
|
+
),
|
|
35
|
+
]),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const ordered: TableDef[] = [];
|
|
39
|
+
const deferred: FkEdge[] = [];
|
|
40
|
+
const done = new Set<string>();
|
|
41
|
+
|
|
42
|
+
while (needs.size > 0) {
|
|
43
|
+
const ready = [...needs.keys()].find((name) => [...needs.get(name)!].every((dep) => done.has(dep)));
|
|
44
|
+
if (ready) {
|
|
45
|
+
ordered.push(byName.get(ready)!);
|
|
46
|
+
done.add(ready);
|
|
47
|
+
needs.delete(ready);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// Every table left waits on another table left — a cycle. Break it by
|
|
51
|
+
// deferring one edge: every column of the first remaining table that points
|
|
52
|
+
// at its first unmet dependency.
|
|
53
|
+
const [name, deps] = [...needs.entries()][0];
|
|
54
|
+
const dep = [...deps].find((d) => !done.has(d))!;
|
|
55
|
+
const table = byName.get(name)!;
|
|
56
|
+
for (const column of table.columns) {
|
|
57
|
+
if (column.references?.table === dep) deferred.push({ table, column });
|
|
58
|
+
}
|
|
59
|
+
deps.delete(dep);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { ordered, deferred };
|
|
63
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -5,12 +5,7 @@ export interface QueryEvent {
|
|
|
5
5
|
/** Which storage ran it — a process may open several (`sources:`). */
|
|
6
6
|
storage: string;
|
|
7
7
|
sql: string;
|
|
8
|
-
/**
|
|
9
|
-
* How MANY parameters, never their values.
|
|
10
|
-
*
|
|
11
|
-
* They are user data nobody chose to expose — the rule the call log states for a body.
|
|
12
|
-
* The count is what a reader needs anyway: it is how you see an `IN (?, ?, ?)` grow.
|
|
13
|
-
*/
|
|
8
|
+
/** How MANY parameters, never their values. */
|
|
14
9
|
parameters: number;
|
|
15
10
|
/** Rounded to three decimals: a sub-microsecond statement reports 17 digits otherwise. */
|
|
16
11
|
ms: number;
|
|
@@ -22,17 +17,7 @@ export type QuerySink = (event: QueryEvent) => void;
|
|
|
22
17
|
|
|
23
18
|
const sinks: QuerySink[] = [];
|
|
24
19
|
|
|
25
|
-
/**
|
|
26
|
-
* Be told about every statement this process runs, and get the unsubscription back.
|
|
27
|
-
*
|
|
28
|
-
* A subscription and not an option, for one reason that decides it: Kysely takes its `log`
|
|
29
|
-
* at CONSTRUCTION, and the storage is built during the boot — before any extension's
|
|
30
|
-
* `up(app)` runs. So the only way to subscribe afterwards is for the log to be installed
|
|
31
|
-
* from the start and route to whoever is listening.
|
|
32
|
-
*
|
|
33
|
-
* The same shape core states for `onLog` and observability for `onSpan`. Costs nothing when
|
|
34
|
-
* nobody listens: `logQueries` returns on an empty list before touching the event.
|
|
35
|
-
*/
|
|
20
|
+
/** Be told about every statement this process runs, and get the unsubscription back. */
|
|
36
21
|
export function onQuery(sink: QuerySink): () => void {
|
|
37
22
|
sinks.push(sink);
|
|
38
23
|
|
package/src/setup.ts
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** Storage setup — the shape every engine answers, and no driver at all. */
|
|
10
2
|
import { Kysely, sql, type Dialect as KyselyDialect } from 'kysely';
|
|
11
3
|
import type { Source, SourceView } from '@fougere/core';
|
|
12
4
|
import { createStorageFactory, type StorageFactoryOptions } from './crud.js';
|
|
13
5
|
import { logQueries } from './query.js';
|
|
14
|
-
import { migrate } from './diff.js';
|
|
6
|
+
import { desiredTables, migrate } from './diff.js';
|
|
7
|
+
import { drift, driftReport } from './drift.js';
|
|
15
8
|
import { toTableName } from './table.js';
|
|
16
9
|
import type { DialectName } from './dialect.js';
|
|
17
10
|
import type { SqlSink } from './ddl.js';
|
|
@@ -19,63 +12,39 @@ import type { SqlSink } from './ddl.js';
|
|
|
19
12
|
export interface SetupOptions {
|
|
20
13
|
/** Override naming for specific entities (e.g. better-auth wants singular table names). */
|
|
21
14
|
storageFactoryOptions?: StorageFactoryOptions;
|
|
22
|
-
/**
|
|
23
|
-
* What to call this storage when a query is reported.
|
|
24
|
-
*
|
|
25
|
-
* A process may open several (`sources:`), and a module-level sink sees them all — without
|
|
26
|
-
* a name, two statements from two databases read as one stream. Defaults to what
|
|
27
|
-
* distinguishes them anyway: the engine here, the file path for SQLite.
|
|
28
|
-
*/
|
|
15
|
+
/** What to call this storage when a query is reported. */
|
|
29
16
|
name?: string;
|
|
30
17
|
}
|
|
31
18
|
|
|
32
|
-
/**
|
|
33
|
-
* A `Source` realized by SQL — and the three members that are SQL's, not the routing's.
|
|
34
|
-
*
|
|
35
|
-
* `dialect`, `db` and `sink` are to a source what `Kysely` is to `SqlStorage.client`: what
|
|
36
|
-
* this adapter is MADE OF, reached by narrowing. Nothing that routes entities across
|
|
37
|
-
* sources reads them.
|
|
38
|
-
*/
|
|
19
|
+
/** A `Source` realized by SQL — and the three members that are SQL's, not the routing's. */
|
|
39
20
|
export interface SqlSource extends Source {
|
|
40
21
|
dialect: DialectName;
|
|
41
22
|
storageFactory: ReturnType<typeof createStorageFactory>;
|
|
42
23
|
/** Runs raw statements — what `autoMigrate` writes through. */
|
|
43
24
|
sink: SqlSink;
|
|
44
25
|
/**
|
|
45
|
-
* The Kysely instance, for what precedes any entity: `migrate(app, setup)` writes the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* It is not the way to reach data from inside an app — that is the injected `Storage`,
|
|
49
|
-
* whose `client` gives the same handle while keeping the scope of its entity.
|
|
26
|
+
* The Kysely instance, for what precedes any entity: `migrate(app, setup)` writes the schema
|
|
27
|
+
* through it, and a script may need it before a container exists.
|
|
50
28
|
*/
|
|
51
29
|
db: Kysely<any>;
|
|
52
|
-
/**
|
|
53
|
-
* Run `fn` inside one transaction of this engine, with a storage factory bound to it.
|
|
54
|
-
*
|
|
55
|
-
* The transaction belongs to the engine, so obtaining one is a gesture on the engine and
|
|
56
|
-
* nowhere else. Nothing new is handed back: `Transaction<DB> extends Kysely<DB>` and
|
|
57
|
-
* `SqlStorage` takes a `Kysely<any>`, so the SAME storage is rebuilt over the substituted
|
|
58
|
-
* connection — which is why a frame needs no support in this package.
|
|
59
|
-
*/
|
|
30
|
+
/** Run `fn` inside one transaction of this engine, with a storage factory bound to it. */
|
|
60
31
|
transacted<R>(fn: (storageFactory: ReturnType<typeof createStorageFactory>) => Promise<R>): Promise<R>;
|
|
61
32
|
}
|
|
62
33
|
|
|
63
34
|
/** The name this shape answered to before it was one realization among several. */
|
|
64
35
|
export type Setup = SqlSource;
|
|
65
36
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
* dialect, so every source was migrated as sqlite, the documented Postgres case included.
|
|
71
|
-
* A source knows its own engine, so the gesture is its own.
|
|
72
|
-
*/
|
|
37
|
+
/** What every SQL engine keeps at the rows, whatever the dialect: the index IS the constraint. */
|
|
38
|
+
export const sqlEnforces = ['unique'] as const;
|
|
39
|
+
|
|
40
|
+
/** The migration of what lives in ONE sql source, carrying its own dialect. */
|
|
73
41
|
function migrating(db: Kysely<any>, dialect: DialectName, opts: SetupOptions) {
|
|
74
|
-
return async (view: SourceView): Promise<void> => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
42
|
+
return async (view: SourceView): Promise<void | string> => {
|
|
43
|
+
const options = { dialect, tableName: opts.storageFactoryOptions?.tableName ?? toTableName };
|
|
44
|
+
await migrate(view as never, db, options);
|
|
45
|
+
const found = await drift(db, desiredTables(view as never, options));
|
|
46
|
+
|
|
47
|
+
return found.length ? driftReport(found) : undefined;
|
|
79
48
|
};
|
|
80
49
|
}
|
|
81
50
|
|
|
@@ -99,6 +68,7 @@ export function setupKysely(
|
|
|
99
68
|
migrate: migrating(db, dialect, opts),
|
|
100
69
|
close: () => db.destroy(),
|
|
101
70
|
name: opts.name ?? dialect,
|
|
71
|
+
enforces: sqlEnforces,
|
|
102
72
|
transacted: (fn) => db.transaction().execute((trx) => fn(createStorageFactory(trx, opts.storageFactoryOptions, dialect))),
|
|
103
73
|
};
|
|
104
74
|
}
|
package/src/sqlite.ts
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** SQLite on a file — the convention a first run meets, and the only driver this package owns. */
|
|
10
2
|
import { mkdirSync } from 'node:fs';
|
|
11
3
|
import { dirname } from 'node:path';
|
|
12
4
|
import { Kysely, SqliteDialect } from 'kysely';
|
|
13
5
|
import Database from 'better-sqlite3';
|
|
14
6
|
import { createStorageFactory } from './crud.js';
|
|
15
7
|
import { logQueries } from './query.js';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
8
|
+
import { drift, driftReport } from './drift.js';
|
|
9
|
+
import { sqlSink, sqlEnforces, type SetupOptions, type SqlSource } from './setup.js';
|
|
10
|
+
import { desiredTables, migrate } from './diff.js';
|
|
18
11
|
import { toTableName } from './table.js';
|
|
19
12
|
import { Sources, type Source, type SourceConfig, type SourceView } from '@fougere/core';
|
|
20
13
|
|
|
@@ -43,22 +36,22 @@ export function setupSqlite(opts: SqliteSetupOptions = {}): SqliteSetup {
|
|
|
43
36
|
storageFactory: createStorageFactory(db, opts.storageFactoryOptions, 'sqlite'),
|
|
44
37
|
sink: sqlSink(db),
|
|
45
38
|
migrate: async (view: SourceView) => {
|
|
46
|
-
|
|
39
|
+
const options = { dialect: 'sqlite' as const, tableName: opts.storageFactoryOptions?.tableName ?? toTableName };
|
|
40
|
+
await migrate(view as never, db, options);
|
|
41
|
+
// What the additive pass left alone and the entity no longer agrees with. Asked
|
|
42
|
+
// AFTER, so a column it just created is judged against what it just wrote.
|
|
43
|
+
const found = await drift(db, desiredTables(view as never, options));
|
|
44
|
+
|
|
45
|
+
return found.length ? driftReport(found) : undefined;
|
|
47
46
|
},
|
|
48
47
|
close: () => db.destroy(),
|
|
49
48
|
name: opts.name ?? path,
|
|
49
|
+
enforces: sqlEnforces,
|
|
50
50
|
transacted: (fn) => db.transaction().execute((trx) => fn(createStorageFactory(trx, opts.storageFactoryOptions, 'sqlite'))),
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
* `source: 'sql'` — answered HERE and not in the driver-free index, because answering a
|
|
56
|
-
* name means building a driver, and this is the one this package owns.
|
|
57
|
-
*
|
|
58
|
-
* The refusal is `refuseUnresolvable` moved: it lived in `@fougere/defaults`, which happened
|
|
59
|
-
* to import the driver, so a second adapter had nowhere to say it exists. A dialect this
|
|
60
|
-
* package cannot build from a name is refused here, where the reason is true.
|
|
61
|
-
*/
|
|
54
|
+
/** `source. */
|
|
62
55
|
Sources.register('sql', (conf: SourceConfig): Source => {
|
|
63
56
|
const dialect = conf.dialect as string | undefined;
|
|
64
57
|
if (dialect !== undefined && dialect !== 'sqlite') {
|
package/src/step.ts
CHANGED
|
@@ -1,15 +1,4 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** The half `delta()` refuses — realised from an intention that was written down. */
|
|
13
2
|
import { sql, type Kysely } from 'kysely';
|
|
14
3
|
import type { Change as ShapeChange, SetDiff } from '@fougere/schema';
|
|
15
4
|
import { dequal } from 'dequal';
|
|
@@ -42,25 +31,11 @@ export interface Plan {
|
|
|
42
31
|
export interface PlanOptions {
|
|
43
32
|
/** Entity key → table name. Same resolver `desiredTables` takes. */
|
|
44
33
|
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
|
-
*/
|
|
34
|
+
/** What the database actually holds, from `actualState`. */
|
|
53
35
|
actual?: SchemaState;
|
|
54
36
|
}
|
|
55
37
|
|
|
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
|
-
*/
|
|
38
|
+
/** Collapse a chain of steps into one, following each field through its renames. */
|
|
64
39
|
export function collapseChain(steps: readonly SetDiff[]): SetDiff {
|
|
65
40
|
const entities: SetDiff['entities'] = {};
|
|
66
41
|
const added: string[] = [];
|
|
@@ -70,9 +45,9 @@ export function collapseChain(steps: readonly SetDiff[]): SetDiff {
|
|
|
70
45
|
added.push(...step.entitiesAdded);
|
|
71
46
|
removed.push(...step.entitiesRemoved);
|
|
72
47
|
for (const [entity, answer] of Object.entries(step.entities)) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
for (const change of answer.changes) compose(
|
|
48
|
+
const entry = (entities[entity] ??= { changes: [], ambiguous: [] });
|
|
49
|
+
entry.ambiguous.push(...answer.ambiguous);
|
|
50
|
+
for (const change of answer.changes) compose(entry.changes, change);
|
|
76
51
|
}
|
|
77
52
|
}
|
|
78
53
|
|
|
@@ -80,9 +55,8 @@ export function collapseChain(steps: readonly SetDiff[]): SetDiff {
|
|
|
80
55
|
}
|
|
81
56
|
|
|
82
57
|
/**
|
|
83
|
-
* Add one change to what the chain has said so far, rewriting rather than appending when
|
|
84
|
-
*
|
|
85
|
-
* never held the name in between, so there is nothing for them to do.
|
|
58
|
+
* Add one change to what the chain has said so far, rewriting rather than appending when it
|
|
59
|
+
* continues a field already moved.
|
|
86
60
|
*/
|
|
87
61
|
function compose(held: ShapeChange[], change: ShapeChange): void {
|
|
88
62
|
if (change.kind === 'renamed') {
|
|
@@ -113,11 +87,7 @@ function compose(held: ShapeChange[], change: ShapeChange): void {
|
|
|
113
87
|
held.push({ ...change, field: origin.from });
|
|
114
88
|
}
|
|
115
89
|
|
|
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
|
-
*/
|
|
90
|
+
/** Turn a frozen step into what the tables must do, and what nobody may decide for you. */
|
|
121
91
|
export function planStep(step: SetDiff, tables: TableDef[], options: PlanOptions = {}): Plan {
|
|
122
92
|
const resolve = options.tableName ?? toTableName;
|
|
123
93
|
const actual = options.actual;
|
|
@@ -191,7 +161,7 @@ function realise(
|
|
|
191
161
|
|
|
192
162
|
case 'reshaped':
|
|
193
163
|
// 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
|
|
164
|
+
// AND may be refused by rows already stored. The validator still enforces it at the door.
|
|
195
165
|
return {
|
|
196
166
|
entity,
|
|
197
167
|
field: change.field,
|
|
@@ -203,13 +173,7 @@ function realise(
|
|
|
203
173
|
}
|
|
204
174
|
}
|
|
205
175
|
|
|
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
|
-
*/
|
|
176
|
+
/** An axis other than shape moved. */
|
|
213
177
|
function restated(entity: string, change: Extract<ShapeChange, { kind: 'restated' }>): { change?: StepChange } | Refusal {
|
|
214
178
|
const refuse = (reason: string): Refusal => ({ entity, field: change.field, reason });
|
|
215
179
|
|
|
@@ -246,13 +210,7 @@ function literalOf(rules: { create?: unknown } | undefined): unknown {
|
|
|
246
210
|
|
|
247
211
|
const show = (value: unknown): string => (value === undefined ? 'none' : JSON.stringify(value));
|
|
248
212
|
|
|
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
|
-
*/
|
|
213
|
+
/** Has the table already moved? Read off the columns themselves. */
|
|
256
214
|
function done(change: StepChange, columns: Set<string> | undefined): boolean {
|
|
257
215
|
if (!columns) return false;
|
|
258
216
|
return change.kind === 'renameColumn'
|