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 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
+ [![CI](https://github.com/hehmonke/kysely-ddl/actions/workflows/ci.yml/badge.svg)](https://github.com/hehmonke/kysely-ddl/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/kysely-ddl)](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 {};