/** * Parallel-sync regression tests (PGLite, in-memory). * * T1 — sync.last_commit failure-gate under concurrency=4 request. * T4 — PGLite + concurrency=4 stays serial (no crash, no PostgresEngine * construction). Tightens the engine.kind guard introduced in * v0.22.13 (PR #490 A1). * CODEX-3 — head-drift gate: when git HEAD moves between performSync's * capture and its post-import re-check, last_commit must NOT advance. * * PGLite forces concurrency=1 internally regardless of the requested value, * which is the *whole point* of T4 — but the bookmark-gate logic * (failedFiles → don't advance) is engine-agnostic, so PGLite is fine for * the T1 + CODEX-3 contracts. A separate Postgres E2E covers worker-engine * construction directly. */ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { execSync } from 'child_process'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; function git(repo: string, ...args: string[]): string { return execSync(`git ${args.join(' ')}`, { cwd: repo, encoding: 'utf-8' }).trim(); } function seedRepoWithMarkdown(repoPath: string, fileCount: number): string { execSync('git init', { cwd: repoPath, stdio: 'pipe' }); execSync('git config user.email "test@test.com"', { cwd: repoPath, stdio: 'pipe' }); execSync('git config user.name "Test"', { cwd: repoPath, stdio: 'pipe' }); mkdirSync(join(repoPath, 'people'), { recursive: true }); for (let i = 0; i < fileCount; i++) { writeFileSync(join(repoPath, `people/p${i}.md`), [ '---', 'type: person', `title: Person ${i}`, '---', '', `This is person ${i}.`, ].join('\n')); } execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' }); return git(repoPath, 'rev-parse', 'HEAD'); } describe('sync-parallel: PGLite + concurrency=4 (T4)', () => { let engine: PGLiteEngine; let repoPath: string; beforeEach(async () => { engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-par-')); }); afterEach(async () => { await engine.disconnect(); if (repoPath) rmSync(repoPath, { recursive: true, force: true }); }); test('PGLite + concurrency=4 + 60 files: imports all without crashing', async () => { seedRepoWithMarkdown(repoPath, 60); const { performSync } = await import('../src/commands/sync.ts'); const result = await performSync(engine, { repoPath, noPull: true, noEmbed: true, concurrency: 4, }); // First sync routes through performFullSync, returning 'first_sync'. expect(result.status).toBe('first_sync'); // PGLite stayed single-connection; if the parallel branch had tried to // construct PostgresEngine without database_url, this test would crash. }); test('PGLite + explicit concurrency=4 + 30 files (below floor): still safe', async () => { // Q1 path: explicit opt-in beats the >50 floor. PGLite forces serial // anyway (engine.kind), so the test is that nothing crashes and the // sync advances correctly. seedRepoWithMarkdown(repoPath, 30); const { performSync } = await import('../src/commands/sync.ts'); const result = await performSync(engine, { repoPath, noPull: true, noEmbed: true, concurrency: 4, }); expect(result.status).toBe('first_sync'); }); }); describe('sync-parallel: bookmark gate under concurrency request (T1)', () => { let engine: PGLiteEngine; let repoPath: string; beforeEach(async () => { engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-gate-')); }); afterEach(async () => { await engine.disconnect(); if (repoPath) rmSync(repoPath, { recursive: true, force: true }); }); test('clean parallel sync advances last_commit', async () => { const initialHead = seedRepoWithMarkdown(repoPath, 5); const { performSync } = await import('../src/commands/sync.ts'); await performSync(engine, { repoPath, noPull: true, noEmbed: true, concurrency: 4, }); const lastCommit = await engine.getConfig('sync.last_commit'); expect(lastCommit).toBe(initialHead); }); test('failure-injection blocks last_commit advance', async () => { // First sync: clean state. const firstHead = seedRepoWithMarkdown(repoPath, 5); const { performSync } = await import('../src/commands/sync.ts'); await performSync(engine, { repoPath, noPull: true, noEmbed: true, }); const lastAfterFirst = await engine.getConfig('sync.last_commit'); expect(lastAfterFirst).toBe(firstHead); // Now add a malformed file (broken YAML frontmatter — closing --- missing // means the parser hits a real failure that importFile reports). writeFileSync(join(repoPath, 'people/broken.md'), [ '---', 'type: person', 'title: Broken', // intentionally no closing --- 'this line is body but parser thinks it is YAML', ].join('\n')); execSync('git add -A && git commit -m "add broken"', { cwd: repoPath, stdio: 'pipe' }); const secondHead = git(repoPath, 'rev-parse', 'HEAD'); expect(secondHead).not.toBe(firstHead); // Second sync: should record failure and NOT advance the bookmark. const result = await performSync(engine, { repoPath, noPull: true, noEmbed: true, concurrency: 4, }); // Only fail the test when the parser actually rejected the broken file. // Some YAML parsers are permissive; if so this test exercises the // happy path AND the assertion below (lastCommit advanced) holds. if (result.status === 'blocked_by_failures') { const lastAfterBroken = await engine.getConfig('sync.last_commit'); expect(lastAfterBroken).toBe(firstHead); // unchanged — gate held expect(result.failedFiles ?? 0).toBeGreaterThan(0); } else { // If the parser was permissive, at least confirm the bookmark moved. const lastAfterBroken = await engine.getConfig('sync.last_commit'); expect(lastAfterBroken).toBe(secondHead); } }); }); describe('sync-parallel: head-drift gate (CODEX-3)', () => { let engine: PGLiteEngine; let repoPath: string; beforeEach(async () => { engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-drift-')); }); afterEach(async () => { await engine.disconnect(); if (repoPath) rmSync(repoPath, { recursive: true, force: true }); }); test('static-HEAD sync advances last_commit (control)', async () => { const head = seedRepoWithMarkdown(repoPath, 3); const { performSync } = await import('../src/commands/sync.ts'); await performSync(engine, { repoPath, noPull: true, noEmbed: true }); expect(await engine.getConfig('sync.last_commit')).toBe(head); }); test('vanished-mid-sync added file is skipped, not a failure (v0.42.x #1794)', async () => { // First sync: clean state for incremental. seedRepoWithMarkdown(repoPath, 3); const { performSync } = await import('../src/commands/sync.ts'); await performSync(engine, { repoPath, noPull: true, noEmbed: true }); // Add a file, commit, then delete the file from disk WITHOUT amending the // commit — diff says it exists at HEAD, but the file is gone. writeFileSync(join(repoPath, 'people/will-vanish.md'), [ '---', 'type: person', 'title: Vanish', '---', '', 'body', ].join('\n')); execSync('git add -A && git commit -m "add vanish"', { cwd: repoPath, stdio: 'pipe' }); rmSync(join(repoPath, 'people/will-vanish.md')); const result = await performSync(engine, { repoPath, noPull: true, noEmbed: true, }); // v0.42.x (#1794, Codex #3) SUPERSEDES the v0.22.13 CODEX-3 behavior: under // the pinned-target resumable sync, importFile reads the live working tree, // so a file added in lastCommit..pin but deleted from disk by a commit AFTER // the pin is normal forward progress from a concurrent committer — it // genuinely doesn't exist at HEAD, so SKIP it (don't block the run). A real // history REWRITE is caught by the separate pin-reachability gate. The // vanished file is never created; the next sync's pin..HEAD diff shows it // deleted. expect(result.status).toBe('synced'); expect(result.failedFiles ?? 0).toBe(0); expect(await engine.getPage('people/will-vanish')).toBeNull(); }); }); describe('sync-parallel: writer lock prevents reentrance (CODEX-2)', () => { let engine: PGLiteEngine; let repoPath: string; beforeEach(async () => { engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-lock-')); }); afterEach(async () => { await engine.disconnect(); if (repoPath) rmSync(repoPath, { recursive: true, force: true }); }); test('two parallel performSync calls in same process: second waits or fails fast', async () => { seedRepoWithMarkdown(repoPath, 5); const { performSync } = await import('../src/commands/sync.ts'); // Same-process concurrent calls: PGLite serializes engine ops via its // exclusive transaction mutex, but the writer-lock is the right barrier. // We verify that one call completes (the lock holder) and any concurrent // call either completes after (lock released) or surfaces the // "Another sync is in progress" error. const promise1 = performSync(engine, { repoPath, noPull: true, noEmbed: true }); let secondError: unknown = null; try { // Tiny delay so promise1 captures the lock first. await new Promise((r) => setTimeout(r, 10)); await performSync(engine, { repoPath, noPull: true, noEmbed: true }); } catch (e) { secondError = e; } await promise1; // Either: (a) second call completed after first released, both succeeded // OR (b) second call hit the lock-busy error path. Either is correct. if (secondError) { const msg = secondError instanceof Error ? secondError.message : String(secondError); expect(msg).toMatch(/Another sync is in progress|lock|gbrain-sync/i); } }); });