migrane 0.3.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.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The vocabulary everything downstream is written against. Nothing here names a
3
+ * database, an ORM or an application: a consumer supplies a {@link Driver} and
4
+ * the runner never learns what it is talking to.
5
+ */
6
+ /** A result row. */
7
+ export type Row = Record<string, unknown>;
8
+ /**
9
+ * Anything a statement can be sent to — a pool, a pinned connection, or a
10
+ * connection inside a transaction. A migration body cannot tell them apart.
11
+ *
12
+ * Omitting `values` is meaningful: with no bind parameters PostgreSQL uses the
13
+ * simple query protocol, which is what lets one template carry several
14
+ * statements separated by `;`. Pass a parameter and exactly one is legal.
15
+ */
16
+ export interface Queryable {
17
+ query<R extends Row = Row>(text: string, values?: readonly unknown[]): Promise<R[]>;
18
+ }
19
+ /**
20
+ * One pinned connection.
21
+ *
22
+ * Separate from {@link Driver} because a transaction and a session-level lock
23
+ * are only meaningful on a connection that stays the same between statements,
24
+ * and a pool hands out whichever is free.
25
+ */
26
+ export interface Session extends Queryable {
27
+ transaction<T>(run: (tx: Queryable) => Promise<T>): Promise<T>;
28
+ /**
29
+ * Hold an exclusive lock named by `key` for the length of `run`, releasing it
30
+ * however `run` ends.
31
+ *
32
+ * Must **block** rather than fail when the lock is held: whoever holds it is
33
+ * applying the migrations this process wants applied, so waiting is correct
34
+ * and failing fast turns a wait into a red deploy.
35
+ */
36
+ lock<T>(key: number, run: () => Promise<T>): Promise<T>;
37
+ }
38
+ /**
39
+ * How the runner reaches a database. Implement these and everything else in
40
+ * this package works — `drivers/pg.ts` is the reference, at under a hundred
41
+ * lines.
42
+ */
43
+ export interface Driver extends Queryable {
44
+ /**
45
+ * The machine these coordinates reach: a hostname, an address, or `''` for a
46
+ * unix socket.
47
+ *
48
+ * Required rather than optional, because it is what a {@link Guard} matches
49
+ * on. A driver that could omit it would silently opt its databases out of
50
+ * every guard written against it. State it even when it is `'localhost'`.
51
+ */
52
+ readonly host: string;
53
+ /**
54
+ * The database these coordinates open, named beside {@link host} for the
55
+ * same reason: a guard that matches on the name must read it off the
56
+ * connection it is guarding, not re-derive it from the environment.
57
+ */
58
+ readonly database: string;
59
+ /** Pin one connection for the length of `run`. */
60
+ session<T>(run: (session: Session) => Promise<T>): Promise<T>;
61
+ close(): Promise<void>;
62
+ }
63
+ /**
64
+ * SQL spliced verbatim rather than bound as a parameter.
65
+ *
66
+ * A branded object rather than a string, so reaching for the escape is visible
67
+ * in a diff instead of being what happens by default.
68
+ */
69
+ declare const FRAGMENT: unique symbol;
70
+ export interface Fragment {
71
+ readonly [FRAGMENT]: string;
72
+ }
73
+ /**
74
+ * The tagged template a migration writes against. Interpolations become bind
75
+ * parameters unless they are {@link Fragment}s, so the ordinary way to write a
76
+ * value is also the safe one.
77
+ */
78
+ export interface Sql {
79
+ <R extends Row = Row>(strings: TemplateStringsArray, ...values: readonly unknown[]): Promise<R[]>;
80
+ /** Splice text in unescaped, for DDL the template cannot express. */
81
+ raw: (text: string) => Fragment;
82
+ /** Quote an identifier — `sql.id('user table')` is `"user table"`. */
83
+ id: (name: string) => Fragment;
84
+ /** Join fragments — a column list, a set of constraints. */
85
+ join: (parts: readonly Fragment[], separator?: string) => Fragment;
86
+ }
87
+ /** What every migration and seed part receives. */
88
+ export interface Context {
89
+ sql: Sql;
90
+ /** The connection underneath, for what the template cannot say. */
91
+ db: Queryable;
92
+ }
93
+ /**
94
+ * What a migration file exports.
95
+ *
96
+ * `down` is optional: many migrations have no honest reverse, and requiring one
97
+ * only ever produces an empty body that lies about being reversible.
98
+ */
99
+ export interface Part {
100
+ up: (context: Context) => Promise<void>;
101
+ down?: (context: Context) => Promise<void>;
102
+ }
103
+ /**
104
+ * Export `transaction = false` to opt out of the wrapping transaction, for
105
+ * statements PostgreSQL refuses to run inside one such as
106
+ * `CREATE INDEX CONCURRENTLY`.
107
+ *
108
+ * The cost is stated where it is taken: an untransacted migration that fails
109
+ * half way leaves the schema changed and its row unwritten.
110
+ */
111
+ export interface Transacted {
112
+ transaction?: boolean;
113
+ }
114
+ /** A migration file's module, once loaded. */
115
+ export type Module = Part & Transacted;
116
+ /** One migration, found on disk but not yet loaded. */
117
+ export interface Discovered {
118
+ /** The storage key — the file or directory name, without extension. */
119
+ name: string;
120
+ /** The leading number, which orders it. */
121
+ sequence: number;
122
+ /** Absolute path of the file, or of the directory. */
123
+ path: string;
124
+ /**
125
+ * What actually runs, in order: one entry for a file or a directory with an
126
+ * `index.ts`, every part in path order for a directory without one.
127
+ */
128
+ run: readonly string[];
129
+ /**
130
+ * Every file the migration is made of, including parts reached only through
131
+ * an `index.ts` — the checksum has to cover what it *executes*, not what
132
+ * discovery happened to open.
133
+ */
134
+ files: readonly string[];
135
+ /** Hash of all of {@link files}, so editing an applied migration is refused. */
136
+ checksum: string;
137
+ }
138
+ /**
139
+ * A migration, loaded and ready to run. Carries no paths: the same runner
140
+ * applies it from a laptop and from an image where the files no longer exist.
141
+ */
142
+ export interface Migration extends Omit<Discovered, 'files' | 'path' | 'run'> {
143
+ module: Module;
144
+ }
145
+ /** Run before any pending migration, in declaration order. */
146
+ export type Hook = (context: Context) => Promise<void>;
147
+ /**
148
+ * What every destructive command runs through before it touches anything.
149
+ *
150
+ * A guard interrogates the **driver** — the config's own product, the one true
151
+ * record of the connection — or queries through it, and it ends with `refuse`
152
+ * for whatever it does not allow:
153
+ *
154
+ * ```ts
155
+ * guard: async (driver, what) => {
156
+ * if (await isDisposable(driver)) return;
157
+ *
158
+ * refuse(driver, what);
159
+ * },
160
+ * ```
161
+ *
162
+ * Returning is allowing; **throwing is refusing** — the same shape `refuse()`
163
+ * has, so ending with it is composition rather than convention.
164
+ *
165
+ * @param what what the command does, as a clause — `reset drops every table`.
166
+ */
167
+ export type Guard = (driver: Driver, what: string) => void | Promise<void>;
168
+ /** What a consumer's `database/config.ts` exports. */
169
+ export interface Config {
170
+ /**
171
+ * Directories holding migrations, in order. Inside a root, a `.ts` or `.sql`
172
+ * file is one migration and so is a directory.
173
+ */
174
+ dirs: readonly string[];
175
+ /** How to reach the database. A factory, so `status` can skip opening one. */
176
+ driver: () => Driver | Promise<Driver>;
177
+ /** Where seed units live, one directory per unit. Omit for no seeds. */
178
+ seeds?: string;
179
+ /**
180
+ * Where `migrane manifest` writes. Omit it and the command says so rather
181
+ * than inventing a path — `entryFor` writes every specifier relative to this
182
+ * destination, so guessing where it goes would guess what is in it.
183
+ */
184
+ manifest?: string;
185
+ /** Bookkeeping table names. Default to `migrations` and `seeds`. */
186
+ table?: string;
187
+ seedTable?: string;
188
+ /** The schema `reset` drops and recreates. Defaults to `public`. */
189
+ schema?: string;
190
+ /**
191
+ * Who may run a destructive command against this database.
192
+ *
193
+ * Omit it and every destructive verb — `down`, `reset`, `fresh`, `seed`,
194
+ * `unseed` — refuses. `up` and `status` are never guarded. The library
195
+ * consults nothing else: no environment variable, no notion of which hosts
196
+ * are local. Which databases are disposable is this config's opinion, and
197
+ * the config is the one place that opinion is correct.
198
+ */
199
+ guard?: Guard;
200
+ /**
201
+ * Run once before pending migrations, and even when none are pending. Not
202
+ * before `status` or `reset`, neither of which has business writing DDL.
203
+ *
204
+ * The seam for anything you want true *before* a migration but do not want to
205
+ * write as one: a synced type, a search path, an extension. Also the only
206
+ * place the runner executes code it did not discover.
207
+ */
208
+ before?: readonly Hook[];
209
+ }
210
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The vocabulary everything downstream is written against. Nothing here names a
3
+ * database, an ORM or an application: a consumer supplies a {@link Driver} and
4
+ * the runner never learns what it is talking to.
5
+ */
6
+ export {};
package/package.json ADDED
@@ -0,0 +1,98 @@
1
+ {
2
+ "name": "migrane",
3
+ "version": "0.3.0",
4
+ "description": "A SQL migration runner that knows nothing about your application.",
5
+ "keywords": [
6
+ "sql",
7
+ "migration",
8
+ "migrations",
9
+ "migrate",
10
+ "postgres",
11
+ "postgresql",
12
+ "database",
13
+ "schema",
14
+ "seed"
15
+ ],
16
+ "author": {
17
+ "name": "eishexac",
18
+ "url": "https://existin.space",
19
+ "email": "hexac@existin.space"
20
+ },
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/eishexac/migrane.git"
28
+ },
29
+ "homepage": "https://github.com/eishexac/migrane",
30
+ "bugs": "https://github.com/eishexac/migrane/issues",
31
+ "type": "module",
32
+ "sideEffects": false,
33
+ "bin": {
34
+ "migrane": "./bin/migrane.js"
35
+ },
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "default": "./dist/index.js"
40
+ },
41
+ "./ship": {
42
+ "types": "./dist/ship.d.ts",
43
+ "default": "./dist/ship.js"
44
+ },
45
+ "./drivers/pg": {
46
+ "types": "./dist/drivers/pg.d.ts",
47
+ "default": "./dist/drivers/pg.js"
48
+ },
49
+ "./package.json": "./package.json"
50
+ },
51
+ "files": [
52
+ "bin",
53
+ "dist",
54
+ "LICENSE",
55
+ "README.md"
56
+ ],
57
+ "peerDependencies": {
58
+ "pg": "^8.23.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "pg": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "devDependencies": {
66
+ "@arethetypeswrong/cli": "^0.18.5",
67
+ "@changesets/changelog-github": "^1.0.0",
68
+ "@changesets/cli": "^3.0.1",
69
+ "@eslint/js": "^10.0.1",
70
+ "@testcontainers/postgresql": "^12.1.0",
71
+ "@types/node": "^24.10.15",
72
+ "@types/pg": "^8.23.1",
73
+ "@vitest/coverage-v8": "^4.1.9",
74
+ "eslint": "^10.5.0",
75
+ "eslint-config-prettier": "^10.1.8",
76
+ "globals": "^17.6.0",
77
+ "pg": "^8.23.0",
78
+ "prettier": "^3.8.4",
79
+ "publint": "^0.3.24",
80
+ "rimraf": "^6.1.3",
81
+ "typescript": "^6.0.3",
82
+ "typescript-eslint": "^8.61.1",
83
+ "vitest": "^4.1.9"
84
+ },
85
+ "engines": {
86
+ "node": ">=22.18",
87
+ "bun": ">=1.0"
88
+ },
89
+ "scripts": {
90
+ "build": "rimraf dist && tsc -p tsconfig.build.json",
91
+ "format": "prettier --write .",
92
+ "lint": "eslint .",
93
+ "type:check": "tsc --noEmit && tsc -p tsconfig.ship.json",
94
+ "test": "vitest run",
95
+ "test:coverage": "vitest run --coverage",
96
+ "pack:check": "pnpm pack --out package.tgz && publint package.tgz && attw package.tgz --profile esm-only && rimraf package.tgz"
97
+ }
98
+ }