@softure-ai/db 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +261 -22
  3. package/dist/cli/run.d.ts +8 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +71 -21
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/client.d.ts +39 -8
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +64 -8
  10. package/dist/client.js.map +1 -1
  11. package/dist/configured.d.ts +31 -0
  12. package/dist/configured.d.ts.map +1 -0
  13. package/dist/configured.js +74 -0
  14. package/dist/configured.js.map +1 -0
  15. package/dist/driver-errors.d.ts +21 -0
  16. package/dist/driver-errors.d.ts.map +1 -0
  17. package/dist/driver-errors.js +25 -0
  18. package/dist/driver-errors.js.map +1 -0
  19. package/dist/index.d.ts +6 -3
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -3
  22. package/dist/index.js.map +1 -1
  23. package/dist/migrations/adopt.d.ts +8 -1
  24. package/dist/migrations/adopt.d.ts.map +1 -1
  25. package/dist/migrations/adopt.js +53 -87
  26. package/dist/migrations/adopt.js.map +1 -1
  27. package/dist/migrations/apply.d.ts +35 -0
  28. package/dist/migrations/apply.d.ts.map +1 -0
  29. package/dist/migrations/apply.js +115 -0
  30. package/dist/migrations/apply.js.map +1 -0
  31. package/dist/migrations/migrator.d.ts +55 -27
  32. package/dist/migrations/migrator.d.ts.map +1 -1
  33. package/dist/migrations/migrator.js +187 -103
  34. package/dist/migrations/migrator.js.map +1 -1
  35. package/dist/migrations/problems.d.ts +9 -0
  36. package/dist/migrations/problems.d.ts.map +1 -1
  37. package/dist/migrations/problems.js +9 -3
  38. package/dist/migrations/problems.js.map +1 -1
  39. package/dist/migrations/reference.d.ts +23 -0
  40. package/dist/migrations/reference.d.ts.map +1 -0
  41. package/dist/migrations/reference.js +64 -0
  42. package/dist/migrations/reference.js.map +1 -0
  43. package/dist/migrations/session.d.ts +2 -1
  44. package/dist/migrations/session.d.ts.map +1 -1
  45. package/dist/migrations/session.js +19 -2
  46. package/dist/migrations/session.js.map +1 -1
  47. package/dist/migrations/stubs.d.ts +23 -0
  48. package/dist/migrations/stubs.d.ts.map +1 -0
  49. package/dist/migrations/stubs.js +99 -0
  50. package/dist/migrations/stubs.js.map +1 -0
  51. package/dist/process.d.ts +23 -0
  52. package/dist/process.d.ts.map +1 -0
  53. package/dist/process.js +82 -0
  54. package/dist/process.js.map +1 -0
  55. package/dist/shared.d.ts +2 -0
  56. package/dist/shared.d.ts.map +1 -1
  57. package/dist/shared.js +9 -0
  58. package/dist/shared.js.map +1 -1
  59. package/dist/testing.d.ts +11 -2
  60. package/dist/testing.d.ts.map +1 -1
  61. package/dist/testing.js +28 -9
  62. package/dist/testing.js.map +1 -1
  63. package/package.json +22 -5
  64. package/src/cli/run.ts +84 -23
  65. package/src/client.ts +88 -16
  66. package/src/configured.ts +109 -0
  67. package/src/driver-errors.ts +40 -0
  68. package/src/index.ts +12 -1
  69. package/src/migrations/adopt.ts +65 -100
  70. package/src/migrations/apply.ts +135 -0
  71. package/src/migrations/migrator.ts +246 -128
  72. package/src/migrations/problems.ts +13 -5
  73. package/src/migrations/reference.ts +78 -0
  74. package/src/migrations/session.ts +28 -2
  75. package/src/migrations/stubs.ts +139 -0
  76. package/src/process.ts +110 -0
  77. package/src/shared.ts +9 -0
  78. package/src/testing.ts +38 -10
package/src/cli/run.ts CHANGED
@@ -5,14 +5,17 @@
5
5
  // import { runMigrateCli } from "@softure-ai/db/cli";
6
6
  // import config from "../softure.config";
7
7
  // process.exitCode = await runMigrateCli({ config, argv: process.argv.slice(2) });
8
+ //
9
+ // An app with its own migrations passes them as `app: { before, after }` (see MigrateOptions.app).
8
10
  import { resolve } from "node:path";
9
11
  import { parseArgs } from "node:util";
10
12
  import { pathToFileURL } from "node:url";
