@softure-ai/db 0.1.5 → 0.1.7

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +261 -22
  3. package/dist/cli/run.d.ts +8 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +71 -21
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/client.d.ts +39 -8
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +64 -8
  10. package/dist/client.js.map +1 -1
  11. package/dist/configured.d.ts +31 -0
  12. package/dist/configured.d.ts.map +1 -0
  13. package/dist/configured.js +74 -0
  14. package/dist/configured.js.map +1 -0
  15. package/dist/driver-errors.d.ts +21 -0
  16. package/dist/driver-errors.d.ts.map +1 -0
  17. package/dist/driver-errors.js +25 -0
  18. package/dist/driver-errors.js.map +1 -0
  19. package/dist/index.d.ts +6 -3
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -3
  22. package/dist/index.js.map +1 -1
  23. package/dist/migrations/adopt.d.ts +8 -1
  24. package/dist/migrations/adopt.d.ts.map +1 -1
  25. package/dist/migrations/adopt.js +53 -87
  26. package/dist/migrations/adopt.js.map +1 -1
  27. package/dist/migrations/apply.d.ts +35 -0
  28. package/dist/migrations/apply.d.ts.map +1 -0
  29. package/dist/migrations/apply.js +115 -0
  30. package/dist/migrations/apply.js.map +1 -0
  31. package/dist/migrations/migrator.d.ts +55 -27
  32. package/dist/migrations/migrator.d.ts.map +1 -1
  33. package/dist/migrations/migrator.js +187 -103
  34. package/dist/migrations/migrator.js.map +1 -1
  35. package/dist/migrations/problems.d.ts +9 -0
  36. package/dist/migrations/problems.d.ts.map +1 -1
  37. package/dist/migrations/problems.js +9 -3
  38. package/dist/migrations/problems.js.map +1 -1
  39. package/dist/migrations/reference.d.ts +23 -0
  40. package/dist/migrations/reference.d.ts.map +1 -0
  41. package/dist/migrations/reference.js +64 -0
  42. package/dist/migrations/reference.js.map +1 -0
  43. package/dist/migrations/session.d.ts +2 -1
  44. package/dist/migrations/session.d.ts.map +1 -1
  45. package/dist/migrations/session.js +19 -2
  46. package/dist/migrations/session.js.map +1 -1
  47. package/dist/migrations/stubs.d.ts +23 -0
  48. package/dist/migrations/stubs.d.ts.map +1 -0
  49. package/dist/migrations/stubs.js +99 -0
  50. package/dist/migrations/stubs.js.map +1 -0
  51. package/dist/process.d.ts +23 -0
  52. package/dist/process.d.ts.map +1 -0
  53. package/dist/process.js +82 -0
  54. package/dist/process.js.map +1 -0
  55. package/dist/shared.d.ts +2 -0
  56. package/dist/shared.d.ts.map +1 -1
  57. package/dist/shared.js +9 -0
  58. package/dist/shared.js.map +1 -1
  59. package/dist/testing.d.ts +11 -2
  60. package/dist/testing.d.ts.map +1 -1
  61. package/dist/testing.js +28 -9
  62. package/dist/testing.js.map +1 -1
  63. package/package.json +22 -5
  64. package/src/cli/run.ts +84 -23
  65. package/src/client.ts +88 -16
  66. package/src/configured.ts +109 -0
  67. package/src/driver-errors.ts +40 -0
  68. package/src/index.ts +12 -1
  69. package/src/migrations/adopt.ts +65 -100
  70. package/src/migrations/apply.ts +135 -0
  71. package/src/migrations/migrator.ts +246 -128
  72. package/src/migrations/problems.ts +13 -5
  73. package/src/migrations/reference.ts +78 -0
  74. package/src/migrations/session.ts +28 -2
  75. package/src/migrations/stubs.ts +139 -0
  76. package/src/process.ts +110 -0
  77. package/src/shared.ts +9 -0
  78. package/src/testing.ts +38 -10
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ Newest first. Each version lists what changed for an app that uses `@softure-ai/db`. When an app has run a version in
4
+ production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
5
+ "Definition of done"). Versions before the first one below are described in their GitHub Releases (`db@x.y.z`).
6
+
7
+ ## 0.1.7
8
+
9
+ - `createProcessDatabase(url, { schema, max })` returns `{ db, open, close, withDatabase, isOpen }`: a module-level,
10
+ synchronous `db` over the process-wide handle for the URL. A query before `open()` throws `Database is not open`;
11
+ `withDatabase(main)` opens, runs and closes for a script. `url` may be a function, read on `open()` (#313).
12
+ - `findDriverError(error)` (`{ code, constraint, message }` of the Postgres error under drizzle's wrapper) and
13
+ `isConstraintViolation(error, { code, constraint })`, so apps and modules stop copying them (#313).
14
+ - `closeSharedDatabase(url)` closes and forgets one shared handle.
15
+
16
+ ## 0.1.6
17
+
18
+ - One database handle per process: `getConfiguredDatabase` opens the config's `database.handle` or URL once, and modules, health route and commands share it.
19
+ - App migrations run around module migrations (`before` and `after` hooks).
20
+ - Adoption: app baselines, `--adopt <module>@<version> --through <n>`, dependencies adopted first, and modules whose SQL references app tables (stub tables).
21
+ - **Breaking:** `pg` and `@electric-sql/pglite` are optional peer dependencies, no longer dependencies. An app installs
22
+ the driver its `DATABASE_URL` uses and lists `"@softure-ai/db"` and that driver in `serverExternalPackages`
23
+ ([README §2](README.md#2-installation)); a URL whose driver is missing fails with a message naming the package.
24
+ - A migrate bundle keeps drizzle's driver adapters external (`--external:drizzle-orm/pglite`,
25
+ `--external:drizzle-orm/node-postgres`).
package/README.md CHANGED
@@ -1,16 +1,15 @@
1
1
  # @softure-ai/db
2
2
 
3
- **Status:** wave 0 · implemented in FD-4 (`db-migrator`), 0.1.5, prepared for its first release (the owner's tag, `scripts/release/README.md`).
4
-
5
3
  The database layer every SOFTURE module stands on. Standard:
6
- [docs/02-module-standard.md](../../docs/02-module-standard.md) §4. Sources in FIRE_TRACKER:
7
- `src/db/client.ts`, `src/db/test-db.ts`, `scripts/migrate.ts`, `docker/Dockerfile` (`migrate.cjs`).
4
+ [docs/02-module-standard.md](../../docs/02-module-standard.md) §4.
8
5
 
9
6
  ## 1. What it provides
10
7
 
11
8
  A pg/PGlite client chosen by URL, a migrator that runs each module's SQL in the module's own
12
9
  Postgres schema (with a ledger, checksums, a lock, a dry run and adoption of existing tables),
13
- `createTestDatabase` for unit tests, and the `softure migrate` command.
10
+ `createTestDatabase` for unit tests, the `softure migrate` command, a module-level process database
11
+ (`createProcessDatabase`) and helpers that read the Postgres error behind a failed query
12
+ (`findDriverError`, `isConstraintViolation`).
14
13
 
15
14
  ## 2. Installation
16
15
 
@@ -18,9 +17,49 @@ Postgres schema (with a ledger, checksums, a lock, a dry run and adoption of exi
18
17
  npm install @softure-ai/db drizzle-orm
19
18
  ```
20
19
 
21
- Node ≥ 22, ESM only. `drizzle-orm` (`^0.45.2`) is a peer dependency, so the app, the modules and
22
- this package share one drizzle. `pg` and `@electric-sql/pglite` come with the package and load
23
- only when used.
20
+ Node ≥ 22, **ESM only**: module packages locate their migrations through `import.meta.url`, which a
21
+ CJS bundle (esbuild `--format=cjs`) leaves empty, so `resolveMigrationsDir` throws naming the bundle
22
+ format; the app script below also uses top-level await. Bundle migrate scripts with `--format=esm`.
23
+ `drizzle-orm` (`^0.45.2`) is a peer dependency, so the app, the modules and
24
+ this package share one drizzle.
25
+
26
+ The drivers are optional peers: install the one the app's URL uses, and only that one goes into the
27
+ app image. Each loads only when a URL needs it.
28
+
29
+ ```bash
30
+ npm install pg # postgres:// and postgresql://
31
+ npm install @electric-sql/pglite # pglite:// (dev without a server)
32
+ npm install -D @electric-sql/pglite # a Postgres app's createTestDatabase, --adopt and baseline
33
+ npm install -D @types/pg # only with skipLibCheck: false
34
+ ```
35
+
36
+ | Needs | `pg` (`^8.11.0`) | `@electric-sql/pglite` (`^0.5.0`) |
37
+ | --- | --- | --- |
38
+ | `postgres://`, `postgresql://` | yes | no |
39
+ | `pglite://` | no | yes |
40
+ | `createTestDatabase` (`@softure-ai/db/testing`) | no | yes (dev) |
41
+ | `softure migrate --adopt`, an app `baseline` (compare against a scratch PGlite) | URL's driver | yes |
42
+
43
+ A URL whose driver is missing fails at the first connection: `createDatabase: postgres:// URLs need
44
+ the "pg" package, which is not installed; …`, with Node's error as `cause`.
45
+
46
+ **Next.js.** List this package and the driver in `serverExternalPackages`:
47
+
48
+ ```ts
49
+ // next.config.ts
50
+ const nextConfig: NextConfig = {
51
+ serverExternalPackages: ["@softure-ai/db", "pg"], // or "@electric-sql/pglite" for pglite://
52
+ };
53
+ ```
54
+
55
+ Bundled, the package's `import()` of each driver and drizzle adapter is resolved at build time, so
56
+ `next build` fails with `Can't resolve '@electric-sql/pglite'` in an app without PGlite (drizzle's
57
+ adapter imports it statically). External, it loads from `node_modules` at run time, and standalone
58
+ output tracing follows the same imports, so `.next/standalone` carries the installed driver and
59
+ drizzle and nothing else (the example app installs `pg` only:
60
+ [`next.config.ts`](../../examples/next-app/next.config.ts)). The config and the shared handles live on `globalThis`, so this copy and the modules bundled next
61
+ to it share them. Hiding the imports from the bundler instead (`turbopackIgnore`) builds, but
62
+ tracing skips the driver and the image fails at the first query.
24
63
 
25
64
  ## 3. Configuration
26
65
 
@@ -42,11 +81,110 @@ await handle.close();
42
81
  ```
43
82
 
44
83
  - `DatabaseHandle` is `{ kind: "postgres", db, pool, close } | { kind: "pglite", db, client, close }`.
45
- - `Database` is the drizzle database of either driver; `Queryable` is a `Database` or an open
46
- transaction, so one helper serves both (`db.transaction(async (tx) => helper(tx))`).
84
+ - `Database<TSchema>` is the drizzle database of either driver; `Queryable<TSchema>` is a `Database`
85
+ or an open transaction, so one helper serves both (`db.transaction(async (tx) => helper(tx))`).
86
+ Without a type argument both accept any schema, so an app database made with
87
+ `drizzle({ client, schema })`, and its transactions, pass to module functions as they are.
47
88
  - An unsupported scheme throws, naming the scheme only; the URL (and its password) is never
48
89
  printed.
49
90
 
91
+ ### One handle per process
92
+
93
+ Modules open their database through the config, never on their own: the app's handle when
94
+ `database.handle` is set, otherwise one process-wide handle per URL (`getSharedDatabase(url)`). An
95
+ app that also has its own client must make sure both are the same handle. A second handle is a second
96
+ pool on Postgres; on `pglite://` it is a second PGlite instance on the same directory, which corrupts
97
+ it without an error (the next open fails with `could not locate a valid checkpoint record`).
98
+
99
+ **Either** hand the app's client to the config. The function is called on first use, never while the
100
+ config is defined (a build does not connect), and must return the app's one handle:
101
+
102
+ ```ts
103
+ // db/client.ts: the app's own client, memoized: `next dev` re-evaluates modules on every change
104
+ import { PGlite } from "@electric-sql/pglite";
105
+ import { createPgliteHandle, createPostgresHandle, type DatabaseHandle } from "@softure-ai/db";
106
+ import pg from "pg";
107
+
108
+ const cache = globalThis as typeof globalThis & { appDatabase?: Promise<DatabaseHandle> };
109
+
110
+ export function getAppDatabase(url: string): Promise<DatabaseHandle> {
111
+ cache.appDatabase ??= url.startsWith("pglite://")
112
+ ? createPgliteHandle(new PGlite(url.slice("pglite://".length)))
113
+ : createPostgresHandle(new pg.Pool({ connectionString: url }).on("error", () => undefined));
114
+ return cache.appDatabase;
115
+ }
116
+
117
+ // softure.config.ts
118
+ const url = process.env.DATABASE_URL ?? "";
119
+ defineSoftureConfig({ database: { url, handle: () => getAppDatabase(url) }, /* … */ });
120
+ ```
121
+
122
+ Every module adapter, the ops health route, `softure migrate` (and the app's migration hooks it
123
+ runs), `softure-blog`, `softure-mail` and ops scripts then use that handle; a command closes it
124
+ when it is done, which writes a `pglite://` directory back.
125
+
126
+ **Or** build the app's typed drizzle instance over the shared handle, so there is nothing to hand
127
+ over:
128
+
129
+ ```ts
130
+ import { getSharedDatabase } from "@softure-ai/db";
131
+ import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
132
+ import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
133
+ import * as schema from "./schema";
134
+
135
+ export async function getDb() {
136
+ const handle = await getSharedDatabase(config.database.url);
137
+ return handle.kind === "postgres" ? drizzlePg({ client: handle.pool, schema }) : drizzlePglite({ client: handle.client, schema });
138
+ }
139
+ ```
140
+
141
+ `getConfiguredDatabase(config.database)` is what modules call; app code that needs the database
142
+ the modules use can call it too. A migration run on a PGlite handle the app shares keeps the app's
143
+ session settings (`TimeZone`, `search_path`); only what the run changed is put back.
144
+
145
+ ### A module-level `db`
146
+
147
+ An app whose code imports one `db` everywhere, instead of awaiting a handle in each function,
148
+ creates it once with `createProcessDatabase`. `db` stands in for the shared handle's drizzle instance
149
+ (the one `getSharedDatabase(url)` gives the modules, so there is still one pool); every entry point
150
+ opens it first, and a query before that throws `Database is not open`:
151
+
152
+ ```ts
153
+ // db/index.ts
154
+ import { createProcessDatabase } from "@softure-ai/db";
155
+ import * as schema from "./schema";
156
+
157
+ // A function: read on open(), so a script may load its environment after importing this file.
158
+ export const { db, open, close, withDatabase } = createProcessDatabase(() => process.env.DATABASE_URL ?? "", { schema });
159
+
160
+ // instrumentation.ts (the server): open once at startup
161
+ export async function register() {
162
+ if (process.env.NEXT_RUNTIME === "nodejs") await (await import("./db")).open();
163
+ }
164
+
165
+ // scripts/import.ts: opens, runs, closes (also when main throws)
166
+ await withDatabase(async (db) => { /* … */ });
167
+ ```
168
+
169
+ `open()` is idempotent; `close()` closes the shared handle for that URL, which the modules use too,
170
+ so only a script's end calls it. With `schema`, `db.query.<table>` is typed and works.
171
+
172
+ ### Driver errors
173
+
174
+ `findDriverError(error)` returns `{ code, constraint, message }` of the Postgres error behind a failed
175
+ query (node-postgres and PGlite alike; drizzle wraps it as the `cause`), or `undefined`.
176
+ `isConstraintViolation(error, { code, constraint })` answers whether it is that SQLSTATE on that
177
+ constraint (any constraint when `constraint` is left out):
178
+
179
+ ```ts
180
+ try {
181
+ await db.insert(articles).values(article);
182
+ } catch (error) {
183
+ if (!isConstraintViolation(error, { code: "23505", constraint: "articles_slug_key" })) throw error;
184
+ return err("slug_taken");
185
+ }
186
+ ```
187
+
50
188
  ## 4. Mounting
51
189
 
52
190
  Nothing to mount. Migrations run as a deploy step:
@@ -69,31 +207,117 @@ process.exitCode = await runMigrateCli({ config, argv: process.argv.slice(2) });
69
207
  # build stage: copy each module's SQL next to the bundle, then bundle the runner
70
208
  RUN npx tsx scripts/migrate.ts --export-migrations ./softure-migrations
71
209
  RUN npx esbuild scripts/migrate.ts --bundle --platform=node --format=esm --target=node22 \
72
- --external:pg --external:@electric-sql/pglite --outfile=migrate.mjs
73
- # run stage (needs pg, and @electric-sql/pglite for --adopt)
210
+ --external:pg --external:@electric-sql/pglite \
211
+ --external:drizzle-orm/pglite --external:drizzle-orm/node-postgres --outfile=migrate.mjs
212
+ # run stage (needs the URL's driver, and @electric-sql/pglite for --adopt or an app baseline)
74
213
  COPY --from=builder /app/migrate.mjs ./migrate.mjs
75
214
  COPY --from=builder /app/softure-migrations ./softure-migrations
76
215
  CMD ["node", "migrate.mjs", "--migrations-dir", "./softure-migrations"]
77
216
  ```
78
217
 
218
+ A config that imports `server-only` throws under plain Node: run the export as
219
+ `npx tsx --conditions=react-server scripts/migrate.ts --export-migrations …` and bundle with
220
+ `--alias:server-only=./scripts/empty.mjs` (an empty file). Path aliases (`@/…`) need nothing extra: tsx and
221
+ esbuild both read `paths` from `tsconfig.json`. The [ops container recipe](../../modules/ops/README.md#container-recipe)
222
+ uses the same commands.
223
+
224
+ The drivers and drizzle's two driver adapters stay external: bundled, the adapter of a driver the app
225
+ does not install imports it at the top of `migrate.mjs`, which then fails at start. At run time they
226
+ come from the standalone output, which carries the installed driver and its adapter.
227
+
79
228
  A bundle cannot find package folders (`import.meta.url` points at the bundle), hence
80
- `--export-migrations` at build time and `--migrations-dir` at run time. The ledger's own migration
229
+ `--export-migrations` at build time and `--migrations-dir` at run time. The build stage needs no
230
+ `DATABASE_URL`: the config accepts an empty `database.url` (`DATABASE_URL ?? ""`) and only a command that
231
+ connects refuses it. The ledger's own migration
81
232
  ships as code.
82
233
 
234
+ **The app's own migrations** run in the same step, through hooks. `before` runs ahead of the module
235
+ files (app tables a module references, normally the app's whole history), `after` behind them (app
236
+ migrations that reference a table a module creates). With drizzle, define them once and pass the same
237
+ object to the migrate script and to `createTestDatabase`:
238
+
239
+ ```ts
240
+ // db/app-migrations.ts
241
+ import type { AppMigrations, DatabaseHandle } from "@softure-ai/db";
242
+
243
+ async function runDrizzle(handle: DatabaseHandle, migrationsFolder: string): Promise<void> {
244
+ if (handle.kind === "postgres") {
245
+ const { migrate } = await import("drizzle-orm/node-postgres/migrator");
246
+ await migrate(handle.db, { migrationsFolder });
247
+ } else {
248
+ const { migrate } = await import("drizzle-orm/pglite/migrator");
249
+ await migrate(handle.db, { migrationsFolder });
250
+ }
251
+ }
252
+
253
+ export const appMigrations: AppMigrations = {
254
+ before: (handle) => runDrizzle(handle, "./drizzle"),
255
+ // only when an app migration needs a module table: a second drizzle folder
256
+ // after: (handle) => runDrizzle(handle, "./drizzle-after"),
257
+ };
258
+
259
+ // scripts/migrate.ts
260
+ process.exitCode = await runMigrateCli({ config, argv: process.argv.slice(2), app: appMigrations });
261
+ ```
262
+
263
+ - Order of a run: the ledger, `before`, the module files in dependency order, `after`. Everything
264
+ runs under the migration lock, after every module file passed its checks; nothing runs when a check
265
+ fails. The hooks run on every run, so they must skip what they already applied (drizzle does).
266
+ - A hook that throws is reported as `db.app_migration_failed` with the error and its causes (drizzle
267
+ puts the Postgres message in `cause`). A failing `before` stops the run before any module file; a
268
+ failing `after` leaves the module files applied.
269
+ - `--plan` runs no hook (it says so); `--adopt` without `--plan` runs `before` first, so the app
270
+ migration that moves a table into the module's schema and the adoption are one command.
271
+
272
+ **A baseline: fresh databases after an adoption.** The app keeps its old migrations, so every fresh
273
+ database (the unit-test template, an integration stack, a reset dev database) recreates the tables it
274
+ moved into a module's schema before the module files run. `baseline` names, per module, the last module
275
+ file the app's own history creates; `migrate` then adopts or migrates by itself, on every database:
276
+
277
+ ```ts
278
+ export const appMigrations: AppMigrations = {
279
+ before: (handle) => runDrizzle(handle, "./drizzle"),
280
+ // the app's history creates auth's 0001 (users, sessions); 0002.. are new to it
281
+ baseline: { auth: 1 },
282
+ };
283
+ ```
284
+
285
+ - For a listed module the ledger has never seen, after `before` and after the modules it depends on:
286
+ an empty schema is migrated normally; otherwise the schema is compared with what files 1..n create
287
+ (as `--adopt` does), files 1..n are recorded as `adopted` and the later files are applied. A
288
+ difference stops the run (`db.schema_mismatch`, every difference printed); nothing of that module is
289
+ recorded and `after` does not run.
290
+ - Once the module is in the ledger the entry has no effect, so the same command serves every deploy.
291
+ Keep the number when the module ships new files: it describes the app's history, which no longer
292
+ changes.
293
+ - Only a prefix 1..n can be adopted (the ledger refuses a file numbered below an applied one): when the
294
+ app's tables already carry a later file's change, align them in the app's own migration or raise n.
295
+ - A module that is not enabled, has no schema, or an n outside 1..files refuses the run before anything
296
+ runs. `--plan` marks the files a baseline may adopt (`adopted if its schema already holds objects`).
297
+ - The comparison builds the reference on a scratch PGlite, only when a module is actually adopted,
298
+ with stubs of the app tables `before` created (section 5, adoption).
299
+ - Under node-postgres a hook queries through the pool while the migrator holds one connection, so the
300
+ pool needs at least two (`createDatabase`'s default is 10).
301
+ - The `softure` bin reads only the config; an app with its own migrations uses the script above.
302
+ - In a bundle, the drizzle folder must be copied next to the bundle, as the module files are.
303
+
83
304
  **Options of `softure migrate`:**
84
305
 
85
306
  | Option | Effect |
86
307
  | --- | --- |
87
308
  | (none) | apply every pending migration |
88
309
  | `--plan` | print what would be applied (or adopted); change nothing |
89
- | `--adopt <module>@<version>` | record an existing schema as migrated after comparing it (section 5) |
310
+ | `--adopt <module>@<version>` | record an existing schema as migrated after comparing it, after applying the pending migrations of its dependencies (section 5) |
311
+ | `--through <n>` | with `--adopt`: adopt files 1..n only; the next migrate applies the rest |
90
312
  | `--migrations-dir <dir>` | read module files from `<dir>/<module id>/` |
91
313
  | `--export-migrations <dir>` | copy module files to `<dir>/<module id>/` and exit; needs no database; replaces older `.sql` copies and refuses a module folder holding anything else |
92
314
 
93
315
  Exit codes: 0 done, 1 a problem or failure (each printed on stderr), 2 a usage error.
94
316
 
95
- The same operations as functions: `migrate(handle, { modules, migrationsDir?, onApplied? })`,
96
- `planMigrations(handle, { modules })`, `adoptModule(handle, { modules, module, version, dryRun? })`,
317
+ The same operations as functions: `migrate(handle, { modules, migrationsDir?, app?, onApplied?, onAdopted?, onAppMigrated? })`
318
+ (`runAppMigrations(handle, app, phase)` runs one hook alone under the lock),
319
+ `planMigrations(handle, { modules, app? })` (`pending`, and `baseline`: the pending files a baseline may adopt),
320
+ `adoptModule(handle, { modules, module, version, through?, dryRun? })`,
97
321
  `exportMigrations(modules, dir)`. Each returns `{ ok: true, value } | { ok: false, error, problems }`;
98
322
  `describeProblem(problem)` gives the English line.
99
323
 
@@ -118,16 +342,16 @@ module_version, method ('applied' | 'adopted'), applied_at`, primary key `(modul
118
342
  `SET LOCAL search_path TO <schema>, public` first. Name other modules' tables with their schema
119
343
  (`REFERENCES notes.notes (id)`), and create extensions `WITH SCHEMA public`.
120
344
 
121
- **Each run:** modules in dependency order (`sortModulesByDependencies`), files in number order.
345
+ **Each run:** the ledger, the app's `before` hook, modules in dependency order (`sortModulesByDependencies`), files in number order.
122
346
  Before anything runs, every file of every module is checked: an applied file that was edited or
123
347
  renamed (`db.migration_changed`), deleted (`db.migration_missing`), or a new file numbered below an
124
348
  applied one (`db.migration_out_of_order`) refuses the whole run. The run holds a session-level
125
349
  `pg_advisory_lock`, so a second runner waits, then finds nothing to do. A failing file is rolled
126
350
  back completely and stops the run; the files before it stay applied. The migration connection is
127
- closed after the run, never returned to the pool.
351
+ closed after the run, never returned to the pool. The app's `after` hook runs last (section 4).
128
352
 
129
353
  **Refused modules:** the id `softure`; the schemas `softure`, `public`, `information_schema`,
130
- `pg_*` and names over 63 bytes; migrations without a `dbSchema`.
354
+ `drizzle` (drizzle's ledger in every app), `pg_*` and names over 63 bytes; migrations without a `dbSchema`.
131
355
 
132
356
  **Adoption** ([docs/05](../../docs/05-adoption-playbook.md) step 3): after the app's own migration
133
357
  moved its tables into the module's schema, `--adopt <module>@<version>` (with `--plan` first)
@@ -136,14 +360,26 @@ one through `pg_catalog`: relations (and whether they are unlogged), columns (ty
136
360
  not null, default, identity, generated), sequences with their parameters, constraints and indexes
137
361
  (names and definitions), triggers, functions, and enum, domain, range and composite types. Only an
138
362
  exact match records the files as `adopted`; every difference is printed (`missing in database: …`,
139
- `unexpected in database: …`). The version must equal the enabled module's, the module must be new
140
- to the ledger, and the modules it depends on must be fully migrated or adopted first. Undo:
363
+ `unexpected in database: …`). `--through <n>` compares with and adopts files 1..n only; the next
364
+ migrate applies the rest. The version must equal the enabled module's and the module must be new to
365
+ the ledger. The pending migrations of the modules it depends on are applied first (listed as
366
+ `apply` lines; a dry run applies nothing); when one fails because its tables exist too, adopt that
367
+ module first. Module SQL may reference the app's own tables (`REFERENCES public.app_users (id)`):
368
+ the scratch database first gets stubs of every table outside the ledger, the enabled modules'
369
+ schemas and `drizzle`, copied from the live database (columns with their types, primary keys,
370
+ unique constraints, plain unique indexes and the enum types they use; no defaults, NOT NULL, checks
371
+ or foreign keys). The tables must therefore exist when the comparison runs: `--adopt` and a baseline
372
+ run `before` first, a `--plan` dry run reads only what is already there. A stub that cannot be
373
+ created (a domain or extension type) is skipped, and named in the failure when the module needs it.
374
+ For fresh databases of an app that adopted a module, declare a baseline (section 4). Undo:
141
375
  `DELETE FROM softure.migrations WHERE module = '<id>' AND method = 'adopted'`.
142
376
 
143
377
  **Tests:** `createTestDatabase(modules)` from `@softure-ai/db/testing` returns `{ db, client, close }`:
144
378
  an in-memory PGlite with the ledger and the listed modules migrated (list the module and the
145
379
  modules it depends on). Each module set is migrated once per test worker and copied per call; a
146
- broken migration throws naming the file.
380
+ broken migration throws naming the file. An app with its own migrations passes the same hooks,
381
+ `createTestDatabase(modules, { app: appMigrations })`, so module tables can reference app tables; the
382
+ template is cached per hook object, so import the shared object rather than writing hooks inline.
147
383
 
148
384
  ```ts
149
385
  import { createTestDatabase } from "@softure-ai/db/testing";
@@ -187,4 +423,7 @@ No personal data. The ledger holds module ids, file names and checksums.
187
423
  transaction-mode PgBouncer.
188
424
  - Adoption does not compare grants, comments, column order, row-level security policies or view
189
425
  bodies, and needs PGlite installed where it runs (also in an image that otherwise uses `pg`).
426
+ - The app table stubs of the reference carry no domains, extension types, views or functions: a
427
+ module whose SQL needs one of those from the app cannot be adopted (`db.adopt_reference_failed`
428
+ names the skipped stub objects).
190
429
  - No cached connection for Next.js dev reloads yet; the Next adapter (identity ID-1, FD-7) adds it.
package/dist/cli/run.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SoftureConfig } from "@softure-ai/core";
2
+ import { type AppMigrations } from "../migrations/migrator.js";
2
3
  export interface CliOutput {
3
4
  readonly log: (line: string) => void;
4
5
  readonly error: (line: string) => void;
@@ -10,11 +11,17 @@ export interface RunMigrateCliOptions {
10
11
  /** Relative paths resolve against it. Default: `process.cwd()`. */
11
12
  readonly cwd?: string;
12
13
  readonly output?: CliOutput;
14
+ /**
15
+ * The app's own migrations: migrate runs `before`, the module files (adopting `baseline` where
16
+ * it applies), then `after`; `--adopt` (without `--plan`) runs `before` first; `--plan` runs no
17
+ * hook and marks the files a baseline may adopt.
18
+ */
19
+ readonly app?: AppMigrations;
13
20
  }
14
21
  export declare const EXIT_OK = 0;
15
22
  export declare const EXIT_FAILED = 1;
16
23
  export declare const EXIT_USAGE = 2;
17
- export declare const MIGRATE_USAGE = "Usage: softure migrate [options]\n\nApplies the SQL migrations of every enabled module, each in its own schema.\n\nOptions:\n --plan show what would be applied (or adopted) and change nothing\n --adopt <module>@<version> record an existing schema as migrated, after comparing it\n --migrations-dir <dir> read module files from <dir>/<module id>/ (bundled runners)\n --export-migrations <dir> copy module files to <dir>/<module id>/ and exit (build stage)\n --help show this help";
24
+ export declare const MIGRATE_USAGE = "Usage: softure migrate [options]\n\nApplies the SQL migrations of every enabled module, each in its own schema.\n\nOptions:\n --plan show what would be applied (or adopted) and change nothing\n --adopt <module>@<version> record an existing schema as migrated, after comparing it;\n applies the pending migrations of its dependencies first\n --through <n> with --adopt: adopt files 1..n only, leave the rest to migrate\n --migrations-dir <dir> read module files from <dir>/<module id>/ (bundled runners)\n --export-migrations <dir> copy module files to <dir>/<module id>/ and exit (build stage)\n --help show this help";
18
25
  /** Runs the command and returns the process exit code: 0 done, 1 failed, 2 usage error. */
19
26
  export declare function runMigrateCli(options: RunMigrateCliOptions): Promise<number>;
20
27
  //# sourceMappingURL=run.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAOtD,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,aAAa,EAAE,UAAU,GAAG,SAAS,CAAC,CAAC;IAC7D,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,mEAAmE;IACnE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;CAC7B;AAED,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B,eAAO,MAAM,aAAa,oiBASqB,CAAC;AAehD,2FAA2F;AAC3F,wBAAsB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,MAAM,CAAC,CA0ClF"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAKtD,OAAO,EAA6C,KAAK,aAAa,EAAsB,MAAM,2BAA2B,CAAC;AAG9H,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,aAAa,EAAE,UAAU,GAAG,SAAS,CAAC,CAAC;IAC7D,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,mEAAmE;IACnE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,aAAa,CAAC;CAC9B;AAED,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B,eAAO,MAAM,aAAa,+tBAWqB,CAAC;AAehD,2FAA2F;AAC3F,wBAAsB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,MAAM,CAAC,CA4ClF"}
package/dist/cli/run.js CHANGED
@@ -5,13 +5,15 @@
5
5
  // import { runMigrateCli } from "@softure-ai/db/cli";
6
6
  // import config from "../softure.config";
7
7
  // process.exitCode = await runMigrateCli({ config, argv: process.argv.slice(2) });
8
+ //
9
+ // An app with its own migrations passes them as `app: { before, after }` (see MigrateOptions.app).
8
10
  import { resolve } from "node:path";
9
11
  import { parseArgs } from "node:util";
10
12
  import { pathToFileURL } from "node:url";
11
- import { createDatabase } from "../client.js";
13
+ import { openCommandDatabase } from "../configured.js";
12
14
  import { adoptModule } from "../migrations/adopt.js";
13
15
  import { exportMigrations } from "../migrations/export.js";
14
- import { migrate, planMigrations } from "../migrations/migrator.js";
16
+ import { migrate, planMigrations, runAppMigrations } from "../migrations/migrator.js";
15
17
  import { describeProblem } from "../migrations/problems.js";
16
18
  export const EXIT_OK = 0;
17
19
  export const EXIT_FAILED = 1;
@@ -22,7 +24,9 @@ Applies the SQL migrations of every enabled module, each in its own schema.
22
24
 
23
25
  Options:
24
26
  --plan show what would be applied (or adopted) and change nothing
25
- --adopt <module>@<version> record an existing schema as migrated, after comparing it
27
+ --adopt <module>@<version> record an existing schema as migrated, after comparing it;
28
+ applies the pending migrations of its dependencies first
29
+ --through <n> with --adopt: adopt files 1..n only, leave the rest to migrate
26
30
  --migrations-dir <dir> read module files from <dir>/<module id>/ (bundled runners)
27
31
  --export-migrations <dir> copy module files to <dir>/<module id>/ and exit (build stage)
28
32
  --help show this help`;
@@ -52,22 +56,24 @@ export async function runMigrateCli(options) {
52
56
  result.value.forEach((step) => output.log(`exported ${formatStep(step)}`));
53
57
  return EXIT_OK;
54
58
  }
55
- const url = options.config.database?.url;
56
- if (url === undefined) {
59
+ const database = options.config.database;
60
+ if (database === null) {
57
61
  output.error("softure migrate: the config has no database; set database.url in softure.config");
58
62
  return EXIT_FAILED;
59
63
  }
60
- let handle;
64
+ let opened;
61
65
  try {
62
- handle = await createDatabase(url);
66
+ // The app's configured handle when it set one, so the app's hooks and the run share one instance.
67
+ opened = await openCommandDatabase(database);
63
68
  }
64
69
  catch (error) {
65
70
  output.error(`softure migrate: ${describeError(error)}`);
66
71
  return EXIT_FAILED;
67
72
  }
73
+ const { handle } = opened;
68
74
  try {
69
75
  const migrationsDir = command.migrationsDir === undefined ? undefined : pathToFileURL(`${resolve(cwd, command.migrationsDir)}/`);
70
- return await runDatabaseCommand(handle, { command, modules: options.config.modules, migrationsDir, output });
76
+ return await runDatabaseCommand(handle, { command, modules: options.config.modules, migrationsDir, output, app: options.app ?? {} });
71
77
  }
72
78
  catch (error) {
73
79
  // Connection and driver errors: the message only, never a stack or the database URL.
@@ -75,34 +81,61 @@ export async function runMigrateCli(options) {
75
81
  return EXIT_FAILED;
76
82
  }
77
83
  finally {
78
- await handle.close();
84
+ await opened.close();
79
85
  }
80
86
  }
81
87
  async function runDatabaseCommand(handle, input) {
82
- const { command, modules, output } = input;
88
+ const { command, modules, output, app } = input;
83
89
  const dirOption = input.migrationsDir === undefined ? {} : { migrationsDir: input.migrationsDir };
90
+ const hasAppMigrations = app.before !== undefined || app.after !== undefined;
91
+ if (command.plan && hasAppMigrations) {
92
+ output.log("app migrations: not planned; the app runs them itself on migrate");
93
+ }
84
94
  if (command.kind === "adopt") {
85
- const result = await adoptModule(handle, { modules, module: command.module, version: command.version, dryRun: command.plan, ...dirOption });
95
+ if (!command.plan) {
96
+ const before = await runAppMigrations(handle, app, "before");
97
+ if (!before.ok)
98
+ return reportFailure(output, before);
99
+ if (before.value.ran)
100
+ output.log("applied app migrations (before)");
101
+ }
102
+ const result = await adoptModule(handle, {
103
+ modules,
104
+ module: command.module,
105
+ version: command.version,
106
+ dryRun: command.plan,
107
+ ...(command.through === undefined ? {} : { through: command.through }),
108
+ ...dirOption,
109
+ });
86
110
  if (!result.ok)
87
111
  return reportFailure(output, result);
88
112
  const verb = command.plan ? "would" : "did";
89
- result.value.ledger.forEach((step) => output.log(`${verb} apply ${formatStep(step)}`));
113
+ [...result.value.ledger, ...result.value.dependencies].forEach((step) => output.log(`${verb} apply ${formatStep(step)}`));
90
114
  result.value.adopted.forEach((step) => output.log(`${verb} adopt ${formatStep(step)}`));
91
- output.log(`${command.module}@${command.version}: the schema matches its migrations${command.plan ? " (plan only, nothing written)" : "; adopted"}`);
115
+ const scope = command.through === undefined ? "" : ` through ${formatVersion(command.through)}`;
116
+ output.log(`${command.module}@${command.version}: the schema matches its migrations${scope}${command.plan ? " (plan only, nothing written)" : "; adopted"}`);
92
117
  return EXIT_OK;
93
118
  }
94
119
  if (command.plan) {
95
- const result = await planMigrations(handle, { modules, ...dirOption });
120
+ const result = await planMigrations(handle, { modules, ...dirOption, app });
96
121
  if (!result.ok)
97
122
  return reportFailure(output, result);
98
- result.value.pending.forEach((step) => output.log(`pending ${formatStep(step)}`));
123
+ const baseline = new Set(result.value.baseline);
124
+ result.value.pending.forEach((step) => output.log(`pending ${formatStep(step)}${baseline.has(step) ? " (adopted if its schema already holds objects)" : ""}`));
99
125
  output.log(result.value.pending.length === 0 ? "nothing to apply" : `${result.value.pending.length} migration(s) to apply`);
100
126
  return EXIT_OK;
101
127
  }
102
- const result = await migrate(handle, { modules, ...dirOption, onApplied: (step) => output.log(`applied ${formatStep(step)}`) });
128
+ const result = await migrate(handle, {
129
+ modules,
130
+ ...dirOption,
131
+ app,
132
+ onApplied: (step) => output.log(`applied ${formatStep(step)}`),
133
+ onAdopted: (step) => output.log(`adopted ${formatStep(step)}`),
134
+ onAppMigrated: (phase) => output.log(`applied app migrations (${phase})`),
135
+ });
103
136
  if (!result.ok)
104
137
  return reportFailure(output, result);
105
- output.log(result.value.applied.length === 0 ? "nothing to apply" : `${result.value.applied.length} migration(s) applied`);
138
+ output.log(formatSummary(result.value.applied.length, result.value.adopted.length));
106
139
  return EXIT_OK;
107
140
  }
108
141
  function parseCommand(argv) {
@@ -113,6 +146,7 @@ function parseCommand(argv) {
113
146
  options: {
114
147
  plan: { type: "boolean" },
115
148
  adopt: { type: "string" },
149
+ through: { type: "string" },
116
150
  "migrations-dir": { type: "string" },
117
151
  "export-migrations": { type: "string" },
118
152
  help: { type: "boolean" },
@@ -128,26 +162,42 @@ function parseCommand(argv) {
128
162
  return { kind: "help" };
129
163
  const exportDir = values["export-migrations"];
130
164
  if (exportDir !== undefined) {
131
- const others = ["plan", "adopt", "migrations-dir"].filter((name) => values[name] !== undefined);
165
+ const others = ["plan", "adopt", "through", "migrations-dir"].filter((name) => values[name] !== undefined);
132
166
  return others.length > 0 ? `--export-migrations cannot be combined with --${others.join(", --")}` : { kind: "export", targetDir: exportDir };
133
167
  }
134
168
  const plan = values.plan === true;
135
169
  const migrationsDir = values["migrations-dir"];
136
170
  if (values.adopt === undefined) {
137
- return { kind: "migrate", plan, migrationsDir };
171
+ return values.through === undefined ? { kind: "migrate", plan, migrationsDir } : "--through needs --adopt <module>@<version>";
138
172
  }
139
173
  const match = ADOPT_TARGET.exec(values.adopt);
140
174
  if (match?.[1] === undefined || match[2] === undefined) {
141
175
  return `--adopt expects <module>@<x.y.z>, e.g. auth@0.1.0, got "${values.adopt}"`;
142
176
  }
143
- return { kind: "adopt", plan, module: match[1], version: match[2], migrationsDir };
177
+ const through = values.through === undefined ? undefined : parseThrough(values.through);
178
+ if (through === null) {
179
+ return `--through expects a positive whole number, got "${values.through ?? ""}"`;
180
+ }
181
+ return { kind: "adopt", plan, module: match[1], version: match[2], through, migrationsDir };
182
+ }
183
+ /** A positive whole number written in digits, or null. */
184
+ function parseThrough(text) {
185
+ return /^[1-9]\d*$/.test(text) ? Number(text) : null;
144
186
  }
145
187
  function reportFailure(output, failure) {
146
188
  failure.problems.forEach((problem) => output.error(describeProblem(problem)));
147
189
  return EXIT_FAILED;
148
190
  }
149
191
  function formatStep(step) {
150
- return `${step.module} ${String(step.version).padStart(4, "0")}_${step.name}.sql`;
192
+ return `${step.module} ${formatVersion(step.version)}_${step.name}.sql`;
193
+ }
194
+ function formatVersion(version) {
195
+ return String(version).padStart(4, "0");
196
+ }
197
+ function formatSummary(applied, adopted) {
198
+ if (applied === 0 && adopted === 0)
199
+ return "nothing to apply";
200
+ return adopted === 0 ? `${applied} migration(s) applied` : `${applied} migration(s) applied, ${adopted} adopted`;
151
201
  }
152
202
  function describeError(error) {
153
203
  return error instanceof Error ? error.message : String(error);