@lightupai/polaris 0.0.103 → 0.0.105
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/Makefile +1 -1
- package/docker-compose.yml +5 -0
- package/package.json +1 -1
- package/src/client/client.ts +32 -8
- package/src/daemon/daemon.ts +117 -5
- package/src/service/db.ts +33 -2
- package/tests/e2e.test.ts +2 -0
package/Makefile
CHANGED
package/docker-compose.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightupai/polaris",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.105",
|
|
4
4
|
"description": "Record and stream Claude Code sessions to Slack. Like Gong for AI coding — capture every prompt, response, and tool call. Multiplayer collaboration across AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
package/src/client/client.ts
CHANGED
|
@@ -264,7 +264,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
264
264
|
...(agent ? { agent } : {}),
|
|
265
265
|
...(create ? { create: true } : {}),
|
|
266
266
|
});
|
|
267
|
-
const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; agent?: string; error?: string; existing?: string[]; account?: string };
|
|
267
|
+
const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; agent?: string; error?: string; existing?: string[]; account?: string; gap?: { events: number; since: string } | null };
|
|
268
268
|
if (res.status === 404 && body.error === "project_not_found") {
|
|
269
269
|
const accountLine = body.account ? ` in ${body.account}` : "";
|
|
270
270
|
const list = body.existing && body.existing.length > 0
|
|
@@ -278,7 +278,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
278
278
|
currentUser = user;
|
|
279
279
|
currentProfile = activeProfile;
|
|
280
280
|
await saveSessionState(currentProject, currentSession, currentUser, currentProfile);
|
|
281
|
-
|
|
281
|
+
let msg = `Connected to #${currentProject}/${currentSession} as ${user}.`;
|
|
282
|
+
if (body.gap && body.gap.events > 0) {
|
|
283
|
+
msg += `\n${body.gap.events} events since ${body.gap.since} not delivered — re-sending now.`;
|
|
284
|
+
}
|
|
285
|
+
return { content: [{ type: "text", text: msg }] };
|
|
282
286
|
}
|
|
283
287
|
return { content: [{ type: "text", text: `Failed to connect: ${body.error ?? "unknown error"}` }] };
|
|
284
288
|
} catch {
|
|
@@ -302,12 +306,23 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
302
306
|
|
|
303
307
|
if (name === "polaris_status") {
|
|
304
308
|
try {
|
|
305
|
-
const
|
|
306
|
-
|
|
309
|
+
const [statusRes, checkpointRes] = await Promise.all([
|
|
310
|
+
daemonGet(`/status/${CC_SESSION_ID}`),
|
|
311
|
+
daemonGet(`/checkpoint/${CC_SESSION_ID}`),
|
|
312
|
+
]);
|
|
313
|
+
const body = (await statusRes.json()) as { connected: boolean; project?: string; session?: string; user?: string; account?: string; slackChannel?: string };
|
|
307
314
|
if (body.connected) {
|
|
308
315
|
const accountTag = body.account ? ` [${body.account}]` : "";
|
|
309
316
|
const slackTag = body.slackChannel ? ` #${body.slackChannel}` : "";
|
|
310
|
-
|
|
317
|
+
let text = `Connected: ${body.project}/${body.session} as ${body.user}${accountTag}${slackTag}`;
|
|
318
|
+
try {
|
|
319
|
+
const cp = await checkpointRes.json() as { hasCheckpoint: boolean; lastEventTimestamp?: string; pending?: number };
|
|
320
|
+
if (cp.hasCheckpoint && cp.lastEventTimestamp) {
|
|
321
|
+
const ts = new Date(cp.lastEventTimestamp).toISOString().slice(11, 19) + "Z";
|
|
322
|
+
text += `\nLast delivered: ${ts}, ${cp.pending ?? 0} pending`;
|
|
323
|
+
}
|
|
324
|
+
} catch { /* best-effort */ }
|
|
325
|
+
return { content: [{ type: "text", text }] };
|
|
311
326
|
}
|
|
312
327
|
return { content: [{ type: "text", text: "Not connected to any Polaris session." }] };
|
|
313
328
|
} catch {
|
|
@@ -478,7 +493,7 @@ async function main() {
|
|
|
478
493
|
user: saved.user,
|
|
479
494
|
profile: reconnectProfile,
|
|
480
495
|
});
|
|
481
|
-
const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; error?: string };
|
|
496
|
+
const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; error?: string; gap?: { events: number; since: string } | null };
|
|
482
497
|
if (res.ok) {
|
|
483
498
|
currentProject = body.project ?? saved.project;
|
|
484
499
|
currentSession = body.session ?? saved.session;
|
|
@@ -486,7 +501,11 @@ async function main() {
|
|
|
486
501
|
currentProfile = reconnectProfile;
|
|
487
502
|
// Show email when it looks like one; fall back to profile key or "default"
|
|
488
503
|
const accountLabel = reconnectProfile.includes("@") ? reconnectProfile : (reconnectProfile || "default");
|
|
489
|
-
|
|
504
|
+
let reconnMsg = `Polaris auto-reconnected to #${currentProject}/${currentSession} [${accountLabel}]`;
|
|
505
|
+
if (body.gap && body.gap.events > 0) {
|
|
506
|
+
reconnMsg += `; re-sending ${body.gap.events} events since ${body.gap.since}`;
|
|
507
|
+
}
|
|
508
|
+
console.error(reconnMsg);
|
|
490
509
|
} else {
|
|
491
510
|
// Session or project no longer exists — clear stale state
|
|
492
511
|
await clearSessionState();
|
|
@@ -514,7 +533,12 @@ async function main() {
|
|
|
514
533
|
profile: currentProfile,
|
|
515
534
|
});
|
|
516
535
|
if (reconnectRes.ok) {
|
|
517
|
-
|
|
536
|
+
const reconnBody = await reconnectRes.json() as { gap?: { events: number; since: string } | null };
|
|
537
|
+
let reconnMsg = `Polaris: session dropped; auto-reconnected to #${currentProject}/${currentSession}`;
|
|
538
|
+
if (reconnBody.gap && reconnBody.gap.events > 0) {
|
|
539
|
+
reconnMsg += `; re-sending ${reconnBody.gap.events} events`;
|
|
540
|
+
}
|
|
541
|
+
console.error(reconnMsg);
|
|
518
542
|
} else {
|
|
519
543
|
console.error(`Polaris: session dropped and reconnect failed. Run /polaris to check status.`);
|
|
520
544
|
currentProject = "";
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Server, ServerWebSocket } from "bun";
|
|
|
2
2
|
import { readFile, writeFile, appendFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
|
|
6
7
|
// --- Session registry ---
|
|
7
8
|
|
|
@@ -205,10 +206,13 @@ function ensureLogDir(): Promise<void> {
|
|
|
205
206
|
return logReady;
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
async function logEvent(endpoint: string, payload: unknown, response?: { status: number; body?: unknown }): Promise<void> {
|
|
209
|
+
async function logEvent(endpoint: string, payload: unknown, response?: { status: number; body?: unknown }, context?: { project?: string; session?: string; ccSessionId?: string }): Promise<void> {
|
|
209
210
|
try {
|
|
210
211
|
await ensureLogDir();
|
|
211
212
|
const entry: Record<string, unknown> = { t: new Date().toISOString(), endpoint, payload };
|
|
213
|
+
if (context?.project) entry.project = context.project;
|
|
214
|
+
if (context?.session) entry.session = context.session;
|
|
215
|
+
if (context?.ccSessionId) entry.ccSessionId = context.ccSessionId;
|
|
212
216
|
if (response) entry.response = response;
|
|
213
217
|
const file = join(LOG_DIR, `daemon-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
214
218
|
await appendFile(file, JSON.stringify(entry) + "\n");
|
|
@@ -324,6 +328,43 @@ async function logSessionChange(action: "connect" | "disconnect" | "ws-drop", cc
|
|
|
324
328
|
} catch { /* best-effort */ }
|
|
325
329
|
}
|
|
326
330
|
|
|
331
|
+
// --- Delivery checkpoint ---
|
|
332
|
+
//
|
|
333
|
+
// After each successful event relay, the daemon writes a tiny checkpoint file
|
|
334
|
+
// next to the session state file: ~/.polaris/sessions/{hash}-checkpoint.json
|
|
335
|
+
// where {hash} is the same SHA-256 prefix used by the client. On reconnect,
|
|
336
|
+
// the checkpoint is used to auto-backfill the gap without any manual action.
|
|
337
|
+
|
|
338
|
+
const SESSIONS_DIR = join(homedir(), ".polaris", "sessions");
|
|
339
|
+
|
|
340
|
+
function checkpointPath(ccSessionId: string): string {
|
|
341
|
+
const hash = createHash("sha256").update(ccSessionId).digest("hex").slice(0, 16);
|
|
342
|
+
return join(SESSIONS_DIR, `${hash}-checkpoint.json`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
interface Checkpoint {
|
|
346
|
+
lastEventId: string;
|
|
347
|
+
lastEventTimestamp: string;
|
|
348
|
+
project: string;
|
|
349
|
+
session: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function writeCheckpoint(ccSessionId: string, eventId: string, eventTimestamp: string, project: string, session: string): Promise<void> {
|
|
353
|
+
try {
|
|
354
|
+
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
355
|
+
const cp: Checkpoint = { lastEventId: eventId, lastEventTimestamp: eventTimestamp, project, session };
|
|
356
|
+
await writeFile(checkpointPath(ccSessionId), JSON.stringify(cp));
|
|
357
|
+
} catch { /* best-effort */ }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function readCheckpoint(ccSessionId: string): Promise<Checkpoint | null> {
|
|
361
|
+
try {
|
|
362
|
+
const data = JSON.parse(await readFile(checkpointPath(ccSessionId), "utf-8")) as Checkpoint;
|
|
363
|
+
if (data.lastEventId && data.lastEventTimestamp) return data;
|
|
364
|
+
} catch { /* none */ }
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
327
368
|
// --- Backfill: recover lost events from daemon log ---
|
|
328
369
|
|
|
329
370
|
async function backfill(mapping: SessionMapping, duration?: string, from?: string, dryRun = false): Promise<{ recovered: number; would_recover?: number; source: string; gaps: string[] }> {
|
|
@@ -362,7 +403,7 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
|
|
|
362
403
|
}
|
|
363
404
|
|
|
364
405
|
// Read daemon log files covering the time range
|
|
365
|
-
const logEntries: Array<{ t: string; endpoint: string; payload: Record<string, unknown
|
|
406
|
+
const logEntries: Array<{ t: string; endpoint: string; payload: Record<string, unknown>; project?: string; session?: string; ccSessionId?: string; response?: unknown }> = [];
|
|
366
407
|
const startDate = since.toISOString().slice(0, 10);
|
|
367
408
|
const endDate = now.toISOString().slice(0, 10);
|
|
368
409
|
|
|
@@ -385,6 +426,14 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
|
|
|
385
426
|
if (entry.endpoint !== "/events") continue;
|
|
386
427
|
if (new Date(entry.t) <= since) continue;
|
|
387
428
|
if (new Date(entry.t) > now) continue;
|
|
429
|
+
// Filter by origin (added since v0.0.105). Old entries without these fields
|
|
430
|
+
// pass through for backwards compatibility with pre-105 logs.
|
|
431
|
+
if (entry.project !== undefined && entry.project !== mapping.project) continue;
|
|
432
|
+
if (entry.session !== undefined && entry.session !== mapping.session) continue;
|
|
433
|
+
if (entry.ccSessionId !== undefined && entry.ccSessionId !== mapping.ccSessionId) continue;
|
|
434
|
+
// Skip error-response entries — they're duplicates of the primary entry
|
|
435
|
+
// and would cause the same event to be replayed twice.
|
|
436
|
+
if (entry.response !== undefined) continue;
|
|
388
437
|
logEntries.push(entry);
|
|
389
438
|
} catch { /* skip malformed lines */ }
|
|
390
439
|
}
|
|
@@ -424,6 +473,19 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
|
|
|
424
473
|
|
|
425
474
|
return { recovered, source: "daemon_log", gaps: [] };
|
|
426
475
|
}
|
|
476
|
+
|
|
477
|
+
// Auto-backfill from checkpoint after a reconnect. Called non-blocking (void)
|
|
478
|
+
// so the /connect response is returned immediately while replay runs in background.
|
|
479
|
+
async function autoBackfillAfterConnect(mapping: SessionMapping): Promise<void> {
|
|
480
|
+
const cp = await readCheckpoint(mapping.ccSessionId);
|
|
481
|
+
if (!cp) return;
|
|
482
|
+
if (cp.project !== mapping.project || cp.session !== mapping.session) return;
|
|
483
|
+
const result = await backfill(mapping, undefined, cp.lastEventTimestamp);
|
|
484
|
+
if (result.recovered > 0) {
|
|
485
|
+
console.error(`polaris daemon: gap detected — auto-backfilled ${result.recovered} events since ${cp.lastEventTimestamp}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
427
489
|
// --- HTTP Server ---
|
|
428
490
|
|
|
429
491
|
function json(data: unknown, status = 200): Response {
|
|
@@ -552,6 +614,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
552
614
|
// Retry with new name on 409 (collision with generated name)
|
|
553
615
|
let attempts = 0;
|
|
554
616
|
let created = false;
|
|
617
|
+
let isReconnect = false; // true only when the session already existed (409 + explicit name)
|
|
555
618
|
while (!created && attempts < 3) {
|
|
556
619
|
const sessionRes = await fetch(`${serviceUrl}/projects/${body.project}/sessions`, {
|
|
557
620
|
method: "POST",
|
|
@@ -569,6 +632,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
569
632
|
body: JSON.stringify({ driver: body.user }),
|
|
570
633
|
});
|
|
571
634
|
created = true;
|
|
635
|
+
isReconnect = true; // session pre-existed → this is a true reconnect
|
|
572
636
|
} else {
|
|
573
637
|
// Generated name collision — retry with new name
|
|
574
638
|
mapping.session = generateSessionName();
|
|
@@ -597,12 +661,33 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
597
661
|
connectCloudWs(mapping);
|
|
598
662
|
await logSessionChange("connect", mapping.ccSessionId, mapping.project, mapping.session, mapping.user, mapping.agent);
|
|
599
663
|
|
|
664
|
+
// Check for a delivery gap since the last checkpoint (local-only dry run — no network).
|
|
665
|
+
// Only meaningful on a true reconnect (existing session); a brand-new session has no
|
|
666
|
+
// history to recover. Clear any stale checkpoint when starting a fresh session.
|
|
667
|
+
let gap: { events: number; since: string } | null = null;
|
|
668
|
+
if (isReconnect) {
|
|
669
|
+
const cp = await readCheckpoint(mapping.ccSessionId);
|
|
670
|
+
if (cp && cp.project === mapping.project && cp.session === mapping.session) {
|
|
671
|
+
const dryResult = await backfill(mapping, undefined, cp.lastEventTimestamp, true);
|
|
672
|
+
const count = dryResult.would_recover ?? 0;
|
|
673
|
+
if (count > 0) {
|
|
674
|
+
gap = { events: count, since: cp.lastEventTimestamp };
|
|
675
|
+
void autoBackfillAfterConnect(mapping);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
// Fresh session — clear any checkpoint left over from a previous session
|
|
680
|
+
// on this CC session ID so stale data never triggers a false backfill.
|
|
681
|
+
void unlink(checkpointPath(mapping.ccSessionId)).catch(() => {});
|
|
682
|
+
}
|
|
683
|
+
|
|
600
684
|
return json({
|
|
601
685
|
status: "connected",
|
|
602
686
|
project: mapping.project,
|
|
603
687
|
session: mapping.session,
|
|
604
688
|
user: mapping.user,
|
|
605
689
|
agent: mapping.agent,
|
|
690
|
+
gap,
|
|
606
691
|
});
|
|
607
692
|
} catch {
|
|
608
693
|
return error("Invalid JSON", 400);
|
|
@@ -623,6 +708,8 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
623
708
|
mapping.session = "";
|
|
624
709
|
mapping.user = "";
|
|
625
710
|
}
|
|
711
|
+
// Clear checkpoint — a deliberate disconnect means there is nothing to recover
|
|
712
|
+
void unlink(checkpointPath(body.ccSessionId)).catch(() => {});
|
|
626
713
|
return json({ status: "disconnected" });
|
|
627
714
|
} catch {
|
|
628
715
|
return error("Invalid JSON", 400);
|
|
@@ -636,6 +723,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
636
723
|
mapping.project = "";
|
|
637
724
|
mapping.session = "";
|
|
638
725
|
mapping.user = "";
|
|
726
|
+
void unlink(checkpointPath(id)).catch(() => {});
|
|
639
727
|
}
|
|
640
728
|
sessions.clear();
|
|
641
729
|
injectQueues.clear();
|
|
@@ -646,7 +734,6 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
646
734
|
if (method === "POST" && pathname === "/events") {
|
|
647
735
|
try {
|
|
648
736
|
const body = (await req.json()) as { session_id?: string; [key: string]: unknown };
|
|
649
|
-
await logEvent("/events", body);
|
|
650
737
|
const ccSessionId = body.session_id;
|
|
651
738
|
if (!ccSessionId) return error("session_id required in hook payload", 400);
|
|
652
739
|
|
|
@@ -689,6 +776,9 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
689
776
|
}
|
|
690
777
|
}
|
|
691
778
|
|
|
779
|
+
// Log with session context so backfill can filter by exact origin
|
|
780
|
+
await logEvent("/events", body, undefined, { project: mapping.project, session: mapping.session, ccSessionId });
|
|
781
|
+
|
|
692
782
|
// Determine sender: human for prompts, agent for everything else
|
|
693
783
|
const hookEvent = body.hook_event_name as string | undefined;
|
|
694
784
|
const sender = hookEvent === "UserPromptSubmit" ? mapping.user : mapping.agent;
|
|
@@ -713,7 +803,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
713
803
|
|
|
714
804
|
if (!res || res.status >= 500) {
|
|
715
805
|
console.error(`polaris daemon: upstream relay failed (${res ? res.status : "network error"}) — queued to outbox`);
|
|
716
|
-
await logEvent("/events", body, { status: res?.status ?? 0, body: "queued to outbox" });
|
|
806
|
+
await logEvent("/events", body, { status: res?.status ?? 0, body: "queued to outbox" }, { project: mapping.project, session: mapping.session, ccSessionId });
|
|
717
807
|
await enqueueOutbox(mapping.project, mapping.session, mapping.profile, relayBody);
|
|
718
808
|
// The event is durably accepted (outbox), so still drain injects
|
|
719
809
|
if (hookEvent === "UserPromptSubmit") {
|
|
@@ -726,10 +816,18 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
726
816
|
|
|
727
817
|
if (!res.ok) {
|
|
728
818
|
const err = await res.text();
|
|
729
|
-
await logEvent("/events", body, { status: res.status, body: err });
|
|
819
|
+
await logEvent("/events", body, { status: res.status, body: err }, { project: mapping.project, session: mapping.session, ccSessionId });
|
|
730
820
|
return new Response(err, { status: res.status });
|
|
731
821
|
}
|
|
732
822
|
|
|
823
|
+
// Write delivery checkpoint so reconnects can auto-backfill the exact gap
|
|
824
|
+
try {
|
|
825
|
+
const delivered = await res.json() as { id?: string; timestamp?: string };
|
|
826
|
+
if (delivered.id && delivered.timestamp) {
|
|
827
|
+
void writeCheckpoint(ccSessionId, delivered.id, delivered.timestamp, mapping.project, mapping.session);
|
|
828
|
+
}
|
|
829
|
+
} catch { /* best-effort — don't block the response */ }
|
|
830
|
+
|
|
733
831
|
// UserPromptSubmit: drain queued advisor injects and hand them back
|
|
734
832
|
// to the hook, which surfaces them via additionalContext
|
|
735
833
|
if (hookEvent === "UserPromptSubmit") {
|
|
@@ -967,6 +1065,20 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
967
1065
|
}
|
|
968
1066
|
}
|
|
969
1067
|
|
|
1068
|
+
// GET /checkpoint/:ccSessionId — checkpoint state + pending count for polaris_status
|
|
1069
|
+
if (method === "GET" && pathname.startsWith("/checkpoint/")) {
|
|
1070
|
+
const ccSessionId = pathname.slice("/checkpoint/".length);
|
|
1071
|
+
const cp = await readCheckpoint(ccSessionId);
|
|
1072
|
+
if (!cp) return json({ hasCheckpoint: false });
|
|
1073
|
+
const mapping = sessions.get(ccSessionId);
|
|
1074
|
+
let pending = 0;
|
|
1075
|
+
if (mapping?.project && cp.project === mapping.project && cp.session === mapping.session) {
|
|
1076
|
+
const dryResult = await backfill(mapping, undefined, cp.lastEventTimestamp, true);
|
|
1077
|
+
pending = dryResult.would_recover ?? 0;
|
|
1078
|
+
}
|
|
1079
|
+
return json({ hasCheckpoint: true, lastEventTimestamp: cp.lastEventTimestamp, pending });
|
|
1080
|
+
}
|
|
1081
|
+
|
|
970
1082
|
// POST /backfill — recover lost events from daemon log
|
|
971
1083
|
if (method === "POST" && pathname === "/backfill") {
|
|
972
1084
|
try {
|
package/src/service/db.ts
CHANGED
|
@@ -34,8 +34,39 @@ export interface ProjectMember {
|
|
|
34
34
|
// --- Schema ---
|
|
35
35
|
|
|
36
36
|
export async function createDb(connectionString?: string): Promise<Sql> {
|
|
37
|
-
const
|
|
38
|
-
|
|
37
|
+
const connStr = connectionString ?? process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
|
|
38
|
+
const sql = postgres(connStr);
|
|
39
|
+
// Retry on transient startup errors (ECONNREFUSED, "starting up" 57P03).
|
|
40
|
+
// Fail fast on auth/role/database errors — retrying won't help.
|
|
41
|
+
const TRANSIENT_CODES = new Set(["57P03", "ECONNREFUSED"]);
|
|
42
|
+
const FATAL_CODES = new Set(["28000", "28P01", "3D000"]);
|
|
43
|
+
const MAX_ATTEMPTS = 10;
|
|
44
|
+
const RETRY_DELAY_MS = 1000;
|
|
45
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
46
|
+
try {
|
|
47
|
+
await ensureSchema(sql);
|
|
48
|
+
return sql;
|
|
49
|
+
} catch (err: unknown) {
|
|
50
|
+
const code = (err as { code?: string }).code ?? "";
|
|
51
|
+
if (FATAL_CODES.has(code)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Database connection failed (${code}): ${(err as Error).message}\n` +
|
|
54
|
+
`Hint: the role/password/database may not exist. ` +
|
|
55
|
+
`Try: docker compose down -v && docker compose up -d --wait`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (!TRANSIENT_CODES.has(code) && attempt === MAX_ATTEMPTS) {
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
62
|
+
console.error(`[db] connection attempt ${attempt}/${MAX_ATTEMPTS} failed (${code}), retrying in ${RETRY_DELAY_MS}ms...`);
|
|
63
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
64
|
+
} else {
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// unreachable
|
|
39
70
|
return sql;
|
|
40
71
|
}
|
|
41
72
|
|
package/tests/e2e.test.ts
CHANGED
|
@@ -32,6 +32,8 @@ afterAll(async () => {
|
|
|
32
32
|
|
|
33
33
|
beforeEach(async () => {
|
|
34
34
|
await resetTestData(sql);
|
|
35
|
+
// Disconnect all daemon sessions so checkpoint files are cleared between tests
|
|
36
|
+
await fetch(`${daemonUrl}/disconnect-all`, { method: "POST", headers: { "Content-Type": "application/json" } });
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
async function post(base: string, path: string, body: unknown) {
|