Files
gbrain/test/schema-bootstrap-coverage.test.ts
T
146a8f1eed v0.41.31.0 feat(embed): delta-aware sync --all cost gate + real stale-embedding semantics (#1632)
* fix(cost): embedding cost preview uses configured model rate, not hardcoded OpenAI

The sync --all cost gate computed spend from a hardcoded
EMBEDDING_COST_PER_1K_TOKENS = 0.00013 (OpenAI text-embedding-3-large)
and labeled the preview with the back-compat EMBEDDING_MODEL constant,
regardless of the actually-configured embedding model. A brain running a
cheaper model (e.g. zeroentropyai:zembed-1 @ $0.05/Mtok) saw a preview
that named the wrong provider and over-stated spend ~2.6x ($337 vs $130
on a 2.6B-token corpus).

estimateEmbeddingCostUsd now resolves the live model via the gateway and
prices it through embedding-pricing.ts (the existing per-provider:model
table), falling back to the OpenAI rate only when the gateway is
unconfigured (unit-test context) or the model is unknown. sync.ts surfaces
the real model name in the preview message and JSON.

Regression test pins model-aware pricing: openai 3-large vs zembed-1 must
produce materially different previews; collapsing both to the OpenAI number
fails the assertion.

* fix(cost): sync --all gate is informational when embed is deferred; delta-aware inline gate

Under federated_v2 (default), sync --all DEFERS embedding to per-source
embed-backfill jobs that already cap spend at $25/source/24h. The v0.20
cost gate predated that cap and fired ConfirmationRequired + exit 2 on
EVERY non-TTY sync --all without --yes, regardless of cost — blocking
nightly crons over already-synced corpora and forcing permanent --yes.

The gate is now mode-aware:
  - Deferred embed (v2 default): print an FYI deferred notice (cap-aware,
    "not charged by this sync") + the stale-chunk backlog estimate, and
    NEVER exit 2. The backfill cap is the real money gate.
  - Inline embed (v2 off, or --serial without --no-embed): keep the
    blocking gate, but estimate the actual delta — full-tree ceiling for
    changed sources (unchanged sources contribute 0 via the same git +
    chunker_version "do work?" gate doctor/sync use) + stale backlog — and
    block only when it exceeds the new configurable floor
    sync.cost_gate_min_usd (default $0.50).

New pure helpers in embedding.ts (willEmbedSynchronously, shouldBlockSync)
keep the decision logic hermetically testable. New engine method
sumStaleChunkChars (both engines) prices the embedding backlog via
estimateCostFromChars. estimateSyncAllCost's per-source walk extracted to
estimateSourceTreeTokens (reused by the inline estimator).

Regressions pinned: R-1 deferred non-TTY never exit 2 (headline), R-2
inline above-floor still exit 2 (protection), plus the willEmbedSynchronously
/ shouldBlockSync matrix and sumStaleChunkChars engine + scope + embed_skip
coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(embed): real stale semantics — re-embed on model/dims swap (migration v108)

Pre-v0.41.30 "stale" meant only `embedding IS NULL`, so swapping the
embedding model or dimensions left the whole corpus silently embedded under
the OLD model — `embed --stale` ignored it and search quality quietly
degraded.

New `pages.embedding_signature` (TEXT, migration v108) stamps the embedding
provenance (`<provider:model>:<dims>`) whenever a page's chunks are embedded.
A later model/dims swap makes the stored signature differ from the current
one, which the embed paths now detect and re-embed.

GRANDFATHER (critical): the stale predicate is
  `embedding IS NULL OR (embedding_signature IS NOT NULL AND <> $current)`
so a NULL signature is NEVER stale. After the migration every existing page
has NULL → none flagged → the next `embed --stale` does NOT re-embed the
whole corpus. Signatures are stamped going forward only.

Surface:
  - countStaleChunks / sumStaleChunkChars gain an optional `signature` opt
    that widens staleness (read-only; used by the dry-run preview + the
    sync cost preview, which is now signature-aware).
  - invalidateStaleSignatureEmbeddings(signature, sourceId?) NULLs the
    embeddings of signature-mismatched pages so the EXISTING NULL-embedding
    cursor (listStaleChunks, untouched) re-embeds them — keeps the keyset
    pagination logic intact.
  - setPageEmbeddingSignature stamps after a page's chunks land.
  - Both embed loops wired: `gbrain embed --stale`/`--all` (embed.ts) and the
    embed-backfill minion (embed-stale.ts) invalidate-then-stamp.

Migration v108 + bootstrap probe (both engines) + REQUIRED_BOOTSTRAP_COVERAGE
entry. Pinned by test/embedding-signature-stale.test.ts (R-4 grandfather,
mismatch detection, matching no-op, scoped invalidate, stamp) + the
bootstrap-coverage gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): surface embed-backfill job state in sources status + deferred notice

