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:
214
test/vanilla-rollback-safety.test.ts
Normal file
214
test/vanilla-rollback-safety.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user