test(v0.18.2.fork.1): Phase 0 vanilla rollback safety drill (lite)

Lite version of /plan-eng-review T3 outside-voice insistence on backup/
restore drill. SQL-level invariants verified directly via PGLite; full
throwaway-LXC + rclone-Drive-restore + age-decrypt ritual deferred to
quarterly Drill 3 per design doc.

IRON property: if fork ships + writes non-default source_id rows, then
rollback to vanilla v0.18.2 cannot delete or overwrite those rows. Verified
via 4 SQL-level test cases:

- Vanilla putPage at 'default' does not touch existing 'memory-dashboard'
  row (composite UNIQUE conflict target mismatch → INSERT, not UPDATE).
- Cross-source slug isolation preserved across 3+ sources after vanilla
  re-import.
- Schema constraint backstop: pages_source_slug_key UNIQUE (source_id,
  slug) installed; no competing global UNIQUE(slug) remains post-v17.
- SECONDARY safety surface: vanilla's full importFromContent flow calls
  tx.getTags(slug) which uses a slug-only subquery. On multi-source
  same-slug data, that subquery returns multiple page_ids → SQL 21000
  → transaction rollback. Vanilla cannot physically write through this
  path; original rows preserved by ROLLBACK. Net: safe (data preserved)
  but vanilla operator must accept "frozen" multi-source slugs until
  re-forking or manual cleanup.

