@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.
- package/CHANGELOG.md +25 -0
- package/README.md +261 -22
- package/dist/cli/run.d.ts +8 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +71 -21
- package/dist/cli/run.js.map +1 -1
- package/dist/client.d.ts +39 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +64 -8
- package/dist/client.js.map +1 -1
- package/dist/configured.d.ts +31 -0
- package/dist/configured.d.ts.map +1 -0
- package/dist/configured.js +74 -0
- package/dist/configured.js.map +1 -0
- package/dist/driver-errors.d.ts +21 -0
- package/dist/driver-errors.d.ts.map +1 -0
- package/dist/driver-errors.js +25 -0
- package/dist/driver-errors.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/migrations/adopt.d.ts +8 -1
- package/dist/migrations/adopt.d.ts.map +1 -1
- package/dist/migrations/adopt.js +53 -87
- package/dist/migrations/adopt.js.map +1 -1
- package/dist/migrations/apply.d.ts +35 -0
- package/dist/migrations/apply.d.ts.map +1 -0
- package/dist/migrations/apply.js +115 -0
- package/dist/migrations/apply.js.map +1 -0
- package/dist/migrations/migrator.d.ts +55 -27
- package/dist/migrations/migrator.d.ts.map +1 -1
- package/dist/migrations/migrator.js +187 -103
- package/dist/migrations/migrator.js.map +1 -1
- package/dist/migrations/problems.d.ts +9 -0
- package/dist/migrations/problems.d.ts.map +1 -1
- package/dist/migrations/problems.js +9 -3
- package/dist/migrations/problems.js.map +1 -1
- package/dist/migrations/reference.d.ts +23 -0
- package/dist/migrations/reference.d.ts.map +1 -0
- package/dist/migrations/reference.js +64 -0
- package/dist/migrations/reference.js.map +1 -0
- package/dist/migrations/session.d.ts +2 -1
- package/dist/migrations/session.d.ts.map +1 -1
- package/dist/migrations/session.js +19 -2
- package/dist/migrations/session.js.map +1 -1
- package/dist/migrations/stubs.d.ts +23 -0
- package/dist/migrations/stubs.d.ts.map +1 -0
- package/dist/migrations/stubs.js +99 -0
- package/dist/migrations/stubs.js.map +1 -0
- package/dist/process.d.ts +23 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +82 -0
- package/dist/process.js.map +1 -0
- package/dist/shared.d.ts +2 -0
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +9 -0
- package/dist/shared.js.map +1 -1
- package/dist/testing.d.ts +11 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +28 -9
- package/dist/testing.js.map +1 -1
- package/package.json +22 -5
- package/src/cli/run.ts +84 -23
- package/src/client.ts +88 -16
- package/src/configured.ts +109 -0
- package/src/driver-errors.ts +40 -0
- package/src/index.ts +12 -1
- package/src/migrations/adopt.ts +65 -100
- package/src/migrations/apply.ts +135 -0
- package/src/migrations/migrator.ts +246 -128
- package/src/migrations/problems.ts +13 -5
- package/src/migrations/reference.ts +78 -0
- package/src/migrations/session.ts +28 -2
- package/src/migrations/stubs.ts +139 -0
- package/src/process.ts +110 -0
- package/src/shared.ts +9 -0
- 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 {
|
|
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
|
|
82
|
-
if (
|
|
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
|
|
97
|
+
let opened: CommandDatabase;
|
|
87
98
|
try {
|
|
88
|
-
handle
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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} ${
|
|
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
|
|
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
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
15
|
-
export
|
|
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()
|
|
40
|
+
* plain read and a step inside `db.transaction()`.
|
|
23
41
|
*/
|
|
24
|
-
export type Queryable
|
|
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:
|
|
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
|
|
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
|
-
/**
|
|
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,
|