mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
fix: 8 root-cause fixes from /investigate (v0.14.2) (#259)
* fix: 8 root-cause fixes from /investigate wave
Consolidated bundle of bug fixes from /investigate on the 8 deferred bugs.
Each fix was designed to go at the structural gap, not the symptom. Codex
verified 20 load-bearing claims on the plan; 12 triggered plan revisions.
Bug 2 — GBRAIN_POOL_SIZE env knob + init finally blocks (no auto-detect).
Covers both the singleton pool (db.ts) and instance pool (import.ts:140).
Bug 3 — Centralize migration ledger writes in apply-migrations runner.
Removed appendCompletedMigration from v0_11_0, v0_12_0, v0_12_2,
v0_13_0, v0_13_1. Added 3-partial wedge cap + --force-retry reset.
'complete wins' preserved; no partial can regress a completed migration.
Bug 5 — v0.14.0 migration registered. src/commands/migrations/v0_14_0.ts
ships Phase A (ALTER minion_jobs.max_stalled SET DEFAULT 3) + Phase B
(pending-host-work ping for shell-jobs adoption).
Bug 6/10 — jsonb_agg(DISTINCT ...) in legacy traverseGraph (both engines).
Presentation-level dedup; schema still preserves provenance rows.
Bug 7 — doctor --fast reads DB URL source via getDbUrlSource() in config.ts.
Precise message: 'Skipping DB checks (--fast mode, URL present from env)'
replaces the misleading 'No database configured'.
Bug 8 — max_stalled default bumped 1→3 in schema-embedded.ts, pglite-schema.ts,
schema.sql (new installs). v0_14_0 Phase A ALTER for existing installs.
autopilot-cycle handler yields to event loop between phases so the
worker's lock-renewal timer fires on huge brains. (Deep AbortSignal
threading through runEmbedCore/runExtractCore/runBacklinksCore/performSync
deferred to v0.15 queue polish.)
Bug 9 — Gate sync.last_commit on no-failures across all three sync paths
(incremental, full via runImport, gbrain import git continuity).
recordSyncFailures() helper + ~/.gbrain/sync-failures.jsonl with
dedup key path+commit+error-hash. New flags: --skip-failed (ack) +
--retry-failed (re-attempt). Doctor surfaces unacknowledged failures.
Bug 11 — brain_score breakdown fields on BrainHealth (embed_coverage_score,
link_density_score, timeline_coverage_score, no_orphans_score,
no_dead_links_score); sum equals brain_score by construction.
dead_links now on the type (resolves featuresTeaserForDoctor drift).
orphan_pages kept as 'islanded' (no inbound AND no outbound) and
docs updated to match — explicit semantic instead of doc drift.
New tests: test/traverse-graph-dedup.test.ts, test/sync-failures.test.ts,
test/brain-score-breakdown.test.ts, test/migration-resume.test.ts,
test/migrations-v0_14_0.test.ts. Extended: migrate, doctor, apply-migrations.
All 1696 unit tests pass locally. postgres-jsonb E2E regression unchanged
(none of these touch the JSONB write surface).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: v0.14.2 CHANGELOG + CLAUDE.md; align migration-flow E2E with runner-owned ledger
CHANGELOG: v0.14.2 entry in the standard release-summary format
(two-line headline + lead + numbers table + "what this means" +
"To take advantage of v0.14.2" self-repair block + itemized
changes grouped by reliability / observability / graph correctness /
new migration / tests / deferred-to-v0.15).
CLAUDE.md: new "Key commands added in v0.14.2" section covers
--skip-failed, --retry-failed, --force-retry, GBRAIN_POOL_SIZE env,
and the new doctor checks (sync_failures, brain_score breakdown).
Migration orchestrator docs updated to describe v0_14_0.ts + the
runner-owned ledger contract from Bug 3.
test/e2e/migration-flow.test.ts: three assertions updated to match
the Bug 3 contract — orchestrators no longer append to completed.jsonl
directly, so direct-orchestrator E2E calls leave the ledger empty.
Preferences assertions remain (that's still the orchestrator's side
of the contract). Runner's ledger write is covered by the unit suite
(test/apply-migrations.test.ts + test/migration-resume.test.ts).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ebfbd5e6f7
commit
b5fa3d044a
@@ -104,8 +104,9 @@ describe('buildPlan — diff against completed + installed VERSION', () => {
|
||||
expect(plan.pending.map(m => m.version)).toContain('0.11.0');
|
||||
// Future migrations (registered but newer than installed VERSION) land in
|
||||
// skippedFuture until the binary catches up. v0.13.0 = frontmatter graph
|
||||
// (master), v0.13.1 = Knowledge Runtime grandfather (this branch).
|
||||
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.0', '0.12.2', '0.13.0', '0.13.1']);
|
||||
// (master), v0.13.1 = Knowledge Runtime grandfather, v0.14.0 = shell
|
||||
// jobs + autopilot cooperative.
|
||||
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.0', '0.12.2', '0.13.0', '0.13.1', '0.14.0']);
|
||||
});
|
||||
|
||||
test('already applied → v0.11.0 lands in `applied` bucket, not pending', () => {
|
||||
@@ -141,10 +142,10 @@ describe('buildPlan — diff against completed + installed VERSION', () => {
|
||||
const idx = indexCompleted([]);
|
||||
const plan = buildPlan(idx, '0.12.0');
|
||||
expect(plan.pending.map(m => m.version)).toContain('0.11.0');
|
||||
// v0.12.2, v0.13.0, and v0.13.1 were added later; installed=0.12.0 means
|
||||
// they belong in skippedFuture, not pending. v0.11.0 and v0.12.0 stay
|
||||
// pending despite being ≤ installed — that is the H9 invariant.
|
||||
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.2', '0.13.0', '0.13.1']);
|
||||
// v0.12.2, v0.13.0, v0.13.1, and v0.14.0 were added later; installed=0.12.0
|
||||
// means they belong in skippedFuture, not pending. v0.11.0 and v0.12.0
|
||||
// stay pending despite being ≤ installed — that is the H9 invariant.
|
||||
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.2', '0.13.0', '0.13.1', '0.14.0']);
|
||||
});
|
||||
|
||||
test('--migration filter narrows to one version', () => {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Bug 11 — brain_score needs a breakdown + orphan_pages metric is wrong.
|
||||
*
|
||||
* Assertions:
|
||||
* 1. getHealth() returns the new *_score breakdown fields.
|
||||
* 2. Breakdown fields sum to brain_score by construction.
|
||||
* 3. orphan_pages counts pages with zero INBOUND links, regardless of
|
||||
* whether they have outbound links (was: required both).
|
||||
* 4. BrainHealth type now carries dead_links.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const t of ['links', 'content_chunks', 'timeline_entries', 'raw_data', 'tags', 'page_versions', 'ingest_log', 'pages']) {
|
||||
await (engine as any).db.exec(`DELETE FROM ${t}`);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Bug 11 — brain_score breakdown sums to total', () => {
|
||||
test('empty brain returns zero score with all breakdown fields present', async () => {
|
||||
const h = await engine.getHealth();
|
||||
expect(h.brain_score).toBe(0);
|
||||
expect(h.embed_coverage_score).toBe(0);
|
||||
expect(h.link_density_score).toBe(0);
|
||||
expect(h.timeline_coverage_score).toBe(0);
|
||||
expect(h.no_orphans_score).toBe(0);
|
||||
expect(h.no_dead_links_score).toBe(0);
|
||||
// dead_links is now on the type.
|
||||
expect(h.dead_links).toBe(0);
|
||||
});
|
||||
|
||||
test('breakdown fields always sum to brain_score', async () => {
|
||||
// Seed a small graph — some pages, some links, some embeds.
|
||||
for (const slug of ['a', 'b', 'c']) {
|
||||
await engine.putPage(slug, { type: 'note', title: slug, compiled_truth: `content of ${slug}`, frontmatter: {} });
|
||||
}
|
||||
const h = await engine.getHealth();
|
||||
const sum =
|
||||
h.embed_coverage_score +
|
||||
h.link_density_score +
|
||||
h.timeline_coverage_score +
|
||||
h.no_orphans_score +
|
||||
h.no_dead_links_score;
|
||||
expect(sum).toBe(h.brain_score);
|
||||
});
|
||||
|
||||
test('brain_score caps at 100', async () => {
|
||||
const h = await engine.getHealth();
|
||||
expect(h.brain_score).toBeGreaterThanOrEqual(0);
|
||||
expect(h.brain_score).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 11 — orphan_pages is "no inbound links"', () => {
|
||||
test('a page with outbound-only links is NOT an orphan', async () => {
|
||||
// Hub page: links out to three others, but nothing links back to it.
|
||||
// Previous (buggy) behavior: hub counted as orphan because it had no
|
||||
// inbound links (correct) AND the old query also required no outbound.
|
||||
await engine.putPage('hub', { type: 'note', title: 'Hub', compiled_truth: 'index', frontmatter: {} });
|
||||
await engine.putPage('leaf1', { type: 'note', title: 'L1', compiled_truth: 'x', frontmatter: {} });
|
||||
await engine.putPage('leaf2', { type: 'note', title: 'L2', compiled_truth: 'y', frontmatter: {} });
|
||||
await engine.putPage('leaf3', { type: 'note', title: 'L3', compiled_truth: 'z', frontmatter: {} });
|
||||
|
||||
const hubId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='hub'`)).rows[0].id;
|
||||
for (const target of ['leaf1', 'leaf2', 'leaf3']) {
|
||||
const tid = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug=$1`, [target])).rows[0].id;
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'mentions')`,
|
||||
[hubId, tid],
|
||||
);
|
||||
}
|
||||
|
||||
const h = await engine.getHealth();
|
||||
// hub has outbound, no inbound → NOT orphan (under the fixed definition).
|
||||
// leaf1/2/3 have inbound from hub → NOT orphan.
|
||||
// So orphan_pages should be 0.
|
||||
expect(h.orphan_pages).toBe(0);
|
||||
});
|
||||
|
||||
test('a page with no links at all IS an orphan', async () => {
|
||||
await engine.putPage('loner', { type: 'note', title: 'Loner', compiled_truth: 'alone', frontmatter: {} });
|
||||
const h = await engine.getHealth();
|
||||
expect(h.orphan_pages).toBe(1);
|
||||
});
|
||||
|
||||
test('a page with inbound links only is NOT an orphan', async () => {
|
||||
await engine.putPage('sink', { type: 'note', title: 'Sink', compiled_truth: 'target', frontmatter: {} });
|
||||
await engine.putPage('source', { type: 'note', title: 'Source', compiled_truth: 'origin', frontmatter: {} });
|
||||
const sinkId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='sink'`)).rows[0].id;
|
||||
const srcId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='source'`)).rows[0].id;
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'mentions')`,
|
||||
[srcId, sinkId],
|
||||
);
|
||||
|
||||
const h = await engine.getHealth();
|
||||
// sink has 1 inbound (from source) → not orphan.
|
||||
// source has no inbound (but has outbound) → not orphan under new definition.
|
||||
expect(h.orphan_pages).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 11 — doctor renders brain_score breakdown', () => {
|
||||
test('doctor source contains brain_score breakdown rendering', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
||||
expect(source).toContain('brain_score');
|
||||
expect(source).toContain('embed_coverage_score');
|
||||
expect(source).toContain('link_density_score');
|
||||
expect(source).toContain('no_orphans_score');
|
||||
expect(source).toContain('no_dead_links_score');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 11 — BrainHealth type shape', () => {
|
||||
test('type includes dead_links + breakdown scores', async () => {
|
||||
const typesSource = await Bun.file(new URL('../src/core/types.ts', import.meta.url)).text();
|
||||
expect(typesSource).toContain('dead_links: number');
|
||||
expect(typesSource).toContain('embed_coverage_score: number');
|
||||
expect(typesSource).toContain('link_density_score: number');
|
||||
expect(typesSource).toContain('timeline_coverage_score: number');
|
||||
expect(typesSource).toContain('no_orphans_score: number');
|
||||
expect(typesSource).toContain('no_dead_links_score: number');
|
||||
// The stale "(0-10)" comment must be corrected to 0-100.
|
||||
expect(typesSource).toContain('0-100');
|
||||
});
|
||||
});
|
||||
+35
-3
@@ -36,9 +36,41 @@ describe('doctor command', () => {
|
||||
|
||||
test('runDoctor accepts null engine for filesystem-only mode', async () => {
|
||||
const { runDoctor } = await import('../src/commands/doctor.ts');
|
||||
// runDoctor should accept null engine — it runs filesystem checks only
|
||||
// We can't call it directly (it calls process.exit), but we verify the signature
|
||||
expect(runDoctor.length).toBe(2); // engine, args
|
||||
// runDoctor should accept null engine — it runs filesystem checks only.
|
||||
// Signature is (engine, args, dbSource?) — third param is optional and
|
||||
// used by --fast to distinguish "no config" from "user skipped DB check".
|
||||
// Function.length counts required params only (JS ignores ?-marked).
|
||||
expect(runDoctor.length).toBeGreaterThanOrEqual(2);
|
||||
expect(runDoctor.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
// Bug 7 — --fast should differentiate "no config anywhere" from "user
|
||||
// chose --fast with GBRAIN_DATABASE_URL / config-file URL present".
|
||||
test('getDbUrlSource reflects GBRAIN_DATABASE_URL env var', async () => {
|
||||
const { getDbUrlSource } = await import('../src/core/config.ts');
|
||||
const orig = process.env.GBRAIN_DATABASE_URL;
|
||||
const origAlt = process.env.DATABASE_URL;
|
||||
try {
|
||||
process.env.GBRAIN_DATABASE_URL = 'postgresql://test@localhost/x';
|
||||
expect(getDbUrlSource()).toBe('env:GBRAIN_DATABASE_URL');
|
||||
delete process.env.GBRAIN_DATABASE_URL;
|
||||
process.env.DATABASE_URL = 'postgresql://test@localhost/x';
|
||||
expect(getDbUrlSource()).toBe('env:DATABASE_URL');
|
||||
} finally {
|
||||
if (orig === undefined) delete process.env.GBRAIN_DATABASE_URL;
|
||||
else process.env.GBRAIN_DATABASE_URL = orig;
|
||||
if (origAlt === undefined) delete process.env.DATABASE_URL;
|
||||
else process.env.DATABASE_URL = origAlt;
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor --fast emits source-specific message when URL present', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
||||
// The source-aware message must reference the variable name so users
|
||||
// know where their URL is coming from.
|
||||
expect(source).toContain('Skipping DB checks (--fast mode, URL present from');
|
||||
// The null-source fallback must still mention both config + env paths.
|
||||
expect(source).toContain('GBRAIN_DATABASE_URL');
|
||||
});
|
||||
|
||||
// v0.12.2 reliability wave — doctor detects JSONB double-encode + truncated
|
||||
|
||||
@@ -121,13 +121,14 @@ describeE2E('E2E: v0.11.0 orchestrator against live Postgres', () => {
|
||||
expect(prefs.set_at).toBeTruthy();
|
||||
expect(prefs.set_in_version).toBeTruthy();
|
||||
|
||||
// Phase G: completed.jsonl has one entry for v0.11.0.
|
||||
// Bug 3 (v0.14.2) — orchestrator no longer writes completed.jsonl.
|
||||
// The runner (apply-migrations.ts) persists the result after the
|
||||
// orchestrator returns. A direct orchestrator call in E2E leaves the
|
||||
// ledger empty; the runner path is tested separately in
|
||||
// test/apply-migrations.test.ts + test/migration-resume.test.ts.
|
||||
const completed = loadCompletedMigrations();
|
||||
expect(completed.length).toBeGreaterThanOrEqual(1);
|
||||
const v0110Entries = completed.filter(e => e.version === '0.11.0');
|
||||
expect(v0110Entries.length).toBe(1);
|
||||
expect(['complete', 'partial']).toContain(v0110Entries[0].status!);
|
||||
expect(v0110Entries[0].mode).toBe('pain_triggered');
|
||||
expect(v0110Entries.length).toBe(0);
|
||||
|
||||
// Phase F is skipped per COMMON_OPTS — autopilot should NOT have been
|
||||
// installed on this host.
|
||||
@@ -142,15 +143,13 @@ describeE2E('E2E: v0.11.0 orchestrator against live Postgres', () => {
|
||||
const second = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
expect(['complete', 'partial']).toContain(second.status);
|
||||
|
||||
// completed.jsonl accumulates entries per run (each run appends one).
|
||||
// The runtime semantics for resume are governed by the diff rule in
|
||||
// apply-migrations; here we just assert the orchestrator itself doesn't
|
||||
// blow up or produce different results on a second run.
|
||||
const completed = loadCompletedMigrations();
|
||||
const v0110 = completed.filter(e => e.version === '0.11.0');
|
||||
expect(v0110.length).toBeGreaterThanOrEqual(2);
|
||||
// Preferences should be stable (same mode, unchanged content).
|
||||
// Bug 3 (v0.14.2) — orchestrator does not write completed.jsonl, so
|
||||
// repeated direct invocations don't accumulate ledger entries. Assert
|
||||
// the preferences state stays stable (the real idempotency signal for
|
||||
// this orchestrator is "running again doesn't corrupt preferences").
|
||||
expect(loadPreferences().minion_mode).toBe('pain_triggered');
|
||||
const completed = loadCompletedMigrations();
|
||||
expect(completed.filter(e => e.version === '0.11.0').length).toBe(0);
|
||||
}, 90_000);
|
||||
|
||||
test('host rewrite: builtin handlers auto-rewritten, non-builtins queued as JSONL TODOs', async () => {
|
||||
@@ -233,15 +232,16 @@ describeE2E('E2E: v0.11.0 orchestrator against live Postgres', () => {
|
||||
|
||||
// Orchestrator re-running on a partial → should succeed (schema apply
|
||||
// and smoke are idempotent; prefs are preserved from the partial
|
||||
// record; host-rewrite runs its safe-skip pass; completed appends a
|
||||
// new status:"complete" row).
|
||||
// record; host-rewrite runs its safe-skip pass). Per Bug 3 (v0.14.2),
|
||||
// the orchestrator itself doesn't append to completed.jsonl — the
|
||||
// runner does. The stopgap's partial entry stays unchanged here.
|
||||
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
expect(['complete', 'partial']).toContain(result.status);
|
||||
|
||||
const completed = loadCompletedMigrations();
|
||||
const v0110 = completed.filter(e => e.version === '0.11.0');
|
||||
// 1 partial (stopgap) + 1 post-orchestrator entry.
|
||||
expect(v0110.length).toBe(2);
|
||||
// Just the stopgap partial — orchestrator doesn't add its own entry.
|
||||
expect(v0110.length).toBe(1);
|
||||
expect(v0110[0].status).toBe('partial');
|
||||
expect(v0110[0].source).toBe('fix-v0.11.0.sh');
|
||||
}, 90_000);
|
||||
|
||||
@@ -201,3 +201,49 @@ describe('migrate: v9 (timeline_dedup_index) regression — must be fast on 1K d
|
||||
expect(helperIdx.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// resolvePoolSize — GBRAIN_POOL_SIZE env override
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Guards the Bug 2 fix: users on constrained poolers (Supabase port 6543)
|
||||
// must be able to cap the pool size via GBRAIN_POOL_SIZE. The default
|
||||
// (10) is unchanged when the env var is unset.
|
||||
|
||||
describe('resolvePoolSize — env var + explicit override', () => {
|
||||
const { resolvePoolSize } = require('../src/core/db.ts');
|
||||
const original = process.env.GBRAIN_POOL_SIZE;
|
||||
|
||||
afterAll(() => {
|
||||
if (original === undefined) delete process.env.GBRAIN_POOL_SIZE;
|
||||
else process.env.GBRAIN_POOL_SIZE = original;
|
||||
});
|
||||
|
||||
test('returns 10 default when unset and no explicit override', () => {
|
||||
delete process.env.GBRAIN_POOL_SIZE;
|
||||
expect(resolvePoolSize()).toBe(10);
|
||||
});
|
||||
|
||||
test('reads GBRAIN_POOL_SIZE as an integer', () => {
|
||||
process.env.GBRAIN_POOL_SIZE = '2';
|
||||
expect(resolvePoolSize()).toBe(2);
|
||||
process.env.GBRAIN_POOL_SIZE = '5';
|
||||
expect(resolvePoolSize()).toBe(5);
|
||||
});
|
||||
|
||||
test('ignores invalid GBRAIN_POOL_SIZE values', () => {
|
||||
process.env.GBRAIN_POOL_SIZE = 'not-a-number';
|
||||
expect(resolvePoolSize()).toBe(10);
|
||||
process.env.GBRAIN_POOL_SIZE = '0';
|
||||
expect(resolvePoolSize()).toBe(10);
|
||||
process.env.GBRAIN_POOL_SIZE = '-1';
|
||||
expect(resolvePoolSize()).toBe(10);
|
||||
});
|
||||
|
||||
test('explicit argument wins over env + default', () => {
|
||||
delete process.env.GBRAIN_POOL_SIZE;
|
||||
expect(resolvePoolSize(3)).toBe(3);
|
||||
process.env.GBRAIN_POOL_SIZE = '7';
|
||||
expect(resolvePoolSize(3)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Bug 3 regression — migration resume semantics.
|
||||
*
|
||||
* Covers:
|
||||
* - statusForVersion prefers 'complete' over 'partial' (never regresses).
|
||||
* - Three consecutive 'partial' entries flip a migration to 'wedged'.
|
||||
* - 'retry' marker resets the counter; next run treats it as fresh.
|
||||
* - appendCompletedMigration no-ops on double 'complete' (idempotency).
|
||||
*
|
||||
* Infrastructure: point HOME at a tmpdir so the ledger writes don't
|
||||
* stomp the real ~/.gbrain/migrations/completed.jsonl.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
let tmpHome: string;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-migration-resume-'));
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome) process.env.HOME = originalHome;
|
||||
else delete process.env.HOME;
|
||||
try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('Bug 3 — statusForVersion semantics', () => {
|
||||
test("complete wins over partial regardless of order", async () => {
|
||||
const { __testing } = await import('../src/commands/apply-migrations.ts');
|
||||
const idx = __testing.indexCompleted([
|
||||
{ version: '0.13.0', status: 'complete' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
] as any);
|
||||
expect(__testing.statusForVersion('0.13.0', idx)).toBe('complete');
|
||||
|
||||
const idx2 = __testing.indexCompleted([
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'complete' },
|
||||
] as any);
|
||||
expect(__testing.statusForVersion('0.13.0', idx2)).toBe('complete');
|
||||
});
|
||||
|
||||
test('two consecutive partials stay at partial', async () => {
|
||||
const { __testing } = await import('../src/commands/apply-migrations.ts');
|
||||
const idx = __testing.indexCompleted([
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
] as any);
|
||||
expect(__testing.statusForVersion('0.13.0', idx)).toBe('partial');
|
||||
});
|
||||
|
||||
test('three consecutive partials flip to wedged', async () => {
|
||||
const { __testing } = await import('../src/commands/apply-migrations.ts');
|
||||
const idx = __testing.indexCompleted([
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
] as any);
|
||||
expect(__testing.statusForVersion('0.13.0', idx)).toBe('wedged');
|
||||
});
|
||||
|
||||
test("retry marker resets the counter", async () => {
|
||||
const { __testing } = await import('../src/commands/apply-migrations.ts');
|
||||
const idx = __testing.indexCompleted([
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'retry' },
|
||||
] as any);
|
||||
// After 'retry', the version is pending (fresh start).
|
||||
expect(__testing.statusForVersion('0.13.0', idx)).toBe('pending');
|
||||
});
|
||||
|
||||
test('complete after wedge is still complete (terminal)', async () => {
|
||||
const { __testing } = await import('../src/commands/apply-migrations.ts');
|
||||
const idx = __testing.indexCompleted([
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'retry' },
|
||||
{ version: '0.13.0', status: 'complete' },
|
||||
] as any);
|
||||
expect(__testing.statusForVersion('0.13.0', idx)).toBe('complete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 3 — appendCompletedMigration idempotency', () => {
|
||||
test('writing complete when last entry is already complete is a no-op', async () => {
|
||||
const { appendCompletedMigration, loadCompletedMigrations } = await import('../src/core/preferences.ts');
|
||||
appendCompletedMigration({ version: '9.9.9', status: 'complete' });
|
||||
const first = loadCompletedMigrations().filter(e => e.version === '9.9.9');
|
||||
expect(first.length).toBe(1);
|
||||
|
||||
appendCompletedMigration({ version: '9.9.9', status: 'complete' });
|
||||
const second = loadCompletedMigrations().filter(e => e.version === '9.9.9');
|
||||
expect(second.length).toBe(1);
|
||||
});
|
||||
|
||||
test('partial always appends (needed for attempt-cap counter)', async () => {
|
||||
const { appendCompletedMigration, loadCompletedMigrations } = await import('../src/core/preferences.ts');
|
||||
appendCompletedMigration({ version: '9.9.9', status: 'partial' });
|
||||
appendCompletedMigration({ version: '9.9.9', status: 'partial' });
|
||||
const entries = loadCompletedMigrations().filter(e => e.version === '9.9.9');
|
||||
expect(entries.length).toBe(2);
|
||||
});
|
||||
|
||||
test("'retry' status is accepted", async () => {
|
||||
const { appendCompletedMigration, loadCompletedMigrations } = await import('../src/core/preferences.ts');
|
||||
appendCompletedMigration({ version: '9.9.9', status: 'retry' } as any);
|
||||
const entries = loadCompletedMigrations().filter(e => e.version === '9.9.9');
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0].status).toBe('retry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 3 — orchestrator no longer writes the ledger directly', () => {
|
||||
test('v0_13_0 does not import appendCompletedMigration', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/migrations/v0_13_0.ts', import.meta.url)).text();
|
||||
expect(source).not.toContain('import { appendCompletedMigration }');
|
||||
});
|
||||
test('v0_13_1 does not import appendCompletedMigration', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/migrations/v0_13_1.ts', import.meta.url)).text();
|
||||
expect(source).not.toContain('import { appendCompletedMigration }');
|
||||
});
|
||||
test('v0_12_0 does not import appendCompletedMigration', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/migrations/v0_12_0.ts', import.meta.url)).text();
|
||||
expect(source).not.toContain('import { appendCompletedMigration }');
|
||||
});
|
||||
test('v0_12_2 does not import appendCompletedMigration', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/migrations/v0_12_2.ts', import.meta.url)).text();
|
||||
expect(source).not.toContain('import { appendCompletedMigration }');
|
||||
});
|
||||
test('v0_11_0 does not import appendCompletedMigration', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/migrations/v0_11_0.ts', import.meta.url)).text();
|
||||
// Import statement should not reference appendCompletedMigration; the
|
||||
// old call site is replaced with a comment.
|
||||
expect(source).not.toMatch(/import .*appendCompletedMigration.*from/);
|
||||
});
|
||||
|
||||
test('apply-migrations.ts runner writes the ledger', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/apply-migrations.ts', import.meta.url)).text();
|
||||
expect(source).toContain("import { loadCompletedMigrations, appendCompletedMigration");
|
||||
expect(source).toContain("appendCompletedMigration({");
|
||||
expect(source).toContain("'retry'");
|
||||
expect(source).toContain('--force-retry');
|
||||
expect(source).toContain('MAX_CONSECUTIVE_PARTIALS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 3 — buildPlan surfaces wedged migrations', () => {
|
||||
test('wedged bucket exists in the plan', async () => {
|
||||
const { __testing } = await import('../src/commands/apply-migrations.ts');
|
||||
const idx = __testing.indexCompleted([
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
{ version: '0.13.0', status: 'partial' },
|
||||
] as any);
|
||||
const plan = __testing.buildPlan(idx, '0.13.0', '0.13.0'); // filter to just this version
|
||||
expect(plan.wedged.length).toBe(1);
|
||||
expect(plan.wedged[0].version).toBe('0.13.0');
|
||||
expect(plan.pending.length).toBe(0);
|
||||
expect(plan.partial.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Bug 5 + Bug 8 — v0_14_0 orchestrator regression.
|
||||
*
|
||||
* The migration ships:
|
||||
* - Phase A (schema): ALTER minion_jobs.max_stalled SET DEFAULT 3
|
||||
* - Phase B (host-work): append skill-ping entry to
|
||||
* ~/.gbrain/migrations/pending-host-work.jsonl
|
||||
*
|
||||
* Both phases are idempotent — re-running the migration is a no-op after
|
||||
* the first successful pass.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
let tmpHome: string;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-v0_14_0-'));
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome) process.env.HOME = originalHome;
|
||||
else delete process.env.HOME;
|
||||
try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('Bug 5 + Bug 8 — v0_14_0 module shape', () => {
|
||||
test('v0_14_0 is registered in migrations/index.ts', async () => {
|
||||
const { migrations } = await import('../src/commands/migrations/index.ts');
|
||||
const m = migrations.find(x => x.version === '0.14.0');
|
||||
expect(m).toBeDefined();
|
||||
expect(m!.featurePitch.headline).toBeTruthy();
|
||||
});
|
||||
|
||||
test('v0_14_0 does NOT write the ledger directly', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/migrations/v0_14_0.ts', import.meta.url)).text();
|
||||
expect(source).not.toContain('appendCompletedMigration');
|
||||
});
|
||||
|
||||
test('orchestrator returns complete when phase A is skipped (no config)', async () => {
|
||||
const { v0_14_0 } = await import('../src/commands/migrations/v0_14_0.ts');
|
||||
// No loadConfig() backing → phaseASchema reports skipped (no brain).
|
||||
// Phase B still emits the host-work ping.
|
||||
const result = await v0_14_0.orchestrator({
|
||||
yes: true,
|
||||
dryRun: false,
|
||||
noAutopilotInstall: true,
|
||||
});
|
||||
expect(['complete', 'partial']).toContain(result.status);
|
||||
expect(result.version).toBe('0.14.0');
|
||||
const hostWork = result.phases.find(p => p.name === 'host-work');
|
||||
expect(hostWork).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 5 — Phase B host-work entry dedup', () => {
|
||||
test('first run writes the entry, second run is a skip', async () => {
|
||||
const { v0_14_0 } = await import('../src/commands/migrations/v0_14_0.ts');
|
||||
|
||||
const first = await v0_14_0.orchestrator({ yes: true, dryRun: false, noAutopilotInstall: true });
|
||||
const hostPath = join(tmpHome, '.gbrain', 'migrations', 'pending-host-work.jsonl');
|
||||
expect(existsSync(hostPath)).toBe(true);
|
||||
|
||||
const beforeLines = readFileSync(hostPath, 'utf-8').split('\n').filter(l => l.trim()).length;
|
||||
expect(beforeLines).toBe(1);
|
||||
|
||||
// Second run — Phase B should skip, not duplicate.
|
||||
await v0_14_0.orchestrator({ yes: true, dryRun: false, noAutopilotInstall: true });
|
||||
const afterLines = readFileSync(hostPath, 'utf-8').split('\n').filter(l => l.trim()).length;
|
||||
expect(afterLines).toBe(1);
|
||||
|
||||
const entry = JSON.parse(readFileSync(hostPath, 'utf-8').split('\n')[0]);
|
||||
expect(entry.migration).toBe('0.14.0');
|
||||
expect(entry.skill).toBe('skills/migrations/v0.14.0.md');
|
||||
});
|
||||
|
||||
test('dry-run writes nothing', async () => {
|
||||
const { v0_14_0 } = await import('../src/commands/migrations/v0_14_0.ts');
|
||||
await v0_14_0.orchestrator({ yes: true, dryRun: true, noAutopilotInstall: true });
|
||||
const hostPath = join(tmpHome, '.gbrain', 'migrations', 'pending-host-work.jsonl');
|
||||
expect(existsSync(hostPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 8 — max_stalled default bumped in schema files', () => {
|
||||
test('schema-embedded.ts has max_stalled DEFAULT 3', async () => {
|
||||
const source = await Bun.file(new URL('../src/core/schema-embedded.ts', import.meta.url)).text();
|
||||
expect(source).toContain('max_stalled INTEGER NOT NULL DEFAULT 3');
|
||||
});
|
||||
test('pglite-schema.ts has max_stalled DEFAULT 3', async () => {
|
||||
const source = await Bun.file(new URL('../src/core/pglite-schema.ts', import.meta.url)).text();
|
||||
expect(source).toContain('max_stalled INTEGER NOT NULL DEFAULT 3');
|
||||
});
|
||||
test('schema.sql has max_stalled DEFAULT 3', async () => {
|
||||
const source = await Bun.file(new URL('../src/schema.sql', import.meta.url)).text();
|
||||
expect(source).toContain('max_stalled INTEGER NOT NULL DEFAULT 3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Bug 9 regression — sync silently drops files with broken YAML.
|
||||
*
|
||||
* Before the fix, sync.ts caught per-file parse errors, printed a warning,
|
||||
* and still advanced sync.last_commit. The failed file was never retried
|
||||
* because it was behind the bookmark. Silent data loss.
|
||||
*
|
||||
* After the fix:
|
||||
* - failures append to ~/.gbrain/sync-failures.jsonl (with dedup)
|
||||
* - incremental + full-sync + import git-continuity paths gate the
|
||||
* sync.last_commit advance on "no failures"
|
||||
* - `gbrain sync --skip-failed` acknowledges the current set
|
||||
* - `gbrain doctor` surfaces unacknowledged failures
|
||||
*
|
||||
* This suite exercises the helper + the dedup behavior. The full CLI
|
||||
* round-trip is covered by E2E tests.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
// Point HOME at a tmpdir so we don't stomp the real ~/.gbrain/sync-failures.jsonl
|
||||
let tmpHome: string;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-sync-failures-'));
|
||||
process.env.HOME = tmpHome;
|
||||
// Belt-and-suspenders: explicitly clear the jsonl at the resolved path.
|
||||
const { syncFailuresPath } = await import('../src/core/sync.ts');
|
||||
try { rmSync(syncFailuresPath(), { force: true }); } catch { /* none */ }
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome) process.env.HOME = originalHome;
|
||||
else delete process.env.HOME;
|
||||
try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('Bug 9 — sync-failures JSONL helpers', () => {
|
||||
test('recordSyncFailures appends one line per failure with dedup', async () => {
|
||||
const { recordSyncFailures, loadSyncFailures, syncFailuresPath } = await import('../src/core/sync.ts');
|
||||
|
||||
recordSyncFailures([
|
||||
{ path: 'people/alice.md', error: 'YAML: unexpected colon in title' },
|
||||
{ path: 'notes/broken.md', error: 'YAML: duplicated key' },
|
||||
], 'abc123def456');
|
||||
|
||||
expect(existsSync(syncFailuresPath())).toBe(true);
|
||||
const entries = loadSyncFailures();
|
||||
expect(entries.length).toBe(2);
|
||||
expect(entries[0].path).toBe('people/alice.md');
|
||||
expect(entries[0].commit).toBe('abc123def456');
|
||||
expect(entries[0].acknowledged).toBeUndefined();
|
||||
|
||||
// Same failure on same commit should NOT re-append.
|
||||
recordSyncFailures([
|
||||
{ path: 'people/alice.md', error: 'YAML: unexpected colon in title' },
|
||||
], 'abc123def456');
|
||||
expect(loadSyncFailures().length).toBe(2);
|
||||
|
||||
// Different commit → new entry.
|
||||
recordSyncFailures([
|
||||
{ path: 'people/alice.md', error: 'YAML: unexpected colon in title' },
|
||||
], 'zzz999');
|
||||
expect(loadSyncFailures().length).toBe(3);
|
||||
});
|
||||
|
||||
test('acknowledgeSyncFailures marks unacked entries, leaves acked alone', async () => {
|
||||
const { recordSyncFailures, acknowledgeSyncFailures, loadSyncFailures } = await import('../src/core/sync.ts');
|
||||
|
||||
recordSyncFailures([
|
||||
{ path: 'a.md', error: 'err1' },
|
||||
{ path: 'b.md', error: 'err2' },
|
||||
], 'commit1');
|
||||
|
||||
const n = acknowledgeSyncFailures();
|
||||
expect(n).toBe(2);
|
||||
const after = loadSyncFailures();
|
||||
expect(after.every(e => e.acknowledged === true)).toBe(true);
|
||||
expect(after.every(e => typeof e.acknowledged_at === 'string')).toBe(true);
|
||||
|
||||
// Second ack: nothing new to mark.
|
||||
expect(acknowledgeSyncFailures()).toBe(0);
|
||||
|
||||
// Adding a fresh failure then ack: only the new one flips.
|
||||
recordSyncFailures([{ path: 'c.md', error: 'err3' }], 'commit2');
|
||||
expect(acknowledgeSyncFailures()).toBe(1);
|
||||
expect(loadSyncFailures().length).toBe(3);
|
||||
expect(loadSyncFailures().every(e => e.acknowledged === true)).toBe(true);
|
||||
});
|
||||
|
||||
test('unacknowledgedSyncFailures filters correctly', async () => {
|
||||
const { recordSyncFailures, acknowledgeSyncFailures, unacknowledgedSyncFailures } = await import('../src/core/sync.ts');
|
||||
|
||||
recordSyncFailures([{ path: 'a.md', error: 'err1' }], 'c1');
|
||||
acknowledgeSyncFailures();
|
||||
recordSyncFailures([{ path: 'b.md', error: 'err2' }], 'c2');
|
||||
|
||||
const unacked = unacknowledgedSyncFailures();
|
||||
expect(unacked.length).toBe(1);
|
||||
expect(unacked[0].path).toBe('b.md');
|
||||
});
|
||||
|
||||
test('loadSyncFailures returns [] when file is missing', async () => {
|
||||
const { loadSyncFailures } = await import('../src/core/sync.ts');
|
||||
expect(loadSyncFailures()).toEqual([]);
|
||||
});
|
||||
|
||||
test('loadSyncFailures tolerates malformed lines', async () => {
|
||||
const { loadSyncFailures, syncFailuresPath, recordSyncFailures } = await import('../src/core/sync.ts');
|
||||
// Seed one valid entry.
|
||||
recordSyncFailures([{ path: 'a.md', error: 'err1' }], 'c1');
|
||||
// Append garbage.
|
||||
writeFileSync(syncFailuresPath(), readFileSync(syncFailuresPath(), 'utf-8') + 'NOT-JSON\n', { flag: 'w' });
|
||||
const out = loadSyncFailures();
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].path).toBe('a.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 9 — doctor surfaces sync failures', () => {
|
||||
test('doctor source contains sync_failures check', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
||||
expect(source).toContain('sync_failures');
|
||||
expect(source).toContain('unacknowledgedSyncFailures');
|
||||
expect(source).toContain("'gbrain sync --skip-failed'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 9 — sync.ts CLI flag wiring', () => {
|
||||
test('runSync parses --skip-failed and --retry-failed flags', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/sync.ts', import.meta.url)).text();
|
||||
expect(source).toContain("args.includes('--skip-failed')");
|
||||
expect(source).toContain("args.includes('--retry-failed')");
|
||||
expect(source).toContain('skipFailed');
|
||||
expect(source).toContain('retryFailed');
|
||||
});
|
||||
|
||||
test('performSync gates sync.last_commit on failedFiles.length', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/sync.ts', import.meta.url)).text();
|
||||
// The gate exists and references the failure set.
|
||||
expect(source).toContain('failedFiles.length > 0');
|
||||
expect(source).toContain('blocked_by_failures');
|
||||
});
|
||||
|
||||
test('performFullSync gates on result.failures from runImport', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/sync.ts', import.meta.url)).text();
|
||||
expect(source).toContain('result.failures.length > 0');
|
||||
});
|
||||
|
||||
test('runImport returns RunImportResult with failures list', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/import.ts', import.meta.url)).text();
|
||||
expect(source).toContain('RunImportResult');
|
||||
expect(source).toContain('failures: Array<{ path: string; error: string }>');
|
||||
expect(source).toContain('recordSyncFailures');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Bug 6/10 regression — legacy traverseGraph jsonb_agg duplicate edges.
|
||||
*
|
||||
* The links table deliberately allows multiple rows with the same
|
||||
* (from_page_id, to_page_id, link_type) when origin_page_id or link_source
|
||||
* differ. That's how markdown-body edges and frontmatter edges coexist for
|
||||
* the same pair. The duplicates should NOT surface in the legacy
|
||||
* traverseGraph() aggregated output — dedup is presentation-only in the
|
||||
* jsonb_agg step. This test seeds two such rows and asserts the aggregation
|
||||
* collapses them. It also asserts the underlying `links` table still has
|
||||
* both rows (provenance preserved).
|
||||
*
|
||||
* Runs against PGLite (unit, always). The postgres-engine path uses the
|
||||
* same SQL; an E2E test covers Postgres.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const t of ['links', 'pages']) {
|
||||
await (engine as any).db.exec(`DELETE FROM ${t}`);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Bug 6/10 — traverseGraph jsonb_agg DISTINCT', () => {
|
||||
test('collapses two provenance rows for the same (from,to,type) edge', async () => {
|
||||
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: '', frontmatter: {} });
|
||||
await engine.putPage('companies/acme', { type: 'company', title: 'Acme', compiled_truth: '', frontmatter: {} });
|
||||
|
||||
const alice = await (engine as any).db.query(`SELECT id FROM pages WHERE slug = 'people/alice'`);
|
||||
const acme = await (engine as any).db.query(`SELECT id FROM pages WHERE slug = 'companies/acme'`);
|
||||
const fromId = alice.rows[0].id as string;
|
||||
const toId = acme.rows[0].id as string;
|
||||
|
||||
// Two rows, same (from, to, type), different provenance:
|
||||
// row 1 from markdown body (origin_page_id = from page itself, link_source 'markdown')
|
||||
// row 2 from frontmatter (origin_page_id = null, link_source 'frontmatter')
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type, origin_page_id, link_source)
|
||||
VALUES ($1, $2, 'works_at', $1, 'markdown')`,
|
||||
[fromId, toId],
|
||||
);
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type, origin_page_id, link_source)
|
||||
VALUES ($1, $2, 'works_at', NULL, 'frontmatter')`,
|
||||
[fromId, toId],
|
||||
);
|
||||
|
||||
// Provenance preserved at the table level.
|
||||
const rawCount = await (engine as any).db.query(
|
||||
`SELECT count(*)::int as n FROM links WHERE from_page_id = $1 AND to_page_id = $2 AND link_type = 'works_at'`,
|
||||
[fromId, toId],
|
||||
);
|
||||
expect(rawCount.rows[0].n).toBe(2);
|
||||
|
||||
// Aggregated output dedups.
|
||||
const nodes = await engine.traverseGraph('people/alice', 2);
|
||||
const alicedNode = nodes.find(n => n.slug === 'people/alice');
|
||||
expect(alicedNode).toBeDefined();
|
||||
|
||||
const worksAtEdges = alicedNode!.links.filter(
|
||||
l => l.to_slug === 'companies/acme' && l.link_type === 'works_at',
|
||||
);
|
||||
expect(worksAtEdges.length).toBe(1);
|
||||
});
|
||||
|
||||
test('keeps genuinely distinct link types even between same nodes', async () => {
|
||||
await engine.putPage('people/bob', { type: 'person', title: 'Bob', compiled_truth: '', frontmatter: {} });
|
||||
await engine.putPage('companies/widget', { type: 'company', title: 'Widget', compiled_truth: '', frontmatter: {} });
|
||||
|
||||
const bob = await (engine as any).db.query(`SELECT id FROM pages WHERE slug = 'people/bob'`);
|
||||
const widget = await (engine as any).db.query(`SELECT id FROM pages WHERE slug = 'companies/widget'`);
|
||||
const fromId = bob.rows[0].id as string;
|
||||
const toId = widget.rows[0].id as string;
|
||||
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type, origin_page_id, link_source)
|
||||
VALUES ($1, $2, 'works_at', $1, 'markdown')`,
|
||||
[fromId, toId],
|
||||
);
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type, origin_page_id, link_source)
|
||||
VALUES ($1, $2, 'founded', $1, 'markdown')`,
|
||||
[fromId, toId],
|
||||
);
|
||||
|
||||
const nodes = await engine.traverseGraph('people/bob', 2);
|
||||
const bobNode = nodes.find(n => n.slug === 'people/bob');
|
||||
const edges = bobNode!.links.filter(l => l.to_slug === 'companies/widget');
|
||||
const types = edges.map(l => l.link_type).sort();
|
||||
expect(types).toEqual(['founded', 'works_at']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user