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.
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/migrane.js +54 -0
- package/dist/bundle.d.ts +25 -0
- package/dist/bundle.js +69 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +167 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +62 -0
- package/dist/connect.d.ts +20 -0
- package/dist/connect.js +25 -0
- package/dist/defaults.d.ts +22 -0
- package/dist/defaults.js +22 -0
- package/dist/discover.d.ts +62 -0
- package/dist/discover.js +228 -0
- package/dist/drivers/pg.d.ts +9 -0
- package/dist/drivers/pg.js +138 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +31 -0
- package/dist/load.d.ts +54 -0
- package/dist/load.js +89 -0
- package/dist/lock.d.ts +30 -0
- package/dist/lock.js +35 -0
- package/dist/reset.d.ts +23 -0
- package/dist/reset.js +16 -0
- package/dist/runner.d.ts +65 -0
- package/dist/runner.js +150 -0
- package/dist/safety.d.ts +32 -0
- package/dist/safety.js +44 -0
- package/dist/seeds.d.ts +50 -0
- package/dist/seeds.js +98 -0
- package/dist/ship.d.ts +52 -0
- package/dist/ship.js +23 -0
- package/dist/sql.d.ts +50 -0
- package/dist/sql.js +73 -0
- package/dist/storage.d.ts +52 -0
- package/dist/storage.js +65 -0
- package/dist/types.d.ts +210 -0
- package/dist/types.js +6 -0
- package/package.json +98 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eishexac <hexac@existin.space>
|
|
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,435 @@
|
|
|
1
|
+
# migrane
|
|
2
|
+
|
|
3
|
+
A SQL migration runner that knows nothing about your application.
|
|
4
|
+
|
|
5
|
+
It is handed a driver and a list of directories. That is all it knows — no ORM,
|
|
6
|
+
no container, no module registry, no dependency it makes you adopt. The package
|
|
7
|
+
itself has no runtime dependencies. Everything specific to an application lives
|
|
8
|
+
in that application's config file.
|
|
9
|
+
|
|
10
|
+
Three entry points, cut by audience:
|
|
11
|
+
|
|
12
|
+
| entry | for | holds |
|
|
13
|
+
| -------------------- | ---------------------------------- | ------------------------------------------------ |
|
|
14
|
+
| `migrane` | config & migration authors | `defineConfig`, `compose`, `createSql`, `refuse` |
|
|
15
|
+
| `migrane/ship` | container entries | `createPlans` and the verbs — no filesystem |
|
|
16
|
+
| `migrane/drivers/pg` | consumers of the bundled pg driver | `pgDriver` |
|
|
17
|
+
|
|
18
|
+
The CLI owns every job that touches a filesystem; `migrane/ship` owns every job
|
|
19
|
+
that does not — it compiles for a target with no filesystem at all, checked in
|
|
20
|
+
this repository's build.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm add -D migrane
|
|
24
|
+
pnpm add pg # only if you use the bundled postgres driver
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// database/config.ts
|
|
29
|
+
import { defineConfig } from 'migrane';
|
|
30
|
+
import { pgDriver } from 'migrane/drivers/pg';
|
|
31
|
+
|
|
32
|
+
export default defineConfig({
|
|
33
|
+
dirs: ['./migrations'],
|
|
34
|
+
seeds: './seeders',
|
|
35
|
+
manifest: './manifest.gen.ts', // only if you ship a container
|
|
36
|
+
driver: () => pgDriver(process.env.DATABASE_URL!),
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
migrane up | down | status
|
|
42
|
+
migrane reset | fresh [unit]
|
|
43
|
+
migrane seed <unit> | unseed <unit>
|
|
44
|
+
migrane manifest [--out <path>]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Config is looked for at `database/config.ts`, then `migrate.config.{ts,js,mjs}`,
|
|
48
|
+
or wherever `--config` points. Paths in it resolve against the config file, not
|
|
49
|
+
the working directory, so the commands answer the same from anywhere in a
|
|
50
|
+
repository.
|
|
51
|
+
|
|
52
|
+
## Exit codes
|
|
53
|
+
|
|
54
|
+
A code, never a sentence — what lets your tooling go fully through the CLI and
|
|
55
|
+
still distinguish outcomes by contract instead of matching stderr:
|
|
56
|
+
|
|
57
|
+
| code | meaning |
|
|
58
|
+
| ---- | ------------------------------------------------- |
|
|
59
|
+
| 0 | ok |
|
|
60
|
+
| 1 | failure |
|
|
61
|
+
| 2 | refused: a migration changed after it was applied |
|
|
62
|
+
| 3 | refused: the guard said no |
|
|
63
|
+
|
|
64
|
+
## Runtimes
|
|
65
|
+
|
|
66
|
+
The published package is plain ES modules; the interesting question is who runs
|
|
67
|
+
_your_ config and migrations, because both may be TypeScript and the CLI loads
|
|
68
|
+
them with `import()`.
|
|
69
|
+
|
|
70
|
+
The executable answers it without being told. Its shebang names `sh`, and the
|
|
71
|
+
first line `exec`s the first of **node** then **bun** that is on `PATH` — so a
|
|
72
|
+
machine holding only bun runs it, and a machine holding node behaves exactly as
|
|
73
|
+
it always did.
|
|
74
|
+
|
|
75
|
+
- **node ≥ 22.18** strips types natively — `migrane up` just works.
|
|
76
|
+
- **bun** always ran TypeScript, and is taken when node is absent. To take it on
|
|
77
|
+
a machine that has both, name it per command with `migrane --runtime=bun up`,
|
|
78
|
+
or for the shell with `MIGRANE_RUNTIME=bun migrane up`. The flag wins, and
|
|
79
|
+
must come first — it is read by the executable before any of this package
|
|
80
|
+
runs, and never reaches the command surface.
|
|
81
|
+
Node is preferred by default because **bun loads `.env` from the working
|
|
82
|
+
directory and node does not** — and bun reads it from where you _ran_ the
|
|
83
|
+
command, while everything else here resolves against the config file. A
|
|
84
|
+
variable already set in the environment still wins, so in CI or a container
|
|
85
|
+
both runtimes reach the same database; the difference shows up on a laptop,
|
|
86
|
+
where `cd apps/api && migrane up` can pick up a different `.env` than the
|
|
87
|
+
repository root. `bun --no-env-file` turns that off, for a project that wants
|
|
88
|
+
bun without it.
|
|
89
|
+
- **older node** works when the config is `.js` and the migrations are `.sql`.
|
|
90
|
+
- **Windows** gets a `.cmd` shim that calls `sh`, which Git Bash provides.
|
|
91
|
+
|
|
92
|
+
## Writing a migration
|
|
93
|
+
|
|
94
|
+
A migration is `<number>-<slug>.ts`, `<number>-<slug>.sql`, or a directory of
|
|
95
|
+
that name. `001-initial` and `20260826143000-add-products` both work and can be
|
|
96
|
+
mixed, because ordering compares the number itself — `9-` sorts before `10-`
|
|
97
|
+
without anyone zero-padding.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import type { Part } from 'migrane';
|
|
101
|
+
|
|
102
|
+
export const up: Part['up'] = async ({ sql }) => {
|
|
103
|
+
await sql`CREATE TABLE users (id uuid PRIMARY KEY, email text NOT NULL)`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const down: Part['down'] = async ({ sql }) => {
|
|
107
|
+
await sql`DROP TABLE users`;
|
|
108
|
+
};
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`down` is optional. A great many migrations have no honest reverse, and a
|
|
112
|
+
required one only ever gets an empty body that lies about being reversible.
|
|
113
|
+
|
|
114
|
+
### The `sql` tag
|
|
115
|
+
|
|
116
|
+
Interpolations become **bind parameters**:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
await sql`INSERT INTO users (email) VALUES (${email})`; // → $1
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
DDL is mostly things that cannot be parameters, so the escape is explicit and
|
|
123
|
+
visible in a diff:
|
|
124
|
+
|
|
125
|
+
| | |
|
|
126
|
+
| --------------------- | --------------------------------------------------- |
|
|
127
|
+
| `sql.raw(text)` | splice verbatim |
|
|
128
|
+
| `sql.id(name)` | quote an identifier — `sql.id('order')` → `"order"` |
|
|
129
|
+
| `sql.join(fragments)` | a column list, a set of constraints |
|
|
130
|
+
|
|
131
|
+
A template that interpolates **nothing** sends no parameters, and may therefore
|
|
132
|
+
carry several statements separated by `;` — PostgreSQL only restricts a request
|
|
133
|
+
to one statement once a bind parameter is present.
|
|
134
|
+
|
|
135
|
+
The tag a migration receives is constructed by `createSql`, which is exported:
|
|
136
|
+
hand it anything with a `query(text, values)` and you hold the same tag —
|
|
137
|
+
`raw`, `id` and `join` included — over a connection this package did not open.
|
|
138
|
+
That is how a `before` hook runs against an ORM's connection, and it is the
|
|
139
|
+
only constructor for the `Sql` and `Fragment` types, so there is exactly one
|
|
140
|
+
way to spell `sql.raw` everywhere.
|
|
141
|
+
|
|
142
|
+
### Directory migrations
|
|
143
|
+
|
|
144
|
+
A directory is one migration made of parts. Two ways to order them, and the
|
|
145
|
+
choice is per directory:
|
|
146
|
+
|
|
147
|
+
**An `index.ts` is the migration.** The array it composes is the order, read top
|
|
148
|
+
to bottom in one place, and the files need no prefixes:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
001-initial/
|
|
152
|
+
├── index.ts compose([extensions, users, devices])
|
|
153
|
+
├── pg/extensions.ts
|
|
154
|
+
└── tables/users.ts, devices.ts
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
export const { up, down } = compose([extensions, users, devices]);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Order is _data_ here, not import order — which is what makes it survive an IDE
|
|
162
|
+
reordering the imports above it.
|
|
163
|
+
|
|
164
|
+
**Without an index**, every `.ts`/`.sql` file under the directory is composed in
|
|
165
|
+
path order, and the numbers carry the order:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
001-initial/
|
|
169
|
+
├── 010-pg/010-extensions.ts
|
|
170
|
+
└── 020-tables/010-users.ts
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Either way `up` runs forwards and `down` in reverse, so foreign keys hold in
|
|
174
|
+
both directions — and either way the checksum covers **every file**, including
|
|
175
|
+
parts only an index imports.
|
|
176
|
+
|
|
177
|
+
### `.sql` migrations
|
|
178
|
+
|
|
179
|
+
```sql
|
|
180
|
+
-- migrate:up
|
|
181
|
+
CREATE TABLE users (id uuid PRIMARY KEY);
|
|
182
|
+
|
|
183
|
+
-- migrate:down
|
|
184
|
+
DROP TABLE users;
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Each section is sent as one statement. Splitting on `;` would be wrong the first
|
|
188
|
+
time a function body or a quoted string contained one.
|
|
189
|
+
|
|
190
|
+
## What it guarantees
|
|
191
|
+
|
|
192
|
+
- **Each migration commits in a transaction with the row that records it.** A
|
|
193
|
+
failure can never leave DDL applied and the bookkeeping unwritten. Add
|
|
194
|
+
`export const transaction = false` for `CREATE INDEX CONCURRENTLY` and friends
|
|
195
|
+
— the runner says so on the line when it does.
|
|
196
|
+
- **A session advisory lock spans the run**, taken before the bookkeeping is
|
|
197
|
+
read. Two containers starting at once is the ordinary case, not the exotic
|
|
198
|
+
one, and without this both see the same empty table and both run the same DDL.
|
|
199
|
+
- **Editing an applied migration is refused**, by checksum, before anything
|
|
200
|
+
runs. A recorded migration that is no longer on disk is _not_ an error —
|
|
201
|
+
that is what squashing looks like from the database's side, and `status` says
|
|
202
|
+
`orphan` rather than failing.
|
|
203
|
+
- **`status` never refuses.** An edited migration is the likeliest reason you
|
|
204
|
+
are running it, so it reports `changed` and finishes the report rather than
|
|
205
|
+
failing on the very thing you asked about. `up` and `down` still refuse, which
|
|
206
|
+
is where refusing belongs.
|
|
207
|
+
|
|
208
|
+
## What it refuses to do
|
|
209
|
+
|
|
210
|
+
`down`, `reset`, `fresh`, `seed` and `unseed` all rewrite data somebody may be
|
|
211
|
+
using, so all five run through the config's `guard` before they touch anything —
|
|
212
|
+
on the functions themselves, so importing one directly does not walk around it.
|
|
213
|
+
`up` and `status` are never guarded.
|
|
214
|
+
|
|
215
|
+
**Declare no guard and every one of them refuses.** The library consults
|
|
216
|
+
nothing else: no `NODE_ENV`, no environment variable, no notion of which hosts
|
|
217
|
+
are local — locality was always a poor proxy, because on a droplet running
|
|
218
|
+
Postgres host-networked, production _is_ `127.0.0.1`. Which databases are
|
|
219
|
+
disposable is your config's opinion, and the config is the one place that
|
|
220
|
+
opinion is correct.
|
|
221
|
+
|
|
222
|
+
The refusal carries the fix. It names the exact coordinates the driver holds,
|
|
223
|
+
in a guard ready to paste once you have read them and decided that database is
|
|
224
|
+
yours to lose:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
reset drops every table, and nothing says "app_dev" on "127.0.0.1" is disposable.
|
|
228
|
+
If it is, say so in the config — the guard replaces this refusal:
|
|
229
|
+
|
|
230
|
+
guard: (driver, what) => {
|
|
231
|
+
if (driver.host === '127.0.0.1' && driver.database === 'app_dev') return;
|
|
232
|
+
|
|
233
|
+
refuse(driver, what);
|
|
234
|
+
},
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
A guard interrogates the **driver** — the config's own product, the one true
|
|
238
|
+
record of the connection — or queries through it, and ends with `refuse` for
|
|
239
|
+
whatever it does not allow. The strongest shape puts the permission on the
|
|
240
|
+
database itself, where it survives a schema drop, needs ownership to set, and
|
|
241
|
+
cannot be typed onto the wrong machine:
|
|
242
|
+
|
|
243
|
+
```sql
|
|
244
|
+
ALTER DATABASE preview SET migrane.disposable = 'yes';
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// database/config.ts
|
|
249
|
+
import { defineConfig, refuse } from 'migrane';
|
|
250
|
+
|
|
251
|
+
export default defineConfig({
|
|
252
|
+
// …
|
|
253
|
+
guard: async (driver, what) => {
|
|
254
|
+
const [row] = await driver.query<{ on: string | null }>(
|
|
255
|
+
`SELECT current_setting('migrane.disposable', true) AS on`,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if (row?.on === 'yes') return; // this database says it is disposable
|
|
259
|
+
|
|
260
|
+
refuse(driver, what);
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
One guard, not a list. A guard yields a verdict, and combining verdicts needs
|
|
266
|
+
an operator a config field cannot spell — so write the composition you mean, as
|
|
267
|
+
above.
|
|
268
|
+
|
|
269
|
+
## Hooks
|
|
270
|
+
|
|
271
|
+
`before` runs once per `up`, on the pinned connection, ahead of anything
|
|
272
|
+
pending — and **even when nothing is pending**, because whether a hook needs to
|
|
273
|
+
run has nothing to do with whether a new migration was added.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
before: [async ({ sql }) => void (await sql`CREATE EXTENSION IF NOT EXISTS pg_trgm`)],
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
It is the seam for anything you want true _before_ a migration but do not want
|
|
280
|
+
to write as one — types synced from a registry, a search path, an extension. It
|
|
281
|
+
is also the only place the runner will execute code it did not discover.
|
|
282
|
+
`status` and `reset` do not run hooks; neither has business writing DDL.
|
|
283
|
+
|
|
284
|
+
## Seeds
|
|
285
|
+
|
|
286
|
+
Seed units are **alternatives, not increments** — one directory is one dataset
|
|
287
|
+
and you run exactly one against a fresh database. A unit is recorded but never
|
|
288
|
+
refused: editing a fixture set and running it again is how one is used, so the
|
|
289
|
+
row moves rather than the insert failing.
|
|
290
|
+
|
|
291
|
+
`status` reads that row back as `current`, `changed since`, or `unit is gone` —
|
|
292
|
+
which answers _which fixtures is this database holding_, and is the only
|
|
293
|
+
question the table exists for.
|
|
294
|
+
|
|
295
|
+
Make a unit safe to run twice: `ON CONFLICT DO NOTHING`, `IF NOT EXISTS`, or
|
|
296
|
+
truncate first. Nothing above it will stop a second run.
|
|
297
|
+
|
|
298
|
+
## Shipping it in a container
|
|
299
|
+
|
|
300
|
+
Discovery is a directory read and loading is `import(file)` or `readFileSync`;
|
|
301
|
+
no bundler follows any of them, and an image has no directories. So the build
|
|
302
|
+
generates a **manifest** — imports and two arrays, and nothing else:
|
|
303
|
+
|
|
304
|
+
```sh
|
|
305
|
+
migrane manifest # writes where `manifest` in the config points
|
|
306
|
+
migrane manifest --out gen/manifest.ts
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Declare the destination rather than letting this package pick one — every
|
|
310
|
+
specifier in the manifest is written **relative to where it lands**, so
|
|
311
|
+
guessing where it goes would guess what is in it. With neither `manifest` nor
|
|
312
|
+
`--out`, the command says so instead of inventing a path. It opens no database,
|
|
313
|
+
which is what makes it a build step: nothing to connect to, and nothing it
|
|
314
|
+
could reach.
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
// database/manifest.gen.ts — generated, gitignored, regenerated every build
|
|
318
|
+
import * as m1_0 from '../migrations/002-backfill.ts';
|
|
319
|
+
|
|
320
|
+
export const migrations = [
|
|
321
|
+
{
|
|
322
|
+
name: '001-initial',
|
|
323
|
+
sequence: 1,
|
|
324
|
+
checksum: 'ab12…',
|
|
325
|
+
parts: [{ sql: `…` }],
|
|
326
|
+
},
|
|
327
|
+
{ name: '002-backfill', sequence: 2, checksum: '77aa…', parts: [m1_0] },
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
export const seeds = [];
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
A `.ts` part is imported, a `.sql` part is carried as text — the same split
|
|
334
|
+
loading makes, because it is the same split. A project written entirely in SQL
|
|
335
|
+
generates a manifest that imports nothing at all, which is to say: data.
|
|
336
|
+
|
|
337
|
+
The lists are discovery's own output, so the image applies the same units in
|
|
338
|
+
the same order and records the same checksums as the CLI. That agreement is the
|
|
339
|
+
whole reason this lives in the library rather than in your build script.
|
|
340
|
+
|
|
341
|
+
### The entry is yours
|
|
342
|
+
|
|
343
|
+
The manifest calls nothing, so nothing worth reading lives where a linter, a
|
|
344
|
+
checker and a test cannot reach it. What to do with the arrays is ordinary
|
|
345
|
+
source in your repository, over `migrane/ship` — which holds every job that
|
|
346
|
+
does not touch a filesystem, and nothing that does:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
// database/migrate.ts
|
|
350
|
+
import { createPlans, status, up, withDriver } from 'migrane/ship';
|
|
351
|
+
import config from './config.ts';
|
|
352
|
+
import * as manifest from './manifest.gen.ts';
|
|
353
|
+
|
|
354
|
+
const [verb = 'up'] = process.argv.slice(2);
|
|
355
|
+
const plans = createPlans(config, manifest); // { migrations, seeds, reset }
|
|
356
|
+
|
|
357
|
+
process.exit(
|
|
358
|
+
await withDriver(config, async (driver) => {
|
|
359
|
+
try {
|
|
360
|
+
if (verb === 'up') await up(driver, plans.migrations);
|
|
361
|
+
else if (verb === 'status') await status(driver, plans.migrations);
|
|
362
|
+
else throw new Error(`unknown command: ${verb}`);
|
|
363
|
+
|
|
364
|
+
return 0;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
console.error(error);
|
|
367
|
+
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The exit code is the contract a deploy reads: a one-shot migrate container that
|
|
375
|
+
exits non-zero holds the previous release in place rather than starting a server
|
|
376
|
+
against a schema that never got written. Waiting for the database is the
|
|
377
|
+
orchestrator's job, not the entry's — `depends_on: condition: service_healthy`
|
|
378
|
+
in a compose file says it where a timeout can be tuned without a release.
|
|
379
|
+
|
|
380
|
+
Offering `down`, `seed` or `reset` there is your call, not this package's — and
|
|
381
|
+
every one of them still runs through the guard above before it touches
|
|
382
|
+
anything.
|
|
383
|
+
|
|
384
|
+
## Writing a driver
|
|
385
|
+
|
|
386
|
+
Three methods and two coordinates, over a session that can lock and transact.
|
|
387
|
+
`drivers/pg.ts` is the reference, and `pg` is an optional peer dependency —
|
|
388
|
+
importing `migrane` does not reach it, only importing `migrane/drivers/pg`
|
|
389
|
+
does.
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
import type { Driver, Session } from 'migrane';
|
|
393
|
+
|
|
394
|
+
interface Driver {
|
|
395
|
+
readonly host: string; // where these coordinates point; '' for a unix socket
|
|
396
|
+
readonly database: string; // which database they open
|
|
397
|
+
query(text, values?): Promise<Row[]>;
|
|
398
|
+
session<T>(run: (session: Session) => Promise<T>): Promise<T>;
|
|
399
|
+
close(): Promise<void>;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
interface Session extends Queryable {
|
|
403
|
+
transaction<T>(run: (tx: Queryable) => Promise<T>): Promise<T>;
|
|
404
|
+
lock<T>(key: number, run: () => Promise<T>): Promise<T>;
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`host` and `database` are required rather than optional-with-a-default because
|
|
409
|
+
they are what a guard matches on, and a driver that could leave them out would
|
|
410
|
+
be a driver that silently opts its databases out of every guard. State them
|
|
411
|
+
even when they are `'localhost'` and obvious.
|
|
412
|
+
|
|
413
|
+
`session` pins one connection: a lock and a transaction are only meaningful on a
|
|
414
|
+
connection that stays the same between statements, and a pool hands out
|
|
415
|
+
whichever is free.
|
|
416
|
+
|
|
417
|
+
`lock` is a **method rather than a statement the runner sends**, because taking
|
|
418
|
+
a lock is a database-agnostic idea and `SELECT pg_advisory_lock($1)` is not —
|
|
419
|
+
this package names no database, and that statement was the one place it did. It
|
|
420
|
+
takes and releases around `run`, so no caller can forget to give a lock back,
|
|
421
|
+
and it must block rather than fail: whoever holds it is applying the migrations
|
|
422
|
+
this process wants applied, so waiting is the correct outcome. Where a database
|
|
423
|
+
has nothing like one, a driver that runs one migrator at a time may implement it
|
|
424
|
+
as `run()` and say so.
|
|
425
|
+
|
|
426
|
+
One honest caveat: the core is agnostic by contract, PostgreSQL-first in
|
|
427
|
+
dialect. The `sql` tag emits `$n` placeholders, the bookkeeping tables use
|
|
428
|
+
`TIMESTAMPTZ`/`now()`, and `reset` speaks `DROP SCHEMA … CASCADE`. A driver for
|
|
429
|
+
another database is absolutely writable — its `query` translates what its
|
|
430
|
+
database spells differently — but that translation is the driver's job until a
|
|
431
|
+
second first-party driver moves the seams here.
|
|
432
|
+
|
|
433
|
+
## License
|
|
434
|
+
|
|
435
|
+
[MIT](LICENSE)
|
package/bin/migrane.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
':' //; f=; case "$1" in --runtime=*) f=${1#--runtime=}; shift;; esac; for r in ${f:-${MIGRANE_RUNTIME:-node bun}}; do command -v "$r" >/dev/null 2>&1 && exec "$r" "$0" "$@"; done; echo 'migrane: needs node or bun on PATH' >&2; exit 1
|
|
3
|
+
|
|
4
|
+
import { run } from '../dist/cli.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `migrane` executable. Argument handling lives in `src/cli.ts`; this file
|
|
8
|
+
* turns its answer into an exit code.
|
|
9
|
+
*
|
|
10
|
+
* Plain JavaScript rather than part of the build, because `cli.ts` is also a
|
|
11
|
+
* library export: an entrypoint runs on import, and what `index.ts` re-exports
|
|
12
|
+
* must never do that.
|
|
13
|
+
*
|
|
14
|
+
* ## Line 2 is sh and JavaScript at once
|
|
15
|
+
*
|
|
16
|
+
* `#!/usr/bin/env node` makes this package unusable on a bun-only machine — the
|
|
17
|
+
* kernel reads the shebang, so nothing of ours runs and no error of ours can
|
|
18
|
+
* explain why. Naming `sh` instead lets the line below pick a runtime and
|
|
19
|
+
* `exec` it on this same file.
|
|
20
|
+
*
|
|
21
|
+
* sh reads `:` called with the argument `//`, then a loop. JavaScript reads the
|
|
22
|
+
* string `':'` and a `//` comment swallowing the rest — which is why the whole
|
|
23
|
+
* dispatch has to stay on one line. Both runtimes strip a `#!` first line, so
|
|
24
|
+
* what arrives is a valid module.
|
|
25
|
+
*
|
|
26
|
+
* **`.prettierignore` names this file, and that is load-bearing.** Prettier
|
|
27
|
+
* reformats the line to `':'; //;`, which is still valid JavaScript and no
|
|
28
|
+
* longer valid sh: `:` loses its argument, the shell tries to run `//`, and
|
|
29
|
+
* every command prints `//: is a directory` before working.
|
|
30
|
+
*
|
|
31
|
+
* ## Which runtime, and why node first
|
|
32
|
+
*
|
|
33
|
+
* `migrane --runtime=bun up` names it per command, `MIGRANE_RUNTIME=bun` for
|
|
34
|
+
* the shell, and the flag wins. It must come first and use the `=` form: this
|
|
35
|
+
* line reads exactly one argument, because scanning the whole list would take
|
|
36
|
+
* more shell than fits in a JavaScript comment. It never reaches `run()`, which
|
|
37
|
+
* is why the usage `cli.ts` prints does not list it.
|
|
38
|
+
*
|
|
39
|
+
* Node is tried first as a compatibility promise, not a preference — a machine
|
|
40
|
+
* holding both behaved as node before this line existed. The two differ in one
|
|
41
|
+
* way that matters: bun loads `.env` from the *working directory*, while
|
|
42
|
+
* everything else here resolves against the config file, so `cd apps/api &&
|
|
43
|
+
* migrane up` can pick up a different `.env` than the repository root. A
|
|
44
|
+
* variable already set in the environment still wins, so CI and containers are
|
|
45
|
+
* unaffected. See the README for the full trade and `bun --no-env-file`.
|
|
46
|
+
*
|
|
47
|
+
* ## The cost is Windows
|
|
48
|
+
*
|
|
49
|
+
* npm writes the `.cmd` shim from this shebang, so it emits one calling `sh`:
|
|
50
|
+
* present under Git Bash, absent otherwise. `#!/bin/sh` would emit a literal
|
|
51
|
+
* path cmd.exe can never resolve, so the `env` form degrades rather than dies.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
process.exit(await run(process.argv.slice(2)));
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Resolved } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Writing the manifest a bundler can follow.
|
|
4
|
+
*
|
|
5
|
+
* Discovery is a directory read and loading is `import(file)` or
|
|
6
|
+
* `readFileSync`; no bundler follows any of them, and an image has no
|
|
7
|
+
* directories. So a build calls this and bundles what it writes. The lists come
|
|
8
|
+
* from `discover()` and `unitsIn()`, so an image applies the same units in the
|
|
9
|
+
* same order and records the same checksums as the CLI.
|
|
10
|
+
*
|
|
11
|
+
* **Data, not a program**: imports and two arrays, calling nothing. What to do
|
|
12
|
+
* with the arrays is the consumer's entry, which is ordinary source in their
|
|
13
|
+
* repository rather than generated text no linter reaches.
|
|
14
|
+
*/
|
|
15
|
+
export interface EntryOptions {
|
|
16
|
+
/** The resolved config. Read for its directories, never imported. */
|
|
17
|
+
config: Resolved;
|
|
18
|
+
/**
|
|
19
|
+
* Where the manifest will be written. Given one, every specifier is relative
|
|
20
|
+
* to it, so the file resolves the same on any machine; omitted, specifiers
|
|
21
|
+
* are absolute, which only suits a file going to a temporary directory.
|
|
22
|
+
*/
|
|
23
|
+
to?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare const entryFor: ({ config, to }: EntryOptions) => string;
|
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, relative, sep } from 'node:path';
|
|
3
|
+
import { discover, unitsIn } from './discover.js';
|
|
4
|
+
/**
|
|
5
|
+
* POSIX separators unconditionally: `relative` answers with backslashes on
|
|
6
|
+
* Windows, and a backslash in an import specifier is an escape.
|
|
7
|
+
*/
|
|
8
|
+
const specifierFor = (file, to) => {
|
|
9
|
+
if (!to)
|
|
10
|
+
return file;
|
|
11
|
+
const path = relative(dirname(to), file).split(sep).join('/');
|
|
12
|
+
return path.startsWith('.') ? path : `./${path}`;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* SQL carried as text rather than imported, because a `.sql` migration is
|
|
16
|
+
* loaded with `readFileSync` and never `import`. Inlining is what that becomes
|
|
17
|
+
* when there is no file to read, byte-for-byte, so the checksum still describes
|
|
18
|
+
* what the image runs.
|
|
19
|
+
*
|
|
20
|
+
* A template literal so newlines survive; three escapes make it reversible.
|
|
21
|
+
*/
|
|
22
|
+
const inlined = (file) => '`' +
|
|
23
|
+
readFileSync(file, 'utf8')
|
|
24
|
+
.replaceAll('\\', '\\\\')
|
|
25
|
+
.replaceAll('`', '\\`')
|
|
26
|
+
.replaceAll('${', '\\${') +
|
|
27
|
+
'`';
|
|
28
|
+
/**
|
|
29
|
+
* One array's worth of entries: the imports its `.ts` parts need, and a row per
|
|
30
|
+
* unit naming its parts in run order.
|
|
31
|
+
*
|
|
32
|
+
* Parts stay an ordered list of either kind, because a directory without an
|
|
33
|
+
* index may hold both and the order is the dependency order. Composing them is
|
|
34
|
+
* `fromManifest`'s job, which keeps this file importing nothing from the runner.
|
|
35
|
+
*/
|
|
36
|
+
const emit = (found, prefix, to) => {
|
|
37
|
+
const imports = [];
|
|
38
|
+
const rows = [];
|
|
39
|
+
found.forEach((entry, index) => {
|
|
40
|
+
const parts = entry.run.map((file, part) => {
|
|
41
|
+
if (extname(file) === '.sql')
|
|
42
|
+
return `{ sql: ${inlined(file)} }`;
|
|
43
|
+
const binding = `${prefix}${index}_${part}`;
|
|
44
|
+
imports.push(`import * as ${binding} from ${JSON.stringify(specifierFor(file, to))};`);
|
|
45
|
+
return binding;
|
|
46
|
+
});
|
|
47
|
+
rows.push(` { name: ${JSON.stringify(entry.name)}, sequence: ${entry.sequence}, checksum: ${JSON.stringify(entry.checksum)}, parts: [${parts.join(', ')}] },`);
|
|
48
|
+
});
|
|
49
|
+
return { imports, rows };
|
|
50
|
+
};
|
|
51
|
+
const arrayOf = (name, rows) => rows.length
|
|
52
|
+
? [`export const ${name} = [`, ...rows, '];']
|
|
53
|
+
: [`export const ${name} = [];`];
|
|
54
|
+
export const entryFor = ({ config, to }) => {
|
|
55
|
+
const migrations = emit(discover(config.dirs), 'm', to);
|
|
56
|
+
// A consumer with no seeds declares none, and gets an empty array rather than
|
|
57
|
+
// a missing export: an entry that destructures both should not have to know
|
|
58
|
+
// which of them this project happens to have.
|
|
59
|
+
const seeds = emit(config.seeds ? unitsIn(config.seeds) : [], 's', to);
|
|
60
|
+
return [
|
|
61
|
+
...migrations.imports,
|
|
62
|
+
...seeds.imports,
|
|
63
|
+
...(migrations.imports.length || seeds.imports.length ? [''] : []),
|
|
64
|
+
...arrayOf('migrations', migrations.rows),
|
|
65
|
+
'',
|
|
66
|
+
...arrayOf('seeds', seeds.rows),
|
|
67
|
+
'',
|
|
68
|
+
].join('\n');
|
|
69
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exit code is the contract a deploy reads: a one-shot migrate container
|
|
3
|
+
* that exits non-zero holds the previous release in place rather than starting
|
|
4
|
+
* a server against a schema that never got written.
|
|
5
|
+
*
|
|
6
|
+
* A code, never a sentence — what lets a consumer's tooling go fully through
|
|
7
|
+
* the CLI and still distinguish outcomes by contract instead of matching
|
|
8
|
+
* stderr:
|
|
9
|
+
*
|
|
10
|
+
* | code | meaning |
|
|
11
|
+
* | ---- | ------------------------------------------------ |
|
|
12
|
+
* | 0 | ok |
|
|
13
|
+
* | 1 | failure |
|
|
14
|
+
* | 2 | refused: a migration changed after it was applied |
|
|
15
|
+
* | 3 | refused: the guard said no |
|
|
16
|
+
*/
|
|
17
|
+
export declare const run: (argv: readonly string[], cwd?: string) => Promise<number>;
|