11
13
  import type { SoftureConfig } from "@softure-ai/core";
12
- import { createDatabase, type DatabaseHandle } from "../client.js";
14
+ import type { DatabaseHandle } from "../client.js";
15
+ import { openCommandDatabase, type CommandDatabase } from "../configured.js";
13
16
  import { adoptModule } from "../migrations/adopt.js";
14
17
  import { exportMigrations } from "../migrations/export.js";
15
- import { migrate, planMigrations, type MigrationStep } from "../migrations/migrator.js";
18
+ import { migrate, planMigrations, runAppMigrations, type AppMigrations, type MigrationStep } from "../migrations/migrator.js";
16
19
  import { describeProblem, type MigrationFailure } from "../migrations/problems.js";
17
20
 
18
21
  export interface CliOutput {
@@ -27,6 +30,12 @@ export interface RunMigrateCliOptions {
27
30
  /** Relative paths resolve against it. Default: `process.cwd()`. */
28
31
  readonly cwd?: string;
29
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;
30
39
  }
31
40
 
32
41
  export const EXIT_OK = 0;
@@ -39,7 +48,9 @@ Applies the SQL migrations of every enabled module, each in its own schema.
39
48
 
40
49
  Options:
41
50
  --plan show what would be applied (or adopted) and change nothing
42
- --adopt <module>@<version> record an existing schema as migrated, after comparing it
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
43
54
  --migrations-dir <dir> read module files from <dir>/<module id>/ (bundled runners)
44
55
  --export-migrations <dir> copy module files to <dir>/<module id>/ and exit (build stage)
45
56
  --help show this help`;
@@ -55,7 +66,7 @@ type Command =
55
66
  | { kind: "help" }
56
67
  | { kind: "export"; targetDir: string }
57
68
  | { kind: "migrate"; plan: boolean; migrationsDir: string | undefined }
58
- | { kind: "adopt"; plan: boolean; module: string; version: string; migrationsDir: string | undefined };
69
+ | { kind: "adopt"; plan: boolean; module: string; version: string; through: number | undefined; migrationsDir: string | undefined };
59
70
 
60
71
  /** Runs the command and returns the process exit code: 0 done, 1 failed, 2 usage error. */
61
72
  export async function runMigrateCli(options: RunMigrateCliOptions): Promise<number> {
@@ -78,27 +89,29 @@ export async function runMigrateCli(options: RunMigrateCliOptions): Promise<numb
78
89
  return EXIT_OK;
79
90
  }
80
91
 
81
- const url = options.config.database?.url;
82
- if (url === undefined) {
92
+ const database = options.config.database;
93
+ if (database === null) {
83
94
  output.error("softure migrate: the config has no database; set database.url in softure.config");
84
95
  return EXIT_FAILED;
85
96
  }
86
- let handle: DatabaseHandle;
97
+ let opened: CommandDatabase;
87
98
  try {
88
- handle = await createDatabase(url);
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);
89
101
  } catch (error) {
90
102
  output.error(`softure migrate: ${describeError(error)}`);
91
103
  return EXIT_FAILED;
92
104
  }
105
+ const { handle } = opened;
93
106
  try {
94
107
  const migrationsDir = command.migrationsDir === undefined ? undefined : pathToFileURL(`${resolve(cwd, command.migrationsDir)}/`);
95
- return await runDatabaseCommand(handle, { command, modules: options.config.modules, migrationsDir, output });
108
+ return await runDatabaseCommand(handle, { command, modules: options.config.modules, migrationsDir, output, app: options.app ?? {} });
96
109
  } catch (error) {
97
110
  // Connection and driver errors: the message only, never a stack or the database URL.
98
111
  output.error(`softure migrate: ${describeError(error)}`);
99
112
  return EXIT_FAILED;
100
113
  } finally {
101
- await handle.close();
114
+ await opened.close();
102
115
  }
103
116
  }
104
117
 
@@ -109,43 +122,73 @@ async function runDatabaseCommand(
109
122
  modules: RunMigrateCliOptions["config"]["modules"];
110
123
  migrationsDir: URL | undefined;
111
124
  output: CliOutput;
125
+ app: AppMigrations;
112
126
  },
113
127
  ): Promise<number> {
114
- const { command, modules, output } = input;
128
+ const { command, modules, output, app } = input;
115
129
  const dirOption = input.migrationsDir === undefined ? {} : { migrationsDir: input.migrationsDir };
116
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
+
117
136
  if (command.kind === "adopt") {
118
- const result = await adoptModule(handle, { modules, module: command.module, version: command.version, dryRun: command.plan, ...dirOption });
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
+ });
119
150
  if (!result.ok) return reportFailure(output, result);
120
151
  const verb = command.plan ? "would" : "did";
121
- result.value.ledger.forEach((step) => output.log(`${verb} apply ${formatStep(step)}`));
152
+ [...result.value.ledger, ...result.value.dependencies].forEach((step) => output.log(`${verb} apply ${formatStep(step)}`));
122
153
  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"}`);
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"}`);
124
156
  return EXIT_OK;
