@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
@@ -0,0 +1,62 @@
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`). An app with its own migrations
5
+ // passes the same `app` hooks (and baseline) as to `migrate`, so module tables can reference app
6
+ // tables and a module the app's history already creates is adopted in the template.
7
+ import { PGlite } from "@electric-sql/pglite";
8
+ import { createPgliteHandle } from "./client.js";
9
+ import { migrate } from "./migrations/migrator.js";
10
+ import { describeProblem } from "./migrations/problems.js";
11
+ const templates = new Map();
12
+ const hookIds = new WeakMap();
13
+ let nextHookId = 1;
14
+ /**
15
+ * A fresh, migrated, in-memory database per call. List the module under test and the modules it
16
+ * depends on. Throws when a migration (a module file or an app hook) fails: that is a bug in the
17
+ * module package or the app's migrations.
18
+ */
19
+ export async function createTestDatabase(modules = [], options = {}) {
20
+ const client = new PGlite({ loadDataDir: await getTemplate(modules, options.app ?? {}) });
21
+ const handle = await createPgliteHandle(client);
22
+ return { db: handle.db, client, close: handle.close };
23
+ }
24
+ function getTemplate(modules, app) {
25
+ const moduleKey = modules.map((module) => `${module.id}@${module.migrations?.dir.href ?? "-"}`).join("|");
26
+ const baselineKey = Object.entries(app.baseline ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([id, through]) => `${id}:${through}`).join(",");
27
+ const key = `${moduleKey}#before:${identifyHook(app.before)}#after:${identifyHook(app.after)}#baseline:${baselineKey}`;
28
+ let template = templates.get(key);
29
+ if (template === undefined) {
30
+ template = buildTemplate(modules, app);
31
+ templates.set(key, template);
32
+ // A failed build is not cached: the next call reports the problem again.
33
+ template.catch(() => templates.delete(key));
34
+ }
35
+ return template;
36
+ }
37
+ // Hooks are functions, so the key holds an id per hook object rather than its source text.
38
+ function identifyHook(hook) {
39
+ if (hook === undefined) {
40
+ return "-";
41
+ }
42
+ let id = hookIds.get(hook);
43
+ if (id === undefined) {
44
+ id = nextHookId++;
45
+ hookIds.set(hook, id);
46
+ }
47
+ return String(id);
48
+ }
49
+ async function buildTemplate(modules, app) {
50
+ const handle = await createPgliteHandle(new PGlite());
51
+ try {
52
+ const result = await migrate(handle, { modules, app });
53
+ if (!result.ok) {
54
+ throw new Error(`createTestDatabase: migrations failed:\n${result.problems.map(describeProblem).join("\n")}`);
55
+ }
56
+ return await handle.client.dumpDataDir("none");
57
+ }
58
+ finally {
59
+ await handle.close();
60
+ }
61
+ }
62
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,75 @@
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.6",
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
+ "CHANGELOG.md",
19
+ "src",
20
+ "!src/**/*.test.ts",
21
+ "!src/**/*.test.tsx"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "bin": {
28
+ "softure": "./dist/cli/bin.js"
29
+ },
30
+ "exports": {
31
+ ".": {
32
+ "@softure-ai/source": "./src/index.ts",
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./testing": {
37
+ "@softure-ai/source": "./src/testing.ts",
38
+ "types": "./dist/testing.d.ts",
39
+ "default": "./dist/testing.js"
40
+ },
41
+ "./cli": {
42
+ "@softure-ai/source": "./src/cli/index.ts",
43
+ "types": "./dist/cli/index.d.ts",
44
+ "default": "./dist/cli/index.js"
45
+ }
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.build.json"
49
+ },
50
+ "dependencies": {
51
+ "@softure-ai/core": "^0.1.0"
52
+ },
53
+ "peerDependencies": {
54
+ "@electric-sql/pglite": "^0.5.0",
55
+ "@types/pg": "^8.11.0",
56
+ "drizzle-orm": "^0.45.2",
57
+ "pg": "^8.11.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@electric-sql/pglite": {
61
+ "optional": true
62
+ },
63
+ "@types/pg": {
64
+ "optional": true
65
+ },
66
+ "pg": {
67
+ "optional": true
68
+ }
69
+ },
70
+ "devDependencies": {
71
+ "@electric-sql/pglite": "^0.5.8",
72
+ "@types/pg": "^8.23.1",
73
+ "pg": "^8.23.1"
74
+ }
75
+ }
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,250 @@
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
+ //
9
+ // An app with its own migrations passes them as `app: { before, after }` (see MigrateOptions.app).
10
+ import { resolve } from "node:path";
11
+ import { parseArgs } from "node:util";
12
+ import { pathToFileURL } from "node:url";
13
+ import type { SoftureConfig } from "@softure-ai/core";
14
+ import type { DatabaseHandle } from "../client.js";
15
+ import { openCommandDatabase, type CommandDatabase } from "../configured.js";
16
+ import { adoptModule } from "../migrations/adopt.js";
17
+ import { exportMigrations } from "../migrations/export.js";
18
+ import { migrate, planMigrations, runAppMigrations, type AppMigrations, type MigrationStep } from "../migrations/migrator.js";
19
+ import { describeProblem, type MigrationFailure } from "../migrations/problems.js";
20
+
21
+ export interface CliOutput {
22
+ readonly log: (line: string) => void;
23
+ readonly error: (line: string) => void;
24
+ }
25
+
26
+ export interface RunMigrateCliOptions {
27
+ readonly config: Pick<SoftureConfig, "database" | "modules">;
28
+ /** The arguments after the command name, e.g. `["--plan"]`. */
29
+ readonly argv: readonly string[];
30
+ /** Relative paths resolve against it. Default: `process.cwd()`. */
31
+ readonly cwd?: string;
32
+ readonly output?: CliOutput;
33
+ /**
34
+ * The app's own migrations: migrate runs `before`, the module files (adopting `baseline` where
35
+ * it applies), then `after`; `--adopt` (without `--plan`) runs `before` first; `--plan` runs no
36
+ * hook and marks the files a baseline may adopt.
37
+ */
38
+ readonly app?: AppMigrations;
39
+ }
40
+
41
+ export const EXIT_OK = 0;
42
+ export const EXIT_FAILED = 1;
43
+ export const EXIT_USAGE = 2;
44
+
45
+ export const MIGRATE_USAGE = `Usage: softure migrate [options]
46
+
47
+ Applies the SQL migrations of every enabled module, each in its own schema.
48
+
49
+ Options:
50
+ --plan show what would be applied (or adopted) and change nothing
51
+ --adopt <module>@<version> record an existing schema as migrated, after comparing it;
52
+ applies the pending migrations of its dependencies first
53
+ --through <n> with --adopt: adopt files 1..n only, leave the rest to migrate
54
+ --migrations-dir <dir> read module files from <dir>/<module id>/ (bundled runners)
55
+ --export-migrations <dir> copy module files to <dir>/<module id>/ and exit (build stage)
56
+ --help show this help`;
57
+
58
+ const ADOPT_TARGET = /^([a-z][a-z0-9]*(?:-[a-z0-9]+)*)@(\d+\.\d+\.\d+)$/;
59
+
60
+ const consoleOutput: CliOutput = {
61
+ log: (line) => console.log(line),
62
+ error: (line) => console.error(line),
63
+ };
64
+
65
+ type Command =
66
+ | { kind: "help" }
67
+ | { kind: "export"; targetDir: string }
68
+ | { kind: "migrate"; plan: boolean; migrationsDir: string | undefined }
69
+ | { kind: "adopt"; plan: boolean; module: string; version: string; through: number | undefined; migrationsDir: string | undefined };
70
+
71
+ /** Runs the command and returns the process exit code: 0 done, 1 failed, 2 usage error. */
72
+ export async function runMigrateCli(options: RunMigrateCliOptions): Promise<number> {
73
+ const output = options.output ?? consoleOutput;
74
+ const cwd = options.cwd ?? process.cwd();
75
+ const command = parseCommand(options.argv);
76
+ if (typeof command === "string") {
77
+ output.error(`softure migrate: ${command}`);
78
+ output.error(MIGRATE_USAGE);
79
+ return EXIT_USAGE;
80
+ }
81
+ if (command.kind === "help") {
82
+ output.log(MIGRATE_USAGE);
83
+ return EXIT_OK;
84
+ }
85
+ if (command.kind === "export") {
86
+ const result = await exportMigrations(options.config.modules, resolve(cwd, command.targetDir));
87
+ if (!result.ok) return reportFailure(output, result);
88
+ result.value.forEach((step) => output.log(`exported ${formatStep(step)}`));
89
+ return EXIT_OK;
90
+ }
91
+
92
+ const database = options.config.database;
93
+ if (database === null) {
94
+ output.error("softure migrate: the config has no database; set database.url in softure.config");
95
+ return EXIT_FAILED;
96
+ }
97
+ let opened: CommandDatabase;
98
+ try {
99
+ // The app's configured handle when it set one, so the app's hooks and the run share one instance.
100
+ opened = await openCommandDatabase(database);
101
+ } catch (error) {
102
+ output.error(`softure migrate: ${describeError(error)}`);
103
+ return EXIT_FAILED;
104
+ }
105
+ const { handle } = opened;
106
+ try {
107
+ const migrationsDir = command.migrationsDir === undefined ? undefined : pathToFileURL(`${resolve(cwd, command.migrationsDir)}/`);
108
+ return await runDatabaseCommand(handle, { command, modules: options.config.modules, migrationsDir, output, app: options.app ?? {} });
109
+ } catch (error) {
110
+ // Connection and driver errors: the message only, never a stack or the database URL.
111
+ output.error(`softure migrate: ${describeError(error)}`);
112
+ return EXIT_FAILED;
113
+ } finally {
114
+ await opened.close();
115
+ }
116
+ }
117
+
118
+ async function runDatabaseCommand(
119
+ handle: DatabaseHandle,
120
+ input: {
121
+ command: Extract<Command, { kind: "migrate" | "adopt" }>;
122
+ modules: RunMigrateCliOptions["config"]["modules"];
123
+ migrationsDir: URL | undefined;
124
+ output: CliOutput;
125
+ app: AppMigrations;
126
+ },
127
+ ): Promise<number> {
128
+ const { command, modules, output, app } = input;
129
+ const dirOption = input.migrationsDir === undefined ? {} : { migrationsDir: input.migrationsDir };
130
+
131
+ const hasAppMigrations = app.before !== undefined || app.after !== undefined;
132
+ if (command.plan && hasAppMigrations) {
133
+ output.log("app migrations: not planned; the app runs them itself on migrate");
134
+ }
135
+
136
+ if (command.kind === "adopt") {
137
+ if (!command.plan) {
138
+ const before = await runAppMigrations(handle, app, "before");
139
+ if (!before.ok) return reportFailure(output, before);
140
+ if (before.value.ran) output.log("applied app migrations (before)");
141
+ }
142
+ const result = await adoptModule(handle, {
143
+ modules,
144
+ module: command.module,
145
+ version: command.version,
146
+ dryRun: command.plan,
147
+ ...(command.through === undefined ? {} : { through: command.through }),
148
+ ...dirOption,
149
+ });
150
+ if (!result.ok) return reportFailure(output, result);
151
+ const verb = command.plan ? "would" : "did";
152
+ [...result.value.ledger, ...result.value.dependencies].forEach((step) => output.log(`${verb} apply ${formatStep(step)}`));
153
+ result.value.adopted.forEach((step) => output.log(`${verb} adopt ${formatStep(step)}`));
154
+ const scope = command.through === undefined ? "" : ` through ${formatVersion(command.through)}`;
155
+ output.log(`${command.module}@${command.version}: the schema matches its migrations${scope}${command.plan ? " (plan only, nothing written)" : "; adopted"}`);
156
+ return EXIT_OK;
157
+ }
158
+
159
+ if (command.plan) {
160
+ const result = await planMigrations(handle, { modules, ...dirOption, app });
161
+ if (!result.ok) return reportFailure(output, result);
162
+ const baseline = new Set(result.value.baseline);
163
+ result.value.pending.forEach((step) =>
164
+ output.log(`pending ${formatStep(step)}${baseline.has(step) ? " (adopted if its schema already holds objects)" : ""}`),
165
+ );
166
+ output.log(result.value.pending.length === 0 ? "nothing to apply" : `${result.value.pending.length} migration(s) to apply`);
167
+ return EXIT_OK;
168
+ }
169
+
170
+ const result = await migrate(handle, {
171
+ modules,
172
+ ...dirOption,
173
+ app,
174
+ onApplied: (step) => output.log(`applied ${formatStep(step)}`),
175
+ onAdopted: (step) => output.log(`adopted ${formatStep(step)}`),
176
+ onAppMigrated: (phase) => output.log(`applied app migrations (${phase})`),
177
+ });
178
+ if (!result.ok) return reportFailure(output, result);
179
+ output.log(formatSummary(result.value.applied.length, result.value.adopted.length));
180
+ return EXIT_OK;
181
+ }
182
+
183
+ function parseCommand(argv: readonly string[]): Command | string {
184
+ let values: { plan?: boolean; adopt?: string; through?: string; "migrations-dir"?: string; "export-migrations"?: string; help?: boolean };
185
+ try {
186
+ ({ values } = parseArgs({
187
+ args: [...argv],
188
+ options: {
189
+ plan: { type: "boolean" },
190
+ adopt: { type: "string" },
191
+ through: { type: "string" },
192
+ "migrations-dir": { type: "string" },
193
+ "export-migrations": { type: "string" },
194
+ help: { type: "boolean" },
195
+ },
196
+ strict: true,
197
+ allowPositionals: false,
198
+ }));
199
+ } catch (error) {
200
+ return describeError(error);
201
+ }
202
+
203
+ if (values.help === true) return { kind: "help" };
204
+ const exportDir = values["export-migrations"];
205
+ if (exportDir !== undefined) {
206
+ const others = (["plan", "adopt", "through", "migrations-dir"] as const).filter((name) => values[name] !== undefined);
207
+ return others.length > 0 ? `--export-migrations cannot be combined with --${others.join(", --")}` : { kind: "export", targetDir: exportDir };
208
+ }
209
+ const plan = values.plan === true;
210
+ const migrationsDir = values["migrations-dir"];
211
+ if (values.adopt === undefined) {
212
+ return values.through === undefined ? { kind: "migrate", plan, migrationsDir } : "--through needs --adopt <module>@<version>";
213
+ }
214
+ const match = ADOPT_TARGET.exec(values.adopt);
215
+ if (match?.[1] === undefined || match[2] === undefined) {
216
+ return `--adopt expects <module>@<x.y.z>, e.g. auth@0.1.0, got "${values.adopt}"`;
217
+ }
218
+ const through = values.through === undefined ? undefined : parseThrough(values.through);
219
+ if (through === null) {
220
+ return `--through expects a positive whole number, got "${values.through ?? ""}"`;
221
+ }
222
+ return { kind: "adopt", plan, module: match[1], version: match[2], through, migrationsDir };
223
+ }
224
+
225
+ /** A positive whole number written in digits, or null. */
226
+ function parseThrough(text: string): number | null {
227
+ return /^[1-9]\d*$/.test(text) ? Number(text) : null;
228
+ }
229
+
230
+ function reportFailure(output: CliOutput, failure: MigrationFailure): number {
231
+ failure.problems.forEach((problem) => output.error(describeProblem(problem)));
232
+ return EXIT_FAILED;
233
+ }
234
+
235
+ function formatStep(step: MigrationStep): string {
236
+ return `${step.module} ${formatVersion(step.version)}_${step.name}.sql`;
237
+ }
238
+
239
+ function formatVersion(version: number): string {
240
+ return String(version).padStart(4, "0");
241
+ }
242
+
243
+ function formatSummary(applied: number, adopted: number): string {
244
+ if (applied === 0 && adopted === 0) return "nothing to apply";
245
+ return adopted === 0 ? `${applied} migration(s) applied` : `${applied} migration(s) applied, ${adopted} adopted`;
246
+ }
247
+
248
+ function describeError(error: unknown): string {
249
+ return error instanceof Error ? error.message : String(error);
250
+ }
package/src/client.ts ADDED
@@ -0,0 +1,151 @@
1
+ // The database client. 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
+ // Both drivers are optional peers and load through dynamic `import()` only when a URL needs one, so an app
5
+ // installs and ships just its own. The specifiers stay literal: a Next.js app lists this package in
6
+ // `serverExternalPackages`, and output tracing follows these imports to copy the installed driver.
7
+ import type { PGlite } from "@electric-sql/pglite";
8
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
9
+ import type { PgliteDatabase } from "drizzle-orm/pglite";
10
+ import type { Pool } from "pg";
11
+
12
+ const PGLITE_PREFIX = "pglite://";
13
+ const POSTGRES_PREFIXES = ["postgres://", "postgresql://"];
14
+ const MODULE_NOT_FOUND_CODES = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]);
15
+
16
+ /** The optional peer a URL scheme needs, named in the error when it is not installed. */
17
+ export interface DriverRequirement {
18
+ readonly packageName: string;
19
+ readonly scheme: string;
20
+ }
21
+
22
+ const POSTGRES_DRIVER: DriverRequirement = { packageName: "pg", scheme: "postgres://" };
23
+ const PGLITE_DRIVER: DriverRequirement = { packageName: "@electric-sql/pglite", scheme: "pglite://" };
24
+
25
+ /**
26
+ * The drizzle schema a database is typed with. The default accepts any schema, so an app database made with
27
+ * `drizzle({ client, schema })` (and its transactions) can be passed wherever a module takes `Database` or
28
+ * `Queryable`; pass the app's schema to keep its relational `db.query` API typed.
29
+ */
30
+ export type DatabaseSchema = Record<string, unknown>;
31
+
32
+ export type PostgresDatabase<TSchema extends DatabaseSchema = DatabaseSchema> = NodePgDatabase<TSchema> & { $client: Pool };
33
+ export type PgliteClientDatabase<TSchema extends DatabaseSchema = DatabaseSchema> = PgliteDatabase<TSchema> & { $client: PGlite };
34
+
35
+ /** The drizzle database modules and the app query through. */
36
+ export type Database<TSchema extends DatabaseSchema = DatabaseSchema> = PostgresDatabase<TSchema> | PgliteClientDatabase<TSchema>;
37
+
38
+ /**
39
+ * A connection *or* an open transaction. Query helpers take this, so one function serves a
40
+ * plain read and a step inside `db.transaction()`.
41
+ */
42
+ export type Queryable<TSchema extends DatabaseSchema = DatabaseSchema> =
43
+ | Database<TSchema>
44
+ | Parameters<Parameters<Database<TSchema>["transaction"]>[0]>[0];
45
+
46
+ export type DatabaseHandle =
47
+ | {
48
+ readonly kind: "postgres";
49
+ readonly db: PostgresDatabase;
50
+ readonly pool: Pool;
51
+ readonly close: () => Promise<void>;
52
+ }
53
+ | {
54
+ readonly kind: "pglite";
55
+ readonly db: PgliteClientDatabase;
56
+ readonly client: PGlite;
57
+ readonly close: () => Promise<void>;
58
+ };
59
+
60
+ export interface CreateDatabaseOptions {
61
+ /** Pool size for Postgres; ignored by PGlite, which has a single connection. Default 10. */
62
+ readonly max?: number;
63
+ }
64
+
65
+ /**
66
+ * Opens the database a URL points at. Throws when the URL is empty or its scheme is not supported:
67
+ * that is a deployment bug. The message names the scheme only, never the URL, which holds a password.
68
+ * The config accepts an empty URL (a build has no DATABASE_URL), so this is where it is refused.
69
+ */
70
+ export async function createDatabase(url: string, options: CreateDatabaseOptions = {}): Promise<DatabaseHandle> {
71
+ if (url === "") {
72
+ throw new Error("createDatabase: the database URL is empty; set database.url in softure.config (usually from DATABASE_URL)");
73
+ }
74
+ if (url.startsWith(PGLITE_PREFIX)) {
75
+ const dataDir = url.slice(PGLITE_PREFIX.length);
76
+ const { PGlite } = await importDriver(PGLITE_DRIVER, () => import("@electric-sql/pglite"));
77
+ const client = dataDir === "" ? new PGlite() : new PGlite(dataDir);
78
+ return createPgliteHandle(client);
79
+ }
80
+ if (POSTGRES_PREFIXES.some((prefix) => url.startsWith(prefix))) {
81
+ const { default: pg } = await importDriver(POSTGRES_DRIVER, () => import("pg"));
82
+ const pool = new pg.Pool({ connectionString: url, max: options.max ?? 10 });
83
+ // An idle client that loses its connection emits 'error' on the pool; unhandled, it would
84
+ // crash the process. The pool drops that client and the next query opens a new one.
85
+ pool.on("error", () => undefined);
86
+ return createPostgresHandle(pool);
87
+ }
88
+ throw new Error(
89
+ `createDatabase: unsupported database URL scheme ${describeScheme(url)}; use postgres://, postgresql:// or pglite://<dir>`,
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Wraps an open PGlite instance, e.g. the app's own, for `database.handle` in softure.config. `close` closes the
95
+ * instance, which writes a `pglite://<dir>` database back to its directory.
96
+ */
97
+ export async function createPgliteHandle(client: PGlite): Promise<DatabaseHandle & { kind: "pglite" }> {
98
+ const { drizzle } = await importDriver(PGLITE_DRIVER, () => import("drizzle-orm/pglite"));
99
+ return { kind: "pglite", db: drizzle({ client }), client, close: () => client.close() };
100
+ }
101
+
102
+ /**
103
+ * Wraps an open node-postgres pool, e.g. the app's own, for `database.handle` in softure.config. `close` ends
104
+ * the pool. The pool's `error` listener stays the app's: add one, or an idle client's lost connection crashes
105
+ * the process.
106
+ */
107
+ export async function createPostgresHandle(pool: Pool): Promise<DatabaseHandle & { kind: "postgres" }> {
108
+ const { drizzle } = await importDriver(POSTGRES_DRIVER, () => import("drizzle-orm/node-postgres"));
109
+ return { kind: "postgres", db: drizzle({ client: pool }), pool, close: () => pool.end() };
110
+ }
111
+
112
+ /** Whether `value` has the shape of a `DatabaseHandle` (what `database.handle` must return). */
113
+ export function isDatabaseHandle(value: unknown): value is DatabaseHandle {
114
+ if (typeof value !== "object" || value === null) return false;
115
+ const candidate = value as Record<string, unknown>;
116
+ if (typeof candidate.db !== "object" || candidate.db === null || typeof candidate.close !== "function") return false;
117
+ if (candidate.kind === "postgres") return typeof candidate.pool === "object" && candidate.pool !== null;
118
+ if (candidate.kind === "pglite") return typeof candidate.client === "object" && candidate.client !== null;
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * Turns Node's "Cannot find package" for a driver (from the driver import or from drizzle's adapter, which imports
124
+ * it statically) into an error that says what to install. Anything else, including a missing package other than
125
+ * the driver, is returned unchanged.
126
+ */
127
+ export function explainMissingDriver(error: unknown, driver: DriverRequirement): unknown {
128
+ if (!(error instanceof Error)) return error;
129
+ const code = (error as Error & { code?: unknown }).code;
130
+ if (typeof code !== "string" || !MODULE_NOT_FOUND_CODES.has(code)) return error;
131
+ if (!error.message.includes(`'${driver.packageName}'`)) return error;
132
+ return new Error(
133
+ `createDatabase: ${driver.scheme} URLs need the "${driver.packageName}" package, which is not installed; run ` +
134
+ `\`npm install ${driver.packageName}\` and, in a Next.js app, list "@softure-ai/db" and "${driver.packageName}" ` +
135
+ "in serverExternalPackages (db README §2)",
136
+ { cause: error },
137
+ );
138
+ }
139
+
140
+ async function importDriver<T>(driver: DriverRequirement, load: () => Promise<T>): Promise<T> {
141
+ try {
142
+ return await load();
143
+ } catch (error) {
144
+ throw explainMissingDriver(error, driver);
145
+ }
146
+ }
147
+
148
+ function describeScheme(url: string): string {
149
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url)?.[1];
150
+ return scheme === undefined ? "(none)" : `"${scheme}:"`;
151
+ }