bopodev-db 0.1.28 → 0.1.30

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,8 +1,16 @@
1
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";
2
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
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";
6
14
  import EmbeddedPostgresModule from "embedded-postgres";
7
15
  import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
8
16
  import { migrate } from "drizzle-orm/postgres-js/migrator";
@@ -21,7 +29,11 @@ const DEFAULT_DB_NAME = "bopodev";
21
29
  const DEFAULT_DB_USER = "bopodev";
22
30
  const DEFAULT_DB_PASSWORD = "bopodev";
23
31
  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");
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
+ );
25
37
  const LOCAL_DB_STATE_VERSION = 1;
26
38
  type EmbeddedPostgresInstance = {
27
39
  initialise(): Promise<void>;
@@ -62,8 +74,7 @@ type LocalDbState = {
62
74
  };
63
75
 
64
76
  type LocalDbLock = {
65
- path: string;
66
- handle: FileHandle;
77
+ release: () => Promise<void>;
67
78
  };
68
79
 
69
80
  type MigrationVersion = {
@@ -198,53 +209,67 @@ export async function ensureDatabaseTarget(dbPath: string = defaultDbPath): Prom
198
209
 
199
210
  async function ensureEmbeddedPostgresTarget(dataPath: string): Promise<DatabaseTarget> {
200
211
  await mkdir(dataPath, { recursive: true });
201
- const lock = await acquireLocalDbLock(dataPath, EMBEDDED_DB_START_TIMEOUT_MS);
202
212
  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
- });
213
+ const configuredPort = DEFAULT_DB_PORT;
214
+ const resolvedDataPath = resolve(dataPath);
214
215
 
215
- const postmasterPidFile = resolve(dataPath, "postmaster.pid");
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;
216
223
  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
- }
224
+ const reusedAfterLock = await tryReuseEmbeddedPostgres(resolvedDataPath, statePath, configuredPort);
225
+ if (reusedAfterLock) {
226
+ await releaseLocalDbLock(lock);
227
+ lockReleased = true;
228
+ return reusedAfterLock;
226
229
  }
230
+
231
+ const postmasterPidFile = resolve(resolvedDataPath, "postmaster.pid");
227
232
  if (existsSync(postmasterPidFile)) {
228
- rmSync(postmasterPidFile, { force: true });
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
+ }
229
239
  }
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.`
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.`
233
246
  );
234
247
  }
235
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
+
236
261
  const instance = new EmbeddedPostgres({
237
- databaseDir: dataPath,
262
+ databaseDir: resolvedDataPath,
238
263
  user: DEFAULT_DB_USER,
239
264
  password: DEFAULT_DB_PASSWORD,
240
- port: DEFAULT_DB_PORT,
265
+ port: selectedPort,
241
266
  persistent: true,
242
267
  initdbFlags: ["--encoding=UTF8", "--locale=C"],
243
268
  onLog: () => {},
244
269
  onError: () => {}
245
270
  });
246
271
 
247
- if (!existsSync(resolve(dataPath, "PG_VERSION"))) {
272
+ if (!existsSync(resolve(resolvedDataPath, "PG_VERSION"))) {
248
273
  await instance.initialise();
249
274
  }
250
275
 
@@ -256,7 +281,7 @@ async function ensureEmbeddedPostgresTarget(dataPath: string): Promise<DatabaseT
256
281
  await instance.start();
257
282
 
258
283
  try {
259
- await ensurePostgresDatabase(connectionStringFor(DEFAULT_DB_PORT, "postgres"), DEFAULT_DB_NAME);
284
+ await ensurePostgresDatabase(connectionStringFor(selectedPort, "postgres"), DEFAULT_DB_NAME);
260
285
  } catch (error) {
261
286
  await instance.stop().catch(() => {});
262
287
  throw error;
@@ -268,10 +293,13 @@ async function ensureEmbeddedPostgresTarget(dataPath: string): Promise<DatabaseT
268
293
  lastError: null
269
294
  });
270
295
 
296
+ await releaseLocalDbLock(lock);
297
+ lockReleased = true;
298
+
271
299
  let stopped = false;
272
300
  return {
273
- connectionString: connectionStringFor(DEFAULT_DB_PORT, DEFAULT_DB_NAME),
274
- dataPath,
301
+ connectionString: connectionStringFor(selectedPort, DEFAULT_DB_NAME),
302
+ dataPath: resolvedDataPath,
275
303
  source: "embedded-postgres",
276
304
  stop: async () => {
277
305
  if (stopped) {
@@ -290,8 +318,8 @@ async function ensureEmbeddedPostgresTarget(dataPath: string): Promise<DatabaseT
290
318
  expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
291
319
  lastError: null
292
320
  }).catch(() => {});
293
- } finally {
294
- await releaseLocalDbLock(lock);
321
+ } catch {
322
+ // Best-effort shutdown; process may already be stopping.
295
323
  }
