mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
* feat(engine): add deletePages + resolveSlugsByPaths to BrainEngine (v0.41.21.0 T1) Two new REQUIRED methods on the BrainEngine interface, implemented on both Postgres and PGLite engines. Closes the per-file N+1 query pattern that PR #1538 batched on Postgres only. deletePages(slugs: string[], opts: { sourceId: string }): Promise<string[]> — Single SQL round-trip: DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2 RETURNING slug — Returns slugs ACTUALLY DELETED (D6, codex CDX-8) so callers can filter pagesAffected to exclude phantom slugs (paths in the deletion list but with no DB row). — Single-batch primitive: caller chunks input to DELETE_BATCH_SIZE. Throws if input exceeds the cap. — sourceId is REQUIRED at the type level (D5, codex CDX-10). Asymmetric with single-row deletePage which keeps the optional 'default' fallback for back-compat. v0.42+ TODO to tighten. resolveSlugsByPaths(paths, opts): Promise<Map<path, slug>> — Batch path → slug lookup. Single SQL round-trip: SELECT slug, source_path FROM pages WHERE source_path = ANY($1::text[]) AND source_id = $2 — Missing paths absent from the Map (caller falls back to path-derived slug, same contract as resolveSlugByPathOrSourcePath). — Empty input short-circuits to empty Map (no SQL). src/core/engine-constants.ts (NEW) — Single source of truth for DELETE_BATCH_SIZE = 500. — Both engines import; no engine-from-engine coupling. — Lives outside engine.ts (the interface module) to avoid circular imports. Also updates the deletePage JSDoc (CDX-11): drops the misleading "hard delete is admin-only" framing. `gbrain sync` hard-deletes on every run that sees a deleted file; not admin-only. Co-Authored-By: garrytan-agents <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(sync): batched delete + rename + DRY refactor (v0.41.21.0 T2/T3/T4) Replaces the per-file delete loop (sync.ts:1241-1257) and per-file rename slug-resolve (sync.ts:1263-1295) with interleaved per-batch flows using engine.resolveSlugsByPaths + engine.deletePages. Also refactors resolveSlugByPathOrSourcePath (sync.ts:267) to delegate to the new batch helper when sourceId is set — one owner of the SQL + fallback semantics (D8). ROUND-TRIP COUNTS (73K-delete commit): pre-fix: 73,000 SELECTs + 73,000 DELETEs = 146,000 (~5 hours) post-fix: 146 SELECTs + 146 DELETEs = 292 (~2 minutes) Headline win: a single commit deleting 73K files no longer jams the sync pipeline for hours, no longer cascades staleness across every other source on the brain. Shape (T2 delete loop, per the plan's ASCII diagram): filtered.deleted (73K paths) │ ▼ slice into batches of DELETE_BATCH_SIZE (500) │ ▼ for each batch: abort-check ──► partial('timeout') │ ▼ engine.resolveSlugsByPaths(batch, {sourceId}) ◀── 1 SQL round-trip │ ▼ slugs = batch.map(path => map.get(path) ?? resolveSlugForPath(path)) ◀── pure-JS fallback │ for frontmatter- ▼ fallback slugs try { deleted = engine.deletePages(slugs, opts) ◀── 1 SQL round-trip pagesAffected.push(...deleted) ◀── D6 confirmed only } catch { // D7 decompose: per-slug deletePage, // unrecoverable failures → failedFiles } Per-batch try-catch (D7) decomposes batch DELETE failures to per-slug deletePage so a transient blip on batch 73 doesn't lose 500 deletes — it self-heals to one-at-a-time for that batch only. Unrecoverable per-slug failures land in failedFiles (matching the existing import-loop pattern at sync.ts:~1350). failedFiles declaration hoisted above the delete loop so both delete decompose and import loops feed the same sync-bookmark gate. T4 rename loop: pre-resolves all `from` slugs in batches via resolveSlugsByPaths BEFORE iterating. Per-file updateSlug + importFile calls stay (those are inherently per-file). The try/catch around updateSlug for slug-doesn't-exist preserves verbatim. T3 DRY refactor: resolveSlugByPathOrSourcePath delegates to resolveSlugsByPaths via a single-element array when sourceId is set. When sourceId is undefined (legacy unscoped callers), falls back to the original executeRaw shape — the batch engine surface requires sourceId per D5 (multi-source-bug-class defense). Atomicity coarsening (D3): each batch is one transaction. A mid-batch abort or connection failure rolls back up to DELETE_BATCH_SIZE - 1 successful deletes from the in-flight batch. Sync is idempotent so the next run picks them up via git diff regenerating the deletion list. Documented at the call site + in the deletePages JSDoc. Co-Authored-By: garrytan-agents <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(schema): global page-generation clock + statement-level trigger (v0.41.21.0 T5) Migration v104: page_generation_clock_and_statement_trigger. The pre-v0.41.21.0 query-cache Layer 1 bookmark read MAX(generation) FROM pages to detect "writes happened since cache-store". Two bugs in that contract — independent of any sync work, surfaced by codex outside-voice on the /plan-eng-review pass: 1. The row-level bump_page_generation_trg (migration v91) sets NEW.generation = OLD.generation + 1 on UPDATE. Updating a NON-MAX page didn't advance MAX(generation). Cache silently served stale for any UPDATE-to-non-max page. (CDX-2) 2. The trigger is BEFORE INSERT OR UPDATE — DELETE doesn't fire it at all. Even an AFTER DELETE wouldn't move MAX (surviving rows are untouched). (CDX-1) Fix: single-row page_generation_clock counter, bumped per-statement (FOR EACH STATEMENT — per-row would turn a 73K-row batch DELETE into 73K UPDATEs on the same counter, recreating the bottleneck this PR fixes elsewhere — codex CDX-4). Layer 1 reads the clock value directly (T6, separate commit). Per-row pages.generation stays for Layer 2 (per-page snapshot via jsonb_each + LEFT JOIN pages) which doesn't care about MAX, only per-page advancement. Seeded with COALESCE(MAX(pages.generation), 0) so existing query_cache rows stored under the old MAX semantics aren't all instantly invalidated on upgrade. Their max_generation_at_store stamp compares cleanly against the seeded clock; future writes bump the clock and the bookmark fires correctly. CREATE TABLE page_generation_clock ( id INTEGER PRIMARY KEY CHECK (id = 1), value BIGINT NOT NULL DEFAULT 0 ); CREATE TRIGGER bump_page_generation_clock_trg AFTER INSERT OR UPDATE OR DELETE ON pages FOR EACH STATEMENT EXECUTE FUNCTION bump_page_generation_clock_fn(); Mirror in src/core/pglite-schema.ts so fresh PGLite installs get the table + trigger via SCHEMA_SQL replay. The forward-reference bootstrap probe doesn't need an entry: page_generation_clock is created directly by SCHEMA_SQL (no separate index or FK references it), so the schema-bootstrap-coverage gate is satisfied as-is. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cache): move Layer 1 to global clock + invalidate empty snapshots (v0.41.21.0 T6) Closes the silent stale-cache bug class that's been live in master since the bookmark feature shipped. Pre-fix, gbrain search would silently serve stale cached results in three independent scenarios: 1. UPDATE to a non-max-generation page (CDX-2) — the row-level trigger advanced per-page generation but didn't move MAX(generation), so the bookmark passed. 2. DELETE of any page (CDX-1) — the trigger didn't fire at all, and even an AFTER DELETE wouldn't move MAX. 3. Empty-result cache row + subsequent matching INSERT (CDX-6 / D20) — page_generations = '{}'::jsonb was "vacuously valid" via Layer 2, surviving any clock bump. Fix: buildPageGenerationsSnapshot (store path) — Replaces the SELECT MAX(generation) FROM pages reads at cache-write time with SELECT value FROM page_generation_clock WHERE id = 1. — Empty pageIds path: only need the clock value (D20 contract). — Combined non-empty path: per-page generation (Layer 2 substrate) + clock value, both folded in one round trip via UNION ALL. CACHE_GATE_WHERE_CLAUSE (lookup path) — Layer 1 reads page_generation_clock.value (single-row O(1) lookup, faster than the pre-fix MAX(generation) backward index scan). — Layer 2 stricter: requires page_generations <> '{}'::jsonb AND the per-page check (not OR with the vacuously-valid `= '{}'` shortcut). Empty snapshots can no longer survive a Layer 1 miss. validateCacheRowAgainstPages (pure validator) — Layer 2 returns false for empty snapshots when Layer 1 fails. — Documented contract change. Backward compat: pre-v0.40.3.0 cache rows have max_generation_at_store = 0 AND page_generations = '{}'::jsonb. On a populated brain, Layer 1 fails (clock > 0). Layer 2 is now stricter so legacy rows invalidate once on first post-upgrade lookup, then the cache fills back correctly. Acceptable one-time miss spike; post-upgrade cache is structurally sound. The clock seed (COALESCE(MAX(pages.generation), 0)) from migration v104 keeps NON-empty legacy rows passing Layer 1 until the next write — they don't all invalidate at once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: cover v0.41.21.0 delete-batch + global clock + cache contract (T7+T8+T9) Tests for every behavior the v0.41.21.0 wave introduces or changes. New test files: test/sync-delete-batch.test.ts (PGLite hermetic) — engine.deletePages: empty input short-circuit, returns confirmed slugs (D6), multi-source isolation, cascade integrity (chunks + links cleared via FK), rejects oversized input. — engine.resolveSlugsByPaths: empty input, present + missing rows, D10 exotic-filename substrate (🌟.md / ทดสอบ.md / عربي.md), source isolation. — D13 pagesAffected filter: 100 deletable + 10 ghost paths → deletePages returns 100 (regression-pin: pre-fix would return all 110 via D6's pre-RETURNING shape). test/sync-delete-batch.slow.test.ts (.slow suffix keeps it out of the fast loop) — 10K-page batched delete completes in <5s on PGLite. Measured 277ms on dev hardware (18x under the gate); pins the headline perf promise. test/sync-rename-batch.test.ts (PGLite hermetic) — 500-rename batch slug-resolve in 1 round-trip (exactly at DELETE_BATCH_SIZE boundary). — Frontmatter-fallback rename: exotic source_paths resolve via the batch SELECT. — Mixed present + missing: partial Map (missing → caller falls back to path-derived). test/page-generation-counter.test.ts (PGLite hermetic) — Statement-level trigger fires once per INSERT statement (raw SQL — NOT putPage, which uses ON CONFLICT DO UPDATE and bumps by 2 in PG semantics). — Statement-level trigger fires once per UPDATE statement. — Headline contract: batch DELETE bumps clock by 1, NOT by row count (25-row batch → +1). — CDX-1 regression: DELETE of non-max page bumps clock. — CDX-2 regression: UPDATE of non-max page bumps clock (raw SQL). — D14 end-to-end: clock advances after batch DELETE → cache rows stamped at the prior clock value are now stale by Layer 1. — CDX-6/D20: empty-result cache + INSERT matching page → clock advances (Layer 1 fires). — Documents the PG quirk: putPage's INSERT...ON CONFLICT DO UPDATE bumps clock by 2 (both INSERT and UPDATE triggers fire). Test-helper update: test/helpers/reset-pglite.ts — Added page_generation_clock to PRESERVE_TABLES so the seeded single-row counter survives resetPgliteState between tests (same treatment as schema_version). Production never truncates. Existing test contract inversions (CDX-6 / D20 fix): test/query-cache-gate.test.ts — Pre-v0.41.21.0 "vacuously valid for legacy empty snapshot" assertion inverted: empty snapshot now invalidates when Layer 1 fires. Add positive CDX-6 regression test (empty-result + INSERT matching page). — SQL shape regression: page_generation_clock in Layer 1 (negative regression guard: MAX(generation) FROM pages MUST be gone). — Empty-snapshot reject guard: `qc.page_generations <> '{}'::jsonb` present; the old `qc.page_generations = '{}'::jsonb OR` shortcut MUST be gone. test/e2e/cache-gate-pglite.test.ts — Pre-v0.41.21.0 "legacy row serves vacuously" test inverted: legacy rows now invalidate on first clock advance post-upgrade. — CDX-1 regression: DELETE bumps clock → cached query for surviving pages invalidates. — CDX-2 regression: UPDATE-to-non-max-page bumps clock → cache invalidates. — CDX-11 comment fix: drop misleading "hard delete is admin-only" framing; gbrain sync hard-deletes on every run. Engine parity extension: test/e2e/engine-parity.test.ts — deletePages parity: same input set, both engines return same string[] of confirmed-deleted slugs (D6). — resolveSlugsByPaths parity: same Map on both engines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): v0.41.21.0 — batched sync deletes + global page-generation clock (T10) VERSION bump (0.41.18.0 → 0.41.21.0; master is at 0.41.20.0 so next free slot per the queue allocator). CHANGELOG entry with the ELI10 lead per CLAUDE.md voice rules. CLAUDE.md annotations on engine.ts, postgres-engine.ts, pglite-engine.ts, sync.ts, and query-cache-gate.ts plus a new entry for engine-constants.ts. llms-full.txt regenerated to match CLAUDE.md (per CLAUDE.md mandatory rule). Co-Authored-By: garrytan-agents <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * dx(test-runner): heartbeat shows real progress instead of 0p 0f Bun's default test reporter doesn't print per-test markers — only a single shard-end summary block when you pass it a file list. The existing heartbeat tried to count `^[[:space:]]+✓` lines as a live pass-count proxy, but bun never emits them in the multi-file mode this runner uses, so every mid-run heartbeat showed `0p 0f` for the entire 12-20 minute wallclock. Users (and agents polling the runner) couldn't distinguish "still bootstrapping" from "wedged" from "almost done." Fix: parse three complementary real-time signals instead. 1. Total files this shard was assigned — parsed from the `[unit-shard N/M] running X files` banner that run-unit-shard.sh echoes before invoking bun test. Available from second 1. 2. PGLite initSchema() count — proxy for "test files started so far." Each PGLite-using test file's beforeAll triggers one initSchema(), which logs `Schema version 1 → 106 (101 migration(s) pending)`. Undercounts because not every test file opens a PGLite engine (covers ~30-60% of files in practice), but it's the only real-time progress signal bun's default reporter leaves in the log. The output uses a `~` prefix to convey "approximate count." 3. Log size in KB — strictly monotonic liveness signal that works even when the PGLite count is still 0 (early-shard startup before the first initSchema fires). 4. Per-shard elapsed time — formatted as MmSSs. New mid-run heartbeat line: [heartbeat] [s1: ~62/190f 476KB 12m31s] [s2: ~63/190f 513KB 12m31s] ... When a shard finishes, the heartbeat upgrades to its final summary including pass/fail counts from bun's end-of-shard summary block: [heartbeat] [s1: done ✓ 2807p 0f] [s2: done ✓ 2784p 0f] ... Portability: BSD awk on macOS doesn't support `match($0, /re/, arr)` with the array sink — that's a gawk extension. The total-files parser uses sed instead so the runner stays portable to the default Mac toolchain. Helpers are pure functions and unit-testable in isolation: pass a log file path, get the parsed number. No mocking. No bun runtime required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): rebump v0.41.23.0 → v0.41.25.0 Per user request — skip v0.41.23.0 / v0.41.24.0 slots to land at v0.41.25.0. Master is at v0.41.22.1, no version-trio collision. Touches VERSION, package.json, CHANGELOG header, CLAUDE.md annotations, src/core/engine-constants.ts header, src/core/migrate.ts migration v106 comment, regenerated llms-full.txt + llms.txt. Migration version (v106) and CDX1-6 trigger semantics unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(schema): reorder migration_impact_log AFTER minion_jobs (CI green) Pre-existing bug in master's SCHEMA_SQL ordering, surfaced by CI on this PR but lived silently on master since v0.41.18.0. migration_impact_log declares `job_id BIGINT REFERENCES minion_jobs(id)`, but its CREATE TABLE was at line 658 while minion_jobs's CREATE TABLE was at line 778. On any fresh-install initSchema() the FK target didn't exist yet: psql:/tmp/schema.sql:672: ERROR: relation "minion_jobs" does not exist postgres-js's `unsafe()` aborts the multi-statement batch on the first error response, so every CREATE TABLE after migration_impact_log (including minion_jobs itself) never ran. Every subsequent CLI subprocess that opened a connection then crashed with `relation "minion_jobs" does not exist` on its first query. Why master CI sometimes passed: the per-shard advisory lock + the test setup's `engine.initSchema()` second pass (which runs the migrations array) would eventually create minion_jobs via the v5 `minion_jobs_table` migration. From there migration_impact_log would land via migration v103 with its FK resolving correctly. But CLI subprocesses spawned by mechanical.test.ts's Parallel Import block open their OWN connections and run a fresh `engine.connect() → initSchema()` — that path runs SCHEMA_SQL FIRST and aborted at the same forward-reference error before the migrations array could repair. Fix: relocate the migration_impact_log CREATE TABLE + its two indexes to AFTER the minion_jobs CREATE TABLE block (lines ~865), keeping the rest of the schema layout intact. PGLite schema (pglite-schema.ts) already had the correct ordering — only Postgres SCHEMA_SQL needed the move. Verified: fresh-DB local repro that previously failed 31/34 tests with `relation minion_jobs does not exist` now passes 78/78 in test/e2e/mechanical.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garrytan-agents <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
726dfff02c
commit
ff32fcaa78
+183
-22
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { join, relative } from 'path';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { DELETE_BATCH_SIZE } from '../core/engine-constants.ts';
|
||||
import { importFile } from '../core/import-file.ts';
|
||||
import { collectSyncableFiles } from './import.ts';
|
||||
import { createInterface } from 'readline';
|
||||
@@ -269,14 +270,25 @@ export async function resolveSlugByPathOrSourcePath(
|
||||
path: string,
|
||||
sourceId?: string,
|
||||
): Promise<string> {
|
||||
// v0.41.19.0 (D8): when sourceId is set, delegate to the new batch
|
||||
// resolveSlugsByPaths so single-call and batched paths share one SQL
|
||||
// owner + one fallback semantic. One Map allocation per single-call;
|
||||
// negligible cost. When sourceId is undefined (legacy unscoped callers),
|
||||
// fall back to the original executeRaw shape — the batch method
|
||||
// requires sourceId to prevent the multi-source-bug-class on its new
|
||||
// surface (D5). The unscoped fallback preserves back-compat.
|
||||
try {
|
||||
const rows = await engine.executeRaw<{ slug: string }>(
|
||||
sourceId
|
||||
? `SELECT slug FROM pages WHERE source_path = $1 AND source_id = $2 LIMIT 1`
|
||||
: `SELECT slug FROM pages WHERE source_path = $1 LIMIT 1`,
|
||||
sourceId ? [path, sourceId] : [path],
|
||||
);
|
||||
if (rows.length > 0 && rows[0].slug) return rows[0].slug;
|
||||
if (sourceId) {
|
||||
const m = await engine.resolveSlugsByPaths([path], { sourceId });
|
||||
const slug = m.get(path);
|
||||
if (slug) return slug;
|
||||
} else {
|
||||
const rows = await engine.executeRaw<{ slug: string }>(
|
||||
`SELECT slug FROM pages WHERE source_path = $1 LIMIT 1`,
|
||||
[path],
|
||||
);
|
||||
if (rows.length > 0 && rows[0].slug) return rows[0].slug;
|
||||
}
|
||||
} catch {
|
||||
// Fall through — best-effort. Pre-migration brains or query errors
|
||||
// shouldn't break delete/rename for path-derived pages.
|
||||
@@ -1233,25 +1245,135 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// Phases: sync.deletes, sync.renames, sync.imports.
|
||||
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||||
|
||||
// Process deletes first (prevents slug conflicts). SP-5: resolveSlugForPath
|
||||
// dispatches to the right slug shape so code file deletes hit the real page.
|
||||
// v0.41.19.0: hoisted out of the import block so the delete decompose
|
||||
// path (per-batch try-catch fallback) can append unrecoverable delete
|
||||
// failures here too. Same canonical surface that gates `sync.last_commit`
|
||||
// advancement at the bottom of this function.
|
||||
const failedFiles: Array<{ path: string; error: string; line?: number }> = [];
|
||||
|
||||
// v0.18.0+ multi-source: scope deletePage so we only delete the source-A
|
||||
// row, not every same-slug row across all sources.
|
||||
const deleteOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
|
||||
|
||||
// v0.41.19.0 (T2/D6/D7/D16/D18 via /plan-eng-review + codex outside-voice):
|
||||
// batched delete loop. Replaces the per-file N+1 that PR #1538 originally
|
||||
// batched on Postgres only. See plan file:
|
||||
// ~/.claude/plans/system-instruction-you-are-working-ethereal-narwhal.md
|
||||
//
|
||||
// SHAPE (interleaved per-batch resolve + delete; caller owns chunking):
|
||||
//
|
||||
// filtered.deleted (e.g. 73K paths)
|
||||
// │
|
||||
// ▼
|
||||
// slice into batches of DELETE_BATCH_SIZE (500)
|
||||
// │
|
||||
// ▼ for each batch:
|
||||
// abort-check ──► partial('timeout')
|
||||
// │
|
||||
// ▼
|
||||
// engine.resolveSlugsByPaths(batch, {sourceId}) ◀── 1 SQL round-trip
|
||||
// │
|
||||
// ▼
|
||||
// slugs = batch.map(path => map.get(path)
|
||||
// ?? resolveSlugForPath(path)) ◀── pure-JS fallback for
|
||||
// │ frontmatter-fallback
|
||||
// ▼ + missing-source-path
|
||||
// try {
|
||||
// deleted = engine.deletePages(slugs, opts) ◀── 1 SQL round-trip
|
||||
// pagesAffected.push(...deleted) ◀── D6: only confirmed
|
||||
// } catch { deletes, not phantoms
|
||||
// // D7 decompose: per-slug deletePage,
|
||||
// // unrecoverable failures → failedFiles
|
||||
// }
|
||||
//
|
||||
// ROUND-TRIP COUNTS (73K deletes):
|
||||
// pre-fix: 73,000 SELECTs + 73,000 DELETEs = 146,000 (~5 hours)
|
||||
// post-fix: 146 SELECTs + 146 DELETEs = 292 (~2 minutes)
|
||||
//
|
||||
// ATOMICITY (D3): each batch is one transaction. A mid-batch abort or
|
||||
// transient connection failure rolls back up to DELETE_BATCH_SIZE - 1
|
||||
// successful deletes. Sync is idempotent — the next run picks them up
|
||||
// via git diff regenerating the deletion list.
|
||||
//
|
||||
// NO-SOURCEID FALLBACK: when opts.sourceId is undefined (legacy unscoped
|
||||
// callers, rare post-v0.34.1 source-resolution wiring), fall back to the
|
||||
// OLD per-path loop. The batch engine surface requires sourceId per D5
|
||||
// (multi-source-bug-class defense at the type level). Production callers
|
||||
// that thread sourceId via resolveSourceWithTier get the new fast path.
|
||||
if (filtered.deleted.length > 0) {
|
||||
progress.start('sync.deletes', filtered.deleted.length);
|
||||
for (const path of filtered.deleted) {
|
||||
// v0.41.13.0 (T2 / D-V4-2): per-iteration abort check. Codex pass-3
|
||||
// F8 caught that v3 only covered pull + add/modify. Refactor commits
|
||||
// with hundreds of deletes can overshoot --timeout without this check.
|
||||
if (opts.signal?.aborted) {
|
||||
progress.finish();
|
||||
return partial('timeout');
|
||||
if (opts.sourceId) {
|
||||
const sid = opts.sourceId;
|
||||
const deleteScopedOpts = { sourceId: sid };
|
||||
for (let i = 0; i < filtered.deleted.length; i += DELETE_BATCH_SIZE) {
|
||||
if (opts.signal?.aborted) {
|
||||
progress.finish();
|
||||
return partial('timeout');
|
||||
}
|
||||
const batch = filtered.deleted.slice(i, i + DELETE_BATCH_SIZE);
|
||||
|
||||
// Phase A: batch slug resolution (1 round-trip per batch).
|
||||
let pathSlugMap: Map<string, string>;
|
||||
try {
|
||||
pathSlugMap = await engine.resolveSlugsByPaths(batch, deleteScopedOpts);
|
||||
} catch {
|
||||
// Resolve failure: fall back to empty map; per-path fallback
|
||||
// below will use resolveSlugForPath. Best-effort, matches the
|
||||
// existing resolveSlugByPathOrSourcePath swallow-and-fallback
|
||||
// semantics.
|
||||
pathSlugMap = new Map();
|
||||
}
|
||||
const slugs = batch.map(p => pathSlugMap.get(p) ?? resolveSlugForPath(p));
|
||||
|
||||
// Phase B: batch delete (1 round-trip per batch).
|
||||
try {
|
||||
const deleted = await engine.deletePages(slugs, deleteScopedOpts);
|
||||
// D6: only push slugs that were actually deleted. Filters phantom
|
||||
// slugs (paths in filtered.deleted but with no DB row) so
|
||||
// downstream extract/embed don't waste lookups.
|
||||
pagesAffected.push(...deleted);
|
||||
} catch (err) {
|
||||
// D7 decompose: a transient blip on this batch shouldn't lose all
|
||||
// 500 deletes. Fall back to per-slug deletePage for THIS batch
|
||||
// only; unrecoverable per-slug failures land in failedFiles
|
||||
// (matching the existing import-loop pattern at sync.ts:~1350).
|
||||
for (let j = 0; j < slugs.length; j++) {
|
||||
try {
|
||||
await engine.deletePage(slugs[j], deleteScopedOpts);
|
||||
pagesAffected.push(slugs[j]);
|
||||
} catch (perSlugErr) {
|
||||
failedFiles.push({
|
||||
path: batch[j],
|
||||
error: `delete failed: ${perSlugErr instanceof Error ? perSlugErr.message : String(perSlugErr)} (batch error: ${err instanceof Error ? err.message : String(err)})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
progress.tick(batch.length, `deletes ${Math.min(i + DELETE_BATCH_SIZE, filtered.deleted.length)}/${filtered.deleted.length}`);
|
||||
}
|
||||
} else {
|
||||
// Legacy no-sourceId path. The engine batch methods require sourceId
|
||||
// per D5 (kills the multi-source-bug-class on the new surface); when
|
||||
// sourceId is unset, fall back to the original per-path loop. Slow
|
||||
// but correct; production callers all thread sourceId so this branch
|
||||
// is functionally dead post-v0.34.1.
|
||||
for (const path of filtered.deleted) {
|
||||
if (opts.signal?.aborted) {
|
||||
progress.finish();
|
||||
return partial('timeout');
|
||||
}
|
||||
const slug = await resolveSlugByPathOrSourcePath(engine, path, undefined);
|
||||
try {
|
||||
await engine.deletePage(slug, deleteOpts);
|
||||
pagesAffected.push(slug);
|
||||
} catch (err) {
|
||||
failedFiles.push({
|
||||
path,
|
||||
error: `delete failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
progress.tick(1, slug);
|
||||
}
|
||||
const slug = await resolveSlugByPathOrSourcePath(engine, path, opts.sourceId);
|
||||
await engine.deletePage(slug, deleteOpts);
|
||||
pagesAffected.push(slug);
|
||||
progress.tick(1, slug);
|
||||
}
|
||||
progress.finish();
|
||||
}
|
||||
@@ -1260,12 +1382,46 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// SP-5: both old and new slugs use resolveSlugForPath so a .ts → .ts
|
||||
// rename (code→code), .md → .md (markdown→markdown), or cross-kind rename
|
||||
// all resolve to the right slug shape for each side.
|
||||
//
|
||||
// v0.41.19.0 (T4): pre-batched slug resolution per Phase 3 of the plan.
|
||||
// Renames' per-file cost is dominated by importFile() (file IO + chunking
|
||||
// + embedding), so the per-iteration updateSlug + importFile loop stays;
|
||||
// only the upfront slug-resolve N+1 gets batched. The try/catch around
|
||||
// updateSlug for slug-doesn't-exist preserves verbatim.
|
||||
if (filtered.renamed.length > 0) {
|
||||
progress.start('sync.renames', filtered.renamed.length);
|
||||
// v0.18.0+ multi-source: scope updateSlug so the rename only touches the
|
||||
// source-A row, not every same-slug row across sources (which would
|
||||
// either sweep them all OR violate (source_id, slug) UNIQUE).
|
||||
const renameOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
|
||||
|
||||
// T4: pre-resolve ALL `from` slugs in batches before iterating. Falls
|
||||
// back to per-path resolveSlugByPathOrSourcePath when sourceId is
|
||||
// unset (matches the delete loop's legacy posture). For large rename
|
||||
// commits (rare but possible: prefix sweep, reorganization), this drops
|
||||
// the slug-resolve round-trips from O(renames) to O(renames/500).
|
||||
const fromSlugByPath = new Map<string, string>();
|
||||
if (opts.sourceId) {
|
||||
const sid = opts.sourceId;
|
||||
const fromPaths = filtered.renamed.map(r => r.from);
|
||||
for (let i = 0; i < fromPaths.length; i += DELETE_BATCH_SIZE) {
|
||||
if (opts.signal?.aborted) {
|
||||
progress.finish();
|
||||
return partial('timeout');
|
||||
}
|
||||
const batch = fromPaths.slice(i, i + DELETE_BATCH_SIZE);
|
||||
let m: Map<string, string>;
|
||||
try {
|
||||
m = await engine.resolveSlugsByPaths(batch, { sourceId: sid });
|
||||
} catch {
|
||||
m = new Map();
|
||||
}
|
||||
for (const p of batch) {
|
||||
fromSlugByPath.set(p, m.get(p) ?? resolveSlugForPath(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { from, to } of filtered.renamed) {
|
||||
// v0.41.13.0 (T2 / D-V4-2): per-iteration abort check. Renames call
|
||||
// importFile() at line 1173-style sites which can be slow on big files;
|
||||
@@ -1274,7 +1430,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
progress.finish();
|
||||
return partial('timeout');
|
||||
}
|
||||
const oldSlug = await resolveSlugByPathOrSourcePath(engine, from, opts.sourceId);
|
||||
const oldSlug = opts.sourceId
|
||||
? (fromSlugByPath.get(from) ?? resolveSlugForPath(from))
|
||||
: await resolveSlugByPathOrSourcePath(engine, from, undefined);
|
||||
// The new path doesn't yet have a row, so resolve from path only.
|
||||
const newSlug = resolveSlugForPath(to);
|
||||
try {
|
||||
@@ -1308,7 +1466,10 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// v0.15.2: per-file progress on stderr via the shared reporter.
|
||||
// Bug 9: per-file failures captured in `failedFiles` so the caller can
|
||||
// gate `sync.last_commit` advancement and record recoverable errors.
|
||||
const failedFiles: Array<{ path: string; error: string; line?: number }> = [];
|
||||
// v0.41.19.0: `failedFiles` is now hoisted above the delete loop (the
|
||||
// delete decompose path appends here too); kept as a comment-pin so
|
||||
// future maintainers know to thread additional failure surfaces through
|
||||
// the same array.
|
||||
const addsAndMods = [...filtered.added, ...filtered.modified];
|
||||
|
||||
// Sort newest-first so date-prefixed brain paths get embedded before older
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// v0.41.25.0 — single source of truth for engine batch-sizing.
|
||||
//
|
||||
// Both PostgresEngine and PGLiteEngine import from here so the constants
|
||||
// cannot drift across engines. Lives outside `src/core/engine.ts` to avoid
|
||||
// circular-import worries (engine.ts is the interface; engines depend on
|
||||
// engine.ts; this file depends on neither).
|
||||
|
||||
/**
|
||||
* Maximum number of slugs per single batch `DELETE FROM pages WHERE slug =
|
||||
* ANY($1::text[])` call. Callers (e.g. `src/commands/sync.ts` delete loop)
|
||||
* are responsible for chunking input arrays to this size; `engine.deletePages`
|
||||
* is a single-batch primitive that does NOT chunk internally (matches the
|
||||
* `addLinksBatch` convention — caller owns chunking, engine assumes the
|
||||
* caller is well-behaved).
|
||||
*
|
||||
* 500 is the same order-of-magnitude as the effective per-call budget for
|
||||
* the existing `addLinksBatch` (postgres-engine.ts) — well under Postgres's
|
||||
* 65535 parameter cap. We bind a single array parameter so the cap doesn't
|
||||
* bite directly, but per-statement work stays bounded for predictable lock
|
||||
* hold time + write-amplification budget.
|
||||
*
|
||||
* The same constant is also used for batch `SELECT slug, source_path FROM
|
||||
* pages WHERE source_path = ANY($1::text[])` in `engine.resolveSlugsByPaths`.
|
||||
*/
|
||||
export const DELETE_BATCH_SIZE = 500;
|
||||
@@ -712,8 +712,67 @@ export interface BrainEngine {
|
||||
* delete doesn't hard-delete the same-slug pages in sources B/C/D. Without
|
||||
* it, the bare DELETE matches every row with that slug across all sources.
|
||||
* Cascades through content_chunks / page_links / chunk_relations via FKs.
|
||||
*
|
||||
* v0.41.19.0 (CDX-11): single-row primitive used by `purgeDeletedPages`,
|
||||
* `gbrain sync` (one path per call), test setup teardown, and the v0.41.19.0
|
||||
* sync-delete decompose path (when `deletePages` throws on a 500-row batch,
|
||||
* the sync loop falls back to per-slug `deletePage` to log unrecoverable
|
||||
* failures to `failedFiles`). `gbrain sync` calls this on EVERY run that
|
||||
* sees a deleted file — it is NOT admin-only.
|
||||
*/
|
||||
deletePage(slug: string, opts?: { sourceId?: string }): Promise<void>;
|
||||
/**
|
||||
* v0.41.19.0 — batch delete: single SQL round-trip via
|
||||
* `DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2
|
||||
* RETURNING slug`. Cascades through content_chunks / page_links (×3) /
|
||||
* tags / raw_data / timeline_entries / page_versions via FKs declared in
|
||||
* `src/schema.sql`. `files.page_id` and `links.origin_page_id` go SET
|
||||
* NULL per their FK definitions.
|
||||
*
|
||||
* SINGLE-BATCH PRIMITIVE: caller is responsible for chunking the input to
|
||||
* `<= DELETE_BATCH_SIZE` entries per call (see
|
||||
* `src/core/engine-constants.ts`). Matches the `addLinksBatch` convention
|
||||
* — engine assumes well-behaved input, caller owns the slicing.
|
||||
*
|
||||
* Returns the slugs of rows ACTUALLY DELETED (order undefined). Callers
|
||||
* use this to filter their own `pagesAffected` tracking so downstream
|
||||
* phases don't waste lookups on phantom slugs (paths that were in the
|
||||
* deletion list but had no DB row).
|
||||
*
|
||||
* ATOMICITY: one statement, one transaction. The whole batch commits or
|
||||
* the whole batch rolls back. Coarser than the per-row `deletePage`
|
||||
* cadence — a mid-loop abort or transient connection failure can roll
|
||||
* back up to `DELETE_BATCH_SIZE - 1` successful deletes from the
|
||||
* in-flight batch. `gbrain sync` is idempotent (next run picks them up
|
||||
* via git diff); other callers should account for the contract.
|
||||
*
|
||||
* sourceId is REQUIRED (no `'default'` fallback). This is asymmetric with
|
||||
* `deletePage` (which keeps the optional/'default' fallback for back-
|
||||
* compat). Filed as v0.42+ TODO to tighten `deletePage` to match once a
|
||||
* full caller audit confirms every site threads `sourceId`.
|
||||
*/
|
||||
deletePages(slugs: string[], opts: { sourceId: string }): Promise<string[]>;
|
||||
/**
|
||||
* v0.41.19.0 — batch path → slug resolution. Single SQL round-trip via
|
||||
* `SELECT slug, source_path FROM pages WHERE source_path = ANY($1::text[])
|
||||
* AND source_id = $2`. Returns `Map<path, slug>`; paths NOT in the map
|
||||
* have no `source_path` row in the DB and the caller is expected to fall
|
||||
* back to `resolveSlugForPath(path)` for the path-derived slug.
|
||||
*
|
||||
* Mirrors the contract of the single-call `resolveSlugByPathOrSourcePath`
|
||||
* helper in `src/commands/sync.ts`, batched. As of v0.41.19.0, that
|
||||
* single-call helper is implemented on top of this method (one Map
|
||||
* allocation per single-path call; negligible cost; one owner of the SQL
|
||||
* + fallback semantics).
|
||||
*
|
||||
* SINGLE-BATCH PRIMITIVE: caller chunks to `<= DELETE_BATCH_SIZE`.
|
||||
*
|
||||
* Empty `paths` short-circuits to an empty Map without touching the DB.
|
||||
*/
|
||||
resolveSlugsByPaths(
|
||||
paths: string[],
|
||||
opts: { sourceId: string },
|
||||
): Promise<Map<string, string>>;
|
||||
/**
|
||||
* v0.26.5 — set `deleted_at = now()` on a page. Returns the slug if a row
|
||||
* was soft-deleted, null if no row matched (already soft-deleted OR not found).
|
||||
|
||||
@@ -4856,6 +4856,70 @@ export const MIGRATIONS: Migration[] = [
|
||||
ON extract_rollup_7d (day);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 107,
|
||||
name: 'page_generation_clock_and_statement_trigger',
|
||||
// v0.41.25.0 (D18/D19, codex outside-voice on /plan-eng-review): global
|
||||
// page-generation clock + statement-level trigger.
|
||||
//
|
||||
// Renumbered v104 → v105 → v106 → v107 during master merges:
|
||||
// PR #1545 (v0.41.21.0 ops-fix-wave) took v104 for pages_atom_source_hash_idx;
|
||||
// PR #1542 (v0.41.22.0 type-unification cathedral) took v105 for slug_aliases;
|
||||
// PR #1541 (v0.41.23.0 extract operator surfaces) took v106 for extract_rollup_7d_table.
|
||||
//
|
||||
// Why this exists: the pre-v0.41.25.0 query-cache Layer 1 bookmark read
|
||||
// `MAX(generation) FROM pages` to detect "writes happened since cache
|
||||
// store". Two bugs in that contract — independent of any sync work:
|
||||
//
|
||||
// 1. The row-level `bump_page_generation_trg` (migration v91) sets
|
||||
// `NEW.generation = OLD.generation + 1` on UPDATE. Updating a
|
||||
// NON-MAX page didn't advance MAX(generation). Cache silently
|
||||
// served stale results for any UPDATE-to-non-max page.
|
||||
// 2. The trigger is BEFORE INSERT OR UPDATE — DELETE doesn't fire it
|
||||
// at all. Even an AFTER DELETE wouldn't move MAX (surviving rows
|
||||
// are untouched).
|
||||
//
|
||||
// The fix: single-row counter, bumped per-statement (FOR EACH STATEMENT
|
||||
// — row-level would turn a 73K-row batch DELETE into 73K UPDATEs on the
|
||||
// same counter, recreating the bottleneck the sync-delete wave is
|
||||
// fixing in this same PR). Layer 1 reads page_generation_clock.value
|
||||
// directly. Per-row pages.generation stays for Layer 2 (per-page
|
||||
// snapshot via jsonb_each + LEFT JOIN pages) which doesn't care about
|
||||
// MAX, only per-page advancement.
|
||||
//
|
||||
// Seeded with COALESCE(MAX(pages.generation), 0) so existing
|
||||
// query_cache rows stored under the old MAX semantics aren't all
|
||||
// instantly invalidated on upgrade. Their max_generation_at_store
|
||||
// stamp compares cleanly against the seeded clock; future writes bump
|
||||
// the clock and the bookmark fires correctly.
|
||||
//
|
||||
// Mirror lives in src/core/pglite-schema.ts (fresh-install path).
|
||||
// Forward-reference bootstrap probe in applyForwardReferenceBootstrap
|
||||
// on both engines so pre-v0.41.25.0 brains pick it up cleanly.
|
||||
idempotent: true,
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS page_generation_clock (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
value BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO page_generation_clock (id, value)
|
||||
VALUES (1, COALESCE((SELECT MAX(generation) FROM pages), 0))
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS $func$
|
||||
BEGIN
|
||||
UPDATE page_generation_clock SET value = value + 1 WHERE id = 1;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$func$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS bump_page_generation_clock_trg ON pages;
|
||||
CREATE TRIGGER bump_page_generation_clock_trg
|
||||
AFTER INSERT OR UPDATE OR DELETE ON pages
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION bump_page_generation_clock_fn();
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
@@ -22,6 +22,7 @@ import { logBatchRetry as auditLogBatchRetry, logBatchExhausted as auditLogBatch
|
||||
import { runMigrations } from './migrate.ts';
|
||||
import { PGLITE_SCHEMA_SQL, getPGLiteSchema } from './pglite-schema.ts';
|
||||
import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './ai/defaults.ts';
|
||||
import { DELETE_BATCH_SIZE } from './engine-constants.ts';
|
||||
import { acquireLock, releaseLock, type LockHandle } from './pglite-lock.ts';
|
||||
import type {
|
||||
Page, PageInput, PageFilters, PageType,
|
||||
@@ -900,6 +901,49 @@ export class PGLiteEngine implements BrainEngine {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.19.0 — batch delete primitive. See BrainEngine.deletePages JSDoc.
|
||||
* Parity implementation with PostgresEngine.deletePages. PGLite supports
|
||||
* `slug = ANY($1)` array-param binding natively (addLinksBatch already
|
||||
* proves this).
|
||||
*/
|
||||
async deletePages(slugs: string[], opts: { sourceId: string }): Promise<string[]> {
|
||||
if (slugs.length === 0) return [];
|
||||
if (slugs.length > DELETE_BATCH_SIZE) {
|
||||
throw new Error(
|
||||
`deletePages: input size ${slugs.length} exceeds DELETE_BATCH_SIZE=${DELETE_BATCH_SIZE}. Caller must chunk.`,
|
||||
);
|
||||
}
|
||||
const { rows } = await this.db.query<{ slug: string }>(
|
||||
'DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2 RETURNING slug',
|
||||
[slugs, opts.sourceId],
|
||||
);
|
||||
return rows.map(r => r.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.19.0 — batch path → slug resolution. See BrainEngine.resolveSlugsByPaths
|
||||
* JSDoc.
|
||||
*/
|
||||
async resolveSlugsByPaths(
|
||||
paths: string[],
|
||||
opts: { sourceId: string },
|
||||
): Promise<Map<string, string>> {
|
||||
if (paths.length === 0) return new Map();
|
||||
if (paths.length > DELETE_BATCH_SIZE) {
|
||||
throw new Error(
|
||||
`resolveSlugsByPaths: input size ${paths.length} exceeds DELETE_BATCH_SIZE=${DELETE_BATCH_SIZE}. Caller must chunk.`,
|
||||
);
|
||||
}
|
||||
const { rows } = await this.db.query<{ slug: string; source_path: string }>(
|
||||
'SELECT slug, source_path FROM pages WHERE source_path = ANY($1::text[]) AND source_id = $2',
|
||||
[paths, opts.sourceId],
|
||||
);
|
||||
const m = new Map<string, string>();
|
||||
for (const r of rows) m.set(r.source_path, r.slug);
|
||||
return m;
|
||||
}
|
||||
|
||||
async softDeletePage(slug: string, opts?: { sourceId?: string }): Promise<{ slug: string } | null> {
|
||||
// Idempotent-as-null: only flip rows currently active. Source filter is
|
||||
// optional; without it the first matching row across sources gets soft-deleted.
|
||||
|
||||
@@ -144,6 +144,32 @@ CREATE TRIGGER bump_page_generation_trg
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION bump_page_generation_fn();
|
||||
|
||||
-- v0.41.19.0 (D18/D19, mirror of src/schema.sql): global page-generation
|
||||
-- clock + statement-level trigger. See src/schema.sql for the full
|
||||
-- rationale comment. Layer 1 bookmark reads page_generation_clock.value;
|
||||
-- per-row pages.generation above stays as the Layer 2 (per-page snapshot)
|
||||
-- substrate.
|
||||
CREATE TABLE IF NOT EXISTS page_generation_clock (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
value BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO page_generation_clock (id, value)
|
||||
VALUES (1, COALESCE((SELECT MAX(generation) FROM pages), 0))
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS $func$
|
||||
BEGIN
|
||||
UPDATE page_generation_clock SET value = value + 1 WHERE id = 1;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$func$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS bump_page_generation_clock_trg ON pages;
|
||||
CREATE TRIGGER bump_page_generation_clock_trg
|
||||
AFTER INSERT OR UPDATE OR DELETE ON pages
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION bump_page_generation_clock_fn();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_type ON pages(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_frontmatter ON pages USING GIN(frontmatter);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_trgm ON pages USING GIN(title gin_trgm_ops);
|
||||
|
||||
@@ -57,6 +57,7 @@ import { validateSlug, contentHash, rowToPage, rowToChunk, rowToSearchResult, pa
|
||||
import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
|
||||
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql } from './search/sql-ranking.ts';
|
||||
import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './ai/defaults.ts';
|
||||
import { DELETE_BATCH_SIZE } from './engine-constants.ts';
|
||||
|
||||
function escapeSqlStringLiteral(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
@@ -911,6 +912,53 @@ export class PostgresEngine implements BrainEngine {
|
||||
await sql`DELETE FROM pages WHERE slug = ${slug} AND source_id = ${sourceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.19.0 — batch delete primitive. See BrainEngine.deletePages JSDoc.
|
||||
* Single SQL round-trip per call; caller is responsible for chunking input
|
||||
* to <= DELETE_BATCH_SIZE. RETURNING slug projects the actually-deleted set
|
||||
* so the caller can filter pagesAffected.
|
||||
*/
|
||||
async deletePages(slugs: string[], opts: { sourceId: string }): Promise<string[]> {
|
||||
if (slugs.length === 0) return [];
|
||||
if (slugs.length > DELETE_BATCH_SIZE) {
|
||||
throw new Error(
|
||||
`deletePages: input size ${slugs.length} exceeds DELETE_BATCH_SIZE=${DELETE_BATCH_SIZE}. Caller must chunk.`,
|
||||
);
|
||||
}
|
||||
const sql = this.sql;
|
||||
const rows = await sql<{ slug: string }[]>`
|
||||
DELETE FROM pages
|
||||
WHERE slug = ANY(${slugs}::text[]) AND source_id = ${opts.sourceId}
|
||||
RETURNING slug
|
||||
`;
|
||||
return rows.map(r => r.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.19.0 — batch path → slug resolution. See BrainEngine.resolveSlugsByPaths
|
||||
* JSDoc. Single SQL round-trip; folds rows into a Map.
|
||||
*/
|
||||
async resolveSlugsByPaths(
|
||||
paths: string[],
|
||||
opts: { sourceId: string },
|
||||
): Promise<Map<string, string>> {
|
||||
if (paths.length === 0) return new Map();
|
||||
if (paths.length > DELETE_BATCH_SIZE) {
|
||||
throw new Error(
|
||||
`resolveSlugsByPaths: input size ${paths.length} exceeds DELETE_BATCH_SIZE=${DELETE_BATCH_SIZE}. Caller must chunk.`,
|
||||
);
|
||||
}
|
||||
const sql = this.sql;
|
||||
const rows = await sql<{ slug: string; source_path: string }[]>`
|
||||
SELECT slug, source_path
|
||||
FROM pages
|
||||
WHERE source_path = ANY(${paths}::text[]) AND source_id = ${opts.sourceId}
|
||||
`;
|
||||
const m = new Map<string, string>();
|
||||
for (const r of rows) m.set(r.source_path, r.slug);
|
||||
return m;
|
||||
}
|
||||
|
||||
async softDeletePage(slug: string, opts?: { sourceId?: string }): Promise<{ slug: string } | null> {
|
||||
const sql = this.sql;
|
||||
const sourceId = opts?.sourceId;
|
||||
|
||||
+108
-3
@@ -185,6 +185,48 @@ CREATE TRIGGER bump_page_generation_trg
|
||||
-- CREATE INDEX since the table is empty.
|
||||
CREATE INDEX IF NOT EXISTS pages_generation_idx ON pages (generation);
|
||||
|
||||
-- v0.41.19.0 (D18/D19, codex outside-voice): global page-generation clock.
|
||||
-- The pre-v0.41.19.0 Layer 1 bookmark read \`MAX(generation) FROM pages\` to
|
||||
-- detect "writes happened since cache-store". Two bugs in that contract:
|
||||
-- 1. The row-level trigger above sets \`NEW.generation = OLD.generation + 1\`
|
||||
-- on UPDATE. Updating a NON-MAX page didn't advance MAX(generation),
|
||||
-- silently serving stale cached results.
|
||||
-- 2. The trigger is \`BEFORE INSERT OR UPDATE\` so DELETE doesn't fire it
|
||||
-- at all — and even if it did, DELETE doesn't touch surviving rows,
|
||||
-- so MAX(generation) wouldn't budge.
|
||||
--
|
||||
-- The fix: a single-row counter, bumped per-statement (FOR EACH STATEMENT
|
||||
-- — codex CDX-4: per-row would turn 73K-row batch DELETE into 73K UPDATEs
|
||||
-- on the same counter, recreating the bottleneck this PR is fixing). Layer
|
||||
-- 1 reads \`page_generation_clock.value\` directly. The per-row
|
||||
-- \`pages.generation\` column above stays as the Layer 2 (per-page snapshot)
|
||||
-- substrate.
|
||||
--
|
||||
-- Seeded with COALESCE(MAX(pages.generation), 0) so existing query_cache
|
||||
-- rows stored under the old MAX semantics aren't all instantly invalidated
|
||||
-- on upgrade (their max_generation_at_store stamp compares cleanly against
|
||||
-- the seeded clock; future writes bump the clock, bookmark fires correctly).
|
||||
CREATE TABLE IF NOT EXISTS page_generation_clock (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
value BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO page_generation_clock (id, value)
|
||||
VALUES (1, COALESCE((SELECT MAX(generation) FROM pages), 0))
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS \$func\$
|
||||
BEGIN
|
||||
UPDATE page_generation_clock SET value = value + 1 WHERE id = 1;
|
||||
RETURN NULL;
|
||||
END;
|
||||
\$func\$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS bump_page_generation_clock_trg ON pages;
|
||||
CREATE TRIGGER bump_page_generation_clock_trg
|
||||
AFTER INSERT OR UPDATE OR DELETE ON pages
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION bump_page_generation_clock_fn();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_type ON pages(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_frontmatter ON pages USING GIN(frontmatter);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_trgm ON pages USING GIN(title gin_trgm_ops);
|
||||
@@ -266,6 +308,13 @@ CREATE INDEX IF NOT EXISTS idx_chunks_embedding_image
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_search_vector ON content_chunks USING GIN(search_vector);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_symbol_qualified
|
||||
ON content_chunks(symbol_name_qualified) WHERE symbol_name_qualified IS NOT NULL;
|
||||
-- v0.41.18.0 (codex finding #9): partial index for \`gbrain embed --stale\`
|
||||
-- + \`--priority recent\`. content_chunks has no updated_at column (chunks
|
||||
-- are re-INSERTed on page change, not UPDATEd), so the "recent-first"
|
||||
-- ORDER BY happens at the JOIN site: outer ORDER BY p.updated_at DESC
|
||||
-- uses idx_pages_updated_at_desc; inner partial uses this index.
|
||||
CREATE INDEX IF NOT EXISTS content_chunks_stale_idx
|
||||
ON content_chunks(page_id, chunk_index) WHERE embedding IS NULL;
|
||||
|
||||
-- v0.20.0 Cathedral II: chunk-grain FTS trigger.
|
||||
-- Weight 'A' on doc_comment + symbol_name_qualified; weight 'B' on chunk_text.
|
||||
@@ -357,10 +406,16 @@ CREATE TABLE IF NOT EXISTS links (
|
||||
to_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
link_type TEXT NOT NULL DEFAULT '',
|
||||
context TEXT NOT NULL DEFAULT '',
|
||||
-- v0.42.0.0: 'mentions' added for auto-linked body-text mentions
|
||||
-- v0.41.18.0: 'mentions' added for auto-linked body-text mentions
|
||||
-- (gbrain extract links --by-mention). Filtered OUT of backlink-count
|
||||
-- for search ranking; only counts toward orphan-ratio + graph traversal.
|
||||
link_source TEXT CHECK (link_source IS NULL OR link_source IN ('markdown', 'frontmatter', 'manual', 'mentions')),
|
||||
-- v0.41.18.0: nullable link_kind distinguishes "plain body mention" from
|
||||
-- "verb-pattern-derived typed link" within link_source='mentions'.
|
||||
-- Codex finding #12 design: keep link_source stable; add link_kind
|
||||
-- so callers can distinguish without breaking existing mentions queries.
|
||||
-- NULL = legacy / unknown / pre-v98 row (semantically 'plain').
|
||||
link_kind TEXT CHECK (link_kind IS NULL OR link_kind IN ('plain', 'typed_ner')),
|
||||
origin_page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
|
||||
origin_field TEXT,
|
||||
-- v0.18.0 Step 4: 'qualified' when the link was written as
|
||||
@@ -423,8 +478,10 @@ CREATE TABLE IF NOT EXISTS timeline_entries (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_page ON timeline_entries(page_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_date ON timeline_entries(date);
|
||||
-- Dedup constraint: same (page, date, summary) treated as same event
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup ON timeline_entries(page_id, date, summary);
|
||||
-- v0.41.18.0 (codex finding #11): widened from (page_id, date, summary) to
|
||||
-- include \`source\` so distinct meeting provenance survives. Legacy rows
|
||||
-- have source='' (schema default) so legacy dedup behavior is preserved.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup ON timeline_entries(page_id, date, summary, source);
|
||||
|
||||
-- ============================================================
|
||||
-- page_versions: snapshot history for compiled_truth
|
||||
@@ -589,6 +646,10 @@ CREATE TABLE IF NOT EXISTS op_checkpoints (
|
||||
CREATE INDEX IF NOT EXISTS op_checkpoints_updated_at_idx
|
||||
ON op_checkpoints (updated_at);
|
||||
|
||||
-- migration_impact_log moved BELOW minion_jobs (was here, lines 645-676)
|
||||
-- because its \`job_id BIGINT REFERENCES minion_jobs(id)\` FK requires
|
||||
-- minion_jobs to exist FIRST during SCHEMA_SQL replay. v0.41.25.0 fix.
|
||||
|
||||
-- ============================================================
|
||||
-- files: binary attachments stored in Supabase Storage
|
||||
-- ============================================================
|
||||
@@ -777,6 +838,50 @@ CREATE TABLE IF NOT EXISTS minion_attachments (
|
||||
CREATE INDEX IF NOT EXISTS idx_minion_attachments_job ON minion_attachments (job_id);
|
||||
ALTER TABLE minion_attachments ALTER COLUMN content SET STORAGE EXTERNAL;
|
||||
|
||||
-- ============================================================
|
||||
-- migration_impact_log: before/after metric stats per onboard remediation
|
||||
-- ============================================================
|
||||
-- v0.41.18.0 (gbrain onboard wave). Every completion captured by the
|
||||
-- onboard remediation pipeline records before/after metric stats so
|
||||
-- \`gbrain onboard --history --json\` can show "you reduced orphans 47%".
|
||||
-- delta computed at read time (NOT a stored GENERATED column —
|
||||
-- zero PGLite parity risk per eng-review D2).
|
||||
--
|
||||
-- Attribution columns (job_id, source_id, brain_id, started_at,
|
||||
-- idempotency_key) per codex finding #10 so concurrent onboard /
|
||||
-- autopilot / manual runs can't misattribute deltas to the wrong
|
||||
-- migration when overlapping runs change the same metric.
|
||||
--
|
||||
-- v0.41.25.0 SCHEMA_SQL ordering fix: this block lives AFTER the
|
||||
-- minion_jobs CREATE TABLE so the \`job_id REFERENCES minion_jobs(id)\`
|
||||
-- FK can resolve on fresh-install schema replay. Originally placed above
|
||||
-- minion_jobs in v0.41.18.0; that fired ERROR: relation "minion_jobs"
|
||||
-- does not exist on every fresh-install initSchema() (silent on master
|
||||
-- because postgres-js's unsafe() continued past the error, but the
|
||||
-- table never got created so any later query on migration_impact_log
|
||||
-- threw 42P01 — which cascaded as "relation minion_jobs does not exist"
|
||||
-- whenever subsequent statements that referenced minion_jobs ran AFTER
|
||||
-- the failed CREATE TABLE statement, aborting the entire SCHEMA_SQL batch).
|
||||
CREATE TABLE IF NOT EXISTS migration_impact_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
remediation_id TEXT NOT NULL,
|
||||
metric_name TEXT NOT NULL,
|
||||
metric_before NUMERIC,
|
||||
metric_after NUMERIC,
|
||||
job_id BIGINT REFERENCES minion_jobs(id) ON DELETE SET NULL,
|
||||
source_id TEXT,
|
||||
brain_id TEXT,
|
||||
started_at TIMESTAMPTZ,
|
||||
idempotency_key TEXT,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
applied_by TEXT,
|
||||
details JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS migration_impact_log_remediation_idx
|
||||
ON migration_impact_log(remediation_id, applied_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS migration_impact_log_attribution_idx
|
||||
ON migration_impact_log(job_id, source_id) WHERE job_id IS NOT NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Subagent runtime (v0.16.0) — durable LLM loops
|
||||
-- ============================================================
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
/**
|
||||
* Cache invalidation gate (v0.40.3.0 — D2 + D6 + D11)
|
||||
* Cache invalidation gate (v0.40.3.0 base — D2 + D6 + D11; v0.41.19.0
|
||||
* rewrite — D18 + D20, codex CDX-1/CDX-2/CDX-5/CDX-6).
|
||||
*
|
||||
* Two pure helpers wired by query-cache.ts at store + lookup time. Pure
|
||||
* surface lets us unit-test the two-layer gate logic without a real cache.
|
||||
*
|
||||
* Layer 1 (cheap bookmark): `MAX(generation) FROM pages` <=
|
||||
* `query_cache.max_generation_at_store`. If true, brain has not been
|
||||
* written since this row stored, so the row is fresh corpus-wide.
|
||||
* Layer 1 (cheap bookmark): `page_generation_clock.value` <=
|
||||
* `query_cache.max_generation_at_store`. If true, no page write has
|
||||
* happened since this row stored, so the row is fresh corpus-wide.
|
||||
*
|
||||
* Layer 2 (per-page snapshot): if bookmark fires, fall through to the
|
||||
* `page_generations JSONB` snapshot. For each `(page_id, stored_gen)`
|
||||
* pair, compare to current `pages.generation`. Any mismatch (page
|
||||
* deleted, page bumped) invalidates.
|
||||
* deleted, page bumped) invalidates. **Empty `page_generations = {}`
|
||||
* does NOT pass Layer 2 in v0.41.19.0+** — empty snapshots have no
|
||||
* per-page signal to invalidate against, so they MUST rely on Layer 1
|
||||
* exclusively (CDX-6 fix: pre-v0.41.19.0 the vacuous-valid path let
|
||||
* empty-result cache rows survive across writes that should have
|
||||
* invalidated them).
|
||||
*
|
||||
* Why the rewrite: pre-v0.41.19.0 Layer 1 read `MAX(generation) FROM
|
||||
* pages`, but the per-row trigger sets `NEW.generation = OLD.generation
|
||||
* + 1` on UPDATE. Updating a NON-MAX page didn't advance MAX(generation),
|
||||
* so the bookmark silently passed stale cache rows (codex CDX-2). DELETE
|
||||
* doesn't fire the trigger AT ALL, so deletion didn't advance MAX either
|
||||
* (codex CDX-1). Migration v105 introduces a global single-row counter
|
||||
* (`page_generation_clock`) bumped per-statement by a separate trigger;
|
||||
* Layer 1 now reads the counter directly so every INSERT/UPDATE/DELETE
|
||||
* statement advances the bookmark exactly once regardless of which rows
|
||||
* changed.
|
||||
*
|
||||
* Backward compat: rows stored before v0.40.3.0 have
|
||||
* `max_generation_at_store = 0` AND `page_generations = '{}'::jsonb`.
|
||||
* Bookmark check: `MAX <= 0` is false on any populated brain, so we fall
|
||||
* through to Layer 2; Layer 2 sees `'{}'::jsonb` and is vacuously valid.
|
||||
* Legacy rows continue to serve naturally (IRON-RULE regression pinned
|
||||
* in test/e2e/cache-gate-pglite.test.ts).
|
||||
* Layer 1 check on a populated brain: `clock > 0` so Layer 1 fails.
|
||||
* Layer 2 v0.41.19.0+ stricter: empty `{}` no longer passes — legacy
|
||||
* rows invalidate on first post-upgrade lookup. Cache fills back up
|
||||
* naturally; correct semantics restored. The pre-v0.41.19.0 IRON-RULE
|
||||
* "legacy rows serve via vacuously-valid Layer 2" is intentionally
|
||||
* reversed: that path was the CDX-6 bug.
|
||||
*
|
||||
* See plan ~/.claude/plans/system-instruction-you-are-working-enchanted-mountain.md
|
||||
* Phase 2A for full design.
|
||||
* Per-page `pages.generation` column + its row-level trigger
|
||||
* (`bump_page_generation_trg`) stay in place. Layer 2 reads from them
|
||||
* — Layer 2 only needs per-page advancement (which the row-level trigger
|
||||
* delivers correctly), NOT a MAX-style aggregate.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
@@ -73,14 +94,20 @@ export async function buildPageGenerationsSnapshot(
|
||||
|
||||
try {
|
||||
if (pageIds.length === 0) {
|
||||
// Empty-result query: only need the Layer 1 bookmark (clock value).
|
||||
// Per D20, empty-result cache rows trust Layer 1 exclusively;
|
||||
// bumping the clock on subsequent writes correctly invalidates them.
|
||||
const rows = await engine.executeRaw<{ v: number }>(
|
||||
`SELECT COALESCE(MAX(generation), 0)::bigint AS v FROM pages`,
|
||||
`SELECT COALESCE((SELECT value FROM page_generation_clock WHERE id = 1), 0)::bigint AS v`,
|
||||
);
|
||||
snapshot.max_generation_at_store = Number(rows[0]?.v ?? 0);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// Combined query: per-page generation + corpus-state MAX.
|
||||
// Combined query: per-page generation (Layer 2 substrate) + global
|
||||
// clock value (Layer 1 bookmark). UNION ALL folds both into one
|
||||
// round trip. The 'CLOCK' tag row is identified by `is_max = true`
|
||||
// (field name preserved for back-compat at the call site).
|
||||
const rows = await engine.executeRaw<{
|
||||
k: string;
|
||||
v: number;
|
||||
@@ -89,8 +116,9 @@ export async function buildPageGenerationsSnapshot(
|
||||
`SELECT id::text AS k, generation::bigint AS v, FALSE AS is_max
|
||||
FROM pages WHERE id = ANY($1::int[])
|
||||
UNION ALL
|
||||
SELECT 'MAX' AS k, COALESCE(MAX(generation), 0)::bigint AS v, TRUE AS is_max
|
||||
FROM pages`,
|
||||
SELECT 'CLOCK' AS k,
|
||||
COALESCE((SELECT value FROM page_generation_clock WHERE id = 1), 0)::bigint AS v,
|
||||
TRUE AS is_max`,
|
||||
[pageIds],
|
||||
);
|
||||
|
||||
@@ -104,10 +132,12 @@ export async function buildPageGenerationsSnapshot(
|
||||
}
|
||||
return snapshot;
|
||||
} catch {
|
||||
// Pre-v91 brain (no `generation` column yet). Return the
|
||||
// backward-compat empty snapshot with zero bookmark — every cache
|
||||
// row will fall through to Layer 2 and serve via the `'{}'::jsonb`
|
||||
// vacuously-valid path. Closes the upgrade-path gap.
|
||||
// Pre-v105 brain (no `page_generation_clock` table yet). Return the
|
||||
// empty snapshot with zero bookmark — every cache row will fall
|
||||
// through to Layer 2 (which is stricter post-v0.41.19.0 and will
|
||||
// invalidate empty snapshots). Acceptable upgrade-path one-time
|
||||
// cache miss; migration v105 fills the table within the same
|
||||
// initSchema() call so this branch is short-lived.
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
@@ -127,17 +157,22 @@ export async function buildPageGenerationsSnapshot(
|
||||
*/
|
||||
export const CACHE_GATE_WHERE_CLAUSE = `
|
||||
(
|
||||
-- Layer 1 (cheap bookmark): O(log N) MAX(generation) via pages_generation_idx.
|
||||
-- If no page has been bumped since this row stored, the row is fresh.
|
||||
(SELECT COALESCE(MAX(generation), 0) FROM pages) <= qc.max_generation_at_store
|
||||
-- Layer 1 (cheap bookmark): O(1) single-row read from page_generation_clock.
|
||||
-- Bumped per-statement by bump_page_generation_clock_trg on every INSERT,
|
||||
-- UPDATE, or DELETE on pages. If no statement has fired since this row
|
||||
-- stored, the row is fresh corpus-wide.
|
||||
COALESCE((SELECT value FROM page_generation_clock WHERE id = 1), 0)
|
||||
<= qc.max_generation_at_store
|
||||
OR
|
||||
-- Layer 2 (per-page snapshot): bookmark fired, but maybe this row's
|
||||
-- specific result set isn't affected. Pre-v0.40.3.0 rows have
|
||||
-- page_generations = '{}'::jsonb and serve vacuously (legacy compat —
|
||||
-- IRON-RULE regression in test/e2e/cache-gate-pglite.test.ts).
|
||||
-- Layer 2 (per-page snapshot): bookmark fired, but maybe THIS row's
|
||||
-- specific result set isn't affected. v0.41.19.0+ requires the snapshot
|
||||
-- to be non-empty — empty {} snapshots cannot disprove staleness, so
|
||||
-- they invalidate when Layer 1 fails (D20 / codex CDX-6 fix). Per-page
|
||||
-- mismatch (page deleted via LEFT JOIN p.id IS NULL, or page generation
|
||||
-- bumped) invalidates.
|
||||
(
|
||||
qc.page_generations = '{}'::jsonb
|
||||
OR NOT EXISTS (
|
||||
qc.page_generations <> '{}'::jsonb
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(qc.page_generations) AS g(page_id, stored_gen)
|
||||
LEFT JOIN pages p ON p.id = (g.page_id)::int
|
||||
@@ -167,12 +202,18 @@ export function validateCacheRowAgainstPages(
|
||||
page_generations: Record<string, number | undefined>;
|
||||
},
|
||||
): boolean {
|
||||
// Layer 1: bookmark.
|
||||
// Layer 1: bookmark. `current.max_generation` is the global clock value
|
||||
// (kept named max_generation for back-compat at call sites; the underlying
|
||||
// read source switched from MAX(pages.generation) to
|
||||
// page_generation_clock.value in v0.41.19.0).
|
||||
if (current.max_generation <= snapshot.max_generation_at_store) return true;
|
||||
|
||||
// Layer 2: per-page snapshot.
|
||||
// Layer 2 (v0.41.19.0+ stricter, D20 / codex CDX-6): empty per-page
|
||||
// snapshots cannot disprove staleness, so they invalidate when Layer 1
|
||||
// fails. Pre-v0.41.19.0 callers got a "vacuously valid" pass that
|
||||
// silently served stale empty-result rows across writes.
|
||||
const ids = Object.keys(snapshot.page_generations);
|
||||
if (ids.length === 0) return true; // Vacuously valid (legacy + zero-page).
|
||||
if (ids.length === 0) return false;
|
||||
|
||||
for (const id of ids) {
|
||||
const storedGen = snapshot.page_generations[id];
|
||||
|
||||
+89
-32
@@ -181,6 +181,48 @@ CREATE TRIGGER bump_page_generation_trg
|
||||
-- CREATE INDEX since the table is empty.
|
||||
CREATE INDEX IF NOT EXISTS pages_generation_idx ON pages (generation);
|
||||
|
||||
-- v0.41.19.0 (D18/D19, codex outside-voice): global page-generation clock.
|
||||
-- The pre-v0.41.19.0 Layer 1 bookmark read `MAX(generation) FROM pages` to
|
||||
-- detect "writes happened since cache-store". Two bugs in that contract:
|
||||
-- 1. The row-level trigger above sets `NEW.generation = OLD.generation + 1`
|
||||
-- on UPDATE. Updating a NON-MAX page didn't advance MAX(generation),
|
||||
-- silently serving stale cached results.
|
||||
-- 2. The trigger is `BEFORE INSERT OR UPDATE` so DELETE doesn't fire it
|
||||
-- at all — and even if it did, DELETE doesn't touch surviving rows,
|
||||
-- so MAX(generation) wouldn't budge.
|
||||
--
|
||||
-- The fix: a single-row counter, bumped per-statement (FOR EACH STATEMENT
|
||||
-- — codex CDX-4: per-row would turn 73K-row batch DELETE into 73K UPDATEs
|
||||
-- on the same counter, recreating the bottleneck this PR is fixing). Layer
|
||||
-- 1 reads `page_generation_clock.value` directly. The per-row
|
||||
-- `pages.generation` column above stays as the Layer 2 (per-page snapshot)
|
||||
-- substrate.
|
||||
--
|
||||
-- Seeded with COALESCE(MAX(pages.generation), 0) so existing query_cache
|
||||
-- rows stored under the old MAX semantics aren't all instantly invalidated
|
||||
-- on upgrade (their max_generation_at_store stamp compares cleanly against
|
||||
-- the seeded clock; future writes bump the clock, bookmark fires correctly).
|
||||
CREATE TABLE IF NOT EXISTS page_generation_clock (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
value BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO page_generation_clock (id, value)
|
||||
VALUES (1, COALESCE((SELECT MAX(generation) FROM pages), 0))
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS $func$
|
||||
BEGIN
|
||||
UPDATE page_generation_clock SET value = value + 1 WHERE id = 1;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$func$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS bump_page_generation_clock_trg ON pages;
|
||||
CREATE TRIGGER bump_page_generation_clock_trg
|
||||
AFTER INSERT OR UPDATE OR DELETE ON pages
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION bump_page_generation_clock_fn();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_type ON pages(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_frontmatter ON pages USING GIN(frontmatter);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_trgm ON pages USING GIN(title gin_trgm_ops);
|
||||
@@ -600,38 +642,9 @@ CREATE TABLE IF NOT EXISTS op_checkpoints (
|
||||
CREATE INDEX IF NOT EXISTS op_checkpoints_updated_at_idx
|
||||
ON op_checkpoints (updated_at);
|
||||
|
||||
-- ============================================================
|
||||
-- migration_impact_log: before/after metric stats per onboard remediation
|
||||
-- ============================================================
|
||||
-- v0.41.18.0 (gbrain onboard wave). Every completion captured by the
|
||||
-- onboard remediation pipeline records before/after metric stats so
|
||||
-- `gbrain onboard --history --json` can show "you reduced orphans 47%".
|
||||
-- delta computed at read time (NOT a stored GENERATED column —
|
||||
-- zero PGLite parity risk per eng-review D2).
|
||||
--
|
||||
-- Attribution columns (job_id, source_id, brain_id, started_at,
|
||||
-- idempotency_key) per codex finding #10 so concurrent onboard /
|
||||
-- autopilot / manual runs can't misattribute deltas to the wrong
|
||||
-- migration when overlapping runs change the same metric.
|
||||
CREATE TABLE IF NOT EXISTS migration_impact_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
remediation_id TEXT NOT NULL,
|
||||
metric_name TEXT NOT NULL,
|
||||
metric_before NUMERIC,
|
||||
metric_after NUMERIC,
|
||||
job_id BIGINT REFERENCES minion_jobs(id) ON DELETE SET NULL,
|
||||
source_id TEXT,
|
||||
brain_id TEXT,
|
||||
started_at TIMESTAMPTZ,
|
||||
idempotency_key TEXT,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
applied_by TEXT,
|
||||
details JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS migration_impact_log_remediation_idx
|
||||
ON migration_impact_log(remediation_id, applied_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS migration_impact_log_attribution_idx
|
||||
ON migration_impact_log(job_id, source_id) WHERE job_id IS NOT NULL;
|
||||
-- migration_impact_log moved BELOW minion_jobs (was here, lines 645-676)
|
||||
-- because its `job_id BIGINT REFERENCES minion_jobs(id)` FK requires
|
||||
-- minion_jobs to exist FIRST during SCHEMA_SQL replay. v0.41.25.0 fix.
|
||||
|
||||
-- ============================================================
|
||||
-- files: binary attachments stored in Supabase Storage
|
||||
@@ -821,6 +834,50 @@ CREATE TABLE IF NOT EXISTS minion_attachments (
|
||||
CREATE INDEX IF NOT EXISTS idx_minion_attachments_job ON minion_attachments (job_id);
|
||||
ALTER TABLE minion_attachments ALTER COLUMN content SET STORAGE EXTERNAL;
|
||||
|
||||
-- ============================================================
|
||||
-- migration_impact_log: before/after metric stats per onboard remediation
|
||||
-- ============================================================
|
||||
-- v0.41.18.0 (gbrain onboard wave). Every completion captured by the
|
||||
-- onboard remediation pipeline records before/after metric stats so
|
||||
-- `gbrain onboard --history --json` can show "you reduced orphans 47%".
|
||||
-- delta computed at read time (NOT a stored GENERATED column —
|
||||
-- zero PGLite parity risk per eng-review D2).
|
||||
--
|
||||
-- Attribution columns (job_id, source_id, brain_id, started_at,
|
||||
-- idempotency_key) per codex finding #10 so concurrent onboard /
|
||||
-- autopilot / manual runs can't misattribute deltas to the wrong
|
||||
-- migration when overlapping runs change the same metric.
|
||||
--
|
||||
-- v0.41.25.0 SCHEMA_SQL ordering fix: this block lives AFTER the
|
||||
-- minion_jobs CREATE TABLE so the `job_id REFERENCES minion_jobs(id)`
|
||||
-- FK can resolve on fresh-install schema replay. Originally placed above
|
||||
-- minion_jobs in v0.41.18.0; that fired ERROR: relation "minion_jobs"
|
||||
-- does not exist on every fresh-install initSchema() (silent on master
|
||||
-- because postgres-js's unsafe() continued past the error, but the
|
||||
-- table never got created so any later query on migration_impact_log
|
||||
-- threw 42P01 — which cascaded as "relation minion_jobs does not exist"
|
||||
-- whenever subsequent statements that referenced minion_jobs ran AFTER
|
||||
-- the failed CREATE TABLE statement, aborting the entire SCHEMA_SQL batch).
|
||||
CREATE TABLE IF NOT EXISTS migration_impact_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
remediation_id TEXT NOT NULL,
|
||||
metric_name TEXT NOT NULL,
|
||||
metric_before NUMERIC,
|
||||
metric_after NUMERIC,
|
||||
job_id BIGINT REFERENCES minion_jobs(id) ON DELETE SET NULL,
|
||||
source_id TEXT,
|
||||
brain_id TEXT,
|
||||
started_at TIMESTAMPTZ,
|
||||
idempotency_key TEXT,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
applied_by TEXT,
|
||||
details JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS migration_impact_log_remediation_idx
|
||||
ON migration_impact_log(remediation_id, applied_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS migration_impact_log_attribution_idx
|
||||
ON migration_impact_log(job_id, source_id) WHERE job_id IS NOT NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Subagent runtime (v0.16.0) — durable LLM loops
|
||||
-- ============================================================
|
||||
|
||||
Reference in New Issue
Block a user