@plakboek/db 0.1.0
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/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +319 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Florian Vanthuyne
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @plakboek/db
|
|
2
|
+
|
|
3
|
+
A Postgres connection factory and a forward-only, lock-safe migration runner
|
|
4
|
+
for Plakboek CMS installations.
|
|
5
|
+
|
|
6
|
+
## What this package provides
|
|
7
|
+
|
|
8
|
+
- `createDb` -- a Drizzle (`drizzle-orm/postgres-js`) connection factory
|
|
9
|
+
over the `postgres` (porsager) driver.
|
|
10
|
+
- `runMigrations` -- applies this package's own statically registered
|
|
11
|
+
migrations, holding a Postgres advisory lock for the whole run so
|
|
12
|
+
concurrent migrators can never double-apply (D-03). What Phase 6's deploy
|
|
13
|
+
step invokes.
|
|
14
|
+
- `withMigrationLock`, `MigrationLockTimeoutError` -- the session-scoped
|
|
15
|
+
advisory lock primitive `runMigrations` wraps around every run.
|
|
16
|
+
- The migration authoring convention every migration -- this package's own
|
|
17
|
+
and every host/host-extension migration -- follows: see
|
|
18
|
+
[`MIGRATIONS.md`](./MIGRATIONS.md).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm add @plakboek/db drizzle-orm
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`drizzle-orm` is a peer dependency: bring your own version, matching the
|
|
27
|
+
one this package was built against.
|
|
28
|
+
|
|
29
|
+
## `createDb`
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createDb } from '@plakboek/db';
|
|
33
|
+
|
|
34
|
+
const { db, sql, close } = createDb({
|
|
35
|
+
connectionString: process.env.DATABASE_URL!,
|
|
36
|
+
maxConnections: 10, // optional, default 10
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// db: a drizzle-orm/postgres-js database instance
|
|
40
|
+
// sql: the raw `postgres` tag function, for one-off queries
|
|
41
|
+
await close(); // ends the underlying connection pool
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`createDb` validates the connection string's scheme (`postgres://` or
|
|
45
|
+
`postgresql://`) before connecting, and never echoes the supplied value in
|
|
46
|
+
an error -- `DbConfigError` always names the two expected schemes instead.
|
|
47
|
+
|
|
48
|
+
## `runMigrations`
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { runMigrations } from '@plakboek/db';
|
|
52
|
+
|
|
53
|
+
const result = await runMigrations({
|
|
54
|
+
connectionString: process.env.DATABASE_URL!,
|
|
55
|
+
lockWaitMs: 60_000, // optional, default 60000
|
|
56
|
+
lockPollIntervalMs: 250, // optional, default 250
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// result.applied: names of migrations applied by this call
|
|
60
|
+
// result.alreadyApplied: names that were already applied before this call
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`runMigrations` is safe to call from every instance of a rolling deploy: it
|
|
64
|
+
opens its own connection, holds the advisory lock for the duration of the
|
|
65
|
+
run, and applies this package's statically registered `MIGRATIONS` in
|
|
66
|
+
order. A second overlapping call either observes the first call's
|
|
67
|
+
migrations as already applied, or throws `MigrationLockTimeoutError` if it
|
|
68
|
+
gives up waiting for the lock before the first call finishes.
|
|
69
|
+
|
|
70
|
+
`MIGRATIONS` ships empty at `0.1.0` -- no product schema exists before
|
|
71
|
+
Phase 2. See [`MIGRATIONS.md`](./MIGRATIONS.md) for how later phases add to
|
|
72
|
+
it, the transactional-vs-existence-guarded-statement decision, and what
|
|
73
|
+
each thrown error class means.
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
| Class | Meaning |
|
|
78
|
+
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
79
|
+
| `DbConfigError` | `createDb` was given an invalid connection string. |
|
|
80
|
+
| `MigrationLockTimeoutError` | Another migrator still holds the advisory lock after `lockWaitMs`. |
|
|
81
|
+
| `MigrationRegistryError` | The migration registry itself is structurally invalid. |
|
|
82
|
+
| `MigrationChecksumMismatchError` | An already-applied migration's SQL no longer matches its registry entry -- shipped migrations are immutable. |
|
|
83
|
+
| `UnknownAppliedMigrationError` | The database has a migration applied that the registry no longer contains. |
|
|
84
|
+
| `MigrationOrderError` | A new migration was inserted before one already applied -- migrations must never be reordered. |
|
|
85
|
+
| `MigrationFailedError` | A migration's SQL failed to apply; carries the failing statement index (non-transactional) and the original error as `cause`. |
|
|
86
|
+
|
|
87
|
+
Full detail, including operator guidance per error, lives in
|
|
88
|
+
[`MIGRATIONS.md`](./MIGRATIONS.md#errors).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Sql } from "postgres";
|
|
2
|
+
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
|
+
import { Client } from "pg";
|
|
4
|
+
//#region src/client.d.ts
|
|
5
|
+
type CreateDbOptions = {
|
|
6
|
+
readonly connectionString: string;
|
|
7
|
+
readonly maxConnections?: number;
|
|
8
|
+
};
|
|
9
|
+
type Db = {
|
|
10
|
+
readonly db: PostgresJsDatabase;
|
|
11
|
+
readonly sql: Sql;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
export declare class DbConfigError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
17
|
+
export declare function createDb(options: CreateDbOptions): Db;
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/lock.d.ts
|
|
20
|
+
export declare class MigrationLockTimeoutError extends Error {
|
|
21
|
+
readonly waitedMs: number;
|
|
22
|
+
constructor(waitedMs: number);
|
|
23
|
+
}
|
|
24
|
+
type LockOptions = {
|
|
25
|
+
readonly waitMs?: number;
|
|
26
|
+
readonly pollIntervalMs?: number;
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/migrate.d.ts
|
|
30
|
+
/** One statically registered migration. `sql` is either a single
|
|
31
|
+
* transaction's worth of SQL (`transactional: true`) or a sequence of
|
|
32
|
+
* independently-committed, existence-guarded statements separated by
|
|
33
|
+
* `STATEMENT_BREAKPOINT` (`transactional: false`) -- required for
|
|
34
|
+
* statements Postgres refuses inside a transaction block. */
|
|
35
|
+
type Migration = {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly sql: string;
|
|
38
|
+
readonly transactional: boolean;
|
|
39
|
+
};
|
|
40
|
+
/** Thrown by `assertValidRegistry` for a structurally invalid registry: an
|
|
41
|
+
* invalid name, a duplicate name, a non-increasing numeric prefix, or empty
|
|
42
|
+
* sql. */
|
|
43
|
+
export declare class MigrationRegistryError extends Error {
|
|
44
|
+
readonly migrationName: string | undefined;
|
|
45
|
+
constructor(message: string, migrationName?: string);
|
|
46
|
+
}
|
|
47
|
+
/** Thrown when an already-applied migration's checksum no longer matches
|
|
48
|
+
* its registry entry -- shipped migrations are immutable. */
|
|
49
|
+
export declare class MigrationChecksumMismatchError extends Error {
|
|
50
|
+
readonly migrationName: string;
|
|
51
|
+
constructor(migrationName: string);
|
|
52
|
+
}
|
|
53
|
+
/** Thrown when the database has an applied migration the registry no longer
|
|
54
|
+
* contains -- this package version is older than the applied schema. */
|
|
55
|
+
export declare class UnknownAppliedMigrationError extends Error {
|
|
56
|
+
readonly migrationName: string;
|
|
57
|
+
constructor(migrationName: string);
|
|
58
|
+
}
|
|
59
|
+
/** Thrown when the registry's order no longer matches the applied migration
|
|
60
|
+
* history -- migrations must never be reordered once shipped. */
|
|
61
|
+
export declare class MigrationOrderError extends Error {
|
|
62
|
+
readonly migrationName: string;
|
|
63
|
+
constructor(migrationName: string);
|
|
64
|
+
}
|
|
65
|
+
/** Thrown when a migration's SQL fails to apply. `statementIndex` is the
|
|
66
|
+
* zero-based index of the failing statement for a non-transactional
|
|
67
|
+
* migration, `undefined` for a transactional one (the whole migration is
|
|
68
|
+
* one statement as far as failure attribution goes). */
|
|
69
|
+
export declare class MigrationFailedError extends Error {
|
|
70
|
+
readonly migrationName: string;
|
|
71
|
+
readonly statementIndex: number | undefined;
|
|
72
|
+
constructor(migrationName: string, statementIndex: number | undefined, cause: unknown);
|
|
73
|
+
}
|
|
74
|
+
type RunMigrationsOptions = {
|
|
75
|
+
readonly connectionString: string;
|
|
76
|
+
readonly lockWaitMs?: number;
|
|
77
|
+
readonly lockPollIntervalMs?: number;
|
|
78
|
+
};
|
|
79
|
+
type RunMigrationsResult = {
|
|
80
|
+
readonly applied: readonly string[];
|
|
81
|
+
readonly alreadyApplied: readonly string[];
|
|
82
|
+
};
|
|
83
|
+
/** Applies the package's own statically registered `MIGRATIONS`. What
|
|
84
|
+
* Phase 6's deploy step invokes. */
|
|
85
|
+
export declare function runMigrations(options: RunMigrationsOptions): Promise<RunMigrationsResult>;
|
|
86
|
+
//#endregion
|
|
87
|
+
export type { CreateDbOptions, Db, LockOptions, Migration, RunMigrationsOptions, RunMigrationsResult };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { Client } from "pg";
|
|
5
|
+
//#region src/client.ts
|
|
6
|
+
var DbConfigError = class extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "DbConfigError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_MAX_CONNECTIONS = 10;
|
|
13
|
+
const CLOSE_TIMEOUT_SECONDS = 5;
|
|
14
|
+
const POSTGRES_SCHEMES = ["postgres://", "postgresql://"];
|
|
15
|
+
/**
|
|
16
|
+
* Validates the connection string's shape only (scheme prefix). The message
|
|
17
|
+
* never echoes the supplied value (T-01-09) -- it always names the two
|
|
18
|
+
* expected schemes instead.
|
|
19
|
+
*/
|
|
20
|
+
function assertPostgresConnectionString(connectionString) {
|
|
21
|
+
if (!(typeof connectionString === "string" && connectionString.length > 0 && POSTGRES_SCHEMES.some((scheme) => connectionString.startsWith(scheme)))) throw new DbConfigError("@plakboek/db: connectionString must be a non-empty string starting with \"postgres://\" or \"postgresql://\"");
|
|
22
|
+
}
|
|
23
|
+
function createDb(options) {
|
|
24
|
+
assertPostgresConnectionString(options.connectionString);
|
|
25
|
+
const max = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
26
|
+
const sql = postgres(options.connectionString, { max });
|
|
27
|
+
return {
|
|
28
|
+
db: drizzle({ client: sql }),
|
|
29
|
+
sql,
|
|
30
|
+
async close() {
|
|
31
|
+
await sql.end({ timeout: CLOSE_TIMEOUT_SECONDS });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
var MigrationLockTimeoutError = class extends Error {
|
|
36
|
+
waitedMs;
|
|
37
|
+
constructor(waitedMs) {
|
|
38
|
+
super(`@plakboek/db: another migrator still holds the migration lock after ${waitedMs}ms`);
|
|
39
|
+
this.name = "MigrationLockTimeoutError";
|
|
40
|
+
this.waitedMs = waitedMs;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const DEFAULT_WAIT_MS = 6e4;
|
|
44
|
+
const DEFAULT_POLL_INTERVAL_MS = 250;
|
|
45
|
+
function sleep(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
}
|
|
48
|
+
async function tryAcquire(client) {
|
|
49
|
+
return (await client.query("SELECT pg_try_advisory_lock($1::bigint) AS locked", ["4839120657231098713"])).rows[0]?.locked ?? false;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Polls `pg_try_advisory_lock` (never a blocking `pg_advisory_lock`) until it
|
|
53
|
+
* succeeds or `waitMs` elapses. A fail-fast try-lock plus bounded polling was
|
|
54
|
+
* chosen over `SET lock_timeout` + a blocking acquire because `lock_timeout`
|
|
55
|
+
* is a session setting: it would persist on this client for every statement
|
|
56
|
+
* run afterwards, including the migrations `withMigrationLock` wraps.
|
|
57
|
+
*/
|
|
58
|
+
async function acquire(client, waitMs, pollIntervalMs) {
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
for (;;) {
|
|
61
|
+
if (await tryAcquire(client)) return;
|
|
62
|
+
const elapsedMs = Date.now() - start;
|
|
63
|
+
if (elapsedMs >= waitMs) throw new MigrationLockTimeoutError(elapsedMs);
|
|
64
|
+
await sleep(pollIntervalMs);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function runGuarded(fn) {
|
|
68
|
+
try {
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
value: await fn()
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Runs `fn` while holding a session-scoped Postgres advisory lock keyed by
|
|
82
|
+
* `MIGRATION_LOCK_KEY`, acquired on the caller-supplied dedicated `client`.
|
|
83
|
+
* Two callers can never be inside `fn` at the same time; a caller that gives
|
|
84
|
+
* up waiting throws `MigrationLockTimeoutError`. If the holding backend
|
|
85
|
+
* crashes or is terminated, Postgres releases the session lock itself, so a
|
|
86
|
+
* waiting caller acquires it without needing this function to run at all.
|
|
87
|
+
*
|
|
88
|
+
* Never issues `SET`/`SET LOCAL` on `client` -- the lock is acquired and
|
|
89
|
+
* released purely via `pg_try_advisory_lock`/`pg_advisory_unlock`, so no
|
|
90
|
+
* session setting (e.g. `lock_timeout`) leaks into statements `fn` runs
|
|
91
|
+
* afterwards on the same client.
|
|
92
|
+
*
|
|
93
|
+
* The unlock query always runs after `fn` settles (success or failure) --
|
|
94
|
+
* this is expressed as unconditional sequential code (not a `try/finally`)
|
|
95
|
+
* so returning `outcome.value` never needs an unsafe cast to narrow `T`.
|
|
96
|
+
*/
|
|
97
|
+
async function withMigrationLock(client, fn, options) {
|
|
98
|
+
await acquire(client, options?.waitMs ?? DEFAULT_WAIT_MS, options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
|
|
99
|
+
const outcome = await runGuarded(fn);
|
|
100
|
+
const unlocked = (await client.query("SELECT pg_advisory_unlock($1::bigint) AS unlocked", ["4839120657231098713"])).rows[0]?.unlocked ?? false;
|
|
101
|
+
if (outcome.ok) {
|
|
102
|
+
if (!unlocked) throw new Error("@plakboek/db: migration lock was not held at release -- this indicates a bug in withMigrationLock or an external pg_advisory_unlock call on the same key");
|
|
103
|
+
return outcome.value;
|
|
104
|
+
}
|
|
105
|
+
throw outcome.error;
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/migrations/index.ts
|
|
109
|
+
/**
|
|
110
|
+
* The static migration registry for @plakboek/db (D-03).
|
|
111
|
+
*
|
|
112
|
+
* Adding a migration:
|
|
113
|
+
* 1. Create `src/migrations/NNNN_snake_name.ts` exporting a `Migration`
|
|
114
|
+
* object named `migration`.
|
|
115
|
+
* 2. Add an explicit static `import` for it below.
|
|
116
|
+
* 3. Append it to the `MIGRATIONS` array, in numeric order.
|
|
117
|
+
*
|
|
118
|
+
* Never edit, rename, or reorder a migration once it has shipped -- shipped
|
|
119
|
+
* migrations are immutable (checksum-enforced) and forward-only.
|
|
120
|
+
* Corrections are always a new, higher-numbered migration. See
|
|
121
|
+
* ../../MIGRATIONS.md for the full authoring convention.
|
|
122
|
+
*
|
|
123
|
+
* MIGRATIONS ships empty at 0.1.0: no product table exists before Phase 2
|
|
124
|
+
* introduces the first schema. The runner, bookkeeping table, advisory
|
|
125
|
+
* lock, and every safety check are fully exercised through fixture
|
|
126
|
+
* registries in this package's own tests (tests/fixtures/migrations.ts).
|
|
127
|
+
*/
|
|
128
|
+
const MIGRATIONS = Object.freeze([]);
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/migrate.ts
|
|
131
|
+
/**
|
|
132
|
+
* The forward-only, statically registered migration runner (D-03). Every
|
|
133
|
+
* migration is a plain `{ name, sql, transactional }` object explicitly
|
|
134
|
+
* imported into `./migrations/index.ts` -- never discovered by scanning a
|
|
135
|
+
* directory. `runMigrations`/`migrateWithRegistry` hold `./lock.ts`'s
|
|
136
|
+
* session-scoped advisory lock for the whole run, so two concurrent
|
|
137
|
+
* migrators can never double-apply. See ../MIGRATIONS.md for the authoring
|
|
138
|
+
* convention every migration must follow.
|
|
139
|
+
*/
|
|
140
|
+
/** Bookkeeping table created (if missing) by every run. */
|
|
141
|
+
const MIGRATIONS_TABLE = "plakboek_migrations";
|
|
142
|
+
/** The drizzle-kit statement separator: splits a non-transactional
|
|
143
|
+
* migration's `sql` into individually-run statements. */
|
|
144
|
+
const STATEMENT_BREAKPOINT = "--> statement-breakpoint";
|
|
145
|
+
/** Thrown by `assertValidRegistry` for a structurally invalid registry: an
|
|
146
|
+
* invalid name, a duplicate name, a non-increasing numeric prefix, or empty
|
|
147
|
+
* sql. */
|
|
148
|
+
var MigrationRegistryError = class extends Error {
|
|
149
|
+
migrationName;
|
|
150
|
+
constructor(message, migrationName) {
|
|
151
|
+
super(message);
|
|
152
|
+
this.name = "MigrationRegistryError";
|
|
153
|
+
this.migrationName = migrationName;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
/** Thrown when an already-applied migration's checksum no longer matches
|
|
157
|
+
* its registry entry -- shipped migrations are immutable. */
|
|
158
|
+
var MigrationChecksumMismatchError = class extends Error {
|
|
159
|
+
migrationName;
|
|
160
|
+
constructor(migrationName) {
|
|
161
|
+
super(`@plakboek/db: migration "${migrationName}" has already been applied, but its SQL no longer matches -- shipped migrations are immutable, corrections are a new migration`);
|
|
162
|
+
this.name = "MigrationChecksumMismatchError";
|
|
163
|
+
this.migrationName = migrationName;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
/** Thrown when the database has an applied migration the registry no longer
|
|
167
|
+
* contains -- this package version is older than the applied schema. */
|
|
168
|
+
var UnknownAppliedMigrationError = class extends Error {
|
|
169
|
+
migrationName;
|
|
170
|
+
constructor(migrationName) {
|
|
171
|
+
super(`@plakboek/db: database has migration "${migrationName}" applied, but the registry does not contain it -- refusing to proceed`);
|
|
172
|
+
this.name = "UnknownAppliedMigrationError";
|
|
173
|
+
this.migrationName = migrationName;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/** Thrown when the registry's order no longer matches the applied migration
|
|
177
|
+
* history -- migrations must never be reordered once shipped. */
|
|
178
|
+
var MigrationOrderError = class extends Error {
|
|
179
|
+
migrationName;
|
|
180
|
+
constructor(migrationName) {
|
|
181
|
+
super(`@plakboek/db: registry order does not match applied migration history at "${migrationName}" -- migrations must never be reordered`);
|
|
182
|
+
this.name = "MigrationOrderError";
|
|
183
|
+
this.migrationName = migrationName;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
/** Thrown when a migration's SQL fails to apply. `statementIndex` is the
|
|
187
|
+
* zero-based index of the failing statement for a non-transactional
|
|
188
|
+
* migration, `undefined` for a transactional one (the whole migration is
|
|
189
|
+
* one statement as far as failure attribution goes). */
|
|
190
|
+
var MigrationFailedError = class extends Error {
|
|
191
|
+
migrationName;
|
|
192
|
+
statementIndex;
|
|
193
|
+
constructor(migrationName, statementIndex, cause) {
|
|
194
|
+
super(`@plakboek/db: migration "${migrationName}" failed${statementIndex === void 0 ? "" : ` at statement index ${statementIndex}`}`, { cause });
|
|
195
|
+
this.name = "MigrationFailedError";
|
|
196
|
+
this.migrationName = migrationName;
|
|
197
|
+
this.statementIndex = statementIndex;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const MIGRATION_NAME_PATTERN = /^\d{4}_[a-z0-9_]+$/;
|
|
201
|
+
/** sha256 hex digest of a migration's `sql`, used to detect an edited
|
|
202
|
+
* shipped migration (T-01-30). */
|
|
203
|
+
function migrationChecksum(sql) {
|
|
204
|
+
return createHash("sha256").update(sql).digest("hex");
|
|
205
|
+
}
|
|
206
|
+
/** Validates registry structure only (name pattern, uniqueness, strictly
|
|
207
|
+
* increasing numeric prefix, non-empty sql) -- never touches a database.
|
|
208
|
+
* Throws `MigrationRegistryError` on the first violation found. */
|
|
209
|
+
function assertValidRegistry(migrations) {
|
|
210
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
211
|
+
let previousPrefix = -1;
|
|
212
|
+
for (const migration of migrations) {
|
|
213
|
+
if (!MIGRATION_NAME_PATTERN.test(migration.name)) throw new MigrationRegistryError(`@plakboek/db: migration name "${migration.name}" does not match ${MIGRATION_NAME_PATTERN.source}`, migration.name);
|
|
214
|
+
if (seenNames.has(migration.name)) throw new MigrationRegistryError(`@plakboek/db: duplicate migration name "${migration.name}"`, migration.name);
|
|
215
|
+
seenNames.add(migration.name);
|
|
216
|
+
const prefix = Number.parseInt(migration.name.slice(0, 4), 10);
|
|
217
|
+
if (prefix <= previousPrefix) throw new MigrationRegistryError(`@plakboek/db: migration "${migration.name}" has a numeric prefix that is not strictly increasing`, migration.name);
|
|
218
|
+
previousPrefix = prefix;
|
|
219
|
+
if (migration.sql.trim().length === 0) throw new MigrationRegistryError(`@plakboek/db: migration "${migration.name}" has empty sql`, migration.name);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function ensureBookkeepingTable(client) {
|
|
223
|
+
await client.query(`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
|
224
|
+
name text PRIMARY KEY,
|
|
225
|
+
checksum text NOT NULL,
|
|
226
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
227
|
+
)`);
|
|
228
|
+
}
|
|
229
|
+
async function readAppliedRows(client) {
|
|
230
|
+
return (await client.query(`SELECT name, checksum FROM ${MIGRATIONS_TABLE} ORDER BY applied_at ASC, name ASC`)).rows;
|
|
231
|
+
}
|
|
232
|
+
/** Checks every applied row against the registry (unknown / checksum
|
|
233
|
+
* mismatch) and that the applied names are exactly the registry's prefix,
|
|
234
|
+
* in order -- never lets a reordered or edited history proceed silently. */
|
|
235
|
+
function validateAppliedHistory(appliedRows, migrations) {
|
|
236
|
+
const byName = new Map(migrations.map((migration) => [migration.name, migration]));
|
|
237
|
+
for (const row of appliedRows) {
|
|
238
|
+
const migration = byName.get(row.name);
|
|
239
|
+
if (!migration) throw new UnknownAppliedMigrationError(row.name);
|
|
240
|
+
if (migrationChecksum(migration.sql) !== row.checksum) throw new MigrationChecksumMismatchError(row.name);
|
|
241
|
+
}
|
|
242
|
+
const appliedNames = appliedRows.map((row) => row.name);
|
|
243
|
+
const registryPrefix = migrations.slice(0, appliedNames.length);
|
|
244
|
+
for (const [index, appliedName] of appliedNames.entries()) {
|
|
245
|
+
const expected = registryPrefix[index];
|
|
246
|
+
if (!expected || expected.name !== appliedName) throw new MigrationOrderError(appliedName);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function recordApplied(client, migration) {
|
|
250
|
+
await client.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, checksum) VALUES ($1, $2)`, [migration.name, migrationChecksum(migration.sql)]);
|
|
251
|
+
}
|
|
252
|
+
async function applyTransactional(client, migration) {
|
|
253
|
+
await client.query("BEGIN");
|
|
254
|
+
try {
|
|
255
|
+
await client.query(migration.sql);
|
|
256
|
+
await recordApplied(client, migration);
|
|
257
|
+
await client.query("COMMIT");
|
|
258
|
+
} catch (error) {
|
|
259
|
+
await client.query("ROLLBACK");
|
|
260
|
+
throw new MigrationFailedError(migration.name, void 0, error);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function applyNonTransactional(client, migration) {
|
|
264
|
+
const statements = migration.sql.split(STATEMENT_BREAKPOINT).map((statement) => statement.trim()).filter((statement) => statement.length > 0);
|
|
265
|
+
for (const [index, statement] of statements.entries()) try {
|
|
266
|
+
await client.query(statement);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
throw new MigrationFailedError(migration.name, index, error);
|
|
269
|
+
}
|
|
270
|
+
await recordApplied(client, migration);
|
|
271
|
+
}
|
|
272
|
+
/** Applies every pending migration in `migrations` against `client`, in
|
|
273
|
+
* registry order. Does not acquire any lock -- callers that need
|
|
274
|
+
* concurrency safety use `migrateWithRegistry`. Exported directly so tests
|
|
275
|
+
* can prove the unlocked case has no safety (the negative control). */
|
|
276
|
+
async function applyPendingMigrations(client, migrations) {
|
|
277
|
+
await ensureBookkeepingTable(client);
|
|
278
|
+
const appliedRows = await readAppliedRows(client);
|
|
279
|
+
validateAppliedHistory(appliedRows, migrations);
|
|
280
|
+
const alreadyApplied = appliedRows.map((row) => row.name);
|
|
281
|
+
const alreadyAppliedSet = new Set(alreadyApplied);
|
|
282
|
+
const pending = migrations.filter((migration) => !alreadyAppliedSet.has(migration.name));
|
|
283
|
+
const applied = [];
|
|
284
|
+
for (const migration of pending) {
|
|
285
|
+
if (migration.transactional) await applyTransactional(client, migration);
|
|
286
|
+
else await applyNonTransactional(client, migration);
|
|
287
|
+
applied.push(migration.name);
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
applied,
|
|
291
|
+
alreadyApplied
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/** Validates `migrations`, opens one `pg.Client`, and runs
|
|
295
|
+
* `applyPendingMigrations` inside `withMigrationLock` -- the primitive
|
|
296
|
+
* `runMigrations` and every test in this package build on. */
|
|
297
|
+
async function migrateWithRegistry(options) {
|
|
298
|
+
assertValidRegistry(options.migrations);
|
|
299
|
+
const client = new Client({ connectionString: options.connectionString });
|
|
300
|
+
await client.connect();
|
|
301
|
+
try {
|
|
302
|
+
return await withMigrationLock(client, () => applyPendingMigrations(client, options.migrations), {
|
|
303
|
+
waitMs: options.lockWaitMs,
|
|
304
|
+
pollIntervalMs: options.lockPollIntervalMs
|
|
305
|
+
});
|
|
306
|
+
} finally {
|
|
307
|
+
await client.end();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Applies the package's own statically registered `MIGRATIONS`. What
|
|
311
|
+
* Phase 6's deploy step invokes. */
|
|
312
|
+
async function runMigrations(options) {
|
|
313
|
+
return await migrateWithRegistry({
|
|
314
|
+
...options,
|
|
315
|
+
migrations: MIGRATIONS
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
export { DbConfigError, MigrationChecksumMismatchError, MigrationFailedError, MigrationLockTimeoutError, MigrationOrderError, MigrationRegistryError, UnknownAppliedMigrationError, createDb, runMigrations };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@plakboek/db",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Postgres connection factory and migration-lock primitives for Plakboek CMS installations",
|
|
5
|
+
"homepage": "https://github.com/flovan/plakboek/tree/main/packages/db#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/flovan/plakboek/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/flovan/plakboek.git",
|
|
13
|
+
"directory": "packages/db"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"pg": "^8.23.0",
|
|
32
|
+
"postgres": "^3.4.9"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
36
|
+
"@types/node": "^22.20.2",
|
|
37
|
+
"@types/pg": "^8.23.1",
|
|
38
|
+
"drizzle-orm": "^0.45.2",
|
|
39
|
+
"publint": "^0.3.24",
|
|
40
|
+
"tsdown": "^0.23.0",
|
|
41
|
+
"typescript": "^7.0.2",
|
|
42
|
+
"vitest": "^5.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"drizzle-orm": "^0.45.2"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22.16"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsdown",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
54
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
55
|
+
"check": "publint --strict && attw --pack . --profile esm-only"
|
|
56
|
+
}
|
|
57
|
+
}
|