Files
gbrain/test/e2e/restore-source-config-jsonb-postgres.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

69 lines
2.9 KiB
TypeScript

/**
* Postgres-only regression for archive/restore config recovery (#3420).
*
* restoreSource patches `config.federated` by binding a JS JSON string to a
* jsonb placeholder. With a bare `$n::jsonb` bind, postgres.js double-encodes
* the value into a jsonb STRING scalar (#2339 class); the coerced object then
* gets `object || string` concatenated, which Postgres evaluates as
* array-concat — so restore RE-CORRUPTS the exact shape this path repairs.
* PGLite cannot reproduce this (its driver parses the bind natively), so this
* is DATABASE_URL-gated per the engine-parity convention. Pins the
* `$n::text::jsonb` bind shape: after archive → restore of a corrupted
* string-scalar config, config must be jsonb_typeof = 'object', the federated
* flag must be applied, and pre-existing keys must survive.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { setupDB, teardownDB, hasDatabase } from './helpers.ts';
import type { PostgresEngine } from '../../src/core/postgres-engine.ts';
import { softDeleteSource, restoreSource } from '../../src/core/destructive-guard.ts';
const skip = !hasDatabase();
const describeIfDB = skip ? describe.skip : describe;
let engine: PostgresEngine;
beforeAll(async () => {
if (skip) return;
engine = await setupDB();
});
afterAll(async () => {
if (skip) return;
await engine.executeRaw(`DELETE FROM sources WHERE id = 'restore-corrupt-cfg'`);
await teardownDB();
});
describeIfDB('restoreSource config jsonb encoding — Postgres regression (#3420)', () => {
test('archive → restore of a string-scalar config yields an object with federated preserved', async () => {
const id = 'restore-corrupt-cfg';
await engine.executeRaw(
`INSERT INTO sources (id, name) VALUES ($1, $1) ON CONFLICT (id) DO NOTHING`,
[id],
);
// Corrupted shape: config is a jsonb STRING scalar whose text is valid JSON.
await engine.executeRaw(
`UPDATE sources SET config = $2::text::jsonb, archived = false WHERE id = $1`,
[id, JSON.stringify(JSON.stringify({ federated: false, remote_url: 'https://example.invalid/repo' }))],
);
const seeded = await engine.executeRaw<{ kind: string }>(
`SELECT jsonb_typeof(config) AS kind FROM sources WHERE id = $1`,
[id],
);
expect(seeded[0]!.kind).toBe('string');
expect(await softDeleteSource(engine, id)).not.toBeNull();
expect(await restoreSource(engine, id, true)).toBe(true);
const rows = await engine.executeRaw<{ kind: string; federated: string | null; remote_url: string | null }>(
`SELECT jsonb_typeof(config) AS kind,
config->>'federated' AS federated,
config->>'remote_url' AS remote_url
FROM sources WHERE id = $1`,
[id],
);
expect(rows[0]!.kind).toBe('object');
expect(rows[0]!.federated).toBe('true');
expect(rows[0]!.remote_url).toBe('https://example.invalid/repo');
});
});