@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/crud.ts
CHANGED
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
import { Lifecycle, Role } from '@fougere/schema';
|
|
2
|
-
/**
|
|
3
|
-
* SqlStorage — per-entity storage over Kysely, one implementation for every engine.
|
|
4
|
-
*
|
|
5
|
-
* Structurally matches @fougere/core's Storage (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
|
|
21
8
|
// lives there for that reason, and this package must not drag the boot to raise one.
|
|
22
|
-
import {
|
|
9
|
+
import { comparisonOf, comparisonsIn, ErrorCode, FougereError, type Comparison } from '@fougere/core/contract';
|
|
23
10
|
import { codecsOf, type ValueCodec } from './values.js';
|
|
24
11
|
|
|
25
12
|
/** ListOptions — duplicated from @fougere/core to avoid a runtime dep. */
|
|
@@ -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)
|
|
@@ -110,7 +90,7 @@ export class SqlStorage {
|
|
|
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 SqlStorage {
|
|
|
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,7 +110,7 @@ export class SqlStorage {
|
|
|
134
110
|
this.selectFields = selectFields;
|
|
135
111
|
}
|
|
136
112
|
|
|
137
|
-
/** The Kysely instance this storage wraps — no
|
|
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
|
}
|
|
@@ -142,7 +118,7 @@ export class SqlStorage {
|
|
|
142
118
|
/** Returns a scoped storage that restricts all read results to the fields of the given schema. */
|
|
143
119
|
output(schema: SchemaView): SqlStorage {
|
|
144
120
|
const scoped = Object.create(this) as SqlStorage;
|
|
145
|
-
|
|
121
|
+
scoped.selectFields = new Set(Object.keys(schema.getFields()));
|
|
146
122
|
return scoped;
|
|
147
123
|
}
|
|
148
124
|
|
|
@@ -177,18 +153,11 @@ export class SqlStorage {
|
|
|
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));
|
|
@@ -203,12 +172,50 @@ export class SqlStorage {
|
|
|
203
172
|
// dual already went through this same door. An empty set matches nothing, said in
|
|
204
173
|
// SQL rather than by returning the whole table.
|
|
205
174
|
private whereAll<Q extends { where(a: any, b: any, c: any): Q }>(query: Q, criteria: Record<string, unknown>): Q {
|
|
206
|
-
return Object.entries(criteria).reduce(
|
|
207
|
-
(
|
|
175
|
+
return Object.entries(criteria).reduce((q, [key, value]) => {
|
|
176
|
+
const comparison = comparisonOf(this.fields[key], value);
|
|
177
|
+
if (comparison) return this.compared(q, key, comparison);
|
|
178
|
+
|
|
179
|
+
return Array.isArray(value)
|
|
208
180
|
? q.where(this.column(key), 'in', [...new Set(value)].map((v) => this.write(key, v)))
|
|
209
|
-
: q.where(this.column(key), '=', this.write(key, value))
|
|
210
|
-
|
|
211
|
-
|
|
181
|
+
: q.where(this.column(key), '=', this.write(key, value));
|
|
182
|
+
}, query);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What a bare value and a set could not say. Every comparison a criterion names is
|
|
187
|
+
* AND-ed, which is the rule two criteria already follow — `{ gte: 100, lte: 400 }` is
|
|
188
|
+
* one range, not two answers.
|
|
189
|
+
*/
|
|
190
|
+
private compared<Q extends { where(a: any, b: any, c: any): Q }>(
|
|
191
|
+
query: Q,
|
|
192
|
+
key: string,
|
|
193
|
+
comparison: Comparison,
|
|
194
|
+
): Q {
|
|
195
|
+
const column = this.column(key);
|
|
196
|
+
const bound = (value: unknown) => this.write(key, value);
|
|
197
|
+
|
|
198
|
+
return comparisonsIn(comparison).reduce((q, [name, value]) => {
|
|
199
|
+
switch (name) {
|
|
200
|
+
case 'gte': return q.where(column, '>=', bound(value));
|
|
201
|
+
case 'lte': return q.where(column, '<=', bound(value));
|
|
202
|
+
case 'gt': return q.where(column, '>', bound(value));
|
|
203
|
+
case 'lt': return q.where(column, '<', bound(value));
|
|
204
|
+
case 'ne': return q.where(column, '!=', bound(value));
|
|
205
|
+
case 'contains': return q.where(column, 'like', `%${String(value)}%`);
|
|
206
|
+
case 'notIn': {
|
|
207
|
+
const values = [...new Set(value as readonly unknown[])].map(bound);
|
|
208
|
+
|
|
209
|
+
return values.length ? q.where(column, 'not in', values) : q;
|
|
210
|
+
}
|
|
211
|
+
case 'isNull': return q.where(column, value ? 'is' : 'is not', null);
|
|
212
|
+
case 'between': {
|
|
213
|
+
const [low, high] = value as [unknown, unknown];
|
|
214
|
+
|
|
215
|
+
return q.where(column, '>=', bound(low)).where(column, '<=', bound(high));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}, query);
|
|
212
219
|
}
|
|
213
220
|
|
|
214
221
|
async list(options?: ListOptions & SelectOption & { where?: Record<string, unknown> }): Promise<ListResult<Record<string, unknown>>> {
|
|
@@ -277,11 +284,8 @@ export class SqlStorage {
|
|
|
277
284
|
}
|
|
278
285
|
|
|
279
286
|
/**
|
|
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.
|
|
287
|
+
* One query for N keys, never N queries — what every page-level read stands on: a computed
|
|
288
|
+
* field, a relation, a resolver on the other side of a wire.
|
|
285
289
|
*/
|
|
286
290
|
async findByKeys(ids: readonly string[], options?: SelectOption): Promise<Map<string, Record<string, unknown>>> {
|
|
287
291
|
if (this.pk.isComposite) {
|
|
@@ -307,13 +311,7 @@ export class SqlStorage {
|
|
|
307
311
|
return found;
|
|
308
312
|
}
|
|
309
313
|
|
|
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
|
-
*/
|
|
314
|
+
/** The other direction of a relation, in one query — see the port's `findAllByKeys`. */
|
|
317
315
|
async findAllByKeys(
|
|
318
316
|
field: string,
|
|
319
317
|
keys: readonly string[],
|
|
@@ -324,25 +322,13 @@ export class SqlStorage {
|
|
|
324
322
|
const rows = await this.findAllBy({ [field]: [...keys] }, options);
|
|
325
323
|
for (const row of rows) {
|
|
326
324
|
const key = String(row[field]);
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
325
|
+
const bucket = grouped.get(key);
|
|
326
|
+
if (bucket) bucket.push(row); else grouped.set(key, [row]);
|
|
329
327
|
}
|
|
330
328
|
return grouped;
|
|
331
329
|
}
|
|
332
330
|
|
|
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
|
-
*/
|
|
331
|
+
/** Write the row, or make the existing one look like this — one statement. */
|
|
346
332
|
async upsert(input: Record<string, unknown>, options?: SelectOption): Promise<Record<string, unknown>> {
|
|
347
333
|
if (this.upsertClause === false) {
|
|
348
334
|
throw new Error(
|
|
@@ -373,17 +359,7 @@ export class SqlStorage {
|
|
|
373
359
|
return (await this.findById(id as never, options))!;
|
|
374
360
|
}
|
|
375
361
|
|
|
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
|
-
*/
|
|
362
|
+
/** Upsert a whole page in one statement — what an import writes through. */
|
|
387
363
|
async upsertAll(inputs: readonly Record<string, unknown>[], _options?: SelectOption): Promise<number> {
|
|
388
364
|
if (this.upsertClause === false) {
|
|
389
365
|
throw new Error(
|
|
@@ -460,12 +436,7 @@ export class SqlStorage {
|
|
|
460
436
|
return out;
|
|
461
437
|
}
|
|
462
438
|
|
|
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
|
-
*/
|
|
439
|
+
/** One criteria object per statement — the oversized set is the one that splits. */
|
|
469
440
|
private refuseOversized(criteria: Record<string, unknown>, op: string): void {
|
|
470
441
|
for (const [key, value] of Object.entries(criteria)) {
|
|
471
442
|
if (!Array.isArray(value) || new Set(value).size <= this.maxBindings) continue;
|
|
@@ -500,7 +471,7 @@ export class SqlStorage {
|
|
|
500
471
|
|
|
501
472
|
await this.refusal(() => this.db.insertInto(this.table.name).values(this.toRow(data)).execute());
|
|
502
473
|
|
|
503
|
-
// Contract: create returns the COMPLETE row (validation
|
|
474
|
+
// Contract: create returns the COMPLETE row (validation validates absence, it
|
|
504
475
|
// never fills) — re-read so SQL-realised defaults appear. Same move as update().
|
|
505
476
|
const id = this.pk.isComposite
|
|
506
477
|
? Object.fromEntries(this.pk.names.map((n) => [n, data[n]]))
|
|
@@ -523,12 +494,8 @@ export class SqlStorage {
|
|
|
523
494
|
}
|
|
524
495
|
|
|
525
496
|
/**
|
|
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.
|
|
497
|
+
* A duplicate is an ANSWER, not a failure — so it leaves as `CONFLICT` and not as the blank
|
|
498
|
+
* `Internal error` a caller used to get.
|
|
532
499
|
*/
|
|
533
500
|
private async refusal<R>(write: () => Promise<R>): Promise<R> {
|
|
534
501
|
try {
|
|
@@ -557,14 +524,8 @@ export interface StorageFactoryOptions {
|
|
|
557
524
|
tableName?: (entityName: string) => string;
|
|
558
525
|
}
|
|
559
526
|
|
|
560
|
-
/**
|
|
561
|
-
* Create a StorageFactory backed by Kysely — same call shape on every engine.
|
|
562
|
-
*
|
|
563
|
-
* ```ts
|
|
564
|
-
* const app = await createApp({ createContainer, storageFactory: createStorageFactory(db) });
|
|
565
|
-
* ```
|
|
566
|
-
*/
|
|
527
|
+
/** Create a StorageFactory backed by Kysely — same call shape on every engine. */
|
|
567
528
|
export function createStorageFactory(db: Kysely<any>, options?: StorageFactoryOptions, dialect: DialectName = 'sqlite') {
|
|
568
529
|
const resolve = options?.tableName ?? toTableName;
|
|
569
|
-
return (entity:
|
|
530
|
+
return (entity: SchemaView, name: string) => new SqlStorage(db, entity, resolve(name), undefined, dialect);
|
|
570
531
|
}
|
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> },
|