125
157
  }
126
158
 
127
159
  if (command.plan) {
128
- const result = await planMigrations(handle, { modules, ...dirOption });
160
+ const result = await planMigrations(handle, { modules, ...dirOption, app });
129
161
  if (!result.ok) return reportFailure(output, result);
130
- result.value.pending.forEach((step) => output.log(`pending ${formatStep(step)}`));
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
+ );
131
166
  output.log(result.value.pending.length === 0 ? "nothing to apply" : `${result.value.pending.length} migration(s) to apply`);
132
167
  return EXIT_OK;
133
168
  }
134
169
 
135
- const result = await migrate(handle, { modules, ...dirOption, onApplied: (step) => output.log(`applied ${formatStep(step)}`) });
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
+ });
136
178
  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`);
179
+ output.log(formatSummary(result.value.applied.length, result.value.adopted.length));
138
180
  return EXIT_OK;
139
181
  }
140
182
 
141
183
  function parseCommand(argv: readonly string[]): Command | string {
142
- let values: { plan?: boolean; adopt?: string; "migrations-dir"?: string; "export-migrations"?: string; help?: boolean };
184
+ let values: { plan?: boolean; adopt?: string; through?: string; "migrations-dir"?: string; "export-migrations"?: string; help?: boolean };
143
185
  try {
144
186
  ({ values } = parseArgs({
145
187
  args: [...argv],
146
188
  options: {
147
189
  plan: { type: "boolean" },
148
190
  adopt: { type: "string" },
191
+ through: { type: "string" },
149
192
  "migrations-dir": { type: "string" },
150
193
  "export-migrations": { type: "string" },
151
194
  help: { type: "boolean" },
@@ -160,19 +203,28 @@ function parseCommand(argv: readonly string[]): Command | string {
160
203
  if (values.help === true) return { kind: "help" };
161
204
  const exportDir = values["export-migrations"];
162
205
  if (exportDir !== undefined) {
163
- const others = (["plan", "adopt", "migrations-dir"] as const).filter((name) => values[name] !== undefined);
206
+ const others = (["plan", "adopt", "through", "migrations-dir"] as const).filter((name) => values[name] !== undefined);
164
207
  return others.length > 0 ? `--export-migrations cannot be combined with --${others.join(", --")}` : { kind: "export", targetDir: exportDir };
165
208
  }
166
209
  const plan = values.plan === true;
167
210
  const migrationsDir = values["migrations-dir"];
168
211
  if (values.adopt === undefined) {
169
- return { kind: "migrate", plan, migrationsDir };
212
+ return values.through === undefined ? { kind: "migrate", plan, migrationsDir } : "--through needs --adopt <module>@<version>";
170
213
  }
171
214
  const match = ADOPT_TARGET.exec(values.adopt);
172
215
  if (match?.[1] === undefined || match[2] === undefined) {
173
216
  return `--adopt expects <module>@<x.y.z>, e.g. auth@0.1.0, got "${values.adopt}"`;
174
217
  }
175
- return { kind: "adopt", plan, module: match[1], version: match[2], migrationsDir };
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;
176
228
  }
177
229
 
178
230
  function reportFailure(output: CliOutput, failure: MigrationFailure): number {
@@ -181,7 +233,16 @@ function reportFailure(output: CliOutput, failure: MigrationFailure): number {
181
233
  }
182
234
 
183
235
  function formatStep(step: MigrationStep): string {
184
- return `${step.module} ${String(step.version).padStart(4, "0")}_${step.name}.sql`;
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`;
185
246
  }
186
247
 