Under federated_v2, `sync --all` exits 0 and embedding lags behind in
embed-backfill jobs (subject to cooldown + the per-source 24h cap). Pre-fix
an operator had no signal those jobs were queued or lagging — the sync looked
"done" while embeddings trickled in later.

`gbrain sources status` now shows a BACKFILL column per source
(active(N)/queued(N)/idle) plus the last completion timestamp, read from
minion_jobs. The deferred-sync notice appends "N backfill job(s) queued" so a
cron operator sees work is enqueued, not lost. Both reads are best-effort —
a brain that never ran a worker (no minion_jobs table) reports idle/0 instead
of crashing the dashboard.

SyncStatusReportSource gains backfill_queued / backfill_active /
backfill_last_completed_at (additive; JSON envelope schema_version unchanged).
Pinned by a new case in test/e2e/sync-status-pglite.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: add currentEmbeddingSignature to embedding.ts mocks + sync stale EXPECTED_PHASES

Commit 3 made embed.ts import currentEmbeddingSignature from embedding.ts.
Four tests mock.module the whole embedding.ts and omitted the new export, so
embed.ts (imported transitively) failed at load with "Export named
'currentEmbeddingSignature' not found". Add the export to each mock:
embed.serial.test.ts, e2e/cycle.test.ts, e2e/dream.test.ts,
e2e/dream-cycle-phase-order-pglite.test.ts.

Also sync the stale EXPECTED_PHASES in dream-cycle-phase-order-pglite.test.ts
to match cycle.ts ALL_PHASES — extract_atoms, synthesize_concepts, and
conversation_facts_backfill drifted in after the test was last touched
(v0.41.0.0) and were never added, so both phase-order assertions were failing
on the branch before this wave (confirmed against 0906ab0a). The dry-run cycle
emits all 20 phases, so mirroring the constant makes both assertions pass.

Pre-existing, unrelated: cycle.test.ts / dream.test.ts have 5 runCycle
failures via direct `bun test` (the conversation_facts_backfill phase uses the
module-singleton getConnection) — present identically at 0906ab0a, not touched
by this wave.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: pin R-3 chunker-drift regression + embed-signature stamp-call wiring

Ship-workflow coverage audit flagged two gaps:
- R-3 (mandatory regression) had no dedicated test: the inline unchanged-source
  short-circuit requires git-unchanged AND chunker_version match, but nothing
  pinned the chunker half. Add a case where git is unchanged (HEAD==last_commit,
  clean) but chunker_version is stale → estimate still fires (exit 2), plus a
  control where chunker matches → short-circuits to $0 (no block).
- The embed loops' setPageEmbeddingSignature call-site was only kept green by the
  mock, never asserted. Add a test that runs `embed --all` and asserts the stamp
  fires once per page with the current signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(embed): stamp embedding_signature on the inline import + per-slug paths (F1); inline cost gate counts new-content only (F2)

Adversarial review caught that the stale-detection feature was inert for
non-federated/inline brains: the embed-write paths that DON'T go through
embed.ts/embed-stale.ts never stamped pages.embedding_signature.

F1 — stamp at the remaining write sites:
  - embedPage (gbrain embed <slug> + sync's post-import runEmbedCore({slugs}))
  - importFromContent markdown branch (inline import/sync embed + gbrain import)
  - importCodeFile (only when EVERY chunk was freshly embedded this call —
    reuse-by-hash carries old-model vectors, so a mixed page stays unstamped
    rather than falsely marked current)
Without this, inline-synced pages kept NULL signatures → grandfathered → never
re-embedded on a model/dims swap. Now all embed-write paths stamp.

F2 — coupled regression the F1 fix would otherwise introduce: the inline cost
gate added the stale backlog (NULL + signature drift) into the BLOCKING cost,
but `gbrain sync` inline only embeds new/changed content — the backlog is
`gbrain embed --stale`'s job. Once F1 gives inline brains real signatures, a
model swap would inflate the inline gate and block the next cron for cost the
sync never incurs. Inline blocking cost is now new-content only; the stale
backlog is shown informationally ("pending gbrain embed --stale"). Deferred
path keeps the signature-aware backlog FYI (the backfill does clear it).

Pinned by test/import-signature-stamp.serial.test.ts (inline stamp + --no-embed
NULL) and the existing R-2/R-3 inline-gate tests (still exit 2 on new-content).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.41.30.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sources): wire BACKFILL into the real `sources status` path (P0a); guard partial-page signature stamping (P0b)

