@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.
Files changed (78) hide show
  1. package/README.md +2 -2
  2. package/dist/adapter.schema.json +16 -0
  3. package/dist/check.d.ts +2 -27
  4. package/dist/check.d.ts.map +1 -1
  5. package/dist/check.js +2 -27
  6. package/dist/check.js.map +1 -1
  7. package/dist/crud.d.ts +21 -81
  8. package/dist/crud.d.ts.map +1 -1
  9. package/dist/crud.js +58 -102
  10. package/dist/crud.js.map +1 -1
  11. package/dist/ddl.d.ts +8 -53
  12. package/dist/ddl.d.ts.map +1 -1
  13. package/dist/ddl.js +16 -55
  14. package/dist/ddl.js.map +1 -1
  15. package/dist/dialect.d.ts +7 -50
  16. package/dist/dialect.d.ts.map +1 -1
  17. package/dist/dialect.js +2 -5
  18. package/dist/dialect.js.map +1 -1
  19. package/dist/diff.d.ts +6 -44
  20. package/dist/diff.d.ts.map +1 -1
  21. package/dist/diff.js +20 -50
  22. package/dist/diff.js.map +1 -1
  23. package/dist/drift.d.ts +29 -0
  24. package/dist/drift.d.ts.map +1 -0
  25. package/dist/drift.js +53 -0
  26. package/dist/drift.js.map +1 -0
  27. package/dist/fields.d.ts +10 -9
  28. package/dist/fields.d.ts.map +1 -1
  29. package/dist/fields.js +8 -1
  30. package/dist/fields.js.map +1 -1
  31. package/dist/index.d.ts +6 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +3 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/order.d.ts +23 -0
  36. package/dist/order.d.ts.map +1 -0
  37. package/dist/order.js +40 -0
  38. package/dist/order.js.map +1 -0
  39. package/dist/query.d.ts +2 -17
  40. package/dist/query.d.ts.map +1 -1
  41. package/dist/query.js +1 -11
  42. package/dist/query.js.map +1 -1
  43. package/dist/setup.d.ts +8 -36
  44. package/dist/setup.d.ts.map +1 -1
  45. package/dist/setup.js +11 -21
  46. package/dist/setup.js.map +1 -1
  47. package/dist/sqlite.d.ts.map +1 -1
  48. package/dist/sqlite.js +12 -20
  49. package/dist/sqlite.js.map +1 -1
  50. package/dist/step.d.ts +4 -33
  51. package/dist/step.d.ts.map +1 -1
  52. package/dist/step.js +11 -46
  53. package/dist/step.js.map +1 -1
  54. package/dist/table.d.ts +15 -77
  55. package/dist/table.d.ts.map +1 -1
  56. package/dist/table.js +34 -146
  57. package/dist/table.js.map +1 -1
  58. package/dist/values.d.ts +1 -17
  59. package/dist/values.d.ts.map +1 -1
  60. package/dist/values.js +2 -7
  61. package/dist/values.js.map +1 -1
  62. package/package.json +4 -4
  63. package/src/adapter.schema.json +16 -0
  64. package/src/check.ts +2 -27
  65. package/src/crud.ts +66 -105
  66. package/src/ddl.ts +14 -55
  67. package/src/dialect.ts +7 -50
  68. package/src/diff.ts +18 -50
  69. package/src/drift.ts +75 -0
  70. package/src/fields.ts +17 -8
  71. package/src/index.ts +5 -3
  72. package/src/order.ts +63 -0
  73. package/src/query.ts +2 -17
  74. package/src/setup.ts +19 -49
  75. package/src/sqlite.ts +13 -20
  76. package/src/step.ts +12 -54
  77. package/src/table.ts +42 -185
  78. 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, schemaOf, type Fields, type SchemaView, type SchemaOrCard } from '@fougere/schema';
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 { FougereError, ErrorCode } from '@fougere/core/contract';
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
- source: SchemaOrCard,
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 judge sits behind it. See Storage.client. */
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
- (scoped as any).selectFields = new Set(Object.keys(schema.getFields()));
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 obj = id as Record<string, unknown>;
191
- return this.pk.names.reduce((q, name) => q.where(this.column(name), '=', this.write(name, obj[name])), query);
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
- (q, [key, value]) => Array.isArray(value)
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
- query,
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
- * a computed field, a relation, a resolver on the other side of a wire.
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 held = grouped.get(key);
328
- if (held) held.push(row); else grouped.set(key, [row]);
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 judges absence, it
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
- * blank `Internal error` a caller used to get. The engine's own wording never travels:
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: SchemaOrCard, name: string) => new SqlStorage(db, entity, resolve(name), undefined, dialect);
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 judging
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
- * relation cycle once every table in it exists. Not available on SQLite (its
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
- * auth runtime entities when present.
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
- * engine, the shape's own answer otherwise.
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
- * unordered, this is the one place that adds engine knowledge to the plan.
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
- built = built.defaultTo(column.default);
146
- if (!column.nullable) built = built.notNull();
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
- * Bring the database up to what the entities describe — additively.
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> },