Files
gbrain/test/migration-v26.test.ts
triton6564685 71aaf22573 fix(v0.18.2.fork.1): v26 — heal string-encoded source configs before jsonb_set
Prod LXC 107 deploy of v26 (2026-05-07) failed with SQLSTATE 22023
"cannot set path in scalar" because 6 of 7 sources had jsonb_typeof = 'string'
instead of 'object'. Root cause is a pre-existing bug in sources.ts:211:

  await engine.executeRaw(
    `INSERT INTO sources (...) VALUES (..., $4::jsonb) ...`,
    [..., JSON.stringify(config)],
  );

postgres-js's unsafe() with $::jsonb cast double-encodes the JSON string —
the cast lands as a JSON STRING scalar, not the intended object. Migration-
inlined inserts (e.g. v17 'default' source) work correctly because they use
literal '{"key":"val"}'::jsonb at SQL level.

v26 was the first migration to hit jsonb_set on these legacy configs,
which is why this surfaced now (drill on D-LXC fixture missed it because
the fixture was empty + sources-add via CLI hit the bug but no further
jsonb_set ran on those rows).

Fix: prepend a Step 0 to v26 that unwraps any string-encoded config back
to its object form via (config #>> '{}')::jsonb. Idempotent on already-
object configs (filtered by jsonb_typeof). Byte-equivalent contents — the
JSON parse step is information-preserving.

Manual prod recovery (2026-05-07 14:05 UTC): unwrap UPDATE applied to LXC
107 BEFORE this commit, then v26 re-ran and applied cleanly. Post-state
verified: 203 gstack-brain pages → 155 stock-dashboard + 40 memory-dashboard
+ 8 default-ambiguous, gstack-brain source dropped, default-ambiguous +
gstack-meta sources created.

This fork commit codifies the fix so future Postgres deploys (other dev
boxes, fresh prod redeploys, the in-progress gbrain-mcp:v0.18.2-fork.1
image rebuild) self-heal automatically. Adds a regression test
(string-encoded config) in tests/migration-v26.test.ts. 14/14 tests pass.

Followup TODO: fix sources.ts:211 to either pass an object directly (let
postgres-js handle JSON serialisation) OR use postgres.json() helper.
Out of scope for this commit — the unwrap heals existing data; an
upstream fix prevents new corruption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:08:02 +08:00

216 lines
9.9 KiB
TypeScript

/**
* v0.18.2.fork.1 — migration v26 source taxonomy rewrite.
*
* Verifies:
* - default-ambiguous source created if absent
* - gstack-meta source created with rules [retros/, analytics/]
* - gstack-meta UPSERT path: existing source gets rules updated
* - memory-dashboard / stock-dashboard get the longer per-project prefixes
* when they pre-exist; UPDATE no-ops on brains that never created them
* - Pages on legacy gstack-brain reclassify correctly:
* slug `projects/triton6564685-stock-dashboard/...` → stock-dashboard
* slug `projects/triton6564685-memory-dashboard/...` → memory-dashboard
* slug `retros/...` or `analytics/...` → gstack-meta
* slug not matching any of the above → default-ambiguous (tombstone)
* - gstack-brain source DELETED after all pages move out
* - Idempotency: running the migration SQL again is a no-op (CR-6)
*
* The migration runs automatically on initSchema() (it's part of MIGRATIONS).
* We re-execute the SQL string a second time to test idempotency, mimicking
* an apply-migrations re-run after a partial failure.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MIGRATIONS } from '../src/core/migrate.ts';
const v26 = MIGRATIONS.find((m) => m.version === 26);
if (!v26) throw new Error('migration v26 missing from MIGRATIONS array');
const v26Sql = v26.sql;
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ type: 'pglite' } as never);
await engine.initSchema();
// Pre-v26 fixture: install legacy gstack-brain (overly broad) + per-project
// sources WITHOUT the new longer rules + sample pages. Simulates a brain
// mid-upgrade where v26 needs to do real work.
// Note: initSchema already ran v26 once (creating default-ambiguous + gstack-meta).
// Inserting gstack-brain back + writing pages to it lets us exercise the
// re-run path that v26 is designed to be idempotent across.
await engine.executeRaw(
`INSERT INTO sources (id, name, config) VALUES
('gstack-brain', 'gstack-brain', '{"federated": true, "slug_prefix_rules": ["projects/", "builder-journey"]}'::jsonb),
('memory-dashboard', 'memory-dashboard', '{"federated": true, "slug_prefix_rules": ["memory-dashboard/"]}'::jsonb),
('stock-dashboard', 'stock-dashboard', '{"federated": true, "slug_prefix_rules": ["stock-dashboard/"]}'::jsonb)
ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config`,
);
await engine.executeRaw(
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, content_hash) VALUES
('projects/triton6564685-stock-dashboard/checkpoints/foo', 'gstack-brain', 'note', 'foo', 'x', 'h1'),
('projects/triton6564685-memory-dashboard/checkpoints/bar', 'gstack-brain', 'note', 'bar', 'x', 'h2'),
('retros/2026-05-07-week-recap', 'gstack-brain', 'note', 'recap', 'x', 'h3'),
('analytics/skill-usage', 'gstack-brain', 'note', 'usage', 'x', 'h4'),
('builder-journey/q1', 'gstack-brain', 'note', 'q1', 'x', 'h5'),
('projects/some-other-project/note', 'gstack-brain', 'note', 'other', 'x', 'h6')
ON CONFLICT (source_id, slug) DO NOTHING`,
);
// Re-execute v26 SQL to reclassify the gstack-brain pages we just added.
// Use runMigration (db.exec) — PGLite's prepared-statement query() rejects
// multi-statement SQL with "cannot insert multiple commands into a prepared
// statement" (42601).
await engine.runMigration(26, v26Sql);
});
afterAll(async () => {
await engine.disconnect();
});
describe('v26 — source rows', () => {
test('default-ambiguous source exists', async () => {
const rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE id = 'default-ambiguous'`,
);
expect(rows.length).toBe(1);
});
test('gstack-meta source exists with rules [retros/, analytics/]', async () => {
const rows = await engine.executeRaw<{ config: string | Record<string, unknown> }>(
`SELECT config FROM sources WHERE id = 'gstack-meta'`,
);
expect(rows.length).toBe(1);
const cfg = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : rows[0].config;
expect(cfg.slug_prefix_rules).toEqual(['retros/', 'analytics/']);
});
test('memory-dashboard rules now include projects/triton6564685-memory-dashboard/', async () => {
const rows = await engine.executeRaw<{ config: string | Record<string, unknown> }>(
`SELECT config FROM sources WHERE id = 'memory-dashboard'`,
);
const cfg = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : rows[0].config;
expect(cfg.slug_prefix_rules).toContain('projects/triton6564685-memory-dashboard/');
expect(cfg.slug_prefix_rules).toContain('memory-dashboard/');
});
test('stock-dashboard rules now include projects/triton6564685-stock-dashboard/', async () => {
const rows = await engine.executeRaw<{ config: string | Record<string, unknown> }>(
`SELECT config FROM sources WHERE id = 'stock-dashboard'`,
);
const cfg = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : rows[0].config;
expect(cfg.slug_prefix_rules).toContain('projects/triton6564685-stock-dashboard/');
expect(cfg.slug_prefix_rules).toContain('stock-dashboard/');
});
test('gstack-brain source DELETED (all pages moved out)', async () => {
const rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE id = 'gstack-brain'`,
);
expect(rows.length).toBe(0);
});
});
describe('v26 — page reclassification', () => {
test('stock-dashboard project page → stock-dashboard source', async () => {
const rows = await engine.executeRaw<{ source_id: string }>(
`SELECT source_id FROM pages WHERE slug = 'projects/triton6564685-stock-dashboard/checkpoints/foo'`,
);
expect(rows.length).toBe(1);
expect(rows[0].source_id).toBe('stock-dashboard');
});
test('memory-dashboard project page → memory-dashboard source', async () => {
const rows = await engine.executeRaw<{ source_id: string }>(
`SELECT source_id FROM pages WHERE slug = 'projects/triton6564685-memory-dashboard/checkpoints/bar'`,
);
expect(rows[0].source_id).toBe('memory-dashboard');
});
test('retros/* page → gstack-meta', async () => {
const rows = await engine.executeRaw<{ source_id: string }>(
`SELECT source_id FROM pages WHERE slug = 'retros/2026-05-07-week-recap'`,
);
expect(rows[0].source_id).toBe('gstack-meta');
});
test('analytics/* page → gstack-meta', async () => {
const rows = await engine.executeRaw<{ source_id: string }>(
`SELECT source_id FROM pages WHERE slug = 'analytics/skill-usage'`,
);
expect(rows[0].source_id).toBe('gstack-meta');
});
test('builder-journey/* page → default-ambiguous (no new rule covers it)', async () => {
const rows = await engine.executeRaw<{ source_id: string }>(
`SELECT source_id FROM pages WHERE slug = 'builder-journey/q1'`,
);
expect(rows[0].source_id).toBe('default-ambiguous');
});
test('projects/some-other-project page → default-ambiguous (catch-all tombstone)', async () => {
const rows = await engine.executeRaw<{ source_id: string }>(
`SELECT source_id FROM pages WHERE slug = 'projects/some-other-project/note'`,
);
expect(rows[0].source_id).toBe('default-ambiguous');
});
});
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 }>(
`SELECT source_id, COUNT(*)::bigint AS n FROM pages GROUP BY source_id ORDER BY source_id`,
);
await engine.runMigration(26, v26Sql);
const after = await engine.executeRaw<{ source_id: string; n: bigint }>(
`SELECT source_id, COUNT(*)::bigint AS n FROM pages GROUP BY source_id ORDER BY source_id`,
);
expect(after.length).toBe(before.length);
for (let i = 0; i < before.length; i++) {
expect(after[i].source_id).toBe(before[i].source_id);
expect(after[i].n).toBe(before[i].n);
}
});
test('running on a brain with no gstack-brain source does not fail', async () => {
// gstack-brain is already gone. v26 should still execute cleanly because
// every UPDATE/DELETE is guarded by source_id = 'gstack-brain' = empty set.
await engine.runMigration(26, v26Sql);
// No exception thrown = pass.
});
});