296
324
  }
297
325
  };
@@ -301,69 +329,153 @@ async function ensureEmbeddedPostgresTarget(dataPath: string): Promise<DatabaseT
301
329
  expectedMigrationCount: EXPECTED_MIGRATION_VERSION.count,
302
330
  lastError: error instanceof Error ? error.message : String(error)
303
331
  }).catch(() => {});
304
- await releaseLocalDbLock(lock).catch(() => {});
332
+ if (!lockReleased) {
333
+ await releaseLocalDbLock(lock).catch(() => {});
334
+ }
305
335
  throw error;
306
336
  }
307
337
  }
308
338
 
309
- async function ensurePostgresDatabase(adminConnectionString: string, databaseName: string) {
310
- const sqlClient = postgres(adminConnectionString, {
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, {
311
420
  max: 1,
312
421
  onnotice: () => {}
313
422
  });
314
423
  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
424
+ const rows = await sqlClient<{ data_directory: string | null }[]>`
425
+ SELECT current_setting('data_directory', true) AS data_directory
321
426
  `;
322
- if (!rows[0]?.exists) {
323
- await sqlClient.unsafe(`CREATE DATABASE "${databaseName.replaceAll("\"", "\"\"")}"`);
324
- }
427
+ const actual = rows[0]?.data_directory;
428
+ return typeof actual === "string" && actual.length > 0 ? actual : null;
325
429
  } finally {
326
430
  await sqlClient.end();
327
431
  }
328
432
  }
329
433
 
330
- function readRunningPostmasterPid(postmasterPidFile: string): number | null {
434
+ function readPostmasterPidFile(postmasterPidFile: string): { pid: number; port: number | null } | null {
331
435
  if (!existsSync(postmasterPidFile)) {
332
436
  return null;
333
437
  }
334
438
  try {
335
- const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
439
+ const raw = readFileSync(postmasterPidFile, "utf8");
440
+ const lines = raw.split(/\r?\n/);
441
+ const pid = Number(lines[0]?.trim());
336
442
  if (!Number.isInteger(pid) || pid <= 0) {
337
443
  return null;
338
444
  }
339
- process.kill(pid, 0);
340
- return pid;
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 };
341
453
  } catch {
342
454
  return null;
343
455
  }
344
456
  }
345
457
 
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;
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("\"", "\"\"")}"`);
351
473
  }
352
- await sleep(200);
474
+ } finally {
475
+ await sqlClient.end();
353
476
  }
354
477
  }
355
478
 
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
479
  function connectionStringFor(port: number, databaseName: string) {
368
480
  return `postgres://${DEFAULT_DB_USER}:${DEFAULT_DB_PASSWORD}@127.0.0.1:${port}/${databaseName}`;
369
481
  }
@@ -374,54 +486,75 @@ function normalizeOptionalEnvValue(value: string | undefined) {
374
486
  }
375
487
 
376
488
  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) {
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) {
380
494
  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
- };
495
+ const release = await acquireProperLockfile(dataPath, {
496
+ lockfilePath: lockDirPath,
497
+ stale: EMBEDDED_DB_LOCK_STALE_MS,
498
+ realpath: true
499
+ });
500
+ return { release };
398
501
  } 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(() => {});
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);
405
506
  continue;
406
507
  }
407
- await sleep(200);
508
+ throw lastError;
408
509
  }
409
510
  }
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}'.`);
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
+ );
417
518
  }
418
519
 
419
520
  async function releaseLocalDbLock(lock: LocalDbLock) {
420
- await lock.handle.close().catch(() => {});
421
- await rm(lock.path, { force: true }).catch(() => {});
521
+ await lock.release().catch(() => {});
422
522
  }
423
523
 