187
248
  function describeError(error: unknown): string {
package/src/client.ts CHANGED
@@ -1,8 +1,9 @@
1
- // The database client (FIRE_TRACKER `src/db/client.ts`, generalised). The driver follows the URL:
1
+ // The database client. The driver follows the URL:
2
2
  // `postgres://` or `postgresql://` → node-postgres (production), `pglite://<dir>` → PGlite, a
3
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.
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.
6
7
  import type { PGlite } from "@electric-sql/pglite";
7
8
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
9
  import type { PgliteDatabase } from "drizzle-orm/pglite";
@@ -10,18 +11,37 @@ import type { Pool } from "pg";
10
11
 
11
12
  const PGLITE_PREFIX = "pglite://";
12
13
  const POSTGRES_PREFIXES = ["postgres://", "postgresql://"];
14
+ const MODULE_NOT_FOUND_CODES = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]);
13
15
 
14
- export type PostgresDatabase = NodePgDatabase & { $client: Pool };
15
- export type PgliteClientDatabase = PgliteDatabase & { $client: PGlite };
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 };
16
34
 
17
35
  /** The drizzle database modules and the app query through. */
18
- export type Database = PostgresDatabase | PgliteClientDatabase;
36
+ export type Database<TSchema extends DatabaseSchema = DatabaseSchema> = PostgresDatabase<TSchema> | PgliteClientDatabase<TSchema>;
19
37
 
20
38
  /**
21
39
  * 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`).
40
+ * plain read and a step inside `db.transaction()`.
23
41
  */
24
- export type Queryable = Database | Parameters<Parameters<Database["transaction"]>[0]>[0];
42
+ export type Queryable<TSchema extends DatabaseSchema = DatabaseSchema> =
43
+ | Database<TSchema>
44
+ | Parameters<Parameters<Database<TSchema>["transaction"]>[0]>[0];
25
45
 
26
46
  export type DatabaseHandle =
27
47
  | {
@@ -43,36 +63,88 @@ export interface CreateDatabaseOptions {
43
63
  }
44
64
 
45
65
  /**
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.
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.
48
69
  */
49
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
+ }
50
74
  if (url.startsWith(PGLITE_PREFIX)) {
51
75
  const dataDir = url.slice(PGLITE_PREFIX.length);
52
- const { PGlite } = await import("@electric-sql/pglite");
76
+ const { PGlite } = await importDriver(PGLITE_DRIVER, () => import("@electric-sql/pglite"));
53
77
  const client = dataDir === "" ? new PGlite() : new PGlite(dataDir);
54
78
  return createPgliteHandle(client);
55
79
  }
56
80
  if (POSTGRES_PREFIXES.some((prefix) => url.startsWith(prefix))) {
57
- const { default: pg } = await import("pg");
58
- const { drizzle } = await import("drizzle-orm/node-postgres");
81
+ const { default: pg } = await importDriver(POSTGRES_DRIVER, () => import("pg"));
59
82
  const pool = new pg.Pool({ connectionString: url, max: options.max ?? 10 });
60
83
  // An idle client that loses its connection emits 'error' on the pool; unhandled, it would
61
84
  // crash the process. The pool drops that client and the next query opens a new one.
62
85
  pool.on("error", () => undefined);
63
- return { kind: "postgres", db: drizzle({ client: pool }), pool, close: () => pool.end() };
86
+ return createPostgresHandle(pool);
64
87
  }
65
88
  throw new Error(
66
89
  `createDatabase: unsupported database URL scheme ${describeScheme(url)}; use postgres://, postgresql:// or pglite://<dir>`,
67
90
  );
68
91
  }
69
92
 
70
- /** Wraps an open PGlite instance (used by `createTestDatabase`). */
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
+ */
71
97
  export async function createPgliteHandle(client: PGlite): Promise<DatabaseHandle & { kind: "pglite" }> {
72
- const { drizzle } = await import("drizzle-orm/pglite");
98
+ const { drizzle } = await importDriver(PGLITE_DRIVER, () => import("drizzle-orm/pglite"));
73
99
  return { kind: "pglite", db: drizzle({ client }), client, close: () => client.close() };
74
100
  }
75
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
+
76
148
  function describeScheme(url: string): string {
77
149
  const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url)?.[1];
78
150
  return scheme === undefined ? "(none)" : `"${scheme}:"`;
