kysely-ddl 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/).
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.2.0] — 2026-09-12
8
+
9
+ ### Added
10
+
11
+ - `concurrently: true` on an index in `defineTable`: the index is built and
12
+ dropped with `CONCURRENTLY`. Postgres refuses that inside a transaction and
13
+ inside a multi-statement query, so the generator writes such statements into a
14
+ migration of their own, `<stamp>_<name>_concurrently.sql`, marked
15
+ `--> no-transaction` and split by `--> statement-breakpoint`, with a
16
+ `DROP INDEX CONCURRENTLY IF EXISTS` in front of every create so that a rerun
17
+ after a failure is clean. `GenerateResult.concurrently` holds these
18
+ statements. Toggling the flag on an existing index is not a change; the
19
+ snapshot records it, and older snapshots read as `false`.
20
+ - `--> no-transaction`: a header marker in a `.sql` migration. The runner honors
21
+ it under `transaction: 'each'` and `'none'` and rejects it under `'all'`
22
+ before applying anything. `sqlFileMigrationProvider` turns it into
23
+ `config: { transaction: false }` for Kysely 0.30+
24
+ (`transactionMode: 'per-migration'`); on older Kysely the marked migration
25
+ fails with a clear error when the `Migrator` runs it inside a transaction.
26
+ - `MigrationError.line`: the line postgres pointed at inside the failing query.
27
+ - `readMigration`, `renderConcurrentStatements`, `NO_TRANSACTION_MARKER`,
28
+ `CONCURRENTLY_SUFFIX` and the `MigrationFile` type.
29
+
30
+ ### Changed
31
+
32
+ - **Breaking:** entry points. `kysely-ddl/kysely` is gone: `inferKyselyTable`,
33
+ `inferKyselyDatabase`, `jsonb`, `jsonbArray` and the `Jsonb` type now come
34
+ from `kysely-ddl`, which therefore imports `kysely`, the peer dependency, at
35
+ runtime. The runner (`createMigrator`, `migrateToLatest`, `MigrationError`,
36
+ `DEFAULT_JOURNAL_TABLE`, `MIGRATION_LOCK_ID` and their types) and
37
+ `sqlFileMigrationProvider` moved to the new `kysely-ddl/migrator`, so that a
38
+ project which applies its migrations some other way does not pull them in.
39
+ - **Breaking:** the `defineTable` option `tableName` is now `name`.
40
+ - **Breaking:** the runner's default `transaction` mode is `'each'` instead of
41
+ `'all'`, so that a generated `_concurrently` migration runs out of the box.
42
+ Pass `transaction: 'all'` to keep the whole run atomic.
43
+ - **Breaking:** `writeMigration` returns the names of the files written, an
44
+ array, instead of one name.
45
+ - **Breaking:** a `Change` of kind `dropIndex` carries `concurrently`.
46
+ - Ordinary migrations are written as plain SQL, without
47
+ `--> statement-breakpoint`, and the runner sends such a file to postgres as
48
+ one query: postgres runs it as one implicit transaction and reports the
49
+ failing line. Files with breakpoints, hand-written or `_concurrently`, are
50
+ still sent one chunk at a time.
51
+
5
52
  ## [0.1.0] — 2026-09-10
6
53
 