Codex adversarial review caught two issues in the v0.41.30 wave:

P0a — `gbrain sources status` routes through computeAllSourceMetrics
(source-health.ts), not the buildSyncStatusReport helper where the BACKFILL
column was added, so the CLI never showed it. Add per-source embed-backfill
active/queued counts to computeAllSourceMetrics (one extra FILTER on the
existing minion_jobs query) and render a BACKFILL column in `sources status`.
The deferred-sync notice's queued-job count (live sync path) already worked.

P0b — embedPage / embedAllStale / embed-stale stamped embedding_signature
unconditionally after embedding only the STALE subset of a page's chunks. A
partially-embedded page (some chunks preserved from a prior embed under
unknown/old provenance) would be falsely marked current, hiding the old
vectors from future stale detection. Now stamp only when EVERY chunk of the
page was (re)embedded this pass (toEmbed === chunks / stale === existing).
importFromContent embeds the full chunk set so it stays unconditional;
importCodeFile already had the equivalent guard. `gbrain embed --all` fully
re-embeds and stamps mixed pages.

Accepted as documented limitations (not fixed): the inline cost gate can
over-estimate a >100-file `--serial` sync that performSync will defer
(non-default mode, conservative-high bias), and model-swap invalidation NULLs
drifted vectors before re-embed (a deliberate, rare operation).

Pinned by a new backfill-counts case in test/source-health.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.41.30.0

Refresh CLAUDE.md Key Files + Commands for the embedding cost-model + stale-semantics wave: model-aware cost helpers in embedding.ts (currentEmbeddingPricePerMTok / currentEmbeddingSignature / willEmbedSynchronously / shouldBlockSync), the embedding-signature stale-detection engine quartet (sumStaleChunkChars / setPageEmbeddingSignature / invalidateStaleSignatureEmbeddings + widened countStaleChunks), migration v108, signature stamping across embed.ts / import-file.ts, the mode-aware sync --all cost gate + sync.cost_gate_min_usd config key, and the sources status BACKFILL column. Add a same-dimension-swap auto-reembed note to docs/embedding-migrations.md. Regenerate llms-full.txt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: re-version 0.41.30.0 → 0.41.31.0 (queue slot)

Mechanical version-string sweep across VERSION, package.json, CHANGELOG,
CLAUDE.md, docs, source/test comments, and regenerated llms bundles. No logic
change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): pin embedding dim in cosine-rescore-column test (CI shard-5 contamination)

CI shard 5 failed deterministically with `expected 1280 dimensions, not 1536`.
Root cause: cosine-rescore-column.test.ts hardcodes 1536-dim `embedding`
vectors and asserts length 1536, but its beforeAll ran `initSchema()` with no
gateway config. initSchema sizes the `embedding` column from
getEmbeddingDimensions(), whose default is 1280 (zeroentropyai:zembed-1). The
test only passed by inheriting a leaked 1536 gateway config from an earlier
test (or, locally, from ~/.gbrain). When the v0.41.31 merge shifted the
weight-aware shard bin-packing, the file order changed so the 1280 default won
in CI → vector(1280) column → 1536 insert rejected. (Passed locally because
the dev machine's ~/.gbrain resolves 1536.)

Fix: configureGateway({ openai:text-embedding-3-large, 1536 }) in beforeAll
BEFORE connect/initSchema so the column is deterministically vector(1536)
regardless of ambient/leaked state, and resetGateway() in afterAll for
hygiene. Proven: under a forced-1280 gateway preload the old test reproduces
the exact CI error and the fixed test passes (4/4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:30:56 -07:00

863 lines
44 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' },
];
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;
`);
// 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.
// 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;
`);
// 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;
}
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, 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.
//
// 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 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);
// Build the "covered" set: for each (table, column) pair, true iff it's in
// the table's CREATE TABLE columns OR added by an ALTER TABLE in the
// bootstrap function.
const covered = (table: string, column: string): boolean => {
const cols = tableColumns.get(table);
if (cols && cols.has(column)) return true;
return bootstrapAdds.some(a => a.table === table && a.column === column);
};
// 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);
// 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 neither in the table's CREATE TABLE body nor 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.`,
);
}
}, 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>([
// 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.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' }]);
});