@softure-ai/db 0.0.0-stage → 0.1.5

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -2
  3. package/dist/cli/bin.d.ts +3 -0
  4. package/dist/cli/bin.d.ts.map +1 -0
  5. package/dist/cli/bin.js +5 -0
  6. package/dist/cli/bin.js.map +1 -0
  7. package/dist/cli/command.d.ts +8 -0
  8. package/dist/cli/command.d.ts.map +1 -0
  9. package/dist/cli/command.js +38 -0
  10. package/dist/cli/command.js.map +1 -0
  11. package/dist/cli/index.d.ts +3 -0
  12. package/dist/cli/index.d.ts.map +1 -0
  13. package/dist/cli/index.js +4 -0
  14. package/dist/cli/index.js.map +1 -0
  15. package/dist/cli/run.d.ts +20 -0
  16. package/dist/cli/run.d.ts.map +1 -0
  17. package/dist/cli/run.js +155 -0
  18. package/dist/cli/run.js.map +1 -0
  19. package/dist/client.d.ts +42 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +34 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/index.d.ts +11 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +12 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/migrations/adopt.d.ts +26 -0
  28. package/dist/migrations/adopt.d.ts.map +1 -0
  29. package/dist/migrations/adopt.js +131 -0
  30. package/dist/migrations/adopt.js.map +1 -0
  31. package/dist/migrations/export.d.ts +10 -0
  32. package/dist/migrations/export.d.ts.map +1 -0
  33. package/dist/migrations/export.js +58 -0
  34. package/dist/migrations/export.js.map +1 -0
  35. package/dist/migrations/exported.d.ts +20 -0
  36. package/dist/migrations/exported.d.ts.map +1 -0
  37. package/dist/migrations/exported.js +61 -0
  38. package/dist/migrations/exported.js.map +1 -0
  39. package/dist/migrations/files.d.ts +21 -0
  40. package/dist/migrations/files.d.ts.map +1 -0
  41. package/dist/migrations/files.js +112 -0
  42. package/dist/migrations/files.js.map +1 -0
  43. package/dist/migrations/introspect.d.ts +6 -0
  44. package/dist/migrations/introspect.d.ts.map +1 -0
  45. package/dist/migrations/introspect.js +117 -0
  46. package/dist/migrations/introspect.js.map +1 -0
  47. package/dist/migrations/ledger.d.ts +27 -0
  48. package/dist/migrations/ledger.d.ts.map +1 -0
  49. package/dist/migrations/ledger.js +45 -0
  50. package/dist/migrations/ledger.js.map +1 -0
  51. package/dist/migrations/migrator.d.ts +69 -0
  52. package/dist/migrations/migrator.d.ts.map +1 -0
  53. package/dist/migrations/migrator.js +217 -0
  54. package/dist/migrations/migrator.js.map +1 -0
  55. package/dist/migrations/problems.d.ts +84 -0
  56. package/dist/migrations/problems.d.ts.map +1 -0
  57. package/dist/migrations/problems.js +56 -0
  58. package/dist/migrations/problems.js.map +1 -0
  59. package/dist/migrations/session.d.ts +14 -0
  60. package/dist/migrations/session.d.ts.map +1 -0
  61. package/dist/migrations/session.js +39 -0
  62. package/dist/migrations/session.js.map +1 -0
  63. package/dist/shared.d.ts +9 -0
  64. package/dist/shared.d.ts.map +1 -0
  65. package/dist/shared.js +32 -0
  66. package/dist/shared.js.map +1 -0
  67. package/dist/testing.d.ts +14 -0
  68. package/dist/testing.d.ts.map +1 -0
  69. package/dist/testing.js +43 -0
  70. package/dist/testing.js.map +1 -0
  71. package/package.json +56 -4
  72. package/src/cli/bin.ts +5 -0
  73. package/src/cli/command.ts +47 -0
  74. package/src/cli/index.ts +3 -0
  75. package/src/cli/run.ts +189 -0
  76. package/src/client.ts +79 -0
  77. package/src/index.ts +37 -0
  78. package/src/migrations/adopt.ts +171 -0
  79. package/src/migrations/export.ts +66 -0
  80. package/src/migrations/exported.ts +82 -0
  81. package/src/migrations/files.ts +129 -0
  82. package/src/migrations/introspect.ts +165 -0
  83. package/src/migrations/ledger.ts +75 -0
  84. package/src/migrations/migrator.ts +285 -0
  85. package/src/migrations/problems.ts +91 -0
  86. package/src/migrations/session.ts +48 -0
  87. package/src/shared.ts +36 -0
  88. package/src/testing.ts +52 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAIA,OAAO,EAAkB,KAAK,qBAAqB,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAQ9F;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,GAAG,OAAO,CAAC,cAAc,CAAC,CAS3G;AAED,+EAA+E;AAC/E,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC,CAO1D"}
