mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(v0.26.5): destructive operation guard — impact preview, confirmation gate, soft-delete
Three-layer protection against accidental data loss:
1. **Impact preview**: Every destructive operation (sources remove, purge)
now shows a formatted preview of exactly what will be destroyed —
page count, chunk count, embedding count, file count — BEFORE acting.
2. **--confirm-destructive flag**: `--yes` alone is no longer sufficient
when a source has data. Must pass `--confirm-destructive` to proceed
with permanent deletion. Prevents scripted/reflexive destroys.
3. **Soft-delete with 72h TTL**: New `gbrain sources archive <id>`
hides a source from search and federation without destroying any data.
Data preserved for 72 hours. Restorable via `gbrain sources restore <id>`.
Expired archives purged via `gbrain sources purge`.
New subcommands:
- `gbrain sources archive <id>` — soft-delete (hide, preserve 72h)
- `gbrain sources restore <id>` — un-archive, re-federate
- `gbrain sources archived` — list soft-deleted sources + TTL
- `gbrain sources purge [<id>] [--confirm-destructive]` — permanent delete
Behavioral changes:
- `sources remove` with data now requires `--confirm-destructive` (not just `--yes`)
- `sources remove --dry-run` shows full impact preview without side effects
- Impact box format shows source name, id, and all cascade counts
New files:
- src/core/destructive-guard.ts — impact assessment, confirmation gate,
soft-delete/restore/purge logic, display formatters
* chore(release): v0.26.5 — destructive operation guard
Bump VERSION + package.json to 0.26.5 and add the v0.26.5 CHANGELOG entry
on top of the destructive-guard feature commit cherry-picked from PR #595.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.26.5): page-level soft-delete + autopilot purge + search visibility
Closes the destructive-guard posture across every gbrain destructive surface.
PR #595 cherry-pick covered the CLI source-remove path; this commit closes
the higher-velocity MCP `delete_page` agent footgun and the three internal
correctness gaps the CEO+Eng review surfaced:
- Gap 1: archived sources were not actually filtered from search. Now they
are, via `buildVisibilityClause` in `searchKeyword`/`searchKeywordChunks`/
`searchVector` for both engines.
- Gap 2: 72h TTL was honor-system. Now wired into a new autopilot `purge`
phase (9th in ALL_PHASES) that calls `purgeExpiredSources` + `engine.
purgeDeletedPages(72)`. Manual escape hatch: `gbrain pages purge-deleted`.
- Gap 3: zero tests for safety-critical code. ~30 cases now in
`test/destructive-guard.test.ts`, `test/pages-soft-delete.test.ts`, and
`test/sql-ranking.test.ts` covering the boundary truth table, JSONB→column
migration, soft-delete/restore/purge round-trip, multi-source isolation,
cascade verification, and the Q3 IRON-rule contract test.
Schema migration v33 (`destructive_guard_columns`): adds `pages.deleted_at`
+ partial purge index, promotes `archived` from `sources.config` JSONB to
real columns (`sources.archived BOOLEAN`, `archived_at`, `archive_expires_at`),
backfills any pre-v0.26.5 JSONB shape. Engine-aware: Postgres uses CREATE
INDEX CONCURRENTLY, PGLite uses plain CREATE INDEX. Forward-reference
bootstrap extended in both engines so pre-v0.26.5 brains don't crash on the
embedded-schema replay.
BrainEngine surface: new `softDeletePage` / `restorePage` /
`purgeDeletedPages` methods + `includeDeleted` flag on `getPage`/`listPages`.
MCP ops: `delete_page` rewired to soft-delete (description string updated);
new `restore_page` (scope: write) + `purge_deleted_pages` (scope: admin,
localOnly: true).
Q3 contract (eng-review lynchpin): `get_page(slug)` returns null for
soft-deleted by default; `get_page(slug, {include_deleted: true})` surfaces
the row with `deleted_at` populated. Same flag for `list_pages`. Mirrors
the search-filter contract end-to-end.
Issue 5 (eng-review): `archived` is now a real column on `sources`, not a
JSONB key. No reserved-key footgun. Faster filter. Visibility clause
compiles to a column lookup, not JSONB containment.
Verification:
- bun run typecheck: PASS
- bun run build:schema + bun run build:llms: regenerated
- targeted test runs: 90 pass / 0 fail across destructive-guard,
pages-soft-delete, sql-ranking, schema-bootstrap-coverage, build-llms
- full bun test: 16 pre-existing failures inherited from v0.26.2 (sync,
sync-parallel, queue-child-done, etc — already filed in TODOS.md as
"Fix 22 pre-existing test failures unrelated to OAuth")
CHANGELOG, CLAUDE.md (Key Files + Commands), TODOS.md updated. The plan
file at ~/.claude/plans/take-a-look-and-gentle-pine.md captures the full
review trail (CEO=C, Eng-Q3=A, Eng-Issue5=a, 8 defaults applied).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v0.26.5): CI fallout — getStats excludes soft-deleted; tests use --confirm-destructive
Two CI failures from the v0.26.5 ship:
1. **Tier 1 (Postgres E2E):** `E2E: Page CRUD > delete_page removes page and
others survive` failed because `delete_page` now soft-deletes (sets
deleted_at) but `getStats.page_count` was still counting all rows. The
test seeds 16 pages, deletes one, and asserts page_count is 15. Fix:
`getStats` now filters `WHERE deleted_at IS NULL` for page_count in both
engines. This matches the visibility-filter contract — soft-deleted pages
are hidden everywhere the user looks (search, get_page, list_pages, stats).
Chunks and links stay raw because they still occupy storage until the
autopilot purge phase runs.
2. **Test 2 (PGLite unit):** `multi-source-integration.test.ts:184` and
`e2e/multi-source.test.ts:274` called `runSources(engine, ['remove', X,
'--yes'])` against populated sources. v0.26.5's destructive guard rejects
`--yes` alone on populated sources and calls `process.exit(5)`, which
killed the bun test runner mid-suite (CI exit 5). Both test sites now
pass `--confirm-destructive` per the v0.26.5 contract.
Verification: 115/0 pass across destructive-guard, pages-soft-delete,
sql-ranking, schema-bootstrap-coverage, sources, repos-alias, and
multi-source-integration test files. typecheck PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): cycle phase count is 9 (v0.26.5 added `purge` phase)
CI failure: `runCycle — yieldBetweenPhases hook` tests asserted exactly 8
phases. v0.26.5 added the autopilot `purge` phase as the 9th, so:
- `test/core/cycle.test.ts:381` — `hookCalls` is now 9 (one yield per phase)
- `test/core/cycle.test.ts:392` — `report.phases.length` is now 9
- `test/e2e/cycle.test.ts:101` — same update for the dry-run E2E
The `purge` phase invocation was already visible in the failing log output:
the cycle ran 9 phases end-to-end; the test assertions hadn't been updated.
Verification: bun run typecheck PASS. cycle.test.ts: 28/0 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
/**
|
|
* v0.26.5 — page-level soft-delete contract tests.
|
|
*
|
|
* IRON RULE regression test for Q3 (the lynchpin eng-review decision):
|
|
* delete_page → get_page returns null → get_page({include_deleted:true}) returns
|
|
* the row with deleted_at populated → restore_page → get_page returns the row
|
|
* again with deleted_at unset.
|
|
*
|
|
* Plus: BrainEngine surface tests (softDeletePage / restorePage /
|
|
* purgeDeletedPages) for happy-path / boundary / cascade cases.
|
|
*
|
|
* Runs against PGLite — same SQL contract as Postgres but DATABASE_URL-free.
|
|
* Postgres-specific paths (CONCURRENTLY index, two-stage CTE) covered by
|
|
* separate Postgres E2E tests.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
|
|
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
|
|
|
async function setupBrain(): Promise<PGLiteEngine> {
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
return engine;
|
|
}
|
|
|
|
async function seedPage(engine: PGLiteEngine, slug: string): Promise<void> {
|
|
await engine.putPage(slug, {
|
|
type: 'note' as any,
|
|
title: slug,
|
|
compiled_truth: `Content of ${slug}`,
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
}
|
|
|
|
describe('softDeletePage', () => {
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = await setupBrain();
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
test('happy path: sets deleted_at and returns slug', async () => {
|
|
await seedPage(engine, 'people/alice');
|
|
const result = await engine.softDeletePage('people/alice');
|
|
expect(result).not.toBeNull();
|
|
expect(result!.slug).toBe('people/alice');
|
|
// The row stays in the DB.
|
|
const rows = await engine.executeRaw<{ deleted_at: string | null }>(
|
|
`SELECT deleted_at FROM pages WHERE slug = $1`,
|
|
['people/alice'],
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].deleted_at).not.toBeNull();
|
|
});
|
|
|
|
test('returns null for unknown slug (idempotent-as-null)', async () => {
|
|
expect(await engine.softDeletePage('does/not/exist')).toBeNull();
|
|
});
|
|
|
|
test('returns null on already-soft-deleted page (idempotent-as-null)', async () => {
|
|
await seedPage(engine, 'people/bob');
|
|
const first = await engine.softDeletePage('people/bob');
|
|
expect(first).not.toBeNull();
|
|
const second = await engine.softDeletePage('people/bob');
|
|
expect(second).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('restorePage', () => {
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = await setupBrain();
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
test('clears deleted_at on a soft-deleted page', async () => {
|
|
await seedPage(engine, 'people/carol');
|
|
await engine.softDeletePage('people/carol');
|
|
expect(await engine.restorePage('people/carol')).toBe(true);
|
|
const rows = await engine.executeRaw<{ deleted_at: string | null }>(
|
|
`SELECT deleted_at FROM pages WHERE slug = $1`,
|
|
['people/carol'],
|
|
);
|
|
expect(rows[0].deleted_at).toBeNull();
|
|
});
|
|
|
|
test('returns false for unknown slug', async () => {
|
|
expect(await engine.restorePage('does/not/exist')).toBe(false);
|
|
});
|
|
|
|
test('returns false on already-active page (idempotent-as-false)', async () => {
|
|
await seedPage(engine, 'people/dave');
|
|
expect(await engine.restorePage('people/dave')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('purgeDeletedPages (TTL boundary)', () => {
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = await setupBrain();
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
test('purges pages whose deleted_at is older than the cutoff', async () => {
|
|
await seedPage(engine, 'people/eve');
|
|
await seedPage(engine, 'people/frank');
|
|
// Soft-delete both, then push one's deleted_at into the distant past.
|
|
await engine.softDeletePage('people/eve');
|
|
await engine.softDeletePage('people/frank');
|
|
await engine.executeRaw(
|
|
`UPDATE pages SET deleted_at = now() - INTERVAL '73 hours' WHERE slug = $1`,
|
|
['people/eve'],
|
|
);
|
|
const result = await engine.purgeDeletedPages(72);
|
|
expect(result.count).toBe(1);
|
|
expect(result.slugs).toContain('people/eve');
|
|
expect(result.slugs).not.toContain('people/frank');
|
|
// 'eve' is gone; 'frank' is still there (still inside recovery window).
|
|
const rows = await engine.executeRaw<{ slug: string }>(`SELECT slug FROM pages`);
|
|
const remaining = rows.map((r) => r.slug);
|
|
expect(remaining).not.toContain('people/eve');
|
|
expect(remaining).toContain('people/frank');
|
|
});
|
|
|
|
test('does NOT touch active pages (deleted_at IS NULL)', async () => {
|
|
// Bound to this test's seeded slug. Other tests in the same describe may
|
|
// have soft-deleted state laying around; we don't care about those, just
|
|
// that THIS test's active page is not deleted.
|
|
await seedPage(engine, 'people/grace-active');
|
|
await engine.purgeDeletedPages(0);
|
|
const rows = await engine.executeRaw<{ slug: string; deleted_at: string | null }>(
|
|
`SELECT slug, deleted_at FROM pages WHERE slug = $1`,
|
|
['people/grace-active'],
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].deleted_at).toBeNull();
|
|
});
|
|
|
|
test('cascades to content_chunks via FK ON DELETE CASCADE', async () => {
|
|
await seedPage(engine, 'people/heidi');
|
|
// Force-add a chunk row so we can observe cascade.
|
|
const pageRows = await engine.executeRaw<{ id: number }>(
|
|
`SELECT id FROM pages WHERE slug = $1`,
|
|
['people/heidi'],
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text, chunk_source) VALUES ($1, 0, 'test', 'compiled_truth')`,
|
|
[pageRows[0].id],
|
|
);
|
|
await engine.softDeletePage('people/heidi');
|
|
await engine.executeRaw(
|
|
`UPDATE pages SET deleted_at = now() - INTERVAL '73 hours' WHERE slug = $1`,
|
|
['people/heidi'],
|
|
);
|
|
await engine.purgeDeletedPages(72);
|
|
const remaining = await engine.executeRaw<{ n: number }>(
|
|
`SELECT COUNT(*)::int AS n FROM content_chunks WHERE page_id = $1`,
|
|
[pageRows[0].id],
|
|
);
|
|
expect(remaining[0].n).toBe(0);
|
|
});
|
|
|
|
test('clamps negative hours to 0 (no crash, no future-cutoff explosion)', async () => {
|
|
await seedPage(engine, 'people/ivan');
|
|
await engine.softDeletePage('people/ivan');
|
|
// The contract being pinned: negative input must NOT pass through to the
|
|
// SQL as a literal negative interval (which would purge from the future
|
|
// and effectively delete every soft-deleted row). Implementation does
|
|
// `Math.max(0, Math.floor(olderThanHours))`, so -72 collapses to 0. With
|
|
// hours=0, the predicate `deleted_at < now()` may or may not match a row
|
|
// soft-deleted in the same statement (timing-dependent), so this test
|
|
// pins only the safety contract: it returns successfully with a finite
|
|
// count and doesn't blow up the brain.
|
|
const result = await engine.purgeDeletedPages(-72);
|
|
expect(result.count).toBeGreaterThanOrEqual(0);
|
|
expect(Number.isFinite(result.count)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getPage / listPages includeDeleted contract (Q3 IRON RULE)', () => {
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = await setupBrain();
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
test('Q3 round-trip: delete → get returns null → get(include_deleted) returns row → restore → get returns row again', async () => {
|
|
await seedPage(engine, 'people/judy');
|
|
|
|
// Step 1: page is visible by default.
|
|
const before = await engine.getPage('people/judy');
|
|
expect(before).not.toBeNull();
|
|
expect(before!.deleted_at).toBeFalsy();
|
|
|
|
// Step 2: soft-delete, default getPage returns null.
|
|
await engine.softDeletePage('people/judy');
|
|
const afterDelete = await engine.getPage('people/judy');
|
|
expect(afterDelete).toBeNull();
|
|
|
|
// Step 3: include_deleted: true surfaces the row with deleted_at populated.
|
|
const surfaced = await engine.getPage('people/judy', { includeDeleted: true });
|
|
expect(surfaced).not.toBeNull();
|
|
expect(surfaced!.deleted_at).toBeInstanceOf(Date);
|
|
|
|
// Step 4: restore → default getPage returns the row again.
|
|
expect(await engine.restorePage('people/judy')).toBe(true);
|
|
const restored = await engine.getPage('people/judy');
|
|
expect(restored).not.toBeNull();
|
|
expect(restored!.deleted_at).toBeFalsy();
|
|
});
|
|
|
|
test('listPages excludes soft-deleted by default', async () => {
|
|
await seedPage(engine, 'people/kim');
|
|
await seedPage(engine, 'people/larry');
|
|
await engine.softDeletePage('people/kim');
|
|
const pages = await engine.listPages({ limit: 100 });
|
|
const slugs = pages.map((p) => p.slug);
|
|
expect(slugs).not.toContain('people/kim');
|
|
expect(slugs).toContain('people/larry');
|
|
});
|
|
|
|
test('listPages includes soft-deleted when includeDeleted: true', async () => {
|
|
await seedPage(engine, 'people/mia');
|
|
await engine.softDeletePage('people/mia');
|
|
const pages = await engine.listPages({ limit: 100, includeDeleted: true });
|
|
const slugs = pages.map((p) => p.slug);
|
|
expect(slugs).toContain('people/mia');
|
|
const mia = pages.find((p) => p.slug === 'people/mia')!;
|
|
expect(mia.deleted_at).toBeInstanceOf(Date);
|
|
});
|
|
});
|
|
|
|
describe('search visibility (soft-deleted pages hidden from searchKeyword)', () => {
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = await setupBrain();
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
test('searchKeyword hides soft-deleted pages', async () => {
|
|
// Two pages, same distinctive term, then soft-delete one.
|
|
await engine.putPage('people/nora', {
|
|
type: 'note' as any,
|
|
title: 'Nora',
|
|
compiled_truth: 'gbrainquantum signature term occurs here',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
await engine.putPage('people/oscar', {
|
|
type: 'note' as any,
|
|
title: 'Oscar',
|
|
compiled_truth: 'gbrainquantum signature term occurs here too',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
// Force chunk creation so search has something to index.
|
|
await engine.upsertChunks('people/nora', [
|
|
{ chunk_index: 0, chunk_text: 'gbrainquantum signature term occurs here', chunk_source: 'compiled_truth' as any },
|
|
]);
|
|
await engine.upsertChunks('people/oscar', [
|
|
{ chunk_index: 0, chunk_text: 'gbrainquantum signature term occurs here too', chunk_source: 'compiled_truth' as any },
|
|
]);
|
|
|
|
const before = await engine.searchKeyword('gbrainquantum');
|
|
expect(before.length).toBe(2);
|
|
|
|
await engine.softDeletePage('people/nora');
|
|
const after = await engine.searchKeyword('gbrainquantum');
|
|
const slugs = after.map((r) => r.slug);
|
|
expect(slugs).not.toContain('people/nora');
|
|
expect(slugs).toContain('people/oscar');
|
|
});
|
|
|
|
test('searchKeyword hides pages from archived sources', async () => {
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ('archived-src', 'archived-src') ON CONFLICT DO NOTHING`,
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title) VALUES ('archived-src', 'archived-src/secret', 'note', 'Secret')`,
|
|
);
|
|
const pageRows = await engine.executeRaw<{ id: number }>(
|
|
`SELECT id FROM pages WHERE slug = 'archived-src/secret'`,
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text, chunk_source) VALUES ($1, 0, 'gbrainsemaphore unique term', 'compiled_truth')`,
|
|
[pageRows[0].id],
|
|
);
|
|
// Trigger should populate search_vector via the schema trigger.
|
|
const before = await engine.searchKeyword('gbrainsemaphore');
|
|
expect(before.length).toBe(1);
|
|
|
|
// Archive the source.
|
|
await engine.executeRaw(
|
|
`UPDATE sources SET archived = true, archived_at = now(), archive_expires_at = now() + INTERVAL '72 hours' WHERE id = 'archived-src'`,
|
|
);
|
|
const after = await engine.searchKeyword('gbrainsemaphore');
|
|
expect(after.length).toBe(0);
|
|
});
|
|
});
|