bopodev-db 0.1.26 → 0.1.28

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/src/client.ts CHANGED
@@ -1,17 +1,507 @@
1
- import { mkdir } from "node:fs/promises";
2
- import { dirname } from "node:path";
3
- import { PGlite } from "@electric-sql/pglite";
4
- import { drizzle } from "drizzle-orm/pglite";
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
+ import { mkdir, open, readFile, rm, writeFile, type FileHandle } from "node:fs/promises";
3
+ import { createServer } from "node:net";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import EmbeddedPostgresModule from "embedded-postgres";
7
+ import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
8
+ import { migrate } from "drizzle-orm/postgres-js/migrator";
9
+ import postgres from "postgres";
5
10
  import * as dbSchema from "./schema";
6
11
  import { resolveDefaultDbPath } from "./default-paths";
7
12
 
8
- export type BopoDb = ReturnType<typeof drizzle<typeof dbSchema>>;
13
+ export type BopoDb = PostgresJsDatabase<typeof dbSchema>;
14
+ export type BopoDatabaseClient = {
15
+ close: () => Promise<void>;
16
+ };
9
17
 
10
18
  const defaultDbPath = resolveDefaultDbPath();
19
+ const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url));
20
+ const DEFAULT_DB_NAME = "bopodev";
21
+ const DEFAULT_DB_USER = "bopodev";
22
+ const DEFAULT_DB_PASSWORD = "bopodev";
23
+ const DEFAULT_DB_PORT = Number(process.env.BOPO_DB_PORT ?? "55432");
24
+ const EMBEDDED_DB_START_TIMEOUT_MS = Number(process.env.BOPO_DB_START_TIMEOUT_MS ?? "15000");
25
+ const LOCAL_DB_STATE_VERSION = 1;
26
+ type EmbeddedPostgresInstance = {
27
+ initialise(): Promise<void>;
28
+ start(): Promise<void>;
29
+ stop(): Promise<void>;
30
+ };
31
+
32
+ type EmbeddedPostgresCtor = new (options: {
33
+ databaseDir: string;
34
+ user: string;
35
+ password: string;
36
+ port: number;
37
+ persistent: boolean;
38
+ initdbFlags?: string[];
39
+ onLog?: (message: unknown) => void;
40
+ onError?: (message: unknown) => void;
41
+ }) => EmbeddedPostgresInstance;
42
+
43
+ type DatabaseTarget = {
44
+ connectionString: string;
45
+ dataPath: string | null;
46
+ stop: () => Promise<void>;
47
+ source: "external-postgres" | "embedded-postgres";
48
+ };
49
+
50
+ type LocalDbPhase = "initializing" | "starting" | "migrating" | "running" | "stopping" | "stopped" | "failed";
51
+
52
+ type LocalDbState = {
53
+ version: number;
54
+ source: "embedded-postgres";
55
+ phase: LocalDbPhase;
56
+ pid: number;
57
+ port: number;
58
+ dataPath: string;
59
+ updatedAt: string;
60
+ expectedMigrationCount: number;
61
+ lastError: string | null;
62
+ };
63
+
64
+ type LocalDbLock = {
65
+ path: string;
66
+ handle: FileHandle;
67
+ };
68
+
69
+ type MigrationVersion = {
70
+ count: number;
71
+ latestTag: string | null;
72
+ };
73
+
74
+ const EmbeddedPostgres = EmbeddedPostgresModule as unknown as EmbeddedPostgresCtor;
75
+ const EXPECTED_MIGRATION_VERSION = readExpectedMigrationVersion();
11
76
 
