{"version":3,"file":"fts-manager-BJYqQRWb.mjs","names":["dialectTableExists"],"sources":["../src/search/fts-manager.ts"],"sourcesContent":["/**\n * FTS5 Manager\n *\n * Manages FTS5 virtual tables and triggers for search indexing.\n */\n\nimport type { Kysely } from \"kysely\";\nimport { sql } from \"kysely\";\n\nimport { isSqlite, tableExists as dialectTableExists } from \"../database/dialect-helpers.js\";\nimport type { Database } from \"../database/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport { SEARCH_TOKENIZERS } from \"./types.js\";\nimport type { SearchConfig, SearchTokenizer } from \"./types.js\";\n\nconst DEFAULT_SEARCH_TOKENIZER: SearchTokenizer = \"porter unicode61\";\n\nfunction isSearchTokenizer(value: unknown): value is SearchTokenizer {\n\treturn SEARCH_TOKENIZERS.some((tokenizer) => tokenizer === value);\n}\n\nfunction resolveSearchTokenizer(tokenize?: SearchTokenizer): SearchTokenizer {\n\tif (tokenize === undefined) return DEFAULT_SEARCH_TOKENIZER;\n\tif (!isSearchTokenizer(tokenize)) {\n\t\tthrow new Error(`Unsupported FTS5 tokenizer: \"${String(tokenize)}\"`);\n\t}\n\treturn tokenize;\n}\n\n/**\n * FTS5 Manager\n *\n * Handles creation, deletion, and management of FTS5 virtual tables\n * for full-text search on content collections.\n */\nexport class FTSManager {\n\tconstructor(private db: Kysely<Database>) {}\n\n\t/**\n\t * Validate a collection slug and its searchable field names.\n\t * Must be called before any raw SQL interpolation.\n\t */\n\tprivate validateInputs(collectionSlug: string, searchableFields?: string[]): void {\n\t\tvalidateIdentifier(collectionSlug, \"collection slug\");\n\t\tif (searchableFields) {\n\t\t\tfor (const field of searchableFields) {\n\t\t\t\tvalidateIdentifier(field, \"searchable field name\");\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Get the FTS table name for a collection\n\t * Uses _emdash_ prefix to clearly mark as internal/system table\n\t */\n\tgetFtsTableName(collectionSlug: string): string {\n\t\tvalidateIdentifier(collectionSlug, \"collection slug\");\n\t\treturn `_emdash_fts_${collectionSlug}`;\n\t}\n\n\t/**\n\t * Get the content table name for a collection\n\t */\n\tgetContentTableName(collectionSlug: string): string {\n\t\tvalidateIdentifier(collectionSlug, \"collection slug\");\n\t\treturn `ec_${collectionSlug}`;\n\t}\n\n\t/**\n\t * Check if an FTS table exists for a collection\n\t */\n\tasync ftsTableExists(collectionSlug: string): Promise<boolean> {\n\t\tconst ftsTable = this.getFtsTableName(collectionSlug);\n\t\treturn dialectTableExists(this.db, ftsTable);\n\t}\n\n\t/**\n\t * Create an FTS5 virtual table for a collection.\n\t * FTS5 is SQLite-only; on other dialects this is a no-op.\n\t *\n\t * @param collectionSlug - The collection slug\n\t * @param searchableFields - Array of field names to index\n\t * @param weights - Optional field weights for ranking\n\t * @param tokenize - Optional FTS5 tokenizer configuration\n\t */\n\tasync createFtsTable(\n\t\tcollectionSlug: string,\n\t\tsearchableFields: string[],\n\t\t_weights?: Record<string, number>,\n\t\ttokenize?: SearchTokenizer,\n\t): Promise<void> {\n\t\tconst tokenizer = resolveSearchTokenizer(tokenize);\n\t\tif (!isSqlite(this.db)) return;\n\t\tthis.validateInputs(collectionSlug, searchableFields);\n\t\tconst ftsTable = this.getFtsTableName(collectionSlug);\n\n\t\t// Build the column list for FTS5\n\t\t// id and locale are UNINDEXED (used for joining/filtering, not searched)\n\t\tconst columns = [\"id UNINDEXED\", \"locale UNINDEXED\", ...searchableFields].join(\", \");\n\n\t\t// Create the FTS5 virtual table. The table stores its own copy of the\n\t\t// indexed values (no `content=` option): Portable Text fields are\n\t\t// indexed as extracted plain text — see searchValueExpr — which cannot\n\t\t// mirror the raw JSON in the ec_* column, and external-content FTS5\n\t\t// requires the index to exactly mirror the backing table's values\n\t\t// (snippet() reads them, and the 'delete' command must be fed the\n\t\t// inserted values or the index corrupts — see migration 039's history).\n\t\t// Storing the extracted text also makes snippet() return prose.\n\t\tawait sql\n\t\t\t.raw(`\n\t\t\tCREATE VIRTUAL TABLE IF NOT EXISTS \"${ftsTable}\" USING fts5(\n\t\t\t\t${columns},\n\t\t\t\ttokenize='${tokenizer}'\n\t\t\t)\n\t\t`)\n\t\t\t.execute(this.db);\n\n\t\t// Create triggers for automatic sync\n\t\tawait this.createTriggers(collectionSlug, searchableFields);\n\t}\n\n\t/**\n\t * SQL expression producing the indexed value for one searchable field.\n\t *\n\t * Portable Text fields are stored as JSON; indexing the raw JSON pollutes\n\t * the index with structural tokens (`_type`, style values like `normal`,\n\t * `_key` ULIDs) and makes snippets show JSON fragments. Extract the prose\n\t * instead: every JSON string under a `text`, `alt`, `caption`, or `code`\n\t * key (span text, image alt/caption, code blocks). This is a superset of\n\t * `extractPlainText` in text-extraction.ts, which walks only known block\n\t * shapes — the SQL variant takes those keys at any depth, so search and\n\t * the extractPlainText consumers (vectorize/ai-search) can see different\n\t * text for the same document. Only JSON documents\n\t * (arrays/objects) are extracted; legacy rows holding a bare string or a\n\t * JSON scalar (`Some title`, `2024`) are indexed as-is. Extraction must\n\t * live in SQL because the sync triggers cannot call into JS.\n\t *\n\t * `ref` must be a validated column reference (`NEW.x`, `OLD.x`, `\"x\"`).\n\t */\n\tprivate searchValueExpr(ref: string, fieldType: string | undefined): string {\n\t\tif (fieldType !== \"portableText\") return ref;\n\t\treturn (\n\t\t\t`CASE WHEN ${ref} IS NULL THEN NULL ` +\n\t\t\t`WHEN json_valid(${ref}) AND json_type(${ref}) IN ('array', 'object') THEN (` +\n\t\t\t`SELECT group_concat(j.value, ' ') FROM json_tree(${ref}) AS j ` +\n\t\t\t`WHERE j.key IN ('text', 'alt', 'caption', 'code') AND j.type = 'text') ` +\n\t\t\t`ELSE ${ref} END`\n\t\t);\n\t}\n\n\t/**\n\t * Field type per slug for a collection, for choosing the indexed-value\n\t * expression. Fields missing from the schema fall back to raw indexing.\n\t */\n\tprivate async getFieldTypes(collectionSlug: string): Promise<Map<string, string>> {\n\t\tconst collection = await this.db\n\t\t\t.selectFrom(\"_emdash_collections\")\n\t\t\t.select(\"id\")\n\t\t\t.where(\"slug\", \"=\", collectionSlug)\n\t\t\t.executeTakeFirst();\n\t\tif (!collection) return new Map();\n\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"_emdash_fields\")\n\t\t\t.select([\"slug\", \"type\"])\n\t\t\t.where(\"collection_id\", \"=\", collection.id)\n\t\t\t.execute();\n\t\treturn new Map(rows.map((r) => [r.slug, r.type]));\n\t}\n\n\t/**\n\t * Create triggers to keep FTS table in sync with content table.\n\t *\n\t * The insert and update triggers only add rows to the FTS index when\n\t * `deleted_at IS NULL`. This keeps soft-deleted content out of the\n\t * search index and ensures the FTS row count matches the non-deleted\n\t * content count (which `verifyAndRepairIndex` relies on).\n\t *\n\t * The FTS table stores its own values (no `content=` option), so removal\n\t * is a plain `DELETE FROM fts WHERE rowid = OLD.rowid` — a harmless no-op\n\t * for rows that were never indexed (soft-deleted content). The\n\t * external-content `'delete'`-command choreography and its corruption\n\t * modes (migration 039) do not apply to self-contained tables.\n\t *\n\t * `INSERT OR REPLACE` keeps the insert path idempotent: re-running a\n\t * populate (D1 has no migration lock, so two isolates can race) converges\n\t * on one index row per content row instead of failing on the rowid\n\t * constraint.\n\t *\n\t * The trigger SQL emitted here MUST stay in lock-step with migration\n\t * `064_fts_plain_text.ts`. If this changes again, add a new migration\n\t * rather than editing shipped ones — migrations are forward-only.\n\t */\n\tprivate async createTriggers(collectionSlug: string, searchableFields: string[]): Promise<void> {\n\t\tthis.validateInputs(collectionSlug, searchableFields);\n\t\tif (searchableFields.length === 0) {\n\t\t\tthrow new Error(\n\t\t\t\t`Cannot create FTS triggers for collection \"${collectionSlug}\": no searchable fields. ` +\n\t\t\t\t\t`Mark at least one field as searchable before enabling search.`,\n\t\t\t);\n\t\t}\n\t\tconst ftsTable = this.getFtsTableName(collectionSlug);\n\t\tconst contentTable = this.getContentTableName(collectionSlug);\n\t\tconst fieldTypes = await this.getFieldTypes(collectionSlug);\n\t\tconst fieldList = searchableFields.join(\", \");\n\t\tconst newValueList = searchableFields\n\t\t\t.map((f) => this.searchValueExpr(`NEW.${f}`, fieldTypes.get(f)))\n\t\t\t.join(\", \");\n\n\t\t// Insert trigger - only index non-deleted content\n\t\tawait sql\n\t\t\t.raw(`\n\t\t\tCREATE TRIGGER IF NOT EXISTS \"${ftsTable}_insert\"\n\t\t\tAFTER INSERT ON \"${contentTable}\"\n\t\t\tWHEN NEW.deleted_at IS NULL\n\t\t\tBEGIN\n\t\t\t\tINSERT OR REPLACE INTO \"${ftsTable}\"(rowid, id, locale, ${fieldList})\n\t\t\t\tVALUES (NEW.rowid, NEW.id, NEW.locale, ${newValueList});\n\t\t\tEND\n\t\t`)\n\t\t\t.execute(this.db);\n\n\t\t// Update trigger - drop the old index row, re-insert when the row is\n\t\t// still visible. Trash (deleted_at set) ends at DELETE only; restore\n\t\t// ends at DELETE (no-op) + re-insert.\n\t\t//\n\t\t// The WHEN guard compares raw column values (null-safe IS NOT) so the\n\t\t// trigger fires only when an indexed value, the row's locale, or its\n\t\t// trash state actually changed. Without it every UPDATE re-tokenizes\n\t\t// the whole document — metadata-only saves (status flips, scheduling,\n\t\t// version bumps) and the publish path's rewrite-identical-values\n\t\t// UPDATEs dominate save CPU and WAL volume. deleted_at must stay in\n\t\t// the guard or trash/restore stop syncing the index.\n\t\tconst changedCondition = [\"deleted_at\", \"locale\", ...searchableFields]\n\t\t\t.map((f) => `OLD.${f} IS NOT NEW.${f}`)\n\t\t\t.join(\" OR \");\n\t\tawait sql\n\t\t\t.raw(`\n\t\t\tCREATE TRIGGER IF NOT EXISTS \"${ftsTable}_update\"\n\t\t\tAFTER UPDATE ON \"${contentTable}\"\n\t\t\tWHEN ${changedCondition}\n\t\t\tBEGIN\n\t\t\t\tDELETE FROM \"${ftsTable}\" WHERE rowid = OLD.rowid;\n\t\t\t\tINSERT INTO \"${ftsTable}\"(rowid, id, locale, ${fieldList})\n\t\t\t\tSELECT NEW.rowid, NEW.id, NEW.locale, ${newValueList}\n\t\t\t\tWHERE NEW.deleted_at IS NULL;\n\t\t\tEND\n\t\t`)\n\t\t\t.execute(this.db);\n\n\t\t// Delete trigger\n\t\tawait sql\n\t\t\t.raw(`\n\t\t\tCREATE TRIGGER IF NOT EXISTS \"${ftsTable}_delete\"\n\t\t\tAFTER DELETE ON \"${contentTable}\"\n\t\t\tBEGIN\n\t\t\t\tDELETE FROM \"${ftsTable}\" WHERE rowid = OLD.rowid;\n\t\t\tEND\n\t\t`)\n\t\t\t.execute(this.db);\n\t}\n\n\t/**\n\t * Drop triggers for a collection\n\t */\n\tprivate async dropTriggers(collectionSlug: string): Promise<void> {\n\t\tthis.validateInputs(collectionSlug);\n\t\tconst ftsTable = this.getFtsTableName(collectionSlug);\n\n\t\tawait sql.raw(`DROP TRIGGER IF EXISTS \"${ftsTable}_insert\"`).execute(this.db);\n\t\tawait sql.raw(`DROP TRIGGER IF EXISTS \"${ftsTable}_update\"`).execute(this.db);\n\t\tawait sql.raw(`DROP TRIGGER IF EXISTS \"${ftsTable}_delete\"`).execute(this.db);\n\t}\n\n\t/**\n\t * Drop the FTS table and triggers for a collection\n\t */\n\tasync dropFtsTable(collectionSlug: string): Promise<void> {\n\t\tif (!isSqlite(this.db)) return;\n\t\tthis.validateInputs(collectionSlug);\n\t\tconst ftsTable = this.getFtsTableName(collectionSlug);\n\n\t\t// Drop triggers first\n\t\tawait this.dropTriggers(collectionSlug);\n\n\t\t// Drop the FTS table\n\t\tawait sql.raw(`DROP TABLE IF EXISTS \"${ftsTable}\"`).execute(this.db);\n\t}\n\n\t/**\n\t * Rebuild the FTS index for a collection\n\t *\n\t * This is useful after bulk imports or if the index gets out of sync.\n\t */\n\tasync rebuildIndex(\n\t\tcollectionSlug: string,\n\t\tsearchableFields: string[],\n\t\tweights?: Record<string, number>,\n\t\ttokenize?: SearchTokenizer,\n\t): Promise<void> {\n\t\tresolveSearchTokenizer(tokenize);\n\t\tif (!isSqlite(this.db)) return;\n\t\t// Drop existing table and triggers\n\t\tawait this.dropFtsTable(collectionSlug);\n\n\t\t// Recreate table and triggers\n\t\tawait this.createFtsTable(collectionSlug, searchableFields, weights, tokenize);\n\n\t\t// Populate from existing content\n\t\tawait this.populateFromContent(collectionSlug, searchableFields);\n\t}\n\n\t/**\n\t * Populate the FTS table from existing content.\n\t *\n\t * `INSERT OR REPLACE` so a concurrent double-populate (D1 has no\n\t * migration lock) converges instead of failing on the rowid constraint.\n\t */\n\tasync populateFromContent(collectionSlug: string, searchableFields: string[]): Promise<void> {\n\t\tif (!isSqlite(this.db)) return;\n\t\tthis.validateInputs(collectionSlug, searchableFields);\n\t\tconst ftsTable = this.getFtsTableName(collectionSlug);\n\t\tconst contentTable = this.getContentTableName(collectionSlug);\n\t\tconst fieldTypes = await this.getFieldTypes(collectionSlug);\n\t\tconst fieldList = searchableFields.join(\", \");\n\t\t// Table-qualified references: json_tree exposes columns named\n\t\t// key/value/type/path/..., and inside the extraction subquery a bare\n\t\t// column reference binds to those instead of the ec_* column.\n\t\tconst valueList = searchableFields\n\t\t\t.map((f) => this.searchValueExpr(`\"${contentTable}\".\"${f}\"`, fieldTypes.get(f)))\n\t\t\t.join(\", \");\n\n\t\t// Insert all existing content into FTS table\n\t\tawait sql\n\t\t\t.raw(`\n\t\t\tINSERT OR REPLACE INTO \"${ftsTable}\"(rowid, id, locale, ${fieldList})\n\t\t\tSELECT rowid, id, locale, ${valueList} FROM \"${contentTable}\"\n\t\t\tWHERE deleted_at IS NULL\n\t\t`)\n\t\t\t.execute(this.db);\n\t}\n\n\t/**\n\t * Get the search configuration for a collection\n\t */\n\tasync getSearchConfig(collectionSlug: string): Promise<SearchConfig | null> {\n\t\tconst result = await this.db\n\t\t\t.selectFrom(\"_emdash_collections\")\n\t\t\t.select([\"search_config\", \"title_field\"])\n\t\t\t.where(\"slug\", \"=\", collectionSlug)\n\t\t\t.executeTakeFirst();\n\n\t\tif (!result?.search_config) {\n\t\t\treturn null;\n\t\t}\n\n\t\ttry {\n\t\t\tconst parsed: unknown = JSON.parse(result.search_config);\n\t\t\tif (\n\t\t\t\ttypeof parsed !== \"object\" ||\n\t\t\t\tparsed === null ||\n\t\t\t\t!(\"enabled\" in parsed) ||\n\t\t\t\ttypeof parsed.enabled !== \"boolean\"\n\t\t\t) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tconst config: SearchConfig = { enabled: parsed.enabled };\n\t\t\tif (result.title_field) config.titleField = result.title_field;\n\t\t\tif (\"weights\" in parsed && typeof parsed.weights === \"object\" && parsed.weights !== null) {\n\t\t\t\t// weights is a JSON-parsed object — safe to treat as Record<string, number>\n\t\t\t\tconst weights: Record<string, number> = {};\n\t\t\t\tfor (const [k, v] of Object.entries(parsed.weights)) {\n\t\t\t\t\tif (typeof v === \"number\") {\n\t\t\t\t\t\tweights[k] = v;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconfig.weights = weights;\n\t\t\t}\n\t\t\tif (\"tokenize\" in parsed) {\n\t\t\t\tif (!isSearchTokenizer(parsed.tokenize)) {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t\tconfig.tokenize = parsed.tokenize;\n\t\t\t}\n\t\t\treturn config;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/**\n\t * Update the search configuration for a collection\n\t */\n\tasync setSearchConfig(collectionSlug: string, config: SearchConfig): Promise<void> {\n\t\tif (config.tokenize !== undefined) {\n\t\t\tresolveSearchTokenizer(config.tokenize);\n\t\t}\n\t\tawait this.db\n\t\t\t.updateTable(\"_emdash_collections\")\n\t\t\t.set({ search_config: JSON.stringify(config) })\n\t\t\t.where(\"slug\", \"=\", collectionSlug)\n\t\t\t.execute();\n\t}\n\n\t/**\n\t * Get searchable fields for a collection\n\t */\n\tasync getSearchableFields(collectionSlug: string): Promise<string[]> {\n\t\tconst collection = await this.db\n\t\t\t.selectFrom(\"_emdash_collections\")\n\t\t\t.select(\"id\")\n\t\t\t.where(\"slug\", \"=\", collectionSlug)\n\t\t\t.executeTakeFirst();\n\n\t\tif (!collection) {\n\t\t\treturn [];\n\t\t}\n\n\t\tconst fields = await this.db\n\t\t\t.selectFrom(\"_emdash_fields\")\n\t\t\t.select(\"slug\")\n\t\t\t.where(\"collection_id\", \"=\", collection.id)\n\t\t\t.where(\"searchable\", \"=\", 1)\n\t\t\t.execute();\n\n\t\treturn fields.map((f) => f.slug);\n\t}\n\n\t/**\n\t * Whether a collection has a user-defined `title` field.\n\t *\n\t * `title` is not a system column on `ec_*` tables -- it exists only when a\n\t * collection defines a field with slug `title`. Search and suggestion SQL\n\t * that selects `c.title` must check this first; otherwise collections\n\t * without a title field raise \"no such column: c.title\".\n\t */\n\tasync hasTitleColumn(collectionSlug: string): Promise<boolean> {\n\t\tconst withTitle = await this.getCollectionsWithTitleColumn([collectionSlug]);\n\t\treturn withTitle.has(collectionSlug);\n\t}\n\n\t/**\n\t * Bulk variant of `hasTitleColumn()`: which of the given collections have\n\t * a user-defined `title` field. One query instead of one `hasTitleColumn`\n\t * round-trip pair per collection -- callers that check this once per\n\t * collection in a loop (multi-collection search, suggestions) should use\n\t * this instead (AGENTS.md: \"one query beats two\").\n\t */\n\tasync getCollectionsWithTitleColumn(collectionSlugs: string[]): Promise<Set<string>> {\n\t\tif (collectionSlugs.length === 0) return new Set();\n\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"_emdash_fields as f\")\n\t\t\t.innerJoin(\"_emdash_collections as c\", \"c.id\", \"f.collection_id\")\n\t\t\t.select([\"c.slug as collection_slug\"])\n\t\t\t.where(\"f.slug\", \"=\", \"title\")\n\t\t\t.execute();\n\n\t\tconst withTitle = new Set(rows.map((r) => r.collection_slug));\n\t\treturn new Set(collectionSlugs.filter((slug) => withTitle.has(slug)));\n\t}\n\n\t/**\n\t * Enable search for a collection.\n\t *\n\t * Uses rebuildIndex to ensure a clean state -- drop any existing FTS\n\t * table/triggers, recreate them, and populate from content. This avoids\n\t * duplicate rows when triggers have already populated the index (e.g.\n\t * during seeding where content is inserted before search is enabled).\n\t */\n\tasync enableSearch(\n\t\tcollectionSlug: string,\n\t\toptions?: { weights?: Record<string, number>; tokenize?: SearchTokenizer },\n\t): Promise<void> {\n\t\tif (options?.tokenize !== undefined) {\n\t\t\tresolveSearchTokenizer(options.tokenize);\n\t\t}\n\t\tif (!isSqlite(this.db)) {\n\t\t\tthrow new Error(\"Full-text search is only available with SQLite databases\");\n\t\t}\n\t\t// Get searchable fields\n\t\tconst searchableFields = await this.getSearchableFields(collectionSlug);\n\n\t\tif (searchableFields.length === 0) {\n\t\t\tthrow new Error(\n\t\t\t\t`No searchable fields defined for collection \"${collectionSlug}\". ` +\n\t\t\t\t\t`Mark at least one field as searchable before enabling search.`,\n\t\t\t);\n\t\t}\n\n\t\tconst existing = await this.getSearchConfig(collectionSlug);\n\t\tconst weights = options?.weights ?? existing?.weights;\n\t\tconst tokenize = options?.tokenize ?? existing?.tokenize;\n\n\t\t// Rebuild from scratch to ensure clean state (no duplicate rows)\n\t\tawait this.rebuildIndex(collectionSlug, searchableFields, weights, tokenize);\n\n\t\t// Update search config\n\t\tawait this.setSearchConfig(collectionSlug, {\n\t\t\tenabled: true,\n\t\t\tweights,\n\t\t\ttokenize,\n\t\t});\n\t}\n\n\t/**\n\t * Disable search for a collection\n\t *\n\t * Drops the FTS table and triggers.\n\t */\n\tasync disableSearch(collectionSlug: string): Promise<void> {\n\t\tif (!isSqlite(this.db)) return;\n\t\tawait this.dropFtsTable(collectionSlug);\n\t\tconst existing = await this.getSearchConfig(collectionSlug);\n\t\tawait this.setSearchConfig(collectionSlug, {\n\t\t\tenabled: false,\n\t\t\tweights: existing?.weights,\n\t\t\ttokenize: existing?.tokenize,\n\t\t});\n\t}\n\n\t/**\n\t * Get index statistics for a collection\n\t */\n\tasync getIndexStats(\n\t\tcollectionSlug: string,\n\t): Promise<{ indexed: number; lastRebuilt?: string } | null> {\n\t\tif (!isSqlite(this.db)) return null;\n\t\tthis.validateInputs(collectionSlug);\n\t\tconst ftsTable = this.getFtsTableName(collectionSlug);\n\t\tconst ftsDocsizeTable = `${ftsTable}_docsize`;\n\n\t\t// Check if table exists\n\t\tif (!(await this.ftsTableExists(collectionSlug))) {\n\t\t\treturn null;\n\t\t}\n\n\t\t// Count indexed rows\n\t\tconst result = await sql<{ count: number }>`\n\t\t\tSELECT COUNT(*) as count FROM \"${sql.raw(ftsDocsizeTable)}\"\n\t\t`.execute(this.db);\n\n\t\treturn {\n\t\t\tindexed: result.rows[0]?.count ?? 0,\n\t\t};\n\t}\n\n\t/**\n\t * Verify FTS index integrity and rebuild if drift is detected.\n\t *\n\t * Cheap belt-and-braces check, run lazily on the first search request\n\t * per isolate. The expensive cases (corrupted indexes from pre-fix\n\t * EmDash versions, broken legacy triggers) are handled at boot time by\n\t * migration `039_fix_fts5_triggers`, not here. This routine sticks to:\n\t *\n\t *   1. FTS table missing while config says search is enabled -> rebuild.\n\t *   2. Row count mismatch between content table and FTS docsize -> rebuild.\n\t *\n\t * Returns true if the index was rebuilt, false if it was healthy.\n\t */\n\tasync verifyAndRepairIndex(collectionSlug: string): Promise<boolean> {\n\t\tif (!isSqlite(this.db)) return false;\n\t\tthis.validateInputs(collectionSlug);\n\t\tconst ftsTable = this.getFtsTableName(collectionSlug);\n\t\tconst ftsDocsizeTable = `${ftsTable}_docsize`;\n\t\tconst contentTable = this.getContentTableName(collectionSlug);\n\t\tconst fields = await this.getSearchableFields(collectionSlug);\n\t\tconst config = await this.getSearchConfig(collectionSlug);\n\n\t\tif (!(await this.ftsTableExists(collectionSlug))) {\n\t\t\tif (!config?.enabled || fields.length === 0) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tconsole.warn(`FTS index for \"${collectionSlug}\" is missing. Rebuilding.`);\n\t\t\tawait this.rebuildIndex(collectionSlug, fields, config.weights, config.tokenize);\n\t\t\treturn true;\n\t\t}\n\n\t\t// Row count parity check against the docsize shadow table, which\n\t\t// tracks rows actually present in the full-text index.\n\t\tconst contentCount = await sql<{ count: number }>`\n\t\t\tSELECT COUNT(*) as count FROM ${sql.ref(contentTable)}\n\t\t\tWHERE deleted_at IS NULL\n\t\t`.execute(this.db);\n\n\t\tconst ftsCount = await sql<{ count: number }>`\n\t\t\tSELECT COUNT(*) as count FROM \"${sql.raw(ftsDocsizeTable)}\"\n\t\t`.execute(this.db);\n\n\t\tconst contentRows = contentCount.rows[0]?.count ?? 0;\n\t\tconst ftsRows = ftsCount.rows[0]?.count ?? 0;\n\n\t\tif (contentRows !== ftsRows) {\n\t\t\tconsole.warn(\n\t\t\t\t`FTS index for \"${collectionSlug}\" has ${ftsRows} rows but content table has ${contentRows}. Rebuilding.`,\n\t\t\t);\n\t\t\tif (fields.length > 0) {\n\t\t\t\tawait this.rebuildIndex(collectionSlug, fields, config?.weights, config?.tokenize);\n\t\t\t}\n\t\t\treturn true;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\t/**\n\t * Verify and repair FTS indexes for all search-enabled collections.\n\t *\n\t * Intended to run at startup to auto-heal any corruption from\n\t * previous process crashes.\n\t */\n\tasync verifyAndRepairAll(): Promise<number> {\n\t\tif (!isSqlite(this.db)) return 0;\n\n\t\tconst collections = await this.db\n\t\t\t.selectFrom(\"_emdash_collections\")\n\t\t\t.select(\"slug\")\n\t\t\t.where(\"search_config\", \"is not\", null)\n\t\t\t.execute();\n\n\t\tlet repaired = 0;\n\t\tfor (const { slug } of collections) {\n\t\t\tconst config = await this.getSearchConfig(slug);\n\t\t\tif (!config?.enabled) continue;\n\n\t\t\ttry {\n\t\t\t\tconst wasRepaired = await this.verifyAndRepairIndex(slug);\n\t\t\t\tif (wasRepaired) repaired++;\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(`Failed to verify/repair FTS index for \"${slug}\":`, error);\n\t\t\t}\n\t\t}\n\n\t\treturn repaired;\n\t}\n}\n"],"mappings":"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"}