@softure-ai/db 0.0.0-stage → 0.1.6

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 (109) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +383 -2
  4. package/dist/cli/bin.d.ts +3 -0
  5. package/dist/cli/bin.d.ts.map +1 -0
  6. package/dist/cli/bin.js +5 -0
  7. package/dist/cli/bin.js.map +1 -0
  8. package/dist/cli/command.d.ts +8 -0
  9. package/dist/cli/command.d.ts.map +1 -0
  10. package/dist/cli/command.js +38 -0
  11. package/dist/cli/command.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -0
  14. package/dist/cli/index.js +4 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/run.d.ts +27 -0
  17. package/dist/cli/run.d.ts.map +1 -0
  18. package/dist/cli/run.js +205 -0
  19. package/dist/cli/run.js.map +1 -0
  20. package/dist/client.d.ts +73 -0
  21. package/dist/client.d.ts.map +1 -0
  22. package/dist/client.js +90 -0
  23. package/dist/client.js.map +1 -0
  24. package/dist/configured.d.ts +31 -0
  25. package/dist/configured.d.ts.map +1 -0
  26. package/dist/configured.js +74 -0
  27. package/dist/configured.js.map +1 -0
  28. package/dist/index.d.ts +12 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +13 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/migrations/adopt.d.ts +33 -0
  33. package/dist/migrations/adopt.d.ts.map +1 -0
  34. package/dist/migrations/adopt.js +97 -0
  35. package/dist/migrations/adopt.js.map +1 -0
  36. package/dist/migrations/apply.d.ts +35 -0
  37. package/dist/migrations/apply.d.ts.map +1 -0
  38. package/dist/migrations/apply.js +115 -0
  39. package/dist/migrations/apply.js.map +1 -0
  40. package/dist/migrations/export.d.ts +10 -0
  41. package/dist/migrations/export.d.ts.map +1 -0
  42. package/dist/migrations/export.js +58 -0
  43. package/dist/migrations/export.js.map +1 -0
  44. package/dist/migrations/exported.d.ts +20 -0
  45. package/dist/migrations/exported.d.ts.map +1 -0
  46. package/dist/migrations/exported.js +61 -0
  47. package/dist/migrations/exported.js.map +1 -0
  48. package/dist/migrations/files.d.ts +21 -0
  49. package/dist/migrations/files.d.ts.map +1 -0
  50. package/dist/migrations/files.js +112 -0
  51. package/dist/migrations/files.js.map +1 -0
  52. package/dist/migrations/introspect.d.ts +6 -0
  53. package/dist/migrations/introspect.d.ts.map +1 -0
  54. package/dist/migrations/introspect.js +117 -0
  55. package/dist/migrations/introspect.js.map +1 -0
  56. package/dist/migrations/ledger.d.ts +27 -0
  57. package/dist/migrations/ledger.d.ts.map +1 -0
  58. package/dist/migrations/ledger.js +45 -0
  59. package/dist/migrations/ledger.js.map +1 -0
  60. package/dist/migrations/migrator.d.ts +97 -0
  61. package/dist/migrations/migrator.d.ts.map +1 -0
  62. package/dist/migrations/migrator.js +301 -0
  63. package/dist/migrations/migrator.js.map +1 -0
  64. package/dist/migrations/problems.d.ts +93 -0
  65. package/dist/migrations/problems.d.ts.map +1 -0
  66. package/dist/migrations/problems.js +62 -0
  67. package/dist/migrations/problems.js.map +1 -0
  68. package/dist/migrations/reference.d.ts +23 -0
  69. package/dist/migrations/reference.d.ts.map +1 -0
  70. package/dist/migrations/reference.js +64 -0
  71. package/dist/migrations/reference.js.map +1 -0
  72. package/dist/migrations/session.d.ts +15 -0
  73. package/dist/migrations/session.d.ts.map +1 -0
  74. package/dist/migrations/session.js +56 -0
  75. package/dist/migrations/session.js.map +1 -0
  76. package/dist/migrations/stubs.d.ts +23 -0
  77. package/dist/migrations/stubs.d.ts.map +1 -0
  78. package/dist/migrations/stubs.js +99 -0
  79. package/dist/migrations/stubs.js.map +1 -0
  80. package/dist/shared.d.ts +9 -0
  81. package/dist/shared.d.ts.map +1 -0
  82. package/dist/shared.js +32 -0
  83. package/dist/shared.js.map +1 -0
  84. package/dist/testing.d.ts +23 -0
  85. package/dist/testing.d.ts.map +1 -0
  86. package/dist/testing.js +62 -0
  87. package/dist/testing.js.map +1 -0
  88. package/package.json +73 -4
  89. package/src/cli/bin.ts +5 -0
  90. package/src/cli/command.ts +47 -0
  91. package/src/cli/index.ts +3 -0
  92. package/src/cli/run.ts +250 -0
  93. package/src/client.ts +151 -0
  94. package/src/configured.ts +109 -0
  95. package/src/index.ts +46 -0
  96. package/src/migrations/adopt.ts +136 -0
  97. package/src/migrations/apply.ts +135 -0
  98. package/src/migrations/export.ts +66 -0
  99. package/src/migrations/exported.ts +82 -0
  100. package/src/migrations/files.ts +129 -0
  101. package/src/migrations/introspect.ts +165 -0
  102. package/src/migrations/ledger.ts +75 -0
  103. package/src/migrations/migrator.ts +403 -0
  104. package/src/migrations/problems.ts +99 -0
  105. package/src/migrations/reference.ts +78 -0
  106. package/src/migrations/session.ts +74 -0
  107. package/src/migrations/stubs.ts +139 -0
  108. package/src/shared.ts +36 -0
  109. package/src/testing.ts +80 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
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.6
8
+
9
+ - One database handle per process: `getConfiguredDatabase` opens the config's `database.handle` or URL once, and modules, health route and commands share it.
10
+ - App migrations run around module migrations (`before` and `after` hooks).
11
+ - Adoption: app baselines, `--adopt <module>@<version> --through <n>`, dependencies adopted first, and modules whose SQL references app tables (stub tables).
12
+ - **Breaking:** `pg` and `@electric-sql/pglite` are optional peer dependencies, no longer dependencies. An app installs
13
+ the driver its `DATABASE_URL` uses and lists `"@softure-ai/db"` and that driver in `serverExternalPackages`
14
+ ([README §2](README.md#2-installation)); a URL whose driver is missing fails with a message naming the package.
15
+ - A migrate bundle keeps drizzle's driver adapters external (`--external:drizzle-orm/pglite`,
16
+ `--external:drizzle-orm/node-postgres`).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SOFTURE
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 CHANGED
@@ -1,3 +1,384 @@
1
- # Temporary Holding Version
1
+ # @softure-ai/db
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The database layer every SOFTURE module stands on. Standard:
4
+ [docs/02-module-standard.md](../../docs/02-module-standard.md) §4.
5
+
6
+ ## 1. What it provides
7
+
8
+ A pg/PGlite client chosen by URL, a migrator that runs each module's SQL in the module's own
9
+ Postgres schema (with a ledger, checksums, a lock, a dry run and adoption of existing tables),
10
+ `createTestDatabase` for unit tests, and the `softure migrate` command.
11
+
12
+ ## 2. Installation
13
+
14
+ ```bash
15
+ npm install @softure-ai/db drizzle-orm
16
+ ```
17
+
18
+ Node ≥ 22, **ESM only**: module packages locate their migrations through `import.meta.url`, which a
19
+ CJS bundle (esbuild `--format=cjs`) leaves empty, so `resolveMigrationsDir` throws naming the bundle
20
+ format; the app script below also uses top-level await. Bundle migrate scripts with `--format=esm`.
21
+ `drizzle-orm` (`^0.45.2`) is a peer dependency, so the app, the modules and
22
+ this package share one drizzle.
23
+
24
+ The drivers are optional peers: install the one the app's URL uses, and only that one goes into the
25
+ app image. Each loads only when a URL needs it.
26
+
27
+ ```bash
28
+ npm install pg # postgres:// and postgresql://
29
+ npm install @electric-sql/pglite # pglite:// (dev without a server)
30
+ npm install -D @electric-sql/pglite # a Postgres app's createTestDatabase, --adopt and baseline
31
+ npm install -D @types/pg # only with skipLibCheck: false
32
+ ```
33
+
34
+ | Needs | `pg` (`^8.11.0`) | `@electric-sql/pglite` (`^0.5.0`) |
35
+ | --- | --- | --- |
36
+ | `postgres://`, `postgresql://` | yes | no |
37
+ | `pglite://` | no | yes |
38
+ | `createTestDatabase` (`@softure-ai/db/testing`) | no | yes (dev) |
39
+ | `softure migrate --adopt`, an app `baseline` (compare against a scratch PGlite) | URL's driver | yes |
40
+
41
+ A URL whose driver is missing fails at the first connection: `createDatabase: postgres:// URLs need
42
+ the "pg" package, which is not installed; …`, with Node's error as `cause`.
43
+
44
+ **Next.js.** List this package and the driver in `serverExternalPackages`:
45
+
46
+ ```ts
47
+ // next.config.ts
48
+ const nextConfig: NextConfig = {
49
+ serverExternalPackages: ["@softure-ai/db", "pg"], // or "@electric-sql/pglite" for pglite://
50
+ };
51
+ ```
52
+
53
+ Bundled, the package's `import()` of each driver and drizzle adapter is resolved at build time, so
54
+ `next build` fails with `Can't resolve '@electric-sql/pglite'` in an app without PGlite (drizzle's
55
+ adapter imports it statically). External, it loads from `node_modules` at run time, and standalone
56
+ output tracing follows the same imports, so `.next/standalone` carries the installed driver and
57
+ drizzle and nothing else (the example app installs `pg` only:
58
+ [`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
59
+ to it share them. Hiding the imports from the bundler instead (`turbopackIgnore`) builds, but
60
+ tracing skips the driver and the image fails at the first query.
61
+
62
+ ## 3. Configuration
63
+
64
+ The database URL comes from the app config (`database.url` in `defineSoftureConfig`), usually
65
+ `process.env.DATABASE_URL`:
66
+
67
+ | URL | Driver |
68
+ | --- | --- |
69
+ | `postgres://…`, `postgresql://…` | node-postgres pool (production) |
70
+ | `pglite://<dir>` | PGlite, a real Postgres in-process, data in `<dir>` (dev without a server) |
71
+ | `pglite://` | PGlite in memory |
72
+
73
+ ```ts
74
+ import { createDatabase, type Queryable } from "@softure-ai/db";
75
+
76
+ const handle = await createDatabase(config.database.url, { max: 10 }); // max: pg pool size
77
+ await handle.db.select().from(notes); // drizzle
78
+ await handle.close();
79
+ ```
80
+
81
+ - `DatabaseHandle` is `{ kind: "postgres", db, pool, close } | { kind: "pglite", db, client, close }`.
82
+ - `Database<TSchema>` is the drizzle database of either driver; `Queryable<TSchema>` is a `Database`
83
+ or an open transaction, so one helper serves both (`db.transaction(async (tx) => helper(tx))`).
84
+ Without a type argument both accept any schema, so an app database made with
85
+ `drizzle({ client, schema })`, and its transactions, pass to module functions as they are.
86
+ - An unsupported scheme throws, naming the scheme only; the URL (and its password) is never
87
+ printed.
88
+
89
+ ### One handle per process
90
+
91
+ Modules open their database through the config, never on their own: the app's handle when
92
+ `database.handle` is set, otherwise one process-wide handle per URL (`getSharedDatabase(url)`). An
93
+ app that also has its own client must make sure both are the same handle. A second handle is a second
94
+ pool on Postgres; on `pglite://` it is a second PGlite instance on the same directory, which corrupts
95
+ it without an error (the next open fails with `could not locate a valid checkpoint record`).
96
+
97
+ **Either** hand the app's client to the config. The function is called on first use, never while the
98
+ config is defined (a build does not connect), and must return the app's one handle:
99
+
100
+ ```ts
101
+ // db/client.ts: the app's own client, memoized: `next dev` re-evaluates modules on every change
102
+ import { PGlite } from "@electric-sql/pglite";
103
+ import { createPgliteHandle, createPostgresHandle, type DatabaseHandle } from "@softure-ai/db";
104
+ import pg from "pg";
105
+
106
+ const cache = globalThis as typeof globalThis & { appDatabase?: Promise<DatabaseHandle> };
107
+
108
+ export function getAppDatabase(url: string): Promise<DatabaseHandle> {
109
+ cache.appDatabase ??= url.startsWith("pglite://")
110
+ ? createPgliteHandle(new PGlite(url.slice("pglite://".length)))
111
+ : createPostgresHandle(new pg.Pool({ connectionString: url }).on("error", () => undefined));
112
+ return cache.appDatabase;
113
+ }
114
+
115
+ // softure.config.ts
116
+ const url = process.env.DATABASE_URL ?? "";
117
+ defineSoftureConfig({ database: { url, handle: () => getAppDatabase(url) }, /* … */ });
118
+ ```
119
+
120
+ Every module adapter, the ops health route, `softure migrate` (and the app's migration hooks it
121
+ runs), `softure-blog`, `softure-mail` and ops scripts then use that handle; a command closes it
122
+ when it is done, which writes a `pglite://` directory back.
123
+
124
+ **Or** build the app's typed drizzle instance over the shared handle, so there is nothing to hand
125
+ over:
126
+
127
+ ```ts
128
+ import { getSharedDatabase } from "@softure-ai/db";
129
+ import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
130
+ import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
131
+ import * as schema from "./schema";
132
+
133
+ export async function getDb() {
134
+ const handle = await getSharedDatabase(config.database.url);
135
+ return handle.kind === "postgres" ? drizzlePg({ client: handle.pool, schema }) : drizzlePglite({ client: handle.client, schema });
136
+ }
137
+ ```
138
+
139
+ `getConfiguredDatabase(config.database)` is what modules call; app code that needs the database
140
+ the modules use can call it too. A migration run on a PGlite handle the app shares keeps the app's
141
+ session settings (`TimeZone`, `search_path`); only what the run changed is put back.
142
+
143
+ ## 4. Mounting
144
+
145
+ Nothing to mount. Migrations run as a deploy step:
146
+
147
+ - **Dev:** `npx softure migrate` finds `softure.config.{ts,mts,js,mjs}` in the working directory
148
+ (or `--config <file>`). The file must be one Node can import: `.js`/`.mjs`, or `.ts` where Node
149
+ strips types (Node ≥ 22.18, relative imports written with `.ts`). Otherwise use the script below.
150
+ The lookup is `@softure-ai/core/cli`'s, shared with `softure-mail` and `softure-blog`.
151
+ - **App script and container image:** the app owns a three-line script, which esbuild bundles:
152
+
153
+ ```ts
154
+ // scripts/migrate.ts
155
+ import { runMigrateCli } from "@softure-ai/db/cli";
156
+ import config from "../softure.config";
157
+
158
+ process.exitCode = await runMigrateCli({ config, argv: process.argv.slice(2) });
159
+ ```
160
+
161
+ ```dockerfile
162
+ # build stage: copy each module's SQL next to the bundle, then bundle the runner
163
+ RUN npx tsx scripts/migrate.ts --export-migrations ./softure-migrations
164
+ RUN npx esbuild scripts/migrate.ts --bundle --platform=node --format=esm --target=node22 \
165
+ --external:pg --external:@electric-sql/pglite \
166
+ --external:drizzle-orm/pglite --external:drizzle-orm/node-postgres --outfile=migrate.mjs
167
+ # run stage (needs the URL's driver, and @electric-sql/pglite for --adopt or an app baseline)
168
+ COPY --from=builder /app/migrate.mjs ./migrate.mjs
169
+ COPY --from=builder /app/softure-migrations ./softure-migrations
170
+ CMD ["node", "migrate.mjs", "--migrations-dir", "./softure-migrations"]
171
+ ```
172
+
173
+ A config that imports `server-only` throws under plain Node: run the export as
174
+ `npx tsx --conditions=react-server scripts/migrate.ts --export-migrations …` and bundle with
175
+ `--alias:server-only=./scripts/empty.mjs` (an empty file). Path aliases (`@/…`) need nothing extra: tsx and
176
+ esbuild both read `paths` from `tsconfig.json`. The [ops container recipe](../../modules/ops/README.md#container-recipe)
177
+ uses the same commands.
178
+
179
+ The drivers and drizzle's two driver adapters stay external: bundled, the adapter of a driver the app
180
+ does not install imports it at the top of `migrate.mjs`, which then fails at start. At run time they
181
+ come from the standalone output, which carries the installed driver and its adapter.
182
+
183
+ A bundle cannot find package folders (`import.meta.url` points at the bundle), hence
184
+ `--export-migrations` at build time and `--migrations-dir` at run time. The build stage needs no
185
+ `DATABASE_URL`: the config accepts an empty `database.url` (`DATABASE_URL ?? ""`) and only a command that
186
+ connects refuses it. The ledger's own migration
187
+ ships as code.
188
+
189
+ **The app's own migrations** run in the same step, through hooks. `before` runs ahead of the module
190
+ files (app tables a module references, normally the app's whole history), `after` behind them (app
191
+ migrations that reference a table a module creates). With drizzle, define them once and pass the same
192
+ object to the migrate script and to `createTestDatabase`:
193
+
194
+ ```ts
195
+ // db/app-migrations.ts
196
+ import type { AppMigrations, DatabaseHandle } from "@softure-ai/db";
197
+
198
+ async function runDrizzle(handle: DatabaseHandle, migrationsFolder: string): Promise<void> {
199
+ if (handle.kind === "postgres") {
200
+ const { migrate } = await import("drizzle-orm/node-postgres/migrator");
201
+ await migrate(handle.db, { migrationsFolder });
202
+ } else {
203
+ const { migrate } = await import("drizzle-orm/pglite/migrator");
204
+ await migrate(handle.db, { migrationsFolder });
205
+ }
206
+ }
207
+
208
+ export const appMigrations: AppMigrations = {
209
+ before: (handle) => runDrizzle(handle, "./drizzle"),
210
+ // only when an app migration needs a module table: a second drizzle folder
211
+ // after: (handle) => runDrizzle(handle, "./drizzle-after"),
212
+ };
213
+
214
+ // scripts/migrate.ts
215
+ process.exitCode = await runMigrateCli({ config, argv: process.argv.slice(2), app: appMigrations });
216
+ ```
217
+
218
+ - Order of a run: the ledger, `before`, the module files in dependency order, `after`. Everything
219
+ runs under the migration lock, after every module file passed its checks; nothing runs when a check
220
+ fails. The hooks run on every run, so they must skip what they already applied (drizzle does).
221
+ - A hook that throws is reported as `db.app_migration_failed` with the error and its causes (drizzle
222
+ puts the Postgres message in `cause`). A failing `before` stops the run before any module file; a
223
+ failing `after` leaves the module files applied.
224
+ - `--plan` runs no hook (it says so); `--adopt` without `--plan` runs `before` first, so the app
225
+ migration that moves a table into the module's schema and the adoption are one command.
226
+
227
+ **A baseline: fresh databases after an adoption.** The app keeps its old migrations, so every fresh
228
+ database (the unit-test template, an integration stack, a reset dev database) recreates the tables it
229
+ moved into a module's schema before the module files run. `baseline` names, per module, the last module
230
+ file the app's own history creates; `migrate` then adopts or migrates by itself, on every database:
231
+
232
+ ```ts
233
+ export const appMigrations: AppMigrations = {
234
+ before: (handle) => runDrizzle(handle, "./drizzle"),
235
+ // the app's history creates auth's 0001 (users, sessions); 0002.. are new to it
236
+ baseline: { auth: 1 },
237
+ };
238
+ ```
239
+
240
+ - For a listed module the ledger has never seen, after `before` and after the modules it depends on:
241
+ an empty schema is migrated normally; otherwise the schema is compared with what files 1..n create
242
+ (as `--adopt` does), files 1..n are recorded as `adopted` and the later files are applied. A
243
+ difference stops the run (`db.schema_mismatch`, every difference printed); nothing of that module is
244
+ recorded and `after` does not run.
245
+ - Once the module is in the ledger the entry has no effect, so the same command serves every deploy.
246
+ Keep the number when the module ships new files: it describes the app's history, which no longer
247
+ changes.
248
+ - Only a prefix 1..n can be adopted (the ledger refuses a file numbered below an applied one): when the
249
+ app's tables already carry a later file's change, align them in the app's own migration or raise n.
250
+ - A module that is not enabled, has no schema, or an n outside 1..files refuses the run before anything
251
+ runs. `--plan` marks the files a baseline may adopt (`adopted if its schema already holds objects`).
252
+ - The comparison builds the reference on a scratch PGlite, only when a module is actually adopted,
253
+ with stubs of the app tables `before` created (section 5, adoption).
254
+ - Under node-postgres a hook queries through the pool while the migrator holds one connection, so the
255
+ pool needs at least two (`createDatabase`'s default is 10).
256
+ - The `softure` bin reads only the config; an app with its own migrations uses the script above.
257
+ - In a bundle, the drizzle folder must be copied next to the bundle, as the module files are.
258
+
259
+ **Options of `softure migrate`:**
260
+
261
+ | Option | Effect |
262
+ | --- | --- |
263
+ | (none) | apply every pending migration |
264
+ | `--plan` | print what would be applied (or adopted); change nothing |
265
+ | `--adopt <module>@<version>` | record an existing schema as migrated after comparing it, after applying the pending migrations of its dependencies (section 5) |
266
+ | `--through <n>` | with `--adopt`: adopt files 1..n only; the next migrate applies the rest |
267
+ | `--migrations-dir <dir>` | read module files from `<dir>/<module id>/` |
268
+ | `--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 |
269
+
270
+ Exit codes: 0 done, 1 a problem or failure (each printed on stderr), 2 a usage error.
271
+
272
+ The same operations as functions: `migrate(handle, { modules, migrationsDir?, app?, onApplied?, onAdopted?, onAppMigrated? })`
273
+ (`runAppMigrations(handle, app, phase)` runs one hook alone under the lock),
274
+ `planMigrations(handle, { modules, app? })` (`pending`, and `baseline`: the pending files a baseline may adopt),
275
+ `adoptModule(handle, { modules, module, version, through?, dryRun? })`,
276
+ `exportMigrations(modules, dir)`. Each returns `{ ok: true, value } | { ok: false, error, problems }`;
277
+ `describeProblem(problem)` gives the English line.
278
+
279
+ For a deploy, `checkExportedMigrations(dir, journal)` runs the same comparison on an exported folder without the app
280
+ config: `journal` is `readJournal(session)` of the target database, and the result lists the pending files and the
281
+ ledger modules the folder does not hold, or every problem. `softure-deploy schema-guard` (`@softure-ai/deploy`) is
282
+ built on it.
283
+
284
+ ## 5. Migrations and tables
285
+
286
+ **Owned:** schema `softure`, table `softure.migrations` (`id, module, version, name, checksum,
287
+ module_version, method ('applied' | 'adopted'), applied_at`, primary key `(module, version)`).
288
+
289
+ **Module files** (`migrations/NNNN_<lower_snake>.sql`, numbered 1..n without gaps):
290
+ - the file opens with a comment block that contains `Rollback:` and says how to undo it;
291
+ - no top-level statement may begin, end or abort a transaction (`BEGIN`, `COMMIT`, `ROLLBACK`,
292
+ `END`, `ABORT`, `START TRANSACTION`, `PREPARE TRANSACTION`); bodies in quotes or dollar quotes
293
+ (PL/pgSQL `BEGIN … END;`) are fine, SQL-standard `BEGIN ATOMIC` bodies are not. Each file runs in
294
+ its own transaction with its ledger row, and a file that still ended it is reported as such,
295
+ not as rolled back;
296
+ - unqualified names land in the module's schema: the runner runs `CREATE SCHEMA IF NOT EXISTS` and
297
+ `SET LOCAL search_path TO <schema>, public` first. Name other modules' tables with their schema
298
+ (`REFERENCES notes.notes (id)`), and create extensions `WITH SCHEMA public`.
299
+
300
+ **Each run:** the ledger, the app's `before` hook, modules in dependency order (`sortModulesByDependencies`), files in number order.
301
+ Before anything runs, every file of every module is checked: an applied file that was edited or
302
+ renamed (`db.migration_changed`), deleted (`db.migration_missing`), or a new file numbered below an
303
+ applied one (`db.migration_out_of_order`) refuses the whole run. The run holds a session-level
304
+ `pg_advisory_lock`, so a second runner waits, then finds nothing to do. A failing file is rolled
305
+ back completely and stops the run; the files before it stay applied. The migration connection is
306
+ closed after the run, never returned to the pool. The app's `after` hook runs last (section 4).
307
+
308
+ **Refused modules:** the id `softure`; the schemas `softure`, `public`, `information_schema`,
309
+ `drizzle` (drizzle's ledger in every app), `pg_*` and names over 63 bytes; migrations without a `dbSchema`.
310
+
311
+ **Adoption** ([docs/05](../../docs/05-adoption-playbook.md) step 3): after the app's own migration
312
+ moved its tables into the module's schema, `--adopt <module>@<version>` (with `--plan` first)
313
+ builds the schema the module's migrations create on a scratch PGlite and compares it with the live
314
+ one through `pg_catalog`: relations (and whether they are unlogged), columns (type, collation,
315
+ not null, default, identity, generated), sequences with their parameters, constraints and indexes
316
+ (names and definitions), triggers, functions, and enum, domain, range and composite types. Only an
317
+ exact match records the files as `adopted`; every difference is printed (`missing in database: …`,
318
+ `unexpected in database: …`). `--through <n>` compares with and adopts files 1..n only; the next
319
+ migrate applies the rest. The version must equal the enabled module's and the module must be new to
320
+ the ledger. The pending migrations of the modules it depends on are applied first (listed as
321
+ `apply` lines; a dry run applies nothing); when one fails because its tables exist too, adopt that
322
+ module first. Module SQL may reference the app's own tables (`REFERENCES public.app_users (id)`):
323
+ the scratch database first gets stubs of every table outside the ledger, the enabled modules'
324
+ schemas and `drizzle`, copied from the live database (columns with their types, primary keys,
325
+ unique constraints, plain unique indexes and the enum types they use; no defaults, NOT NULL, checks
326
+ or foreign keys). The tables must therefore exist when the comparison runs: `--adopt` and a baseline
327
+ run `before` first, a `--plan` dry run reads only what is already there. A stub that cannot be
328
+ created (a domain or extension type) is skipped, and named in the failure when the module needs it.
329
+ For fresh databases of an app that adopted a module, declare a baseline (section 4). Undo:
330
+ `DELETE FROM softure.migrations WHERE module = '<id>' AND method = 'adopted'`.
331
+
332
+ **Tests:** `createTestDatabase(modules)` from `@softure-ai/db/testing` returns `{ db, client, close }`:
333
+ an in-memory PGlite with the ledger and the listed modules migrated (list the module and the
334
+ modules it depends on). Each module set is migrated once per test worker and copied per call; a
335
+ broken migration throws naming the file. An app with its own migrations passes the same hooks,
336
+ `createTestDatabase(modules, { app: appMigrations })`, so module tables can reference app tables; the
337
+ template is cached per hook object, so import the shared object rather than writing hooks inline.
338
+
339
+ ```ts
340
+ import { createTestDatabase } from "@softure-ai/db/testing";
341
+
342
+ const database = await createTestDatabase([notes()]);
343
+ afterEach(() => database.close());
344
+ ```
345
+
346
+ ## 6. Environment variables
347
+
348
+ None read by the package. The app passes `DATABASE_URL` (or any variable) through
349
+ `database.url` in its config.
350
+
351
+ ## 7. Switches
352
+
353
+ None.
354
+
355
+ ## 8. Appearance
356
+
357
+ None.
358
+
359
+ ## 9. Copy
360
+
361
+ None: no user-facing text. Command output and problem lines are English developer messages.
362
+
363
+ ## 10. Hooks
364
+
365
+ `onApplied(step)` in `migrate` options, called after each committed file.
366
+
367
+ ## 11. GDPR
368
+
369
+ No personal data. The ledger holds module ids, file names and checksums.
370
+
371
+ ## 12. Limitations
372
+
373
+ - Forward only: there are no down migrations; each file states its rollback in its comment.
374
+ - A file cannot run outside a transaction (`CREATE INDEX CONCURRENTLY`, `ALTER TYPE … ADD VALUE`
375
+ before Postgres 12).
376
+ - No lock timeout: a stuck runner holds the lock until its connection ends (visible in `pg_locks`).
377
+ The session-level lock needs a direct connection or a session-mode pooler, not a
378
+ transaction-mode PgBouncer.
379
+ - Adoption does not compare grants, comments, column order, row-level security policies or view
380
+ bodies, and needs PGlite installed where it runs (also in an image that otherwise uses `pg`).
381
+ - The app table stubs of the reference carry no domains, extension types, views or functions: a
382
+ module whose SQL needs one of those from the app cannot be adopted (`db.adopt_reference_failed`
383
+ names the skipped stub objects).
384
+ - No cached connection for Next.js dev reloads yet; the Next adapter (identity ID-1, FD-7) adds it.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=bin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":""}
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // The `softure` executable. All logic lives in command.ts, which the tests call directly.
3
+ import { runSoftureCommand } from "./command.js";
4
+ process.exitCode = await runSoftureCommand({ argv: process.argv.slice(2), cwd: process.cwd() });
5
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":";AACA,0FAA0F;AAC1F,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAEjD,OAAO,CAAC,QAAQ,GAAG,MAAM,iBAAiB,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { type CliOutput } from "./run.js";
2
+ export interface RunSoftureCommandOptions {
3
+ readonly argv: readonly string[];
4
+ readonly cwd: string;
5
+ readonly output?: CliOutput;
6
+ }
7
+ export declare function runSoftureCommand(options: RunSoftureCommandOptions): Promise<number>;
8
+ //# sourceMappingURL=command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../../src/cli/command.ts"],"names":[],"mappings":"AAKA,OAAO,EAAkE,KAAK,SAAS,EAAE,MAAM,UAAU,CAAC;AAE1G,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;CAC7B;AASD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CA0B1F"}
@@ -0,0 +1,38 @@
1
+ // The `softure` bin: `softure migrate [--config <file>] [options]`. It loads the app's config through
2
+ // `@softure-ai/core/cli`, so the file must be loadable by Node itself: `.js`/`.mjs`, or `.ts` where
3
+ // Node strips types (Node >= 22.18; relative imports then need `.ts` extensions). Anything else
4
+ // uses an app script with `runMigrateCli` instead (see run.ts).
5
+ import { loadAppConfig, takeConfigOption } from "@softure-ai/core/cli";
6
+ import { EXIT_FAILED, EXIT_OK, EXIT_USAGE, MIGRATE_USAGE, runMigrateCli } from "./run.js";
7
+ const APP_SCRIPT = { runner: "runMigrateCli", packageName: "@softure-ai/db" };
8
+ const consoleOutput = {
9
+ log: (line) => console.log(line),
10
+ error: (line) => console.error(line),
11
+ };
12
+ export async function runSoftureCommand(options) {
13
+ const output = options.output ?? consoleOutput;
14
+ const [command, ...rest] = options.argv;
15
+ if (command !== "migrate") {
16
+ output.error(command === undefined ? "softure: missing command" : `softure: unknown command "${command}"`);
17
+ output.error("Commands: migrate");
18
+ output.error(MIGRATE_USAGE);
19
+ return EXIT_USAGE;
20
+ }
21
+ if (rest.includes("--help")) {
22
+ output.log(MIGRATE_USAGE);
23
+ return EXIT_OK;
24
+ }
25
+ const taken = takeConfigOption(rest);
26
+ if (!taken.ok) {
27
+ output.error(`softure migrate: ${taken.problem}`);
28
+ output.error(MIGRATE_USAGE);
29
+ return EXIT_USAGE;
30
+ }
31
+ const loaded = await loadAppConfig({ cwd: options.cwd, configPath: taken.configPath, appScript: APP_SCRIPT });
32
+ if (!loaded.ok) {
33
+ output.error(`softure: ${loaded.problem}`);
34
+ return EXIT_FAILED;
35
+ }
36
+ return runMigrateCli({ config: loaded.config, argv: taken.argv, cwd: options.cwd, output });
37
+ }
38
+ //# sourceMappingURL=command.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.js","sourceRoot":"","sources":["../../src/cli/command.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,oGAAoG;AACpG,gGAAgG;AAChG,gEAAgE;AAChE,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACvE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAkB,MAAM,UAAU,CAAC;AAQ1G,MAAM,UAAU,GAAG,EAAE,MAAM,EAAE,eAAe,EAAE,WAAW,EAAE,gBAAgB,EAAE,CAAC;AAE9E,MAAM,aAAa,GAAc;IAC/B,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC;IAChC,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC;CACrC,CAAC;AAEF,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,OAAiC;IACvE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,aAAa,CAAC;IAC/C,MAAM,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IACxC,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,MAAM,CAAC,KAAK,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,0BAA0B,CAAC,CAAC,CAAC,6BAA6B,OAAO,GAAG,CAAC,CAAC;QAC3G,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAC;QAClC,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAC5B,OAAO,UAAU,CAAC;IACpB,CAAC;IAED,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5B,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QAC1B,OAAO,OAAO,CAAC;IACjB,CAAC;IACD,MAAM,KAAK,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;IACrC,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC;QACd,MAAM,CAAC,KAAK,CAAC,oBAAoB,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;QAClD,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAC5B,OAAO,UAAU,CAAC;IACpB,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;IAC9G,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;QACf,MAAM,CAAC,KAAK,CAAC,YAAY,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;QAC3C,OAAO,WAAW,CAAC;IACrB,CAAC;IACD,OAAO,aAAa,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,CAAC,CAAC;AAC9F,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { runSoftureCommand, type RunSoftureCommandOptions } from "./command.js";
2
+ export { EXIT_FAILED, EXIT_OK, EXIT_USAGE, MIGRATE_USAGE, runMigrateCli, type CliOutput, type RunMigrateCliOptions } from "./run.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,KAAK,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAChF,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAE,KAAK,SAAS,EAAE,KAAK,oBAAoB,EAAE,MAAM,UAAU,CAAC"}
@@ -0,0 +1,4 @@
1
+ // `@softure-ai/db/cli`: the `softure migrate` command for app scripts and container bundles.
2
+ export { runSoftureCommand } from "./command.js";
3
+ export { EXIT_FAILED, EXIT_OK, EXIT_USAGE, MIGRATE_USAGE, runMigrateCli } from "./run.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,OAAO,EAAE,iBAAiB,EAAiC,MAAM,cAAc,CAAC;AAChF,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAA6C,MAAM,UAAU,CAAC"}
@@ -0,0 +1,27 @@
1
+ import type { SoftureConfig } from "@softure-ai/core";
2
+ import { type AppMigrations } from "../migrations/migrator.js";
3
+ export interface CliOutput {
4
+ readonly log: (line: string) => void;
5
+ readonly error: (line: string) => void;
6
+ }
7
+ export interface RunMigrateCliOptions {
8
+ readonly config: Pick<SoftureConfig, "database" | "modules">;
9
+ /** The arguments after the command name, e.g. `["--plan"]`. */
10
+ readonly argv: readonly string[];
11
+ /** Relative paths resolve against it. Default: `process.cwd()`. */
12
+ readonly cwd?: string;
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;
20
+ }
21
+ export declare const EXIT_OK = 0;
22
+ export declare const EXIT_FAILED = 1;
23
+ export declare const EXIT_USAGE = 2;
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";
25
+ /** Runs the command and returns the process exit code: 0 done, 1 failed, 2 usage error. */
26
+ export declare function runMigrateCli(options: RunMigrateCliOptions): Promise<number>;
27
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
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"}