12
77
  export async function createDb(dbPath = defaultDbPath) {
13
- await mkdir(dirname(dbPath), { recursive: true });
14
- const client = new PGlite(dbPath);
15
- const db = drizzle({ client, schema: dbSchema });
16
- return { db, client };
78
+ const target = await ensureDatabaseTarget(dbPath);
79
+ const sqlClient = postgres(target.connectionString, {
80
+ onnotice: () => {}
81
+ });
82
+ const db = drizzle(sqlClient, { schema: dbSchema });
83
+ let closed = false;
84
+ const client: BopoDatabaseClient = {
85
+ close: async () => {
86
+ if (closed) {
87
+ return;
88
+ }
89
+ closed = true;
90
+ try {
91
+ await sqlClient.end();
92
+ } finally {
93
+ await target.stop();
94
+ }
95
+ }
96
+ };
97
+ return {
98
+ db,
99
+ client,
100
+ connectionString: target.connectionString,
101
+ dataPath: target.dataPath,
102
+ source: target.source
103
+ };
104
+ }
105
+
106
+ export async function applyDatabaseMigrations(connectionString: string, options?: { dataPath?: string | null }) {
107
+ const statePath = options?.dataPath ? resolveLocalDbStatePath(options.dataPath) : null;
108
+ if (statePath) {
109
+ await updateLocalDbState(statePath, {
110
+ phase: "migrating",
111
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
112
+ lastError: null
113
+ });
114
+ }
115
+ const sqlClient = postgres(connectionString, {
116
+ max: 1,
117
+ onnotice: () => {}
118
+ });
119
+ try {
120
+ const migrationDb = drizzle({ client: sqlClient });
121
+ await migrate(migrationDb, { migrationsFolder: MIGRATIONS_FOLDER });
122
+ await verifyDatabaseSchema(connectionString);
123
+ if (statePath) {
124
+ await updateLocalDbState(statePath, {
125
+ phase: "running",
126
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
127
+ lastError: null
128
+ });
129
+ }
130
+ } catch (error) {
131
+ if (statePath) {
132
+ await updateLocalDbState(statePath, {
133
+ phase: "failed",
134
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
135
+ lastError: error instanceof Error ? error.message : String(error)
136
+ }).catch(() => {});
137
+ }
138
+ throw error;
139
+ } finally {
140
+ await sqlClient.end();
141
+ }
142
+ }
143
+
144
+ export function getExpectedDatabaseSchemaVersion() {
145
+ return EXPECTED_MIGRATION_VERSION;
146
+ }
147
+
148
+ export async function verifyDatabaseSchema(connectionString: string) {
149
+ const appliedCount = await readAppliedMigrationCount(connectionString);
150
+ if (appliedCount !== EXPECTED_MIGRATION_VERSION.count) {
151
+ const suffix = EXPECTED_MIGRATION_VERSION.latestTag ? ` (${EXPECTED_MIGRATION_VERSION.latestTag})` : "";
152
+ throw new Error(
153
+ `Database schema version mismatch: expected ${EXPECTED_MIGRATION_VERSION.count}${suffix} migrations, ` +
154
+ `but found ${appliedCount}. Run 'pnpm db:migrate' or 'pnpm upgrade:local' before starting this release.`
155
+ );
156
+ }
157
+ return {
158
+ appliedCount,
159
+ expectedCount: EXPECTED_MIGRATION_VERSION.count,
160
+ latestTag: EXPECTED_MIGRATION_VERSION.latestTag
161
+ };
162
+ }
163
+
164
+ export async function readAppliedMigrationCount(connectionString: string) {
165
+ const sqlClient = postgres(connectionString, {
166
+ max: 1,
167
+ onnotice: () => {}
168
+ });
169
+ try {
170
+ const rows = await sqlClient<{ count: string }[]>`
171
+ SELECT COUNT(*)::text AS count
172
+ FROM drizzle."__drizzle_migrations"
173
+ `;
174
+ return Number(rows[0]?.count ?? "0");
175
+ } catch (error) {
176
+ const message = String(error).toLowerCase();
177
+ if (message.includes("__drizzle_migrations") || message.includes("schema \"drizzle\"")) {
178
+ return 0;
179
+ }
180
+ throw error;
181
+ } finally {
182
+ await sqlClient.end();
183
+ }
184
+ }
185
+
186
+ export async function ensureDatabaseTarget(dbPath: string = defaultDbPath): Promise<DatabaseTarget> {
187
+ const externalUrl = normalizeOptionalEnvValue(process.env.DATABASE_URL);
188
+ if (externalUrl) {
189
+ return {
190
+ connectionString: externalUrl,
191
+ dataPath: null,
192
+ stop: async () => {},
193
+ source: "external-postgres"
194
+ };
195
+ }
196
+ return ensureEmbeddedPostgresTarget(resolve(dbPath));
197
+ }
198
+
199
+ async function ensureEmbeddedPostgresTarget(dataPath: string): Promise<DatabaseTarget> {
200
+ await mkdir(dataPath, { recursive: true });
201
+ const lock = await acquireLocalDbLock(dataPath, EMBEDDED_DB_START_TIMEOUT_MS);
202
+ const statePath = resolveLocalDbStatePath(dataPath);
203
+ await writeLocalDbState(statePath, {
204
+ version: LOCAL_DB_STATE_VERSION,
205
+ source: "embedded-postgres",
206
+ phase: "initializing",
207
+ pid: process.pid,
208
+ port: DEFAULT_DB_PORT,
209
+ dataPath,
210
+ updatedAt: new Date().toISOString(),
211
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
212
+ lastError: null
213
+ });
214
+
215
+ const postmasterPidFile = resolve(dataPath, "postmaster.pid");
216
+ try {
217
+ const runningPid = readRunningPostmasterPid(postmasterPidFile);
218
+ if (runningPid) {
219
+ await waitForPostmasterExit(postmasterPidFile, EMBEDDED_DB_START_TIMEOUT_MS);
220
+ const activePid = readRunningPostmasterPid(postmasterPidFile);
221
+ if (activePid) {
222
+ throw new Error(
223
+ `Embedded Postgres data path '${dataPath}' is still in use by pid ${activePid}. Stop the other process or wait for it to exit.`
224
+ );
225
+ }
226
+ }
227
+ if (existsSync(postmasterPidFile)) {
228
+ rmSync(postmasterPidFile, { force: true });
229
+ }
230
+ if (await isPortInUse(DEFAULT_DB_PORT)) {
231
+ throw new Error(
232
+ `Embedded Postgres port ${DEFAULT_DB_PORT} is already in use. Stop the process using that port or set BOPO_DB_PORT before retrying.`
233
+ );
234
+ }
235
+
236
+ const instance = new EmbeddedPostgres({
237
+ databaseDir: dataPath,
238
+ user: DEFAULT_DB_USER,
239
+ password: DEFAULT_DB_PASSWORD,
240
+ port: DEFAULT_DB_PORT,
241
+ persistent: true,
242
+ initdbFlags: ["--encoding=UTF8", "--locale=C"],
243
+ onLog: () => {},
244
+ onError: () => {}
245
+ });
246
+
247
+ if (!existsSync(resolve(dataPath, "PG_VERSION"))) {
248
+ await instance.initialise();
249
+ }
250
+
251
+ await updateLocalDbState(statePath, {
252
+ phase: "starting",
253
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
254
+ lastError: null
255
+ });
256
+ await instance.start();
257
+
258
+ try {
259
+ await ensurePostgresDatabase(connectionStringFor(DEFAULT_DB_PORT, "postgres"), DEFAULT_DB_NAME);
260
+ } catch (error) {
261
+ await instance.stop().catch(() => {});
262
+ throw error;
263
+ }
264
+
265
+ await updateLocalDbState(statePath, {
266
+ phase: "running",
267
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
268
+ lastError: null
269
+ });
270
+
271
+ let stopped = false;
272
+ return {
273
+ connectionString: connectionStringFor(DEFAULT_DB_PORT, DEFAULT_DB_NAME),
274
+ dataPath,
275
+ source: "embedded-postgres",
276
+ stop: async () => {
277
+ if (stopped) {
278
+ return;
279
+ }
280
+ stopped = true;
281
+ await updateLocalDbState(statePath, {
282
+ phase: "stopping",
283
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
284
+ lastError: null
285
+ }).catch(() => {});
286
+ try {
287
+ await instance.stop();
288
+ await updateLocalDbState(statePath, {
289
+ phase: "stopped",
290
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
291
+ lastError: null
292
+ }).catch(() => {});
293
+ } finally {
294
+ await releaseLocalDbLock(lock);
295
+ }
296
+ }
297
+ };
298
+ } catch (error) {
299
+ await updateLocalDbState(statePath, {
300
+ phase: "failed",
301
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
302
+ lastError: error instanceof Error ? error.message : String(error)
303
+ }).catch(() => {});
304
+ await releaseLocalDbLock(lock).catch(() => {});
305
+ throw error;
306
+ }
307
+ }
308
+
309
+ async function ensurePostgresDatabase(adminConnectionString: string, databaseName: string) {
310
+ const sqlClient = postgres(adminConnectionString, {
311
+ max: 1,
312
+ onnotice: () => {}
313
+ });
314
+ try {
315
+ const rows = await sqlClient<{ exists: boolean }[]>`
316
+ SELECT EXISTS (
317
+ SELECT 1
318
+ FROM pg_database
319
+ WHERE datname = ${databaseName}
320
+ ) AS exists
321
+ `;
322
+ if (!rows[0]?.exists) {
323
+ await sqlClient.unsafe(`CREATE DATABASE "${databaseName.replaceAll("\"", "\"\"")}"`);
324
+ }
325
+ } finally {
326
+ await sqlClient.end();
327
+ }
328
+ }
329
+
330
+ function readRunningPostmasterPid(postmasterPidFile: string): number | null {
331
+ if (!existsSync(postmasterPidFile)) {
332
+ return null;
333
+ }
334
+ try {
335
+ const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
336
+ if (!Number.isInteger(pid) || pid <= 0) {
337
+ return null;
338
+ }
339
+ process.kill(pid, 0);
340
+ return pid;
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ async function waitForPostmasterExit(postmasterPidFile: string, timeoutMs: number) {
347
+ const startedAt = Date.now();
348
+ while (Date.now() - startedAt < timeoutMs) {
349
+ if (!readRunningPostmasterPid(postmasterPidFile)) {
350
+ return;
351
+ }
352
+ await sleep(200);
353
+ }
354
+ }
355
+
356
+ function isPortInUse(port: number) {
357
+ return new Promise<boolean>((resolvePromise) => {
358
+ const server = createServer();
359
+ server.unref();
360
+ server.once("error", () => resolvePromise(true));
361
+ server.listen(port, "127.0.0.1", () => {
362
+ server.close(() => resolvePromise(false));
363
+ });
364
+ });
365
+ }
366
+
367
+ function connectionStringFor(port: number, databaseName: string) {
368
+ return `postgres://${DEFAULT_DB_USER}:${DEFAULT_DB_PASSWORD}@127.0.0.1:${port}/${databaseName}`;
369
+ }
370
+
371
+ function normalizeOptionalEnvValue(value: string | undefined) {
372
+ const normalized = value?.trim();
373
+ return normalized && normalized.length > 0 ? normalized : null;
374
+ }
375
+
376
+ async function acquireLocalDbLock(dataPath: string, timeoutMs: number): Promise<LocalDbLock> {
377
+ const lockPath = resolveLocalDbLockPath(dataPath);
378
+ const startedAt = Date.now();
379
+ while (Date.now() - startedAt < timeoutMs) {
380
+ try {
381
+ const handle = await open(lockPath, "wx");
382
+ await handle.writeFile(
383
+ JSON.stringify(
384
+ {
385
+ pid: process.pid,
386
+ acquiredAt: new Date().toISOString(),
387
+ dataPath
388
+ },
389
+ null,
390
+ 2
391
+ ),
392
+ "utf8"
393
+ );
394
+ return {
395
+ path: lockPath,
396
+ handle
397
+ };
398
+ } catch (error) {
399
+ if (!isAlreadyExistsError(error)) {
400
+ throw error;
401
+ }
402
+ const owner = await readLockOwner(lockPath);
403
+ if (!owner || !isPidAlive(owner.pid)) {
404
+ await rm(lockPath, { force: true }).catch(() => {});
405
+ continue;
406
+ }
407
+ await sleep(200);
408
+ }
409
+ }
410
+ const owner = await readLockOwner(lockPath);
411
+ if (owner?.pid) {
412
+ throw new Error(
413
+ `Timed out waiting for embedded Postgres lock at '${lockPath}'. Another process (pid ${owner.pid}) is starting or stopping the local database.`
414
+ );
415
+ }
416
+ throw new Error(`Timed out waiting for embedded Postgres lock at '${lockPath}'.`);
417
+ }
418
+
419
+ async function releaseLocalDbLock(lock: LocalDbLock) {
420
+ await lock.handle.close().catch(() => {});
421
+ await rm(lock.path, { force: true }).catch(() => {});
422
+ }
423
+
424
+ async function readLockOwner(lockPath: string) {
425
+ try {
426
+ const raw = await readFile(lockPath, "utf8");
427
+ const parsed = JSON.parse(raw) as { pid?: unknown };
428
+ return typeof parsed.pid === "number" ? { pid: parsed.pid } : null;
429
+ } catch {
430
+ return null;
431
+ }
432
+ }
433
+
434
+ async function writeLocalDbState(statePath: string, state: LocalDbState) {
435
+ await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
436
+ }
437
+
438
+ async function updateLocalDbState(
439
+ statePath: string,
440
+ patch: Partial<Pick<LocalDbState, "phase" | "expectedMigrationCount" | "lastError">>
441
+ ) {
442
+ const current = await readLocalDbState(statePath);
443
+ if (!current) {
444
+ return;
445
+ }
446
+ await writeLocalDbState(statePath, {
447
+ ...current,
448
+ ...patch,
449
+ updatedAt: new Date().toISOString()
450
+ });
451
+ }
452
+
453
+ async function readLocalDbState(statePath: string): Promise<LocalDbState | null> {
454
+ try {
455
+ const raw = await readFile(statePath, "utf8");
456
+ const parsed = JSON.parse(raw) as LocalDbState;
457
+ return parsed?.version === LOCAL_DB_STATE_VERSION ? parsed : null;
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+
463
+ function readExpectedMigrationVersion(): MigrationVersion {
464
+ try {
465
+ const journalPath = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
466
+ const raw = readFileSync(journalPath, "utf8");
467
+ const parsed = JSON.parse(raw) as { entries?: Array<{ tag?: unknown }> };
468
+ const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
469
+ const lastTag = entries.length > 0 && typeof entries[entries.length - 1]?.tag === "string"
470
+ ? String(entries[entries.length - 1]?.tag)
471
+ : null;
472
+ return {
473
+ count: entries.length,
474
+ latestTag: lastTag
475
+ };
476
+ } catch {
477
+ return {
478
+ count: 0,
479
+ latestTag: null
480
+ };
481
+ }
482
+ }
483
+
484
+ function isAlreadyExistsError(error: unknown) {
485
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
486
+ }
487
+
488
+ function isPidAlive(pid: number) {
489
+ try {
490
+ process.kill(pid, 0);
491
+ return true;
492
+ } catch {
493
+ return false;
494
+ }
495
+ }
496
+
497
+ function resolveLocalDbLockPath(dataPath: string) {
498
+ return `${join(dirname(dataPath), basename(dataPath))}.lock`;
499
+ }
500
+
501
+ function resolveLocalDbStatePath(dataPath: string) {
502
+ return `${join(dirname(dataPath), basename(dataPath))}.state.json`;
503
+ }
504
+
505
+ function sleep(ms: number) {
506
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
17
507
  }
@@ -43,5 +43,5 @@ export function resolveBopoInstanceRoot() {
43
43
  }
44
44
 
45
45
  export function resolveDefaultDbPath() {
46
- return join(resolveBopoInstanceRoot(), "db", "bopodev.db");
46
+ return join(resolveBopoInstanceRoot(), "db", "postgres");
47
47
  }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
+ export { and, asc, desc, eq, gt, inArray, like, notInArray, sql } from "drizzle-orm";
1
2
  export * from "./bootstrap";
2
3
  export * from "./client";
4
+ export { resolveDefaultDbPath, resolveBopoInstanceRoot } from "./default-paths";
3
5
  export * from "./repositories";
4
6
  export * from "./schema";
package/src/migrate.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { applyDatabaseMigrations, createDb } from "./client";
2
+
3
+ async function main() {
4
+ const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
5
+ const connection = await createDb(dbPath);
6
+ try {
7
+ await applyDatabaseMigrations(connection.connectionString, { dataPath: connection.dataPath });
8
+ // eslint-disable-next-line no-console
9
+ console.log("Database migrated and verified.");
10
+ } finally {
11
+ await connection.client.close();
12
+ }
13
+ }
14
+
15
+ void main();
16
+
17
+ function normalizeOptionalDbPath(value: string | undefined) {
18
+ const normalized = value?.trim();
19
+ return normalized && normalized.length > 0 ? normalized : undefined;
20
+ }