package/dist/shared.js ADDED
@@ -0,0 +1,32 @@
1
+ // One database handle per URL for the whole server process, shared by every module's Next.js
2
+ // adapter and the app. Package code cannot import the app's own handle, and `next dev` re-evaluates
3
+ // modules on every change, so the handles live on `globalThis` under a `Symbol.for` key: each
4
+ // reload, and each copy of this file in separate server bundles, finds the same pool.
5
+ import { createDatabase } from "./client.js";
6
+ const HANDLES_KEY = Symbol.for("@softure-ai/db/shared-handles");
7
+ const host = globalThis;
8
+ /**
9
+ * The process-wide handle for `url`, opened on first use. `options` apply only to that first call.
10
+ * A failed open is not kept, so the next call tries again.
11
+ */
12
+ export function getSharedDatabase(url, options = {}) {
13
+ const handles = (host[HANDLES_KEY] ??= new Map());
14
+ let handle = handles.get(url);
15
+ if (handle === undefined) {
16
+ handle = createDatabase(url, options);
17
+ handles.set(url, handle);
18
+ handle.catch(() => handles.delete(url));
19
+ }
20
+ return handle;
21
+ }
22
+ /** Closes and forgets every shared handle. For tests and graceful shutdown. */
23
+ export async function closeSharedDatabases() {
24
+ const handles = host[HANDLES_KEY];
25
+ if (handles === undefined)
26
+ return;
27
+ const opened = [...handles.values()];
28
+ handles.clear();
29
+ const results = await Promise.allSettled(opened);
30
+ await Promise.all(results.flatMap((result) => (result.status === "fulfilled" ? [result.value.close()] : [])));
31
+ }
32
+ //# sourceMappingURL=shared.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.js","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,oGAAoG;AACpG,8FAA8F;AAC9F,sFAAsF;AACtF,OAAO,EAAE,cAAc,EAAmD,MAAM,aAAa,CAAC;AAE9F,MAAM,WAAW,GAAG,MAAM,CAAC,GAAG,CAAC,+BAA+B,CAAC,CAAC;AAIhE,MAAM,IAAI,GAAG,UAAmC,CAAC;AAEjD;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,UAAiC,EAAE;IAChF,MAAM,OAAO,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,IAAI,GAAG,EAAmC,CAAC,CAAC;IACnF,IAAI,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC9B,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,GAAG,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QACzB,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IAC1C,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,+EAA+E;AAC/E,MAAM,CAAC,KAAK,UAAU,oBAAoB;IACxC,MAAM,OAAO,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC;IAClC,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO;IAClC,MAAM,MAAM,GAAG,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACrC,OAAO,CAAC,KAAK,EAAE,CAAC;IAChB,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IACjD,MAAM,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAChH,CAAC"}
@@ -0,0 +1,14 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import type { AnySoftureModule } from "@softure-ai/core";
3
+ import { type PgliteClientDatabase } from "./client.js";
4
+ export interface TestDatabase {
5
+ readonly db: PgliteClientDatabase;
6
+ readonly client: PGlite;
7
+ readonly close: () => Promise<void>;
8
+ }
9
+ /**
10
+ * A fresh, migrated, in-memory database per call. List the module under test and the modules it
11
+ * depends on. Throws when a migration is invalid or fails: that is a bug in the module package.
12
+ */
13
+ export declare function createTestDatabase(modules?: readonly AnySoftureModule[]): Promise<TestDatabase>;
14
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAsB,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAI5E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,oBAAoB,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAID;;;GAGG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,SAAS,gBAAgB,EAAO,GAAG,OAAO,CAAC,YAAY,CAAC,CAIzG"}
@@ -0,0 +1,43 @@
1
+ // Test databases for module unit tests (docs/02-module-standard.md §10): a real Postgres (PGlite)
2
+ // with the real migrations of the given modules. Migrating costs about two seconds, so each
3
+ // module set is migrated once per test worker into a template, and every call starts from a copy
4
+ // of the template's data folder (FIRE_TRACKER `src/db/test-db.ts`).
5
+ import { PGlite } from "@electric-sql/pglite";
6
+ import { createPgliteHandle } from "./client.js";
7
+ import { migrate } from "./migrations/migrator.js";
8
+ import { describeProblem } from "./migrations/problems.js";
9
+ const templates = new Map();
10
+ /**
11
+ * A fresh, migrated, in-memory database per call. List the module under test and the modules it
12
+ * depends on. Throws when a migration is invalid or fails: that is a bug in the module package.
13
+ */
14
+ export async function createTestDatabase(modules = []) {
15
+ const client = new PGlite({ loadDataDir: await getTemplate(modules) });
16
+ const handle = await createPgliteHandle(client);
17
+ return { db: handle.db, client, close: handle.close };
18
+ }
19
+ function getTemplate(modules) {
20
+ const key = modules.map((module) => `${module.id}@${module.migrations?.dir.href ?? "-"}`).join("|");
21
+ let template = templates.get(key);
22
+ if (template === undefined) {
23
+ template = buildTemplate(modules);
24
+ templates.set(key, template);
25
+ // A failed build is not cached: the next call reports the problem again.
26
+ template.catch(() => templates.delete(key));
27
+ }
28
+ return template;
29
+ }
30
+ async function buildTemplate(modules) {
31
+ const handle = await createPgliteHandle(new PGlite());
32
+ try {
33
+ const result = await migrate(handle, { modules });
34
+ if (!result.ok) {
35
+ throw new Error(`createTestDatabase: migrations failed:\n${result.problems.map(describeProblem).join("\n")}`);
36
+ }
37
+ return await handle.client.dumpDataDir("none");
38
+ }
39
+ finally {
40
+ await handle.close();
41
+ }
42
+ }
43
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,4FAA4F;AAC5F,iGAAiG;AACjG,oEAAoE;AACpE,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAE9C,OAAO,EAAE,kBAAkB,EAA6B,MAAM,aAAa,CAAC;AAC5E,OAAO,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAQ3D,MAAM,SAAS,GAAG,IAAI,GAAG,EAAgC,CAAC;AAE1D;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,UAAuC,EAAE;IAChF,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IACvE,MAAM,MAAM,GAAG,MAAM,kBAAkB,CAAC,MAAM,CAAC,CAAC;IAChD,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC;AACxD,CAAC;AAED,SAAS,WAAW,CAAC,OAAoC;IACvD,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,EAAE,IAAI,MAAM,CAAC,UAAU,EAAE,GAAG,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACpG,IAAI,QAAQ,GAAG,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,QAAQ,GAAG,aAAa,CAAC,OAAO,CAAC,CAAC;QAClC,SAAS,CAAC,GAAG,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;QAC7B,yEAAyE;QACzE,QAAQ,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,OAAoC;IAC/D,MAAM,MAAM,GAAG,MAAM,kBAAkB,CAAC,IAAI,MAAM,EAAE,CAAC,CAAC;IACtD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;QAClD,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;YACf,MAAM,IAAI,KAAK,CAAC,2CAA2C,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAChH,CAAC;QACD,OAAO,MAAM,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;IACjD,CAAC;YAAS,CAAC;QACT,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;IACvB,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@softure-ai/db",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.5",
4
+ "description": "SOFTURE AI database layer: pg/PGlite client, per-module migrator with plan and adopt, PGlite test databases.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/SOFTURE/AI.git",
14
+ "directory": "foundation/db"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "!src/**/*.test.ts",
20
+ "!src/**/*.test.tsx"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "provenance": true
25
+ },
26
+ "bin": {
27
+ "softure": "./dist/cli/bin.js"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "@softure-ai/source": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./testing": {
36
+ "@softure-ai/source": "./src/testing.ts",
37
+ "types": "./dist/testing.d.ts",
38
+ "default": "./dist/testing.js"
39
+ },
40
+ "./cli": {
41
+ "@softure-ai/source": "./src/cli/index.ts",
42
+ "types": "./dist/cli/index.d.ts",
43
+ "default": "./dist/cli/index.js"
44
+ }
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.build.json"
48
+ },
49
+ "dependencies": {
50
+ "@electric-sql/pglite": "^0.5.8",
51
+ "@softure-ai/core": "^0.1.0",
52
+ "@types/pg": "^8.23.1",
53
+ "pg": "^8.23.1"
54
+ },
55
+ "peerDependencies": {
56
+ "drizzle-orm": "^0.45.2"
57
+ }
58
+ }
package/src/cli/bin.ts ADDED
@@ -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
+
5
+ process.exitCode = await runSoftureCommand({ argv: process.argv.slice(2), cwd: process.cwd() });
@@ -0,0 +1,47 @@
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, type CliOutput } from "./run.js";
7
+
8
+ export interface RunSoftureCommandOptions {
9
+ readonly argv: readonly string[];
10
+ readonly cwd: string;
11
+ readonly output?: CliOutput;
12
+ }
13
+
14
+ const APP_SCRIPT = { runner: "runMigrateCli", packageName: "@softure-ai/db" };
15
+
16
+ const consoleOutput: CliOutput = {
17
+ log: (line) => console.log(line),
18
+ error: (line) => console.error(line),
19
+ };
20
+
21
+ export async function runSoftureCommand(options: RunSoftureCommandOptions): Promise<number> {
22
+ const output = options.output ?? consoleOutput;
23
+ const [command, ...rest] = options.argv;
24
+ if (command !== "migrate") {
25
+ output.error(command === undefined ? "softure: missing command" : `softure: unknown command "${command}"`);
26
+ output.error("Commands: migrate");
27
+ output.error(MIGRATE_USAGE);
28
+ return EXIT_USAGE;
29
+ }
30
+
31
+ if (rest.includes("--help")) {
32
+ output.log(MIGRATE_USAGE);
33
+ return EXIT_OK;
34
+ }
35
+ const taken = takeConfigOption(rest);
36
+ if (!taken.ok) {
37
+ output.error(`softure migrate: ${taken.problem}`);
38
+ output.error(MIGRATE_USAGE);
39
+ return EXIT_USAGE;
40
+ }
41
+ const loaded = await loadAppConfig({ cwd: options.cwd, configPath: taken.configPath, appScript: APP_SCRIPT });
42
+ if (!loaded.ok) {
43
+ output.error(`softure: ${loaded.problem}`);
44
+ return EXIT_FAILED;
45
+ }
46
+ return runMigrateCli({ config: loaded.config, argv: taken.argv, cwd: options.cwd, output });
47
+ }
@@ -0,0 +1,3 @@
1
+ // `@softure-ai/db/cli`: the `softure migrate` command for app scripts and container bundles.
2
+ export { runSoftureCommand, type RunSoftureCommandOptions } from "./command.js";
3
+ export { EXIT_FAILED, EXIT_OK, EXIT_USAGE, MIGRATE_USAGE, runMigrateCli, type CliOutput, type RunMigrateCliOptions } from "./run.js";
package/src/cli/run.ts ADDED
@@ -0,0 +1,189 @@
1
+ // `softure migrate` (docs/02-module-standard.md §4). The app passes its validated config, so the
2
+ // same function serves the `softure` bin and an app script that esbuild bundles for a container:
3
+ //
4
+ // // scripts/migrate.ts
5
+ // import { runMigrateCli } from "@softure-ai/db/cli";
6
+ // import config from "../softure.config";
7
+ // process.exitCode = await runMigrateCli({ config, argv: process.argv.slice(2) });
8
+ import { resolve } from "node:path";
9
+ import { parseArgs } from "node:util";
10
+ import { pathToFileURL } from "node:url";
11
+ import type { SoftureConfig } from "@softure-ai/core";
12
+ import { createDatabase, type DatabaseHandle } from "../client.js";
13
+ import { adoptModule } from "../migrations/adopt.js";
14
+ import { exportMigrations } from "../migrations/export.js";
15
+ import { migrate, planMigrations, type MigrationStep } from "../migrations/migrator.js";
16
+ import { describeProblem, type MigrationFailure } from "../migrations/problems.js";
17
+
18
+ export interface CliOutput {
19
+ readonly log: (line: string) => void;
20
+ readonly error: (line: string) => void;
21
+ }
22
+
23
+ export interface RunMigrateCliOptions {
24
+ readonly config: Pick<SoftureConfig, "database" | "modules">;
25
+ /** The arguments after the command name, e.g. `["--plan"]`. */
26
+ readonly argv: readonly string[];
27
+ /** Relative paths resolve against it. Default: `process.cwd()`. */
28
+ readonly cwd?: string;
29
+ readonly output?: CliOutput;
30
+ }
31
+
32
+ export const EXIT_OK = 0;
33
+ export const EXIT_FAILED = 1;
34
+ export const EXIT_USAGE = 2;
35
+
36
+ export const MIGRATE_USAGE = `Usage: softure migrate [options]
37
+
38
+ Applies the SQL migrations of every enabled module, each in its own schema.
39
+
40
+ Options:
41
+ --plan show what would be applied (or adopted) and change nothing
42
+ --adopt <module>@<version> record an existing schema as migrated, after comparing it
43
+ --migrations-dir <dir> read module files from <dir>/<module id>/ (bundled runners)
44
+ --export-migrations <dir> copy module files to <dir>/<module id>/ and exit (build stage)
45
+ --help show this help`;
46
+
47
+ const ADOPT_TARGET = /^([a-z][a-z0-9]*(?:-[a-z0-9]+)*)@(\d+\.\d+\.\d+)$/;
48
+
49
+ const consoleOutput: CliOutput = {
50
+ log: (line) => console.log(line),
51
+ error: (line) => console.error(line),
52
+ };
53
+
54
+ type Command =
55
+ | { kind: "help" }
56
+ | { kind: "export"; targetDir: string }
57
+ | { kind: "migrate"; plan: boolean; migrationsDir: string | undefined }
58
+ | { kind: "adopt"; plan: boolean; module: string; version: string; migrationsDir: string | undefined };
59
+
60
+ /** Runs the command and returns the process exit code: 0 done, 1 failed, 2 usage error. */
61
+ export async function runMigrateCli(options: RunMigrateCliOptions): Promise<number> {
62
+ const output = options.output ?? consoleOutput;
63
+ const cwd = options.cwd ?? process.cwd();
64
+ const command = parseCommand(options.argv);
65
+ if (typeof command === "string") {
66
+ output.error(`softure migrate: ${command}`);
67
+ output.error(MIGRATE_USAGE);
68
+ return EXIT_USAGE;
69
+ }
70
+ if (command.kind === "help") {
71
+ output.log(MIGRATE_USAGE);
72
+ return EXIT_OK;
73
+ }
74
+ if (command.kind === "export") {
75
+ const result = await exportMigrations(options.config.modules, resolve(cwd, command.targetDir));
76
+ if (!result.ok) return reportFailure(output, result);
77
+ result.value.forEach((step) => output.log(`exported ${formatStep(step)}`));
78
+ return EXIT_OK;
79
+ }
80
+
81
+ const url = options.config.database?.url;
82
+ if (url === undefined) {
83
+ output.error("softure migrate: the config has no database; set database.url in softure.config");
84
+ return EXIT_FAILED;
85
+ }
86
+ let handle: DatabaseHandle;
87
+ try {
88
+ handle = await createDatabase(url);
89
+ } catch (error) {
90
+ output.error(`softure migrate: ${describeError(error)}`);
91
+ return EXIT_FAILED;
92
+ }
93
+ try {
94
+ const migrationsDir = command.migrationsDir === undefined ? undefined : pathToFileURL(`${resolve(cwd, command.migrationsDir)}/`);
95
+ return await runDatabaseCommand(handle, { command, modules: options.config.modules, migrationsDir, output });
96
+ } catch (error) {
97
+ // Connection and driver errors: the message only, never a stack or the database URL.
98
+ output.error(`softure migrate: ${describeError(error)}`);
99
+ return EXIT_FAILED;
100
+ } finally {
101
+ await handle.close();
102
+ }
103
+ }
104
+
105
+ async function runDatabaseCommand(
106
+ handle: DatabaseHandle,
107
+ input: {
108
+ command: Extract<Command, { kind: "migrate" | "adopt" }>;
109
+ modules: RunMigrateCliOptions["config"]["modules"];
110
+ migrationsDir: URL | undefined;
111
+ output: CliOutput;
112
+ },
113
+ ): Promise<number> {
114
+ const { command, modules, output } = input;
115
+ const dirOption = input.migrationsDir === undefined ? {} : { migrationsDir: input.migrationsDir };
116
+
117
+ if (command.kind === "adopt") {
118
+ const result = await adoptModule(handle, { modules, module: command.module, version: command.version, dryRun: command.plan, ...dirOption });
119
+ if (!result.ok) return reportFailure(output, result);
120
+ const verb = command.plan ? "would" : "did";
121
+ result.value.ledger.forEach((step) => output.log(`${verb} apply ${formatStep(step)}`));
122
+ result.value.adopted.forEach((step) => output.log(`${verb} adopt ${formatStep(step)}`));
123
+ output.log(`${command.module}@${command.version}: the schema matches its migrations${command.plan ? " (plan only, nothing written)" : "; adopted"}`);
124
+ return EXIT_OK;
125
+ }
126
+
127
+ if (command.plan) {
128
+ const result = await planMigrations(handle, { modules, ...dirOption });
129
+ if (!result.ok) return reportFailure(output, result);
130
+ result.value.pending.forEach((step) => output.log(`pending ${formatStep(step)}`));
131
+ output.log(result.value.pending.length === 0 ? "nothing to apply" : `${result.value.pending.length} migration(s) to apply`);
132
+ return EXIT_OK;
133
+ }
134
+
135
+ const result = await migrate(handle, { modules, ...dirOption, onApplied: (step) => output.log(`applied ${formatStep(step)}`) });
136
+ if (!result.ok) return reportFailure(output, result);
137
+ output.log(result.value.applied.length === 0 ? "nothing to apply" : `${result.value.applied.length} migration(s) applied`);
138
+ return EXIT_OK;
139
+ }
140
+
141
+ function parseCommand(argv: readonly string[]): Command | string {
142
+ let values: { plan?: boolean; adopt?: string; "migrations-dir"?: string; "export-migrations"?: string; help?: boolean };
143
+ try {
144
+ ({ values } = parseArgs({
145
+ args: [...argv],
146
+ options: {
147
+ plan: { type: "boolean" },
148
+ adopt: { type: "string" },
149
+ "migrations-dir": { type: "string" },
150
+ "export-migrations": { type: "string" },
151
+ help: { type: "boolean" },
152
+ },
153
+ strict: true,
154
+ allowPositionals: false,
155
+ }));
156
+ } catch (error) {
157
+ return describeError(error);
158
+ }
159
+
160
+ if (values.help === true) return { kind: "help" };
161
+ const exportDir = values["export-migrations"];
162
+ if (exportDir !== undefined) {
163
+ const others = (["plan", "adopt", "migrations-dir"] as const).filter((name) => values[name] !== undefined);
164
+ return others.length > 0 ? `--export-migrations cannot be combined with --${others.join(", --")}` : { kind: "export", targetDir: exportDir };
165
+ }
166
+ const plan = values.plan === true;
167
+ const migrationsDir = values["migrations-dir"];
168
+ if (values.adopt === undefined) {
169
+ return { kind: "migrate", plan, migrationsDir };
170
+ }
171
+ const match = ADOPT_TARGET.exec(values.adopt);
172
+ if (match?.[1] === undefined || match[2] === undefined) {
173
+ return `--adopt expects <module>@<x.y.z>, e.g. auth@0.1.0, got "${values.adopt}"`;
174
+ }
175
+ return { kind: "adopt", plan, module: match[1], version: match[2], migrationsDir };
176
+ }
177
+
178
+ function reportFailure(output: CliOutput, failure: MigrationFailure): number {
179
+ failure.problems.forEach((problem) => output.error(describeProblem(problem)));
180
+ return EXIT_FAILED;
181
+ }
182
+
183
+ function formatStep(step: MigrationStep): string {
184
+ return `${step.module} ${String(step.version).padStart(4, "0")}_${step.name}.sql`;
185
+ }
186
+
187
+ function describeError(error: unknown): string {
188
+ return error instanceof Error ? error.message : String(error);
189
+ }
package/src/client.ts ADDED
@@ -0,0 +1,79 @@
1
+ // The database client (FIRE_TRACKER `src/db/client.ts`, generalised). The driver follows the URL:
2
+ // `postgres://` or `postgresql://` → node-postgres (production), `pglite://<dir>` → PGlite, a
3
+ // real Postgres in-process (dev without a server); `pglite://` alone is an in-memory database.
4
+ // Drivers load through dynamic `import()`, so a bundle can keep both external and an app pays
5
+ // only for the one it uses.
6
+ import type { PGlite } from "@electric-sql/pglite";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+ import type { PgliteDatabase } from "drizzle-orm/pglite";
9
+ import type { Pool } from "pg";
10
+
11
+ const PGLITE_PREFIX = "pglite://";
12
+ const POSTGRES_PREFIXES = ["postgres://", "postgresql://"];
13
+
14
+ export type PostgresDatabase = NodePgDatabase & { $client: Pool };
15
+ export type PgliteClientDatabase = PgliteDatabase & { $client: PGlite };
16
+
17
+ /** The drizzle database modules and the app query through. */
18
+ export type Database = PostgresDatabase | PgliteClientDatabase;
19
+
20
+ /**
21
+ * A connection *or* an open transaction. Query helpers take this, so one function serves a
22
+ * plain read and a step inside `db.transaction()` (FIRE `client.ts`).
23
+ */
24
+ export type Queryable = Database | Parameters<Parameters<Database["transaction"]>[0]>[0];
25
+
26
+ export type DatabaseHandle =
27
+ | {
28
+ readonly kind: "postgres";
29
+ readonly db: PostgresDatabase;
30
+ readonly pool: Pool;
31
+ readonly close: () => Promise<void>;
32
+ }
33
+ | {
34
+ readonly kind: "pglite";
35
+ readonly db: PgliteClientDatabase;
36
+ readonly client: PGlite;
37
+ readonly close: () => Promise<void>;
38
+ };
39
+
40
+ export interface CreateDatabaseOptions {
41
+ /** Pool size for Postgres; ignored by PGlite, which has a single connection. Default 10. */
42
+ readonly max?: number;
43
+ }
44
+
45
+ /**
46
+ * Opens the database a URL points at. Throws when the scheme is not supported: that is a
47
+ * deployment bug. The message names the scheme only, never the URL, which holds a password.
48
+ */
49
+ export async function createDatabase(url: string, options: CreateDatabaseOptions = {}): Promise<DatabaseHandle> {
50
+ if (url.startsWith(PGLITE_PREFIX)) {
51
+ const dataDir = url.slice(PGLITE_PREFIX.length);
52
+ const { PGlite } = await import("@electric-sql/pglite");
53
+ const client = dataDir === "" ? new PGlite() : new PGlite(dataDir);
54
+ return createPgliteHandle(client);
55
+ }
56
+ if (POSTGRES_PREFIXES.some((prefix) => url.startsWith(prefix))) {
57
+ const { default: pg } = await import("pg");
58
+ const { drizzle } = await import("drizzle-orm/node-postgres");
59
+ const pool = new pg.Pool({ connectionString: url, max: options.max ?? 10 });
60
+ // An idle client that loses its connection emits 'error' on the pool; unhandled, it would
61
+ // crash the process. The pool drops that client and the next query opens a new one.
62
+ pool.on("error", () => undefined);
63
+ return { kind: "postgres", db: drizzle({ client: pool }), pool, close: () => pool.end() };
64
+ }
65
+ throw new Error(
66
+ `createDatabase: unsupported database URL scheme ${describeScheme(url)}; use postgres://, postgresql:// or pglite://<dir>`,
67
+ );
68
+ }
69
+
70
+ /** Wraps an open PGlite instance (used by `createTestDatabase`). */
71
+ export async function createPgliteHandle(client: PGlite): Promise<DatabaseHandle & { kind: "pglite" }> {
72
+ const { drizzle } = await import("drizzle-orm/pglite");
73
+ return { kind: "pglite", db: drizzle({ client }), client, close: () => client.close() };
74
+ }
75
+
76
+ function describeScheme(url: string): string {
77
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url)?.[1];
78
+ return scheme === undefined ? "(none)" : `"${scheme}:"`;
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Public API of @softure-ai/db: the database client and the module migrator
2
+ // (docs/02-module-standard.md §4). Test databases: `@softure-ai/db/testing`; CLI: `@softure-ai/db/cli`.
3
+ export {
4
+ createDatabase,
5
+ type CreateDatabaseOptions,
6
+ type Database,
7
+ type DatabaseHandle,
8
+ type PgliteClientDatabase,
9
+ type PostgresDatabase,
10
+ type Queryable,
11
+ } from "./client.js";
12
+ export { closeSharedDatabases, getSharedDatabase } from "./shared.js";
13
+ export { computeChecksum, readMigrationFiles, type MigrationFile } from "./migrations/files.js";
14
+ export {
15
+ describeProblem,
16
+ type MigrationErrorCode,
17
+ type MigrationFailure,
18
+ type MigrationProblem,
19
+ type MigrationResult,
20
+ } from "./migrations/problems.js";
21
+ export {
22
+ migrate,
23
+ planMigrations,
24
+ type MigrateOptions,
25
+ type MigrationPlan,
26
+ type MigrationReport,
27
+ type MigrationStep,
28
+ } from "./migrations/migrator.js";
29
+ export { adoptModule, type AdoptionReport, type AdoptOptions } from "./migrations/adopt.js";
30
+ export { exportMigrations } from "./migrations/export.js";
31
+ export {
32
+ checkExportedMigrations,
33
+ type ExportedMigration,
34
+ type ExportedMigrationsCheck,
35
+ } from "./migrations/exported.js";
36
+ export { readJournal, type JournalRow, type MigrationMethod } from "./migrations/ledger.js";
37
+ export type { MigrationSession } from "./migrations/session.js";