Tests use direct engine.putPage to isolate the SQL-level invariant from
the importFromContent transaction (which would crash on tag
reconciliation as documented in the secondary-safety test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 22:49:50 +08:00
parent 888fe26c24
commit 37b9e8dca3

View File

@@ -0,0 +1,214 @@
/**
* v0.18.2.fork.1 — Phase 0 backup/restore rollback safety drill (lite).
*
* This is the "Phase 0 outside-voice T3" assertion expressed at the SQL
* semantics level. The throwaway-LXC + rclone-Drive-restore + age-decrypt
* full ritual is deferred (Drive backup pipeline gets covered by the
* separate quarterly Drill 3 per design doc); this file proves the
* IMPORTANT SQL invariants directly:
*
* IF the fork has shipped + written rows with non-default source_id,
* AND we then roll back to vanilla v0.18.2 (via Phase -1 vendored image),
* THEN vanilla `gbrain sync` MUST NOT delete or overwrite those rows.
*
* Vanilla v0.18.2 code path:
* sync.ts → importFile → importFromContent → tx.putPage(slug, {...no source_id})
*
* In our fork, omitting source_id falls back to schema DEFAULT 'default' —
* the vanilla code path is byte-identical at the putPage SQL level. So we
* simulate vanilla writes by calling engine.putPage with source_id omitted.
*
* We DO NOT use importFromContent here for two reasons:
* 1. Test setup: importFromContent runs inside a transaction that calls
* tx.getTags(slug) which uses a slug-only subquery — that fails with
* SQL 21000 "more than one row returned by a subquery" on multi-source
* same-slug data. This is itself part of the safety property: vanilla
* cannot successfully re-import a multi-source slug, which means it
* can't accidentally write competing data either. The failure is
* transaction-local, original rows are preserved by ROLLBACK.
* 2. Test purity: we want to isolate the SQL-level invariant, not the
* tag-reconciliation interaction.
*
* Key SQL property: composite UNIQUE (source_id, slug) means an INSERT at
* ('default', slug) does not collide with an existing ('memory-dashboard',
* slug) row. Vanilla writes land at default; non-default rows persist.
*
* Acceptable side-effect: parallel rows in 'default' and the original
* source (cleanable post-rollback). The IRON property is no data loss
* for the original non-default content.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { importFromContent } from '../src/core/import-file.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ type: 'pglite' } as never);
await engine.initSchema();
await engine.executeRaw(
`INSERT INTO sources (id, name, config) VALUES
('memory-dashboard', 'memory-dashboard', '{"federated": true}'::jsonb),
('stock-dashboard', 'stock-dashboard', '{"federated": true}'::jsonb)
ON CONFLICT (id) DO NOTHING`,
);
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await engine.executeRaw(`DELETE FROM pages WHERE slug LIKE 'rollback-drill/%'`);
});
describe('Vanilla rollback safety — IRON: non-default source_id rows are preserved', () => {
test('Vanilla putPage at default does not delete existing non-default row', async () => {
// Step 1: simulate post-fork-deploy state — fork wrote a row at memory-dashboard.
await engine.putPage('rollback-drill/architecture', {
type: 'note',
title: 'Architecture (fork-written)',
compiled_truth: 'Original fork content under memory-dashboard.',
source_id: 'memory-dashboard',
});
// Step 2: simulate vanilla v0.18.2 putPage without source_id
// (= vanilla sync.ts → importFromContent → tx.putPage path's SQL effect).
// Schema DEFAULT 'default' applies. ON CONFLICT (source_id='default', slug)
// does NOT match existing ('memory-dashboard', slug), so this INSERTs a
// NEW row, leaving the memory-dashboard row untouched.
await engine.putPage('rollback-drill/architecture', {
type: 'note',
title: 'Architecture (vanilla re-import)',
compiled_truth: 'Vanilla sync re-imported content lands here.',
});
// Step 3: assert both rows coexist; original fork content unchanged.
const rows = await engine.executeRaw<{ source_id: string; title: string; compiled_truth: string }>(
`SELECT source_id, title, compiled_truth FROM pages
WHERE slug = 'rollback-drill/architecture'
ORDER BY source_id`,
);
expect(rows.length).toBe(2);
const defaultRow = rows.find(r => r.source_id === 'default');
const mdRow = rows.find(r => r.source_id === 'memory-dashboard');
expect(defaultRow).toBeDefined();
expect(mdRow).toBeDefined();
// IRON RULE: original non-default row content unchanged.
expect(mdRow!.title).toBe('Architecture (fork-written)');
expect(mdRow!.compiled_truth).toBe('Original fork content under memory-dashboard.');
// Side-effect: vanilla wrote its content into default. Acceptable.
expect(defaultRow!.title).toBe('Architecture (vanilla re-import)');
});
test('Vanilla writes do not corrupt cross-source slug isolation across multiple sources', async () => {
await engine.putPage('rollback-drill/notes', {
type: 'note',
title: 'Notes in MD',
compiled_truth: 'memory-dashboard content',
source_id: 'memory-dashboard',
});
await engine.putPage('rollback-drill/notes', {
type: 'note',
title: 'Notes in SD',
compiled_truth: 'stock-dashboard content',
source_id: 'stock-dashboard',
});
await engine.putPage('rollback-drill/notes', {
type: 'note',
title: 'Notes (vanilla)',
compiled_truth: 'Vanilla content lands at default.',
});
const rows = await engine.executeRaw<{ source_id: string; title: string }>(
`SELECT source_id, title FROM pages
WHERE slug = 'rollback-drill/notes'
ORDER BY source_id`,
);
expect(rows.length).toBe(3);
expect(rows.find(r => r.source_id === 'memory-dashboard')!.title).toBe('Notes in MD');
expect(rows.find(r => r.source_id === 'stock-dashboard')!.title).toBe('Notes in SD');
expect(rows.find(r => r.source_id === 'default')!.title).toBe('Notes (vanilla)');
});
test('IRON RULE: composite UNIQUE (source_id, slug) constraint installed (the schema backstop)', async () => {
// Belt-and-suspenders: confirm the schema constraint backing the safety
// property. If composite UNIQUE were ever loosened back to plain
// UNIQUE(slug), vanilla sync's UPSERT would clobber non-default rows.
const rows = await engine.executeRaw<{ conname: string; constraint_def: string }>(
`SELECT conname, pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint
WHERE conrelid = 'pages'::regclass
AND contype = 'u'`,
);
const composite = rows.find(r => r.conname === 'pages_source_slug_key');
expect(composite).toBeDefined();
expect(composite!.constraint_def).toContain('source_id');
expect(composite!.constraint_def).toContain('slug');
// No competing global UNIQUE(slug) should remain post-v17.
const globalUniq = rows.filter(
r => /\(\s*slug\s*\)/.test(r.constraint_def) && !r.constraint_def.includes('source_id'),
);
expect(globalUniq.length).toBe(0);
});
test('Additional safety surface: vanilla full-flow re-import on multi-source slug bails out (transaction rollback)', async () => {
// Document a SECONDARY safety property exposed during this drill:
// vanilla's importFromContent → tx.getTags(slug) uses a slug-only
// subquery. On a multi-source same-slug brain, that subquery returns
// multiple page_ids → SQL 21000 → transaction rollback. Net effect:
// vanilla cannot write through importFromContent on these slugs, so
// even if the operator tries to sync after rollback, multi-source rows
// are physically prevented from being touched.
//
// This is GOOD news for safety, BAD news for ergonomics — vanilla
// operator must either (a) accept the slug is "frozen" until
// forking again, OR (b) manually remove cross-source data first.
await engine.putPage('rollback-drill/blocked', {
type: 'note',
title: 'In MD',
compiled_truth: 'fork-written',
source_id: 'memory-dashboard',
});
// Add a tag so getTags has data to attempt to read.
await engine.addTag('rollback-drill/blocked', 'docs', 'memory-dashboard');
let threw = false;
try {
await importFromContent(
engine,
'rollback-drill/blocked',
`---
title: Vanilla attempt
type: note
---
Should fail in tag reconciliation.
`,
{ noEmbed: true /* no sourceId — vanilla code path */ },
);
} catch (e) {
threw = true;
const msg = e instanceof Error ? e.message : String(e);
// PGLite error wrapper or driver string — match the SQL state.
expect(msg.toLowerCase()).toContain('subquery');
}
expect(threw).toBe(true);
// Original row intact (transaction rolled back).
const intact = await engine.executeRaw<{ title: string; compiled_truth: string }>(
`SELECT title, compiled_truth FROM pages
WHERE source_id = 'memory-dashboard' AND slug = 'rollback-drill/blocked'`,
);
expect(intact.length).toBe(1);
expect(intact[0].title).toBe('In MD');
expect(intact[0].compiled_truth).toBe('fork-written');
});
});