Files
gbrain/test/list-all-sources.test.ts
T
16782aee7f fix(sources): recover corrupted config shapes (#3420)
* 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>
2026-07-27 14:16:05 -07:00

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',
});
});
});