kysely-ddl 0.1.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 +33 -0
- package/LICENSE +21 -0
- package/README.md +437 -0
- package/dist/generator/diff.d.ts +68 -0
- package/dist/generator/diff.js +162 -0
- package/dist/generator/generate.d.ts +19 -0
- package/dist/generator/generate.js +8 -0
- package/dist/generator/render.d.ts +6 -0
- package/dist/generator/render.js +137 -0
- package/dist/generator/snapshot.d.ts +48 -0
- package/dist/generator/snapshot.js +54 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +26 -0
- package/dist/kysely/index.d.ts +12 -0
- package/dist/kysely/index.js +3 -0
- package/dist/kysely/infer.d.ts +61 -0
- package/dist/kysely/infer.js +1 -0
- package/dist/kysely/json.d.ts +43 -0
- package/dist/kysely/json.js +54 -0
- package/dist/kysely/provider.d.ts +20 -0
- package/dist/kysely/provider.js +63 -0
- package/dist/kysely/runner.d.ts +76 -0
- package/dist/kysely/runner.js +169 -0
- package/dist/migrator/store.d.ts +17 -0
- package/dist/migrator/store.js +96 -0
- package/dist/table/casing.d.ts +26 -0
- package/dist/table/casing.js +43 -0
- package/dist/table/columns.d.ts +195 -0
- package/dist/table/columns.js +114 -0
- package/dist/table/define.d.ts +165 -0
- package/dist/table/define.js +198 -0
- package/dist/table/identifier.d.ts +25 -0
- package/dist/table/identifier.js +58 -0
- package/dist/table/sql.d.ts +43 -0
- package/dist/table/sql.js +115 -0
- package/package.json +79 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## [0.1.0] — 2026-09-10
|
|
6
|
+
|
|
7
|
+
First release.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `defineTable`, `ref`, `sql`, `inArray`: PostgreSQL tables as code. Column
|
|
12
|
+
names are kept as literals in the types, index and constraint names are
|
|
13
|
+
generated automatically and fit into 63 bytes. snake_case of property names
|
|
14
|
+
follows the `CamelCasePlugin` rule (`appleID` -> `apple_id`); `toSnakeCase`,
|
|
15
|
+
`toCamelCase`, `SnakeCase`, `CamelCase` are exported.
|
|
16
|
+
- `generateMigration`: diff of the schema against the snapshot of the latest
|
|
17
|
+
migration, returning the SQL, individual statements and the new snapshot.
|
|
18
|
+
- `writeMigration`, `readLatestSnapshot`, `listMigrations`: migrations on disk as
|
|
19
|
+
flat `.sql` files with the `--> statement-breakpoint` separator and a single
|
|
20
|
+
`snapshot.json`; file names are monotonic.
|
|
21
|
+
- `kysely-ddl/kysely`:
|
|
22
|
+
- `inferKyselyTable`, `inferKyselyDatabase`: Kysely table types keyed by the
|
|
23
|
+
column names in the database; with `true` as the second parameter the keys
|
|
24
|
+
are camelCase for `CamelCasePlugin`;
|
|
25
|
+
- `jsonb`, `jsonbArray` and the `Jsonb<T>` type: jsonb values with an explicit
|
|
26
|
+
`::text::jsonb` cast that behaves the same on `pg` and `Bun.SQL`; jsonb
|
|
27
|
+
columns require them in the write types; the helper rejects `null` and
|
|
28
|
+
`undefined`, SQL NULL is passed as is;
|
|
29
|
+
- `createMigrator` / `migrateToLatest`: running `.sql` migrations through a
|
|
30
|
+
ready `Kysely`: status, three transaction modes, `allowUnordered`, an error
|
|
31
|
+
naming the failing statement; the journal and the lock are compatible with
|
|
32
|
+
Kysely's `Migrator`;
|
|
33
|
+
- `sqlFileMigrationProvider`: a `.sql` file provider for Kysely's `Migrator`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hehmonke
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# kysely-ddl
|
|
2
|
+
|
|
3
|
+
[](https://github.com/hehmonke/kysely-ddl/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/kysely-ddl)
|
|
5
|
+
|
|
6
|
+
PostgreSQL schema as TypeScript code, SQL migrations generated from snapshot
|
|
7
|
+
diffs, Kysely table types, and a migration runner on top of your `Kysely`.
|
|
8
|
+
|
|
9
|
+
- **PostgreSQL only.** Tested with `pg` (Node and Bun) and with `Bun.SQL` through a Kysely dialect.
|
|
10
|
+
- **Column names stay literal in the types**, so the Kysely interface is derived
|
|
11
|
+
from the column names in the database, without casing plugins or conventions.
|
|
12
|
+
- **Diffs are computed against a snapshot**, not a live database: generation is deterministic.
|
|
13
|
+
- **Migrations are plain `.sql` files**, run either by the built-in runner or by
|
|
14
|
+
Kysely's `Migrator`: the journal is shared.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun add kysely-ddl kysely
|
|
20
|
+
# or
|
|
21
|
+
npm install kysely-ddl kysely
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`kysely >= 0.28` is a peer dependency. The driver is yours: `pg` under Node or
|
|
25
|
+
Bun, or any PostgreSQL dialect for Kysely. Runtime: Node >= 20 or Bun >= 1.2.
|
|
26
|
+
|
|
27
|
+
## Entry points
|
|
28
|
+
|
|
29
|
+
| import | contents |
|
|
30
|
+
|---|---|
|
|
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` |
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
`schema.ts`, the tables:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { defineTable, ref, sql } from 'kysely-ddl';
|
|
40
|
+
|
|
41
|
+
export const userTable = defineTable({
|
|
42
|
+
tableName: 'user',
|
|
43
|
+
// builders arrive as an argument; they are not exported one by one
|
|
44
|
+
columns: t => ({
|
|
45
|
+
// no explicit column name -> derived from the property: created_at, apple_id, ...
|
|
46
|
+
id: t.uuid().notNull().default(sql`gen_random_uuid()`),
|
|
47
|
+
createdAt: t.timestamp({ withTimezone: true }).defaultNow().notNull(),
|
|
48
|
+
nickname: t.varchar().notNull(),
|
|
49
|
+
email: t.varchar(),
|
|
50
|
+
status: t.enum(['active', 'banned']).notNull(),
|
|
51
|
+
}),
|
|
52
|
+
// no explicit names -> user_pk, user_nickname_idx, user_nickname_check
|
|
53
|
+
primaryKey: { columns: ['id'] },
|
|
54
|
+
indexes: [{ unique: true, columns: ['nickname'] }],
|
|
55
|
+
checks: [{ expression: c => sql`char_length(${c.nickname}) >= 2` }],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const sessionTable = defineTable({
|
|
59
|
+
tableName: 'session',
|
|
60
|
+
columns: t => ({
|
|
61
|
+
id: t.uuid().notNull().default(sql`gen_random_uuid()`),
|
|
62
|
+
userId: t.uuid().notNull(),
|
|
63
|
+
}),
|
|
64
|
+
primaryKey: { columns: ['id'] },
|
|
65
|
+
foreignKeys: [{ columns: ['userId'], references: ref(userTable, ['id']), onDelete: 'cascade' }],
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`generate.ts`, a new migration diffed against the latest snapshot:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { generateMigration, readLatestSnapshot, writeMigration } from 'kysely-ddl';
|
|
73
|
+
import * as schema from './schema';
|
|
74
|
+
|
|
75
|
+
const dir = './migrations';
|
|
76
|
+
const result = generateMigration([schema.userTable, schema.sessionTable], readLatestSnapshot(dir));
|
|
77
|
+
|
|
78
|
+
if (result.statements.length === 0) {
|
|
79
|
+
console.log('no changes');
|
|
80
|
+
} else {
|
|
81
|
+
console.log(writeMigration(dir, process.argv[2] ?? 'migration', result)); // 20260910123045_migration
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`migrate.ts`, applying migrations:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { migrateToLatest } from 'kysely-ddl/kysely';
|
|
89
|
+
import { Kysely, PostgresDialect } from 'kysely';
|
|
90
|
+
import pg from 'pg';
|
|
91
|
+
|
|
92
|
+
const db = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: process.env.DATABASE_URL }) }) });
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const { applied } = await migrateToLatest({ db, migrationsDir: './migrations' });
|
|
96
|
+
console.log(applied.length === 0 ? 'nothing to apply' : `applied: ${applied.join(', ')}`);
|
|
97
|
+
} finally {
|
|
98
|
+
await db.destroy();
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Types for queries:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import type { inferKyselyDatabase } from 'kysely-ddl/kysely';
|
|
106
|
+
import * as schema from './schema';
|
|
107
|
+
|
|
108
|
+
type DB = inferKyselyDatabase<typeof schema>;
|
|
109
|
+
const db = new Kysely<DB>({ dialect });
|
|
110
|
+
|
|
111
|
+
const rows = await db.selectFrom('user').select(['nickname', 'created_at']).execute();
|
|
112
|
+
// ^? { nickname: string; created_at: Date }[]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Describing tables
|
|
116
|
+
|
|
117
|
+
A hybrid: columns as chains, everything else as a declarative block.
|
|
118
|
+
|
|
119
|
+
| | |
|
|
120
|
+
|---|---|
|
|
121
|
+
| 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
|
+
| 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 |
|
|
124
|
+
| expressions | `` sql`...` `` with column and literal interpolation, `inArray(c.status, [...])` |
|
|
125
|
+
|
|
126
|
+
When a column name is not given, it is derived from the property name with the
|
|
127
|
+
same snake_case rule as Kysely's `CamelCasePlugin`: `createdAt` -> `created_at`,
|
|
128
|
+
`appleID` -> `apple_id`. A name can also be set explicitly, `t.uuid('id')`, and
|
|
129
|
+
then it is what ends up in the type. The builder takes the name first and the
|
|
130
|
+
config second: `t.varchar('title', { length: 200 })`.
|
|
131
|
+
|
|
132
|
+
TypeScript value types match what the driver returns, without modes like
|
|
133
|
+
`bigint({ mode })`: `bigint` and `numeric` are strings (no precision loss),
|
|
134
|
+
`timestamp` is `Date`, `jsonb` is `unknown`. For another type use `$type<T>()`
|
|
135
|
+
and convert on your side.
|
|
136
|
+
|
|
137
|
+
### `enum([...])`
|
|
138
|
+
|
|
139
|
+
The column stays `varchar`, the allowed values go into a check constraint named
|
|
140
|
+
`{table}_{column}_check`, and in TypeScript it is a union of string literals:
|
|
141
|
+
|
|
142
|
+
```sql
|
|
143
|
+
"status" varchar NOT NULL,
|
|
144
|
+
CONSTRAINT "user_status_check" CHECK ("status" in ('active', 'banned'))
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
There is deliberately no native `create type ... as enum`: a new value cannot be
|
|
148
|
+
used in the same transaction that adds it, which breaks exactly when migrations
|
|
149
|
+
run in a single transaction. Changing the value list of `varchar` + check is a
|
|
150
|
+
regular `ALTER TABLE ... DROP CONSTRAINT ..., ADD CONSTRAINT ...`, and the diff
|
|
151
|
+
catches it. `enum().array()` is not supported yet.
|
|
152
|
+
|
|
153
|
+
### Foreign keys
|
|
154
|
+
|
|
155
|
+
The target is given through `ref(table, [columns])`, so TypeScript checks the
|
|
156
|
+
column names on both sides, and single-column and composite keys look the same:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
foreignKeys: [
|
|
160
|
+
{ columns: ['ticketId', 'locale'], references: ref(ticketTranslationTable, ['ticketId', 'locale']), onDelete: 'cascade' },
|
|
161
|
+
],
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Foreign keys are always emitted as separate `ALTER TABLE` statements after all
|
|
165
|
+
`CREATE TABLE`s, so declaration order and circular references do not matter.
|
|
166
|
+
|
|
167
|
+
### Names: auto-generation and the postgres limit
|
|
168
|
+
|
|
169
|
+
| object | pattern | example |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| primary key | `{table}_pk` | `user_pk` |
|
|
172
|
+
| unique | `{table}_{columns}_uq` | `ticket_number_uq` |
|
|
173
|
+
| foreign key | `{table}_{columns}_fk` | `session_user_id_fk` |
|
|
174
|
+
| check | `{table}_{expression columns}_check` | `ticket_status_check` |
|
|
175
|
+
| index | `{table}_{columns}_idx` | `user_resource_transaction_user_id_resource_idx` |
|
|
176
|
+
|
|
177
|
+
Postgres identifiers are limited to **63 bytes**, and anything longer is silently
|
|
178
|
+
truncated. The snapshot would remember the long name, the database would hold
|
|
179
|
+
the short one, and the diff would forever try to create the "missing" index.
|
|
180
|
+
So auto-names are shortened deterministically with a hash suffix derived from
|
|
181
|
+
the full name, while an explicit name over the limit is a `defineTable` error.
|
|
182
|
+
Collisions are caught too: two objects on the same columns, an index named like
|
|
183
|
+
a unique constraint, a check with neither columns nor a name, two properties
|
|
184
|
+
mapping to the same database name.
|
|
185
|
+
|
|
186
|
+
### What is missing
|
|
187
|
+
|
|
188
|
+
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
|
+
|
|
193
|
+
## Types for Kysely
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import type { inferKyselyDatabase, inferKyselyTable } from 'kysely-ddl/kysely';
|
|
197
|
+
import type { Insertable, Selectable } from 'kysely';
|
|
198
|
+
|
|
199
|
+
type UserTable = inferKyselyTable<typeof userTable>;
|
|
200
|
+
type DB = inferKyselyDatabase<typeof schema>; // anything that is not a table is filtered out
|
|
201
|
+
|
|
202
|
+
type UserRow = Selectable<UserTable>;
|
|
203
|
+
type NewUser = Insertable<UserTable>;
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Keys are **the column names in the database**: they are known at the type level
|
|
207
|
+
because `defineTable` keeps them as literals. With Kysely's `CamelCasePlugin`
|
|
208
|
+
pass `true` as the second parameter and the column and table keys become
|
|
209
|
+
camelCase, see below. Write rules:
|
|
210
|
+
|
|
211
|
+
| column | `Insertable` |
|
|
212
|
+
|---|---|
|
|
213
|
+
| `notNull()` without a default | required field |
|
|
214
|
+
| has `default()` / `defaultNow()` | optional |
|
|
215
|
+
| nullable | optional, accepts `null` |
|
|
216
|
+
| `generatedAlwaysAsIdentity()` | no key at all |
|
|
217
|
+
| `jsonb()` | branded `Jsonb<T>`: values only through the `jsonb()` helper, `jsonbArray()` for `jsonb[]` |
|
|
218
|
+
|
|
219
|
+
### CamelCasePlugin
|
|
220
|
+
|
|
221
|
+
`CamelCasePlugin` rewrites camelCase in code to snake_case in SQL and back in
|
|
222
|
+
result row keys. To have the types reflect that, both `infer` types take `true`
|
|
223
|
+
as the second parameter:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { CamelCasePlugin, Kysely } from 'kysely';
|
|
227
|
+
|
|
228
|
+
type DB = inferKyselyDatabase<typeof schema, true>;
|
|
229
|
+
const db = new Kysely<DB>({ dialect, plugins: [new CamelCasePlugin()] });
|
|
230
|
+
|
|
231
|
+
await db.selectFrom('auditLog').select(['userId', 'happenedAt']).execute();
|
|
232
|
+
// select "user_id", "happened_at" from "audit_log"
|
|
233
|
+
// ^? { userId: string; happenedAt: Date }[]
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Keys are computed from the database names with the same algorithm as the plugin
|
|
237
|
+
with default options (`created_at` -> `createdAt`), and `toSnakeCase` in
|
|
238
|
+
`defineTable` follows the same rule: consecutive capitals are not split,
|
|
239
|
+
`appleID` gives `apple_id`, `parseJSONValue` gives `parse_jsonvalue`. So names
|
|
240
|
+
derived from properties round-trip without loss, while explicit names deserve a
|
|
241
|
+
check: the plugin does not restore an underscore before a digit (`field_2`) or a
|
|
242
|
+
leading underscore. Plugin options (`upperCase`, `underscoreBeforeDigits`,
|
|
243
|
+
`underscoreBetweenUppercaseLetters`) are not supported by the types. The
|
|
244
|
+
conversions themselves are exported too: the types `SnakeCase`, `CamelCase` and
|
|
245
|
+
the functions `toSnakeCase`, `toCamelCase`.
|
|
246
|
+
|
|
247
|
+
### jsonb: values through `jsonb()` and `jsonbArray()`
|
|
248
|
+
|
|
249
|
+
Drivers disagree on how a jsonb parameter should be passed, and there is no raw
|
|
250
|
+
representation both accept: `pg` expects a JSON string and turns a JS array into
|
|
251
|
+
a postgres array literal (`invalid input syntax for type json`), while `Bun.SQL`
|
|
252
|
+
expects a raw value and encodes a ready JSON string a second time. The helpers
|
|
253
|
+
build an expression with an explicit `$1::text::jsonb` cast that both understand,
|
|
254
|
+
and the `Jsonb<T>` brand in the write types keeps raw objects and strings from
|
|
255
|
+
slipping past them:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import { jsonb, jsonbArray } from 'kysely-ddl/kysely';
|
|
259
|
+
|
|
260
|
+
await db.insertInto('user').values({ settings: jsonb({ theme: 'dark', tags: ['a'] }) }).execute();
|
|
261
|
+
await db.updateTable('user').set({ settings: jsonb({ theme: 'light' }) }).where('id', '=', id).execute();
|
|
262
|
+
await db.selectFrom('user').selectAll().where('settings', '@>', jsonb({ theme: 'dark' })).execute();
|
|
263
|
+
|
|
264
|
+
await db.insertInto('audit').values({ changes: jsonbArray([{ field: 'a' }, { field: 'b' }]) }).execute(); // jsonb[]
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`jsonb()` accepts an object, an array, a string, a number or a boolean. It does
|
|
268
|
+
not accept `null` or `undefined`: `jsonb(null)` would write JSON null rather than
|
|
269
|
+
SQL NULL, which passes a `NOT NULL` column, is invisible to `IS NULL` and
|
|
270
|
+
`COALESCE`, and is indistinguishable from SQL NULL when read. SQL NULL for a
|
|
271
|
+
nullable column is passed as a plain `null` without the helper. Inside objects
|
|
272
|
+
and arrays `null` stays regular JSON.
|
|
273
|
+
|
|
274
|
+
The type inside `Jsonb<T>` comes from the column's `$type<T>()`, so
|
|
275
|
+
`jsonb({ theme: 'blue' })` does not compile for `$type<{ theme: 'light' | 'dark' }>()`.
|
|
276
|
+
On read, jsonb arrives as a parsed value with both drivers.
|
|
277
|
+
|
|
278
|
+
## Generating migrations
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
const result = generateMigration(tables, previousSnapshot);
|
|
282
|
+
// result.sql — the migration text; '' when there are no changes
|
|
283
|
+
// result.statements — the same SQL as individual statements
|
|
284
|
+
// result.snapshot — the state after the migration; writeMigration stores it
|
|
285
|
+
// result.changes — the parsed changes, handy for a "what changed" summary
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Layout on disk
|
|
289
|
+
|
|
290
|
+
```
|
|
291
|
+
migrations/
|
|
292
|
+
20260910120000_init.sql
|
|
293
|
+
20260910123000_add_tickets.sql
|
|
294
|
+
snapshot.json
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`writeMigration(dir, name, result)` writes the `.sql` file and updates
|
|
298
|
+
`snapshot.json`, `readLatestSnapshot(dir)` reads it for the next diff, and
|
|
299
|
+
`listMigrations(dir)` returns names in the order the runner applies them (by
|
|
300
|
+
character codes, like Kysely). There is no separate journal on disk: the table in
|
|
301
|
+
the database knows what has been applied.
|
|
302
|
+
|
|
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
|
+
|
|
307
|
+
Names are monotonic: if the previous migration was created in the same second,
|
|
308
|
+
the timestamp is bumped forward, otherwise the suffix would decide the order.
|
|
309
|
+
|
|
310
|
+
There is a single snapshot describing the state after the latest migration. The
|
|
311
|
+
price is merge conflicts: two branches that each add a migration diverge in
|
|
312
|
+
`snapshot.json`, and it has to be regenerated after the merge.
|
|
313
|
+
|
|
314
|
+
### Statement order
|
|
315
|
+
|
|
316
|
+
Fixed, so it does not depend on the order tables are declared in:
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
DROP INDEX -> DROP CONSTRAINT -> CREATE TABLE -> ADD COLUMN -> ALTER COLUMN
|
|
320
|
+
-> ADD / REPLACE CONSTRAINT -> CREATE INDEX -> ADD FOREIGN KEY
|
|
321
|
+
-> DROP COLUMN -> DROP TABLE
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`PRIMARY KEY`, `UNIQUE` and `CHECK` of a new table go inside `CREATE TABLE`.
|
|
325
|
+
Replacing a constraint is a single `ALTER TABLE ... DROP CONSTRAINT ..., ADD CONSTRAINT ...`.
|
|
326
|
+
`DROP COLUMN` and `DROP TABLE` are printed as is, so review a migration before
|
|
327
|
+
committing it.
|
|
328
|
+
|
|
329
|
+
### Why diff against a snapshot, not the database
|
|
330
|
+
|
|
331
|
+
Postgres normalizes expressions on save: `x in (...)` becomes
|
|
332
|
+
`x = ANY (ARRAY[...])`, `trim(x)` becomes `TRIM(BOTH FROM x)`. A tool that reads
|
|
333
|
+
the schema back from the database gets a perpetual diff on the same checks. The
|
|
334
|
+
snapshot compares two representations produced by the same code. The price is
|
|
335
|
+
that database drift (someone edited the schema by hand) is invisible to it.
|
|
336
|
+
|
|
337
|
+
## Running migrations
|
|
338
|
+
|
|
339
|
+
The runner takes a ready `Kysely` with any PostgreSQL dialect and works through a
|
|
340
|
+
single connection (`db.connection()`): the lock and the transaction live on it.
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
import { createMigrator, migrateToLatest } from 'kysely-ddl/kysely';
|
|
344
|
+
|
|
345
|
+
const migrator = createMigrator({
|
|
346
|
+
db, // a Kysely instance, not a Transaction
|
|
347
|
+
migrationsDir: './migrations',
|
|
348
|
+
journalTable: 'kysely_migration', // the default, same as Kysely
|
|
349
|
+
transaction: 'all', // 'all' | 'each' | 'none'
|
|
350
|
+
allowUnordered: false,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await migrator.status(); // { applied: string[], pending: string[] }
|
|
354
|
+
await migrator.toLatest(); // { applied: string[] }, what this run applied
|
|
355
|
+
|
|
356
|
+
await migrateToLatest({ db, migrationsDir: './migrations' }); // the same in one line
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
| option | effect |
|
|
360
|
+
|---|---|
|
|
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 |
|
|
364
|
+
| `allowUnordered` | apply migrations that sort before already applied ones (branch merges). An error by default |
|
|
365
|
+
|
|
366
|
+
A failing statement throws `MigrationError` with `migration`, `statement`,
|
|
367
|
+
`applied` (what this run applied before the failure) and the driver's `cause`.
|
|
368
|
+
A migration present in the journal but missing on disk is an error: restore the
|
|
369
|
+
file from history or delete the row by hand.
|
|
370
|
+
|
|
371
|
+
Concurrent runs are safe: the runner takes a session-level advisory lock with the
|
|
372
|
+
same key as Kysely's `Migrator` and re-reads the journal after waiting.
|
|
373
|
+
|
|
374
|
+
### Compatibility with Kysely's `Migrator`
|
|
375
|
+
|
|
376
|
+
The journal is the same `kysely_migration` table with the same columns, and the
|
|
377
|
+
lock is the same `pg_advisory_lock`. So `.sql` migrations can also be run by the
|
|
378
|
+
built-in `Migrator`, and the two runners can alternate on one database:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
import { sqlFileMigrationProvider } from 'kysely-ddl/kysely';
|
|
382
|
+
import { Migrator } from 'kysely/migration';
|
|
383
|
+
|
|
384
|
+
const migrator = new Migrator({ db, provider: sqlFileMigrationProvider('./migrations') });
|
|
385
|
+
const { error, results } = await migrator.migrateToLatest();
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
There are no rollbacks: the generator only writes forward. Without `down`,
|
|
389
|
+
Kysely skips a migration on `migrateDown` (`NotExecuted`) and leaves it in the
|
|
390
|
+
journal, so by default the provider supplies a `down` that fails with a clear
|
|
391
|
+
error: a rollback must not look successful.
|
|
392
|
+
`sqlFileMigrationProvider(dir, { onDown: 'skip' })` restores Kysely's behaviour.
|
|
393
|
+
|
|
394
|
+
### Under Bun
|
|
395
|
+
|
|
396
|
+
`pg` works under Bun as is. The built-in `Bun.SQL` is connected through any
|
|
397
|
+
PostgreSQL dialect for Kysely; the package does not ship one. A test
|
|
398
|
+
implementation lives in `test/helpers/bun-dialect.ts`, and the whole suite runs
|
|
399
|
+
through it alongside `pg`.
|
|
400
|
+
|
|
401
|
+
### Driver differences
|
|
402
|
+
|
|
403
|
+
What the checks showed on `pg` 8.23 and `Bun.SQL` 1.4 (parameters via Kysely):
|
|
404
|
+
|
|
405
|
+
| value -> column | `pg` | `Bun.SQL` | what to do |
|
|
406
|
+
|---|---|---|---|
|
|
407
|
+
| object -> `jsonb` | ok | ok | `jsonb(obj)`, works everywhere |
|
|
408
|
+
| JS array -> `jsonb` | error: postgres array literal | ok | `jsonb([...])` |
|
|
409
|
+
| JSON string -> `jsonb` | ok | encoded twice, into a jsonb string | `jsonb(value)` |
|
|
410
|
+
| number, boolean -> `jsonb` | ok | parameter type error | `jsonb(42)` |
|
|
411
|
+
| array of objects -> `jsonb[]` | ok | error: arrays are not encoded | `jsonbArray([...])` |
|
|
412
|
+
| array of strings -> `varchar[]` | ok, including commas, quotes, `null` | error: elements joined with commas | a Bun dialect must encode arrays into literals itself, as the test one does |
|
|
413
|
+
| `numeric` = 0 on read | `'0.00'` | `'0'` | compare as numbers |
|
|
414
|
+
|
|
415
|
+
Reading jsonb, JSON arrays, `jsonb[]` and `varchar[]` yields parsed JS values
|
|
416
|
+
with both drivers.
|
|
417
|
+
|
|
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.
|
|
430
|
+
|
|
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.
|
|
434
|
+
|
|
435
|
+
## License
|
|
436
|
+
|
|
437
|
+
MIT.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff of two snapshots -> an ordered list of changes.
|
|
3
|
+
*
|
|
4
|
+
* Order matters more than completeness: first drop what gets in the way (indexes,
|
|
5
|
+
* constraints), then create, then drop columns and tables. Foreign keys go last as
|
|
6
|
+
* separate ALTERs, otherwise the CREATE TABLE order would start to matter.
|
|
7
|
+
*/
|
|
8
|
+
import type { ColumnSnapshot, Snapshot, TableSnapshot } from './snapshot.ts';
|
|
9
|
+
type Index = TableSnapshot['indexes'][number];
|
|
10
|
+
type ForeignKey = TableSnapshot['foreignKeys'][number];
|
|
11
|
+
type Unique = TableSnapshot['uniques'][number];
|
|
12
|
+
type Check = TableSnapshot['checks'][number];
|
|
13
|
+
type PrimaryKey = NonNullable<TableSnapshot['primaryKey']>;
|
|
14
|
+
export type Constraint = {
|
|
15
|
+
readonly type: 'primaryKey';
|
|
16
|
+
readonly def: PrimaryKey;
|
|
17
|
+
} | {
|
|
18
|
+
readonly type: 'unique';
|
|
19
|
+
readonly def: Unique;
|
|
20
|
+
} | {
|
|
21
|
+
readonly type: 'check';
|
|
22
|
+
readonly def: Check;
|
|
23
|
+
} | {
|
|
24
|
+
readonly type: 'foreignKey';
|
|
25
|
+
readonly def: ForeignKey;
|
|
26
|
+
};
|
|
27
|
+
export type Change = {
|
|
28
|
+
readonly kind: 'createTable';
|
|
29
|
+
readonly table: TableSnapshot;
|
|
30
|
+
} | {
|
|
31
|
+
readonly kind: 'dropTable';
|
|
32
|
+
readonly table: string;
|
|
33
|
+
} | {
|
|
34
|
+
readonly kind: 'addColumn';
|
|
35
|
+
readonly table: string;
|
|
36
|
+
readonly column: ColumnSnapshot;
|
|
37
|
+
} | {
|
|
38
|
+
readonly kind: 'dropColumn';
|
|
39
|
+
readonly table: string;
|
|
40
|
+
readonly column: string;
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: 'alterColumn';
|
|
43
|
+
readonly table: string;
|
|
44
|
+
readonly from: ColumnSnapshot;
|
|
45
|
+
readonly to: ColumnSnapshot;
|
|
46
|
+
} | {
|
|
47
|
+
readonly kind: 'addConstraint';
|
|
48
|
+
readonly table: string;
|
|
49
|
+
readonly constraint: Constraint;
|
|
50
|
+
} | {
|
|
51
|
+
readonly kind: 'dropConstraint';
|
|
52
|
+
readonly table: string;
|
|
53
|
+
readonly name: string;
|
|
54
|
+
} | {
|
|
55
|
+
/** DROP + ADD in one ALTER: postgres supports it, and it is atomic. */
|
|
56
|
+
readonly kind: 'replaceConstraint';
|
|
57
|
+
readonly table: string;
|
|
58
|
+
readonly constraint: Constraint;
|
|
59
|
+
} | {
|
|
60
|
+
readonly kind: 'createIndex';
|
|
61
|
+
readonly table: string;
|
|
62
|
+
readonly index: Index;
|
|
63
|
+
} | {
|
|
64
|
+
readonly kind: 'dropIndex';
|
|
65
|
+
readonly index: string;
|
|
66
|
+
};
|
|
67
|
+
export declare function diffSnapshots(prev: Snapshot, next: Snapshot): Change[];
|
|
68
|
+
export {};
|