[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

performance: gate tables init, optimize filtered queries with multiple filters

Florian (Jun 7, 2026, 9:29 PM +0200) 89aee1b2 e779e9a8

+477 -4
+36
.changeset/cold-start-and-planner-stats.md
··· 1 + --- 2 + "@atmo-dev/contrail-appview": minor 3 + "@atmo-dev/contrail-base": minor 4 + --- 5 + 6 + perf: gate schema replay on a fingerprint; add opt-in planner-stat maintenance 7 + 8 + Two independent performance fixes found while profiling a D1 consumer. 9 + 10 + **Cold-start schema replay (always on).** `initSchema` ran ~40 base/collection/ 11 + index/fts/feed/spaces DDL statements serially on every `init()` call, with no 12 + gate. Consumers call `init()` once per isolate and Workers isolates recycle 13 + constantly, so the first request to each cold isolate paid ~40 sequential 14 + round-trips to the D1 storage object before any real work. `initSchema` now 15 + records a fingerprint of the resolved schema (hash of the generated DDL + 16 + `CONTRAIL_SCHEMA_VERSION`) in a new `_contrail_meta` table and, on a match, 17 + skips all DDL after a single read. Steady-state cold start drops from ~40 18 + round-trips to one; the full apply only runs on first init or an actual schema 19 + change. Concurrent-init safety on Postgres is unchanged (the gate just wraps the 20 + existing idempotent apply). 21 + 22 + **Query-planner statistics (opt-in).** Without `ANALYZE`, SQLite's planner picks 23 + the least-selective index for multi-predicate queries (measured ~50x more rows 24 + read on a `subject.uri` + `status` filter). New opt-in config: 25 + 26 + ```ts 27 + maintenance: { optimize: true } // or { intervalMs, analysisLimit } 28 + ``` 29 + 30 + When enabled, the ingest tick runs a CPU-bounded `PRAGMA analysis_limit=400; 31 + PRAGMA optimize` on a persisted daily cadence (stored in `_contrail_meta`, so it 32 + isn't defeated by recycled isolates — the same in-memory-state bug the feed 33 + prune had). `analysis_limit` bounds the work so it can't exceed D1's per-query 34 + CPU budget and reset the DO. Also exposed as `contrail.optimize(db)` for 35 + consumers that prefer to schedule it themselves. No-op on Postgres 36 + (autovacuum/autoanalyze handles planner stats).
+3 -1
packages/contrail-appview/src/core/db/index.ts
··· 1 - export { initSchema } from "./schema"; 1 + export { initSchema, CONTRAIL_SCHEMA_VERSION } from "./schema"; 2 + export { getMeta, setMeta, getMetaNumber } from "./meta"; 3 + export { optimizeDatabase } from "./optimize"; 2 4 export { getLastCursor, saveCursor, applyEvents, lookupExistingRecords, queryRecords, queryAcrossSources, pruneFeedItems, pruneActorFeed, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./records"; 3 5 export type { QueryOptions, SortOption, ExistingRecordInfo, FeedSweepResult } from "./records"; 4 6 export type { RecordSource } from "../types";
+47
packages/contrail-appview/src/core/db/meta.ts
··· 1 + import type { Database } from "../types"; 2 + 3 + /** 4 + * Generic single-row-per-key store backed by `_contrail_meta(key, value)`. 5 + * Backs the schema-fingerprint gate (schema.ts) and the optimize cadence 6 + * timestamp (the ingest tick). 7 + * 8 + * Reads are tolerant: if the table doesn't exist yet — the first `initSchema` 9 + * before any DDL has run — the read resolves to null rather than throwing, so a 10 + * caller treats "no table" the same as "no value". Any transient read error 11 + * degrades the same way (callers fall back to doing the work), which is safe 12 + * because the only callers gate idempotent work on the result. 13 + */ 14 + export async function getMeta(db: Database, key: string): Promise<string | null> { 15 + try { 16 + const row = await db 17 + .prepare("SELECT value FROM _contrail_meta WHERE key = ?") 18 + .bind(key) 19 + .first<{ value: string }>(); 20 + return row?.value ?? null; 21 + } catch { 22 + return null; 23 + } 24 + } 25 + 26 + export async function setMeta( 27 + db: Database, 28 + key: string, 29 + value: string 30 + ): Promise<void> { 31 + await db 32 + .prepare( 33 + "INSERT INTO _contrail_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value" 34 + ) 35 + .bind(key, value) 36 + .run(); 37 + } 38 + 39 + export async function getMetaNumber( 40 + db: Database, 41 + key: string 42 + ): Promise<number | null> { 43 + const v = await getMeta(db, key); 44 + if (v === null) return null; 45 + const n = Number(v); 46 + return Number.isFinite(n) ? n : null; 47 + }
+36
packages/contrail-appview/src/core/db/optimize.ts
··· 1 + import type { Database } from "../types"; 2 + import { getDialect, postgresDialect } from "../dialect"; 3 + 4 + /** 5 + * Refresh the query planner's statistics so multi-predicate queries pick the 6 + * selective index rather than the planner's default heuristic. 7 + * 8 + * SQLite/D1 only. Runs a CPU-bounded `PRAGMA optimize`: `analysis_limit` caps 9 + * the rows sampled per run so it can't exceed D1's per-query CPU budget and 10 + * reset the shared Durable Object — the same guardrail the feed prune needs (a 11 + * raw unbounded `ANALYZE` on a large table is exactly that failure mode). 12 + * `PRAGMA optimize` only reanalyzes tables whose stats are stale, so it's a 13 + * near-no-op once warmed; the first call on a never-analyzed DB does the bulk 14 + * of the work, which `analysis_limit` bounds. 15 + * 16 + * No-op on Postgres, where autovacuum/autoanalyze maintains planner stats. 17 + * 18 + * Surfaces errors to the caller (e.g. an environment that rejects the pragmas); 19 + * the auto-run in the ingest tick wraps this so maintenance can't break ingest. 20 + */ 21 + export async function optimizeDatabase( 22 + db: Database, 23 + analysisLimit = 400 24 + ): Promise<void> { 25 + if (getDialect(db) === postgresDialect) return; 26 + 27 + try { 28 + await db 29 + .prepare(`PRAGMA analysis_limit = ${Math.max(0, Math.floor(analysisLimit))}`) 30 + .run(); 31 + } catch { 32 + // Some environments reject analysis_limit; PRAGMA optimize is still safe to 33 + // run (just potentially less bounded), so don't abort on this. 34 + } 35 + await db.prepare("PRAGMA optimize").run(); 36 + }
+85
packages/contrail-appview/src/core/db/schema.ts
··· 12 12 import { getSearchableFields } from "../search"; 13 13 import { buildSpacesBaseSchema } from "../spaces/schema"; 14 14 import { buildLabelsSchema } from "../labels/schema"; 15 + import { getMeta, setMeta } from "./meta"; 16 + 17 + /** Bump when contrail changes schema in a way the generated-DDL hash below 18 + * can't see on its own — chiefly the spaces / community / labels internal 19 + * table shapes (their DDL isn't all enumerated into the fingerprint). Pure 20 + * config-driven changes (collections, feeds, indexes, migrations) bust the 21 + * fingerprint automatically and don't need a bump. */ 22 + export const CONTRAIL_SCHEMA_VERSION = 1; 23 + 24 + const SCHEMA_FINGERPRINT_KEY = "schema_fingerprint"; 15 25 16 26 function getResolved(config: ContrailConfig): ResolvedMaps { 17 27 return (config as ResolvedContrailConfig)._resolved ?? resolveConfig(config)._resolved; ··· 19 29 20 30 function buildBaseSchema(dialect: SqlDialect): string { 21 31 return ` 32 + CREATE TABLE IF NOT EXISTS _contrail_meta ( 33 + key TEXT PRIMARY KEY, 34 + value TEXT NOT NULL 35 + ); 22 36 CREATE TABLE IF NOT EXISTS backfills ( 23 37 did TEXT NOT NULL, 24 38 collection TEXT NOT NULL, ··· 474 488 await applyCountColumns(target, config, { forSpaces: true }); 475 489 } 476 490 491 + /** Stable, dependency-free 64-bit-ish hash (two seeded FNV-1a passes → hex). 492 + * Sync and Workers-safe (no crypto). Collisions only matter if two *different* 493 + * schemas hash identically AND a deploy transitions between them — negligible, 494 + * and CONTRAIL_SCHEMA_VERSION is the explicit backstop. */ 495 + function hashStrings(parts: string[]): string { 496 + const joined = parts.join("�"); 497 + let h1 = 0x811c9dc5; 498 + let h2 = 0x01000193; 499 + for (let i = 0; i < joined.length; i++) { 500 + const c = joined.charCodeAt(i); 501 + h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0; 502 + h2 = Math.imul(h2 ^ c, 0x811c9dc5) >>> 0; 503 + } 504 + return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0"); 505 + } 506 + 507 + /** Fingerprint of everything `initSchema` would apply, so an unchanged schema 508 + * can skip the DDL entirely. Includes the generated core DDL (which already 509 + * reflects collections/feeds/indexes), the count-column + migration set, the 510 + * labels/spaces base DDL when enabled, feature flags, dialect, and the 511 + * version constant. */ 512 + function schemaFingerprint( 513 + config: ContrailConfig, 514 + dialect: SqlDialect, 515 + ddl: { 516 + base: string[]; 517 + collections: string[]; 518 + indexes: string[]; 519 + feeds: string[]; 520 + fts: string[]; 521 + } 522 + ): string { 523 + const hasSpaces = !!(config.spaces?.authority || config.spaces?.recordHost); 524 + return hashStrings([ 525 + `v${CONTRAIL_SCHEMA_VERSION}`, 526 + dialect.bigintType, 527 + config.community ? "community" : "", 528 + config.spaces?.authority ? "spaces.authority" : "", 529 + config.spaces?.recordHost ? "spaces.recordHost" : "", 530 + config.labels ? "labels" : "", 531 + ...ddl.base, 532 + ...ddl.collections, 533 + ...ddl.indexes, 534 + ...ddl.feeds, 535 + ...ddl.fts, 536 + ...buildCountColumns(config), 537 + ...(config.labels ? buildLabelsSchema(dialect) : []), 538 + ...(hasSpaces ? buildSpacesBaseSchema(dialect) : []), 539 + JSON.stringify(MIGRATIONS), 540 + ]); 541 + } 542 + 477 543 export async function initSchema( 478 544 db: Database, 479 545 config: ContrailConfig, ··· 487 553 const indexStatements = buildDynamicIndexes(config, dialect); 488 554 const ftsStatements = buildFtsTables(config, dialect); 489 555 const feedStatements = buildFeedTables(config, dialect); 556 + 557 + // Steady-state fast path: if the schema already on disk matches what we'd 558 + // apply, skip every DDL statement after one cheap read. Consumers call 559 + // init() once per isolate, and Workers isolates recycle constantly, so 560 + // otherwise the first request to each cold isolate pays ~40 sequential DDL 561 + // round-trips to the D1 storage object before any real work. The read 562 + // tolerates a missing `_contrail_meta` (true first init) and returns null. 563 + const fingerprint = schemaFingerprint(config, dialect, { 564 + base: baseStatements, 565 + collections: collectionStatements, 566 + indexes: indexStatements, 567 + feeds: feedStatements, 568 + fts: ftsStatements, 569 + }); 570 + if ((await getMeta(db, SCHEMA_FINGERPRINT_KEY)) === fingerprint) return; 490 571 491 572 const spacesDb = options.spacesDb; 492 573 const spacesSharesMainDb = !spacesDb || spacesDb === db; ··· 541 622 // Idempotent count-column ALTERs + their indexes. Routed through 542 623 // `applyCountColumns` so non-duplicate-column errors propagate. 543 624 await applyCountColumns(db, config); 625 + 626 + // Record the applied fingerprint so future cold starts skip all the DDL 627 + // above after a single read. `_contrail_meta` was created by the base DDL. 628 + await setMeta(db, SCHEMA_FINGERPRINT_KEY, fingerprint); 544 629 }
+31 -1
packages/contrail-appview/src/core/jetstream.ts
··· 5 5 getDependentNsids, 6 6 shortNameForNsid, 7 7 buildFeedTargetCaps, 8 + optimizeEnabled, 9 + optimizeIntervalMs, 10 + optimizeAnalysisLimit, 8 11 } from "./types"; 9 - import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./db"; 12 + import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor, getMetaNumber, setMeta, optimizeDatabase } from "./db"; 10 13 import { refreshStaleIdentities, applyIdentityEvent } from "./identity"; 11 14 import { backfillFollowersFromConstellation } from "./constellation"; 12 15 ··· 15 18 * actor costs a handful of index-backed O(cap) deletes, so this bounds the 16 19 * prune's per-tick CPU regardless of how large feed_items grows. */ 17 20 export const FEED_PRUNE_SWEEP_ACTORS = 500; 21 + 22 + /** `_contrail_meta` key for the persisted optimize cadence (so recycled cron 23 + * isolates don't re-run it every tick — the in-memory-state bug we hit with 24 + * the feed prune). Shared by the persistent loop. */ 25 + export const OPTIMIZE_LAST_MS_KEY = "optimize_last_ms"; 26 + 27 + /** Run the opt-in planner-stat maintenance if enabled and its persisted 28 + * interval has elapsed. Bounded + no-op on Postgres (see optimizeDatabase). 29 + * Wrapped by callers so a pragma-unsupported environment can't break ingest. */ 30 + export async function maybeOptimize(db: Database, config: ContrailConfig, log: Logger): Promise<void> { 31 + if (!optimizeEnabled(config)) return; 32 + const last = await getMetaNumber(db, OPTIMIZE_LAST_MS_KEY); 33 + if (Date.now() - (last ?? 0) <= optimizeIntervalMs(config)) return; 34 + // Claim the interval up front so a failing/unsupported pragma can't re-run 35 + // every tick — it retries only after the next interval elapses. 36 + await setMeta(db, OPTIMIZE_LAST_MS_KEY, String(Date.now())); 37 + try { 38 + await optimizeDatabase(db, optimizeAnalysisLimit(config)); 39 + log.log("[maintenance] refreshed planner stats (PRAGMA optimize)"); 40 + } catch (err) { 41 + log.warn(`[maintenance] optimize failed: ${err}`); 42 + } 43 + } 18 44 19 45 /** Mutable state that persists across ingest cycles within the same process. */ 20 46 export interface IngestState { ··· 362 388 if (pruned > 0) log.log(`Pruned ${pruned} feed items (sweep)`); 363 389 } 364 390 } 391 + 392 + // Opt-in planner-stat maintenance (gated + persisted cadence; no-op unless 393 + // config.maintenance.optimize is set). 394 + await maybeOptimize(db, config, log); 365 395 366 396 log.log(`[ingest] cycle complete. stored=${events.length}`); 367 397 }
+4 -1
packages/contrail-appview/src/core/persistent.ts
··· 10 10 import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./db"; 11 11 import { refreshStaleIdentities, applyIdentityEvent } from "./identity"; 12 12 import { backfillFollowersFromConstellation } from "./constellation"; 13 - import { createIngestState, FEED_PRUNE_SWEEP_ACTORS } from "./jetstream"; 13 + import { createIngestState, FEED_PRUNE_SWEEP_ACTORS, maybeOptimize } from "./jetstream"; 14 14 import type { IngestState } from "./jetstream"; 15 15 16 16 /** How often the long-lived persistent loop runs a bounded feed sweep. The ··· 197 197 } 198 198 state.lastFeedSweepMs = Date.now(); 199 199 } 200 + 201 + // Opt-in planner-stat maintenance (gated + persisted cadence). 202 + await maybeOptimize(db, config, log); 200 203 201 204 log.log(`Flushed ${batch.length} events. Cursor: ${lastTimeUs}`); 202 205 } finally {
+2
packages/contrail-appview/src/index.ts
··· 38 38 // DB 39 39 export * from "./core/db/schema"; 40 40 export * from "./core/db/records"; 41 + export * from "./core/db/meta"; 42 + export * from "./core/db/optimize"; 41 43 // note: ./core/db/index is implicitly covered by the wildcard if we export it 42 44 // — but we don't, since both schema and records may export overlapping names. 43 45 // Tests can import the specifics they need.
+43
packages/contrail-base/src/types.ts
··· 272 272 * Example: ["pds.dev.svc.cluster.local"]. */ 273 273 additionalAllowedHosts?: string[]; 274 274 }; 275 + /** Optional background database maintenance. All off by default. */ 276 + maintenance?: MaintenanceConfig; 277 + } 278 + 279 + export interface MaintenanceConfig { 280 + /** Periodically refresh the SQLite query planner's statistics so 281 + * multi-predicate queries pick the selective index instead of the planner's 282 + * default heuristic (measured ~50x fewer rows read on a 2-predicate query). 283 + * Off by default — it's a DB write + CPU and shouldn't change behavior for 284 + * existing consumers unless enabled. `true` uses defaults; pass an object to 285 + * tune. No-op on Postgres, where autovacuum/autoanalyze handles this. */ 286 + optimize?: boolean | MaintenanceOptimizeConfig; 287 + } 288 + 289 + export interface MaintenanceOptimizeConfig { 290 + /** Minimum gap between optimize runs (default: 24h). Planner stats change 291 + * slowly, so daily is plenty. */ 292 + intervalMs?: number; 293 + /** `PRAGMA analysis_limit` — bounds the work per run so it can't exceed 294 + * D1's per-query CPU budget and reset the shared DO (default: 400). */ 295 + analysisLimit?: number; 296 + } 297 + 298 + export const DEFAULT_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60 * 1000; 299 + export const DEFAULT_ANALYSIS_LIMIT = 400; 300 + 301 + /** Whether the opt-in planner-stat maintenance is enabled. */ 302 + export function optimizeEnabled(config: ContrailConfig): boolean { 303 + return !!config.maintenance?.optimize; 304 + } 305 + 306 + /** Resolved optimize interval (ms), falling back to the 24h default. */ 307 + export function optimizeIntervalMs(config: ContrailConfig): number { 308 + const o = config.maintenance?.optimize; 309 + if (o && typeof o === "object" && o.intervalMs != null) return o.intervalMs; 310 + return DEFAULT_OPTIMIZE_INTERVAL_MS; 311 + } 312 + 313 + /** Resolved `analysis_limit` for optimize, falling back to the default. */ 314 + export function optimizeAnalysisLimit(config: ContrailConfig): number { 315 + const o = config.maintenance?.optimize; 316 + if (o && typeof o === "object" && o.analysisLimit != null) return o.analysisLimit; 317 + return DEFAULT_ANALYSIS_LIMIT; 275 318 } 276 319 277 320 export interface ConstellationConfig {
+11 -1
packages/contrail/src/contrail.ts
··· 1 1 import type { ContrailConfig, Database, ResolvedContrailConfig } from "./core/types"; 2 - import { resolveConfig, validateConfig } from "./core/types"; 2 + import { resolveConfig, validateConfig, optimizeAnalysisLimit } from "./core/types"; 3 3 import { initSchema } from "./core/db/schema"; 4 + import { optimizeDatabase } from "./core/db"; 4 5 import { queryRecords } from "./core/db/records"; 5 6 import type { QueryOptions, SortOption } from "./core/db/records"; 6 7 import { runIngestCycle, createIngestState } from "./core/jetstream"; ··· 90 91 const spaces = spacesDb ?? this._spacesDb; 91 92 const extraSchemas = this._community ? [this._community.applySchema] : []; 92 93 await initSchema(main, this.config, { spacesDb: spaces, extraSchemas }); 94 + } 95 + 96 + /** Refresh the SQLite query-planner statistics (bounded `PRAGMA optimize`) so 97 + * multi-predicate queries pick the selective index. No-op on Postgres. Safe 98 + * to call on a schedule; the ingest tick runs this automatically when 99 + * `config.maintenance.optimize` is enabled, so most consumers don't need to 100 + * call it directly. */ 101 + async optimize(db?: Database): Promise<void> { 102 + await optimizeDatabase(this.getDb(db), optimizeAnalysisLimit(this.config)); 93 103 } 94 104 95 105 /** Query records from a collection. */
+83
packages/contrail/tests/maintenance-optimize.test.ts
··· 1 + import { describe, it, expect, vi } from "vitest"; 2 + import { createSqliteDatabase } from "../src/adapters/sqlite"; 3 + import { initSchema, optimizeDatabase, getMetaNumber } from "../src/core/db"; 4 + import { maybeOptimize } from "../src/core/jetstream"; 5 + import { resolveConfig } from "../src/core/types"; 6 + 7 + const BASE = { 8 + namespace: "com.example", 9 + collections: { event: { collection: "com.example.event" } }, 10 + }; 11 + 12 + function silentLogger() { 13 + return { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; 14 + } 15 + 16 + async function freshDb(config = resolveConfig(BASE)) { 17 + const db = createSqliteDatabase(":memory:"); 18 + await initSchema(db, config); 19 + return db; 20 + } 21 + 22 + describe("optimizeDatabase", () => { 23 + it("runs PRAGMA optimize on sqlite without error", async () => { 24 + const db = await freshDb(); 25 + await expect(optimizeDatabase(db, 400)).resolves.toBeUndefined(); 26 + }); 27 + 28 + it("tolerates an unusual analysis_limit", async () => { 29 + const db = await freshDb(); 30 + await expect(optimizeDatabase(db, 0)).resolves.toBeUndefined(); 31 + }); 32 + }); 33 + 34 + describe("maybeOptimize gating", () => { 35 + it("is a no-op when maintenance.optimize is unset", async () => { 36 + const cfg = resolveConfig(BASE); 37 + const db = await freshDb(cfg); 38 + await maybeOptimize(db, cfg, silentLogger()); 39 + expect(await getMetaNumber(db, "optimize_last_ms")).toBeNull(); 40 + }); 41 + 42 + it("runs and persists the timestamp when enabled and due", async () => { 43 + const cfg = resolveConfig({ ...BASE, maintenance: { optimize: true } }); 44 + const db = await freshDb(cfg); 45 + await maybeOptimize(db, cfg, silentLogger()); 46 + expect(await getMetaNumber(db, "optimize_last_ms")).toBeGreaterThan(0); 47 + }); 48 + 49 + it("skips while within the interval", async () => { 50 + const cfg = resolveConfig({ 51 + ...BASE, 52 + maintenance: { optimize: { intervalMs: 1_000_000 } }, 53 + }); 54 + const db = await freshDb(cfg); 55 + 56 + await maybeOptimize(db, cfg, silentLogger()); 57 + const ts1 = await getMetaNumber(db, "optimize_last_ms"); 58 + expect(ts1).toBeGreaterThan(0); 59 + 60 + await maybeOptimize(db, cfg, silentLogger()); 61 + const ts2 = await getMetaNumber(db, "optimize_last_ms"); 62 + expect(ts2).toBe(ts1); // not re-run within the interval 63 + }); 64 + 65 + it("claims the interval up front even if optimize throws", async () => { 66 + const cfg = resolveConfig({ ...BASE, maintenance: { optimize: true } }); 67 + const db = await freshDb(cfg); 68 + 69 + // Force the optimize itself to fail; the cadence timestamp must still be 70 + // written so a broken/unsupported pragma can't re-run every tick. 71 + const orig = db.prepare.bind(db); 72 + db.prepare = (sql: string) => { 73 + if (/PRAGMA optimize/i.test(sql)) throw new Error("pragma unsupported"); 74 + return orig(sql); 75 + }; 76 + const log = silentLogger(); 77 + 78 + await maybeOptimize(db, cfg, log); 79 + 80 + expect(await getMetaNumber(db, "optimize_last_ms")).toBeGreaterThan(0); 81 + expect(log.warn).toHaveBeenCalled(); 82 + }); 83 + });
+96
packages/contrail/tests/schema-fingerprint-gate.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { createSqliteDatabase } from "../src/adapters/sqlite"; 3 + import { initSchema, getMeta } from "../src/core/db"; 4 + import { resolveConfig } from "../src/core/types"; 5 + import type { Database, Statement } from "../src/core/types"; 6 + 7 + // initSchema replays ~40 DDL statements serially on every call; on recycled 8 + // Workers isolates that's hundreds of ms of cold-start round-trips. The 9 + // fingerprint gate must skip all of it after a single read when the schema is 10 + // unchanged, and re-apply when it changes. 11 + 12 + function recordingDb(real: Database): { db: Database; prepares: string[] } { 13 + const prepares: string[] = []; 14 + const db: Database = { 15 + prepare(sql: string): Statement { 16 + prepares.push(sql); 17 + return real.prepare(sql); 18 + }, 19 + batch(stmts: Statement[]): Promise<any[]> { 20 + return real.batch(stmts); 21 + }, 22 + dialect: real.dialect, 23 + }; 24 + return { db, prepares }; 25 + } 26 + 27 + const CONFIG = resolveConfig({ 28 + namespace: "com.example", 29 + collections: { 30 + event: { 31 + collection: "community.lexicon.calendar.event", 32 + queryable: { name: {} }, 33 + }, 34 + }, 35 + }); 36 + 37 + describe("schema fingerprint gate", () => { 38 + it("applies the full DDL on first init, then skips a matching second init", async () => { 39 + const real = createSqliteDatabase(":memory:"); 40 + 41 + const first = recordingDb(real); 42 + await initSchema(first.db, CONFIG); 43 + expect(first.prepares.length).toBeGreaterThan(10); // full apply 44 + 45 + const fp = await getMeta(real, "schema_fingerprint"); 46 + expect(fp).toBeTruthy(); 47 + 48 + const second = recordingDb(real); 49 + await initSchema(second.db, CONFIG); 50 + // Steady state: a single read, zero DDL. 51 + expect(second.prepares).toHaveLength(1); 52 + expect(second.prepares[0]).toMatch(/_contrail_meta/); 53 + expect(await getMeta(real, "schema_fingerprint")).toBe(fp); // unchanged 54 + }); 55 + 56 + it("re-applies when the generated schema changes (fingerprint busts)", async () => { 57 + const real = createSqliteDatabase(":memory:"); 58 + await initSchema(real, CONFIG); 59 + const fp1 = await getMeta(real, "schema_fingerprint"); 60 + 61 + // Add a collection → different generated DDL → different fingerprint. 62 + const CONFIG2 = resolveConfig({ 63 + namespace: "com.example", 64 + collections: { 65 + event: { 66 + collection: "community.lexicon.calendar.event", 67 + queryable: { name: {} }, 68 + }, 69 + note: { collection: "com.example.note" }, 70 + }, 71 + }); 72 + 73 + const second = recordingDb(real); 74 + await initSchema(second.db, CONFIG2); 75 + expect(second.prepares.length).toBeGreaterThan(1); // DDL ran again 76 + expect(await getMeta(real, "schema_fingerprint")).not.toBe(fp1); 77 + 78 + // The new collection's table now exists. 79 + const row = await real 80 + .prepare( 81 + "SELECT name FROM sqlite_master WHERE type='table' AND name='records_note'" 82 + ) 83 + .first(); 84 + expect(row).toBeTruthy(); 85 + }); 86 + 87 + it("does not skip on a fresh database (no fingerprint row yet)", async () => { 88 + const real = createSqliteDatabase(":memory:"); 89 + const rec = recordingDb(real); 90 + // First-ever init: the gate read hits a missing _contrail_meta, resolves 91 + // null, and the full apply runs. 92 + await initSchema(rec.db, CONFIG); 93 + expect(rec.prepares.length).toBeGreaterThan(10); 94 + expect(await getMeta(real, "schema_fingerprint")).toBeTruthy(); 95 + }); 96 + });