Files
gbrain/test/sql-ranking.test.ts
T
0de9eb68ba v0.26.5 feat: destructive operation guard end-to-end (sources + pages + autopilot purge) (#600)
* 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>
2026-05-03 20:41:39 -07:00

294 lines
11 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import {
buildSourceFactorCase,
buildHardExcludeClause,
buildVisibilityClause,
__test__,
} from '../src/core/search/sql-ranking.ts';
import {
DEFAULT_SOURCE_BOOSTS,
DEFAULT_HARD_EXCLUDES,
parseSourceBoostEnv,
parseHardExcludesEnv,
resolveBoostMap,
resolveHardExcludes,
} from '../src/core/search/source-boost.ts';
const { escapeLikePattern, escapeSqlLiteral, buildLikePrefixLiteral } = __test__;
describe('escapeLikePattern', () => {
test('escapes %', () => {
expect(escapeLikePattern('foo%bar')).toBe('foo\\%bar');
});
test('escapes _', () => {
expect(escapeLikePattern('foo_bar')).toBe('foo\\_bar');
});
test('escapes \\ (Postgres LIKE default escape char)', () => {
expect(escapeLikePattern('foo\\bar')).toBe('foo\\\\bar');
});
test('escapes all three together', () => {
expect(escapeLikePattern('a%b_c\\d')).toBe('a\\%b\\_c\\\\d');
});
test('leaves plain strings untouched', () => {
expect(escapeLikePattern('originals/talks/')).toBe('originals/talks/');
});
});
describe('escapeSqlLiteral', () => {
test('doubles single quotes', () => {
expect(escapeSqlLiteral("O'Brien")).toBe("O''Brien");
});
test('handles SQL injection attempts as literal data', () => {
// Classic injection pattern is rendered harmless because the doubled
// quote keeps it inside a string literal in the emitted SQL.
expect(escapeSqlLiteral("'; DROP TABLE pages; --")).toBe("''; DROP TABLE pages; --");
});
});
describe('buildLikePrefixLiteral', () => {
test('produces a quoted LIKE pattern with trailing %', () => {
expect(buildLikePrefixLiteral('originals/')).toBe("'originals/%'");
});
test('escapes meta-chars before adding the trailing %', () => {
// Input contains a literal % that should be escaped, and the trailing
// % we add is the LIKE wildcard.
expect(buildLikePrefixLiteral('weird%path/')).toBe("'weird\\%path/%'");
});
test('escapes single-quote in prefix as SQL literal', () => {
expect(buildLikePrefixLiteral("o'brien/")).toBe("'o''brien/%'");
});
});
describe('buildSourceFactorCase', () => {
test('returns plain 1.0 when detail is "high" (temporal bypass)', () => {
const result = buildSourceFactorCase('p.slug', { 'originals/': 1.5 }, 'high');
expect(result).toBe('1.0');
});
test('temporal bypass tolerates uppercase / whitespace from MCP boundary', () => {
// Agents passing JSON over MCP can send "HIGH" or "high " (trailing
// space). The bypass must catch these — otherwise loose-string callers
// silently get boosted ranking instead of temporal bypass.
const map = { 'originals/': 1.5 };
expect(buildSourceFactorCase('p.slug', map, 'HIGH' as 'high')).toBe('1.0');
expect(buildSourceFactorCase('p.slug', map, 'high ' as 'high')).toBe('1.0');
expect(buildSourceFactorCase('p.slug', map, ' High ' as 'high')).toBe('1.0');
});
test('returns plain 1.0 when boost map is empty', () => {
expect(buildSourceFactorCase('p.slug', {}, 'medium')).toBe('1.0');
});
test('emits a CASE expression for non-high detail', () => {
const result = buildSourceFactorCase('p.slug', { 'originals/': 1.5 }, 'medium');
expect(result).toBe("(CASE WHEN p.slug LIKE 'originals/%' THEN 1.5 ELSE 1.0 END)");
});
test('sorts prefixes by length descending so longest-match wins', () => {
const result = buildSourceFactorCase(
'p.slug',
{ 'media/': 0.9, 'media/articles/': 1.1, 'media/x/': 0.7 },
'medium',
);
// Longest first: media/articles/ (15), media/x/ (8), media/ (6)
const m = result.match(/LIKE '([^']+)%'/g);
expect(m).toEqual([
"LIKE 'media/articles/%'",
"LIKE 'media/x/%'",
"LIKE 'media/%'",
]);
});
test('detail=low and detail=undefined both emit the boost CASE', () => {
const map = { 'originals/': 1.5 };
expect(buildSourceFactorCase('p.slug', map, 'low')).toContain('CASE WHEN');
expect(buildSourceFactorCase('p.slug', map, undefined)).toContain('CASE WHEN');
});
test('rejects non-finite or negative factors', () => {
const result = buildSourceFactorCase(
'p.slug',
{ 'good/': 1.5, 'nan/': NaN, 'neg/': -1, 'inf/': Infinity },
'medium',
);
expect(result).toBe("(CASE WHEN p.slug LIKE 'good/%' THEN 1.5 ELSE 1.0 END)");
});
test('uses the supplied slug column reference', () => {
expect(buildSourceFactorCase('slug', { 'originals/': 1.5 }, 'medium'))
.toContain('WHEN slug LIKE');
});
});
describe('buildHardExcludeClause', () => {
test('returns empty string when prefixes is empty', () => {
expect(buildHardExcludeClause('p.slug', [])).toBe('');
});
test('emits NOT (col LIKE ... OR col LIKE ...)', () => {
const result = buildHardExcludeClause('p.slug', ['test/', 'archive/']);
expect(result).toBe(`AND NOT (p.slug LIKE 'test/%' OR p.slug LIKE 'archive/%')`);
});
test('escapes %, _, and \\ as LIKE meta-characters', () => {
// CEO pass 4 + codex finding: backslash is Postgres LIKE's default escape char.
// A literal backslash in a user-supplied prefix must be escaped to \\ so
// it's treated as data, not as "escape the next char".
const result = buildHardExcludeClause('p.slug', ['weird\\path/']);
expect(result).toBe(`AND NOT (p.slug LIKE 'weird\\\\path/%')`);
});
test('treats SQL-injection-style input as literal', () => {
const result = buildHardExcludeClause('p.slug', ["'; DROP TABLE pages; --"]);
// Single quotes get doubled — the injection becomes inert text inside
// the string literal.
expect(result).toContain("''; DROP TABLE pages; --");
// Sanity: the structure of the clause is intact.
expect(result).toMatch(/^AND NOT \(p\.slug LIKE '.*%'\)$/);
});
test('skips empty-string prefixes', () => {
const result = buildHardExcludeClause('p.slug', ['test/', '', 'archive/']);
// Two LIKE clauses, one OR.
expect((result.match(/LIKE/g) || []).length).toBe(2);
});
});
describe('parseSourceBoostEnv', () => {
test('parses comma-separated prefix:factor pairs', () => {
expect(parseSourceBoostEnv('originals/:1.8,openclaw/chat/:0.3'))
.toEqual({ 'originals/': 1.8, 'openclaw/chat/': 0.3 });
});
test('returns empty object for undefined or empty', () => {
expect(parseSourceBoostEnv(undefined)).toEqual({});
expect(parseSourceBoostEnv('')).toEqual({});
});
test('skips malformed entries', () => {
expect(parseSourceBoostEnv('bogus,no-colon,originals/:abc,valid/:1.5'))
.toEqual({ 'valid/': 1.5 });
});
test('rejects negative factors', () => {
expect(parseSourceBoostEnv('foo/:-1.0,bar/:0.5')).toEqual({ 'bar/': 0.5 });
});
test('accepts factor=0 (legal but performance-inferior to hard-exclude)', () => {
expect(parseSourceBoostEnv('foo/:0')).toEqual({ 'foo/': 0 });
});
test('uses last colon to separate prefix from factor', () => {
// Edge case: someone puts a colon inside the prefix. Last colon wins.
expect(parseSourceBoostEnv('foo:bar/:1.5')).toEqual({ 'foo:bar/': 1.5 });
});
});
describe('parseHardExcludesEnv', () => {
test('parses comma-separated prefixes', () => {
expect(parseHardExcludesEnv('test/,scratch/,private/'))
.toEqual(['test/', 'scratch/', 'private/']);
});
test('returns empty array for undefined', () => {
expect(parseHardExcludesEnv(undefined)).toEqual([]);
});
test('trims whitespace and drops empty entries', () => {
expect(parseHardExcludesEnv(' test/ , , scratch/ ')).toEqual(['test/', 'scratch/']);
});
});
describe('resolveBoostMap', () => {
test('returns defaults when env is unset', () => {
expect(resolveBoostMap(undefined)).toEqual(DEFAULT_SOURCE_BOOSTS);
});
test('env override takes precedence over defaults', () => {
const merged = resolveBoostMap('originals/:99');
expect(merged['originals/']).toBe(99);
// Other defaults still present.
expect(merged['concepts/']).toBe(DEFAULT_SOURCE_BOOSTS['concepts/']);
});
test('env-only entries are added on top of defaults', () => {
const merged = resolveBoostMap('newprefix/:2.5');
expect(merged['newprefix/']).toBe(2.5);
expect(merged['originals/']).toBe(DEFAULT_SOURCE_BOOSTS['originals/']);
});
});
describe('resolveHardExcludes', () => {
test('returns defaults when nothing is overridden', () => {
const r = resolveHardExcludes(undefined, undefined, undefined);
for (const p of DEFAULT_HARD_EXCLUDES) expect(r).toContain(p);
});
test('caller exclude_slug_prefixes adds to the union', () => {
const r = resolveHardExcludes(['scratch/'], undefined, undefined);
expect(r).toContain('scratch/');
expect(r).toContain('test/'); // default still present
});
test('include_slug_prefixes opts back in', () => {
const r = resolveHardExcludes(undefined, ['test/'], undefined);
expect(r).not.toContain('test/');
// Other defaults still present.
expect(r).toContain('archive/');
});
test('env GBRAIN_SEARCH_EXCLUDE adds to the union', () => {
const r = resolveHardExcludes(undefined, undefined, 'envdir/');
expect(r).toContain('envdir/');
});
test('include subtracts from env-supplied excludes too', () => {
const r = resolveHardExcludes(undefined, ['envdir/'], 'envdir/');
expect(r).not.toContain('envdir/');
});
});
// v0.26.5 — visibility clause for soft-deleted pages and archived sources.
describe('buildVisibilityClause (v0.26.5)', () => {
test('emits both predicates joined by AND with a leading AND', () => {
const clause = buildVisibilityClause('p', 's');
// Leading AND so callers can splice unconditionally.
expect(clause.startsWith('AND ')).toBe(true);
// Both predicates present: page-level deleted_at IS NULL + source-level NOT archived.
expect(clause).toContain('p.deleted_at IS NULL');
expect(clause).toContain('NOT s.archived');
});
test('uses the supplied aliases verbatim', () => {
expect(buildVisibilityClause('pp', 'src')).toBe('AND pp.deleted_at IS NULL AND NOT src.archived');
});
test('does NOT bypass on detail level — visibility is a contract, not a temporal preference', () => {
// Distinct from buildSourceFactorCase: there's no detail-gated short-circuit.
// Soft-deleted content stays hidden regardless of caller's detail level.
// Function signature has no detail param at all; this test pins that contract.
expect(buildVisibilityClause.length).toBe(2);
});
test('emits a stable string regardless of call order (idempotent for snapshot tests)', () => {
const a = buildVisibilityClause('p', 's');
const b = buildVisibilityClause('p', 's');
expect(a).toBe(b);
});
test('produces no JSONB containment in the output (column-based, not @>)', () => {
// Issue 5 contract: archived was promoted from JSONB key to real column.
// The visibility clause must not regress to JSONB containment.
const clause = buildVisibilityClause('p', 's');
expect(clause).not.toContain('@>');
expect(clause).not.toContain('config');
});
});