@@ -0,0 +1,109 @@
1
+ // The handle a config's database resolves to: the app's own (`database.handle`) when it set one, so one
2
+ // process never holds two handles on one database (with `pglite://`, two PGlite instances on one directory
3
+ // corrupt it), otherwise the process-wide handle for `database.url`. Module adapters, the health route and the
4
+ // package commands all resolve through here.
5
+ import type { SoftureDatabaseConfig } from "@softure-ai/core";
6
+ import { createDatabase, isDatabaseHandle, type CreateDatabaseOptions, type DatabaseHandle } from "./client.js";
7
+ import { getSharedDatabase } from "./shared.js";
8
+
9
+ declare module "@softure-ai/core" {
10
+ interface SoftureDatabaseHandleTypes {
11
+ readonly handle: DatabaseHandle;
12
+ }
13
+ }
14
+
15
+ type HandleSource = NonNullable<SoftureDatabaseConfig["handle"]>;
16
+
17
+ const CONFIGURED_KEY = Symbol.for("@softure-ai/db/configured-handles");
18
+
19
+ interface ConfiguredHandles {
20
+ /** Keyed by the app's function: `next dev` re-evaluates the config, and a new function is a new entry. */
21
+ readonly byFunction: WeakMap<HandleSource, Promise<DatabaseHandle>>;
22
+ /** The same promises, so `closeConfiguredDatabases` can reach them. */
23
+ readonly open: Set<Promise<DatabaseHandle>>;
24
+ }
25
+
26
+ type HandleHost = Record<typeof CONFIGURED_KEY, ConfiguredHandles | undefined>;
27
+
28
+ const host = globalThis as unknown as HandleHost;
29
+
30
+ function getConfiguredHandles(): ConfiguredHandles {
31
+ return (host[CONFIGURED_KEY] ??= { byFunction: new WeakMap(), open: new Set() });
32
+ }
33
+
34
+ /**
35
+ * The handle for the config's database: the app's `database.handle`, called once per function and checked, or
36
+ * else `getSharedDatabase(database.url)`. Never closes anything; for a server's whole life. A failed or invalid
37
+ * `handle` call is not kept, so the next call tries again.
38
+ */
39
+ export function getConfiguredDatabase(database: SoftureDatabaseConfig): Promise<DatabaseHandle> {
40
+ const source = database.handle;
41
+ if (source === undefined) return getSharedDatabase(database.url);
42
+
43
+ const handles = getConfiguredHandles();
44
+ let handle = handles.byFunction.get(source);
45
+ if (handle === undefined) {
46
+ handle = resolveHandle(source);
47
+ const opened = handle;
48
+ handles.byFunction.set(source, opened);
49
+ handles.open.add(opened);
50
+ opened.catch(() => {
51
+ handles.byFunction.delete(source);
52
+ handles.open.delete(opened);
53
+ });
54
+ }
55
+ return handle;
56
+ }
57
+
58
+ async function resolveHandle(source: HandleSource): Promise<DatabaseHandle> {
59
+ const value: unknown = await source();
60
+ if (!isDatabaseHandle(value)) {
61
+ throw new Error(
62
+ "database.handle: the function must return a database handle; wrap the app's client with createPostgresHandle(pool) or createPgliteHandle(client) from @softure-ai/db",
63
+ );
64
+ }
65
+ return value;
66
+ }
67
+
68
+ export interface CommandDatabase {
69
+ readonly handle: DatabaseHandle;
70
+ /** Closes what the command opened: its own handle, or the app's configured one (and forgets it). */
71
+ readonly close: () => Promise<void>;
72
+ }
73
+
74
+ /**
75
+ * The database for a command's own process (`softure migrate`, a module CLI, an ops script): the app's
76
+ * configured handle when `database.handle` is set, so the app's hooks and the command share one instance,
77
+ * otherwise a new handle on `database.url` with `options`. `close` closes either one; a `pglite://` directory is
78
+ * written back only then. Not for a running server: closing would close the app's handle under it.
79
+ */
80
+ export async function openCommandDatabase(database: SoftureDatabaseConfig, options: CreateDatabaseOptions = {}): Promise<CommandDatabase> {
81
+ const source = database.handle;
82
+ if (source === undefined) {
83
+ const handle = await createDatabase(database.url, options);
84
+ return { handle, close: handle.close };
85
+ }
86
+ const opened = getConfiguredDatabase(database);
87
+ const handle = await opened;
88
+ return {
89
+ handle,
90
+ close: async () => {
91
+ const handles = getConfiguredHandles();
92
+ handles.byFunction.delete(source);
93
+ handles.open.delete(opened);
94
+ await handle.close();
95
+ },
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Closes and forgets every configured handle this process resolved. For tests; at shutdown the app closes its own
101
+ * handle, which it owns (`closeSharedDatabases` leaves configured handles alone for that reason).
102
+ */
103
+ export async function closeConfiguredDatabases(): Promise<void> {
104
+ const handles = host[CONFIGURED_KEY];
105
+ if (handles === undefined) return;
106
+ host[CONFIGURED_KEY] = undefined;
107
+ const results = await Promise.allSettled([...handles.open]);
108
+ await Promise.all(results.flatMap((result) => (result.status === "fulfilled" ? [result.value.close()] : [])));
109
+ }
@@ -0,0 +1,40 @@
1
+ // The Postgres error behind a failed query. node-postgres and PGlite both put the SQLSTATE in `code` and the
2
+ // violated constraint's name in `constraint`; drizzle wraps that error as the `cause` of its own query error.
3
+ // App and module code that turns a known violation into a result (a taken slug, a conflicting row) reads it here.
4
+
5
+ /** How many `cause` links are followed: drizzle adds one, an app's own wrapper may add another. */
6
+ const MAX_CAUSE_DEPTH = 5;
7
+
8
+ export interface DriverError {
9
+ /** The SQLSTATE, e.g. `23505` (unique violation) or `23P01` (exclusion violation). */
10
+ readonly code: string;
11
+ /** The violated constraint's name, when the error is about one. */
12
+ readonly constraint: string | undefined;
13
+ readonly message: string;
14
+ }
15
+
16
+ export interface ConstraintViolation {
17
+ /** The SQLSTATE to match, e.g. `23505`. */
18
+ readonly code: string;
19
+ /** The constraint's name; when left out, any constraint (or none) matches. */
20
+ readonly constraint?: string;
21
+ }
22
+
23
+ /** The driver error behind `error` (itself or one of its causes), or undefined when there is none. */
24
+ export function findDriverError(error: unknown): DriverError | undefined {
25
+ for (let current: unknown = error, depth = 0; current instanceof Error && depth < MAX_CAUSE_DEPTH; current = current.cause, depth += 1) {
26
+ const { code, constraint } = current as { code?: unknown; constraint?: unknown };
27
+ if (typeof code === "string") return { code, constraint: typeof constraint === "string" ? constraint : undefined, message: current.message };
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ /**
33
+ * Whether `error` is the driver's `violation.code`, on `violation.constraint` when given:
34
+ * `isConstraintViolation(error, { code: "23505", constraint: "articles_slug_key" })` for a taken slug.
35
+ */
36
+ export function isConstraintViolation(error: unknown, violation: ConstraintViolation): boolean {
37
+ const driverError = findDriverError(error);
38
+ if (driverError?.code !== violation.code) return false;
39
+ return violation.constraint === undefined || driverError.constraint === violation.constraint;
40
+ }
package/src/index.ts CHANGED
@@ -2,14 +2,21 @@
2
2
  // (docs/02-module-standard.md §4). Test databases: `@softure-ai/db/testing`; CLI: `@softure-ai/db/cli`.
3
3
  export {
4
4
  createDatabase,
5
+ createPgliteHandle,
6
+ createPostgresHandle,
7
+ isDatabaseHandle,
5
8
  type CreateDatabaseOptions,
6
9
  type Database,
7
10
  type DatabaseHandle,
11
+ type DatabaseSchema,
8
12
  type PgliteClientDatabase,
9
13
  type PostgresDatabase,
10
14
  type Queryable,
11
15
  } from "./client.js";
12
- export { closeSharedDatabases, getSharedDatabase } from "./shared.js";
16
+ export { closeSharedDatabase, closeSharedDatabases, getSharedDatabase } from "./shared.js";
17
+ export { createProcessDatabase, type ProcessDatabase, type ProcessDatabaseOptions } from "./process.js";
18
+ export { findDriverError, isConstraintViolation, type ConstraintViolation, type DriverError } from "./driver-errors.js";
19
+ export { closeConfiguredDatabases, getConfiguredDatabase, openCommandDatabase, type CommandDatabase } from "./configured.js";
13
20
  export { computeChecksum, readMigrationFiles, type MigrationFile } from "./migrations/files.js";
14
21
  export {
15
22
  describeProblem,
@@ -21,6 +28,10 @@ export {
21
28
  export {
22
29
  migrate,
23
30
  planMigrations,
31
+ runAppMigrations,
32
+ type AppMigrationHook,
33
+ type AppMigrationPhase,
34
+ type AppMigrations,
24
35
  type MigrateOptions,
25
36
  type MigrationPlan,
26
37
  type MigrationReport,