mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
* fix(schema): bootstrap timeline_entries.event_page_id forward reference — un-wedge pre-v121 upgrades (#2626 #2594 #2579 #2537 #2536) v0.42.56.0 (Chronicle, migration v121) added timeline_entries.event_page_id and two partial indexes in the embedded schema blobs without extending applyForwardReferenceBootstrap — any brain whose timeline_entries predates v121 wedged initSchema at blob replay ("column event_page_id does not exist") before runMigrations could apply v121, with no in-band recovery. - Add the timeline_entries.event_page_id probe + column-only ALTER to applyForwardReferenceBootstrap in BOTH engines; FK + partial indexes land via the idempotent v121 / blob replay afterwards. Stays in the always-run bootstrap (never a migration hook — those skip oddly-stamped brains). - REQUIRED_BOOTSTRAP_COVERAGE entry + strip blocks in both runtime tests. - e2e: pre-v121 rewind → full initSchema converges (indexes re-created); wedged-brain recovery — a brain that already FAILED the upgrade attempt converges on retry with full final shape (column + FK + both partial indexes) and no ledger residue. Absorbs PR #2548 (@chetan-guevara) and the e2e test from PR #2623 (@colinagent) — thank you both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): coverage guard cross-references migration-added columns — close the scan hole that shipped the v121 wedge The A2 static check treated a column as covered when the current CREATE TABLE body declared it. But a column that is BOTH in the blob's CREATE TABLE AND added by a migration is a forward reference by definition — on pre-existing tables CREATE TABLE IF NOT EXISTS no-ops and the blob's CREATE INDEX crashes initSchema before runMigrations can help. That mask is exactly how timeline_entries.event_page_id passed the guard while wedging every pre-v121 brain. - buildIndexRefCoveragePredicate: migration-added columns (from extractAddedColumnsFromMigrations over the MIGRATIONS array) require a bootstrap ALTER; CREATE TABLE presence no longer counts for them. - Unit test pins the v121 regression shape red/green with synthetic inputs; the A2 test pins the incident triple directly (migration-added + blob-indexed + bootstrap-covered). - The strengthened predicate immediately surfaced two more latent wedges of the same class: minion_jobs.timeout_at + minion_jobs.idempotency_key (migration v7, blob-indexed, unprobed). Added probes in both engines + coverage entries + runtime strip blocks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): serve --http hides the generated admin token from non-TTY output by default (#2624) Generated admin bootstrap tokens printed into container/log-aggregator stdout on every headless start. shouldSuppressBootstrapPrint now defaults to hidden unless stderr is an interactive TTY; env-sourced tokens are never printed; --print-admin-token is the explicit escape hatch for capturing the value on a trusted non-TTY start; --suppress-bootstrap-token still overrides everything. Unit-tested across all five postures. Absorbs PR #2625 (@irresi) — thank you. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): legacy bearer tokens honor permissions.takes_holders over serve --http (#2529) GBrainOAuthProvider.verifyAccessToken never returned takesHoldersAllowList, so the serve --http dispatch site always fell back to ['world'] — remote MCP callers with an operator-configured takes_holders grant saw only public takes. The legacy branch now extracts permissions.takes_holders exactly like src/mcp/http-transport.ts (fail-safe ['world'] default, non-string entries dropped, malformed permissions JSON fails closed without throwing), and AuthInfo carries the field as a typed contract. OAuth-registered clients have no takes_holders storage on oauth_clients; that lane is design work tracked in TODOS (column migration + DCR/ registration surface), not part of this hotfix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): get_chunks honors the federated source grant + stops shipping embedding vectors (#2555, getChunks half of #2544) The get_chunks op still used the pre-#2200 scalar pattern (ctx.sourceId ? {sourceId} : {}) and engine.getChunks had no sourceIds[] support — a federated client that could read a page via get_page got [] from get_chunks. The op now routes through sourceScopeOpts (canonical ladder: federated array > scalar floor > nothing) and both engines gain the getPage-style sourceIds[] precedence branch; the unset-opts 'default' floor is preserved for local callers (importCodeFile contract). While in the function: SELECT cc.* pulled every embedding vector over the wire per chunk only for rowToChunk to discard them — replaced with the explicit non-vector column list in both engines (the getChunks half of #2544; the per-put_page getAllSlugs half is tracked separately). getChunksWithEmbeddings stays scalar-only by design (engine-internal, zero remote-reachable callers — documented at the interface). Tests: op-level federated repro + isolation + default-floor bleed guard (PGLite), engine precedence + Chunk-shape pin, and a DATABASE_URL-gated engine-parity test covering all three scope shapes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): /admin/api/register-client accepts source + federatedRead bindings (#2143 enabler) The HTTP register endpoint hardcoded source_id='default' and federated_read=undefined — only the CLI could mint a client bound to a non-default source, so HTTP-registered MCP clients wrote into 'default' regardless of intent. The endpoint now accepts optional source / federatedRead body fields, validated via assertValidSourceId with a structured 400 on bad input; omitting both preserves the historical default. The admin-UI form layer is a tracked follow-up. Absorbs PR #2016 — thank you. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scope,calibration): think reads over MCP; source-scoped takes reads; calibration CLI reachability + model resolution (#2078-class #2451) - think op → scope:'read' for OAuth/MCP clients: the handler already forces save/take off for remote callers before persistence, so a read-scoped token can think without a write grant; local CLI persistence unchanged. Scope-annotation test carries an explicit remote-gated allowlist. - takes_list / takes_search / takes_scorecard / takes_calibration route through sourceScopeOpts (the #2200 class on the takes read lane) with engine support in BOTH engines + tests. - 'calibration' added to CLI_ONLY (the command was registered but unreachable — dispatch-gap class) and calibration_profile/voice-gate resolve models through the canonical gateway tier resolver instead of bare ids that parseModelId rejects. - BigInt-safe local-op output normalization (bigintToStringReplacer, postgres.js wire parity) — first half of the #2450 fix; the formatResult default case lands with the cli-output commit. Absorbs PR #2598 (@colinagent) and PR #2452 (@spinsirr) — thank you both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): output correctness — BigInt-safe rendering, search --json, files bigint, and 5 unreachable commands (#2450 #2527 #2042 #2035-class) - normalizeLocalResult wraps the local-op output round-trip with the bigint→string replacer (postgres.js wire parity; a bare stringify THROWS on BIGSERIAL keys); formatResult's default renderer gets the same replacer so nothing upstream can crash it. - search/query --json: CLI-local formatter flag threaded through the shared formatter — stdout is a parseable result array, never human text on the --json path (the #2042 residual). - file_list normalizes size_bytes (Postgres BIGINT → Number) so MCP serialization and the CLI KB math survive; null preserved. - NEW dispatch-gap guard: every handleCliOnly top-level case label must be reachable via CLI_ONLY. It immediately caught FIVE live unreachable commands: pages, backfill, reconcile-links, notability-eval (added to CLI_ONLY), and the documented 'gbrain search modes|stats|tune' dashboards (pre-fix, 'search modes' silently keyword-searched the word "modes") — now routed via a pre-dispatch subcommand gate. 'whoknows' stays on its op-alias route (collision guard); tracked with PR #2509. Absorbs PR #2494 and PR #2531 (@javieraldape) and adapts PR #472 (@vinsew) — thank you. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): updateSourceConfig survives mixed-array config rows + repair/doctor cover the subagent jsonb columns (#2251) The array-coercion branch called jsonb_each(elem) bare — a mixed array (e.g. '["x", {"last_full_cycle_at": ...}]') threw 'cannot call jsonb_each on a non-object' DURING row production, permanently failing every subsequent updateSourceConfig (last_full_cycle_at could never be written again). Non-object elements are now neutralized inline via a CASE-guarded jsonb_each; the row self-heals to a flat object on the next write, object elements' keys recovered. Pinned ungated on PGLite (real Postgres semantics) and via a DATABASE_URL-gated e2e on the real engine. repair-jsonb + doctor's jsonb_integrity check extend from 5 to 8 columns (subagent_messages.content_blocks, subagent_tool_executions.input/output — historical damage rows from the pre-v0.42.53.0 positional double-encode; the write paths themselves were fixed in #2375) with a to_regclass skip for brains predating those tables. Adapts the repair/doctor extension from PR #597 (@vinsew) — thank you. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): dry-run honesty — strict unknown-flag rejection CLI-wide + unify-types worker defaults to dry-run (#2185 #1575) #2185: 'gbrain init --migrate-only --dry-run' applied REAL migrations — flags are read ad hoc (args.includes) so anything a handler doesn't look for was silently ignored, including intent-bearing safety flags. The CLI now validates every flag pre-dispatch and pre-engine: - Op commands validate against the operation contract (op.params + CLI-local --json/--explain), mirroring parseOpArgs so flag values that begin with '--' are never misread. parseOpArgs also gains the --key=value inline form (previously parsed as a junk key that consumed the NEXT token). - CLI_ONLY commands validate against a GENERATED per-command registry (scripts/generate-flag-registry.ts scans each command's case block + imported modules + one level of relative imports; deliberately over-inclusive so a missed flag can't break a working invocation). Committed as src/core/cli-flag-registry.generated.ts; 'bun run build:flag-registry' regenerates. - Passthrough by construction: everything after '--', plus call / config / 'jobs submit' payloads (handler-defined params are their contract). - Guards: sweep test (every command × nonsense flag → error), acceptance tests (real flags, --no- negation, = form, -- passthrough), drift guard (every CLI_ONLY member has an entry), freshness guard (committed registry == fresh generator run), subprocess smokes incl. the literal #2185 repro failing loud with zero engine work. BREAKING: scripts passing stray flags now fail loud with "Unknown flag --x for 'gbrain <cmd>'" — that is the point. #1575: the unify-types worker registration passed apply ?? true while the handler documents 'Default false (dry-run)' — the canonical operator invocation destructively retyped 25K+ pages by default. Now ?? false with a structural test; explicit --params '{"apply":true}' is the only way to mutate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(todos): file fix-wave 1 follow-ups (OAuth takes_holders design, parseFlags end-state, whoknows routing, #2544 half, #1558 UI, #2536 diagnostics) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): memory-safe unit runner — adaptive concurrency + serial OOM rescue pass A default run (4 shards × 4 intra-shard files) holds up to 16 concurrent PGLite WASM instances (~1.5GB each). With sibling Conductor workspaces running their own suites, PGLite connect failed with 'Out of memory' across every shard at once — 369 phantom test failures on a healthy branch, indistinguishable from real breakage at a glance. Two default-on layers in scripts/run-unit-parallel.sh: 1. Memory-aware sizing: total concurrency is capped to available memory (vm_stat on macOS, MemAvailable on Linux) at GBRAIN_TEST_MEM_PER_FILE_MB (default 1536) per concurrent file, shedding shards before intra-shard width. Quiet machines are unaffected (banner: mem-ok); pressured ones degrade instead of OOMing (banner: mem-adapted AxB→CxD). 2. Serial OOM rescue: failures whose shard log carries the WASM out-of-memory signature are re-run at --max-concurrency 1 after the fan-out drains. Phantoms pass serially → run goes green with an oom_rescued note and the failure blocks marked superseded; real failures fail again and stay red. Plain assertion failures never match the signature and never enter the rescue lane (existing exit-code and failure-log contract tests unchanged). Escape hatches: GBRAIN_TEST_NO_MEM_ADAPT=1, GBRAIN_TEST_NO_OOM_FALLBACK=1. Tests: OOM-once fixture rescued to exit 0; kill-switch stays red; banner advertises the sizing verdict. Documented in docs/TESTING.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test,cli): merge-seam repairs — shard timeout for the tripled suite, init flag-error contract, think scope exemption Three post-merge repairs surfaced by the first full-suite run: - Shard timeout 1500s -> 3000s: the suite roughly tripled since the cap was sized (~3900 -> 11k+ tests; PGLite inits replay 120 migrations, was 92). Two shards were killed mid-progress at 1500s. - The #2185 pre-dispatch validator now emits the same error contract as init.ts's in-handler check it preempts: lowercase 'unknown flag' on stderr + structured {status:'error', reason:'invalid_flag'} on stdout for --json callers (pinned by test/init-migrate-only.test.ts). - test/operations-trust-boundary.test.ts gets the same documented remote-gated allowlist for think's read scope (#2598) that test/oauth.test.ts already carries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): rescue lane also covers externally-killed shards (sibling-workspace pkill / memory jetsam) Second phantom class observed on a multi-workspace Conductor machine: 3 shards SIGTERM'd + 1 SIGKILL'd at ~700s under a 3000s cap, all mid-progress — an external killer, not a wedge. The dead shards then poisoned the serial pass (lock/state residue → 18 more phantoms), and every one of the 18 passed standalone. The runner now stamps per-shard start/end epochs; a shard dying on 143/137 before 80% of SHARD_TIMEOUT is classified externally-killed and its file list joins the serial rescue queue (real wedges die AT the cap and stay red). Serial-pass failures that occur while any shard was externally killed are treated as suspect residue and rescued too. Structural tests pin the detector, threshold, and routing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): keep tokenEndpointAuthMethod terminal in the register-client destructure (PKCE structural contract) The #2016 absorb appended source/federatedRead after tokenEndpointAuthMethod; test/fix-wave-structural.test.ts pins tokenEndpointAuthMethod as the final destructured field (v0.36.1.x #1077 PKCE regression contract). The added fields move into the regex's optional-middle slot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): re-apply the #2598 remote-gated scope exemption to master's oauth scope-annotation test Taking master's v0.42.74.0 oauth.test.ts (its #2529 implementation) dropped the think read-scope allowlist that PR #2598 carries; re-applied to match test/operations-trust-boundary.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): pre-landing review batch — CLI-local flag acceptance, json= coherence, re-landed getChunks trim, payload-aware jsonb repair, rescue-lane tightening Ship Step 9 findings (checklist pass + 4 specialists), all verified before fixing: - CRITICAL: the #2185 validator rejected --source/--dry-run on op commands (makeContext CLI-locals consumed outside the op contract) — 'gbrain search "x" --source y' exited 1. Exempted with parser-mirroring value consumption + unit/subprocess tests incl. global-flag acceptance. - CRITICAL: --json=<v> diverged between validator (accepted) and parseOpArgs (junk-key path consumed the NEXT token, corrupting positionals). Parser now handles --json=true|false; =-forms of bare-only CLI-locals reject loud. parseOpArgs inline-= suite added (regression rule). - CRITICAL: the master merge silently restored SELECT cc.* in both engines' getChunks while docs claimed the #2544 trim. Re-landed the explicit non-vector column list + a source-level structural pin so a merge can't silently undo it again. - repair-jsonb/doctor: the subagent columns legitimately hold jsonb string scalars (persistToolExec binds pre-serialized strings) — unconditional unwrap would abort the repair run or corrupt legit values. jsonPayloadOnly predicate (JSON-container content only) on those 3 targets, mirrored in doctor + parameterized to_regclass + behavioral test (damage flagged, legit string ignored, absent table skipped). - runner rescue-lane tightening: serial failures rescue-eligible only with their own OOM signature or after an external shard kill (residue), never because a sibling shard OOM'd — flaky serial tests stay red. Rescue passes no longer double-count into TOTAL_PASS; shard timeout scales when mem-adaptation sheds shards; negative-path tests (mixed run stays red, deterministic OOM-signature failure stays red). - fail-closed remote spelling at 2 forward sites (ctx.remote !== false per the CLAUDE.md invariant); registry generator drops template-literal flag prefixes; real-PG e2es for the v121 + minion_jobs wedge classes; stale comments corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate flag registry after master merge (#3864 added extract help flags) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): red-team batch — dispatch-order validation, real --dry-run boolean, rescue-lane timeout/isolation/cap, parseable-payload repair gate Red-team pass (post-specialist) findings, all verified before fixing: - CRITICAL: validateCommandFlags checked the op lane before CLI_ONLY while dispatch runs CLI_ONLY first — dual-lane commands (think/salience/ anomalies) were validated against the WRONG contract, rejecting documented invocations ('salience --kind entity'). Lane order now mirrors dispatch. - CRITICAL: --dry-run was blessed as legal on op commands but parseOpArgs never SET it (trailing → nothing → ctx.dryRun false → the REAL destructive action ran; leading → consumed the next token). Now a CLI-local boolean exactly like --json, with --dry-run=false support and regression tests. - CRITICAL: the rescue lane ran bun test WITHOUT --timeout=60000 (bun default 5s) — PGLite phantoms re-failed on timeout and were mislabeled 'confirmed real'. Both rescue invocations now mirror the shard flags; serial files re-run one process per file (run-serial-tests.sh isolation contract); rescue wallclock capped at 2x the shard timeout. - CRITICAL: the jsonPayloadOnly probe matched container-LOOKING invalid JSON ('[INFO] fetch complete') whose repair cast would throw and abort the run mid-loop. Predicate now gates on pg_input_is_valid (PG16+, same floor as the existing IS JSON usage) + per-target catch records and continues; doctor mirrors; behavioral test covers the lookalike row. - Registry generator bounded at handleCliOnly's closing brace (the LAST case block absorbed ~100 junk flags from the rest of cli.ts, neutering strict validation for it); uppercase flag typos reject loudly in both lanes (handlers are lowercase-sensitive); --json=true spelling gets the structured invalid_flag envelope; get_chunks __all__ narrowing filed as a Wave 3 TODO. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(engines): getChunks trimmed SELECT must carry modality (codex P1-B) The #2544 egress trim replaced SELECT cc.* with an explicit column list but omitted cc.modality — every rowToChunk field except the vector must survive the trim, or the embed round-trip (getChunks -> upsertChunks) rewrites image chunks as text. Both engines; the structural pin now iterates the full rowToChunk field list instead of spot-checking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): safety flags require consumption evidence in the flag registry (codex P1-A) upgrade.ts prints a help hint naming another command's --dry-run; that literal is depth-0 text for post-upgrade, so the generator allowlisted --dry-run there — recreating the exact #2185 repro this wave kills (post-upgrade --dry-run accepted, ignored, migrations run for real). Safety flags now need a tight-quoted standalone literal (an args read like has('--dry-run')) before the registry grants them; prose bleed embeds the flag inside a longer string and never qualifies. Regenerated registry drops --dry-run from post-upgrade, keeps genuine consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v0.42.76.0 fix(upgrade,security,cli): strict flag validation, bootstrap-wedge class kill, federated chunk scope Version bump + CHANGELOG for the fix wave: CLI-wide unknown-flag rejection with a generated per-command registry (#2185, #1575 class), minion_jobs bootstrap probes + migration-aware coverage guard (v121 wedge class), get_chunks federated scope + egress trim (#2555, half of #2544), think read-scope over MCP (#2598), register-client source bindings (#2016, #2143 enabler), repair-jsonb/doctor subagent columns with a parse-validated damage predicate, memory-safe unit-test runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v0.42.76.0 CONTRIBUTING.md: test-runner claims match the memory-safe 4-shard default (was 8-shard) and the CLI-only command recipe now includes the build:flag-registry regen step. KEY_FILES.md: repair-jsonb entry updated to the 8-column parse-gated current state; new entries for the strict flag-validation subsystem and src/core/source-id.ts; operations/engine/ serve-http entries updated for get_chunks scope ladder + trimmed SELECT and the register-client HTTP source bindings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: apply doc-review fixes for v0.42.76.0 Cross-model doc review caught stragglers: docs/TESTING.md still said 8-shard in the file taxonomy, carried a two-generations-stale shard timeout default (600s -> 3000s), and didn't name the new run-unit-parallel regression test or the remaining runner knobs; CONTRIBUTING.md's fast-loop file count predated the tripled suite (92+ -> 1000+); test-count claims unified at 3700+; the CLIENT_FENCED_WRITE_OPS comment in operations.ts still described think as scope write; KEY_FILES names the exported findUnknownFlag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): rescue lane survives CI — strip ::group:: prefixes, satisfy the bun-test-timeout guard Two CI-only breaks from the master merge: (1) under GITHUB_ACTIONS the shard wraps file sections as ::group::path.test.ts, so the rescue pass extracted literal ::group:: non-paths that matched zero test files — failing_files_in_log now strips the prefix; (2) master's new check-bun-test-timeout guard greps for bare 'bun test' and tripped on run_rescue's comment text (the invocations themselves carry --timeout=60000) — comment reworded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): shard-mechanics tests disable mem-adaptation — CI's 7GB runner collapsed explicit 2 shards to 1 The runner deliberately adapts even explicit --shards to available memory (GBRAIN_TEST_NO_MEM_ADAPT=1 is the escape hatch); on GitHub's ~7GB runners that collapsed the tests' 2-shard sandbox runs to 1 shard, breaking every 'shard 1/2:' expectation while passing locally. The tests pin shard MECHANICS with tiny synthetic files, so they now set the escape hatch; the one test that checks the mem banner overrides it back on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
994 lines
51 KiB
TypeScript
994 lines
51 KiB
TypeScript
/**
|
|
* CI guard: PGLITE_SCHEMA_SQL must not forward-reference state that
|
|
* `applyForwardReferenceBootstrap` doesn't know how to create.
|
|
*
|
|
* Background: gbrain ships an "embedded latest schema" blob
|
|
* (`pglite-schema.ts`) for fast bootstraps, alongside a numbered migration
|
|
* chain (`migrate.ts`) for incremental upgrades. Across 2 years and 6 schema
|
|
* versions, every release that added a column-with-index in the schema blob
|
|
* without a corresponding bootstrap addition has triggered the same wedge
|
|
* incident class (#239, #243, #266, #266, #357, #366, #374, #375, #378,
|
|
* #395, #396).
|
|
*
|
|
* The bootstrap is the structural fix. This test enforces the contract:
|
|
* for every "forward reference" the schema blob makes (FK or indexed column
|
|
* defined later than its reference site, or any column that older brains
|
|
* lack), the bootstrap MUST add enough state so that running the schema
|
|
* blob is replay-safe on a brain that lacks every member of
|
|
* `REQUIRED_BOOTSTRAP_COVERAGE`.
|
|
*
|
|
* **When you add a new schema-blob forward reference:**
|
|
* 1. Extend `applyForwardReferenceBootstrap` in pglite-engine.ts +
|
|
* postgres-engine.ts to add the new state.
|
|
* 2. Add an entry to `REQUIRED_BOOTSTRAP_COVERAGE` below.
|
|
* 3. This test will pass.
|
|
*
|
|
* If you add a forward reference but skip step 1, this test fails. If you
|
|
* skip step 2, this test passes but the bootstrap silently drifts behind
|
|
* the schema. The eng-review polish notes recommended layered coverage
|
|
* (per-engine integration tests in `test/bootstrap.test.ts` +
|
|
* `test/e2e/postgres-bootstrap.test.ts`) to catch step 2 oversights.
|
|
*/
|
|
|
|
import { test, expect } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
|
|
// Tier 3 opt-out: this file tests the bootstrap coverage contract explicitly,
|
|
// running applyForwardReferenceBootstrap against fresh PGlite instances. A
|
|
// snapshot-loaded engine would skip the bootstrap entirely.
|
|
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
|
|
|
// Forward-reference targets that PGLITE_SCHEMA_SQL requires.
|
|
// When you add a new one, extend this list AND the bootstrap.
|
|
type ForwardReference =
|
|
| { kind: 'table'; name: string }
|
|
| { kind: 'column'; table: string; column: string };
|
|
|
|
const REQUIRED_BOOTSTRAP_COVERAGE: ForwardReference[] = [
|
|
// Forward-referenced by `pages.source_id REFERENCES sources(id)` and the
|
|
// `INSERT INTO sources (id, name, config) VALUES ('default', ...)` seed.
|
|
{ kind: 'table', name: 'sources' },
|
|
// Forward-referenced by `CREATE INDEX idx_pages_source_id ON pages(source_id)`.
|
|
{ kind: 'column', table: 'pages', column: 'source_id' },
|
|
// Forward-referenced by `CREATE INDEX idx_links_source ON links(link_source)`.
|
|
{ kind: 'column', table: 'links', column: 'link_source' },
|
|
// Forward-referenced by `CREATE INDEX idx_links_origin ON links(origin_page_id)`.
|
|
{ kind: 'column', table: 'links', column: 'origin_page_id' },
|
|
// v0.19+ — forward-referenced by `CREATE INDEX idx_chunks_symbol_name
|
|
// ON content_chunks(symbol_name) WHERE symbol_name IS NOT NULL`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'symbol_name' },
|
|
// v0.19+ — forward-referenced by `CREATE INDEX idx_chunks_language
|
|
// ON content_chunks(language) WHERE language IS NOT NULL`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'language' },
|
|
// v0.20+ Cathedral II — forward-referenced by `CREATE INDEX
|
|
// idx_chunks_search_vector ON content_chunks USING GIN(search_vector)`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'search_vector' },
|
|
// v0.20+ Cathedral II — forward-referenced by `CREATE INDEX
|
|
// idx_chunks_symbol_qualified ON content_chunks(symbol_name_qualified)`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'symbol_name_qualified' },
|
|
// v0.20+ Cathedral II — populated by update_chunk_search_vector trigger;
|
|
// present in PGLITE_SCHEMA_SQL CREATE TABLE definition.
|
|
{ kind: 'column', table: 'content_chunks', column: 'parent_symbol_path' },
|
|
{ kind: 'column', table: 'content_chunks', column: 'doc_comment' },
|
|
// v0.26.5 — forward-referenced by `CREATE INDEX pages_deleted_at_purge_idx
|
|
// ON pages (deleted_at) WHERE deleted_at IS NOT NULL`.
|
|
{ kind: 'column', table: 'pages', column: 'deleted_at' },
|
|
// v0.27.1 — forward-referenced by `CREATE INDEX idx_chunks_embedding_image
|
|
// ON content_chunks USING hnsw (embedding_image vector_cosine_ops)
|
|
// WHERE embedding_image IS NOT NULL`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'embedding_image' },
|
|
// v0.27.1 — added in the same migration as embedding_image. Sibling column;
|
|
// not directly forward-referenced by an index but the bootstrap adds it
|
|
// alongside embedding_image for the v39 contract.
|
|
{ kind: 'column', table: 'content_chunks', column: 'modality' },
|
|
// v0.26.3 (v33) — forward-referenced by `CREATE INDEX idx_mcp_log_agent_time
|
|
// ON mcp_request_log(agent_name, created_at DESC)`.
|
|
{ kind: 'column', table: 'mcp_request_log', column: 'agent_name' },
|
|
// v0.27 (v36) — forward-referenced by `CREATE INDEX
|
|
// idx_subagent_messages_provider ON subagent_messages (job_id, provider_id)`.
|
|
// Composite-index second column; the array-based test pattern misses these
|
|
// by default, which is why this fix wave's Step 3 replaces this with a
|
|
// SQL parser that extracts every column referenced by any DDL.
|
|
{ kind: 'column', table: 'subagent_messages', column: 'provider_id' },
|
|
// v0.29 (v40) — pages.emotional_weight populated by recompute_emotional_weight;
|
|
// bootstrapped alongside the v41 columns since they share the v0.29.1 wave.
|
|
{ kind: 'column', table: 'pages', column: 'emotional_weight' },
|
|
// v0.29.1 (v41) — forward-referenced by `CREATE INDEX pages_coalesce_date_idx
|
|
// ON pages ((COALESCE(effective_date, updated_at)))`. The expression-index
|
|
// claim from earlier plan iterations was wrong; PG's planner won't use a
|
|
// partial index for the negative side of a COALESCE — expression index is.
|
|
{ kind: 'column', table: 'pages', column: 'effective_date' },
|
|
// v0.29.1 (v41) — sibling columns added in the same migration as
|
|
// effective_date; bootstrap adds them all together.
|
|
{ kind: 'column', table: 'pages', column: 'effective_date_source' },
|
|
{ kind: 'column', table: 'pages', column: 'import_filename' },
|
|
{ kind: 'column', table: 'pages', column: 'salience_touched_at' },
|
|
// v0.31.2 (v50) — forward-referenced by `CREATE INDEX
|
|
// idx_ingest_log_source_type_created ON ingest_log (source_id, source_type,
|
|
// created_at DESC)`. Old brains have ingest_log without source_id; bootstrap
|
|
// adds the column before SCHEMA_SQL replay creates the index.
|
|
{ kind: 'column', table: 'ingest_log', column: 'source_id' },
|
|
// v0.18 (v18) — forward-referenced by `CREATE INDEX idx_files_source_id ON
|
|
// files(source_id)` and `CREATE INDEX idx_files_page_id ON files(page_id)`.
|
|
// Pre-v18 brains have files without these columns; bootstrap adds them
|
|
// before SCHEMA_SQL replay creates the indexes.
|
|
{ kind: 'column', table: 'files', column: 'source_id' },
|
|
{ kind: 'column', table: 'files', column: 'page_id' },
|
|
// v0.34.1 (v60+v61+v65) — forward-referenced by the FK
|
|
// `oauth_clients.source_id REFERENCES sources(id)` and the GIN index
|
|
// `idx_oauth_clients_federated_read ON oauth_clients USING GIN (federated_read)`.
|
|
// Pre-v60 brains have oauth_clients without these columns; bootstrap adds
|
|
// them before SCHEMA_SQL replay creates the FK + index.
|
|
{ kind: 'column', table: 'oauth_clients', column: 'source_id' },
|
|
{ kind: 'column', table: 'oauth_clients', column: 'federated_read' },
|
|
// v0.26.5 (v34) — promotes archive lifecycle from JSONB config to real
|
|
// columns on sources. CREATE TABLE IF NOT EXISTS is a no-op on existing
|
|
// sources tables, so the visibility filters in search/list_pages that
|
|
// reference these columns trip on pre-v34 brains. Bootstrap adds them
|
|
// before any visibility-filter SQL runs.
|
|
{ kind: 'column', table: 'sources', column: 'archived' },
|
|
{ kind: 'column', table: 'sources', column: 'archived_at' },
|
|
{ kind: 'column', table: 'sources', column: 'archive_expires_at' },
|
|
// v0.37.0 (v79) — forward-referenced by `CREATE INDEX
|
|
// pages_last_retrieved_at_idx ON pages (last_retrieved_at)`. Pre-v79 brains
|
|
// have pages without this column; bootstrap adds it before SCHEMA_SQL
|
|
// replay creates the index.
|
|
{ kind: 'column', table: 'pages', column: 'last_retrieved_at' },
|
|
// v0.38.0 (v81) — pages_provenance_columns adds four nullable columns
|
|
// (ingested_via, ingested_at, source_uri, source_kind) to track WHERE
|
|
// every page came from (capture-cli, webhook, put_page, dream, etc.).
|
|
// No SCHEMA_SQL index/FK references them today, but bootstrap probes
|
|
// are added defense-in-depth so future schema work that does reference
|
|
// them doesn't wedge pre-v81 brains. Renumbered v80→v81 during master
|
|
// merge with v0.37.2.0 takes_unresolvable_quality hotfix.
|
|
{ kind: 'column', table: 'pages', column: 'ingested_via' },
|
|
{ kind: 'column', table: 'pages', column: 'ingested_at' },
|
|
{ kind: 'column', table: 'pages', column: 'source_uri' },
|
|
{ kind: 'column', table: 'pages', column: 'source_kind' },
|
|
// v0.40.3.0 (v90, renumbered from v0.40.3.0 v81 on master merge) —
|
|
// contextual_retrieval_columns adds five additive columns wiring the
|
|
// three-tier wrapper ladder. Bootstrap probes added defense-in-depth
|
|
// for future schema work.
|
|
{ kind: 'column', table: 'pages', column: 'contextual_retrieval_mode' },
|
|
{ kind: 'column', table: 'pages', column: 'corpus_generation' },
|
|
{ kind: 'column', table: 'sources', column: 'contextual_retrieval_mode' },
|
|
{ kind: 'column', table: 'sources', column: 'trust_frontmatter_overrides' },
|
|
// v0.40.3.0 (v91) — pages.generation BIGINT bumped by the
|
|
// bump_page_generation_fn trigger. Forward-referenced by
|
|
// pages_generation_idx (CREATE INDEX ON pages (generation)) so bootstrap
|
|
// probes guard pre-v91 brains.
|
|
{ kind: 'column', table: 'pages', column: 'generation' },
|
|
// v0.41.31 (v108) — pages.embedding_signature TEXT for real stale
|
|
// semantics. No SCHEMA_SQL index references it; bootstrap probe is
|
|
// defense-in-depth (and satisfies the MIGRATIONS ADD COLUMN coverage gate).
|
|
{ kind: 'column', table: 'pages', column: 'embedding_signature' },
|
|
// v0.42.7 (v112) — forward-referenced by `CREATE INDEX
|
|
// pages_links_extracted_at_idx ON pages (source_id, links_extracted_at)`.
|
|
// Pre-v112 brains have pages without this column; bootstrap adds it before
|
|
// SCHEMA_SQL replay creates the index. Powers `gbrain extract --stale` + the
|
|
// `links_extraction_lag` doctor check.
|
|
{ kind: 'column', table: 'pages', column: 'links_extracted_at' },
|
|
// v121 — referenced by the timeline event lookup and dedup indexes before
|
|
// the numbered migration can add the column on an existing brain.
|
|
{ kind: 'column', table: 'timeline_entries', column: 'event_page_id' },
|
|
// v7-era — surfaced by the #2626-class scanner sweep: both columns are
|
|
// migration-added (v7) AND referenced by blob indexes
|
|
// (`idx_minion_jobs_timeout` partial on timeout_at, the partial UNIQUE
|
|
// `uniq_minion_jobs_idempotency` on idempotency_key). A pre-v7 minion_jobs
|
|
// wedges the blob replay exactly like the v121 incident.
|
|
{ kind: 'column', table: 'minion_jobs', column: 'timeout_at' },
|
|
{ kind: 'column', table: 'minion_jobs', column: 'idempotency_key' },
|
|
];
|
|
|
|
test('applyForwardReferenceBootstrap covers every forward reference declared in REQUIRED_BOOTSTRAP_COVERAGE', async () => {
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
// Strip every required forward-reference target so the brain looks like
|
|
// it pre-dates the migrations that introduced these objects. Drop columns
|
|
// before the table-level constraints that depend on them.
|
|
await db.exec(`
|
|
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
|
|
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
|
|
DROP INDEX IF EXISTS idx_pages_source_id;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS source_id;
|
|
DROP TABLE IF EXISTS sources CASCADE;
|
|
|
|
DROP INDEX IF EXISTS idx_links_source;
|
|
DROP INDEX IF EXISTS idx_links_origin;
|
|
ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS link_source;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS origin_page_id;
|
|
|
|
DROP INDEX IF EXISTS idx_chunks_symbol_name;
|
|
DROP INDEX IF EXISTS idx_chunks_language;
|
|
DROP INDEX IF EXISTS idx_chunks_search_vector;
|
|
DROP INDEX IF EXISTS idx_chunks_symbol_qualified;
|
|
DROP TRIGGER IF EXISTS chunk_search_vector_trigger ON content_chunks;
|
|
DROP FUNCTION IF EXISTS update_chunk_search_vector;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS symbol_name;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS language;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS parent_symbol_path;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS doc_comment;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS symbol_name_qualified;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS search_vector;
|
|
|
|
DROP INDEX IF EXISTS pages_deleted_at_purge_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS deleted_at;
|
|
|
|
DROP INDEX IF EXISTS idx_chunks_embedding_image;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding_image;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS modality;
|
|
|
|
DROP INDEX IF EXISTS idx_mcp_log_agent_time;
|
|
DROP INDEX IF EXISTS idx_mcp_log_time_agent;
|
|
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS agent_name;
|
|
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS params;
|
|
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS error_message;
|
|
|
|
DROP INDEX IF EXISTS idx_subagent_messages_provider;
|
|
ALTER TABLE subagent_messages DROP COLUMN IF EXISTS provider_id;
|
|
|
|
DROP INDEX IF EXISTS pages_coalesce_date_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date_source;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS import_filename;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS salience_touched_at;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS emotional_weight;
|
|
|
|
DROP INDEX IF EXISTS idx_ingest_log_source_type_created;
|
|
ALTER TABLE ingest_log DROP COLUMN IF EXISTS source_id;
|
|
|
|
DROP INDEX IF EXISTS idx_files_source_id;
|
|
DROP INDEX IF EXISTS idx_files_page_id;
|
|
ALTER TABLE files DROP COLUMN IF EXISTS source_id;
|
|
ALTER TABLE files DROP COLUMN IF EXISTS page_id;
|
|
|
|
DROP INDEX IF EXISTS idx_oauth_clients_federated_read;
|
|
ALTER TABLE oauth_clients DROP COLUMN IF EXISTS source_id;
|
|
ALTER TABLE oauth_clients DROP COLUMN IF EXISTS federated_read;
|
|
|
|
-- v0.40.3.0 v90 + v91 column strips so applyForwardReferenceBootstrap
|
|
-- has work to do. Only strip pages columns + the trigger; sources
|
|
-- columns were already nuked by the earlier DROP TABLE IF EXISTS
|
|
-- sources CASCADE, and the bootstrap needsPagesBootstrap branch
|
|
-- recreates sources from schema-embedded.ts (which now includes the
|
|
-- CR columns inline). Same convention as the sources.archived note.
|
|
DROP TRIGGER IF EXISTS bump_page_generation_trg ON pages;
|
|
DROP FUNCTION IF EXISTS bump_page_generation_fn;
|
|
DROP INDEX IF EXISTS pages_generation_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS generation;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS contextual_retrieval_mode;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS corpus_generation;
|
|
|
|
DROP INDEX IF EXISTS idx_timeline_event_dedup;
|
|
DROP INDEX IF EXISTS idx_timeline_event_page;
|
|
ALTER TABLE timeline_entries DROP CONSTRAINT IF EXISTS timeline_entries_event_page_id_fkey;
|
|
ALTER TABLE timeline_entries DROP COLUMN IF EXISTS event_page_id;
|
|
|
|
-- v7 minion_jobs strip (#2626 class sweep): timeout_at + idempotency_key
|
|
-- are migration-added and blob-indexed; strip so bootstrap must re-add.
|
|
DROP INDEX IF EXISTS idx_minion_jobs_timeout;
|
|
DROP INDEX IF EXISTS uniq_minion_jobs_idempotency;
|
|
ALTER TABLE minion_jobs DROP COLUMN IF EXISTS timeout_at;
|
|
ALTER TABLE minion_jobs DROP COLUMN IF EXISTS idempotency_key;
|
|
`);
|
|
|
|
// Note: we don't strip sources.archived* here because they're inline in the
|
|
// sources CREATE TABLE definition (no separate ALTER TABLE), and the
|
|
// earlier `DROP TABLE IF EXISTS sources CASCADE` already nuked them.
|
|
// The bootstrap's needsPagesBootstrap branch recreates sources without the
|
|
// archive columns; the new needsSourcesArchive probe adds them.
|
|
|
|
const { rows: preBootstrapTimelineEventPageId } = await db.query(`
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'public'
|
|
AND table_name = 'timeline_entries'
|
|
AND column_name = 'event_page_id'
|
|
`);
|
|
expect(preBootstrapTimelineEventPageId).toHaveLength(0);
|
|
|
|
// Run bootstrap in isolation (NOT initSchema). This is what we're testing.
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
|
|
// Assert every required forward-reference target now satisfies the
|
|
// schema-blob's expectations.
|
|
for (const ref of REQUIRED_BOOTSTRAP_COVERAGE) {
|
|
if (ref.kind === 'table') {
|
|
const { rows } = await db.query(
|
|
`SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = $1`,
|
|
[ref.name],
|
|
);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
} else {
|
|
const { rows } = await db.query(
|
|
`SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`,
|
|
[ref.table, ref.column],
|
|
);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
test('after bootstrap, PGLITE_SCHEMA_SQL replays without crashing on missing forward references', async () => {
|
|
// End-to-end contract: bootstrap → SCHEMA_SQL must succeed even on a brain
|
|
// that lacks every forward-referenced target. This catches the case where
|
|
// REQUIRED_BOOTSTRAP_COVERAGE drifts behind PGLITE_SCHEMA_SQL — if the
|
|
// schema blob added a new index on a column the bootstrap doesn't create,
|
|
// the SCHEMA_SQL exec below would crash even though the per-target asserts
|
|
// above pass.
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
await db.exec(`
|
|
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
|
|
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
|
|
DROP INDEX IF EXISTS idx_pages_source_id;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS source_id;
|
|
DROP TABLE IF EXISTS sources CASCADE;
|
|
DROP INDEX IF EXISTS idx_links_source;
|
|
DROP INDEX IF EXISTS idx_links_origin;
|
|
ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS link_source;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS origin_page_id;
|
|
DROP INDEX IF EXISTS pages_deleted_at_purge_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS deleted_at;
|
|
|
|
DROP INDEX IF EXISTS idx_chunks_embedding_image;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding_image;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS modality;
|
|
|
|
DROP INDEX IF EXISTS pages_coalesce_date_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date_source;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS import_filename;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS salience_touched_at;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS emotional_weight;
|
|
|
|
DROP INDEX IF EXISTS idx_timeline_event_dedup;
|
|
DROP INDEX IF EXISTS idx_timeline_event_page;
|
|
ALTER TABLE timeline_entries DROP CONSTRAINT IF EXISTS timeline_entries_event_page_id_fkey;
|
|
ALTER TABLE timeline_entries DROP COLUMN IF EXISTS event_page_id;
|
|
|
|
-- v7 minion_jobs strip (#2626 class sweep): the SCHEMA_SQL replay would
|
|
-- crash on idx_minion_jobs_timeout / uniq_minion_jobs_idempotency
|
|
-- without the bootstrap re-adding these migration-added columns.
|
|
DROP INDEX IF EXISTS idx_minion_jobs_timeout;
|
|
DROP INDEX IF EXISTS uniq_minion_jobs_idempotency;
|
|
ALTER TABLE minion_jobs DROP COLUMN IF EXISTS timeout_at;
|
|
ALTER TABLE minion_jobs DROP COLUMN IF EXISTS idempotency_key;
|
|
`);
|
|
|
|
// Bootstrap, then schema replay. Either step crashing fails the test.
|
|
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
await db.exec(PGLITE_SCHEMA_SQL);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// v0.28.5 — A2 structural prevention: auto-derive coverage from SQL.
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// The hand-maintained REQUIRED_BOOTSTRAP_COVERAGE array is the contract
|
|
// that's failed 11 times across 6 schema versions: every release that
|
|
// added a column-with-index in the schema blob without a corresponding
|
|
// bootstrap addition has triggered a wedge incident.
|
|
//
|
|
// Codex outside-voice review of v0.28.5's plan caught a critical hole in
|
|
// the array-based approach: composite indexes like
|
|
// `idx_subagent_messages_provider ON subagent_messages (job_id, provider_id)`
|
|
// have a SECOND-column forward reference (`provider_id`) that a first-col-
|
|
// only extractor would miss entirely. v0.27 wedged exactly this way.
|
|
//
|
|
// This parser extracts every column referenced by a CREATE INDEX in
|
|
// PGLITE_SCHEMA_SQL — including composite-index second/third columns —
|
|
// and asserts each one is either in the baseline CREATE TABLE OR added
|
|
// by `applyForwardReferenceBootstrap`. Self-updating: any future
|
|
// CREATE INDEX in the schema blob is structurally covered the moment
|
|
// it's added, with no human required to remember to update an array.
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse `CREATE TABLE [IF NOT EXISTS] <name> (<body>)` blocks.
|
|
* Returns a map from table name → set of column names declared in the body.
|
|
*
|
|
* Body parser is naive but sufficient for `pglite-schema.ts`: splits on
|
|
* commas at depth 0 (respecting nested parens for things like `vector(N)`,
|
|
* `numeric(p, s)`, `CHECK (col IN ('a', 'b'))`), skips constraint lines
|
|
* (CONSTRAINT/PRIMARY/UNIQUE/CHECK/FOREIGN), and grabs the first identifier
|
|
* of each remaining row as the column name.
|
|
*/
|
|
function parseBaseTableColumns(sql: string): Map<string, Set<string>> {
|
|
const result = new Map<string, Set<string>>();
|
|
const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\(/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(sql)) !== null) {
|
|
const tableName = m[1].toLowerCase();
|
|
const bodyStart = m.index + m[0].length;
|
|
let depth = 1;
|
|
let i = bodyStart;
|
|
while (i < sql.length && depth > 0) {
|
|
const ch = sql[i];
|
|
if (ch === '(') depth++;
|
|
else if (ch === ')') depth--;
|
|
i++;
|
|
}
|
|
const body = sql.slice(bodyStart, i - 1);
|
|
|
|
const columns = new Set<string>();
|
|
// Split body on commas at depth 0.
|
|
let parenDepth = 0;
|
|
let start = 0;
|
|
const parts: string[] = [];
|
|
for (let j = 0; j < body.length; j++) {
|
|
const ch = body[j];
|
|
if (ch === '(') parenDepth++;
|
|
else if (ch === ')') parenDepth--;
|
|
else if (ch === ',' && parenDepth === 0) {
|
|
parts.push(body.slice(start, j));
|
|
start = j + 1;
|
|
}
|
|
}
|
|
parts.push(body.slice(start));
|
|
|
|
for (const partRaw of parts) {
|
|
// Strip SQL line comments (`-- ...` to end of line) and block
|
|
// comments (`/* ... */`) before identifying the column name.
|
|
// Without this, a column definition preceded by a comment inside
|
|
// the CREATE TABLE body is silently dropped (the comment is the
|
|
// "first identifier" and the parser bails out).
|
|
const stripped = partRaw
|
|
.replace(/--[^\n]*/g, '')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
const part = stripped.trim();
|
|
if (!part) continue;
|
|
// Skip constraint lines.
|
|
if (/^(CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN|EXCLUDE)\b/i.test(part)) continue;
|
|
// First whitespace-separated token is the column name.
|
|
const colMatch = part.match(/^["`]?(\w+)["`]?/);
|
|
if (colMatch) columns.add(colMatch[1].toLowerCase());
|
|
}
|
|
result.set(tableName, columns);
|
|
}
|
|
|
|
// Also walk ALTER TABLE ... ADD COLUMN statements in the schema blob
|
|
// itself. Several columns (e.g. `pages.search_vector`) are added by an
|
|
// inline ALTER inside PGLITE_SCHEMA_SQL after the original CREATE TABLE.
|
|
// The schema-blob replay adds them in order, so they are NOT
|
|
// forward-references that bootstrap must provide — the schema blob
|
|
// itself self-heals on already-existing tables.
|
|
const alterRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(\w+)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/gi;
|
|
let am: RegExpExecArray | null;
|
|
while ((am = alterRe.exec(sql)) !== null) {
|
|
const tableName = am[1].toLowerCase();
|
|
const colName = am[2].toLowerCase();
|
|
if (!result.has(tableName)) result.set(tableName, new Set());
|
|
result.get(tableName)!.add(colName);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Parse `CREATE [UNIQUE] INDEX [IF NOT EXISTS] <name> ON <table> [USING method] (<cols>)`.
|
|
* Returns every (table, column) pair referenced — including composite-index
|
|
* second/third columns. Function-call wrappers like `lower(col)` are unwrapped
|
|
* to their inner identifier; literal-only expressions like `(slug, NULLS LAST)`
|
|
* keep the bare column.
|
|
*
|
|
* Out of scope: WHERE-clause columns in partial indexes (rare in our schema;
|
|
* those columns are always also referenced in the index column list itself).
|
|
* Trigger function bodies are out of scope (they reference NEW.col / OLD.col
|
|
* which the existing test file's strip-list handles separately).
|
|
*/
|
|
function parseIndexColumnReferences(sql: string): Array<{ table: string; column: string }> {
|
|
const result: Array<{ table: string; column: string }> = [];
|
|
// Match CREATE INDEX up through the column-list paren group.
|
|
const re = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?\w+\s+ON\s+(\w+)\s*(?:USING\s+\w+\s*)?\(/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(sql)) !== null) {
|
|
const table = m[1].toLowerCase();
|
|
const argsStart = m.index + m[0].length;
|
|
let depth = 1;
|
|
let i = argsStart;
|
|
while (i < sql.length && depth > 0) {
|
|
const ch = sql[i];
|
|
if (ch === '(') depth++;
|
|
else if (ch === ')') depth--;
|
|
i++;
|
|
}
|
|
const args = sql.slice(argsStart, i - 1);
|
|
|
|
// Split args on commas at depth 0.
|
|
let parenDepth = 0;
|
|
let start = 0;
|
|
const parts: string[] = [];
|
|
for (let j = 0; j < args.length; j++) {
|
|
const ch = args[j];
|
|
if (ch === '(') parenDepth++;
|
|
else if (ch === ')') parenDepth--;
|
|
else if (ch === ',' && parenDepth === 0) {
|
|
parts.push(args.slice(start, j));
|
|
start = j + 1;
|
|
}
|
|
}
|
|
parts.push(args.slice(start));
|
|
|
|
for (const partRaw of parts) {
|
|
// Strip ASC/DESC, NULLS FIRST/LAST modifiers.
|
|
const partClean = partRaw
|
|
.replace(/\s+(?:ASC|DESC)\s*$/i, '')
|
|
.replace(/\s+NULLS\s+(?:FIRST|LAST)\s*$/i, '')
|
|
.trim();
|
|
if (!partClean) continue;
|
|
// Two shapes to extract from:
|
|
// `col` — plain identifier
|
|
// `col vector_cosine_ops` — column followed by operator class (HNSW)
|
|
// `col COLLATE "C"` — column with collation
|
|
// `lower(col)` — function-wrapped
|
|
// For shapes 1-3, the column is the LEADING identifier. For shape 4,
|
|
// the column is the LAST identifier before a close paren.
|
|
let col: string | null = null;
|
|
if (partClean.includes('(')) {
|
|
// Function-wrapped: `lower(col)` → grab the last identifier inside.
|
|
const fnMatch = partClean.match(/(\w+)\s*\)\s*$/);
|
|
if (fnMatch) col = fnMatch[1];
|
|
} else {
|
|
// Plain or operator-class-suffixed: leading identifier wins.
|
|
const leadMatch = partClean.match(/^["`]?(\w+)["`]?/);
|
|
if (leadMatch) col = leadMatch[1];
|
|
}
|
|
if (col && !/^(true|false|null|asc|desc)$/i.test(col)) {
|
|
result.push({ table, column: col.toLowerCase() });
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
test('parseBaseTableColumns + parseIndexColumnReferences extract structural references', () => {
|
|
// Sanity checks for the parser helpers themselves. Runs in-process (no DB).
|
|
const fixture = `
|
|
CREATE TABLE IF NOT EXISTS pages (
|
|
id INTEGER PRIMARY KEY,
|
|
slug TEXT NOT NULL,
|
|
embedding vector(1536),
|
|
CONSTRAINT pages_slug_key UNIQUE (slug)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_pages_slug ON pages (slug);
|
|
CREATE INDEX idx_pages_lower ON pages (lower(slug));
|
|
CREATE INDEX idx_pages_composite ON pages (slug, id DESC);
|
|
CREATE INDEX idx_pages_hnsw ON pages USING hnsw (embedding vector_cosine_ops);
|
|
`;
|
|
const baseCols = parseBaseTableColumns(fixture);
|
|
expect(baseCols.get('pages')).toBeDefined();
|
|
expect(baseCols.get('pages')!.has('id')).toBe(true);
|
|
expect(baseCols.get('pages')!.has('slug')).toBe(true);
|
|
expect(baseCols.get('pages')!.has('embedding')).toBe(true);
|
|
// Constraint lines must NOT leak as columns.
|
|
expect(baseCols.get('pages')!.has('constraint')).toBe(false);
|
|
|
|
const refs = parseIndexColumnReferences(fixture);
|
|
// Single-col index.
|
|
expect(refs).toContainEqual({ table: 'pages', column: 'slug' });
|
|
// Function-wrapped column.
|
|
expect(refs.some(r => r.table === 'pages' && r.column === 'slug')).toBe(true);
|
|
// Composite — BOTH columns must be captured (codex's case).
|
|
expect(refs).toContainEqual({ table: 'pages', column: 'id' });
|
|
// USING hnsw with operator class.
|
|
expect(refs).toContainEqual({ table: 'pages', column: 'embedding' });
|
|
});
|
|
|
|
test('parseIndexColumnReferences catches v0.27 composite second-column case', () => {
|
|
// The exact codex regression: `idx_subagent_messages_provider ON
|
|
// subagent_messages (job_id, provider_id)` has provider_id as the SECOND
|
|
// column. A first-col-only extractor would miss this — v0.27 wedged exactly
|
|
// because earlier patterns missed it.
|
|
const fixture = `
|
|
CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider
|
|
ON subagent_messages (job_id, provider_id);
|
|
`;
|
|
const refs = parseIndexColumnReferences(fixture);
|
|
expect(refs).toContainEqual({ table: 'subagent_messages', column: 'job_id' });
|
|
expect(refs).toContainEqual({ table: 'subagent_messages', column: 'provider_id' });
|
|
});
|
|
|
|
/**
|
|
* Parse `ALTER TABLE [IF EXISTS] [ONLY] <table> ADD COLUMN [IF NOT EXISTS] <col>`
|
|
* statements out of an arbitrary SQL string. Used to extract the (table, column)
|
|
* pairs that `applyForwardReferenceBootstrap` adds, so we can verify static
|
|
* coverage without running a DB.
|
|
*/
|
|
function parseAlterAddColumns(sql: string): Array<{ table: string; column: string }> {
|
|
const result: Array<{ table: string; column: string }> = [];
|
|
const re = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(\w+)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(sql)) !== null) {
|
|
result.push({ table: m[1].toLowerCase(), column: m[2].toLowerCase() });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* The coverage predicate for blob index-column references, extracted so the
|
|
* v121-regression unit test below can exercise it with synthetic inputs.
|
|
*
|
|
* v0.42.58 (#2626 class): CREATE TABLE presence must NOT count as coverage
|
|
* for a column that ANY migration also adds via ALTER TABLE ADD COLUMN. The
|
|
* migration's existence proves pre-existing tables can lack the column, and
|
|
* on those brains `CREATE TABLE IF NOT EXISTS` no-ops — so the blob's
|
|
* CREATE INDEX crashes initSchema before runMigrations can help. For such
|
|
* columns, only an applyForwardReferenceBootstrap ALTER counts. This is
|
|
* exactly how `timeline_entries.event_page_id` (v121, Life Chronicle) shipped
|
|
* a P0 upgrade wedge past the old predicate: it was in the current CREATE
|
|
* TABLE body, so the check passed while every pre-v121 brain wedged.
|
|
*/
|
|
function buildIndexRefCoveragePredicate(
|
|
tableColumns: Map<string, Set<string>>,
|
|
bootstrapAdds: Array<{ table: string; column: string }>,
|
|
migrationAddedKeys: Set<string>,
|
|
): (table: string, column: string) => boolean {
|
|
return (table: string, column: string): boolean => {
|
|
const inBootstrap = bootstrapAdds.some(a => a.table === table && a.column === column);
|
|
if (inBootstrap) return true;
|
|
// Migration-added columns are forward references by definition —
|
|
// CREATE TABLE presence is exactly the mask that hid the v121 wedge.
|
|
if (migrationAddedKeys.has(`${table}.${column}`)) return false;
|
|
const cols = tableColumns.get(table);
|
|
return Boolean(cols && cols.has(column));
|
|
};
|
|
}
|
|
|
|
test('buildIndexRefCoveragePredicate: CREATE TABLE presence does not mask migration-added columns (v121 regression shape)', () => {
|
|
const tableColumns = new Map([['timeline_entries', new Set(['id', 'event_page_id'])]]);
|
|
const migrationAdded = new Set(['timeline_entries.event_page_id']);
|
|
|
|
// The exact pre-fix v121 shape: column in CREATE TABLE, added by migration,
|
|
// NO bootstrap probe → must be UNCOVERED (old predicate said covered).
|
|
const withoutProbe = buildIndexRefCoveragePredicate(tableColumns, [], migrationAdded);
|
|
expect(withoutProbe('timeline_entries', 'event_page_id')).toBe(false);
|
|
// Plain blob-native column (not migration-added) stays covered by CREATE TABLE.
|
|
expect(withoutProbe('timeline_entries', 'id')).toBe(true);
|
|
|
|
// With the bootstrap probe present, the same column is covered.
|
|
const withProbe = buildIndexRefCoveragePredicate(
|
|
tableColumns,
|
|
[{ table: 'timeline_entries', column: 'event_page_id' }],
|
|
migrationAdded,
|
|
);
|
|
expect(withProbe('timeline_entries', 'event_page_id')).toBe(true);
|
|
});
|
|
|
|
test('every CREATE INDEX column in PGLITE_SCHEMA_SQL is covered by CREATE TABLE or bootstrap (A2 static check)', async () => {
|
|
// The structural test that closes the 11-incident wedge class. Static
|
|
// contract: every column referenced by a CREATE INDEX in PGLITE_SCHEMA_SQL
|
|
// must be either (a) declared in the current CREATE TABLE body AND not
|
|
// added by any migration (see buildIndexRefCoveragePredicate — migration-
|
|
// added columns are forward references even when the CREATE TABLE body has
|
|
// them), or (b) added by `applyForwardReferenceBootstrap` in
|
|
// pglite-engine.ts.
|
|
//
|
|
// Codex outside-voice review caught the 11th wedge: composite-index second
|
|
// columns (`provider_id` in `(job_id, provider_id)`) are forward references
|
|
// that earlier extractors missed. This parser walks the full column list
|
|
// of every index — composite or not — and asserts each one is covered.
|
|
// The 12th wedge (v121 `timeline_entries.event_page_id`, #2626 #2594 #2579
|
|
// #2537 #2536) slipped through because CREATE TABLE presence masked the
|
|
// forward reference; the predicate now cross-references MIGRATIONS.
|
|
//
|
|
// Self-updating: when a future migration adds a CREATE INDEX in
|
|
// PGLITE_SCHEMA_SQL on a column that bootstrap doesn't yet provide, this
|
|
// test fails loud at PR time. No human required to update an array.
|
|
const { readFileSync } = await import('fs');
|
|
const { resolve: resolvePath } = await import('path');
|
|
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
|
|
const { extractAddedColumnsFromMigrations } = await import('./helpers/extract-added-columns.ts');
|
|
|
|
const enginePath = resolvePath(process.cwd(), 'src/core/pglite-engine.ts');
|
|
const engineSrc = readFileSync(enginePath, 'utf-8');
|
|
|
|
const tableColumns = parseBaseTableColumns(PGLITE_SCHEMA_SQL);
|
|
const indexRefs = parseIndexColumnReferences(PGLITE_SCHEMA_SQL);
|
|
const bootstrapAdds = parseAlterAddColumns(engineSrc);
|
|
const migrationAddedKeys = new Set(
|
|
extractAddedColumnsFromMigrations().map(a => `${a.table}.${a.column}`),
|
|
);
|
|
|
|
const covered = buildIndexRefCoveragePredicate(tableColumns, bootstrapAdds, migrationAddedKeys);
|
|
|
|
// Sanity checks: parser caught the codex case AND bootstrap provides it.
|
|
expect(indexRefs).toContainEqual({ table: 'subagent_messages', column: 'provider_id' });
|
|
expect(bootstrapAdds).toContainEqual({ table: 'subagent_messages', column: 'provider_id' });
|
|
expect(covered('subagent_messages', 'provider_id')).toBe(true);
|
|
|
|
// Direct pin of the v121 incident: the column is migration-added, blob-
|
|
// indexed, and MUST be bootstrap-covered.
|
|
expect(migrationAddedKeys.has('timeline_entries.event_page_id')).toBe(true);
|
|
expect(indexRefs.some(r => r.table === 'timeline_entries' && r.column === 'event_page_id')).toBe(true);
|
|
expect(bootstrapAdds).toContainEqual({ table: 'timeline_entries', column: 'event_page_id' });
|
|
|
|
// The actual contract: every index column reference must be covered.
|
|
const uncovered: Array<{ table: string; column: string }> = [];
|
|
for (const ref of indexRefs) {
|
|
if (!covered(ref.table, ref.column)) {
|
|
uncovered.push(ref);
|
|
}
|
|
}
|
|
|
|
if (uncovered.length > 0) {
|
|
const list = uncovered.map(u => ` ${u.table}.${u.column}`).join('\n');
|
|
throw new Error(
|
|
`PGLITE_SCHEMA_SQL has ${uncovered.length} CREATE INDEX column reference(s) ` +
|
|
`that are not safely covered (in the CREATE TABLE body AND not migration-added, ` +
|
|
`or added by applyForwardReferenceBootstrap):\n${list}\n\n` +
|
|
`Fix: extend applyForwardReferenceBootstrap in src/core/pglite-engine.ts ` +
|
|
`(and the matching Postgres engine) with the missing ALTER TABLE ADD COLUMN. ` +
|
|
`A column that is BOTH in the blob's CREATE TABLE AND added by a migration ` +
|
|
`is a forward reference for pre-existing tables — CREATE TABLE presence ` +
|
|
`does not cover it (that mask shipped the v121 upgrade wedge).`,
|
|
);
|
|
}
|
|
}, 30000);
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// v0.36+ — MIGRATIONS introspection: catch the column-only forward-ref class.
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// The CREATE INDEX parser above kills the column-with-index forward-ref class.
|
|
// v0.26.5 (v34) introduced a column-ONLY class: `sources.archived` +
|
|
// `sources.archived_at` + `sources.archive_expires_at` aren't indexed but
|
|
// `CREATE TABLE IF NOT EXISTS sources` is a no-op on pre-v34 brains. The
|
|
// schema-blob replay never adds the archive columns, so downstream visibility
|
|
// filters trip immediately.
|
|
//
|
|
// This test walks every `ALTER TABLE ... ADD COLUMN` in the MIGRATIONS array
|
|
// (our own structured code, not arbitrary Postgres DDL) and asserts every
|
|
// (table, column) pair is also added by `applyForwardReferenceBootstrap`.
|
|
// Future contributors who add a migration with ALTER TABLE ADD COLUMN AND
|
|
// forget to extend the bootstrap will see this test fail at PR time with a
|
|
// paste-ready `Add probe for <table>.<column>` message.
|
|
//
|
|
// Why regex-on-our-own-SQL is safe vs regex-on-prod-Postgres-DDL: every
|
|
// migration's SQL string is authored by us with consistent shape. The
|
|
// ALTER TABLE ADD COLUMN pattern is stable across all 60+ existing
|
|
// migrations. We control the input, not Postgres.
|
|
//
|
|
// Exemption mechanism: some migrations add columns that are intentionally
|
|
// not in the schema blob (one-off transition columns later dropped, etc.).
|
|
// Those go in the COLUMN_EXEMPTIONS set below with a brief rationale.
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
const COLUMN_EXEMPTIONS = new Set<string>([
|
|
// T7 — search_telemetry rank-1 drift columns (migration v111). search_telemetry
|
|
// is created entirely by migration v57 (not in the schema blob), so the v57+v111
|
|
// chain handles fresh + upgrade; no CREATE INDEX references these columns, so
|
|
// there's no forward reference for the bootstrap to cover.
|
|
'search_telemetry.sum_rank1_score',
|
|
'search_telemetry.count_rank1',
|
|
'search_telemetry.rank1_lt_solid',
|
|
'search_telemetry.rank1_solid',
|
|
'search_telemetry.rank1_high',
|
|
// Schema-blob-not-yet-refreshed: each of these columns is added by a
|
|
// migration but NOT (yet) referenced by `PGLITE_SCHEMA_SQL` (neither in a
|
|
// CREATE TABLE body nor in any CREATE INDEX). Bootstrap doesn't need to
|
|
// add them because there's no forward reference for the schema blob's
|
|
// replay to trip on. The migration handles every upgrade path correctly:
|
|
// - fresh install: schema blob replays, then migration adds the column.
|
|
// - pre-existing brain missing the column: migration adds it via ALTER.
|
|
// - pre-existing brain already on this column: ALTER ... IF NOT EXISTS no-ops.
|
|
// If a future migration adds a CREATE INDEX that references one of these
|
|
// columns, the existing v0.28.5 CREATE-INDEX parser will catch it and
|
|
// force a bootstrap probe (and the exemption should be removed).
|
|
//
|
|
// Refreshing PGLITE_SCHEMA_SQL is a separate concern handled by
|
|
// `bun run build:schema` from src/schema.sql; not gated by this test.
|
|
'minion_jobs.quiet_hours',
|
|
'minion_jobs.stagger_key',
|
|
'sources.chunker_version',
|
|
'access_tokens.permissions',
|
|
'takes.resolved_quality',
|
|
'pages.emotional_weight_recomputed_at',
|
|
'facts.notability',
|
|
'facts.row_num',
|
|
'facts.source_markdown_slug',
|
|
'pages.chunker_version',
|
|
'pages.source_path',
|
|
'content_chunks.edges_backfilled_at',
|
|
'query_cache.knobs_hash',
|
|
// v0.40.3.0 (migration v90, renumbered from v0.40.3.0 v81 on master merge)
|
|
// — query_cache is migration-only (added in v55), not in PGLITE_SCHEMA_SQL.
|
|
// The v90 ALTER TABLE query_cache ADD COLUMN page_generations runs after
|
|
// v55 in the migration sequence, so fresh installs get it correctly. No
|
|
// forward-reference exists for PGLITE_SCHEMA_SQL to trip on because
|
|
// query_cache isn't in the schema blob to begin with. Same exemption
|
|
// rationale as knobs_hash.
|
|
'query_cache.page_generations',
|
|
// v0.40.3.0 (migration v91) — same exemption rationale: query_cache is
|
|
// migration-only; max_generation_at_store is added by v91 ALTER and never
|
|
// forward-referenced by PGLITE_SCHEMA_SQL.
|
|
'query_cache.max_generation_at_store',
|
|
// v0.35.6 (migration v67) — typed-claim columns + facts_typed_claim_idx
|
|
// partial index are co-defined in the same migration, so the schema-blob
|
|
// forward-reference path isn't tripped. Bootstrap is only required when an
|
|
// index in PGLITE_SCHEMA_SQL references a column added by a later migration.
|
|
'facts.claim_metric',
|
|
'facts.claim_value',
|
|
'facts.claim_unit',
|
|
'facts.claim_period',
|
|
// v0.40.2.0 (migration v89) — event_type column. Same precedent as
|
|
// facts.claim_metric et al: no forward-reference index in
|
|
// PGLITE_SCHEMA_SQL, no downstream filter breaks on old brains
|
|
// (existing callers — founder-scorecard, eval-trajectory,
|
|
// gbrain think trajectory injection — all defensively skip
|
|
// NULL-metric rows in per-metric math, so event_type=NULL on old
|
|
// brains is invisible to them). Migration is column-only, no FK,
|
|
// no index — bootstrap probe would be pure overhead.
|
|
'facts.event_type',
|
|
// v0.42.56.0 (migration v122, #2390) — Life Chronicle ontology columns.
|
|
// Same precedent as facts.claim_metric et al: the `facts` table itself is
|
|
// migration-created (absent from PGLITE_SCHEMA_SQL), so no schema-blob
|
|
// forward reference can exist; the partial indexes (idx_facts_dimension,
|
|
// idx_facts_ontology_dedup) live INSIDE the same v122 migration. Every
|
|
// reader filters `dimension IS NOT NULL`, so NULL on old brains is
|
|
// invisible. Column-only, no bootstrap probe needed.
|
|
'facts.dimension',
|
|
'facts.value',
|
|
'facts.value_hash',
|
|
'facts.dim_status',
|
|
// v0.39.1.0 (migration v88) — schema-pack provenance per-source captured as
|
|
// inline canonical closure snapshot on every eval_candidates row. NULL by
|
|
// default; no index in PGLITE_SCHEMA_SQL references it. Migration handles
|
|
// both fresh installs and pre-existing brains via ADD COLUMN IF NOT EXISTS.
|
|
// Schema-pack codegen (scripts/generate-gbrain-base.ts) consumes the value
|
|
// only via the eval-replay CLI, not via SQL filters that would force a
|
|
// bootstrap probe.
|
|
'eval_candidates.schema_pack_per_source',
|
|
// v0.41 (migration v94) — minions cathedral budget columns. Same precedent
|
|
// as facts.claim_metric and friends: column-only additions on `minion_jobs`,
|
|
// no forward-reference index in PGLITE_SCHEMA_SQL (the partial indexes
|
|
// `minion_jobs_budget_owner_idx` + `minion_jobs_budget_root_owner_idx`
|
|
// live INSIDE the same v93 migration, not in the schema blob), and
|
|
// downstream callers explicitly handle NULL via the Eng D10 NULL-bypass
|
|
// branch in budget-tracker (jobs without `budget_owner_job_id` skip
|
|
// reservation entirely). Old brains pre-v93 silently get NULL on these
|
|
// columns; the budget enforcement path treats NULL as "no budget."
|
|
'minion_jobs.budget_remaining_cents',
|
|
'minion_jobs.budget_owner_job_id',
|
|
'minion_jobs.budget_root_owner_id',
|
|
]);
|
|
|
|
test('every ALTER TABLE ADD COLUMN in MIGRATIONS is covered by applyForwardReferenceBootstrap (column-only class)', async () => {
|
|
const { extractAddedColumnsFromMigrations } = await import('./helpers/extract-added-columns.ts');
|
|
const { readFileSync } = await import('fs');
|
|
const { resolve: resolvePath } = await import('path');
|
|
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
|
|
|
|
const enginePath = resolvePath(process.cwd(), 'src/core/pglite-engine.ts');
|
|
const engineSrc = readFileSync(enginePath, 'utf-8');
|
|
const bootstrapAdds = parseAlterAddColumns(engineSrc);
|
|
|
|
// Bootstrap's own CREATE TABLE statements (e.g. needsPagesBootstrap inlines
|
|
// `archived BOOLEAN ...` inside the CREATE TABLE sources block). Those
|
|
// count as covered without a separate ALTER TABLE ADD COLUMN.
|
|
const bootstrapCreateTableCols = parseBaseTableColumns(engineSrc);
|
|
|
|
// PGLITE_SCHEMA_SQL's CREATE TABLE definitions. The schema blob defines
|
|
// every modern table inline; columns added by migrations are typically
|
|
// ALSO updated in the schema blob so fresh installs get them natively.
|
|
// The bootstrap is only needed when: (a) the table existed before the
|
|
// migration ran (so CREATE TABLE IF NOT EXISTS is a no-op on old brains)
|
|
// AND (b) the column has a forward-reference index OR a downstream filter
|
|
// that breaks on old brains. Schema-blob coverage handles the fresh case.
|
|
const schemaCreateTableCols = parseBaseTableColumns(PGLITE_SCHEMA_SQL);
|
|
|
|
const migrationAdds = extractAddedColumnsFromMigrations();
|
|
|
|
const covered = (table: string, column: string): boolean => {
|
|
if (COLUMN_EXEMPTIONS.has(`${table}.${column}`)) return true;
|
|
if (bootstrapAdds.some(a => a.table === table && a.column === column)) return true;
|
|
const bootstrapCols = bootstrapCreateTableCols.get(table);
|
|
if (bootstrapCols && bootstrapCols.has(column)) return true;
|
|
const schemaCols = schemaCreateTableCols.get(table);
|
|
if (schemaCols && schemaCols.has(column)) return true;
|
|
return false;
|
|
};
|
|
|
|
const uncovered: typeof migrationAdds = [];
|
|
for (const ref of migrationAdds) {
|
|
if (!covered(ref.table, ref.column)) {
|
|
uncovered.push(ref);
|
|
}
|
|
}
|
|
|
|
if (uncovered.length > 0) {
|
|
const list = uncovered
|
|
.map(u => ` ${u.table}.${u.column}`)
|
|
.join('\n');
|
|
throw new Error(
|
|
`MIGRATIONS file (src/core/migrate.ts) adds ${uncovered.length} (table, column) pair(s) that ` +
|
|
`applyForwardReferenceBootstrap does NOT cover:\n${list}\n\n` +
|
|
`Fix one of:\n` +
|
|
` 1. Add a probe + ALTER TABLE ADD COLUMN in applyForwardReferenceBootstrap ` +
|
|
`(src/core/pglite-engine.ts AND src/core/postgres-engine.ts), OR\n` +
|
|
` 2. If the column is intentionally not in the schema blob ` +
|
|
`(transitional / handler-only / later-dropped), add the (table, column) ` +
|
|
`to COLUMN_EXEMPTIONS in test/schema-bootstrap-coverage.test.ts with a ` +
|
|
`brief rationale comment.`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('extractAddedColumnsFromMigrations sanity-checks against known migration column additions', async () => {
|
|
// Lightweight sanity test that the helper extracts the columns we expect
|
|
// for a few well-known v34 / v60 / v61 migrations. Catches regex
|
|
// regressions in the helper itself.
|
|
const { extractAddedColumnsFromMigrations } = await import('./helpers/extract-added-columns.ts');
|
|
const refs = extractAddedColumnsFromMigrations();
|
|
const has = (table: string, column: string) =>
|
|
refs.some(r => r.table === table && r.column === column);
|
|
// v34 sources.archived* (the codex C1 case)
|
|
expect(has('sources', 'archived')).toBe(true);
|
|
expect(has('sources', 'archived_at')).toBe(true);
|
|
expect(has('sources', 'archive_expires_at')).toBe(true);
|
|
// v60+v61 oauth_clients.*
|
|
expect(has('oauth_clients', 'source_id')).toBe(true);
|
|
expect(has('oauth_clients', 'federated_read')).toBe(true);
|
|
// v18 files.*
|
|
expect(has('files', 'source_id')).toBe(true);
|
|
expect(has('files', 'page_id')).toBe(true);
|
|
});
|
|
|
|
test('extractAlterAddColumnsFromSql handles representative migration SQL shapes', async () => {
|
|
const { __internal } = await import('./helpers/extract-added-columns.ts');
|
|
const fn = __internal.extractAlterAddColumnsFromSql;
|
|
|
|
// Standard shape (with IF NOT EXISTS)
|
|
expect(fn('ALTER TABLE sources ADD COLUMN IF NOT EXISTS archived BOOLEAN')).toEqual([
|
|
{ table: 'sources', column: 'archived' },
|
|
]);
|
|
// No IF NOT EXISTS (older migrations)
|
|
expect(fn('ALTER TABLE pages ADD COLUMN deleted_at TIMESTAMPTZ;')).toEqual([
|
|
{ table: 'pages', column: 'deleted_at' },
|
|
]);
|
|
// Multi-statement, mixed
|
|
expect(fn(`
|
|
CREATE INDEX foo ON bar(x);
|
|
ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS source_id TEXT REFERENCES sources(id);
|
|
ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS federated_read TEXT[] NOT NULL DEFAULT '{}';
|
|
UPDATE oauth_clients SET source_id = 'default';
|
|
`)).toEqual([
|
|
{ table: 'oauth_clients', column: 'source_id' },
|
|
{ table: 'oauth_clients', column: 'federated_read' },
|
|
]);
|
|
// Quoted identifiers
|
|
expect(fn('ALTER TABLE "pages" ADD COLUMN "effective_date" TIMESTAMPTZ')).toEqual([
|
|
{ table: 'pages', column: 'effective_date' },
|
|
]);
|
|
// ALTER TABLE IF EXISTS / ONLY variants
|
|
expect(fn('ALTER TABLE IF EXISTS ONLY content_chunks ADD COLUMN language TEXT')).toEqual([
|
|
{ table: 'content_chunks', column: 'language' },
|
|
]);
|
|
});
|
|
|
|
test('planted-bug: simulated unprovided column produces a clear failure message', async () => {
|
|
// Negative case — regression guard. If the contract test silently passes
|
|
// on uncovered columns, the gate is fake. This test plants a fake column
|
|
// in a fake SQL string and verifies the helper extracts it (proving the
|
|
// gate would catch it in the real contract test).
|
|
const { __internal } = await import('./helpers/extract-added-columns.ts');
|
|
const fn = __internal.extractAlterAddColumnsFromSql;
|
|
const planted = fn('ALTER TABLE pages ADD COLUMN IF NOT EXISTS planted_test_col TEXT');
|
|
expect(planted).toEqual([{ table: 'pages', column: 'planted_test_col' }]);
|
|
});
|