Files
gbrain/test/core/cycle.test.ts
T
e734937254 fix: pass sourceId in cycle sync phase to prevent full reimport (#475)
* fix: pass sourceId in cycle sync phase to prevent full reimport

cycle.ts calls performSync without sourceId, so it always reads
the global config.sync.last_commit key instead of the per-source
sources.last_commit. When the global anchor gets garbage-collected
(after a force push or rebase), sync falls back to a full reimport
of all files — on a large brain this takes 30+ minutes and blocks
the autopilot cycle.

The fix resolves the source id from the brain directory by querying
the sources table. When a matching source exists, sync reads the
per-source anchor which is updated on every successful sync and
stays in sync with the repo history. Falls back gracefully to the
global config path for pre-v0.18 brains without a sources table.

* v0.22.5: tests + version bump for sync-cycle-source-id fix

Adds 6 regression tests in test/core/cycle.test.ts pinning the new
resolveSourceForDir() helper added to src/core/cycle.ts in this PR:

1. Seeded sources row → performSync receives matching sourceId
2. No matching row → sourceId=undefined (falls through to global key)
3. Different brainDir than registered source → undefined (no cross-match)
4. sources table missing (very old brain) → catch returns undefined,
   sync still runs. Uses a fresh PGLiteEngine because initSchema() only
   re-runs PENDING migrations; DROP TABLE on the shared engine would
   leave it permanently degraded for every later test in the file.
   (Codex review caught this landmine.)
5. Multiple rows with same local_path → resolver returns one matching
   id (non-deterministic; SQL has no ORDER BY). Documents the contract
   for the v0.23 UNIQUE-constraint follow-up.
6. Empty-string id row → resolver propagates "" (defensive case Codex
   flagged: schema PK prevents NULL but '' can be inserted).

Extends the performSync mock at line 51-65 to also capture sourceId.

Bumps:
- VERSION: 0.22.4 → 0.22.5
- package.json: 0.22.4 → 0.22.5
- CHANGELOG.md: new [0.22.5] entry following v0.22.4 voice (release
  summary + numbers table + behavior matrix + To-take-advantage block
  + itemized changes + for-contributors)
- CLAUDE.md: annotates src/core/cycle.ts entry with v0.22.5 (#475) note
- llms-full.txt: regenerated via bun run build:llms

Test results:
- Unit: 28 pass / 0 fail in test/core/cycle.test.ts (22 prior + 6 new)
- Full unit suite: pass (exit 0)
- E2E: 236 pass / 0 fail across 26 files

Plan + codex outside-voice review at:
~/.claude/plans/whimsical-bubbling-goose.md

Follow-up TODOs filed for v0.23:
- Normalize brainDir + sources.local_path before SQL compare
- Add UNIQUE index on sources.local_path
- Narrow resolveSourceForDir's catch to PG 42P01 (undefined_table)
- Add doctor check for config.sync.last_commit / sources divergence

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: typecheck error in cycle.test.ts test 5 (sourceId regression)

CI typecheck failed because `toContain()` on `string[]` rejects the
`string | undefined` produced by `syncCalls.at(-1)?.sourceId`'s optional
chain. Tests 1, 4, and 6 use `toBe()` which accepts `string | undefined`
through its overload, but `toContain()` is stricter.

Fix: pull the value into a typed variable, assert it's defined, then
check membership. Makes the contract explicit ("resolver returned a
defined sourceId, and it was one of the matching ids") instead of
relying on a silent undefined → no-match-in-array assertion.

Locally:
- bun run typecheck: clean
- bun test test/core/cycle.test.ts: 28 pass / 0 fail (75 expect calls)
- All CI gate scripts: OK (jsonb, progress-to-stdout, wasm-embedded)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: add --timeout=60000 to E2E runner to prevent setupDB flake

PR #475's Tier 1 (Mechanical) CI job hit a 5000.09ms beforeAll hook
timeout in `E2E: Tags > (unnamed)`. Cause: scripts/run-e2e.sh invokes
`bun test "$f"` without a --timeout flag, falling back to bun's 5s
default. setupDB() does TRUNCATE CASCADE on ~30 tables, and on a CI
runner under load that can exceed 5s.

Match what the unit suite uses (--timeout=60000 in package.json's
"test" script). Same 1m ceiling, no behavior change for healthy runs;
just removes the artificial 5s floor on hooks.

Verified locally: bun test --timeout=60000 test/e2e/mechanical.test.ts
runs 78 pass / 0 fail in 27.99s against a fresh pgvector pg16 docker
container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:56:07 -07:00

543 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for src/core/cycle.ts — runCycle primitive.
*
* Tests use mock.module to replace each phase's library function with
* deterministic stubs. Zero fixtures, zero DB, zero network. Covers
* the dryRun × phases × lock_held × engine-null matrix.
*
* The lock primitives are tested against an in-memory PGLite engine
* so they exercise real SQL paths.
*/
import { describe, test, expect, mock, beforeEach, beforeAll, afterAll, afterEach } from 'bun:test';
import { existsSync, unlinkSync } from 'fs';
// ─── Mocks ──────────────────────────────────────────────────────────
// Track what each phase was called with so tests can assert.
let lintCalls: Array<{ target: string; fix: boolean; dryRun: boolean | undefined }> = [];
let backlinksCalls: Array<{ action: string; dir: string; dryRun: boolean | undefined }> = [];
let syncCalls: Array<{ dryRun: boolean | undefined; noPull: boolean | undefined; noExtract: boolean | undefined; sourceId: string | undefined }> = [];
let extractCalls: Array<{ mode: string; dir: string; slugs: string[] | undefined }> = [];
let embedCalls: Array<{ stale: boolean | undefined; dryRun: boolean | undefined }> = [];
let orphansCalls: number = 0;
// Mock lint
mock.module('../../src/commands/lint.ts', () => ({
runLintCore: async (opts: any) => {
lintCalls.push({ target: opts.target, fix: opts.fix, dryRun: opts.dryRun });
return { total_issues: 2, total_fixed: opts.dryRun ? 0 : 2, pages_scanned: 5 };
},
}));
// Mock backlinks
mock.module('../../src/commands/backlinks.ts', () => ({
runBacklinksCore: async (opts: any) => {
backlinksCalls.push({ action: opts.action, dir: opts.dir, dryRun: opts.dryRun });
return { action: opts.action, gaps_found: 3, fixed: opts.dryRun ? 0 : 3, pages_affected: 2, dryRun: !!opts.dryRun };
},
// keep other exports present so import doesn't error
extractEntityRefs: () => [],
extractPageTitle: () => '',
hasBacklink: () => false,
buildBacklinkEntry: () => '',
findBacklinkGaps: () => [],
fixBacklinkGaps: () => 0,
runBacklinks: async () => {},
}));
// Mock sync
mock.module('../../src/commands/sync.ts', () => ({
performSync: async (_engine: any, opts: any) => {
syncCalls.push({ dryRun: opts.dryRun, noPull: opts.noPull, noExtract: opts.noExtract, sourceId: opts.sourceId });
return {
status: opts.dryRun ? 'dry_run' : 'synced',
fromCommit: 'abcd',
toCommit: 'efgh',
added: opts.dryRun ? 0 : 4,
modified: opts.dryRun ? 0 : 2,
deleted: 0,
renamed: 0,
chunksCreated: opts.dryRun ? 0 : 10,
embedded: 0,
pagesAffected: opts.dryRun ? [] : ['a', 'b'],
};
},
runSync: async () => {},
buildSyncManifest: () => ({ added: [], modified: [], deleted: [], renamed: [] }),
isSyncable: () => true,
pathToSlug: (s: string) => s,
}));
// Mock extract
mock.module('../../src/commands/extract.ts', () => ({
runExtractCore: async (_engine: any, opts: any) => {
extractCalls.push({ mode: opts.mode, dir: opts.dir, slugs: opts.slugs });
return { links_created: 7, timeline_entries_created: 3, pages_processed: opts.slugs?.length ?? 5 };
},
walkMarkdownFiles: () => [],
extractMarkdownLinks: () => [],
resolveSlug: () => null,
}));
// Mock embed
mock.module('../../src/commands/embed.ts', () => ({
runEmbedCore: async (_engine: any, opts: any) => {
embedCalls.push({ stale: opts.stale, dryRun: opts.dryRun });
return {
embedded: opts.dryRun ? 0 : 8,
skipped: 2,
would_embed: opts.dryRun ? 8 : 0,
total_chunks: 10,
pages_processed: 3,
dryRun: !!opts.dryRun,
};
},
runEmbed: async () => {},
}));
// Mock orphans
mock.module('../../src/commands/orphans.ts', () => ({
findOrphans: async () => {
orphansCalls++;
return {
orphans: [],
total_orphans: 1,
total_linkable: 20,
total_pages: 20,
excluded: 0,
};
},
queryOrphanPages: async () => [],
shouldExclude: () => false,
deriveDomain: () => 'root',
formatOrphansText: () => '',
}));
// Import after mocks.
const { runCycle, ALL_PHASES } = await import('../../src/core/cycle.ts');
const { PGLiteEngine } = await import('../../src/core/pglite-engine.ts');
// Shared PGLite engine per describe block. Each block does its own
// beforeAll/afterAll (below). `truncateCycleLocks` clears the cycle
// lock row between tests so state doesn't leak across assertions.
async function truncateCycleLocks(engine: InstanceType<typeof PGLiteEngine>) {
await (sharedEngine as any).db.query('DELETE FROM gbrain_cycle_locks');
}
// One shared PGLite engine for the whole file. Creating a fresh engine
// per describe (15 migrations each) was causing the parallel test suite
// to hit beforeAll timeouts. truncateCycleLocks between tests keeps
// state clean.
let sharedEngine: InstanceType<typeof PGLiteEngine>;
beforeAll(async () => {
sharedEngine = new PGLiteEngine();
await sharedEngine.connect({});
await sharedEngine.initSchema();
}, 60_000);
afterAll(async () => {
await sharedEngine.disconnect();
}, 60_000);
beforeEach(() => {
lintCalls = [];
backlinksCalls = [];
syncCalls = [];
extractCalls = [];
embedCalls = [];
orphansCalls = 0;
});
// ─── dryRun propagation (regression guards) ────────────────────────
describe('runCycle — dryRun propagates to every phase', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('dryRun:true reaches lint, backlinks, sync, embed', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: true });
expect(lintCalls.at(-1)?.dryRun).toBe(true);
expect(backlinksCalls.at(-1)?.dryRun).toBe(true);
expect(syncCalls.at(-1)?.dryRun).toBe(true);
expect(embedCalls.at(-1)?.dryRun).toBe(true);
});
test('dryRun:false writes in every phase', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: false });
expect(lintCalls.at(-1)?.dryRun).toBe(false);
expect(backlinksCalls.at(-1)?.dryRun).toBe(false);
expect(syncCalls.at(-1)?.dryRun).toBe(false);
expect(embedCalls.at(-1)?.dryRun).toBe(false);
});
test('dryRun skips extract phase (no dry-run support)', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: true });
expect(extractCalls.length).toBe(0);
const extractPhase = report.phases.find(p => p.phase === 'extract');
expect(extractPhase?.status).toBe('skipped');
expect(extractPhase?.details.reason).toBe('no_dry_run_support');
});
});
// ─── Phase selection ──────────────────────────────────────────────
describe('runCycle — phase selection', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('default: all 6 phases run in order', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report.phases.map(p => p.phase)).toEqual(ALL_PHASES);
});
test('--phase lint only runs lint', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['lint'] });
expect(report.phases.map(p => p.phase)).toEqual(['lint']);
expect(lintCalls.length).toBe(1);
expect(backlinksCalls.length).toBe(0);
expect(syncCalls.length).toBe(0);
});
test('--phase orphans only runs orphans', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['orphans'] });
expect(orphansCalls).toBe(1);
expect(syncCalls.length).toBe(0);
});
});
// ─── Lock-skip for non-DB-write phase selections ──────────────────
describe('runCycle — cycle lock acquire/release semantics', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('phases: [orphans] (read-only) skips the lock entirely', async () => {
// We can tell the lock wasn't acquired because the lock table is
// never written to. Seeding a stale holder and verifying it survives
// the run would also work, but a simpler assertion: no rows ever
// existed for a read-only-only selection.
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['orphans'] });
const { rows } = await (sharedEngine as any).db.query('SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks');
expect(rows[0].n).toBe(0);
});
test('phases including lint DOES acquire + release (table empty after run)', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['lint'] });
// Lock is released in finally, so no rows survive the run.
const { rows } = await (sharedEngine as any).db.query('SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks');
expect(rows[0].n).toBe(0);
});
test('phases including sync DOES acquire + release the lock', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['sync'] });
const { rows } = await (sharedEngine as any).db.query('SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks');
expect(rows[0].n).toBe(0);
});
});
// ─── Lock held by another live holder ──────────────────────────────
describe('runCycle — cycle_already_running skip', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('returns status=skipped when lock is held by live pid in the future', async () => {
// Seed a lock row that looks live (far-future TTL, different PID).
await (sharedEngine as any).db.query(
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`
);
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report.status).toBe('skipped');
expect(report.reason).toBe('cycle_already_running');
expect(report.phases.length).toBe(0);
// None of the phase runners were called.
expect(lintCalls.length).toBe(0);
expect(syncCalls.length).toBe(0);
});
test('TTL-expired lock is auto-claimed (crashed holder)', async () => {
// Seed a lock row that looks stale (TTL already past).
await (sharedEngine as any).db.query(
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
VALUES ('gbrain-cycle', 99999, 'crashed-host', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour')`
);
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report.status).not.toBe('skipped');
expect(syncCalls.length).toBe(1); // cycle ran
});
});
// ─── Engine null path ─────────────────────────────────────────────
describe('runCycle — engine = null (filesystem-only mode)', () => {
const lockFile = require('path').join(require('os').homedir(), '.gbrain', 'cycle.lock');
afterEach(() => {
if (existsSync(lockFile)) { try { unlinkSync(lockFile); } catch { /* */ } }
});
test('filesystem phases still run when engine is null', async () => {
const report = await runCycle(null, { brainDir: '/tmp/brain' });
// Lint and backlinks ran.
expect(lintCalls.length).toBe(1);
expect(backlinksCalls.length).toBe(1);
// DB phases skipped with reason:no_database.
const syncPhase = report.phases.find(p => p.phase === 'sync');
expect(syncPhase?.status).toBe('skipped');
expect(syncPhase?.details.reason).toBe('no_database');
const embedPhase = report.phases.find(p => p.phase === 'embed');
expect(embedPhase?.status).toBe('skipped');
// syncCalls + embedCalls are empty because DB-required phases skipped.
expect(syncCalls.length).toBe(0);
expect(embedCalls.length).toBe(0);
});
test('file lock blocks concurrent engine=null cycles', async () => {
// Seed a lock file pointing at PID 1 (init/launchd — always alive on
// unix, and never equals our test PID). Fresh mtime means "live holder".
// With engine=null + the default phases selection, lint + backlinks
// trigger NEEDS_LOCK_PHASES → acquireFileLock sees the live holder and
// returns null → runCycle returns skipped/cycle_already_running.
const { writeFileSync, mkdirSync } = require('fs');
const path = require('path');
mkdirSync(path.dirname(lockFile), { recursive: true });
writeFileSync(lockFile, `1\n${new Date().toISOString()}\n`);
const report = await runCycle(null, { brainDir: '/tmp/brain' });
expect(report.status).toBe('skipped');
expect(report.reason).toBe('cycle_already_running');
// None of the filesystem phases ran because the lock blocked entry.
expect(lintCalls.length).toBe(0);
expect(backlinksCalls.length).toBe(0);
});
});
// ─── Status derivation ─────────────────────────────────────────────
describe('runCycle — status derivation', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('ok when work was done (non-dry-run)', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(['ok', 'partial']).toContain(report.status);
// Non-dry-run fixtures produce work (fixes:2, added:4 etc.), so:
expect(report.status).toBe('ok');
expect(report.totals.lint_fixes).toBe(2);
expect(report.totals.backlinks_added).toBe(3);
expect(report.totals.pages_synced).toBe(6); // added + modified from sync mock
expect(report.totals.pages_embedded).toBe(8);
expect(report.totals.orphans_found).toBe(1);
});
test('schema_version is stable at "1"', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report.schema_version).toBe('1');
});
test('CycleReport shape includes all required top-level fields', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report).toHaveProperty('schema_version');
expect(report).toHaveProperty('timestamp');
expect(report).toHaveProperty('duration_ms');
expect(report).toHaveProperty('status');
expect(report).toHaveProperty('brain_dir');
expect(report).toHaveProperty('phases');
expect(report).toHaveProperty('totals');
});
});
// ─── yieldBetweenPhases hook ─────────────────────────────────────
describe('runCycle — yieldBetweenPhases hook', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('hook is called between every phase', async () => {
let hookCalls = 0;
await runCycle(sharedEngine,{
brainDir: '/tmp/brain',
yieldBetweenPhases: async () => {
hookCalls++;
},
});
// 6 phases → 6 yield calls (one after each).
expect(hookCalls).toBe(6);
});
test('hook exceptions do not abort the cycle', async () => {
const report = await runCycle(sharedEngine,{
brainDir: '/tmp/brain',
yieldBetweenPhases: async () => {
throw new Error('synthetic hook error');
},
});
// Cycle still completed all phases.
expect(report.phases.length).toBe(6);
});
});
// ─────────────────────────────────────────────────────────────────
// Wave regression guards (#417 + Codex F2)
// ─────────────────────────────────────────────────────────────────
describe('runCycle — incremental extract slug propagation (#417)', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
syncCalls = [];
extractCalls = [];
});
test('cycle threads sync.pagesAffected into extract phase as the slugs argument', async () => {
// performSync mock returns pagesAffected = ['a', 'b']. The extract phase
// must receive those exact slugs, not undefined (which would trigger a full walk).
await runCycle(sharedEngine, { brainDir: '/tmp/brain' });
// Sync ran once
expect(syncCalls.length).toBe(1);
// Extract ran once with the slugs from sync (not undefined)
expect(extractCalls.length).toBe(1);
expect(extractCalls[0].slugs).toEqual(['a', 'b']);
});
test('extract phase falls back to full walk when sync was skipped (slugs undefined)', async () => {
// Run only the extract phase — sync didn't run, so syncPagesAffected
// is undefined and extract should walk the full directory (slugs:undefined).
await runCycle(sharedEngine, { brainDir: '/tmp/brain', phases: ['extract'] });
expect(syncCalls.length).toBe(0);
expect(extractCalls.length).toBe(1);
expect(extractCalls[0].slugs).toBeUndefined();
});
});
describe('runCycle — Codex F2: noExtract is gated on whether extract phase runs', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
syncCalls = [];
extractCalls = [];
});
test('full cycle (sync + extract): noExtract=true so sync skips inline extraction (extract phase handles it)', async () => {
await runCycle(sharedEngine, { brainDir: '/tmp/brain', phases: ['sync', 'extract'] });
expect(syncCalls.length).toBe(1);
expect(syncCalls[0].noExtract).toBe(true); // dedupe enabled
expect(extractCalls.length).toBe(1); // extract phase ran
});
test('phases:[sync] only: noExtract=false so sync runs inline extraction (no silent extract drop)', async () => {
await runCycle(sharedEngine, { brainDir: '/tmp/brain', phases: ['sync'] });
expect(syncCalls.length).toBe(1);
// Critical: noExtract must be false here. If it were true, the user just lost
// their extraction without any indication. This is the F2 regression guard.
expect(syncCalls[0].noExtract).toBe(false);
expect(extractCalls.length).toBe(0); // extract phase did NOT run
});
});
// ─── sourceId resolution (regression #475) ─────────────────────────
//
// Production OpenClaw deployment hit a 30+ min hang on every autopilot
// cycle because runPhaseSync was calling performSync without sourceId,
// so sync read the global config.sync.last_commit key (which had drifted
// out of git history after a force-push GC'd the commit). The per-source
// sources.last_commit anchor was valid the entire time. PR #475 added
// resolveSourceForDir() so the cycle reads the per-source anchor instead.
//
// These tests pin the resolver -> performSync(opts.sourceId) plumbing.
describe('runCycle — sourceId resolution (regression #475)', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
await (sharedEngine as any).db.query('DELETE FROM sources');
});
test('seeded sources row → performSync receives matching sourceId', async () => {
await (sharedEngine as any).db.query(
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
['default', 'default', '/tmp/brain-475-a'],
);
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-a' });
expect(syncCalls.at(-1)?.sourceId).toBe('default');
});
test('no matching sources row → performSync receives sourceId=undefined', async () => {
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-b' });
expect(syncCalls.at(-1)?.sourceId).toBeUndefined();
});
test('different brainDir than registered source → undefined (no cross-match)', async () => {
await (sharedEngine as any).db.query(
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
['other', 'other', '/some/other/brain'],
);
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-c' });
expect(syncCalls.at(-1)?.sourceId).toBeUndefined();
});
test('sources table missing (very old brain) → catch returns undefined, sync still runs', async () => {
// CRITICAL: do NOT DROP TABLE on the shared engine. initSchema() only
// re-runs PENDING migrations; once schema_version is at latest, the
// v20 migration that creates `sources` will not re-execute. Use a
// fresh one-shot engine so the shared engine isn't degraded for
// every later test in this file.
const fresh = new PGLiteEngine();
await fresh.connect({});
await fresh.initSchema();
await (fresh as any).db.query('DROP TABLE IF EXISTS sources CASCADE');
try {
await runCycle(fresh, { brainDir: '/tmp/brain-475-d' });
expect(syncCalls.at(-1)?.sourceId).toBeUndefined();
} finally {
await fresh.disconnect();
}
});
test('multiple rows with same local_path → resolver returns one matching id (non-deterministic)', async () => {
// Schema has no UNIQUE on local_path; SQL has no ORDER BY. Either id
// is acceptable; the contract is "any matching id, never null when
// matches exist." This test pins behavior so the follow-up
// UNIQUE-constraint TODO has a regression target.
await (sharedEngine as any).db.query(
`INSERT INTO sources (id, name, local_path) VALUES
('first', 'first', '/tmp/brain-475-e'),
('second', 'second', '/tmp/brain-475-e')`,
);
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-e' });
const sourceId = syncCalls.at(-1)?.sourceId;
expect(sourceId).toBeDefined();
expect(['first', 'second']).toContain(sourceId as string);
});
test('empty-string id row → resolver propagates as "" (defensive)', async () => {
// Schema has id as PRIMARY KEY (NOT NULL), so NULL id can't happen.
// Empty string CAN be inserted, and the resolver's `rows[0]?.id`
// would treat any falsy id as "no source" via the optional chain.
// This test pins the current behavior (we DO pass '' through to
// performSync) so a future refactor doesn't silently regress it.
await (sharedEngine as any).db.query(
`INSERT INTO sources (id, name, local_path) VALUES ('', 'empty', '/tmp/brain-475-f')`,
);
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-f' });
expect(syncCalls.at(-1)?.sourceId).toBe('');
});
});