mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +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>
246 lines
9.8 KiB
TypeScript
246 lines
9.8 KiB
TypeScript
/**
|
|
* v0.18.0 Step 9 — multi-source integration test against real PGLite.
|
|
*
|
|
* Exercises the full Step-1-through-Step-7 surface:
|
|
* - migration v16 seeds the default source with federated=true
|
|
* - migration v17 adds pages.source_id + composite UNIQUE
|
|
* - migration v18 adds links.resolution_type column
|
|
* - putPage implicitly targets the default source via the
|
|
* schema DEFAULT 'default' clause
|
|
* - raw INSERT can write pages to a non-default source and the
|
|
* composite UNIQUE allows same-slug pages across sources
|
|
* - sources CLI add/list/federate operations are reflected in DB
|
|
* - federated flag distinguishes unqualified-search-visibility
|
|
*
|
|
* PGLite-only (fast + zero deps). Real Postgres parity lives in
|
|
* test/e2e/mechanical.test.ts when DATABASE_URL is set.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { runSources } from '../src/commands/sources.ts';
|
|
import { resolveSourceId } from '../src/core/source-resolver.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ type: 'pglite' } as never);
|
|
await engine.initSchema();
|
|
}, 60_000); // OAuth v25 + full migration chain needs breathing room
|
|
|
|
afterAll(async () => {
|
|
if (engine) await engine.disconnect();
|
|
}, 60_000);
|
|
|
|
describe('v0.18.0 — sources table seeded with default row on fresh PGLite', () => {
|
|
test("sources('default') exists after initSchema + migration", async () => {
|
|
const rows = await engine.executeRaw<{ id: string; name: string; config: string | Record<string, unknown> }>(
|
|
`SELECT id, name, config FROM sources WHERE id = 'default'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].name).toBe('default');
|
|
const config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : rows[0].config;
|
|
expect(config.federated).toBe(true);
|
|
});
|
|
|
|
test('pages.source_id column exists with DEFAULT default', async () => {
|
|
const rows = await engine.executeRaw<{ column_default: string | null }>(
|
|
`SELECT column_default FROM information_schema.columns
|
|
WHERE table_name = 'pages' AND column_name = 'source_id'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
// PGLite normalizes the default literal.
|
|
expect(rows[0].column_default).toContain('default');
|
|
});
|
|
|
|
test('composite UNIQUE (source_id, slug) is installed', async () => {
|
|
const rows = await engine.executeRaw<{ conname: string }>(
|
|
`SELECT conname FROM pg_constraint WHERE conname = 'pages_source_slug_key'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('v0.18.0 — putPage implicitly writes to default source', () => {
|
|
test('putPage without explicit source → source_id = default', async () => {
|
|
await engine.putPage('topics/step9-auto', {
|
|
type: 'concept',
|
|
title: 'Step 9 Auto',
|
|
compiled_truth: 'Auto-defaulted to default source.',
|
|
});
|
|
const rows = await engine.executeRaw<{ source_id: string; slug: string }>(
|
|
`SELECT source_id, slug FROM pages WHERE slug = 'topics/step9-auto'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].source_id).toBe('default');
|
|
});
|
|
});
|
|
|
|
describe('v0.18.0 — composite UNIQUE allows same-slug across sources', () => {
|
|
test('same slug in two different sources coexists (regression: Codex critical)', async () => {
|
|
// Insert a second source via sources CLI.
|
|
await runSources(engine, ['add', 'testsrc', '--no-federated']);
|
|
|
|
// Sanity: default already has this slug from the previous test.
|
|
// Now write the same slug under testsrc via raw INSERT (putPage only
|
|
// targets default until a later step surfaces sourceId; raw INSERT is
|
|
// the "source-aware write" Step 5 continuation will add).
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash)
|
|
VALUES ('testsrc', 'topics/step9-auto', 'concept', 'Step 9 Auto (testsrc variant)',
|
|
'A different page with the same slug in a different source.',
|
|
'', '{}'::jsonb, 'hash2')`,
|
|
);
|
|
|
|
// Both rows must exist under the composite unique.
|
|
const rows = await engine.executeRaw<{ source_id: string; slug: string; title: string }>(
|
|
`SELECT source_id, slug, title FROM pages
|
|
WHERE slug = 'topics/step9-auto'
|
|
ORDER BY source_id`,
|
|
);
|
|
expect(rows.length).toBe(2);
|
|
expect(rows.map(r => r.source_id).sort()).toEqual(['default', 'testsrc']);
|
|
});
|
|
|
|
test('inserting THIRD row with same (source_id, slug) hits composite UNIQUE', async () => {
|
|
let err: Error | null = null;
|
|
try {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash)
|
|
VALUES ('testsrc', 'topics/step9-auto', 'concept', 'Dup attempt',
|
|
'Should fail', '', '{}'::jsonb, 'hash3')`,
|
|
);
|
|
} catch (e) {
|
|
err = e as Error;
|
|
}
|
|
expect(err).not.toBeNull();
|
|
expect(err!.message.toLowerCase()).toMatch(/unique|duplicate/);
|
|
});
|
|
});
|
|
|
|
describe('v0.18.0 — sources CLI manipulates the sources table', () => {
|
|
test('sources federate flips config.federated true', async () => {
|
|
await runSources(engine, ['federate', 'testsrc']);
|
|
const rows = await engine.executeRaw<{ config: string | Record<string, unknown> }>(
|
|
`SELECT config FROM sources WHERE id = 'testsrc'`,
|
|
);
|
|
const config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : rows[0].config;
|
|
expect(config.federated).toBe(true);
|
|
});
|
|
|
|
test('sources unfederate flips config.federated false', async () => {
|
|
await runSources(engine, ['unfederate', 'testsrc']);
|
|
const rows = await engine.executeRaw<{ config: string | Record<string, unknown> }>(
|
|
`SELECT config FROM sources WHERE id = 'testsrc'`,
|
|
);
|
|
const config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : rows[0].config;
|
|
expect(config.federated).toBe(false);
|
|
});
|
|
|
|
test('sources rename changes name but keeps id immutable', async () => {
|
|
await runSources(engine, ['rename', 'testsrc', 'Test Source']);
|
|
const rows = await engine.executeRaw<{ id: string; name: string }>(
|
|
`SELECT id, name FROM sources WHERE id = 'testsrc'`,
|
|
);
|
|
expect(rows[0].id).toBe('testsrc');
|
|
expect(rows[0].name).toBe('Test Source');
|
|
});
|
|
});
|
|
|
|
describe('v0.18.0 — source resolution priority (integration)', () => {
|
|
test('explicit --source flag wins when the source exists', async () => {
|
|
const id = await resolveSourceId(engine, 'testsrc');
|
|
expect(id).toBe('testsrc');
|
|
});
|
|
|
|
test('GBRAIN_SOURCE env wins when no flag', async () => {
|
|
process.env.GBRAIN_SOURCE = 'testsrc';
|
|
try {
|
|
const id = await resolveSourceId(engine, null);
|
|
expect(id).toBe('testsrc');
|
|
} finally {
|
|
delete process.env.GBRAIN_SOURCE;
|
|
}
|
|
});
|
|
|
|
test('fallback to default when nothing is set', async () => {
|
|
const id = await resolveSourceId(engine, null, '/nowhere-registered');
|
|
expect(id).toBe('default');
|
|
});
|
|
|
|
test('rejects unregistered explicit source with an actionable error', async () => {
|
|
await expect(resolveSourceId(engine, 'ghost-source')).rejects.toThrow(/not found/);
|
|
});
|
|
});
|
|
|
|
describe('v0.18.0 — sources remove cascades to pages', () => {
|
|
test('removing a source cascade-deletes its pages', async () => {
|
|
const before = await engine.executeRaw<{ n: number }>(
|
|
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = 'testsrc'`,
|
|
);
|
|
expect(before[0].n).toBeGreaterThan(0);
|
|
|
|
// v0.26.5: populated sources require --confirm-destructive; --yes alone is rejected.
|
|
await runSources(engine, ['remove', 'testsrc', '--confirm-destructive']);
|
|
|
|
const after = await engine.executeRaw<{ n: number }>(
|
|
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = 'testsrc'`,
|
|
);
|
|
expect(after[0].n).toBe(0);
|
|
|
|
const src = await engine.executeRaw<{ id: string }>(
|
|
`SELECT id FROM sources WHERE id = 'testsrc'`,
|
|
);
|
|
expect(src.length).toBe(0);
|
|
|
|
// Default source is untouched.
|
|
const defaultPages = await engine.executeRaw<{ n: number }>(
|
|
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = 'default'`,
|
|
);
|
|
expect(defaultPages[0].n).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('v0.18.0 — links.resolution_type column exists (Step 4)', () => {
|
|
test('links table accepts qualified/unqualified resolution_type', async () => {
|
|
// Create two pages, insert a link with resolution_type='qualified'.
|
|
await engine.putPage('topics/qf-a', {
|
|
type: 'concept', title: 'QA', compiled_truth: 'a',
|
|
});
|
|
await engine.putPage('topics/qf-b', {
|
|
type: 'concept', title: 'QB', compiled_truth: 'b',
|
|
});
|
|
await engine.executeRaw(
|
|
`INSERT INTO links (from_page_id, to_page_id, link_type, context, link_source, resolution_type)
|
|
SELECT a.id, b.id, 'ref', '', 'markdown', 'qualified'
|
|
FROM pages a, pages b
|
|
WHERE a.slug = 'topics/qf-a' AND b.slug = 'topics/qf-b'
|
|
AND a.source_id = 'default' AND b.source_id = 'default'`,
|
|
);
|
|
const rows = await engine.executeRaw<{ resolution_type: string }>(
|
|
`SELECT l.resolution_type
|
|
FROM links l
|
|
JOIN pages a ON a.id = l.from_page_id
|
|
WHERE a.slug = 'topics/qf-a'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].resolution_type).toBe('qualified');
|
|
});
|
|
|
|
test('links CHECK constraint rejects invalid resolution_type values', async () => {
|
|
let err: Error | null = null;
|
|
try {
|
|
await engine.executeRaw(
|
|
`INSERT INTO links (from_page_id, to_page_id, link_type, resolution_type)
|
|
SELECT a.id, a.id, 'self', 'bogus-value'
|
|
FROM pages a WHERE a.slug = 'topics/qf-a' AND a.source_id = 'default'`,
|
|
);
|
|
} catch (e) {
|
|
err = e as Error;
|
|
}
|
|
expect(err).not.toBeNull();
|
|
expect(err!.message.toLowerCase()).toMatch(/check|constraint/);
|
|
});
|
|
});
|