Closes the in-code Step 5 TODO at postgres-engine.ts:131 + pglite-engine.ts:127.
- types.ts: PageInput.source_id?: string
- {postgres,pglite}-engine.ts putPage: accept source_id, INSERT explicit col when set
- {postgres,pglite}-engine.ts getTags/addTag/removeTag/upsertChunks/deleteChunks/
createVersion: optional sourceId param scopes slug->page_id lookup (avoids
subquery uniqueness violations on multi-source same-slug)
- engine.ts interface: matching optional sourceId params
- import-file.ts importFromContent/importFromFile: opts.sourceId, source-aware
idempotency check, threads through entire transaction
- import.ts runImport: opts.sourceId
- sync.ts: thread opts.sourceId through 3 importFile call sites + unconditional
resolveSourceId with pre-v0.17 backward-compat safety net (drop literal
'default' to undefined when no explicit/env signal)
- operations.ts put_page handler: resolveSourceId chain, source_id param schema
Tests:
- test/multi-source-write-path.test.ts (new): putPage explicit/implicit, ON
CONFLICT upsert, cross-source same-slug isolation, importFromContent
threading, content_hash idempotency source-aware
- test/sync-resolveSourceId-unconditional-regression.test.ts (new, CRITICAL
REGRESSION): pre-v0.17 brain backwards-compat, dotfile/cwd-prefix branches
fire, sync.ts safety-net rule
bun test: 2182 pass / 0 fail / 250 skip (E2E DATABASE_URL gated). Baseline
preserved.
Strategy: full fork-local (no upstream PR sent), per /plan-eng-review T1
outside-voice tension reconsidered post-impl. Engine-method source-aware
expansion was discovered mid-impl when cross-source same-slug tests hit
SQL state 21000 (subquery uniqueness violation) on slug-only methods.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
6.9 KiB
TypeScript
180 lines
6.9 KiB
TypeScript
/**
|
|
* v0.18.0 Step 5 — REGRESSION test for sync.ts:549 unconditional resolveSourceId.
|
|
*
|
|
* IRON-RULE regression coverage. Pre-Step-5, sync.ts only invoked
|
|
* resolveSourceId when --source or GBRAIN_SOURCE was set. The dotfile and
|
|
* cwd-prefix branches of resolveSourceId were therefore dead in practice
|
|
* for `gbrain sync` (alive only for direct `gbrain put` and similar).
|
|
*
|
|
* Step 5 lifts that guard so dotfile + cwd-prefix fire for plain
|
|
* `gbrain sync`. The risk: pre-v0.17 brains (no sources.default config,
|
|
* no .gbrain-source dotfile, no flag, no env) MUST still flow through the
|
|
* legacy global-config sync path with `sourceId = undefined`. If we naively
|
|
* pass the resolver's literal 'default' fallback through, the per-source
|
|
* anchor on the 'default' row gets read instead of the legacy
|
|
* sync.repo_path/last_commit config — which is NULL for never-migrated
|
|
* brains and breaks sync silently.
|
|
*
|
|
* The safety net in sync.ts:
|
|
*
|
|
* let sourceId = await resolveSourceId(engine, explicitSource);
|
|
* if (!explicitSource && !process.env.GBRAIN_SOURCE && sourceId === 'default') {
|
|
* sourceId = undefined;
|
|
* }
|
|
*
|
|
* This file verifies (a) the resolver chain returns the expected id under
|
|
* each input scenario, and (b) the safety-net rule preserves backward compat.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resolveSourceId } from '../src/core/source-resolver.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpRoot: string;
|
|
let originalEnv: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ type: 'pglite' } as never);
|
|
await engine.initSchema();
|
|
|
|
tmpRoot = mkdtempSync(join(tmpdir(), 'gbrain-step5-regr-'));
|
|
|
|
// Register two sources with concrete local_paths so cwd-prefix matches
|
|
// are testable. memory-dashboard owns ${tmpRoot}/proj-mem; stock-dashboard
|
|
// owns ${tmpRoot}/proj-stock.
|
|
mkdirSync(join(tmpRoot, 'proj-mem'), { recursive: true });
|
|
mkdirSync(join(tmpRoot, 'proj-stock'), { recursive: true });
|
|
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name, local_path, config) VALUES
|
|
('memory-dashboard', 'memory-dashboard', $1, '{"federated": true}'::jsonb),
|
|
('stock-dashboard', 'stock-dashboard', $2, '{"federated": true}'::jsonb)
|
|
ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path`,
|
|
[join(tmpRoot, 'proj-mem'), join(tmpRoot, 'proj-stock')],
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
beforeEach(() => {
|
|
originalEnv = process.env.GBRAIN_SOURCE;
|
|
delete process.env.GBRAIN_SOURCE;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (originalEnv !== undefined) process.env.GBRAIN_SOURCE = originalEnv;
|
|
});
|
|
|
|
describe('Resolver chain — unconditional invocation outcomes', () => {
|
|
test('pre-v0.17 brain shape: no flag, no env, no dotfile, no cwd-prefix → returns literal default', async () => {
|
|
// CWD outside any registered source's local_path; no dotfile in tree.
|
|
const isolatedCwd = mkdtempSync(join(tmpdir(), 'gbrain-isolated-'));
|
|
try {
|
|
const result = await resolveSourceId(engine, null, isolatedCwd);
|
|
expect(result).toBe('default');
|
|
} finally {
|
|
rmSync(isolatedCwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('cwd inside registered source local_path → resolves to that source', async () => {
|
|
const cwd = join(tmpRoot, 'proj-mem', 'subdir');
|
|
mkdirSync(cwd, { recursive: true });
|
|
const result = await resolveSourceId(engine, null, cwd);
|
|
expect(result).toBe('memory-dashboard');
|
|
});
|
|
|
|
test('.gbrain-source dotfile pinned to source → resolves to that source even outside local_path', async () => {
|
|
const cwd = mkdtempSync(join(tmpdir(), 'gbrain-dotfile-'));
|
|
try {
|
|
writeFileSync(join(cwd, '.gbrain-source'), 'stock-dashboard\n');
|
|
const result = await resolveSourceId(engine, null, cwd);
|
|
expect(result).toBe('stock-dashboard');
|
|
} finally {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('explicit flag wins over cwd-prefix that would have matched', async () => {
|
|
const cwd = join(tmpRoot, 'proj-mem');
|
|
const result = await resolveSourceId(engine, 'stock-dashboard', cwd);
|
|
expect(result).toBe('stock-dashboard');
|
|
});
|
|
|
|
test('GBRAIN_SOURCE env var wins over cwd-prefix that would have matched', async () => {
|
|
process.env.GBRAIN_SOURCE = 'stock-dashboard';
|
|
try {
|
|
const cwd = join(tmpRoot, 'proj-mem');
|
|
const result = await resolveSourceId(engine, null, cwd);
|
|
expect(result).toBe('stock-dashboard');
|
|
} finally {
|
|
delete process.env.GBRAIN_SOURCE;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('sync.ts safety net — drop literal default to undefined when no signal', () => {
|
|
/**
|
|
* Reproduces the exact sync.ts:549-560 logic so the safety-net invariant
|
|
* is locked into a unit test. If anyone removes the "drop to undefined"
|
|
* branch in a future refactor, this test fails immediately rather than
|
|
* silently breaking pre-v0.17 sync.
|
|
*/
|
|
async function syncResolveCwd(
|
|
explicitSource: string | null,
|
|
envSource: string | null,
|
|
cwd: string,
|
|
): Promise<string | undefined> {
|
|
if (envSource !== null) process.env.GBRAIN_SOURCE = envSource;
|
|
else delete process.env.GBRAIN_SOURCE;
|
|
let sourceId: string | undefined = await resolveSourceId(engine, explicitSource, cwd);
|
|
if (!explicitSource && !process.env.GBRAIN_SOURCE && sourceId === 'default') {
|
|
sourceId = undefined;
|
|
}
|
|
return sourceId;
|
|
}
|
|
|
|
test('REGRESSION: pre-v0.17 brain pattern (no signal, no match) → sourceId = undefined', async () => {
|
|
const isolatedCwd = mkdtempSync(join(tmpdir(), 'gbrain-isolated-2-'));
|
|
try {
|
|
const result = await syncResolveCwd(null, null, isolatedCwd);
|
|
expect(result).toBeUndefined();
|
|
} finally {
|
|
rmSync(isolatedCwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('cwd-prefix match returns named source (not undefined)', async () => {
|
|
const result = await syncResolveCwd(null, null, join(tmpRoot, 'proj-mem'));
|
|
expect(result).toBe('memory-dashboard');
|
|
});
|
|
|
|
test('explicit --source default still returns default (signal present, do not drop)', async () => {
|
|
const isolatedCwd = mkdtempSync(join(tmpdir(), 'gbrain-isolated-3-'));
|
|
try {
|
|
const result = await syncResolveCwd('default', null, isolatedCwd);
|
|
expect(result).toBe('default');
|
|
} finally {
|
|
rmSync(isolatedCwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('GBRAIN_SOURCE=default still returns default (signal present, do not drop)', async () => {
|
|
const isolatedCwd = mkdtempSync(join(tmpdir(), 'gbrain-isolated-4-'));
|
|
try {
|
|
const result = await syncResolveCwd(null, 'default', isolatedCwd);
|
|
expect(result).toBe('default');
|
|
} finally {
|
|
rmSync(isolatedCwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|