From 3625ca4f4f0efdb0df2219c008175743987dbc53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 08:55:35 -0400 Subject: [PATCH] Q193: search re-indexes only what changed Co-Authored-By: Claude Opus 5.5 --- .../database/migrations/107-search-pending.ts | 48 ++++++ src/main/database/schema.ts | 2 + src/main/services/search-service.ts | 138 ++++++++++-------- tests/unit/domain-services.test.ts | 2 +- tests/unit/search-service.test.ts | 56 ++++++- 5 files changed, 186 insertions(+), 60 deletions(-) create mode 100644 src/main/database/migrations/107-search-pending.ts diff --git a/src/main/database/migrations/107-search-pending.ts b/src/main/database/migrations/107-search-pending.ts new file mode 100644 index 00000000..e6245b30 --- /dev/null +++ b/src/main/database/migrations/107-search-pending.ts @@ -0,0 +1,48 @@ +// Search re-indexes only what changed: triggers queue each changed record (a plain insert, since an outer statement's conflict policy overrides one inside a trigger), and a company's index is built in full once. +const INDEXED: Array<[table: string, entityType: string]> = [ + ["employees", "employee"], + ["agents", "agent"], + ["tasks", "task"], + ["projects", "project"], + ["goals", "goal"], + ["workspaces", "workspace"], + ["approvals", "approval"], + ["chat_spaces", "chat_space"], + ["chat_messages", "chat_message"], + ["file_objects", "file"], + ["providers", "provider"], + ["documents", "document"], + ["knowledge_entries", "knowledge"], +]; + +const triggers = INDEXED.map(([table, entityType]) => ["insert", "update", "delete"].map((event) => { + const row = event === "delete" ? "old" : "new"; + return ` + create trigger if not exists search_pending_${table}_${event} after ${event} on ${table} + begin + insert into search_pending(org_id, entity_type, entity_id) + select ${row}.org_id, '${entityType}', ${row}.id + where not exists (select 1 from search_pending where entity_type = '${entityType}' and entity_id = ${row}.id); + end;`; +}).join("")).join(""); + +export const searchPendingSchema = ` + create table if not exists search_pending ( + org_id text not null, + entity_type text not null, + entity_id text not null, + primary key (entity_type, entity_id) + ); + create table if not exists search_built (org_id text primary key); + ${triggers} + + create trigger if not exists search_cleanup_organization after delete on organizations + begin + delete from search_documents where org_id = old.id; + delete from search_pending where org_id = old.id; + delete from search_built where org_id = old.id; + end; + + insert into schema_migrations(version, name, applied_at) + values (107, 'search_pending', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 6824ded5..0844a5f7 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -105,6 +105,7 @@ import { workVerdictsSchema } from "./migrations/103-work-verdicts"; import { workLanguageSchema } from "./migrations/104-work-language"; import { onboardingProgressSchema } from "./migrations/105-onboarding-progress"; import { viewStateSchema } from "./migrations/106-view-state"; +import { searchPendingSchema } from "./migrations/107-search-pending"; interface Migration { version: number; @@ -219,6 +220,7 @@ const MIGRATIONS: Migration[] = [ { version: 104, name: "work_language", sql: workLanguageSchema }, { version: 105, name: "onboarding_progress", sql: onboardingProgressSchema }, { version: 106, name: "view_state", sql: viewStateSchema }, + { version: 107, name: "search_pending", sql: searchPendingSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/search-service.ts b/src/main/services/search-service.ts index 45e7424c..ac6a4521 100644 --- a/src/main/services/search-service.ts +++ b/src/main/services/search-service.ts @@ -33,7 +33,7 @@ export class SearchService { const limit = Math.max(1, Math.min(input.limit ?? 20, 50)); const offset = Math.max(0, input.offset ?? 0); const entityTypes = validEntityTypes(input.entityTypes ?? [...SEARCH_ENTITY_TYPES]); - this.reindexOrg(input.orgId); + this.refreshIndex(input.orgId); if (query.blank) return { results: [], total: 0 }; // English-like words use the full-text index; other scripts, which it does not split into words, must appear in the text. const rows = this.db.prepare(` @@ -46,6 +46,7 @@ export class SearchService { .filter((row) => query.contains.every((word) => row.title.toLowerCase().includes(word) || row.body.toLowerCase().includes(word))) .filter((row) => entityTypes.has(row.entity_type)) .filter((row) => this.canRead(input.orgId, input.employeeId, row.entity_type, row.entity_id)); + const now = new Date().toISOString(); const results = readable .slice(offset, offset + limit) .map((row) => ({ @@ -53,119 +54,133 @@ export class SearchService { entityId: row.entity_id, title: row.title, preview: preview(row.body), - facets: parseFacets(row.facet_json), + facets: row.entity_type === "knowledge" ? knowledgeFacets(parseFacets(row.facet_json), now) : parseFacets(row.facet_json), })); return { results, total: readable.length }; } - private reindexOrg(orgId: string): void { - this.db.exec("savepoint search_reindex"); + /** Brings the index up to date: a company's first search builds it whole; after that only records queued by change triggers are redone. */ + private refreshIndex(orgId: string): void { + this.db.exec("savepoint search_refresh"); try { - this.db.prepare("delete from search_documents where org_id = ?").run(orgId); - const insert = this.db.prepare(` - insert into search_documents(org_id, entity_type, entity_id, title, body, facet_json) - values (?, ?, ?, ?, ?, ?) - `); - for (const doc of this.documents(orgId)) { - insert.run(orgId, doc.entityType, doc.entityId, doc.title, doc.body, JSON.stringify(doc.facets)); + const insert = this.db.prepare("insert into search_documents(org_id, entity_type, entity_id, title, body, facet_json) values (?, ?, ?, ?, ?, ?)"); + const write = (doc: SearchDoc) => insert.run(orgId, doc.entityType, doc.entityId, doc.title, doc.body, JSON.stringify(doc.facets)); + if (!this.db.prepare("select 1 from search_built where org_id = ?").get(orgId)) { + this.db.prepare("delete from search_documents where org_id = ?").run(orgId); + for (const doc of this.documents(orgId, null)) write(doc); + this.db.prepare("delete from search_pending where org_id = ?").run(orgId); + this.db.prepare("insert into search_built(org_id) values (?)").run(orgId); + console.info(`[search] ${orgId}: index built`); + } else { + const pending = this.db.prepare("select entity_type, entity_id from search_pending where org_id = ?").all(orgId) as Array<{ entity_type: SearchEntityType; entity_id: string }>; + for (const change of pending) { + this.db.prepare("delete from search_documents where entity_type = ? and entity_id = ?").run(change.entity_type, change.entity_id); + for (const doc of this.documents(orgId, { entityType: change.entity_type, id: change.entity_id })) write(doc); + } + this.db.prepare("delete from search_pending where org_id = ?").run(orgId); } - this.db.exec("release search_reindex"); + this.db.exec("release search_refresh"); } catch (error) { - this.db.exec("rollback to search_reindex"); - this.db.exec("release search_reindex"); + this.db.exec("rollback to search_refresh"); + this.db.exec("release search_refresh"); throw error; } } - private documents(orgId: string): SearchDoc[] { - return [ - ...this.employeeDocs(orgId), - ...this.agentDocs(orgId), - ...this.taskDocs(orgId), - ...this.projectDocs(orgId), - ...this.goalDocs(orgId), - ...this.workspaceDocs(orgId), - ...this.approvalDocs(orgId), - ...this.chatSpaceDocs(orgId), - ...this.chatMessageDocs(orgId), - ...this.fileDocs(orgId), - ...this.providerDocs(orgId), - ...this.documentDocs(orgId), - ...this.knowledgeDocs(orgId), - ]; + + /** One company's records, or just one of them, as index documents. */ + private documents(orgId: string, only: { entityType: SearchEntityType; id: string } | null): SearchDoc[] { + const builders: Record SearchDoc[]> = { + employee: (org, id) => this.employeeDocs(org, id), + agent: (org, id) => this.agentDocs(org, id), + task: (org, id) => this.taskDocs(org, id), + project: (org, id) => this.projectDocs(org, id), + goal: (org, id) => this.goalDocs(org, id), + workspace: (org, id) => this.workspaceDocs(org, id), + approval: (org, id) => this.approvalDocs(org, id), + chat_space: (org, id) => this.chatSpaceDocs(org, id), + chat_message: (org, id) => this.chatMessageDocs(org, id), + file: (org, id) => this.fileDocs(org, id), + provider: (org, id) => this.providerDocs(org, id), + document: (org, id) => this.documentDocs(org, id), + knowledge: (org, id) => this.knowledgeDocs(org, id), + }; + if (only) return builders[only.entityType](orgId, only.id); + return SEARCH_ENTITY_TYPES.flatMap((entityType) => builders[entityType](orgId, null)); } - private employeeDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, display_name, role from employees where org_id = ?").all(orgId) as Array<{ id: string; display_name: string; role: string }>; + + private employeeDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, display_name, role from employees where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; display_name: string; role: string }>; return rows.map((row) => doc("employee", row.id, row.display_name, row.role, { type: "People" })); } - private agentDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, name, role, department, title from agents where org_id = ?").all(orgId) as Array<{ id: string; name: string; role: string; department: string; title: string }>; + private agentDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, name, role, department, title from agents where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; name: string; role: string; department: string; title: string }>; return rows.map((row) => doc("agent", row.id, row.name, [row.role, row.department, row.title].join(" "), { type: "People", department: row.department })); } - private taskDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, title, description, status, risk from tasks where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string; risk: string }>; + private taskDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, title, description, status, risk from tasks where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; title: string; description: string; status: string; risk: string }>; return rows.map((row) => doc("task", row.id, row.title, row.description, { type: "Work", status: row.status, risk: row.risk })); } - private projectDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, name, description, status from projects where org_id = ?").all(orgId) as Array<{ id: string; name: string; description: string; status: string }>; + private projectDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, name, description, status from projects where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; name: string; description: string; status: string }>; return rows.map((row) => doc("project", row.id, row.name, row.description, { type: "Work", status: row.status })); } - private goalDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, title, description, status from goals where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string }>; + private goalDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, title, description, status from goals where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; title: string; description: string; status: string }>; return rows.map((row) => doc("goal", row.id, row.title, row.description, { type: "Work", status: row.status })); } - private workspaceDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, name, local_path, repo_url from workspaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; local_path: string; repo_url: string }>; + private workspaceDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, name, local_path, repo_url from workspaces where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; name: string; local_path: string; repo_url: string }>; return rows.map((row) => doc("workspace", row.id, row.name, `${row.local_path} ${row.repo_url}`, { type: "Work" })); } - private approvalDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, title, details, state, risk from approvals where org_id = ?").all(orgId) as Array<{ id: string; title: string; details: string; state: string; risk: string }>; + private approvalDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, title, details, state, risk from approvals where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; title: string; details: string; state: string; risk: string }>; return rows.map((row) => doc("approval", row.id, row.title, row.details, { type: "Governance", state: row.state, risk: row.risk })); } - private chatSpaceDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, name, topic, visibility from chat_spaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; topic: string; visibility: string }>; + private chatSpaceDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, name, topic, visibility from chat_spaces where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; name: string; topic: string; visibility: string }>; return rows.map((row) => doc("chat_space", row.id, row.name, row.topic, { type: "Messages", visibility: row.visibility })); } - private chatMessageDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, space_id, thread_id, body_md, sender_type from chat_messages where org_id = ? and deleted_at is null").all(orgId) as Array<{ id: string; space_id: string; thread_id: string; body_md: string; sender_type: string }>; + private chatMessageDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, space_id, thread_id, body_md, sender_type from chat_messages where org_id = ? and (? is null or id = ?) and deleted_at is null").all(orgId, id, id) as Array<{ id: string; space_id: string; thread_id: string; body_md: string; sender_type: string }>; return rows.map((row) => doc("chat_message", row.id, createTitle(row.body_md), row.body_md, { type: "Messages", sender: row.sender_type, space: row.space_id, thread: row.thread_id })); } - private fileDocs(orgId: string): SearchDoc[] { + private fileDocs(orgId: string, id: string | null): SearchDoc[] { // Superseded versions are kept for records but listed nowhere, so they are not search hits either. - const rows = this.db.prepare("select id, name, kind, sha256, scan_state, storage_path, size_bytes from file_objects where org_id = ? and superseded_at is null").all(orgId) as Array<{ id: string; name: string; kind: string; sha256: string; scan_state: string; storage_path: string; size_bytes: number }>; + const rows = this.db.prepare("select id, name, kind, sha256, scan_state, storage_path, size_bytes from file_objects where org_id = ? and (? is null or id = ?) and superseded_at is null").all(orgId, id, id) as Array<{ id: string; name: string; kind: string; sha256: string; scan_state: string; storage_path: string; size_bytes: number }>; return rows.map((row) => doc("file", row.id, row.name, `${fileText(row)} ${row.kind} ${row.sha256}`, { type: "Files", state: row.scan_state })); } - private providerDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, label, provider_key, auth_state, health_state from providers where org_id = ?").all(orgId) as Array<{ id: string; label: string; provider_key: string; auth_state: string; health_state: string }>; + private providerDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, label, provider_key, auth_state, health_state from providers where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; label: string; provider_key: string; auth_state: string; health_state: string }>; return rows.map((row) => doc("provider", row.id, row.label, row.provider_key, { type: "Settings", auth: row.auth_state, health: row.health_state })); } - private documentDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, title, doc_type, status, content from documents where org_id = ?").all(orgId) as Array<{ id: string; title: string; doc_type: string; status: string; content: string }>; + private documentDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, title, doc_type, status, content from documents where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; title: string; doc_type: string; status: string; content: string }>; return rows.map((row) => doc("document", row.id, row.title, row.content, { type: "Library", kind: row.doc_type, status: row.status })); } - private knowledgeDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, topic, category, content, scope_type, superseded_by, valid_until from knowledge_entries where org_id = ?").all(orgId) as Array<{ + private knowledgeDocs(orgId: string, id: string | null): SearchDoc[] { + const rows = this.db.prepare("select id, topic, category, content, scope_type, superseded_by, valid_until from knowledge_entries where org_id = ? and (? is null or id = ?)").all(orgId, id, id) as Array<{ id: string; topic: string; category: string; content: string; scope_type: string; superseded_by: string | null; valid_until: string | null; }>; - const now = new Date().toISOString(); return rows.map((row) => doc("knowledge", row.id, row.topic, row.content, { type: "Library", category: row.category, scope: row.scope_type, - state: row.superseded_by ? "superseded" : row.valid_until && row.valid_until <= now ? "expired" : "active", + superseded: row.superseded_by ? "yes" : "no", + validUntil: row.valid_until ?? "", })); } @@ -236,6 +251,13 @@ function fileText(file: { name: string; storage_path: string; sha256: string; si } } +/** A knowledge entry's state as of now: replaced, past its date, or current. */ +function knowledgeFacets(stored: Record, now: string): Record { + const { superseded, validUntil, ...facets } = stored; + const state = superseded === "yes" ? "superseded" : validUntil && validUntil <= now ? "expired" : "active"; + return { ...facets, state }; +} + function validEntityTypes(entityTypes: SearchEntityType[]): Set { const allowed = new Set(SEARCH_ENTITY_TYPES); return new Set(entityTypes.filter((type) => allowed.has(type))); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index a27fc224..9ce5a3f4 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -35,7 +35,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107]); connection.close(); }); diff --git a/tests/unit/search-service.test.ts b/tests/unit/search-service.test.ts index bbd7e712..af4245fe 100644 --- a/tests/unit/search-service.test.ts +++ b/tests/unit/search-service.test.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { ChatService } from "../../src/main/services/chat-service"; import { OrganizationService } from "../../src/main/services/organization-service"; @@ -276,4 +276,58 @@ describe("search service", () => { expect(blocked[0]!.preview).not.toContain("PRIVATE KEY"); connection.close(); }); + + test("indexes a company once, then redoes only what changed", () => { + const connection = createConnection(); + const db = connection.db; + const organization = new OrganizationService(db); + const search = new SearchService(db); + let orgId = ""; + let taskId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Index Co", employeeName: "Dana Lee", mission: "m", goal: "g" }); + taskId = organization.createTask({ + orgId, title: "Osprey survey", description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: null, risk: "low", + projectId: null, goalId: null, workspaceId: null, priority: "normal", requiresFilesystem: false, + }); + }); + const employeeId = (db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + const titles = (query: string) => search.search({ orgId, employeeId, query }).results.map((hit) => hit.title); + const indexRows = () => db.prepare("select rowid from search_documents where org_id = ? order by rowid").all(orgId); + expect(titles("osprey")).toEqual(["Osprey survey"]); + expect(db.prepare("select org_id from search_built").all()).toEqual([{ org_id: orgId }]); + // Nothing changed, nothing is rewritten. + const before = indexRows(); + expect(titles("osprey")).toEqual(["Osprey survey"]); + expect(indexRows()).toEqual(before); + // Edits, additions and removals are queued and applied on the next search. + db.prepare("update tasks set title = 'Heron survey' where id = ?").run(taskId); + connection.transaction(() => organization.createTask({ + orgId, title: "Kestrel count", description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: null, risk: "low", + projectId: null, goalId: null, workspaceId: null, priority: "normal", requiresFilesystem: false, + })); + expect(db.prepare("select count(*) as n from search_pending where org_id = ?").get(orgId)).toEqual({ n: 2 }); + expect(titles("osprey")).toEqual([]); + expect(titles("heron")).toEqual(["Heron survey"]); + expect(titles("kestrel")).toEqual(["Kestrel count"]); + expect(db.prepare("select count(*) as n from search_pending where org_id = ?").get(orgId)).toEqual({ n: 0 }); + // A knowledge entry's state is read against the clock, not frozen at indexing. + new KnowledgeService(db).create({ + orgId, topic: "Ferry timetable", category: "policy", content: "Ferries leave hourly.", importance: "normal", + sourceType: "employee", sourceId: null, scopeType: "organization", scopeId: null, validUntil: "2030-01-01T00:00:00Z", supersedesId: null, + }); + expect(search.search({ orgId, employeeId, query: "ferry", entityTypes: ["knowledge"] }).results[0]!.facets.state).toBe("active"); + vi.setSystemTime(new Date("2031-01-01T00:00:00Z")); + try { + expect(search.search({ orgId, employeeId, query: "ferry", entityTypes: ["knowledge"] }).results[0]!.facets.state).toBe("expired"); + expect(db.prepare("select count(*) as n from search_pending where org_id = ?").get(orgId)).toEqual({ n: 0 }); + } finally { + vi.useRealTimers(); + } + // A deleted company leaves no index behind. + connection.transaction(() => organization.deleteOrganization(orgId)); + expect(db.prepare("select count(*) as n from search_documents where org_id = ?").get(orgId)).toEqual({ n: 0 }); + expect(db.prepare("select count(*) as n from search_built where org_id = ?").get(orgId)).toEqual({ n: 0 }); + connection.close(); + }); });