bopodev-db 0.1.27 → 0.1.29

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,642 @@
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, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const require = createRequire(import.meta.url);
8
+ /** Default export is `lock()` — see proper-lockfile `index.js`. */
9
+ const acquireProperLockfile = require("proper-lockfile") as (
10
+ path: string,
11
+ options?: Record<string, unknown>
12
+ ) => Promise<() => Promise<void>>;
13
+ import detectPort from "detect-port";
14
+ import EmbeddedPostgresModule from "embedded-postgres";
15
+ import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
16
+ import { migrate } from "drizzle-orm/postgres-js/migrator";
17
+ import postgres from "postgres";
5
18
  import * as dbSchema from "./schema";
6
19
  import { resolveDefaultDbPath } from "./default-paths";
7
20
 
8
- export type BopoDb = ReturnType<typeof drizzle<typeof dbSchema>>;
21
+ export type BopoDb = PostgresJsDatabase<typeof dbSchema>;
22
+ export type BopoDatabaseClient = {
23
+ close: () => Promise<void>;
24
+ };
9
25
 
10
26
  const defaultDbPath = resolveDefaultDbPath();
27
+ const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url));
28
+ const DEFAULT_DB_NAME = "bopodev";
29
+ const DEFAULT_DB_USER = "bopodev";
30
+ const DEFAULT_DB_PASSWORD = "bopodev";
31
+ const DEFAULT_DB_PORT = Number(process.env.BOPO_DB_PORT ?? "55432");
32
+ const EMBEDDED_DB_START_TIMEOUT_MS = Number(process.env.BOPO_DB_START_TIMEOUT_MS ?? "120000");
33
+ const EMBEDDED_DB_LOCK_STALE_MS = Math.max(
34
+ 5000,
35
+ Number(process.env.BOPO_DB_LOCK_STALE_MS ?? "60000")
36
+ );
37
+ const LOCAL_DB_STATE_VERSION = 1;
38
+ type EmbeddedPostgresInstance = {
39
+ initialise(): Promise<void>;
40
+ start(): Promise<void>;
41
+ stop(): Promise<void>;
42
+ };
43
+
44
+ type EmbeddedPostgresCtor = new (options: {
45
+ databaseDir: string;
46
+ user: string;
47
+ password: string;
48
+ port: number;
49
+ persistent: boolean;
50
+ initdbFlags?: string[];
51
+ onLog?: (message: unknown) => void;
52
+ onError?: (message: unknown) => void;
53
+ }) => EmbeddedPostgresInstance;
54
+
55
+ type DatabaseTarget = {
56
+ connectionString: string;
57
+ dataPath: string | null;
58
+ stop: () => Promise<void>;
59
+ source: "external-postgres" | "embedded-postgres";
60
+ };
61
+
62
+ type LocalDbPhase = "initializing" | "starting" | "migrating" | "running" | "stopping" | "stopped" | "failed";
63
+
64
+ type LocalDbState = {
65
+ version: number;
66
+ source: "embedded-postgres";
67
+ phase: LocalDbPhase;
68
+ pid: number;
69
+ port: number;
70
+ dataPath: string;
71
+ updatedAt: string;
72
+ expectedMigrationCount: number;
73
+ lastError: string | null;
74
+ };
75
+
76
+ type LocalDbLock = {
77
+ release: () => Promise<void>;
78
+ };
79
+
80
+ type MigrationVersion = {
81
+ count: number;
82
+ latestTag: string | null;
83
+ };
84
+
85
+ const EmbeddedPostgres = EmbeddedPostgresModule as unknown as EmbeddedPostgresCtor;
86
+ const EXPECTED_MIGRATION_VERSION = readExpectedMigrationVersion();
11
87
 
12
88
  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 };