424
- async function readLockOwner(lockPath: string) {
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) {
425
558
  try {
426
559
  const raw = await readFile(lockPath, "utf8");
427
560
  const parsed = JSON.parse(raw) as { pid?: unknown };
@@ -481,10 +614,6 @@ function readExpectedMigrationVersion(): MigrationVersion {
481
614
  }
482
615
  }
483
616
 
484
- function isAlreadyExistsError(error: unknown) {
485
- return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
486
- }
487
-
488
617
  function isPidAlive(pid: number) {
489
618
  try {
490
619
  process.kill(pid, 0);
@@ -494,7 +623,13 @@ function isPidAlive(pid: number) {
494
623
  }
495
624
  }
496
625
 
497
- function resolveLocalDbLockPath(dataPath: string) {
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) {
498
633
  return `${join(dirname(dataPath), basename(dataPath))}.lock`;
499
634
  }
500
635
 
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
- export { and, asc, desc, eq, gt, inArray, like, notInArray, sql } from "drizzle-orm";
1
+ export { and, asc, desc, eq, gt, inArray, like, max, notInArray, sql } from "drizzle-orm";
2
2
  export * from "./bootstrap";
3
3
  export * from "./client";
4
4
  export { resolveDefaultDbPath, resolveBopoInstanceRoot } from "./default-paths";
5
+ export * from "./ping";
5
6
  export * from "./repositories";
6
7
  export * from "./schema";
@@ -0,0 +1 @@
1
+ ALTER TABLE "issues" ADD COLUMN "external_link" text;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "goals" ADD COLUMN "owner_agent_id" text REFERENCES "agents"("id") ON DELETE SET NULL;
2
+ ALTER TABLE "issues" ADD COLUMN "goal_id" text REFERENCES "goals"("id") ON DELETE SET NULL;
@@ -0,0 +1,12 @@
1
+ CREATE TABLE "issue_goals" (
2
+ "issue_id" text NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE,
3
+ "goal_id" text NOT NULL REFERENCES "goals"("id") ON DELETE CASCADE,
4
+ "company_id" text NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
5
+ "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
6
+ PRIMARY KEY ("issue_id", "goal_id")
7
+ );
8
+
9
+ INSERT INTO "issue_goals" ("issue_id", "goal_id", "company_id")
10
+ SELECT "id", "goal_id", "company_id" FROM "issues" WHERE "goal_id" IS NOT NULL;
11
+
12
+ ALTER TABLE "issues" DROP COLUMN "goal_id";
@@ -0,0 +1 @@
1
+ ALTER TABLE "agents" ADD COLUMN "capabilities" text;
@@ -8,6 +8,34 @@
8
8
  "when": 1742500000000,
9
9
  "tag": "0000_initial",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1742600000000,
16
+ "tag": "0001_issues_external_link",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1742700000000,
23
+ "tag": "0002_issues_goal_goals_owner_agent",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1742800000000,
30
+ "tag": "0003_issue_goals_junction",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1742900000000,
37
+ "tag": "0004_agents_capabilities",
38
+ "breakpoints": true
11
39
  }
12
40
  ]
13
41
  }
package/src/ping.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { sql } from "drizzle-orm";
2
+ import type { BopoDb } from "./client";
3
+
4
+ /** Cheap connection liveness check (no table scan). Kept as SQL because Drizzle has no relational API for a table-free SELECT. */
5
+ export async function pingDatabase(db: BopoDb): Promise<void> {
6
+ await db.execute(sql`SELECT 1`);
7
+ }
@@ -0,0 +1,41 @@
1
+ import { desc, eq } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import type { BopoDb } from "../client";
4
+ import { companies } from "../schema";
5
+ import { compactUpdate } from "./helpers";
6
+
7
+ export async function createCompany(db: BopoDb, input: { name: string; mission?: string | null }) {
8
+ const id = nanoid(12);
9
+ await db.insert(companies).values({
10
+ id,
11
+ name: input.name,
12
+ mission: input.mission ?? null
13
+ });
14
+ return { id, ...input };
15
+ }
16
+
17
+ export async function listCompanies(db: BopoDb) {
18
+ return db.select().from(companies).orderBy(desc(companies.createdAt));
19
+ }
20
+
21
+ export async function getCompany(db: BopoDb, id: string) {
22
+ const [row] = await db.select().from(companies).where(eq(companies.id, id)).limit(1);
23
+ return row ?? null;
24
+ }
25
+
26
+ export async function updateCompany(
27
+ db: BopoDb,
28
+ input: { id: string; name?: string; mission?: string | null }
29
+ ) {
30
+ const [company] = await db
31
+ .update(companies)
32
+ .set(compactUpdate({ name: input.name, mission: input.mission }))
33
+ .where(eq(companies.id, input.id))
34
+ .returning();
35
+ return company ?? null;
36
+ }
37
+
38
+ export async function deleteCompany(db: BopoDb, id: string) {
39
+ const [deletedCompany] = await db.delete(companies).where(eq(companies.id, id)).returning({ id: companies.id });
40
+ return Boolean(deletedCompany);
41
+ }