mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* fix(sources): recover corrupted config shapes (#3401) Use one canonical normalizer for nested string and array-shaped source configs across federation reads, config writes, archive/restore, and doctor remediation.\n\nFixes #3401\nFixes #3402\nFixes #3403 Signed-off-by: arisgysel-design <arisgysel-design@users.noreply.github.com> * fix(sources): bind restoreSource federated patch via ::text::jsonb (#2339 class) restoreSource bound a JS JSON string to a bare $1::jsonb placeholder; postgres.js double-encodes that into a jsonb string scalar, so on the Postgres engine the coerced object || string-scalar concat evaluates as array-concat and restore RE-CORRUPTS the exact config shape this PR repairs. PGLite masks the bug (its driver parses the bind natively). Fix: bind through $1::text::jsonb per the repo JSONB rule. Adds the DATABASE_URL-gated Postgres regression (test/e2e/restore-source-config-jsonb-postgres.test.ts): seeds a corrupted string-scalar config, runs archive -> restore, asserts jsonb_typeof(config) = 'object' with the federated flag applied and pre-existing keys preserved. Verified red on the bare ::jsonb bind (config became a jsonb array) and green on the fix against a real pgvector Postgres; skips cleanly without DATABASE_URL. Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Signed-off-by: arisgysel-design <arisgysel-design@users.noreply.github.com> Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com> Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
170 lines
7.2 KiB
TypeScript
170 lines
7.2 KiB
TypeScript
/**
|
|
* v0.38 engine.listAllSources + updateSourceConfig integration tests (PGLite).
|
|
*
|
|
* Runs against an in-memory PGLite engine so the test is hermetic (no
|
|
* DATABASE_URL required). Postgres parity is covered by the e2e suite.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
async function seedSource(
|
|
id: string,
|
|
opts: { local_path?: string | null; archived?: boolean; config?: Record<string, unknown> } = {},
|
|
): Promise<void> {
|
|
const localPath = opts.local_path === undefined ? `/tmp/${id}` : opts.local_path;
|
|
const archived = opts.archived === true;
|
|
const config = JSON.stringify(opts.config ?? {});
|
|
// ON CONFLICT to make the seed idempotent in case the test bed re-seeds 'default'.
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
|
|
VALUES ($1, $2, $3, $4::jsonb, $5, NOW())
|
|
ON CONFLICT (id) DO UPDATE
|
|
SET local_path = EXCLUDED.local_path,
|
|
config = EXCLUDED.config,
|
|
archived = EXCLUDED.archived`,
|
|
[id, id, localPath, config, archived],
|
|
);
|
|
}
|
|
|
|
async function setRawConfig(id: string, rawJson: string): Promise<void> {
|
|
await engine.executeRaw(
|
|
`UPDATE sources SET config = $2::text::jsonb WHERE id = $1`,
|
|
[id, rawJson],
|
|
);
|
|
}
|
|
|
|
describe('engine.listAllSources', () => {
|
|
test('returns empty array on fresh brain with only seeded default', async () => {
|
|
// 'default' source seeded by migration; we'll just check it appears
|
|
const all = await engine.listAllSources();
|
|
expect(all.length).toBeGreaterThanOrEqual(1);
|
|
expect(all.some(s => s.id === 'default')).toBe(true);
|
|
});
|
|
|
|
test('filters archived by default', async () => {
|
|
await seedSource('alive');
|
|
await seedSource('dead', { archived: true });
|
|
const all = await engine.listAllSources();
|
|
expect(all.some(s => s.id === 'alive')).toBe(true);
|
|
expect(all.some(s => s.id === 'dead')).toBe(false);
|
|
});
|
|
|
|
test('includeArchived: true returns archived rows too', async () => {
|
|
await seedSource('alive');
|
|
await seedSource('dead', { archived: true });
|
|
const all = await engine.listAllSources({ includeArchived: true });
|
|
expect(all.some(s => s.id === 'alive')).toBe(true);
|
|
expect(all.some(s => s.id === 'dead')).toBe(true);
|
|
});
|
|
|
|
test('localPathOnly: true filters local_path IS NULL (codex P1-4)', async () => {
|
|
await seedSource('with-path', { local_path: '/tmp/x' });
|
|
await seedSource('db-only', { local_path: null });
|
|
const all = await engine.listAllSources({ localPathOnly: true });
|
|
expect(all.some(s => s.id === 'with-path')).toBe(true);
|
|
expect(all.some(s => s.id === 'db-only')).toBe(false);
|
|
});
|
|
|
|
test('config JSONB parses to object (autopilot reads last_full_cycle_at)', async () => {
|
|
await seedSource('fred', { config: { last_full_cycle_at: '2026-05-22T07:00:00.000Z', remote_url: 'https://x' } });
|
|
const all = await engine.listAllSources();
|
|
const fred = all.find(s => s.id === 'fred')!;
|
|
expect(fred.config.last_full_cycle_at).toBe('2026-05-22T07:00:00.000Z');
|
|
expect(fred.config.remote_url).toBe('https://x');
|
|
});
|
|
|
|
test('default source sorts first', async () => {
|
|
await seedSource('zebra');
|
|
await seedSource('alpha');
|
|
const all = await engine.listAllSources();
|
|
expect(all[0].id).toBe('default');
|
|
});
|
|
});
|
|
|
|
describe('engine.updateSourceConfig', () => {
|
|
test('returns false for unknown source', async () => {
|
|
const updated = await engine.updateSourceConfig('does-not-exist', { last_full_cycle_at: 'x' });
|
|
expect(updated).toBe(false);
|
|
});
|
|
|
|
test('returns true and merges patch into config JSONB', async () => {
|
|
await seedSource('alpha', { config: { existing: 'keep-me', remote_url: 'https://x' } });
|
|
const updated = await engine.updateSourceConfig('alpha', { last_full_cycle_at: '2026-05-22T08:00:00.000Z' });
|
|
expect(updated).toBe(true);
|
|
const all = await engine.listAllSources();
|
|
const a = all.find(s => s.id === 'alpha')!;
|
|
expect(a.config.existing).toBe('keep-me');
|
|
expect(a.config.remote_url).toBe('https://x');
|
|
expect(a.config.last_full_cycle_at).toBe('2026-05-22T08:00:00.000Z');
|
|
});
|
|
|
|
test('patch overwrites same-key value (last-write-wins per JSONB ||)', async () => {
|
|
await seedSource('beta', { config: { last_full_cycle_at: '2026-01-01T00:00:00.000Z' } });
|
|
await engine.updateSourceConfig('beta', { last_full_cycle_at: '2026-05-22T09:00:00.000Z' });
|
|
const all = await engine.listAllSources();
|
|
expect(all.find(s => s.id === 'beta')!.config.last_full_cycle_at).toBe('2026-05-22T09:00:00.000Z');
|
|
});
|
|
|
|
test('idempotent: repeat write of same patch is a no-op semantically', async () => {
|
|
await seedSource('charlie');
|
|
await engine.updateSourceConfig('charlie', { last_full_cycle_at: '2026-05-22T10:00:00.000Z' });
|
|
await engine.updateSourceConfig('charlie', { last_full_cycle_at: '2026-05-22T10:00:00.000Z' });
|
|
const all = await engine.listAllSources();
|
|
expect(all.find(s => s.id === 'charlie')!.config.last_full_cycle_at).toBe('2026-05-22T10:00:00.000Z');
|
|
});
|
|
|
|
test('COALESCE defense: works on source with empty config (the v0.38 fresh shape)', async () => {
|
|
// Schema enforces config JSONB NOT NULL DEFAULT '{}', so we cannot
|
|
// produce a row with NULL config here. The COALESCE in
|
|
// updateSourceConfig is defensive against pre-migration brains whose
|
|
// sources table predates the NOT NULL constraint. This case just
|
|
// confirms the happy-path on the default empty config.
|
|
await seedSource('delta', { config: {} });
|
|
const updated = await engine.updateSourceConfig('delta', { last_full_cycle_at: '2026-05-22T11:00:00.000Z' });
|
|
expect(updated).toBe(true);
|
|
const all = await engine.listAllSources();
|
|
expect(all.find(s => s.id === 'delta')!.config.last_full_cycle_at).toBe('2026-05-22T11:00:00.000Z');
|
|
});
|
|
|
|
test('IS-JSON guard: non-JSON string config normalizes to {} on merge', async () => {
|
|
await seedSource('bad-string');
|
|
await setRawConfig('bad-string', JSON.stringify('garbage text'));
|
|
expect(await engine.updateSourceConfig('bad-string', { merged_key: 'v' })).toBe(true);
|
|
const all = await engine.listAllSources();
|
|
expect(all.find(source => source.id === 'bad-string')!.config).toEqual({ merged_key: 'v' });
|
|
});
|
|
|
|
test('historical array config is flattened safely before merge', async () => {
|
|
await seedSource('array-config');
|
|
await setRawConfig('array-config', JSON.stringify([
|
|
JSON.stringify(JSON.stringify({ remote_url: 'https://example.invalid/repo' })),
|
|
{ federated: true },
|
|
'garbage',
|
|
]));
|
|
expect(await engine.updateSourceConfig('array-config', { tracked_branch: 'main' })).toBe(true);
|
|
const all = await engine.listAllSources();
|
|
expect(all.find(source => source.id === 'array-config')!.config).toEqual({
|
|
remote_url: 'https://example.invalid/repo',
|
|
federated: true,
|
|
tracked_branch: 'main',
|
|
});
|
|
});
|
|
});
|