89
+ const target = await ensureDatabaseTarget(dbPath);
90
+ const sqlClient = postgres(target.connectionString, {
91
+ onnotice: () => {}
92
+ });
93
+ const db = drizzle(sqlClient, { schema: dbSchema });
94
+ let closed = false;
95
+ const client: BopoDatabaseClient = {
96
+ close: async () => {
97
+ if (closed) {
98
+ return;
99
+ }
100
+ closed = true;
101
+ try {
102
+ await sqlClient.end();
103
+ } finally {
104
+ await target.stop();
105
+ }
106
+ }
107
+ };
108
+ return {
109
+ db,
110
+ client,
111
+ connectionString: target.connectionString,
112
+ dataPath: target.dataPath,
113
+ source: target.source
114
+ };
115
+ }
116
+
117
+ export async function applyDatabaseMigrations(connectionString: string, options?: { dataPath?: string | null }) {
118
+ const statePath = options?.dataPath ? resolveLocalDbStatePath(options.dataPath) : null;
119
+ if (statePath) {
120
+ await updateLocalDbState(statePath, {
121
+ phase: "migrating",
122
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
123
+ lastError: null
124
+ });
125
+ }
126
+ const sqlClient = postgres(connectionString, {
127
+ max: 1,
128
+ onnotice: () => {}
129
+ });
130
+ try {
131
+ const migrationDb = drizzle({ client: sqlClient });
132
+ await migrate(migrationDb, { migrationsFolder: MIGRATIONS_FOLDER });
133
+ await verifyDatabaseSchema(connectionString);
134
+ if (statePath) {
135
+ await updateLocalDbState(statePath, {
136
+ phase: "running",
137
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
138
+ lastError: null
139
+ });
140
+ }
141
+ } catch (error) {
142
+ if (statePath) {
143
+ await updateLocalDbState(statePath, {
144
+ phase: "failed",
145
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
146
+ lastError: error instanceof Error ? error.message : String(error)
147
+ }).catch(() => {});
148
+ }
149
+ throw error;
150
+ } finally {
151
+ await sqlClient.end();
152
+ }
153
+ }
154
+
155
+ export function getExpectedDatabaseSchemaVersion() {
156
+ return EXPECTED_MIGRATION_VERSION;
157
+ }
158
+
159
+ export async function verifyDatabaseSchema(connectionString: string) {
160
+ const appliedCount = await readAppliedMigrationCount(connectionString);
161
+ if (appliedCount !== EXPECTED_MIGRATION_VERSION.count) {
162
+ const suffix = EXPECTED_MIGRATION_VERSION.latestTag ? ` (${EXPECTED_MIGRATION_VERSION.latestTag})` : "";
163
+ throw new Error(
164
+ `Database schema version mismatch: expected ${EXPECTED_MIGRATION_VERSION.count}${suffix} migrations, ` +
165
+ `but found ${appliedCount}. Run 'pnpm db:migrate' or 'pnpm upgrade:local' before starting this release.`
166
+ );
167
+ }
168
+ return {
169
+ appliedCount,
170
+ expectedCount: EXPECTED_MIGRATION_VERSION.count,
171
+ latestTag: EXPECTED_MIGRATION_VERSION.latestTag
172
+ };
173
+ }
174
+
175
+ export async function readAppliedMigrationCount(connectionString: string) {
176
+ const sqlClient = postgres(connectionString, {
177
+ max: 1,
178
+ onnotice: () => {}
179
+ });
180
+ try {
181
+ const rows = await sqlClient<{ count: string }[]>`
182
+ SELECT COUNT(*)::text AS count
183
+ FROM drizzle."__drizzle_migrations"
184
+ `;
185
+ return Number(rows[0]?.count ?? "0");
186
+ } catch (error) {
187
+ const message = String(error).toLowerCase();
188
+ if (message.includes("__drizzle_migrations") || message.includes("schema \"drizzle\"")) {
189
+ return 0;
190
+ }
191
+ throw error;
192
+ } finally {
193
+ await sqlClient.end();
194
+ }
195
+ }
196
+
197
+ export async function ensureDatabaseTarget(dbPath: string = defaultDbPath): Promise<DatabaseTarget> {
198
+ const externalUrl = normalizeOptionalEnvValue(process.env.DATABASE_URL);
199
+ if (externalUrl) {
200
+ return {
201
+ connectionString: externalUrl,
202
+ dataPath: null,
203
+ stop: async () => {},
204
+ source: "external-postgres"
205
+ };
206
+ }
207
+ return ensureEmbeddedPostgresTarget(resolve(dbPath));
208
+ }
209
+
210
+ async function ensureEmbeddedPostgresTarget(dataPath: string): Promise<DatabaseTarget> {
211
+ await mkdir(dataPath, { recursive: true });
212
+ const statePath = resolveLocalDbStatePath(dataPath);
213
+ const configuredPort = DEFAULT_DB_PORT;
214
+ const resolvedDataPath = resolve(dataPath);
215
+
216
+ const reused = await tryReuseEmbeddedPostgres(resolvedDataPath, statePath, configuredPort);
217
+ if (reused) {
218
+ return reused;
219
+ }
220
+
221
+ const lock = await acquireLocalDbLock(dataPath, EMBEDDED_DB_START_TIMEOUT_MS);
222
+ let lockReleased = false;
223
+ try {
224
+ const reusedAfterLock = await tryReuseEmbeddedPostgres(resolvedDataPath, statePath, configuredPort);
225
+ if (reusedAfterLock) {
226
+ await releaseLocalDbLock(lock);
227
+ lockReleased = true;
228
+ return reusedAfterLock;
229
+ }
230
+
231
+ const postmasterPidFile = resolve(resolvedDataPath, "postmaster.pid");
232
+ if (existsSync(postmasterPidFile)) {
233
+ const pm = readPostmasterPidFile(postmasterPidFile);
234
+ if (!pm || !isPidAlive(pm.pid)) {
235
+ // eslint-disable-next-line no-console
236
+ console.warn("[bopodev-db] Removing stale embedded Postgres postmaster.pid");
237
+ rmSync(postmasterPidFile, { force: true });
238
+ }
239
+ }
240
+
241
+ const selectedPort = await detectPort(configuredPort);
242
+ if (selectedPort !== configuredPort) {
243
+ // eslint-disable-next-line no-console
244
+ console.warn(
245
+ `[bopodev-db] Embedded Postgres port ${configuredPort} is in use; using ${selectedPort}. Set BOPO_DB_PORT to pin a port.`
246
+ );
247
+ }
248
+
249
+ await writeLocalDbState(statePath, {
250
+ version: LOCAL_DB_STATE_VERSION,
251
+ source: "embedded-postgres",
252
+ phase: "initializing",
253
+ pid: process.pid,
254
+ port: selectedPort,
255
+ dataPath: resolvedDataPath,
256
+ updatedAt: new Date().toISOString(),
257
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
258
+ lastError: null
259
+ });
260
+
261
+ const instance = new EmbeddedPostgres({
262
+ databaseDir: resolvedDataPath,
263
+ user: DEFAULT_DB_USER,
264
+ password: DEFAULT_DB_PASSWORD,
265
+ port: selectedPort,
266
+ persistent: true,
267
+ initdbFlags: ["--encoding=UTF8", "--locale=C"],
268
+ onLog: () => {},
269
+ onError: () => {}
270
+ });
271
+
272
+ if (!existsSync(resolve(resolvedDataPath, "PG_VERSION"))) {
273
+ await instance.initialise();
274
+ }
275
+
276
+ await updateLocalDbState(statePath, {
277
+ phase: "starting",
278
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
279
+ lastError: null
280
+ });
281
+ await instance.start();
282
+
283
+ try {
284
+ await ensurePostgresDatabase(connectionStringFor(selectedPort, "postgres"), DEFAULT_DB_NAME);
285
+ } catch (error) {
286
+ await instance.stop().catch(() => {});
287
+ throw error;
288
+ }
289
+
290
+ await updateLocalDbState(statePath, {
291
+ phase: "running",
292
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
293
+ lastError: null
294
+ });
295
+
296
+ await releaseLocalDbLock(lock);
297
+ lockReleased = true;
298
+
299
+ let stopped = false;
300
+ return {
301
+ connectionString: connectionStringFor(selectedPort, DEFAULT_DB_NAME),
302
+ dataPath: resolvedDataPath,
303
+ source: "embedded-postgres",
304
+ stop: async () => {
305
+ if (stopped) {
306
+ return;
307
+ }
308
+ stopped = true;
309
+ await updateLocalDbState(statePath, {
310
+ phase: "stopping",
311
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
312
+ lastError: null
313
+ }).catch(() => {});
314
+ try {
315
+ await instance.stop();
316
+ await updateLocalDbState(statePath, {
317
+ phase: "stopped",
318
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
319
+ lastError: null
320
+ }).catch(() => {});
321
+ } catch {
322
+ // Best-effort shutdown; process may already be stopping.
323
+ }
324
+ }
325
+ };
326
+ } catch (error) {
327
+ await updateLocalDbState(statePath, {
328
+ phase: "failed",
329
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
330
+ lastError: error instanceof Error ? error.message : String(error)
331
+ }).catch(() => {});
332
+ if (!lockReleased) {
333
+ await releaseLocalDbLock(lock).catch(() => {});
334
+ }
335
+ throw error;
336
+ }
337
+ }
338
+
339
+ async function tryReuseEmbeddedPostgres(
340
+ resolvedDataPath: string,
341
+ statePath: string,
342
+ configuredPort: number
343
+ ): Promise<DatabaseTarget | null> {
344
+ const postmasterPidFile = resolve(resolvedDataPath, "postmaster.pid");
345
+ const pm = readPostmasterPidFile(postmasterPidFile);
346
+ if (pm && isPidAlive(pm.pid)) {
347
+ const port = pm.port ?? configuredPort;
348
+ const adminUrl = connectionStringFor(port, "postgres");
349
+ let dir: string | null;
350
+ try {
351
+ dir = await getPostgresDataDirectory(adminUrl);
352
+ } catch (error) {
353
+ throw new Error(
354
+ `Embedded Postgres data path '${resolvedDataPath}' has postmaster pid ${pm.pid}, but connecting on port ${port} failed: ` +
355
+ (error instanceof Error ? error.message : String(error))
356
+ );
357
+ }
358
+ if (!dir || resolve(dir) !== resolvedDataPath) {
359
+ throw new Error(
360
+ `Embedded Postgres data path '${resolvedDataPath}' has a live postmaster (pid ${pm.pid}), but the server reachable on port ${port} does not use this data directory.`
361
+ );
362
+ }
363
+ // eslint-disable-next-line no-console
364
+ console.warn(`[bopodev-db] Embedded Postgres already running; reusing (pid=${pm.pid}, port=${port}).`);
365
+ await writeLocalDbState(statePath, {
366
+ version: LOCAL_DB_STATE_VERSION,
367
+ source: "embedded-postgres",
368
+ phase: "running",
369
+ pid: process.pid,
370
+ port,
371
+ dataPath: resolvedDataPath,
372
+ updatedAt: new Date().toISOString(),
373
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
374
+ lastError: null
375
+ });
376
+ await ensurePostgresDatabase(adminUrl, DEFAULT_DB_NAME);
377
+ return {
378
+ connectionString: connectionStringFor(port, DEFAULT_DB_NAME),
379
+ dataPath: resolvedDataPath,
380
+ source: "embedded-postgres",
381
+ stop: async () => {}
382
+ };
383
+ }
384
+
385
+ try {
386
+ const adminUrl = connectionStringFor(configuredPort, "postgres");
387
+ const dir = await getPostgresDataDirectory(adminUrl);
388
+ if (dir && resolve(dir) === resolvedDataPath) {
389
+ // eslint-disable-next-line no-console
390
+ console.warn(
391
+ `[bopodev-db] Embedded Postgres reachable without a postmaster.pid; reusing server on port ${configuredPort}.`
392
+ );
393
+ await writeLocalDbState(statePath, {
394
+ version: LOCAL_DB_STATE_VERSION,
395
+ source: "embedded-postgres",
396
+ phase: "running",
397
+ pid: process.pid,
398
+ port: configuredPort,
399
+ dataPath: resolvedDataPath,
400
+ updatedAt: new Date().toISOString(),
401
+ expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
402
+ lastError: null
403
+ });
404
+ await ensurePostgresDatabase(adminUrl, DEFAULT_DB_NAME);
405
+ return {
406
+ connectionString: connectionStringFor(configuredPort, DEFAULT_DB_NAME),
407
+ dataPath: resolvedDataPath,
408
+ source: "embedded-postgres",
409
+ stop: async () => {}
410
+ };
411
+ }
412
+ } catch {
413
+ return null;
414
+ }
415
+ return null;
416
+ }
417
+
418
+ async function getPostgresDataDirectory(connectionString: string): Promise<string | null> {
419
+ const sqlClient = postgres(connectionString, {
420
+ max: 1,
421
+ onnotice: () => {}
422
+ });
423
+ try {
424
+ const rows = await sqlClient<{ data_directory: string | null }[]>`
425
+ SELECT current_setting('data_directory', true) AS data_directory
426
+ `;
427
+ const actual = rows[0]?.data_directory;
428
+ return typeof actual === "string" && actual.length > 0 ? actual : null;
429
+ } finally {
430
+ await sqlClient.end();
431
+ }
432
+ }
433
+
434
+ function readPostmasterPidFile(postmasterPidFile: string): { pid: number; port: number | null } | null {
435
+ if (!existsSync(postmasterPidFile)) {
436
+ return null;
437
+ }
438
+ try {
439
+ const raw = readFileSync(postmasterPidFile, "utf8");
440
+ const lines = raw.split(/\r?\n/);
441
+ const pid = Number(lines[0]?.trim());
442
+ if (!Number.isInteger(pid) || pid <= 0) {
443
+ return null;
444
+ }
445
+ let port: number | null = null;
446
+ if (lines.length >= 4) {
447
+ const parsedPort = Number(lines[3]?.trim());
448
+ if (Number.isInteger(parsedPort) && parsedPort > 0) {
449
+ port = parsedPort;
450
+ }
451
+ }
452
+ return { pid, port };
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+
458
+ async function ensurePostgresDatabase(adminConnectionString: string, databaseName: string) {
459
+ const sqlClient = postgres(adminConnectionString, {
460
+ max: 1,
461
+ onnotice: () => {}
462
+ });
463
+ try {
464
+ const rows = await sqlClient<{ exists: boolean }[]>`
465
+ SELECT EXISTS (
466
+ SELECT 1
467
+ FROM pg_database
468
+ WHERE datname = ${databaseName}
469
+ ) AS exists
470
+ `;
471
+ if (!rows[0]?.exists) {
472
+ await sqlClient.unsafe(`CREATE DATABASE "${databaseName.replaceAll("\"", "\"\"")}"`);
473
+ }
474
+ } finally {
475
+ await sqlClient.end();
476
+ }
477
+ }
478
+
479
+ function connectionStringFor(port: number, databaseName: string) {
480
+ return `postgres://${DEFAULT_DB_USER}:${DEFAULT_DB_PASSWORD}@127.0.0.1:${port}/${databaseName}`;
481
+ }
482
+
483
+ function normalizeOptionalEnvValue(value: string | undefined) {
484
+ const normalized = value?.trim();
485
+ return normalized && normalized.length > 0 ? normalized : null;
486
+ }
487
+
488
+ async function acquireLocalDbLock(dataPath: string, timeoutMs: number): Promise<LocalDbLock> {
489
+ const deadline = Date.now() + timeoutMs;
490
+ await waitForLegacyLockFileReleased(dataPath, deadline);
491
+ const lockDirPath = resolveEmbeddedPostgresLockDirPath(dataPath);
492
+ let lastError: Error | null = null;
493
+ while (Date.now() < deadline) {
494
+ try {
495
+ const release = await acquireProperLockfile(dataPath, {
496
+ lockfilePath: lockDirPath,
497
+ stale: EMBEDDED_DB_LOCK_STALE_MS,
498
+ realpath: true
499
+ });
500
+ return { release };
501
+ } catch (error) {
502
+ lastError = error instanceof Error ? error : new Error(String(error));
503
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
504
+ if (code === "ELOCKED" || lastError.message.includes("already being held")) {
505
+ await sleep(200);
506
+ continue;
507
+ }
508
+ throw lastError;
509
+ }
510
+ }
511
+ throw new Error(
512
+ `Timed out waiting for embedded Postgres lock at '${lockDirPath}' (${timeoutMs}ms). ` +
513
+ `Stop other API processes using this data path, or wait for them to finish. ` +
514
+ `If a process crashed, the lock becomes stale after ${EMBEDDED_DB_LOCK_STALE_MS}ms without updates; ` +
515
+ `you can lower BOPO_DB_LOCK_STALE_MS temporarily or remove '${lockDirPath}' if it is orphaned. ` +
516
+ (lastError ? `Last error: ${lastError.message}` : "")
517
+ );
518
+ }
519
+
520
+ async function releaseLocalDbLock(lock: LocalDbLock) {
521
+ await lock.release().catch(() => {});
522
+ }
523
+
524
+ /**
525
+ * Older builds used a JSON file lock; wait until it is gone or clearly stale so we never
526
+ * run two embedded Postgres instances against the same data path during version skew.
527
+ */
528
+ async function waitForLegacyLockFileReleased(dataPath: string, deadline: number) {
529
+ const legacyPath = resolveLegacyLocalDbLockFilePath(dataPath);
530
+ while (Date.now() < deadline) {
531
+ let st;
532
+ try {
533
+ st = await stat(legacyPath);
534
+ } catch (error) {
535
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
536
+ return;
537
+ }
538
+ throw error;
539
+ }
540
+ if (!st.isFile()) {
541
+ return;
542
+ }
543
+ const owner = await readLegacyLockOwner(legacyPath);
544
+ if (!owner || !isPidAlive(owner.pid)) {
545
+ await rm(legacyPath, { force: true }).catch(() => {});
546
+ return;
547
+ }
548
+ await sleep(200);
549
+ }
550
+ const owner = await readLegacyLockOwner(legacyPath);
551
+ throw new Error(
552
+ `Timed out waiting for legacy embedded Postgres lock at '${legacyPath}'.` +
553
+ (owner ? ` Another process (pid ${owner.pid}) is using the old lock format; stop it or upgrade it.` : "")
554
+ );
555
+ }
556
+
557
+ async function readLegacyLockOwner(lockPath: string) {
558
+ try {
559
+ const raw = await readFile(lockPath, "utf8");
560
+ const parsed = JSON.parse(raw) as { pid?: unknown };
561
+ return typeof parsed.pid === "number" ? { pid: parsed.pid } : null;
562
+ } catch {
563
+ return null;
564
+ }
565
+ }
566
+
567
+ async function writeLocalDbState(statePath: string, state: LocalDbState) {
568
+ await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
569
+ }
570
+
571
+ async function updateLocalDbState(
572
+ statePath: string,
573
+ patch: Partial<Pick<LocalDbState, "phase" | "expectedMigrationCount" | "lastError">>
574
+ ) {
575
+ const current = await readLocalDbState(statePath);
576
+ if (!current) {
577
+ return;
578
+ }
579
+ await writeLocalDbState(statePath, {
580
+ ...current,
581
+ ...patch,
582
+ updatedAt: new Date().toISOString()
583
+ });
584
+ }
585
+
586
+ async function readLocalDbState(statePath: string): Promise<LocalDbState | null> {
587
+ try {
588
+ const raw = await readFile(statePath, "utf8");
589
+ const parsed = JSON.parse(raw) as LocalDbState;
590
+ return parsed?.version === LOCAL_DB_STATE_VERSION ? parsed : null;
591
+ } catch {
592
+ return null;
593
+ }
594
+ }
595
+
596
+ function readExpectedMigrationVersion(): MigrationVersion {
597
+ try {
598
+ const journalPath = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
599
+ const raw = readFileSync(journalPath, "utf8");
600
+ const parsed = JSON.parse(raw) as { entries?: Array<{ tag?: unknown }> };
601
+ const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
602
+ const lastTag = entries.length > 0 && typeof entries[entries.length - 1]?.tag === "string"
603
+ ? String(entries[entries.length - 1]?.tag)
604
+ : null;
605
+ return {
606
+ count: entries.length,
607
+ latestTag: lastTag
608
+ };
609
+ } catch {
610
+ return {
611
+ count: 0,
612
+ latestTag: null
613
+ };
614
+ }
615
+ }
616
+
617
+ function isPidAlive(pid: number) {
618
+ try {
619
+ process.kill(pid, 0);
620
+ return true;
621
+ } catch {
622
+ return false;
623
+ }
624
+ }
625
+
626
+ /** Directory lock used by proper-lockfile (mkdir + mtime heartbeat; stale locks self-heal). */
627
+ function resolveEmbeddedPostgresLockDirPath(dataPath: string) {
628
+ return `${join(dirname(dataPath), basename(dataPath))}.embed.lock`;
629
+ }
630
+
631
+ /** Legacy JSON file lock path (pre proper-lockfile). */
632
+ function resolveLegacyLocalDbLockFilePath(dataPath: string) {
633
+ return `${join(dirname(dataPath), basename(dataPath))}.lock`;
634
+ }
635
+
636
+ function resolveLocalDbStatePath(dataPath: string) {
637
+ return `${join(dirname(dataPath), basename(dataPath))}.state.json`;
638
+ }
639
+
640
+ function sleep(ms: number) {
641
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
17
642
  }
@@ -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,5 +1,7 @@
1
+ export { and, asc, desc, eq, gt, inArray, like, max, notInArray, sql } from "drizzle-orm";
1
2
  export * from "./bootstrap";
2
3
  export * from "./client";
3
4
  export { resolveDefaultDbPath, resolveBopoInstanceRoot } from "./default-paths";
5
+ export * from "./ping";
4
6
  export * from "./repositories";
5
7
  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
+ }