7
54
  First release.
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/hehmonke/kysely-ddl/actions/workflows/ci.yml/badge.svg)](https://github.com/hehmonke/kysely-ddl/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/kysely-ddl)](https://www.npmjs.com/package/kysely-ddl)
5
+ [![license](https://img.shields.io/npm/l/kysely-ddl)](LICENSE)
5
6
 
6
7
  PostgreSQL schema as TypeScript code, SQL migrations generated from snapshot
7
8
  diffs, Kysely table types, and a migration runner on top of your `Kysely`.
@@ -28,8 +29,8 @@ Bun, or any PostgreSQL dialect for Kysely. Runtime: Node >= 20 or Bun >= 1.2.
28
29
 
29
30
  | import | contents |
30
31
  |---|---|
31
- | `kysely-ddl` | table definitions, migration generation, migration files on disk. Does not import Kysely |
32
- | `kysely-ddl/kysely` | `inferKyselyTable`, `inferKyselyDatabase`, `jsonb` / `jsonbArray`, `createMigrator` / `migrateToLatest`, `sqlFileMigrationProvider` |
32
+ | `kysely-ddl` | table definitions, migration generation, migration files on disk, `inferKyselyTable` / `inferKyselyDatabase`, `jsonb` / `jsonbArray` |
33
+ | `kysely-ddl/migrator` | running migrations: `createMigrator` / `migrateToLatest` and `sqlFileMigrationProvider`. A separate entry point, so that a project which applies its migrations some other way does not pull the runner in |
33
34
 
34
35
  ## Quick start
35
36
 
@@ -39,7 +40,7 @@ Bun, or any PostgreSQL dialect for Kysely. Runtime: Node >= 20 or Bun >= 1.2.
39
40
  import { defineTable, ref, sql } from 'kysely-ddl';
40
41
 
41
42
  export const userTable = defineTable({
42
- tableName: 'user',
43
+ name: 'user',
43
44
  // builders arrive as an argument; they are not exported one by one
44
45
  columns: t => ({
45
46
  // no explicit column name -> derived from the property: created_at, apple_id, ...
@@ -56,7 +57,7 @@ export const userTable = defineTable({
56
57
  });
57
58
 
58
59
  export const sessionTable = defineTable({
59
- tableName: 'session',
60
+ name: 'session',
60
61
  columns: t => ({
61
62
  id: t.uuid().notNull().default(sql`gen_random_uuid()`),
62
63
  userId: t.uuid().notNull(),
@@ -75,17 +76,19 @@ import * as schema from './schema';
75
76
  const dir = './migrations';
76
77
  const result = generateMigration([schema.userTable, schema.sessionTable], readLatestSnapshot(dir));
77
78
 
78
- if (result.statements.length === 0) {
79
+ if (result.changes.length === 0) {
79
80
  console.log('no changes');
80
81
  } else {
81
- console.log(writeMigration(dir, process.argv[2] ?? 'migration', result)); // 20260910123045_migration
82
+ // one file, or two when the diff has indexes built CONCURRENTLY:
83
+ // [ '20260910123045_migration', '20260910123046_migration_concurrently' ]
84
+ console.log(writeMigration(dir, process.argv[2] ?? 'migration', result));
82
85
  }
83
86
  ```
84
87
 
85
88
  `migrate.ts`, applying migrations:
86
89
 
87
90
  ```ts
88
- import { migrateToLatest } from 'kysely-ddl/kysely';
91
+ import { migrateToLatest } from 'kysely-ddl/migrator';
89
92
  import { Kysely, PostgresDialect } from 'kysely';
90
93
  import pg from 'pg';
91
94
 
@@ -102,7 +105,7 @@ try {
102
105
  Types for queries:
103
106
 
104
107
  ```ts
105
- import type { inferKyselyDatabase } from 'kysely-ddl/kysely';
108
+ import type { inferKyselyDatabase } from 'kysely-ddl';
106
109
  import * as schema from './schema';
107
110
 
108
111
  type DB = inferKyselyDatabase<typeof schema>;
@@ -120,7 +123,7 @@ A hybrid: columns as chains, everything else as a declarative block.
120
123
  |---|---|
121
124
  | column types | `t.uuid` `t.varchar({ length })` `t.integer` `t.bigint` `t.boolean` `t.numeric({ precision, scale })` `t.timestamp({ withTimezone, precision })` `t.jsonb` `t.enum([...])` |
122
125
  | modifiers | `.notNull()` `.default(v \| sql)` `.defaultNow()` `.array()` `.$type<T>()` `.generatedAlwaysAsIdentity()` |
123
- | table | `primaryKey` (composite too), `uniques`, `indexes` (unique and partial via `where`), `foreignKeys` (`onDelete` / `onUpdate`), `checks`; names are optional everywhere |
126
+ | table | `primaryKey` (composite too), `uniques`, `indexes` (unique, partial via `where`, built online via `concurrently`), `foreignKeys` (`onDelete` / `onUpdate`), `checks`; names are optional everywhere |
124
127
  | expressions | `` sql`...` `` with column and literal interpolation, `inArray(c.status, [...])` |
125
128
 
126
129
  When a column name is not given, it is derived from the property name with the
@@ -183,17 +186,16 @@ Collisions are caught too: two objects on the same columns, an index named like
183
186
  a unique constraint, a check with neither columns nor a name, two properties
184
187
  mapping to the same database name.
185
188
 
186
- ### What is missing
189
+ ### Limitations
187
190
 
188
191
  Native enums, stored generated columns, schemas other than `public`, views,
189
- `DEFERRABLE`, covering indexes, index methods other than btree. Everything is
190
- added the same way: a field in `TableSpec` -> a field in the snapshot -> a branch
191
- in the diff -> a branch in the renderer.
192
+ `DEFERRABLE`, covering indexes, index methods other than btree. Pull requests are
193
+ welcome; [CONTRIBUTING.md](CONTRIBUTING.md) describes how such a feature is added.
192
194
 
193
195
  ## Types for Kysely
194
196
 
195
197
  ```ts
196
- import type { inferKyselyDatabase, inferKyselyTable } from 'kysely-ddl/kysely';
198
+ import type { inferKyselyDatabase, inferKyselyTable } from 'kysely-ddl';
197
199
  import type { Insertable, Selectable } from 'kysely';
198
200
 
199
201
  type UserTable = inferKyselyTable<typeof userTable>;
@@ -255,7 +257,7 @@ and the `Jsonb<T>` brand in the write types keeps raw objects and strings from
255
257
  slipping past them:
256
258
 
257
259
  ```ts
258
- import { jsonb, jsonbArray } from 'kysely-ddl/kysely';
260
+ import { jsonb, jsonbArray } from 'kysely-ddl';
259
261
 
260
262
  await db.insertInto('user').values({ settings: jsonb({ theme: 'dark', tags: ['a'] }) }).execute();
261
263
  await db.updateTable('user').set({ settings: jsonb({ theme: 'light' }) }).where('id', '=', id).execute();
@@ -294,15 +296,56 @@ migrations/
294
296
  snapshot.json
295
297
  ```
296
298
 
297
- `writeMigration(dir, name, result)` writes the `.sql` file and updates
298
- `snapshot.json`, `readLatestSnapshot(dir)` reads it for the next diff, and
299
+ `writeMigration(dir, name, result)` writes the `.sql` files and updates
300
+ `snapshot.json`, returning the names in application order.
301
+ `readLatestSnapshot(dir)` reads the snapshot for the next diff, and
299
302
  `listMigrations(dir)` returns names in the order the runner applies them (by
300
303
  character codes, like Kysely). There is no separate journal on disk: the table in
301
304
  the database knows what has been applied.
302
305
 
303
- Inside a `.sql` file, statements are separated by the `--> statement-breakpoint`
304
- comment: the file stays valid for `psql`, and the runner splits on it to execute
305
- statements one at a time so that an error points at a specific statement.
306
+ An ordinary migration is plain SQL. The runner sends the whole file to postgres
307
+ as one query, and postgres runs a multi-statement query as one implicit
308
+ transaction, so even under `transaction: 'none'` a failing statement rolls the
309
+ file back. `MigrationError.line` says which line postgres pointed at.
310
+
311
+ ### Indexes built `CONCURRENTLY`
312
+
313
+ `CREATE INDEX CONCURRENTLY` does not lock the table against writes, but postgres
314
+ refuses it inside a transaction and inside a multi-statement query alike. An
315
+ index declared with `concurrently: true` is therefore written as a migration of
316
+ its own, one second after the ordinary one:
317
+
318
+ ```ts
319
+ indexes: [{ columns: ['userId'], concurrently: true }],
320
+ ```
321
+
322
+ ```
323
+ migrations/
324
+ 20260910120000_add_tickets.sql <- the table and its fk, one transaction
325
+ 20260910120001_add_tickets_concurrently.sql <- the index, outside any transaction
326
+ ```
327
+
328
+ ```sql
329
+ --> no-transaction
330
+ DROP INDEX CONCURRENTLY IF EXISTS "ticket_user_id_idx";
331
+ --> statement-breakpoint
332
+ CREATE INDEX CONCURRENTLY "ticket_user_id_idx" ON "ticket" ("user_id");
333
+ ```
334
+
335
+ The `--> no-transaction` marker in the header tells the runner to skip the
336
+ transaction, and `--> statement-breakpoint` makes it send the statements one at a
337
+ time, which `CONCURRENTLY` also demands. Both are comments for `psql`. Dropping
338
+ such an index is `DROP INDEX CONCURRENTLY` in the same kind of file. A failed
339
+ `CREATE INDEX CONCURRENTLY` leaves an invalid index behind while the migration
340
+ stays unrecorded, hence the `DROP INDEX CONCURRENTLY IF EXISTS` in front: the
341
+ rerun is clean. Toggling `concurrently` on an existing index changes nothing.
342
+
343
+ The runner applies such a migration under the default `transaction: 'each'` and
344
+ under `'none'`, and rejects it under `'all'` before touching the database; through the provider,
345
+ Kysely's `Migrator` needs `transactionMode: 'per-migration'` (Kysely 0.30+).
346
+ Both markers may also be written by hand. A `--> no-transaction` below the
347
+ header is an error: postgres would take it for a comment. `readMigration(dir, name)`
348
+ returns the execution chunks together with the `transaction` flag.
306
349
 
307
350
  Names are monotonic: if the previous migration was created in the same second,
308
351
  the timestamp is bumped forward, otherwise the suffix would decide the order.
@@ -340,13 +383,13 @@ The runner takes a ready `Kysely` with any PostgreSQL dialect and works through
340
383
  single connection (`db.connection()`): the lock and the transaction live on it.
341
384
 
342
385
  ```ts
343
- import { createMigrator, migrateToLatest } from 'kysely-ddl/kysely';
386
+ import { createMigrator, migrateToLatest } from 'kysely-ddl/migrator';
344
387
 
345
388
  const migrator = createMigrator({
346
389
  db, // a Kysely instance, not a Transaction
347
390
  migrationsDir: './migrations',
348
391
  journalTable: 'kysely_migration', // the default, same as Kysely
349
- transaction: 'all', // 'all' | 'each' | 'none'
392
+ transaction: 'each', // the default; 'all' | 'each' | 'none'
350
393
  allowUnordered: false,
351
394
  });
352
395
 
@@ -358,13 +401,15 @@ await migrateToLatest({ db, migrationsDir: './migrations' }); // the same in one
358
401
 
359
402
  | option | effect |
360
403
  |---|---|
361
- | `transaction: 'all'` | the whole run in one transaction, like Kysely: a failing migration rolls back the earlier ones from the same run |
362
- | `transaction: 'each'` | one transaction per migration: the ones before the failure stay applied |
363
- | `transaction: 'none'` | no transactions, for `CREATE INDEX CONCURRENTLY` and the like |
404
+ | `transaction: 'each'` | the default: one transaction per migration, the ones before the failure stay applied. A `--> no-transaction` migration runs without one, its journal row in autocommit right after it |
405
+ | `transaction: 'all'` | the whole run in one transaction, like Kysely: a failing migration rolls back the earlier ones from the same run. A `--> no-transaction` migration is an error before anything is applied: one shared transaction cannot leave it out |
406
+ | `transaction: 'none'` | no `BEGIN` at all. A plain file still runs as one implicit transaction; only a file split by `--> statement-breakpoint` fails statement by statement. `--> no-transaction` changes nothing here |
364
407
  | `allowUnordered` | apply migrations that sort before already applied ones (branch merges). An error by default |
365
408
 
366
- A failing statement throws `MigrationError` with `migration`, `statement`,
367
- `applied` (what this run applied before the failure) and the driver's `cause`.
409
+ A failing query throws `MigrationError` with `migration`, `statement` (the text
410
+ sent: a whole file, or one chunk between breakpoints), `line` (where postgres
411
+ pointed inside it), `applied` (what this run applied before the failure) and the
412
+ driver's `cause`.
368
413
  A migration present in the journal but missing on disk is an error: restore the
369
414
  file from history or delete the row by hand.
370
415
 
@@ -378,13 +423,22 @@ lock is the same `pg_advisory_lock`. So `.sql` migrations can also be run by the
378
423
  built-in `Migrator`, and the two runners can alternate on one database:
379
424
 
380
425
  ```ts
381
- import { sqlFileMigrationProvider } from 'kysely-ddl/kysely';
426
+ import { sqlFileMigrationProvider } from 'kysely-ddl/migrator';
382
427
  import { Migrator } from 'kysely/migration';
383
428
 
384
429
  const migrator = new Migrator({ db, provider: sqlFileMigrationProvider('./migrations') });
385
430
  const { error, results } = await migrator.migrateToLatest();
386
431
  ```
387
432
 
433
+ `--> no-transaction` reaches the `Migrator` as `config: { transaction: false }`
434
+ on the migration, the field Kysely 0.30+ honors under
435
+ `new Migrator({ ..., transactionMode: 'per-migration' })`: every migration in
436
+ its own transaction, the marked one without. Under the default `'per-run'`
437
+ Kysely itself reports the marked migration as an error and applies nothing.
438
+ Older Kysely ignores `config`; there the marked migration checks that it is not
439
+ inside a transaction and fails with an error naming the fix, and the only way to
440
+ run it is `disableTransactions: true` for the whole run.
441
+
388
442
  There are no rollbacks: the generator only writes forward. Without `down`,
389
443
  Kysely skips a migration on `migrateDown` (`NotExecuted`) and leaves it in the
390
444
  journal, so by default the provider supplies a `down` that fails with a clear
@@ -415,23 +469,13 @@ What the checks showed on `pg` 8.23 and `Bun.SQL` 1.4 (parameters via Kysely):
415
469
  Reading jsonb, JSON arrays, `jsonb[]` and `varchar[]` yields parsed JS values
416
470
  with both drivers.
417
471
 
418
- ## Development
419
-
420
- ```bash
421
- bun install
422
- bun run db:up # postgres:18-alpine on 54329
423
- DATABASE_URL=postgres://postgres:postgres@localhost:54329/kysely_ddl bun test
424
- bun run typecheck && bun run lint && bun run build
425
- DATABASE_URL=... bun run smoke:node # the built dist under Node with pg
426
- ```
427
-
428
- Without `DATABASE_URL` the integration tests are skipped, the unit tests always
429
- run. Type-level checks live in `test/types.test-d.ts` and are read by `tsc` only.
472
+ ## Contributing
430
473
 
431
- Releases are tagged `vX.Y.Z`, matching `version` in `package.json`: GitHub
432
- Actions runs the checks and does `npm publish` with provenance. It needs the
433
- `NPM_TOKEN` secret in the repository settings.
474
+ Bug reports and pull requests are welcome on
475
+ [GitHub](https://github.com/hehmonke/kysely-ddl/issues). [CONTRIBUTING.md](CONTRIBUTING.md)
476
+ covers setting up the repository, running the tests and cutting a release.
477
+ Changes between versions are listed in [CHANGELOG.md](CHANGELOG.md).
434
478
 
435
479
  ## License
436
480
 
437
- MIT.
481
+ [MIT](LICENSE)
@@ -63,6 +63,8 @@ export type Change = {
63
63
  } | {
64
64
  readonly kind: 'dropIndex';
65
65
  readonly index: string;
66
+ /** `DROP INDEX CONCURRENTLY`, in the `--> no-transaction` migration together with the concurrent creates. */
67
+ readonly concurrently: boolean;
66
68
  };
67
69
  export declare function diffSnapshots(prev: Snapshot, next: Snapshot): Change[];
68
70
  export {};
@@ -24,6 +24,7 @@ function sameConstraint(a, b) {
24
24
  }
25
25
  return false;
26
26
  }
27
+ /** `concurrently` is left out on purpose: it says how to build the index, not what the index is. */
27
28
  function sameIndex(a, b) {
28
29
  return a.unique === b.unique && sameArray(a.columns, b.columns) && a.where === b.where;
29
30
  }
@@ -137,13 +138,15 @@ export function diffSnapshots(prev, next) {
137
138
  createIndexes.push({ kind: 'createIndex', table: after.name, index });
138
139
  }
139
140
  else if (!sameIndex(old, index)) {
140
- dropIndexes.push({ kind: 'dropIndex', index: index.name });
141
+ // the drop follows the NEW flag: both statements must land in the same
142
+ // migration, or the create would run before the drop
143
+ dropIndexes.push({ kind: 'dropIndex', index: index.name, concurrently: index.concurrently });
141
144
  createIndexes.push({ kind: 'createIndex', table: after.name, index });
142
145
  }
143
146
  }
144
147
  for (const index of before.indexes) {
145
148
  if (!afterIndexes.has(index.name)) {
146
- dropIndexes.push({ kind: 'dropIndex', index: index.name });
149
+ dropIndexes.push({ kind: 'dropIndex', index: index.name, concurrently: index.concurrently });
147
150
  }
148
151
  }
149
152
  }
@@ -7,13 +7,19 @@ import type { AnyTable } from '../table/define.ts';
7
7
  import { type Change } from './diff.ts';
8
8
  import { type Snapshot } from './snapshot.ts';
9
9
  export interface GenerateResult {
10
- /** Migration SQL; an empty string means no changes. */
10
+ /** The migration text, without the `CONCURRENTLY` statements; an empty string means none. */
11
11
  readonly sql: string;
12
- /** The same SQL as individual statements; the runner executes these. */
12
+ /** The same SQL statement by statement. */
13
13
  readonly statements: readonly string[];
14
+ /**
15
+ * The `CONCURRENTLY` index statements. Postgres refuses them inside a
16
+ * transaction and inside a multi-statement query alike, so `writeMigration`
17
+ * puts them into a migration of their own, marked `--> no-transaction`.
18
+ */
19
+ readonly concurrently: readonly string[];
14
20
  /** The snapshot to store next to the migration. */
15
21
  readonly snapshot: Snapshot;
16
- /** Parsed changes; handy for tests and for a "what changed" summary. */
22
+ /** Parsed changes; handy for tests and for a "what changed" summary. Empty means nothing to write. */
17
23
  readonly changes: readonly Change[];
18
24
  }
19
25
  export declare function generateMigration(tables: readonly AnyTable[], previous?: Snapshot): GenerateResult;
@@ -1,8 +1,14 @@
1
1
  import { diffSnapshots } from './diff.js';
2
- import { renderChanges, renderStatements } from './render.js';
2
+ import { renderChanges, renderConcurrentStatements, renderStatements } from './render.js';
3
3
  import { buildSnapshot, EMPTY_SNAPSHOT } from './snapshot.js';
4
4
  export function generateMigration(tables, previous = EMPTY_SNAPSHOT) {
5
5
  const snapshot = buildSnapshot(tables);
6
6
  const changes = diffSnapshots(previous, snapshot);
7
- return { sql: renderChanges(changes), statements: renderStatements(changes), snapshot, changes };
7
+ return {
8
+ sql: renderChanges(changes),
9
+ statements: renderStatements(changes),
10
+ concurrently: renderConcurrentStatements(changes),
11
+ snapshot,
12
+ changes,
13
+ };
8
14
  }
@@ -1,6 +1,15 @@
1
1
  import type { Change } from './diff.ts';
2
2
  export declare function renderChange(change: Change): string[];
3
- /** Individual statements; `writeMigration` writes these to disk. */
3
+ /** The statements of the ordinary migration, that is, everything but the `CONCURRENTLY` ones. */
4
4
  export declare function renderStatements(changes: readonly Change[]): string[];
5
- /** Multi-line statements are separated by a blank line, single-line ones follow each other. */
5
+ /**
6
+ * The statements of the `--> no-transaction` migration: `CONCURRENTLY` drops and
7
+ * creates. A replaced index yields its drop twice, as a change of its own and as
8
+ * the guard in front of the create, so duplicates are folded.
9
+ */
10
+ export declare function renderConcurrentStatements(changes: readonly Change[]): string[];
11
+ /**
12
+ * The ordinary migration as one text: multi-line statements are separated by a
13
+ * blank line, single-line ones follow each other. `writeMigration` writes it as is.
14
+ */
6
15
  export declare function renderChanges(changes: readonly Change[]): string;
@@ -67,8 +67,19 @@ function createTable(table) {
67
67
  }
68
68
  function createIndex(table, index) {
69
69
  const unique = index.unique ? 'UNIQUE ' : '';
70
+ const concurrently = index.concurrently ? 'CONCURRENTLY ' : '';
70
71
  const where = index.where === null ? '' : ` WHERE ${index.where}`;
71
- return `CREATE ${unique}INDEX ${q(index.name)} ON ${q(table)} (${columns(index.columns)})${where};`;
72
+ return `CREATE ${unique}INDEX ${concurrently}${q(index.name)} ON ${q(table)} (${columns(index.columns)})${where};`;
73
+ }
74
+ function dropIndexConcurrently(name) {
75
+ return `DROP INDEX CONCURRENTLY IF EXISTS ${q(name)};`;
76
+ }
77
+ /**
78
+ * Changes whose SQL postgres refuses inside a transaction, and inside a
79
+ * multi-statement query too. `writeMigration` gives them a migration of their own.
80
+ */
81
+ function isConcurrent(change) {
82
+ return (change.kind === 'createIndex' && change.index.concurrently) || (change.kind === 'dropIndex' && change.concurrently);
72
83
  }
73
84
  function alterColumn(table, from, to) {
74
85
  const head = `ALTER TABLE ${q(table)} ALTER COLUMN ${q(to.name)}`;
@@ -111,18 +122,34 @@ export function renderChange(change) {
111
122
  `ADD ${constraintClause(change.constraint)};`,
112
123
  ];
113
124
  case 'createIndex':
114
- return [createIndex(change.table, change.index)];
125
+ // a failed CREATE INDEX CONCURRENTLY leaves an invalid index behind, and the
126
+ // migration stays unrecorded; the drop in front makes the rerun clean
127
+ return change.index.concurrently
128
+ ? [dropIndexConcurrently(change.index.name), createIndex(change.table, change.index)]
129
+ : [createIndex(change.table, change.index)];
115
130
  case 'dropIndex':
116
- return [`DROP INDEX ${q(change.index)};`];
131
+ return [change.concurrently ? dropIndexConcurrently(change.index) : `DROP INDEX ${q(change.index)};`];
117
132
  default:
118
133
  return unreachable(change);
119
134
  }
120
135
  }
121
- /** Individual statements; `writeMigration` writes these to disk. */
136
+ /** The statements of the ordinary migration, that is, everything but the `CONCURRENTLY` ones. */
122
137
  export function renderStatements(changes) {
123
- return changes.flatMap(renderChange);
138
+ return changes.filter(change => !isConcurrent(change)).flatMap(renderChange);
124
139
  }
125
- /** Multi-line statements are separated by a blank line, single-line ones follow each other. */
140
+ /**
141
+ * The statements of the `--> no-transaction` migration: `CONCURRENTLY` drops and
142
+ * creates. A replaced index yields its drop twice, as a change of its own and as
143
+ * the guard in front of the create, so duplicates are folded.
144
+ */
145
+ export function renderConcurrentStatements(changes) {
146
+ const statements = changes.filter(isConcurrent).flatMap(renderChange);
147
+ return statements.filter((statement, i) => statements.indexOf(statement) === i);
148
+ }
149
+ /**
150
+ * The ordinary migration as one text: multi-line statements are separated by a
151
+ * blank line, single-line ones follow each other. `writeMigration` writes it as is.
152
+ */
126
153
  export function renderChanges(changes) {
127
154
  const statements = renderStatements(changes);
128
155
  let out = '';
@@ -25,6 +25,12 @@ export interface TableSnapshot {
25
25
  readonly unique: boolean;
26
26
  readonly columns: readonly string[];
27
27
  readonly where: string | null;
28
+ /**
29
+ * Built and dropped with `CONCURRENTLY`. How the index is built, not what it
30
+ * is: the diff ignores it when comparing indexes. Snapshots written before
31
+ * the field existed simply lack it, which reads as `false`.
32
+ */
33
+ readonly concurrently: boolean;
28
34
  }[];
29
35
  readonly foreignKeys: readonly {
30
36
  readonly name: string;
@@ -29,6 +29,7 @@ function tableSnapshot(spec) {
29
29
  unique: i.unique,
30
30
  columns: [...i.columns],
31
31
  where: i.where !== undefined ? renderSql(i.where) : null,
32
+ concurrently: i.concurrently,
32
33
  })),
33
34
  foreignKeys: spec.foreignKeys.map(f => ({
34
35
  name: f.name,
package/dist/index.d.ts CHANGED
@@ -1,27 +1,37 @@
1
1
  /**
2
- * kysely-ddl: PostgreSQL schema as code and SQL migration generation.
3
- * Has no runtime dependency on Kysely or on any driver.
2
+ * kysely-ddl: PostgreSQL schema as code, SQL migration generation and the
3
+ * Kysely side of it, row types and jsonb values. Imports `kysely`, the peer
4
+ * dependency, and no driver.
4
5
  *
5
6
  * Layers and dependency direction:
6
7
  *
7
- * table ◄── generator ◄── migrator ◄── kysely
8
+ * table ◄── generator ◄── migrator/store ◄── migrator (runner, provider)
9
+ * table ◄── kysely (row types, jsonb values)
8
10
  *
9
11
  * Entry points:
10
12
  *
11
- * kysely-ddl — this file: tables, generation, migration files
12
- * kysely-ddl/kysely — row types, migration runner, provider for Migrator
13
+ * kysely-ddl — this file: tables, generation, migration files on disk,
14
+ * `inferKyselyTable` / `inferKyselyDatabase`, `jsonb` / `jsonbArray`
15
+ * kysely-ddl/migrator — running migrations: `createMigrator` / `migrateToLatest`
16
+ * and `sqlFileMigrationProvider`; separate, so that a project
17
+ * which applies migrations some other way does not pull it in
13
18
  *
14
19
  * Exports are sorted by module path: generator (snapshot, diff, render,
15
- * facade), migrator (files on disk), table (table definitions).
20
+ * facade), kysely (row types, jsonb values), migrator (files on disk), table
21
+ * (table definitions).
16
22
  */
17
23
  export { diffSnapshots } from './generator/diff.ts';
18
24
  export type { Change, Constraint } from './generator/diff.ts';
19
25
  export { generateMigration } from './generator/generate.ts';
20
26
  export type { GenerateResult } from './generator/generate.ts';
21
- export { renderChange, renderChanges, renderStatements } from './generator/render.ts';
27
+ export { renderChange, renderChanges, renderConcurrentStatements, renderStatements } from './generator/render.ts';
22
28
  export { buildSnapshot, EMPTY_SNAPSHOT, SNAPSHOT_VERSION } from './generator/snapshot.ts';
23
29
  export type { ColumnSnapshot, Snapshot, TableSnapshot } from './generator/snapshot.ts';
24
- export { listMigrations, MIGRATION_EXTENSION, migrationTimestamp, readLatestSnapshot, readStatements, SNAPSHOT_FILE, STATEMENT_SEPARATOR, writeMigration, } from './migrator/store.ts';
30
+ export type { inferKyselyDatabase, inferKyselyTable } from './kysely/infer.ts';
31
+ export { jsonb, jsonbArray } from './kysely/json.ts';
32
+ export type { Jsonb } from './kysely/json.ts';
33
+ export { CONCURRENTLY_SUFFIX, listMigrations, MIGRATION_EXTENSION, migrationTimestamp, NO_TRANSACTION_MARKER, readLatestSnapshot, readMigration, readStatements, SNAPSHOT_FILE, STATEMENT_SEPARATOR, writeMigration, } from './migrator/store.ts';
34
+ export type { MigrationFile } from './migrator/store.ts';
25
35
  export { toCamelCase, toSnakeCase } from './table/casing.ts';
26
36
  export type { CamelCase, SnakeCase } from './table/casing.ts';
27
37
  export { ColumnBuilder, columnBuilders } from './table/columns.ts';
package/dist/index.js CHANGED
@@ -1,24 +1,31 @@
1
1
  /**
2
- * kysely-ddl: PostgreSQL schema as code and SQL migration generation.
3
- * Has no runtime dependency on Kysely or on any driver.
2
+ * kysely-ddl: PostgreSQL schema as code, SQL migration generation and the
3
+ * Kysely side of it, row types and jsonb values. Imports `kysely`, the peer
4
+ * dependency, and no driver.
4
5
  *
5
6
  * Layers and dependency direction:
6
7
  *
7
- * table ◄── generator ◄── migrator ◄── kysely
8
+ * table ◄── generator ◄── migrator/store ◄── migrator (runner, provider)
9
+ * table ◄── kysely (row types, jsonb values)
8
10
  *
9
11
  * Entry points:
10
12
  *
11
- * kysely-ddl — this file: tables, generation, migration files
12
- * kysely-ddl/kysely — row types, migration runner, provider for Migrator
13
+ * kysely-ddl — this file: tables, generation, migration files on disk,
14
+ * `inferKyselyTable` / `inferKyselyDatabase`, `jsonb` / `jsonbArray`
15
+ * kysely-ddl/migrator — running migrations: `createMigrator` / `migrateToLatest`
16
+ * and `sqlFileMigrationProvider`; separate, so that a project
17
+ * which applies migrations some other way does not pull it in
13
18
  *
14
19
  * Exports are sorted by module path: generator (snapshot, diff, render,
15
- * facade), migrator (files on disk), table (table definitions).
20
+ * facade), kysely (row types, jsonb values), migrator (files on disk), table
21
+ * (table definitions).
16
22
  */
17
23
  export { diffSnapshots } from './generator/diff.js';
18
24
  export { generateMigration } from './generator/generate.js';
19
- export { renderChange, renderChanges, renderStatements } from './generator/render.js';
25
+ export { renderChange, renderChanges, renderConcurrentStatements, renderStatements } from './generator/render.js';
20
26
  export { buildSnapshot, EMPTY_SNAPSHOT, SNAPSHOT_VERSION } from './generator/snapshot.js';
21
- export { listMigrations, MIGRATION_EXTENSION, migrationTimestamp, readLatestSnapshot, readStatements, SNAPSHOT_FILE, STATEMENT_SEPARATOR, writeMigration, } from './migrator/store.js';
27
+ export { jsonb, jsonbArray } from './kysely/json.js';
28
+ export { CONCURRENTLY_SUFFIX, listMigrations, MIGRATION_EXTENSION, migrationTimestamp, NO_TRANSACTION_MARKER, readLatestSnapshot, readMigration, readStatements, SNAPSHOT_FILE, STATEMENT_SEPARATOR, writeMigration, } from './migrator/store.js';
22
29
  export { toCamelCase, toSnakeCase } from './table/casing.js';
23
30
  export { ColumnBuilder, columnBuilders } from './table/columns.js';
24
31
  export { AUTO_NAMES, defineTable, ref } from './table/define.js';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Running `.sql` migrations. Entry point `kysely-ddl/migrator`, kept apart from
3
+ * the root one so that a project which applies its migrations some other way
4
+ * (psql, a CI job, a runner of its own) does not pull this in.
5
+ *
6
+ * Two ways to run: `createMigrator` / `migrateToLatest`, a runner on top of a
7
+ * ready `Kysely`, and `sqlFileMigrationProvider`, which feeds the same files to
8
+ * Kysely's own `Migrator`. Both read what `writeMigration` writes; the file
9
+ * format itself (`store.ts` next door) is exported from the root entry point,
10
+ * since generating a migration needs it and running one is optional.
11
+ */
12
+ export { sqlFileMigrationProvider } from './provider.ts';
13
+ export type { SqlMigrationProviderOptions } from './provider.ts';
14
+ export { createMigrator, DEFAULT_JOURNAL_TABLE, migrateToLatest, MIGRATION_LOCK_ID, MigrationError, } from './runner.ts';
15
+ export type { MigrationRunResult, MigrationStatus, MigratorOptions, SqlMigrator, TransactionMode } from './runner.ts';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Running `.sql` migrations. Entry point `kysely-ddl/migrator`, kept apart from
3
+ * the root one so that a project which applies its migrations some other way
4
+ * (psql, a CI job, a runner of its own) does not pull this in.
5
+ *
6
+ * Two ways to run: `createMigrator` / `migrateToLatest`, a runner on top of a
7
+ * ready `Kysely`, and `sqlFileMigrationProvider`, which feeds the same files to
8
+ * Kysely's own `Migrator`. Both read what `writeMigration` writes; the file
9
+ * format itself (`store.ts` next door) is exported from the root entry point,
10
+ * since generating a migration needs it and running one is optional.
11
+ */
12
+ export { sqlFileMigrationProvider } from './provider.js';
13
+ export { createMigrator, DEFAULT_JOURNAL_TABLE, migrateToLatest, MIGRATION_LOCK_ID, MigrationError, } from './runner.js';
@@ -21,13 +21,16 @@
21
21
  *
22
22
  * On transactions: on postgres Kysely runs the WHOLE run in one transaction by
23
23
  * default, DDL is transactional there, so a failing migration rolls back all the
24
- * previous ones from the same run. It is disabled with
25
- * `new Migrator({ ..., disableTransactions: true })`; that is needed for
26
- * operations postgres forbids inside a transaction (`CREATE INDEX CONCURRENTLY`
27
- * and the like).
24
+ * previous ones from the same run. A migration with `--> no-transaction` in its
25
+ * header gets `config: { transaction: false }`, which Kysely 0.30+ honors under
26
+ * `new Migrator({ ..., transactionMode: 'per-migration' })`: every migration in
27
+ * its own transaction, the marked one without. Older Kysely ignores `config`, so
28
+ * the marked migration checks that it is not inside a transaction and fails with
29
+ * a clear error instead of a postgres one; there the only way out is
30
+ * `disableTransactions: true` for the whole run.
28
31
  */
29
32
  import { sql } from 'kysely';
30
- import { listMigrations, readStatements } from '../migrator/store.js';
33
+ import { listMigrations, NO_TRANSACTION_MARKER, readMigration } from './store.js';
31
34
  /**
32
35
  * A provider that hands `.sql` files from a folder to the `Migrator`.
33
36
  * The migration name is the file name without the extension, the order is
@@ -39,14 +42,22 @@ export function sqlFileMigrationProvider(dir, options = {}) {
39
42
  async getMigrations() {
40
43
  const migrations = {};
41
44
  for (const name of listMigrations(dir)) {
42
- migrations[name] = {
45
+ const file = readMigration(dir, name);
46
+ const migration = {
43
47
  async up(db) {
44
- // one statement at a time: the driver does not trip over
45
- // multi-statement text, and an error points at a specific query
46
- for (const statement of readStatements(dir, name)) {
48
+ if (!file.transaction && db.isTransaction) {
49
+ throw new Error(`${name} is marked "${NO_TRANSACTION_MARKER}" but the Migrator runs it inside a transaction. ` +
50
+ "Kysely 0.30+: new Migrator({ ..., transactionMode: 'per-migration' }); older Kysely: " +
51
+ "disableTransactions: true; or kysely-ddl's createMigrator({ ..., transaction: 'each' }).");
52
+ }
53
+ // chunk by chunk: a plain file is one chunk, a file with
54
+ // `--> statement-breakpoint` several; CONCURRENTLY statements must travel alone
55
+ for (const statement of file.statements) {
47
56
  await sql.raw(statement).execute(db);
48
57
  }
49
58
  },
59
+ // only when marked: in Kysely's other transaction modes an explicit value is an error
60
+ ...(file.transaction ? {} : { config: { transaction: false } }),
50
61
  ...(onDown === 'throw'
51
62
  ? {
52
63
  down() {
@@ -56,6 +67,7 @@ export function sqlFileMigrationProvider(dir, options = {}) {
56
67
  }
57
68
  : {}),
58
69
  };
70
+ migrations[name] = migration;
59
71
  }
60
72
  return migrations;
61
73
  },
@@ -34,10 +34,13 @@ export interface MigratorOptions {
34
34
  /** The journal table. `kysely_migration` by default, like Kysely. */
35
35
  readonly journalTable?: string;
36
36
  /**
37
- * `all` (default): the whole run in one transaction, like Kysely, so a failing
38
- * migration rolls back the previous ones from the same run. `each`: a
39
- * transaction per migration. `none`: no transactions, needed for
40
- * `CREATE INDEX CONCURRENTLY`.
37
+ * `each` (default): a transaction per migration; a migration with
38
+ * `--> no-transaction` in its header, which is how the generator writes
39
+ * `CREATE INDEX CONCURRENTLY`, runs without one. `all`: the whole run in one
40
+ * transaction, like Kysely, so a failing migration rolls back the previous ones
41
+ * from the same run; the marker is an error here, one shared transaction cannot
42
+ * leave a migration out. `none`: no `BEGIN` at all; a plain file still runs as
43
+ * one implicit transaction, since postgres treats a multi-statement query that way.
41
44
  */
42
45
  readonly transaction?: TransactionMode;
43
46
  /**
@@ -60,14 +63,23 @@ export interface SqlMigrator {
60
63
  /** Applies everything pending. A failing statement throws `MigrationError`. */
61
64
  toLatest(): Promise<MigrationRunResult>;
62
65
  }
63
- /** A failing statement: which migration, which query and what was applied before it. */
66
+ /** A failing query: which migration, which text and what was applied before it. */
64
67
  export declare class MigrationError extends Error {
65
68
  readonly migration: string;
69
+ /** The text sent as one query: a whole file, or one chunk between `--> statement-breakpoint` markers. */
66
70
  readonly statement: string;
67
71
  /** Migrations applied by this run before the error. With `transaction: 'all'` they are rolled back. */
68
72
  readonly applied: readonly string[];
69
73
  readonly name = "MigrationError";
70
- constructor(migration: string, statement: string,
74
+ /**
75
+ * The line inside `statement` postgres pointed at, when it did. A generated
76
+ * migration is one query with several statements, so this is what locates
77
+ * the failing one.
78
+ */
79
+ readonly line: number | undefined;
80
+ constructor(migration: string,
81
+ /** The text sent as one query: a whole file, or one chunk between `--> statement-breakpoint` markers. */
82
+ statement: string,
71
83
  /** Migrations applied by this run before the error. With `transaction: 'all'` they are rolled back. */
72
84
  applied: readonly string[], cause: unknown);
73
85
  }
@@ -18,31 +18,49 @@
18
18
  * happy with `Migrator` takes `sqlFileMigrationProvider`.
19
19
  */
20
20
  import { sql } from 'kysely';
21
- import { listMigrations, readStatements } from '../migrator/store.js';
21
+ import { listMigrations, NO_TRANSACTION_MARKER, readMigration } from './store.js';
22
22
  /** Same as Kysely, so both runners see one state. */
23
23
  export const DEFAULT_JOURNAL_TABLE = 'kysely_migration';
24
24
  /** The `pg_advisory_lock` key, the same as Kysely's `PostgresAdapter` uses. */
25
25
  export const MIGRATION_LOCK_ID = 3853314791062309107n;
26
26
  /** How long to wait for another run. An hour, like Kysely. */
27
27
  const LOCK_TIMEOUT_MS = 60 * 60 * 1000;
28
- /** A failing statement: which migration, which query and what was applied before it. */
28
+ /** A failing query: which migration, which text and what was applied before it. */
29
29
  export class MigrationError extends Error {
30
30
  migration;
31
31
  statement;
32
32
  applied;
33
33
  name = 'MigrationError';
34
- constructor(migration, statement,
34
+ /**
35
+ * The line inside `statement` postgres pointed at, when it did. A generated
36
+ * migration is one query with several statements, so this is what locates
37
+ * the failing one.
38
+ */
39
+ line;
40
+ constructor(migration,
41
+ /** The text sent as one query: a whole file, or one chunk between `--> statement-breakpoint` markers. */
42
+ statement,
35
43
  /** Migrations applied by this run before the error. With `transaction: 'all'` they are rolled back. */
36
44
  applied, cause) {
37
- super(`${migration}: statement failed\n${statement}\n${describe(cause)}`, { cause });
45
+ const line = lineOf(statement, cause);
46
+ super(`${migration}: statement failed${line === undefined ? '' : ` at line ${line}`}\n${statement}\n${describe(cause)}`, { cause });
38
47
  this.migration = migration;
39
48
  this.statement = statement;
40
49
  this.applied = applied;
50
+ this.line = line;
41
51
  }
42
52
  }
43
53
  function describe(cause) {
44
54
  return cause instanceof Error ? cause.message : String(cause);
45
55
  }
56
+ /** Postgres reports the error position as a 1-based character offset into the query; both drivers pass it on. */
57
+ function lineOf(statement, cause) {
58
+ const position = Number(cause?.position);
59
+ if (!Number.isInteger(position) || position < 1) {
60
+ return undefined;
61
+ }
62
+ return statement.slice(0, position - 1).split('\n').length;
63
+ }
46
64
  export function createMigrator(options) {
47
65
  const { db, migrationsDir: dir } = options;
48
66
  if (db.isTransaction) {
@@ -53,7 +71,7 @@ export function createMigrator(options) {
53
71
  throw new Error(`journalTable: "${journal}" may only contain letters, digits and underscores`);
54
72
  }
55
73
  const table = sql.table(journal);
56
- const mode = options.transaction ?? 'all';
74
+ const mode = options.transaction ?? 'each';
57
75
  const allowUnordered = options.allowUnordered ?? false;
58
76
  return {
59
77
  status: () => db.connection().execute(async (connection) => {
@@ -122,8 +140,17 @@ async function apply(connection, table, dir, pending, mode) {
122
140
  if (pending.length === 0) {
123
141
  return { applied };
124
142
  }
125
- const runOne = async (name) => {
126
- for (const statement of readStatements(dir, name)) {
143
+ // parse everything first: a malformed file, or a marker the mode cannot honor,
144
+ // must fail before the first statement reaches the database
145
+ const migrations = pending.map(name => ({ name, ...readMigration(dir, name) }));
146
+ const marked = migrations.find(migration => !migration.transaction);
147
+ // under 'none' the marker asks for what already happens; under 'all' it cannot be honored
148
+ if (marked !== undefined && mode === 'all') {
149
+ throw new Error(`${dir}: ${marked.name} is marked "${NO_TRANSACTION_MARKER}", but transaction: 'all' runs the whole run ` +
150
+ "in one transaction and cannot leave a migration out. Use transaction: 'each'.");
151
+ }
152
+ const runOne = async ({ name, statements }) => {
153
+ for (const statement of statements) {
127
154
  try {
128
155
  await run(connection, statement);
129
156
  }
@@ -149,19 +176,25 @@ async function apply(connection, table, dir, pending, mode) {
149
176
  switch (mode) {
150
177
  case 'all':
151
178
  await inTransaction(async () => {
152
- for (const name of pending) {
153
- await runOne(name);
179
+ for (const migration of migrations) {
180
+ await runOne(migration);
154
181
  }
155
182
  });
156
183
  break;
157
184
  case 'each':
158
- for (const name of pending) {
159
- await inTransaction(() => runOne(name));
185
+ for (const migration of migrations) {
186
+ // a marked migration runs in autocommit, its journal row too, like Kysely's 'per-migration' mode
187
+ if (migration.transaction) {
188
+ await inTransaction(() => runOne(migration));
189
+ }
190
+ else {
191
+ await runOne(migration);
192
+ }
160
193
  }
161
194
  break;
162
195
  case 'none':
163
- for (const name of pending) {
164
- await runOne(name);
196
+ for (const migration of migrations) {
197
+ await runOne(migration);
165
198
  }
166
199
  break;
167
200
  }
@@ -7,11 +7,43 @@ export declare const SNAPSHOT_FILE = "snapshot.json";
7
7
  * stays valid when fed to psql as a whole.
8
8
  */
9
9
  export declare const STATEMENT_SEPARATOR = "--> statement-breakpoint";
10
+ /**
11
+ * A header marker: the migration runs outside a transaction, which postgres
12
+ * demands for `CREATE INDEX CONCURRENTLY` and the like. Recognized only in the
13
+ * header, that is, among the blank lines and comments before the first statement.
14
+ *
15
+ * The runner honors it under `transaction: 'each'`. For Kysely's `Migrator` the
16
+ * provider turns it into `config: { transaction: false }`, which Kysely 0.30+
17
+ * honors under `transactionMode: 'per-migration'`.
18
+ */
19
+ export declare const NO_TRANSACTION_MARKER = "--> no-transaction";
10
20
  export declare function migrationTimestamp(now?: Date): string;
11
21
  /** Migration names (without `.sql`), sorted the way Kysely sorts them: by character codes. */
12
22
  export declare function listMigrations(dir: string): string[];
13
23
  /** The schema state after the latest migration; the next diff is computed from it. */
14
24
  export declare function readLatestSnapshot(dir: string): Snapshot;
25
+ /** A parsed migration file. */
26
+ export interface MigrationFile {
27
+ /** The statements, split on `STATEMENT_SEPARATOR`, with the header markers taken out. */
28
+ readonly statements: readonly string[];
29
+ /** `false` when the header carries `NO_TRANSACTION_MARKER`. */
30
+ readonly transaction: boolean;
31
+ }
32
+ /** The statements of a migration and what its header markers said. */
33
+ export declare function readMigration(dir: string, name: string): MigrationFile;
34
+ /** The statements only; `readMigration` also tells whether the migration wants a transaction. */
15
35
  export declare function readStatements(dir: string, name: string): string[];
16
- /** Writes the migration file, updates the snapshot and returns the migration name. */
17
- export declare function writeMigration(dir: string, name: string, result: GenerateResult): string;
36
+ /** The suffix of the migration that holds the `CONCURRENTLY` statements. */
37
+ export declare const CONCURRENTLY_SUFFIX = "_concurrently";
38
+ /**
39
+ * Writes the migration files, updates the snapshot and returns the names in
40
+ * application order.
41
+ *
42
+ * The ordinary statements go into `<stamp>_<name>.sql` as plain SQL: postgres
43
+ * runs such a file as one query. The `CONCURRENTLY` statements, which postgres
44
+ * refuses inside a transaction and inside a multi-statement query alike, go into
45
+ * `<stamp+1>_<name>_concurrently.sql`, marked `--> no-transaction` and split by
46
+ * `--> statement-breakpoint` so that the runner sends them one at a time.
47
+ * Either file is skipped when it would be empty.
48
+ */
49
+ export declare function writeMigration(dir: string, name: string, result: GenerateResult): string[];
@@ -17,17 +17,34 @@
17
17
  * which is all that is needed to compute the next diff. The price is merge
18
18
  * conflicts: two branches that each add a migration diverge in one file, and
19
19
  * the snapshot has to be regenerated after the merge.
20
+ *
21
+ * Inside a file, `-->` comments are markers for the runner; postgres sees plain
22
+ * comments. `--> statement-breakpoint` separates statements, and
23
+ * `--> no-transaction` in the header says the migration runs outside a transaction.
20
24
  */
21
25
  import fs from 'node:fs';
22
26
  import path from 'node:path';
23
27
  import { EMPTY_SNAPSHOT } from '../generator/snapshot.js';
24
28
  export const MIGRATION_EXTENSION = '.sql';
25
29
  export const SNAPSHOT_FILE = 'snapshot.json';
30
+ /** Machine-readable comments start with this. */
31
+ const MARKER = '-->';
32
+ const NO_TRANSACTION = 'no-transaction';
26
33
  /**
27
34
  * Separator between statements inside a file. It is an SQL comment, so the file
28
35
  * stays valid when fed to psql as a whole.
29
36
  */
30
37
  export const STATEMENT_SEPARATOR = '--> statement-breakpoint';
38
+ /**
39
+ * A header marker: the migration runs outside a transaction, which postgres
40
+ * demands for `CREATE INDEX CONCURRENTLY` and the like. Recognized only in the
41
+ * header, that is, among the blank lines and comments before the first statement.
42
+ *
43
+ * The runner honors it under `transaction: 'each'`. For Kysely's `Migrator` the
44
+ * provider turns it into `config: { transaction: false }`, which Kysely 0.30+
45
+ * honors under `transactionMode: 'per-migration'`.
46
+ */
47
+ export const NO_TRANSACTION_MARKER = `${MARKER} ${NO_TRANSACTION}`;
31
48
  /** Parses the file name prefix back into a date. */
32
49
  function parseTimestamp(value) {
33
50
  return new Date(Date.UTC(Number(value.slice(0, 4)), Number(value.slice(4, 6)) - 1, Number(value.slice(6, 8)), Number(value.slice(8, 10)), Number(value.slice(10, 12)), Number(value.slice(12, 14))));
@@ -61,36 +78,104 @@ export function readLatestSnapshot(dir) {
61
78
  }
62
79
  return JSON.parse(fs.readFileSync(file, 'utf8'));
63
80
  }
64
- export function readStatements(dir, name) {
65
- const text = fs.readFileSync(path.join(dir, `${name}${MIGRATION_EXTENSION}`), 'utf8');
66
- return text
81
+ /** The word after `-->` on a marker line, undefined for any other line. */
82
+ function markerOf(line) {
83
+ return line.startsWith(MARKER) ? line.slice(MARKER.length).trim() : undefined;
84
+ }
85
+ /**
86
+ * The header is everything before the first statement: blank lines, plain `--`
87
+ * comments and markers. Markers are taken out, the rest goes to postgres as is.
88
+ * A marker below the header would reach postgres as a comment and change
89
+ * nothing, so it is an error rather than a silent no-op.
90
+ */
91
+ function parseMigration(text, name) {
92
+ const kept = [];
93
+ let transaction = true;
94
+ let inHeader = true;
95
+ for (const raw of text.split('\n')) {
96
+ const line = raw.trim();
97
+ const marker = markerOf(line);
98
+ if (inHeader && marker !== undefined && line !== STATEMENT_SEPARATOR) {
99
+ if (marker !== NO_TRANSACTION) {
100
+ throw new Error(`${name}: unknown marker "${line}" in the header. The only header marker is "${NO_TRANSACTION_MARKER}".`);
101
+ }
102
+ transaction = false;
103
+ continue;
104
+ }
105
+ // the first statement, or a separator, ends the header
106
+ if (inHeader && line !== '' && (marker !== undefined || !line.startsWith('--'))) {
107
+ inHeader = false;
108
+ }
109
+ if (!inHeader && marker === NO_TRANSACTION) {
110
+ throw new Error(`${name}: "${NO_TRANSACTION_MARKER}" must be in the header, before the first statement.`);
111
+ }
112
+ kept.push(raw);
113
+ }
114
+ const statements = kept
115
+ .join('\n')
67
116
  .split(STATEMENT_SEPARATOR)
68
117
  .map(statement => statement.trim())
69
118
  .filter(statement => statement !== '');
119
+ return { statements, transaction };
120
+ }
121
+ /** The statements of a migration and what its header markers said. */
122
+ export function readMigration(dir, name) {
123
+ return parseMigration(fs.readFileSync(path.join(dir, `${name}${MIGRATION_EXTENSION}`), 'utf8'), name);
124
+ }
125
+ /** The statements only; `readMigration` also tells whether the migration wants a transaction. */
126
+ export function readStatements(dir, name) {
127
+ return [...readMigration(dir, name).statements];
70
128
  }
71
- /** Writes the migration file, updates the snapshot and returns the migration name. */
129
+ /**
130
+ * Names must grow monotonically: Kysely applies migrations in alphabetical
131
+ * order. Two migrations created within the same second would get the same
132
+ * prefix, and the suffix would decide the order, that is, by luck.
133
+ */
134
+ function nextTimestamp(dir) {
135
+ const previous = listMigrations(dir).at(-1);
136
+ const stamp = migrationTimestamp();
137
+ if (previous !== undefined && stamp <= previous.slice(0, 14)) {
138
+ return migrationTimestamp(new Date(parseTimestamp(previous.slice(0, 14)).getTime() + 1000));
139
+ }
140
+ return stamp;
141
+ }
142
+ /** The suffix of the migration that holds the `CONCURRENTLY` statements. */
143
+ export const CONCURRENTLY_SUFFIX = '_concurrently';
144
+ /**
145
+ * Writes the migration files, updates the snapshot and returns the names in
146
+ * application order.
147
+ *
148
+ * The ordinary statements go into `<stamp>_<name>.sql` as plain SQL: postgres
149
+ * runs such a file as one query. The `CONCURRENTLY` statements, which postgres
150
+ * refuses inside a transaction and inside a multi-statement query alike, go into
151
+ * `<stamp+1>_<name>_concurrently.sql`, marked `--> no-transaction` and split by
152
+ * `--> statement-breakpoint` so that the runner sends them one at a time.
153
+ * Either file is skipped when it would be empty.
154
+ */
72
155
  export function writeMigration(dir, name, result) {
73
- if (result.statements.length === 0) {
156
+ if (result.statements.length === 0 && result.concurrently.length === 0) {
74
157
  throw new Error('nothing to write: no changes');
75
158
  }
76
159
  if (!/^[a-z0-9_]+$/.test(name)) {
77
160
  throw new Error(`migration name "${name}": only [a-z0-9_] is allowed`);
78
161
  }
79
- // Names must grow monotonically: Kysely applies migrations in alphabetical
80
- // order. Two migrations created within the same second would get the same
81
- // prefix, and the suffix would decide the order, that is, by luck.
82
- const previous = listMigrations(dir).at(-1);
83
- let stamp = migrationTimestamp();
84
- if (previous !== undefined && stamp <= previous.slice(0, 14)) {
85
- stamp = migrationTimestamp(new Date(parseTimestamp(previous.slice(0, 14)).getTime() + 1000));
162
+ fs.mkdirSync(dir, { recursive: true });
163
+ const written = [];
164
+ const write = (suffix, text) => {
165
+ const migration = `${nextTimestamp(dir)}_${name}${suffix}`;
166
+ const file = path.join(dir, `${migration}${MIGRATION_EXTENSION}`);
167
+ if (fs.existsSync(file)) {
168
+ throw new Error(`migration ${migration} already exists`);
169
+ }
170
+ fs.writeFileSync(file, text);
171
+ written.push(migration);
172
+ };
173
+ if (result.statements.length > 0) {
174
+ write('', result.sql);
86
175
  }
87
- const migration = `${stamp}_${name}`;
88
- const file = path.join(dir, `${migration}${MIGRATION_EXTENSION}`);
89
- if (fs.existsSync(file)) {
90
- throw new Error(`migration ${migration} already exists`);
176
+ if (result.concurrently.length > 0) {
177
+ write(CONCURRENTLY_SUFFIX, `${NO_TRANSACTION_MARKER}\n${result.concurrently.join(`\n${STATEMENT_SEPARATOR}\n`)}\n`);
91
178
  }
92
- fs.mkdirSync(dir, { recursive: true });
93
- fs.writeFileSync(file, `${result.statements.join(`\n${STATEMENT_SEPARATOR}\n`)}\n`);
94
179
  fs.writeFileSync(path.join(dir, SNAPSHOT_FILE), `${JSON.stringify(result.snapshot, null, 2)}\n`);
95
- return migration;
180
+ return written;
96
181
  }
@@ -168,7 +168,7 @@ declare function timestamp<N extends string>(name: N, config?: TimestampConfig):
168
168
  declare function timestamp(config?: TimestampConfig): Fresh<undefined, Date>;
169
169
  /**
170
170
  * `unknown` in TS, narrow it with `$type<T>()`. Values for writes go only
171
- * through `jsonb()` from `kysely-ddl/kysely`: drivers accept a raw jsonb
171
+ * through `jsonb()` from `kysely-ddl`: drivers accept a raw jsonb
172
172
  * parameter differently, and the helper evens that out.
173
173
  */
174
174
  declare function jsonb<N extends string>(name: N): Fresh<N, unknown, undefined, true>;
@@ -69,6 +69,8 @@ export interface TableSpec {
69
69
  readonly unique: boolean;
70
70
  readonly columns: readonly string[];
71
71
  readonly where: Sql | undefined;
72
+ /** Built and dropped with `CONCURRENTLY`, in a migration of its own. */
73
+ readonly concurrently: boolean;
72
74
  }[];
73
75
  readonly foreignKeys: readonly {
74
76
  readonly name: string;
@@ -121,7 +123,7 @@ export interface ForeignKeyDef<TCols> {
121
123
  export declare function ref<T extends AnyTable>(table: T, columns: readonly (keyof T['_']['columns'] & string)[]): Reference;
122
124
  export interface TableOptions<TName extends string, TCols extends Record<string, AnyColumn>> {
123
125
  /** The table name in the database. Inferred as a literal; the Kysely interface keys come from it. */
124
- readonly tableName: TName;
126
+ readonly name: TName;
125
127
  /**
126
128
  * Columns are declared with a callback that receives the builder set:
127
129
  *
@@ -153,6 +155,14 @@ export interface TableOptions<TName extends string, TCols extends Record<string,
153
155
  readonly unique?: boolean;
154
156
  readonly columns: readonly (keyof TCols & string)[];
155
157
  readonly where?: (c: Refs<TCols>) => Sql;
158
+ /**
159
+ * Build and drop the index with `CONCURRENTLY`, without locking the table
160
+ * against writes. Postgres refuses that inside a transaction, so the
161
+ * generator puts such statements into a separate migration marked
162
+ * `--> no-transaction`, which the runner applies under `transaction: 'each'`.
163
+ * Toggling the flag on an existing index changes nothing in the database.
164
+ */
165
+ readonly concurrently?: boolean;
156
166
  }[];
157
167
  readonly foreignKeys?: readonly ForeignKeyDef<TCols>[];
158
168
  readonly checks?: readonly {
@@ -46,7 +46,7 @@ export function ref(table, columns) {
46
46
  }
47
47
  // ── defineTable ──────────────────────────────────────────────────────────────
48
48
  export function defineTable(options) {
49
- const name = options.tableName;
49
+ const name = options.name;
50
50
  assertIdentifier(name, 'table');
51
51
  const columns = [];
52
52
  const dbName = {};
@@ -93,6 +93,7 @@ export function defineTable(options) {
93
93
  unique: index.unique ?? false,
94
94
  columns: indexColumns,
95
95
  where: index.where !== undefined ? index.where(typedRefs) : undefined,
96
+ concurrently: index.concurrently ?? false,
96
97
  };
97
98
  });
98
99
  // ── primary key ────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kysely-ddl",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "PostgreSQL schema as TypeScript code: SQL migrations from snapshot diffs, Kysely table types and a migration runner",
5
5
  "keywords": [
6
6
  "postgres",
@@ -41,9 +41,9 @@
41
41
  "types": "./dist/index.d.ts",
42
42
  "default": "./dist/index.js"
43
43
  },
44
- "./kysely": {
45
- "types": "./dist/kysely/index.d.ts",
46
- "default": "./dist/kysely/index.js"
44
+ "./migrator": {
45
+ "types": "./dist/migrator/index.d.ts",
46
+ "default": "./dist/migrator/index.js"
47
47
  },
48
48
  "./package.json": "./package.json"
49
49
  },
@@ -1,12 +0,0 @@
1
- /**
2
- * Everything tied to Kysely: row types, jsonb values, the migration runner on
3
- * top of a ready `Kysely`, and the `.sql` file provider for the built-in `Migrator`.
4
- * Entry point `kysely-ddl/kysely`.
5
- */
6
- export type { inferKyselyDatabase, inferKyselyTable } from './infer.ts';
7
- export { jsonb, jsonbArray } from './json.ts';
8
- export type { Jsonb } from './json.ts';
9
- export { sqlFileMigrationProvider } from './provider.ts';
10
- export type { SqlMigrationProviderOptions } from './provider.ts';
11
- export { createMigrator, DEFAULT_JOURNAL_TABLE, migrateToLatest, MIGRATION_LOCK_ID, MigrationError, } from './runner.ts';
12
- export type { MigrationRunResult, MigrationStatus, MigratorOptions, SqlMigrator, TransactionMode } from './runner.ts';
@@ -1,3 +0,0 @@
1
- export { jsonb, jsonbArray } from './json.js';
2
- export { sqlFileMigrationProvider } from './provider.js';
3
- export { createMigrator, DEFAULT_JOURNAL_TABLE, migrateToLatest, MIGRATION_LOCK_ID, MigrationError, } from './runner.js';
File without changes