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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Values for jsonb columns.
|
|
3
|
+
*
|
|
4
|
+
* Drivers disagree on how a jsonb parameter should be passed. `pg` expects JSON
|
|
5
|
+
* text: it serializes objects itself, but turns a JS array into a postgres array
|
|
6
|
+
* literal (`{1,2,3}`) and gets `invalid input syntax for type json`. `Bun.SQL`
|
|
7
|
+
* looks at the parameter type reported by the server and for jsonb serializes the
|
|
8
|
+
* JS value to JSON itself, so a ready JSON string gets encoded a second time and
|
|
9
|
+
* becomes a jsonb string, while a number or boolean is rejected by postgres.
|
|
10
|
+
*
|
|
11
|
+
* The one representation both understand is a text parameter with an explicit
|
|
12
|
+
* `$1::text::jsonb` cast. That is what `jsonb()` and `jsonbArray()` build, and the
|
|
13
|
+
* `Jsonb<T>` brand in the write types (`inferKyselyTable`) keeps a raw object or
|
|
14
|
+
* string from being passed around the helper.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* await db.insertInto('user').values({ settings: jsonb({ theme: 'dark' }) }).execute();
|
|
18
|
+
* await db.updateTable('user').set({ settings: jsonb({ theme: 'light' }) }).execute();
|
|
19
|
+
* await db.selectFrom('user').where('settings', '@>', jsonb({ theme: 'dark' })).execute();
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import { sql } from 'kysely';
|
|
23
|
+
/**
|
|
24
|
+
* A value for a jsonb column: an object, an array, a string, a number or a boolean.
|
|
25
|
+
*
|
|
26
|
+
* `null` and `undefined` are rejected on purpose. `jsonb(null)` would write JSON
|
|
27
|
+
* null rather than SQL NULL: it passes a `NOT NULL` column, is invisible to
|
|
28
|
+
* `IS NULL` and `COALESCE`, and is indistinguishable from SQL NULL when read.
|
|
29
|
+
* SQL NULL for a nullable column is passed as a plain `null` without the helper.
|
|
30
|
+
* A top-level JSON null is needed so rarely that raw `sql` remains for it.
|
|
31
|
+
*/
|
|
32
|
+
export function jsonb(value) {
|
|
33
|
+
return sql `${encode(value)}::text::jsonb`;
|
|
34
|
+
}
|
|
35
|
+
/** A value for a `jsonb[]` column: each element is a separate JSON value, `null` inside is JSON null. */
|
|
36
|
+
export function jsonbArray(values) {
|
|
37
|
+
return sql `${pgArrayLiteral(values.map(encode))}::text::jsonb[]`;
|
|
38
|
+
}
|
|
39
|
+
function encode(value) {
|
|
40
|
+
// lib.d.ts types JSON.stringify as returning string, but for undefined, functions and symbols it returns undefined
|
|
41
|
+
const text = JSON.stringify(value);
|
|
42
|
+
if (text === undefined) {
|
|
43
|
+
throw new Error(`jsonb(): a value of type ${typeof value} cannot be serialized to JSON`);
|
|
44
|
+
}
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A postgres array literal from strings: `{"a","b,c","d\"e",NULL}`. Every element
|
|
49
|
+
* is quoted, so commas, braces and spaces inside are safe.
|
|
50
|
+
*/
|
|
51
|
+
export function pgArrayLiteral(values) {
|
|
52
|
+
const elements = values.map(value => value === null ? 'NULL' : `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`);
|
|
53
|
+
return `{${elements.join(',')}}`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MigrationProvider } from 'kysely/migration';
|
|
2
|
+
export interface SqlMigrationProviderOptions {
|
|
3
|
+
/**
|
|
4
|
+
* What to do on `migrateDown` / `migrateTo`.
|
|
5
|
+
*
|
|
6
|
+
* The generator only writes forward, and migrations have no `down`. Without
|
|
7
|
+
* `down`, Kysely skips the migration (`NotExecuted`) and leaves it in the
|
|
8
|
+
* journal: `migrateDown` quietly does nothing. By default the provider supplies
|
|
9
|
+
* a `down` that fails with a clear error: a rollback must not look successful.
|
|
10
|
+
* `skip` restores Kysely's behaviour. A paired rollback, when needed, is
|
|
11
|
+
* written by hand and wired through your own provider.
|
|
12
|
+
*/
|
|
13
|
+
readonly onDown?: 'throw' | 'skip';
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A provider that hands `.sql` files from a folder to the `Migrator`.
|
|
17
|
+
* The migration name is the file name without the extension, the order is
|
|
18
|
+
* alphabetical (hence the timestamp prefix).
|
|
19
|
+
*/
|
|
20
|
+
export declare function sqlFileMigrationProvider(dir: string, options?: SqlMigrationProviderOptions): MigrationProvider;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running migrations with Kysely's built-in `Migrator`.
|
|
3
|
+
*
|
|
4
|
+
* No runner of its own is needed here: Kysely already handles the lock
|
|
5
|
+
* (`kysely_migration_lock`), the record of what has been applied
|
|
6
|
+
* (`kysely_migration`), ordering and the transaction. The one thing it lacks is
|
|
7
|
+
* loading migrations from anything but modules with `up`/`down` functions
|
|
8
|
+
* (`FileMigrationProvider`), while we generate plain SQL. That is what
|
|
9
|
+
* `sqlFileMigrationProvider` covers.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { Migrator } from 'kysely/migration';
|
|
13
|
+
*
|
|
14
|
+
* const migrator = new Migrator({
|
|
15
|
+
* db,
|
|
16
|
+
* provider: sqlFileMigrationProvider('./migrations'),
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* const { error, results } = await migrator.migrateToLatest();
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* On transactions: on postgres Kysely runs the WHOLE run in one transaction by
|
|
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).
|
|
28
|
+
*/
|
|
29
|
+
import { sql } from 'kysely';
|
|
30
|
+
import { listMigrations, readStatements } from '../migrator/store.js';
|
|
31
|
+
/**
|
|
32
|
+
* A provider that hands `.sql` files from a folder to the `Migrator`.
|
|
33
|
+
* The migration name is the file name without the extension, the order is
|
|
34
|
+
* alphabetical (hence the timestamp prefix).
|
|
35
|
+
*/
|
|
36
|
+
export function sqlFileMigrationProvider(dir, options = {}) {
|
|
37
|
+
const onDown = options.onDown ?? 'throw';
|
|
38
|
+
return {
|
|
39
|
+
async getMigrations() {
|
|
40
|
+
const migrations = {};
|
|
41
|
+
for (const name of listMigrations(dir)) {
|
|
42
|
+
migrations[name] = {
|
|
43
|
+
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)) {
|
|
47
|
+
await sql.raw(statement).execute(db);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
...(onDown === 'throw'
|
|
51
|
+
? {
|
|
52
|
+
down() {
|
|
53
|
+
return Promise.reject(new Error(`${name}: no rollback available. The generator only writes forward migrations: ` +
|
|
54
|
+
'write the reverse migration yourself or pass { onDown: "skip" }.'));
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return migrations;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A runner for `.sql` migrations on top of a ready `Kysely`.
|
|
3
|
+
*
|
|
4
|
+
* Kysely is the only abstraction over the driver: under `pg` that is
|
|
5
|
+
* `PostgresDialect`, under Bun any postgres dialect over `Bun.SQL` (the repository
|
|
6
|
+
* has a test one, `test/helpers/bun-dialect.ts`). The runner needs one connection
|
|
7
|
+
* for the whole run, the advisory lock and the transaction live on it, so it works
|
|
8
|
+
* through `db.connection()`.
|
|
9
|
+
*
|
|
10
|
+
* Compatible with Kysely's `Migrator`: the same `kysely_migration` journal
|
|
11
|
+
* (`name varchar(255)`, `timestamp varchar(255)` in ISO) and the same lock key as
|
|
12
|
+
* `PostgresAdapter`. The two runners can alternate on one database and will not
|
|
13
|
+
* run at the same time.
|
|
14
|
+
*
|
|
15
|
+
* Why a runner of our own when `Migrator` exists: that one is built around modules
|
|
16
|
+
* with `up`/`down`. Here there are flat `.sql` files, a status without applying,
|
|
17
|
+
* an error naming the failing statement and three transaction modes. Whoever is
|
|
18
|
+
* happy with `Migrator` takes `sqlFileMigrationProvider`.
|
|
19
|
+
*/
|
|
20
|
+
import { type Kysely } from 'kysely';
|
|
21
|
+
/** Same as Kysely, so both runners see one state. */
|
|
22
|
+
export declare const DEFAULT_JOURNAL_TABLE = "kysely_migration";
|
|
23
|
+
/** The `pg_advisory_lock` key, the same as Kysely's `PostgresAdapter` uses. */
|
|
24
|
+
export declare const MIGRATION_LOCK_ID = 3853314791062309107n;
|
|
25
|
+
export type TransactionMode = 'all' | 'each' | 'none';
|
|
26
|
+
export interface MigratorOptions {
|
|
27
|
+
/**
|
|
28
|
+
* A ready `Kysely` with any postgres dialect. Not a `Transaction`: the runner
|
|
29
|
+
* opens transactions and takes the lock itself.
|
|
30
|
+
*/
|
|
31
|
+
readonly db: Kysely<any>;
|
|
32
|
+
/** The folder with `.sql` migrations, the same one `writeMigration` writes to. */
|
|
33
|
+
readonly migrationsDir: string;
|
|
34
|
+
/** The journal table. `kysely_migration` by default, like Kysely. */
|
|
35
|
+
readonly journalTable?: string;
|
|
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`.
|
|
41
|
+
*/
|
|
42
|
+
readonly transaction?: TransactionMode;
|
|
43
|
+
/**
|
|
44
|
+
* Allow migrations that sort before already applied ones (two branches each
|
|
45
|
+
* added a migration and merged out of name order). An error by default.
|
|
46
|
+
*/
|
|
47
|
+
readonly allowUnordered?: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface MigrationStatus {
|
|
50
|
+
readonly applied: readonly string[];
|
|
51
|
+
readonly pending: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
export interface MigrationRunResult {
|
|
54
|
+
/** What this particular run applied, in application order. */
|
|
55
|
+
readonly applied: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
export interface SqlMigrator {
|
|
58
|
+
/** What is applied and what is pending. Also checks that the journal and the folder agree. */
|
|
59
|
+
status(): Promise<MigrationStatus>;
|
|
60
|
+
/** Applies everything pending. A failing statement throws `MigrationError`. */
|
|
61
|
+
toLatest(): Promise<MigrationRunResult>;
|
|
62
|
+
}
|
|
63
|
+
/** A failing statement: which migration, which query and what was applied before it. */
|
|
64
|
+
export declare class MigrationError extends Error {
|
|
65
|
+
readonly migration: string;
|
|
66
|
+
readonly statement: string;
|
|
67
|
+
/** Migrations applied by this run before the error. With `transaction: 'all'` they are rolled back. */
|
|
68
|
+
readonly applied: readonly string[];
|
|
69
|
+
readonly name = "MigrationError";
|
|
70
|
+
constructor(migration: string, statement: string,
|
|
71
|
+
/** Migrations applied by this run before the error. With `transaction: 'all'` they are rolled back. */
|
|
72
|
+
applied: readonly string[], cause: unknown);
|
|
73
|
+
}
|
|
74
|
+
export declare function createMigrator(options: MigratorOptions): SqlMigrator;
|
|
75
|
+
/** `createMigrator(options).toLatest()` in one line. */
|
|
76
|
+
export declare function migrateToLatest(options: MigratorOptions): Promise<MigrationRunResult>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A runner for `.sql` migrations on top of a ready `Kysely`.
|
|
3
|
+
*
|
|
4
|
+
* Kysely is the only abstraction over the driver: under `pg` that is
|
|
5
|
+
* `PostgresDialect`, under Bun any postgres dialect over `Bun.SQL` (the repository
|
|
6
|
+
* has a test one, `test/helpers/bun-dialect.ts`). The runner needs one connection
|
|
7
|
+
* for the whole run, the advisory lock and the transaction live on it, so it works
|
|
8
|
+
* through `db.connection()`.
|
|
9
|
+
*
|
|
10
|
+
* Compatible with Kysely's `Migrator`: the same `kysely_migration` journal
|
|
11
|
+
* (`name varchar(255)`, `timestamp varchar(255)` in ISO) and the same lock key as
|
|
12
|
+
* `PostgresAdapter`. The two runners can alternate on one database and will not
|
|
13
|
+
* run at the same time.
|
|
14
|
+
*
|
|
15
|
+
* Why a runner of our own when `Migrator` exists: that one is built around modules
|
|
16
|
+
* with `up`/`down`. Here there are flat `.sql` files, a status without applying,
|
|
17
|
+
* an error naming the failing statement and three transaction modes. Whoever is
|
|
18
|
+
* happy with `Migrator` takes `sqlFileMigrationProvider`.
|
|
19
|
+
*/
|
|
20
|
+
import { sql } from 'kysely';
|
|
21
|
+
import { listMigrations, readStatements } from '../migrator/store.js';
|
|
22
|
+
/** Same as Kysely, so both runners see one state. */
|
|
23
|
+
export const DEFAULT_JOURNAL_TABLE = 'kysely_migration';
|
|
24
|
+
/** The `pg_advisory_lock` key, the same as Kysely's `PostgresAdapter` uses. */
|
|
25
|
+
export const MIGRATION_LOCK_ID = 3853314791062309107n;
|
|
26
|
+
/** How long to wait for another run. An hour, like Kysely. */
|
|
27
|
+
const LOCK_TIMEOUT_MS = 60 * 60 * 1000;
|
|
28
|
+
/** A failing statement: which migration, which query and what was applied before it. */
|
|
29
|
+
export class MigrationError extends Error {
|
|
30
|
+
migration;
|
|
31
|
+
statement;
|
|
32
|
+
applied;
|
|
33
|
+
name = 'MigrationError';
|
|
34
|
+
constructor(migration, statement,
|
|
35
|
+
/** Migrations applied by this run before the error. With `transaction: 'all'` they are rolled back. */
|
|
36
|
+
applied, cause) {
|
|
37
|
+
super(`${migration}: statement failed\n${statement}\n${describe(cause)}`, { cause });
|
|
38
|
+
this.migration = migration;
|
|
39
|
+
this.statement = statement;
|
|
40
|
+
this.applied = applied;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function describe(cause) {
|
|
44
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
45
|
+
}
|
|
46
|
+
export function createMigrator(options) {
|
|
47
|
+
const { db, migrationsDir: dir } = options;
|
|
48
|
+
if (db.isTransaction) {
|
|
49
|
+
throw new Error('createMigrator: pass a Kysely instance, not a Transaction: the runner manages transactions and the lock itself');
|
|
50
|
+
}
|
|
51
|
+
const journal = options.journalTable ?? DEFAULT_JOURNAL_TABLE;
|
|
52
|
+
if (!/^[a-z_][a-z0-9_]*$/i.test(journal)) {
|
|
53
|
+
throw new Error(`journalTable: "${journal}" may only contain letters, digits and underscores`);
|
|
54
|
+
}
|
|
55
|
+
const table = sql.table(journal);
|
|
56
|
+
const mode = options.transaction ?? 'all';
|
|
57
|
+
const allowUnordered = options.allowUnordered ?? false;
|
|
58
|
+
return {
|
|
59
|
+
status: () => db.connection().execute(async (connection) => {
|
|
60
|
+
await ensureJournal(connection, table);
|
|
61
|
+
return readState(connection, table, dir, allowUnordered);
|
|
62
|
+
}),
|
|
63
|
+
toLatest: () => db.connection().execute(connection => withLock(connection, async () => {
|
|
64
|
+
await ensureJournal(connection, table);
|
|
65
|
+
const state = await readState(connection, table, dir, allowUnordered);
|
|
66
|
+
return apply(connection, table, dir, state.pending, mode);
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** `createMigrator(options).toLatest()` in one line. */
|
|
71
|
+
export function migrateToLatest(options) {
|
|
72
|
+
return createMigrator(options).toLatest();
|
|
73
|
+
}
|
|
74
|
+
async function run(connection, statement) {
|
|
75
|
+
await sql.raw(statement).execute(connection);
|
|
76
|
+
}
|
|
77
|
+
async function withLock(connection, fn) {
|
|
78
|
+
// set_config(..., true) lasts until the end of the current transaction; outside BEGIN that is exactly one statement
|
|
79
|
+
await run(connection, `with set_timeout as (select set_config('lock_timeout', '${LOCK_TIMEOUT_MS}', true)) ` +
|
|
80
|
+
`select pg_advisory_lock(${MIGRATION_LOCK_ID}) from set_timeout`);
|
|
81
|
+
const unlock = `select pg_advisory_unlock(${MIGRATION_LOCK_ID})`;
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = await fn();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// if the connection died, the server released the lock itself; keeping the original error matters more
|
|
88
|
+
await run(connection, unlock).catch(() => undefined);
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
await run(connection, unlock);
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
async function ensureJournal(connection, table) {
|
|
95
|
+
await sql `create table if not exists ${table} ("name" varchar(255) not null primary key, "timestamp" varchar(255) not null)`.execute(connection);
|
|
96
|
+
}
|
|
97
|
+
async function readState(connection, table, dir, allowUnordered) {
|
|
98
|
+
const files = listMigrations(dir);
|
|
99
|
+
const { rows } = await sql `select "name" from ${table}`.execute(connection);
|
|
100
|
+
// sort here, by character codes, like the files and like Kysely, not by the database collation
|
|
101
|
+
const applied = rows.map(row => row.name).toSorted();
|
|
102
|
+
const onDisk = new Set(files);
|
|
103
|
+
const missing = applied.filter(name => !onDisk.has(name));
|
|
104
|
+
if (missing.length > 0) {
|
|
105
|
+
throw new Error(`${dir}: the journal has migrations that are missing on disk: ${missing.join(', ')}. ` +
|
|
106
|
+
'Restore the files from history or delete the journal rows by hand.');
|
|
107
|
+
}
|
|
108
|
+
const done = new Set(applied);
|
|
109
|
+
const pending = files.filter(name => !done.has(name));
|
|
110
|
+
const last = applied.at(-1);
|
|
111
|
+
if (!allowUnordered && last !== undefined) {
|
|
112
|
+
const outOfOrder = pending.filter(name => name < last);
|
|
113
|
+
if (outOfOrder.length > 0) {
|
|
114
|
+
throw new Error(`${dir}: migrations ${outOfOrder.join(', ')} sort before the already applied ${last}. ` +
|
|
115
|
+
'Rename them with a newer timestamp or pass allowUnordered: true.');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { applied, pending };
|
|
119
|
+
}
|
|
120
|
+
async function apply(connection, table, dir, pending, mode) {
|
|
121
|
+
const applied = [];
|
|
122
|
+
if (pending.length === 0) {
|
|
123
|
+
return { applied };
|
|
124
|
+
}
|
|
125
|
+
const runOne = async (name) => {
|
|
126
|
+
for (const statement of readStatements(dir, name)) {
|
|
127
|
+
try {
|
|
128
|
+
await run(connection, statement);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
throw new MigrationError(name, statement, [...applied], error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
await sql `insert into ${table} ("name", "timestamp") values (${name}, ${new Date().toISOString()})`.execute(connection);
|
|
135
|
+
applied.push(name);
|
|
136
|
+
};
|
|
137
|
+
// begin/commit by hand, on the same connection: PostgresDriver inside Kysely does the same
|
|
138
|
+
const inTransaction = async (fn) => {
|
|
139
|
+
await run(connection, 'begin');
|
|
140
|
+
try {
|
|
141
|
+
await fn();
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
await run(connection, 'rollback').catch(() => undefined);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
await run(connection, 'commit');
|
|
148
|
+
};
|
|
149
|
+
switch (mode) {
|
|
150
|
+
case 'all':
|
|
151
|
+
await inTransaction(async () => {
|
|
152
|
+
for (const name of pending) {
|
|
153
|
+
await runOne(name);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
break;
|
|
157
|
+
case 'each':
|
|
158
|
+
for (const name of pending) {
|
|
159
|
+
await inTransaction(() => runOne(name));
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case 'none':
|
|
163
|
+
for (const name of pending) {
|
|
164
|
+
await runOne(name);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
return { applied };
|
|
169
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GenerateResult } from '../generator/generate.ts';
|
|
2
|
+
import { type Snapshot } from '../generator/snapshot.ts';
|
|
3
|
+
export declare const MIGRATION_EXTENSION = ".sql";
|
|
4
|
+
export declare const SNAPSHOT_FILE = "snapshot.json";
|
|
5
|
+
/**
|
|
6
|
+
* Separator between statements inside a file. It is an SQL comment, so the file
|
|
7
|
+
* stays valid when fed to psql as a whole.
|
|
8
|
+
*/
|
|
9
|
+
export declare const STATEMENT_SEPARATOR = "--> statement-breakpoint";
|
|
10
|
+
export declare function migrationTimestamp(now?: Date): string;
|
|
11
|
+
/** Migration names (without `.sql`), sorted the way Kysely sorts them: by character codes. */
|
|
12
|
+
export declare function listMigrations(dir: string): string[];
|
|
13
|
+
/** The schema state after the latest migration; the next diff is computed from it. */
|
|
14
|
+
export declare function readLatestSnapshot(dir: string): Snapshot;
|
|
15
|
+
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;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrations on disk: flat `.sql` files next to a single snapshot.
|
|
3
|
+
*
|
|
4
|
+
* ```
|
|
5
|
+
* migrations/
|
|
6
|
+
* 20260910120000_init.sql
|
|
7
|
+
* 20260910123000_add_tickets.sql
|
|
8
|
+
* snapshot.json
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* There is no separate journal: the file list is the journal, and what has been
|
|
12
|
+
* applied is known to `kysely_migration` in the database itself. The file name
|
|
13
|
+
* without the extension is also the migration name for `Migrator`, hence the
|
|
14
|
+
* timestamp prefix: Kysely sorts migrations as strings.
|
|
15
|
+
*
|
|
16
|
+
* There is a single snapshot describing the state after the LATEST migration,
|
|
17
|
+
* which is all that is needed to compute the next diff. The price is merge
|
|
18
|
+
* conflicts: two branches that each add a migration diverge in one file, and
|
|
19
|
+
* the snapshot has to be regenerated after the merge.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { EMPTY_SNAPSHOT } from '../generator/snapshot.js';
|
|
24
|
+
export const MIGRATION_EXTENSION = '.sql';
|
|
25
|
+
export const SNAPSHOT_FILE = 'snapshot.json';
|
|
26
|
+
/**
|
|
27
|
+
* Separator between statements inside a file. It is an SQL comment, so the file
|
|
28
|
+
* stays valid when fed to psql as a whole.
|
|
29
|
+
*/
|
|
30
|
+
export const STATEMENT_SEPARATOR = '--> statement-breakpoint';
|
|
31
|
+
/** Parses the file name prefix back into a date. */
|
|
32
|
+
function parseTimestamp(value) {
|
|
33
|
+
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))));
|
|
34
|
+
}
|
|
35
|
+
/** `20260910123045`: Kysely sorts migrations by name, hence this prefix. */
|
|
36
|
+
const pad = (value, width = 2) => String(value).padStart(width, '0');
|
|
37
|
+
export function migrationTimestamp(now = new Date()) {
|
|
38
|
+
return (`${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}` +
|
|
39
|
+
`${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`);
|
|
40
|
+
}
|
|
41
|
+
/** Migration names (without `.sql`), sorted the way Kysely sorts them: by character codes. */
|
|
42
|
+
export function listMigrations(dir) {
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return fs
|
|
47
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
48
|
+
.filter(entry => entry.isFile() && entry.name.endsWith(MIGRATION_EXTENSION))
|
|
49
|
+
.map(entry => entry.name.slice(0, -MIGRATION_EXTENSION.length))
|
|
50
|
+
.toSorted();
|
|
51
|
+
}
|
|
52
|
+
/** The schema state after the latest migration; the next diff is computed from it. */
|
|
53
|
+
export function readLatestSnapshot(dir) {
|
|
54
|
+
const file = path.join(dir, SNAPSHOT_FILE);
|
|
55
|
+
if (!fs.existsSync(file)) {
|
|
56
|
+
if (listMigrations(dir).length > 0) {
|
|
57
|
+
throw new Error(`${dir}: migrations exist but ${SNAPSHOT_FILE} is missing. ` +
|
|
58
|
+
'Without it there is nothing to diff against: restore the file from history.');
|
|
59
|
+
}
|
|
60
|
+
return EMPTY_SNAPSHOT;
|
|
61
|
+
}
|
|
62
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
export function readStatements(dir, name) {
|
|
65
|
+
const text = fs.readFileSync(path.join(dir, `${name}${MIGRATION_EXTENSION}`), 'utf8');
|
|
66
|
+
return text
|
|
67
|
+
.split(STATEMENT_SEPARATOR)
|
|
68
|
+
.map(statement => statement.trim())
|
|
69
|
+
.filter(statement => statement !== '');
|
|
70
|
+
}
|
|
71
|
+
/** Writes the migration file, updates the snapshot and returns the migration name. */
|
|
72
|
+
export function writeMigration(dir, name, result) {
|
|
73
|
+
if (result.statements.length === 0) {
|
|
74
|
+
throw new Error('nothing to write: no changes');
|
|
75
|
+
}
|
|
76
|
+
if (!/^[a-z0-9_]+$/.test(name)) {
|
|
77
|
+
throw new Error(`migration name "${name}": only [a-z0-9_] is allowed`);
|
|
78
|
+
}
|
|
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));
|
|
86
|
+
}
|
|
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`);
|
|
91
|
+
}
|
|
92
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
93
|
+
fs.writeFileSync(file, `${result.statements.join(`\n${STATEMENT_SEPARATOR}\n`)}\n`);
|
|
94
|
+
fs.writeFileSync(path.join(dir, SNAPSHOT_FILE), `${JSON.stringify(result.snapshot, null, 2)}\n`);
|
|
95
|
+
return migration;
|
|
96
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snake_case and camelCase at the type level and at runtime, following the
|
|
3
|
+
* rules of Kysely's `CamelCasePlugin` with default options. This way the names
|
|
4
|
+
* derived from schema properties and the type keys under the plugin match what
|
|
5
|
+
* the plugin does to identifiers and result rows:
|
|
6
|
+
*
|
|
7
|
+
* createdAt -> created_at -> createdAt
|
|
8
|
+
* appleID -> apple_id -> appleId consecutive capitals are not split
|
|
9
|
+
* parseJSONValue -> parse_jsonvalue the boundary after an acronym is lost
|
|
10
|
+
* line2Total -> line2_total -> line2Total
|
|
11
|
+
*
|
|
12
|
+
* Names with an underscore before a digit (`field_2` -> `field2`) and with a
|
|
13
|
+
* leading underscore do not round-trip, exactly the plugin's own limitations.
|
|
14
|
+
*/
|
|
15
|
+
type IsUpper<C extends string> = C extends Uppercase<C> ? (C extends Lowercase<C> ? false : true) : false;
|
|
16
|
+
type SnakeInner<S extends string, PrevUpper extends boolean, Acc extends string> = S extends `${infer C}${infer R}` ? IsUpper<C> extends true ? SnakeInner<R, true, `${Acc}${PrevUpper extends true ? '' : '_'}${Lowercase<C>}`> : SnakeInner<R, false, `${Acc}${C}`> : Acc;
|
|
17
|
+
/** `createdAt` -> `created_at`. The first letter is lowercased without an underscore. */
|
|
18
|
+
export type SnakeCase<S extends string> = S extends `${infer C}${infer R}` ? SnakeInner<R, IsUpper<C>, Lowercase<C>> : S;
|
|
19
|
+
type CamelInner<S extends string, AfterUnderscore extends boolean, Acc extends string> = S extends `${infer C}${infer R}` ? C extends '_' ? CamelInner<R, true, Acc> : CamelInner<R, false, `${Acc}${AfterUnderscore extends true ? Uppercase<C> : C}`> : Acc;
|
|
20
|
+
/** `created_at` -> `createdAt`. The first character stays as is, like the plugin does. */
|
|
21
|
+
export type CamelCase<S extends string> = S extends `${infer C}${infer R}` ? CamelInner<R, C extends '_' ? true : false, C> : S;
|
|
22
|
+
/** Runtime counterpart of `SnakeCase`: the same algorithm as `CamelCasePlugin`. */
|
|
23
|
+
export declare function toSnakeCase(input: string): string;
|
|
24
|
+
/** Runtime counterpart of `CamelCase`: this is how the plugin renames result row keys. */
|
|
25
|
+
export declare function toCamelCase(input: string): string;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snake_case and camelCase at the type level and at runtime, following the
|
|
3
|
+
* rules of Kysely's `CamelCasePlugin` with default options. This way the names
|
|
4
|
+
* derived from schema properties and the type keys under the plugin match what
|
|
5
|
+
* the plugin does to identifiers and result rows:
|
|
6
|
+
*
|
|
7
|
+
* createdAt -> created_at -> createdAt
|
|
8
|
+
* appleID -> apple_id -> appleId consecutive capitals are not split
|
|
9
|
+
* parseJSONValue -> parse_jsonvalue the boundary after an acronym is lost
|
|
10
|
+
* line2Total -> line2_total -> line2Total
|
|
11
|
+
*
|
|
12
|
+
* Names with an underscore before a digit (`field_2` -> `field2`) and with a
|
|
13
|
+
* leading underscore do not round-trip, exactly the plugin's own limitations.
|
|
14
|
+
*/
|
|
15
|
+
function isUpper(char) {
|
|
16
|
+
return char === char.toUpperCase() && char !== char.toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
/** Runtime counterpart of `SnakeCase`: the same algorithm as `CamelCasePlugin`. */
|
|
19
|
+
export function toSnakeCase(input) {
|
|
20
|
+
let out = input.charAt(0).toLowerCase();
|
|
21
|
+
for (let i = 1; i < input.length; i++) {
|
|
22
|
+
const char = input.charAt(i);
|
|
23
|
+
if (isUpper(char)) {
|
|
24
|
+
out += isUpper(input.charAt(i - 1)) ? char.toLowerCase() : `_${char.toLowerCase()}`;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
out += char;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** Runtime counterpart of `CamelCase`: this is how the plugin renames result row keys. */
|
|
33
|
+
export function toCamelCase(input) {
|
|
34
|
+
let out = input.charAt(0);
|
|
35
|
+
for (let i = 1; i < input.length; i++) {
|
|
36
|
+
const char = input.charAt(i);
|
|
37
|
+
if (char === '_') {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
out += input.charAt(i - 1) === '_' ? char.toUpperCase() : char;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|