diff --git a/.changeset/cold-start-and-planner-stats.md b/.changeset/cold-start-and-planner-stats.md new file mode 100644 index 0000000..68c4acf --- /dev/null +++ b/.changeset/cold-start-and-planner-stats.md @@ -0,0 +1,36 @@ +--- +"@atmo-dev/contrail-appview": minor +"@atmo-dev/contrail-base": minor +--- + +perf: gate schema replay on a fingerprint; add opt-in planner-stat maintenance + +Two independent performance fixes found while profiling a D1 consumer. + +**Cold-start schema replay (always on).** `initSchema` ran ~40 base/collection/ +index/fts/feed/spaces DDL statements serially on every `init()` call, with no +gate. Consumers call `init()` once per isolate and Workers isolates recycle +constantly, so the first request to each cold isolate paid ~40 sequential +round-trips to the D1 storage object before any real work. `initSchema` now +records a fingerprint of the resolved schema (hash of the generated DDL + +`CONTRAIL_SCHEMA_VERSION`) in a new `_contrail_meta` table and, on a match, +skips all DDL after a single read. Steady-state cold start drops from ~40 +round-trips to one; the full apply only runs on first init or an actual schema +change. Concurrent-init safety on Postgres is unchanged (the gate just wraps the +existing idempotent apply). + +**Query-planner statistics (opt-in).** Without `ANALYZE`, SQLite's planner picks +the least-selective index for multi-predicate queries (measured ~50x more rows +read on a `subject.uri` + `status` filter). New opt-in config: + +```ts +maintenance: { optimize: true } // or { intervalMs, analysisLimit } +``` + +When enabled, the ingest tick runs a CPU-bounded `PRAGMA analysis_limit=400; +PRAGMA optimize` on a persisted daily cadence (stored in `_contrail_meta`, so it +isn't defeated by recycled isolates — the same in-memory-state bug the feed +prune had). `analysis_limit` bounds the work so it can't exceed D1's per-query +CPU budget and reset the DO. Also exposed as `contrail.optimize(db)` for +consumers that prefer to schedule it themselves. No-op on Postgres +(autovacuum/autoanalyze handles planner stats). diff --git a/packages/contrail-appview/src/core/db/index.ts b/packages/contrail-appview/src/core/db/index.ts index fcc52bd..544ade3 100644 --- a/packages/contrail-appview/src/core/db/index.ts +++ b/packages/contrail-appview/src/core/db/index.ts @@ -1,4 +1,6 @@ -export { initSchema } from "./schema"; +export { initSchema, CONTRAIL_SCHEMA_VERSION } from "./schema"; +export { getMeta, setMeta, getMetaNumber } from "./meta"; +export { optimizeDatabase } from "./optimize"; export { getLastCursor, saveCursor, applyEvents, lookupExistingRecords, queryRecords, queryAcrossSources, pruneFeedItems, pruneActorFeed, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./records"; export type { QueryOptions, SortOption, ExistingRecordInfo, FeedSweepResult } from "./records"; export type { RecordSource } from "../types"; diff --git a/packages/contrail-appview/src/core/db/meta.ts b/packages/contrail-appview/src/core/db/meta.ts new file mode 100644 index 0000000..19061ba --- /dev/null +++ b/packages/contrail-appview/src/core/db/meta.ts @@ -0,0 +1,47 @@ +import type { Database } from "../types"; + +/** + * Generic single-row-per-key store backed by `_contrail_meta(key, value)`. + * Backs the schema-fingerprint gate (schema.ts) and the optimize cadence + * timestamp (the ingest tick). + * + * Reads are tolerant: if the table doesn't exist yet — the first `initSchema` + * before any DDL has run — the read resolves to null rather than throwing, so a + * caller treats "no table" the same as "no value". Any transient read error + * degrades the same way (callers fall back to doing the work), which is safe + * because the only callers gate idempotent work on the result. + */ +export async function getMeta(db: Database, key: string): Promise { + try { + const row = await db + .prepare("SELECT value FROM _contrail_meta WHERE key = ?") + .bind(key) + .first<{ value: string }>(); + return row?.value ?? null; + } catch { + return null; + } +} + +export async function setMeta( + db: Database, + key: string, + value: string +): Promise { + await db + .prepare( + "INSERT INTO _contrail_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value" + ) + .bind(key, value) + .run(); +} + +export async function getMetaNumber( + db: Database, + key: string +): Promise { + const v = await getMeta(db, key); + if (v === null) return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} diff --git a/packages/contrail-appview/src/core/db/optimize.ts b/packages/contrail-appview/src/core/db/optimize.ts new file mode 100644 index 0000000..64daa08 --- /dev/null +++ b/packages/contrail-appview/src/core/db/optimize.ts @@ -0,0 +1,36 @@ +import type { Database } from "../types"; +import { getDialect, postgresDialect } from "../dialect"; + +/** + * Refresh the query planner's statistics so multi-predicate queries pick the + * selective index rather than the planner's default heuristic. + * + * SQLite/D1 only. Runs a CPU-bounded `PRAGMA optimize`: `analysis_limit` caps + * the rows sampled per run so it can't exceed D1's per-query CPU budget and + * reset the shared Durable Object — the same guardrail the feed prune needs (a + * raw unbounded `ANALYZE` on a large table is exactly that failure mode). + * `PRAGMA optimize` only reanalyzes tables whose stats are stale, so it's a + * near-no-op once warmed; the first call on a never-analyzed DB does the bulk + * of the work, which `analysis_limit` bounds. + * + * No-op on Postgres, where autovacuum/autoanalyze maintains planner stats. + * + * Surfaces errors to the caller (e.g. an environment that rejects the pragmas); + * the auto-run in the ingest tick wraps this so maintenance can't break ingest. + */ +export async function optimizeDatabase( + db: Database, + analysisLimit = 400 +): Promise { + if (getDialect(db) === postgresDialect) return; + + try { + await db + .prepare(`PRAGMA analysis_limit = ${Math.max(0, Math.floor(analysisLimit))}`) + .run(); + } catch { + // Some environments reject analysis_limit; PRAGMA optimize is still safe to + // run (just potentially less bounded), so don't abort on this. + } + await db.prepare("PRAGMA optimize").run(); +} diff --git a/packages/contrail-appview/src/core/db/schema.ts b/packages/contrail-appview/src/core/db/schema.ts index 7089827..1827269 100644 --- a/packages/contrail-appview/src/core/db/schema.ts +++ b/packages/contrail-appview/src/core/db/schema.ts @@ -12,6 +12,16 @@ import { import { getSearchableFields } from "../search"; import { buildSpacesBaseSchema } from "../spaces/schema"; import { buildLabelsSchema } from "../labels/schema"; +import { getMeta, setMeta } from "./meta"; + +/** Bump when contrail changes schema in a way the generated-DDL hash below + * can't see on its own — chiefly the spaces / community / labels internal + * table shapes (their DDL isn't all enumerated into the fingerprint). Pure + * config-driven changes (collections, feeds, indexes, migrations) bust the + * fingerprint automatically and don't need a bump. */ +export const CONTRAIL_SCHEMA_VERSION = 1; + +const SCHEMA_FINGERPRINT_KEY = "schema_fingerprint"; function getResolved(config: ContrailConfig): ResolvedMaps { return (config as ResolvedContrailConfig)._resolved ?? resolveConfig(config)._resolved; @@ -19,6 +29,10 @@ function getResolved(config: ContrailConfig): ResolvedMaps { function buildBaseSchema(dialect: SqlDialect): string { return ` +CREATE TABLE IF NOT EXISTS _contrail_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS backfills ( did TEXT NOT NULL, collection TEXT NOT NULL, @@ -474,6 +488,58 @@ async function applySpacesSchema( await applyCountColumns(target, config, { forSpaces: true }); } +/** Stable, dependency-free 64-bit-ish hash (two seeded FNV-1a passes → hex). + * Sync and Workers-safe (no crypto). Collisions only matter if two *different* + * schemas hash identically AND a deploy transitions between them — negligible, + * and CONTRAIL_SCHEMA_VERSION is the explicit backstop. */ +function hashStrings(parts: string[]): string { + const joined = parts.join(""); + let h1 = 0x811c9dc5; + let h2 = 0x01000193; + for (let i = 0; i < joined.length; i++) { + const c = joined.charCodeAt(i); + h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0; + h2 = Math.imul(h2 ^ c, 0x811c9dc5) >>> 0; + } + return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0"); +} + +/** Fingerprint of everything `initSchema` would apply, so an unchanged schema + * can skip the DDL entirely. Includes the generated core DDL (which already + * reflects collections/feeds/indexes), the count-column + migration set, the + * labels/spaces base DDL when enabled, feature flags, dialect, and the + * version constant. */ +function schemaFingerprint( + config: ContrailConfig, + dialect: SqlDialect, + ddl: { + base: string[]; + collections: string[]; + indexes: string[]; + feeds: string[]; + fts: string[]; + } +): string { + const hasSpaces = !!(config.spaces?.authority || config.spaces?.recordHost); + return hashStrings([ + `v${CONTRAIL_SCHEMA_VERSION}`, + dialect.bigintType, + config.community ? "community" : "", + config.spaces?.authority ? "spaces.authority" : "", + config.spaces?.recordHost ? "spaces.recordHost" : "", + config.labels ? "labels" : "", + ...ddl.base, + ...ddl.collections, + ...ddl.indexes, + ...ddl.feeds, + ...ddl.fts, + ...buildCountColumns(config), + ...(config.labels ? buildLabelsSchema(dialect) : []), + ...(hasSpaces ? buildSpacesBaseSchema(dialect) : []), + JSON.stringify(MIGRATIONS), + ]); +} + export async function initSchema( db: Database, config: ContrailConfig, @@ -488,6 +554,21 @@ export async function initSchema( const ftsStatements = buildFtsTables(config, dialect); const feedStatements = buildFeedTables(config, dialect); + // Steady-state fast path: if the schema already on disk matches what we'd + // apply, skip every DDL statement after one cheap read. Consumers call + // init() once per isolate, and Workers isolates recycle constantly, so + // otherwise the first request to each cold isolate pays ~40 sequential DDL + // round-trips to the D1 storage object before any real work. The read + // tolerates a missing `_contrail_meta` (true first init) and returns null. + const fingerprint = schemaFingerprint(config, dialect, { + base: baseStatements, + collections: collectionStatements, + indexes: indexStatements, + feeds: feedStatements, + fts: ftsStatements, + }); + if ((await getMeta(db, SCHEMA_FINGERPRINT_KEY)) === fingerprint) return; + const spacesDb = options.spacesDb; const spacesSharesMainDb = !spacesDb || spacesDb === db; @@ -541,4 +622,8 @@ export async function initSchema( // Idempotent count-column ALTERs + their indexes. Routed through // `applyCountColumns` so non-duplicate-column errors propagate. await applyCountColumns(db, config); + + // Record the applied fingerprint so future cold starts skip all the DDL + // above after a single read. `_contrail_meta` was created by the base DDL. + await setMeta(db, SCHEMA_FINGERPRINT_KEY, fingerprint); } diff --git a/packages/contrail-appview/src/core/jetstream.ts b/packages/contrail-appview/src/core/jetstream.ts index ee5cab3..fc48449 100644 --- a/packages/contrail-appview/src/core/jetstream.ts +++ b/packages/contrail-appview/src/core/jetstream.ts @@ -5,8 +5,11 @@ import { getDependentNsids, shortNameForNsid, buildFeedTargetCaps, + optimizeEnabled, + optimizeIntervalMs, + optimizeAnalysisLimit, } from "./types"; -import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./db"; +import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor, getMetaNumber, setMeta, optimizeDatabase } from "./db"; import { refreshStaleIdentities, applyIdentityEvent } from "./identity"; import { backfillFollowersFromConstellation } from "./constellation"; @@ -16,6 +19,29 @@ const BATCH_SIZE = 50; * prune's per-tick CPU regardless of how large feed_items grows. */ export const FEED_PRUNE_SWEEP_ACTORS = 500; +/** `_contrail_meta` key for the persisted optimize cadence (so recycled cron + * isolates don't re-run it every tick — the in-memory-state bug we hit with + * the feed prune). Shared by the persistent loop. */ +export const OPTIMIZE_LAST_MS_KEY = "optimize_last_ms"; + +/** Run the opt-in planner-stat maintenance if enabled and its persisted + * interval has elapsed. Bounded + no-op on Postgres (see optimizeDatabase). + * Wrapped by callers so a pragma-unsupported environment can't break ingest. */ +export async function maybeOptimize(db: Database, config: ContrailConfig, log: Logger): Promise { + if (!optimizeEnabled(config)) return; + const last = await getMetaNumber(db, OPTIMIZE_LAST_MS_KEY); + if (Date.now() - (last ?? 0) <= optimizeIntervalMs(config)) return; + // Claim the interval up front so a failing/unsupported pragma can't re-run + // every tick — it retries only after the next interval elapses. + await setMeta(db, OPTIMIZE_LAST_MS_KEY, String(Date.now())); + try { + await optimizeDatabase(db, optimizeAnalysisLimit(config)); + log.log("[maintenance] refreshed planner stats (PRAGMA optimize)"); + } catch (err) { + log.warn(`[maintenance] optimize failed: ${err}`); + } +} + /** Mutable state that persists across ingest cycles within the same process. */ export interface IngestState { cachedKnownDids?: Set; @@ -363,5 +389,9 @@ export async function runIngestCycle( } } + // Opt-in planner-stat maintenance (gated + persisted cadence; no-op unless + // config.maintenance.optimize is set). + await maybeOptimize(db, config, log); + log.log(`[ingest] cycle complete. stored=${events.length}`); } diff --git a/packages/contrail-appview/src/core/persistent.ts b/packages/contrail-appview/src/core/persistent.ts index 63a3ff0..391dd55 100644 --- a/packages/contrail-appview/src/core/persistent.ts +++ b/packages/contrail-appview/src/core/persistent.ts @@ -10,7 +10,7 @@ import { import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./db"; import { refreshStaleIdentities, applyIdentityEvent } from "./identity"; import { backfillFollowersFromConstellation } from "./constellation"; -import { createIngestState, FEED_PRUNE_SWEEP_ACTORS } from "./jetstream"; +import { createIngestState, FEED_PRUNE_SWEEP_ACTORS, maybeOptimize } from "./jetstream"; import type { IngestState } from "./jetstream"; /** How often the long-lived persistent loop runs a bounded feed sweep. The @@ -198,6 +198,9 @@ async function streamAndFlush( state.lastFeedSweepMs = Date.now(); } + // Opt-in planner-stat maintenance (gated + persisted cadence). + await maybeOptimize(db, config, log); + log.log(`Flushed ${batch.length} events. Cursor: ${lastTimeUs}`); } finally { flushing = false; diff --git a/packages/contrail-appview/src/index.ts b/packages/contrail-appview/src/index.ts index 4fed4a0..a89b7a3 100644 --- a/packages/contrail-appview/src/index.ts +++ b/packages/contrail-appview/src/index.ts @@ -38,6 +38,8 @@ export * from "./core/constellation"; // DB export * from "./core/db/schema"; export * from "./core/db/records"; +export * from "./core/db/meta"; +export * from "./core/db/optimize"; // note: ./core/db/index is implicitly covered by the wildcard if we export it // — but we don't, since both schema and records may export overlapping names. // Tests can import the specifics they need. diff --git a/packages/contrail-base/src/types.ts b/packages/contrail-base/src/types.ts index 8a15a4a..386cb1a 100644 --- a/packages/contrail-base/src/types.ts +++ b/packages/contrail-base/src/types.ts @@ -272,6 +272,49 @@ export interface ContrailConfig { * Example: ["pds.dev.svc.cluster.local"]. */ additionalAllowedHosts?: string[]; }; + /** Optional background database maintenance. All off by default. */ + maintenance?: MaintenanceConfig; +} + +export interface MaintenanceConfig { + /** Periodically refresh the SQLite query planner's statistics so + * multi-predicate queries pick the selective index instead of the planner's + * default heuristic (measured ~50x fewer rows read on a 2-predicate query). + * Off by default — it's a DB write + CPU and shouldn't change behavior for + * existing consumers unless enabled. `true` uses defaults; pass an object to + * tune. No-op on Postgres, where autovacuum/autoanalyze handles this. */ + optimize?: boolean | MaintenanceOptimizeConfig; +} + +export interface MaintenanceOptimizeConfig { + /** Minimum gap between optimize runs (default: 24h). Planner stats change + * slowly, so daily is plenty. */ + intervalMs?: number; + /** `PRAGMA analysis_limit` — bounds the work per run so it can't exceed + * D1's per-query CPU budget and reset the shared DO (default: 400). */ + analysisLimit?: number; +} + +export const DEFAULT_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60 * 1000; +export const DEFAULT_ANALYSIS_LIMIT = 400; + +/** Whether the opt-in planner-stat maintenance is enabled. */ +export function optimizeEnabled(config: ContrailConfig): boolean { + return !!config.maintenance?.optimize; +} + +/** Resolved optimize interval (ms), falling back to the 24h default. */ +export function optimizeIntervalMs(config: ContrailConfig): number { + const o = config.maintenance?.optimize; + if (o && typeof o === "object" && o.intervalMs != null) return o.intervalMs; + return DEFAULT_OPTIMIZE_INTERVAL_MS; +} + +/** Resolved `analysis_limit` for optimize, falling back to the default. */ +export function optimizeAnalysisLimit(config: ContrailConfig): number { + const o = config.maintenance?.optimize; + if (o && typeof o === "object" && o.analysisLimit != null) return o.analysisLimit; + return DEFAULT_ANALYSIS_LIMIT; } export interface ConstellationConfig { diff --git a/packages/contrail/src/contrail.ts b/packages/contrail/src/contrail.ts index dcefac7..5162663 100644 --- a/packages/contrail/src/contrail.ts +++ b/packages/contrail/src/contrail.ts @@ -1,6 +1,7 @@ import type { ContrailConfig, Database, ResolvedContrailConfig } from "./core/types"; -import { resolveConfig, validateConfig } from "./core/types"; +import { resolveConfig, validateConfig, optimizeAnalysisLimit } from "./core/types"; import { initSchema } from "./core/db/schema"; +import { optimizeDatabase } from "./core/db"; import { queryRecords } from "./core/db/records"; import type { QueryOptions, SortOption } from "./core/db/records"; import { runIngestCycle, createIngestState } from "./core/jetstream"; @@ -92,6 +93,15 @@ export class Contrail { await initSchema(main, this.config, { spacesDb: spaces, extraSchemas }); } + /** Refresh the SQLite query-planner statistics (bounded `PRAGMA optimize`) so + * multi-predicate queries pick the selective index. No-op on Postgres. Safe + * to call on a schedule; the ingest tick runs this automatically when + * `config.maintenance.optimize` is enabled, so most consumers don't need to + * call it directly. */ + async optimize(db?: Database): Promise { + await optimizeDatabase(this.getDb(db), optimizeAnalysisLimit(this.config)); + } + /** Query records from a collection. */ async query( collection: string, diff --git a/packages/contrail/tests/maintenance-optimize.test.ts b/packages/contrail/tests/maintenance-optimize.test.ts new file mode 100644 index 0000000..0a1afe8 --- /dev/null +++ b/packages/contrail/tests/maintenance-optimize.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from "vitest"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema, optimizeDatabase, getMetaNumber } from "../src/core/db"; +import { maybeOptimize } from "../src/core/jetstream"; +import { resolveConfig } from "../src/core/types"; + +const BASE = { + namespace: "com.example", + collections: { event: { collection: "com.example.event" } }, +}; + +function silentLogger() { + return { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; +} + +async function freshDb(config = resolveConfig(BASE)) { + const db = createSqliteDatabase(":memory:"); + await initSchema(db, config); + return db; +} + +describe("optimizeDatabase", () => { + it("runs PRAGMA optimize on sqlite without error", async () => { + const db = await freshDb(); + await expect(optimizeDatabase(db, 400)).resolves.toBeUndefined(); + }); + + it("tolerates an unusual analysis_limit", async () => { + const db = await freshDb(); + await expect(optimizeDatabase(db, 0)).resolves.toBeUndefined(); + }); +}); + +describe("maybeOptimize gating", () => { + it("is a no-op when maintenance.optimize is unset", async () => { + const cfg = resolveConfig(BASE); + const db = await freshDb(cfg); + await maybeOptimize(db, cfg, silentLogger()); + expect(await getMetaNumber(db, "optimize_last_ms")).toBeNull(); + }); + + it("runs and persists the timestamp when enabled and due", async () => { + const cfg = resolveConfig({ ...BASE, maintenance: { optimize: true } }); + const db = await freshDb(cfg); + await maybeOptimize(db, cfg, silentLogger()); + expect(await getMetaNumber(db, "optimize_last_ms")).toBeGreaterThan(0); + }); + + it("skips while within the interval", async () => { + const cfg = resolveConfig({ + ...BASE, + maintenance: { optimize: { intervalMs: 1_000_000 } }, + }); + const db = await freshDb(cfg); + + await maybeOptimize(db, cfg, silentLogger()); + const ts1 = await getMetaNumber(db, "optimize_last_ms"); + expect(ts1).toBeGreaterThan(0); + + await maybeOptimize(db, cfg, silentLogger()); + const ts2 = await getMetaNumber(db, "optimize_last_ms"); + expect(ts2).toBe(ts1); // not re-run within the interval + }); + + it("claims the interval up front even if optimize throws", async () => { + const cfg = resolveConfig({ ...BASE, maintenance: { optimize: true } }); + const db = await freshDb(cfg); + + // Force the optimize itself to fail; the cadence timestamp must still be + // written so a broken/unsupported pragma can't re-run every tick. + const orig = db.prepare.bind(db); + db.prepare = (sql: string) => { + if (/PRAGMA optimize/i.test(sql)) throw new Error("pragma unsupported"); + return orig(sql); + }; + const log = silentLogger(); + + await maybeOptimize(db, cfg, log); + + expect(await getMetaNumber(db, "optimize_last_ms")).toBeGreaterThan(0); + expect(log.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/contrail/tests/schema-fingerprint-gate.test.ts b/packages/contrail/tests/schema-fingerprint-gate.test.ts new file mode 100644 index 0000000..5d845ef --- /dev/null +++ b/packages/contrail/tests/schema-fingerprint-gate.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema, getMeta } from "../src/core/db"; +import { resolveConfig } from "../src/core/types"; +import type { Database, Statement } from "../src/core/types"; + +// initSchema replays ~40 DDL statements serially on every call; on recycled +// Workers isolates that's hundreds of ms of cold-start round-trips. The +// fingerprint gate must skip all of it after a single read when the schema is +// unchanged, and re-apply when it changes. + +function recordingDb(real: Database): { db: Database; prepares: string[] } { + const prepares: string[] = []; + const db: Database = { + prepare(sql: string): Statement { + prepares.push(sql); + return real.prepare(sql); + }, + batch(stmts: Statement[]): Promise { + return real.batch(stmts); + }, + dialect: real.dialect, + }; + return { db, prepares }; +} + +const CONFIG = resolveConfig({ + namespace: "com.example", + collections: { + event: { + collection: "community.lexicon.calendar.event", + queryable: { name: {} }, + }, + }, +}); + +describe("schema fingerprint gate", () => { + it("applies the full DDL on first init, then skips a matching second init", async () => { + const real = createSqliteDatabase(":memory:"); + + const first = recordingDb(real); + await initSchema(first.db, CONFIG); + expect(first.prepares.length).toBeGreaterThan(10); // full apply + + const fp = await getMeta(real, "schema_fingerprint"); + expect(fp).toBeTruthy(); + + const second = recordingDb(real); + await initSchema(second.db, CONFIG); + // Steady state: a single read, zero DDL. + expect(second.prepares).toHaveLength(1); + expect(second.prepares[0]).toMatch(/_contrail_meta/); + expect(await getMeta(real, "schema_fingerprint")).toBe(fp); // unchanged + }); + + it("re-applies when the generated schema changes (fingerprint busts)", async () => { + const real = createSqliteDatabase(":memory:"); + await initSchema(real, CONFIG); + const fp1 = await getMeta(real, "schema_fingerprint"); + + // Add a collection → different generated DDL → different fingerprint. + const CONFIG2 = resolveConfig({ + namespace: "com.example", + collections: { + event: { + collection: "community.lexicon.calendar.event", + queryable: { name: {} }, + }, + note: { collection: "com.example.note" }, + }, + }); + + const second = recordingDb(real); + await initSchema(second.db, CONFIG2); + expect(second.prepares.length).toBeGreaterThan(1); // DDL ran again + expect(await getMeta(real, "schema_fingerprint")).not.toBe(fp1); + + // The new collection's table now exists. + const row = await real + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='records_note'" + ) + .first(); + expect(row).toBeTruthy(); + }); + + it("does not skip on a fresh database (no fingerprint row yet)", async () => { + const real = createSqliteDatabase(":memory:"); + const rec = recordingDb(real); + // First-ever init: the gate read hits a missing _contrail_meta, resolves + // null, and the full apply runs. + await initSchema(rec.db, CONFIG); + expect(rec.prepares.length).toBeGreaterThan(10); + expect(await getMeta(real, "schema_fingerprint")).toBeTruthy(); + }); +});