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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-lint.log +4 -0
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/ping.d.ts +3 -0
- package/dist/repositories/companies.d.ts +32 -0
- package/dist/repositories/helpers.d.ts +16 -0
- package/dist/repositories/index.d.ts +3 -0
- package/dist/repositories/legacy.d.ts +1420 -0
- package/dist/schema.d.ts +252 -0
- package/package.json +4 -2
- package/src/client.ts +249 -114
- package/src/index.ts +2 -1
- package/src/migrations/0001_issues_external_link.sql +1 -0
- package/src/migrations/0002_issues_goal_goals_owner_agent.sql +2 -0
- package/src/migrations/0003_issue_goals_junction.sql +12 -0
- package/src/migrations/0004_agents_capabilities.sql +1 -0
- package/src/migrations/meta/_journal.json +28 -0
- package/src/ping.ts +7 -0
- package/src/repositories/companies.ts +41 -0
- package/src/repositories/helpers.ts +104 -0
- package/src/repositories/index.ts +3 -0
- package/src/{repositories.ts → repositories/legacy.ts} +148 -115
- package/src/schema.ts +23 -0
package/src/client.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { mkdir,
|
|
3
|
-
import {
|
|
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 ?? "
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
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
|
|
218
|
-
if (
|
|
219
|
-
await
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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:
|
|
262
|
+
databaseDir: resolvedDataPath,
|
|
238
263
|
user: DEFAULT_DB_USER,
|
|
239
264
|
password: DEFAULT_DB_PASSWORD,
|
|
240
|
-
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(
|
|
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(
|
|
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(
|
|
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
|
-
}
|
|
294
|
-
|
|
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
|
-
|
|
332
|
+
if (!lockReleased) {
|
|
333
|
+
await releaseLocalDbLock(lock).catch(() => {});
|
|
334
|
+
}
|
|
305
335
|
throw error;
|
|
306
336
|
}
|
|
307
337
|
}
|
|
308
338
|
|
|
309
|
-
async function
|
|
310
|
-
|
|
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<{
|
|
316
|
-
SELECT
|
|
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
|
-
|
|
323
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
508
|
+
throw lastError;
|
|
408
509
|
}
|
|
409
510
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
`
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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.
|
|
421
|
-
await rm(lock.path, { force: true }).catch(() => {});
|
|
521
|
+
await lock.release().catch(() => {});
|
|
422
522
|
}
|
|
423
523
|
|
|
424
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|