@fougere/adapter-sql 0.5.0-alpha.1 → 0.7.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 +4 -4
- 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 +19 -85
- package/dist/crud.d.ts.map +1 -1
- package/dist/crud.js +25 -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/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 +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- 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 +15 -35
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +18 -12
- package/dist/setup.js.map +1 -1
- package/dist/sqlite.d.ts +2 -2
- package/dist/sqlite.d.ts.map +1 -1
- package/dist/sqlite.js +22 -12
- 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 +28 -105
- package/src/ddl.ts +14 -55
- package/src/dialect.ts +7 -50
- package/src/diff.ts +18 -50
- package/src/fields.ts +17 -8
- package/src/index.ts +6 -6
- package/src/order.ts +63 -0
- package/src/query.ts +2 -17
- package/src/setup.ts +33 -37
- package/src/sqlite.ts +28 -14
- package/src/step.ts +12 -54
- package/src/table.ts +42 -185
- package/src/values.ts +3 -24
package/src/check.ts
CHANGED
|
@@ -1,24 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shape → CHECK constraints.
|
|
3
|
-
*
|
|
4
|
-
* `oneOf`, `min`, `max` were declared on the field and read by the façade alone: a
|
|
5
|
-
* handler writing through the ORM put `status: 'brouillon'` in a column that declares
|
|
6
|
-
* two values, and nothing said a word. The rule was in the schema; no one held it on
|
|
7
|
-
* that path.
|
|
8
|
-
*
|
|
9
|
-
* So the storage learns what the shape already says. `validate` judges what a client
|
|
10
|
-
* proposes; this holds what anyone writes — including us.
|
|
11
|
-
*
|
|
12
|
-
* Only the keywords a database can decide alone. `pattern` and `format` are left to
|
|
13
|
-
* the façade: regex dialects diverge (POSIX here, PCRE there, nothing in SQLite
|
|
14
|
-
* without an extension), and a constraint that means something different per engine
|
|
15
|
-
* is worse than none.
|
|
16
|
-
*
|
|
17
|
-
* Every value is inlined with `sql.lit`, not bound: SQLite answers `parameters
|
|
18
|
-
* prohibited in CHECK constraints`, and a constraint is part of the schema rather
|
|
19
|
-
* than of a query. The values are the author's own literals — `oneOf('draft', …)`,
|
|
20
|
-
* `max: 160` — never anything a request carried, and Kysely escapes them.
|
|
21
|
-
*/
|
|
1
|
+
/** Shape → CHECK constraints. */
|
|
22
2
|
import { sql, type Expression, type SqlBool } from 'kysely';
|
|
23
3
|
import type { ColumnDef } from './table.js';
|
|
24
4
|
|
|
@@ -31,12 +11,7 @@ export interface ShapeBounds {
|
|
|
31
11
|
maximum?: number;
|
|
32
12
|
}
|
|
33
13
|
|
|
34
|
-
/**
|
|
35
|
-
* The CHECK expression for one column, or nothing when its shape bounds nothing.
|
|
36
|
-
*
|
|
37
|
-
* A nullable column passes when it holds `null`: `NOT NULL` is the axis that decides
|
|
38
|
-
* presence, and stacking the two would make `optional()` unwritable.
|
|
39
|
-
*/
|
|
14
|
+
/** The CHECK expression for one column, or nothing when its shape bounds nothing. */
|
|
40
15
|
export function checkFor(column: ColumnDef): Expression<SqlBool> | undefined {
|
|
41
16
|
const bounds = column.bounds;
|
|
42
17
|
if (!bounds) return undefined;
|
package/src/crud.ts
CHANGED
|
@@ -1,20 +1,7 @@
|
|
|
1
1
|
import { Lifecycle, Role } from '@fougere/schema';
|
|
2
|
-
/**
|
|
3
|
-
* SqlEntityOrm — per-entity ORM over Kysely, one implementation for every engine.
|
|
4
|
-
*
|
|
5
|
-
* Structurally matches @fougere/core's EntityOrm (duck typed, no dep). There is
|
|
6
|
-
* no generated table object: Kysely addresses tables and columns by name, so the
|
|
7
|
-
* entity stays the only description. The field↔column mapping is explicit rather
|
|
8
|
-
* than a global plugin — auth tables carry their own naming and must not be
|
|
9
|
-
* rewritten behind the caller's back.
|
|
10
|
-
*
|
|
11
|
-
* `create` and `update` re-read the row instead of using `RETURNING`: the
|
|
12
|
-
* contract is to hand back the COMPLETE row, including defaults realised by SQL.
|
|
13
|
-
* That also makes the code identical on MySQL and SQL Server, which have no
|
|
14
|
-
* `RETURNING` clause.
|
|
15
|
-
*/
|
|
2
|
+
/** SqlStorage — per-entity storage over Kysely, one implementation for every engine. */
|
|
16
3
|
import { sql, type Kysely } from 'kysely';
|
|
17
|
-
import { applyCreate, applyUpdate,
|
|
4
|
+
import { applyCreate, applyUpdate, type Fields, type SchemaView } from '@fougere/schema';
|
|
18
5
|
import { toTable, toTableName, type TableDef } from './table.js';
|
|
19
6
|
import { resolveDialect, type Dialect, type DialectName } from './dialect.js';
|
|
20
7
|
// The contract entry and not the main one: `FougereError` crosses a process boundary and
|
|
@@ -48,14 +35,7 @@ interface PrimaryKeyInfo {
|
|
|
48
35
|
isComposite: boolean;
|
|
49
36
|
}
|
|
50
37
|
|
|
51
|
-
/**
|
|
52
|
-
* The primary key, read off the role axis.
|
|
53
|
-
*
|
|
54
|
-
* Used to answer "what identifies a row" — where to point a WHERE, what a cursor
|
|
55
|
-
* carries. The generated ids and managed timestamps that used to be computed here
|
|
56
|
-
* moved to `applyCreate`/`applyUpdate` (`@fougere/schema`): nothing in them was about
|
|
57
|
-
* SQL, and every other storage was re-deriving them from scratch.
|
|
58
|
-
*/
|
|
38
|
+
/** The primary key, read off the role axis. */
|
|
59
39
|
function analyzeFields(entity: SchemaView): { pk: PrimaryKeyInfo } {
|
|
60
40
|
const pkNames = Object.entries(entity.getFields())
|
|
61
41
|
.filter(([, field]) => Role.of(field).isPrimary)
|
|
@@ -89,7 +69,7 @@ function pickList<T extends Record<string, unknown>>(list: ListResult<T>, keys:
|
|
|
89
69
|
return result;
|
|
90
70
|
}
|
|
91
71
|
|
|
92
|
-
export class
|
|
72
|
+
export class SqlStorage {
|
|
93
73
|
private table: TableDef;
|
|
94
74
|
private pk: PrimaryKeyInfo;
|
|
95
75
|
/** The axes `applyCreate`/`applyUpdate` read — held once, they are asked per write. */
|
|
@@ -110,7 +90,7 @@ export class SqlEntityOrm {
|
|
|
110
90
|
|
|
111
91
|
constructor(
|
|
112
92
|
private db: Kysely<any>,
|
|
113
|
-
|
|
93
|
+
entity: SchemaView,
|
|
114
94
|
tableName: string,
|
|
115
95
|
selectFields?: Set<string>,
|
|
116
96
|
dialect: DialectName = 'sqlite',
|
|
@@ -119,10 +99,6 @@ export class SqlEntityOrm {
|
|
|
119
99
|
this.dialect = resolved;
|
|
120
100
|
this.maxBindings = resolved.maxBindings;
|
|
121
101
|
this.upsertClause = resolved.upsert;
|
|
122
|
-
// Normalized once: the table projection and the axis analysis below both read the
|
|
123
|
-
// schema, and a card handed to each separately would be rebuilt twice into two
|
|
124
|
-
// unrelated field objects. Past this line nothing knows which form arrived.
|
|
125
|
-
const entity = schemaOf(source);
|
|
126
102
|
this.table = toTable(tableName, entity);
|
|
127
103
|
for (const column of this.table.columns) {
|
|
128
104
|
this.toColumn.set(column.field, column.name);
|
|
@@ -134,15 +110,15 @@ export class SqlEntityOrm {
|
|
|
134
110
|
this.selectFields = selectFields;
|
|
135
111
|
}
|
|
136
112
|
|
|
137
|
-
/** The Kysely instance this
|
|
113
|
+
/** The Kysely instance this storage wraps — no validator sits behind it. See Storage.client. */
|
|
138
114
|
get client(): Kysely<any> {
|
|
139
115
|
return this.db;
|
|
140
116
|
}
|
|
141
117
|
|
|
142
|
-
/** Returns a scoped
|
|
143
|
-
output(schema: SchemaView):
|
|
144
|
-
const scoped = Object.create(this) as
|
|
145
|
-
|
|
118
|
+
/** Returns a scoped storage that restricts all read results to the fields of the given schema. */
|
|
119
|
+
output(schema: SchemaView): SqlStorage {
|
|
120
|
+
const scoped = Object.create(this) as SqlStorage;
|
|
121
|
+
scoped.selectFields = new Set(Object.keys(schema.getFields()));
|
|
146
122
|
return scoped;
|
|
147
123
|
}
|
|
148
124
|
|
|
@@ -177,18 +153,11 @@ export class SqlEntityOrm {
|
|
|
177
153
|
return data;
|
|
178
154
|
}
|
|
179
155
|
|
|
180
|
-
/**
|
|
181
|
-
* Apply a primary-key filter (simple or composite).
|
|
182
|
-
*
|
|
183
|
-
* The key crosses to the column exactly like every other value — `whereAll` states
|
|
184
|
-
* the rule two lines below and this did not follow it. It cost nothing while every
|
|
185
|
-
* generated key was a string; a key that holds a Date (`primary(created())`) inserted
|
|
186
|
-
* fine and then failed its own re-read, with the row already persisted.
|
|
187
|
-
*/
|
|
156
|
+
/** Apply a primary-key filter (simple or composite). */
|
|
188
157
|
private wherePk<Q extends { where(a: any, b: any, c: any): Q }>(query: Q, id: string | Record<string, unknown>): Q {
|
|
189
158
|
if (this.pk.isComposite) {
|
|
190
|
-
const
|
|
191
|
-
return this.pk.names.reduce((q, name) => q.where(this.column(name), '=', this.write(name,
|
|
159
|
+
const composite = id as Record<string, unknown>;
|
|
160
|
+
return this.pk.names.reduce((q, name) => q.where(this.column(name), '=', this.write(name, composite[name])), query);
|
|
192
161
|
}
|
|
193
162
|
const name = this.pk.names[0];
|
|
194
163
|
return query.where(this.column(name), '=', this.write(name, id));
|
|
@@ -277,11 +246,8 @@ export class SqlEntityOrm {
|
|
|
277
246
|
}
|
|
278
247
|
|
|
279
248
|
/**
|
|
280
|
-
* One query for N keys, never N queries — what every page-level read stands on:
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* A composite key has no list form: it is refused by name rather than answering a
|
|
284
|
-
* partial result that reads as complete.
|
|
249
|
+
* One query for N keys, never N queries — what every page-level read stands on: a computed
|
|
250
|
+
* field, a relation, a resolver on the other side of a wire.
|
|
285
251
|
*/
|
|
286
252
|
async findByKeys(ids: readonly string[], options?: SelectOption): Promise<Map<string, Record<string, unknown>>> {
|
|
287
253
|
if (this.pk.isComposite) {
|
|
@@ -307,13 +273,7 @@ export class SqlEntityOrm {
|
|
|
307
273
|
return found;
|
|
308
274
|
}
|
|
309
275
|
|
|
310
|
-
/**
|
|
311
|
-
* The other direction of a relation, in one query — see the port's `findAllByKeys`.
|
|
312
|
-
*
|
|
313
|
-
* The grouping key is read off the ROW rather than trusted from the request: a codec
|
|
314
|
-
* may write a value one way and read it back another, and a group keyed on the
|
|
315
|
-
* request's spelling would then be empty while the rows sit there.
|
|
316
|
-
*/
|
|
276
|
+
/** The other direction of a relation, in one query — see the port's `findAllByKeys`. */
|
|
317
277
|
async findAllByKeys(
|
|
318
278
|
field: string,
|
|
319
279
|
keys: readonly string[],
|
|
@@ -324,25 +284,13 @@ export class SqlEntityOrm {
|
|
|
324
284
|
const rows = await this.findAllBy({ [field]: [...keys] }, options);
|
|
325
285
|
for (const row of rows) {
|
|
326
286
|
const key = String(row[field]);
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
287
|
+
const bucket = grouped.get(key);
|
|
288
|
+
if (bucket) bucket.push(row); else grouped.set(key, [row]);
|
|
329
289
|
}
|
|
330
290
|
return grouped;
|
|
331
291
|
}
|
|
332
292
|
|
|
333
|
-
/**
|
|
334
|
-
* Write the row, or make the existing one look like this — one statement.
|
|
335
|
-
*
|
|
336
|
-
* The gesture an import needs and the port did not have: `create` throws on the
|
|
337
|
-
* second run (`UNIQUE constraint failed`), so re-reading anything meant deleting
|
|
338
|
-
* first. Measured pulling 500 rows from an API twice.
|
|
339
|
-
*
|
|
340
|
-
* Both lifecycles are realized, each on the side it belongs to: `applyCreate` fills
|
|
341
|
-
* what a first write owes (a generated key, `created()`, a declared default) and
|
|
342
|
-
* `applyUpdate` stamps what every write owes (`updated()`). On conflict the key and
|
|
343
|
-
* the creation stamps are left alone — a row keeps the moment it appeared, whatever
|
|
344
|
-
* later overwrites say.
|
|
345
|
-
*/
|
|
293
|
+
/** Write the row, or make the existing one look like this — one statement. */
|
|
346
294
|
async upsert(input: Record<string, unknown>, options?: SelectOption): Promise<Record<string, unknown>> {
|
|
347
295
|
if (this.upsertClause === false) {
|
|
348
296
|
throw new Error(
|
|
@@ -373,17 +321,7 @@ export class SqlEntityOrm {
|
|
|
373
321
|
return (await this.findById(id as never, options))!;
|
|
374
322
|
}
|
|
375
323
|
|
|
376
|
-
/**
|
|
377
|
-
* Upsert a whole page in one statement — what an import writes through.
|
|
378
|
-
*
|
|
379
|
-
* Row by row, 500 rows were 500 statements (measured pulling an API); the shape of
|
|
380
|
-
* an import is a page, so the write should be one too. Sliced like every other batch,
|
|
381
|
-
* but by rows × COLUMNS: a statement binds values, not rows, so the ceiling divides.
|
|
382
|
-
*
|
|
383
|
-
* Answers how many rows were written and not the rows themselves. `create` hands back
|
|
384
|
-
* the complete row because a caller acts on it; an import acts on none of them, and
|
|
385
|
-
* re-reading a page to satisfy a symmetry nobody uses would double the work.
|
|
386
|
-
*/
|
|
324
|
+
/** Upsert a whole page in one statement — what an import writes through. */
|
|
387
325
|
async upsertAll(inputs: readonly Record<string, unknown>[], _options?: SelectOption): Promise<number> {
|
|
388
326
|
if (this.upsertClause === false) {
|
|
389
327
|
throw new Error(
|
|
@@ -460,12 +398,7 @@ export class SqlEntityOrm {
|
|
|
460
398
|
return out;
|
|
461
399
|
}
|
|
462
400
|
|
|
463
|
-
/**
|
|
464
|
-
* One criteria object per statement — the oversized set is the one that splits.
|
|
465
|
-
*
|
|
466
|
-
* Only ONE criterion may be split: two split sets would need their cross product,
|
|
467
|
-
* which is a different query, so the second is refused rather than silently wrong.
|
|
468
|
-
*/
|
|
401
|
+
/** One criteria object per statement — the oversized set is the one that splits. */
|
|
469
402
|
private refuseOversized(criteria: Record<string, unknown>, op: string): void {
|
|
470
403
|
for (const [key, value] of Object.entries(criteria)) {
|
|
471
404
|
if (!Array.isArray(value) || new Set(value).size <= this.maxBindings) continue;
|
|
@@ -500,7 +433,7 @@ export class SqlEntityOrm {
|
|
|
500
433
|
|
|
501
434
|
await this.refusal(() => this.db.insertInto(this.table.name).values(this.toRow(data)).execute());
|
|
502
435
|
|
|
503
|
-
// Contract: create returns the COMPLETE row (validation
|
|
436
|
+
// Contract: create returns the COMPLETE row (validation validates absence, it
|
|
504
437
|
// never fills) — re-read so SQL-realised defaults appear. Same move as update().
|
|
505
438
|
const id = this.pk.isComposite
|
|
506
439
|
? Object.fromEntries(this.pk.names.map((n) => [n, data[n]]))
|
|
@@ -523,12 +456,8 @@ export class SqlEntityOrm {
|
|
|
523
456
|
}
|
|
524
457
|
|
|
525
458
|
/**
|
|
526
|
-
* A duplicate is an ANSWER, not a failure — so it leaves as `CONFLICT` and not as the
|
|
527
|
-
*
|
|
528
|
-
* it names a table and a constraint, which is our schema and not the caller's business.
|
|
529
|
-
*
|
|
530
|
-
* Only the dialect can recognize it; this method knows no engine, which is the rule
|
|
531
|
-
* `dialect.ts` exists to keep.
|
|
459
|
+
* A duplicate is an ANSWER, not a failure — so it leaves as `CONFLICT` and not as the blank
|
|
460
|
+
* `Internal error` a caller used to get.
|
|
532
461
|
*/
|
|
533
462
|
private async refusal<R>(write: () => Promise<R>): Promise<R> {
|
|
534
463
|
try {
|
|
@@ -552,19 +481,13 @@ export class SqlEntityOrm {
|
|
|
552
481
|
}
|
|
553
482
|
|
|
554
483
|
|
|
555
|
-
export interface
|
|
484
|
+
export interface StorageFactoryOptions {
|
|
556
485
|
/** Override table name resolution. Default: camelCase → snake_case + 's'. */
|
|
557
486
|
tableName?: (entityName: string) => string;
|
|
558
487
|
}
|
|
559
488
|
|
|
560
|
-
/**
|
|
561
|
-
|
|
562
|
-
*
|
|
563
|
-
* ```ts
|
|
564
|
-
* const app = await createApp({ createContainer, ormFactory: createOrmFactory(db) });
|
|
565
|
-
* ```
|
|
566
|
-
*/
|
|
567
|
-
export function createOrmFactory(db: Kysely<any>, options?: OrmFactoryOptions, dialect: DialectName = 'sqlite') {
|
|
489
|
+
/** Create a StorageFactory backed by Kysely — same call shape on every engine. */
|
|
490
|
+
export function createStorageFactory(db: Kysely<any>, options?: StorageFactoryOptions, dialect: DialectName = 'sqlite') {
|
|
568
491
|
const resolve = options?.tableName ?? toTableName;
|
|
569
|
-
return (entity:
|
|
492
|
+
return (entity: SchemaView, name: string) => new SqlStorage(db, entity, resolve(name), undefined, dialect);
|
|
570
493
|
}
|
package/src/ddl.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DDL — the table description, rendered as SQL.
|
|
3
|
-
*
|
|
4
|
-
* Kysely's schema builder is what makes this dialect-agnostic: it owns the
|
|
5
|
-
* identifier quoting and the per-engine syntax, so this module only decides
|
|
6
|
-
* *what* to emit. Compilation needs no connection — a `DummyDriver` paired with
|
|
7
|
-
* a real query compiler renders the statement for any engine, which is why the
|
|
8
|
-
* whole surface is testable without a database.
|
|
9
|
-
*/
|
|
1
|
+
/** DDL — the table description, rendered as SQL. */
|
|
10
2
|
import {
|
|
11
3
|
Kysely,
|
|
12
4
|
DummyDriver,
|
|
@@ -18,13 +10,13 @@ import {
|
|
|
18
10
|
} from 'kysely';
|
|
19
11
|
import {
|
|
20
12
|
isKeyed,
|
|
21
|
-
orderTables,
|
|
22
13
|
toTableName,
|
|
23
14
|
toTables,
|
|
24
15
|
type AppLike,
|
|
25
16
|
type ColumnDef,
|
|
26
17
|
type TableDef,
|
|
27
18
|
} from './table.js';
|
|
19
|
+
import { orderTables } from './order.js';
|
|
28
20
|
import { columnTypeFor, resolveDialect, type DialectName } from './dialect.js';
|
|
29
21
|
import { checkFor } from './check.js';
|
|
30
22
|
|
|
@@ -58,18 +50,7 @@ export function compiler(name: DialectName): Kysely<any> {
|
|
|
58
50
|
|
|
59
51
|
// ─── CREATE TABLE ──────────────────────────────────
|
|
60
52
|
|
|
61
|
-
/**
|
|
62
|
-
* Render `CREATE TABLE` for one described table.
|
|
63
|
-
*
|
|
64
|
-
* `IF NOT EXISTS` is emitted everywhere it exists — SQL Server has no such
|
|
65
|
-
* clause, so there the statement is bare and the caller must not replay it
|
|
66
|
-
* blindly (the diff pass, once it lands, answers that properly).
|
|
67
|
-
*
|
|
68
|
-
* `skipReferences` names the columns whose FK is rendered WITHOUT the inline
|
|
69
|
-
* `references()` — the column itself still gets created; `orderTables` sends a
|
|
70
|
-
* column here when its target is part of a cycle, so the constraint reaches the
|
|
71
|
-
* table separately, once every table involved exists (`addForeignKeyConstraintSQL`).
|
|
72
|
-
*/
|
|
53
|
+
/** Render `CREATE TABLE` for one described table. */
|
|
73
54
|
export function createTableSQL(
|
|
74
55
|
table: TableDef,
|
|
75
56
|
dialectName: DialectName,
|
|
@@ -89,7 +70,7 @@ export function createTableSQL(
|
|
|
89
70
|
if (column.primary && !composite) built = built.primaryKey();
|
|
90
71
|
if (!column.nullable) built = built.notNull();
|
|
91
72
|
if (column.default !== undefined) built = built.defaultTo(column.default);
|
|
92
|
-
// Uniqueness is the storage's to enforce: no shape can express it, since
|
|
73
|
+
// Uniqueness is the storage's to enforce: no shape can express it, since validating
|
|
93
74
|
// one value never sees the other rows.
|
|
94
75
|
if (column.unique) built = built.unique();
|
|
95
76
|
if (column.references && !skip?.has(column.name)) {
|
|
@@ -121,18 +102,16 @@ export function createTableSQL(
|
|
|
121
102
|
return builder.compile().sql;
|
|
122
103
|
}
|
|
123
104
|
|
|
124
|
-
/**
|
|
125
|
-
* `CREATE INDEX` for every column that asked for one.
|
|
126
|
-
*
|
|
127
|
-
* Separate statements, never part of `CREATE TABLE`: an index is not a constraint, it
|
|
128
|
-
* changes no answer — only what a read costs. `IF NOT EXISTS` everywhere it exists, so
|
|
129
|
-
* replaying the batch is safe (SQL Server has no such clause, same rule as the tables).
|
|
130
|
-
*/
|
|
105
|
+
/** `CREATE INDEX` for every column that asked for one. */
|
|
131
106
|
export function indexSQL(table: TableDef, column: ColumnDef, dialectName: DialectName): string {
|
|
132
107
|
let builder = compiler(dialectName)
|
|
133
108
|
.schema.createIndex(`${table.name}_${column.name}_idx`)
|
|
134
109
|
.on(table.name)
|
|
135
110
|
.column(column.name);
|
|
111
|
+
// A `unique()` on a table that already exists has nowhere else to land: `createTable`
|
|
112
|
+
// writes it as a column constraint, and no engine here can ALTER one in. As an index it
|
|
113
|
+
// arrives — or the statement fails on the rows that already break it, which is the answer.
|
|
114
|
+
if (column.unique) builder = builder.unique();
|
|
136
115
|
if (dialectName !== 'mssql') builder = builder.ifNotExists();
|
|
137
116
|
return builder.compile().sql;
|
|
138
117
|
}
|
|
@@ -145,10 +124,8 @@ export function createIndexSQL(table: TableDef, dialectName: DialectName): strin
|
|
|
145
124
|
}
|
|
146
125
|
|
|
147
126
|
/**
|
|
148
|
-
* `ALTER TABLE ADD CONSTRAINT` for one FK `orderTables` deferred — closes a
|
|
149
|
-
*
|
|
150
|
-
* `ALTER TABLE` is limited to RENAME/ADD COLUMN/RENAME COLUMN/DROP COLUMN) — a
|
|
151
|
-
* caller on that dialect never produces a deferred edge to render here.
|
|
127
|
+
* `ALTER TABLE ADD CONSTRAINT` for one FK `orderTables` deferred — closes a relation cycle once
|
|
128
|
+
* every table in it exists.
|
|
152
129
|
*/
|
|
153
130
|
export function addForeignKeyConstraintSQL(table: TableDef, column: ColumnDef, dialectName: DialectName): string {
|
|
154
131
|
const ref = column.references!;
|
|
@@ -170,20 +147,8 @@ export interface GenerateOptions {
|
|
|
170
147
|
}
|
|
171
148
|
|
|
172
149
|
/**
|
|
173
|
-
* `CREATE TABLE` for every entity the app hosts — scanned frond entities plus
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* SQLite resolves FK targets lazily and accepts any order, and it has no
|
|
177
|
-
* `ALTER TABLE ADD CONSTRAINT` to close a cycle with — every FK stays inline,
|
|
178
|
-
* unordered. Every other engine needs a referenced table to exist first:
|
|
179
|
-
* `orderTables` sorts the batch and reports the edges a cycle forces to defer,
|
|
180
|
-
* rendered as `ALTER TABLE ADD CONSTRAINT` after every `CREATE TABLE`.
|
|
181
|
-
*
|
|
182
|
-
* Caveat for a repeat call (`autoMigrate`): `CREATE TABLE IF NOT EXISTS` is
|
|
183
|
-
* idempotent, `ADD CONSTRAINT` is not — on pg/mysql/mssql, calling this twice
|
|
184
|
-
* for an app with a relation cycle re-issues the same constraint and errors.
|
|
185
|
-
* The introspection-based `migrate()` (`diff.ts`) doesn't have this problem: it
|
|
186
|
-
* only ever emits a table's constraints once, the run that creates it.
|
|
150
|
+
* `CREATE TABLE` for every entity the app hosts — scanned frond entities plus auth runtime
|
|
151
|
+
* entities when present.
|
|
187
152
|
*/
|
|
188
153
|
export function generateSQL(app: AppLike, options?: GenerateOptions): string[] {
|
|
189
154
|
const resolve = options?.tableName ?? toTableName;
|
|
@@ -227,13 +192,7 @@ function runOn(sink: SqlSink, statement: string): unknown {
|
|
|
227
192
|
return 'execute' in sink ? sink.execute(statement) : sink.exec(statement);
|
|
228
193
|
}
|
|
229
194
|
|
|
230
|
-
/**
|
|
231
|
-
* Create every missing table. Additive only — an existing table is left alone.
|
|
232
|
-
*
|
|
233
|
-
* Stays SYNCHRONOUS when the sink is (a raw better-sqlite3 handle), so a caller
|
|
234
|
-
* that doesn't await still gets its tables before the next statement. Returns a
|
|
235
|
-
* promise only when the sink actually returns one.
|
|
236
|
-
*/
|
|
195
|
+
/** Create every missing table. */
|
|
237
196
|
export function autoMigrate(app: AppLike, sink: SqlSink, options?: GenerateOptions): void | Promise<void> {
|
|
238
197
|
const pending = generateSQL(app, options)
|
|
239
198
|
.map((statement) => runOn(sink, statement))
|
package/src/dialect.ts
CHANGED
|
@@ -1,62 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dialect — the only place that speaks SQL.
|
|
3
|
-
*
|
|
4
|
-
* A dialect answers two questions and nothing else: which column type carries
|
|
5
|
-
* this shape, and does this engine support `RETURNING`. Everything structural
|
|
6
|
-
* (which columns exist, which are keys) is decided upstream in `TableDef`.
|
|
7
|
-
*/
|
|
1
|
+
/** Dialect — the only place that speaks SQL. */
|
|
8
2
|
import type { ColumnDef } from './table.js';
|
|
9
3
|
|
|
10
4
|
export type DialectName = 'sqlite' | 'pg' | 'mysql' | 'mssql';
|
|
11
5
|
|
|
12
6
|
export interface Dialect {
|
|
13
7
|
name: DialectName;
|
|
14
|
-
/**
|
|
15
|
-
* SQL type for a column. `keyed` is true when the column belongs to a primary
|
|
16
|
-
* key — MySQL and SQL Server cannot index an unbounded text column, so they
|
|
17
|
-
* narrow to a bounded varchar there.
|
|
18
|
-
*/
|
|
8
|
+
/** SQL type for a column. */
|
|
19
9
|
columnType(column: ColumnDef, keyed: boolean): string;
|
|
20
10
|
/**
|
|
21
11
|
* Does `INSERT … RETURNING` work? MySQL has no such clause and SQL Server
|
|
22
12
|
* spells it `OUTPUT`; both take the insert-then-select path instead.
|
|
23
13
|
*/
|
|
24
14
|
supportsReturning: boolean;
|
|
25
|
-
/**
|
|
26
|
-
* How many values one statement may bind — what splits a batch read into several.
|
|
27
|
-
*
|
|
28
|
-
* A key set comes from a PAGE, and a page has no ceiling (`list()` with no limit
|
|
29
|
-
* reads the table), so `where id in (…)` eventually meets the engine's limit.
|
|
30
|
-
* Measured on SQLite: 32 766 binds, and 32 767 answers `too many SQL variables`.
|
|
31
|
-
* SQL Server is the low one at 2100, which is why this is per dialect and not one
|
|
32
|
-
* constant — a batch that works on SQLite and dies on SQL Server is the same value
|
|
33
|
-
* behaving differently per engine, the thing this file exists to absorb.
|
|
34
|
-
*
|
|
35
|
-
* The number below is the limit MINUS a margin for the other values a statement
|
|
36
|
-
* carries (a filter, a cursor): a batch read is never the only thing in the query.
|
|
37
|
-
*/
|
|
15
|
+
/** How many values one statement may bind — what splits a batch read into several. */
|
|
38
16
|
maxBindings: number;
|
|
39
|
-
/**
|
|
40
|
-
* How this engine spells "write it, or replace what is there".
|
|
41
|
-
*
|
|
42
|
-
* `'on conflict'` is the standard clause (SQLite, Postgres); MySQL spells the same
|
|
43
|
-
* thing `ON DUPLICATE KEY UPDATE`; SQL Server has only `MERGE`, a different statement
|
|
44
|
-
* with different semantics — so it answers `false` and the port refuses by name
|
|
45
|
-
* rather than emulating a write with a read in front of it, which would be a lie
|
|
46
|
-
* about atomicity in an engine that has no transaction here either.
|
|
47
|
-
*/
|
|
17
|
+
/** How this engine spells "write it, or replace what is there". */
|
|
48
18
|
upsert: 'on conflict' | 'on duplicate key' | false;
|
|
49
|
-
/**
|
|
50
|
-
* Is this the engine refusing a duplicate, rather than failing?
|
|
51
|
-
*
|
|
52
|
-
* A driver reports it as a plain `Error` whose wording is the engine's own, so only a
|
|
53
|
-
* dialect can tell — the same reason `maxBindings` and `upsert` live here. Without it
|
|
54
|
-
* every engine's phrasing would be matched in one place, and this file exists so no
|
|
55
|
-
* other one learns a dialect.
|
|
56
|
-
*
|
|
57
|
-
* A false negative costs an INTERNAL_ERROR where a CONFLICT was due — which is what
|
|
58
|
-
* every engine answered before this existed, so nothing is made worse by a gap.
|
|
59
|
-
*/
|
|
19
|
+
/** Is this the engine refusing a duplicate, rather than failing? */
|
|
60
20
|
isUniqueViolation(error: unknown): boolean;
|
|
61
21
|
}
|
|
62
22
|
|
|
@@ -193,11 +153,8 @@ export function resolveDialect(name: DialectName): Dialect {
|
|
|
193
153
|
}
|
|
194
154
|
|
|
195
155
|
/**
|
|
196
|
-
* The type this column is emitted with — what the entity stated when it named THIS
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* The fallback is what keeps the statement local: `columnType: { pg: 'tsvector' }` leaves
|
|
200
|
-
* SQLite exactly where it was, so the same entity still boots on every dialect.
|
|
156
|
+
* The type this column is emitted with — what the entity stated when it named THIS engine, the
|
|
157
|
+
* shape's own answer otherwise.
|
|
201
158
|
*/
|
|
202
159
|
export function columnTypeFor(dialect: Dialect, column: ColumnDef, keyed: boolean): string {
|
|
203
160
|
return column.stated?.columnType?.[dialect.name] ?? dialect.columnType(column, keyed);
|
package/src/diff.ts
CHANGED
|
@@ -1,29 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Diff — what the database is missing, compared to what the entities describe.
|
|
3
|
-
*
|
|
4
|
-
* Two states, one comparison, one realisation. The desired state comes from the
|
|
5
|
-
* entities; the actual one from Kysely's introspection, which is already
|
|
6
|
-
* engine-agnostic. The comparison itself is pure — no IO, no SQL.
|
|
7
|
-
*
|
|
8
|
-
* ADDITIVE ONLY, and that incapacity is the guarantee: a missing table is
|
|
9
|
-
* created, a missing column is added, and **nothing else ever happens**. Drops,
|
|
10
|
-
* renames and type changes are human intentions — a rename is not even
|
|
11
|
-
* detectable from a diff (it reads as a drop plus an add). Those belong in a
|
|
12
|
-
* written migration, never in an automatic pass.
|
|
13
|
-
*/
|
|
1
|
+
/** Diff — what the database is missing, compared to what the entities describe. */
|
|
14
2
|
import { sql, type Kysely } from 'kysely';
|
|
15
3
|
import { addForeignKeyConstraintSQL, compiler, createTableSQL, indexSQL, type GenerateOptions } from './ddl.js';
|
|
16
4
|
import { checkFor } from './check.js';
|
|
17
5
|
import { columnTypeFor, resolveDialect, type DialectName } from './dialect.js';
|
|
18
6
|
import {
|
|
19
7
|
isKeyed,
|
|
20
|
-
orderTables,
|
|
21
8
|
toTables,
|
|
22
9
|
toTableName,
|
|
23
10
|
type AppLike,
|
|
24
11
|
type ColumnDef,
|
|
25
12
|
type TableDef,
|
|
26
13
|
} from './table.js';
|
|
14
|
+
import { orderTables } from './order.js';
|
|
27
15
|
|
|
28
16
|
/** What the database actually holds: column names per table. */
|
|
29
17
|
export type SchemaState = Map<string, Set<string>>;
|
|
@@ -66,26 +54,21 @@ export function delta(desired: TableDef[], actual: SchemaState): Change[] {
|
|
|
66
54
|
// cannot see whether an index exists. `CREATE INDEX IF NOT EXISTS` is idempotent, so
|
|
67
55
|
// proposing it every time is cheaper and more honest than introspecting to guess —
|
|
68
56
|
// the alternative would be an index that a `unique()` added later never gets.
|
|
57
|
+
//
|
|
58
|
+
// `unique` comes here too, and for that very reason: written as a column constraint it
|
|
59
|
+
// only ever reached a table being created, so the rule was declared and never realized on
|
|
60
|
+
// a database that had already lived. A unique index is the one form that can arrive late.
|
|
69
61
|
for (const table of desired) {
|
|
70
62
|
for (const column of table.columns) {
|
|
71
|
-
if (column.index) changes.push({ kind: 'createIndex', table, column });
|
|
63
|
+
if (column.index || (column.unique && !column.primary)) changes.push({ kind: 'createIndex', table, column });
|
|
72
64
|
}
|
|
73
65
|
}
|
|
74
66
|
return changes;
|
|
75
67
|
}
|
|
76
68
|
|
|
77
69
|
/**
|
|
78
|
-
* Order the changes `delta` found, dialect-aware — `delta` itself stays pure and
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* SQLite resolves FK targets lazily and accepts any order, with no `ALTER TABLE
|
|
82
|
-
* ADD CONSTRAINT` to defer to — changes pass through unchanged. Every other
|
|
83
|
-
* engine needs a `createTable`'s FK targets to already exist: `orderTables`
|
|
84
|
-
* sorts the NEW tables among themselves and reports the edges a cycle forces to
|
|
85
|
-
* defer as `addConstraint` changes. An `addColumn` always lands last — its
|
|
86
|
-
* table already exists (that's why it's `addColumn` and not `createTable`), but
|
|
87
|
-
* its FK target might be one of THIS batch's new tables, so it waits until
|
|
88
|
-
* every `createTable`/`addConstraint` above it has run.
|
|
70
|
+
* Order the changes `delta` found, dialect-aware — `delta` itself stays pure and unordered, this
|
|
71
|
+
* is the one place that adds engine knowledge to the plan.
|
|
89
72
|
*/
|
|
90
73
|
export function orderChanges(changes: Change[], dialectName: DialectName): Change[] {
|
|
91
74
|
if (dialectName === 'sqlite') return changes;
|
|
@@ -112,14 +95,7 @@ export function orderChanges(changes: Change[], dialectName: DialectName): Chang
|
|
|
112
95
|
return [...createChanges, ...constraintChanges, ...addColumns, ...indexes];
|
|
113
96
|
}
|
|
114
97
|
|
|
115
|
-
/**
|
|
116
|
-
* Render one change.
|
|
117
|
-
*
|
|
118
|
-
* A column added to a populated table cannot be `NOT NULL` without a default —
|
|
119
|
-
* every engine refuses it. So an added column keeps its `NOT NULL` only when a
|
|
120
|
-
* default answers the existing rows; otherwise it lands nullable, and tightening
|
|
121
|
-
* it is a written migration.
|
|
122
|
-
*/
|
|
98
|
+
/** Render one change. */
|
|
123
99
|
export function changeSQL(change: Change, dialectName: DialectName): string {
|
|
124
100
|
const dialect = resolveDialect(dialectName);
|
|
125
101
|
if (change.kind === 'createTable') {
|
|
@@ -141,10 +117,12 @@ export function changeSQL(change: Change, dialectName: DialectName): string {
|
|
|
141
117
|
.schema.alterTable(table.name)
|
|
142
118
|
.addColumn(column.name, sql.raw(type) as any, (col) => {
|
|
143
119
|
let built = col;
|
|
144
|
-
if (column.default !== undefined)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
120
|
+
if (column.default !== undefined) built = built.defaultTo(column.default);
|
|
121
|
+
// Stated whether a default fills it or not: the same declaration gave NOT NULL on a
|
|
122
|
+
// fresh table and nothing on a migrated one, so two databases of one entity promised
|
|
123
|
+
// different things. Without a default the engine refuses on a table holding rows —
|
|
124
|
+
// which is the refusal `planStep` already names, arriving here instead of never.
|
|
125
|
+
if (!column.nullable) built = built.notNull();
|
|
148
126
|
if (column.references) {
|
|
149
127
|
built = built.references(`${column.references.table}.${column.references.column}`);
|
|
150
128
|
if (column.references.onDelete) built = built.onDelete(column.references.onDelete);
|
|
@@ -170,18 +148,8 @@ export async function planMigration(
|
|
|
170
148
|
return { changes, statements: changes.map((change) => changeSQL(change, dialect)) };
|
|
171
149
|
}
|
|
172
150
|
|
|
173
|
-
/**
|
|
174
|
-
|
|
175
|
-
*
|
|
176
|
-
* Returns what it did, so a caller can log or refuse. Replaces the old
|
|
177
|
-
* create-if-not-exists pass: it also catches a field added to an existing entity,
|
|
178
|
-
* which used to be silently ignored.
|
|
179
|
-
*/
|
|
180
|
-
/**
|
|
181
|
-
* Bring the schema up to date. Takes the setup itself — `migrate(app, setup)` — so the
|
|
182
|
-
* common case never has to reach into `setup.db`, the one handle that meets no judge.
|
|
183
|
-
* A bare Kysely instance is still accepted, for a caller who holds only that.
|
|
184
|
-
*/
|
|
151
|
+
/** Bring the database up to what the entities describe — additively. */
|
|
152
|
+
/** Bring the schema up to date. */
|
|
185
153
|
export async function migrate(
|
|
186
154
|
app: AppLike,
|
|
187
155
|
target: Kysely<any> | { db: Kysely<any> },
|
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>;
|