diff --git a/src/core/migrate.ts b/src/core/migrate.ts index 5272578..08187a4 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -866,6 +866,20 @@ export const MIGRATIONS: Migration[] = [ version: 26, name: 'source_taxonomy_rewrite', sql: ` + -- Step 0: heal pre-existing data corruption from sources.ts:211. + -- gbrain CLI's runAdd writes config via $4::jsonb on a JSON.stringify()'d + -- value through postgres-js unsafe(), which double-encodes the payload — + -- the cast lands as a JSON STRING scalar instead of a JSON object. + -- Verified on prod LXC 107 (2026-05-07): 6 of 7 sources had jsonb_typeof + -- = 'string' (only the migration-inlined 'default' source was a true + -- object). jsonb_set() in subsequent steps fails on scalars with + -- "cannot set path in scalar" (SQLSTATE 22023). Unwrap is byte-equivalent + -- (parses the JSON string back to its underlying object form) and + -- idempotent on already-object configs (filtered by jsonb_typeof). + UPDATE sources + SET config = (config #>> '{}')::jsonb + WHERE jsonb_typeof(config) = 'string'; + -- Step 1: ensure tombstone source exists for slug-no-match fallbacks. INSERT INTO sources (id, name, config) VALUES ( diff --git a/test/migration-v26.test.ts b/test/migration-v26.test.ts index 28a143b..b4d34f7 100644 --- a/test/migration-v26.test.ts +++ b/test/migration-v26.test.ts @@ -159,6 +159,37 @@ describe('v26 — page reclassification', () => { }); }); +describe('v26 — string-encoded config heal (regression)', () => { + test('migration unwraps jsonb string scalar configs to objects before jsonb_set', async () => { + // Reproduces prod LXC 107 (2026-05-07) data corruption: gbrain CLI's + // sources.ts:211 INSERT via $::jsonb on JSON.stringify() output produces + // a JSON STRING scalar, not an object. jsonb_set on a scalar throws + // SQLSTATE 22023 'cannot set path in scalar'. v26 step 0 unwraps before + // the rest of the migration touches config. + await engine.executeRaw( + `INSERT INTO sources (id, name, config) VALUES + ('regression-string-cfg', 'regression-string-cfg', + '"{\\"federated\\":true,\\"slug_prefix_rules\\":[\\"regression/\\"]}"'::jsonb) + ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config`, + ); + // Sanity: confirm we set up the bug condition. + const before = await engine.executeRaw<{ type: string }>( + `SELECT jsonb_typeof(config) AS type FROM sources WHERE id = 'regression-string-cfg'`, + ); + expect(before[0].type).toBe('string'); + + // Re-run v26: step 0 should unwrap, then the remaining steps proceed cleanly. + await engine.runMigration(26, v26Sql); + + const after = await engine.executeRaw<{ type: string; rules: string[] | null }>( + `SELECT jsonb_typeof(config) AS type, config->'slug_prefix_rules' AS rules FROM sources WHERE id = 'regression-string-cfg'`, + ); + expect(after[0].type).toBe('object'); + // Contents preserved byte-for-byte after unwrap. + expect(after[0].rules).toEqual(['regression/']); + }); +}); + describe('v26 — idempotency (CR-6)', () => { test('re-running migration is a no-op: source distribution unchanged', async () => { const before = await engine.executeRaw<{ source_id: string; n: bigint }>(