@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 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).
@@ -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
+ }