/** * AI Software Factory — 100,000-doc search fixture seeder (TASK-0100). * * Seeds the OpenSearch content index with 100k synthetic docs (a realistic * 7-type mix) PLUS a minimal deterministic Postgres footprint — 2 workspaces, * 200 users, 200 memberships, 2 teams — so the k6 suite (search-search.k6.js) * can mint real HS256 access JWTs for perf users that pass the API's auth + * tenant scope (the viewer's workspaceIds come from DB memberships, never * the token). * * DESIGN NOTES (why this shape): * - **OS-only docs, no `search_documents` mirror rows.** The fixtures are * data, not source rows: the worker's reconcile sweep (contentHash over * mirror rows) never sees them, and `runReindex` only removes orphans when * explicitly triggered for a workspace — so the fixtures are inert to the * indexing pipeline and reset is a single `delete_by_query`. No DB doc * rows means seeding 100k is seconds, not minutes (the < 10 min budget). * - **Deterministic ids + bodies** (index-derived, no RNG): re-running * `seed` is an idempotent no-op for OpenSearch (same `_id`s → `_bulk` * index op is an upsert) and the DB createMany uses skipDuplicates. * - **Doc mix** (spec req 2: 10k pages × 7-type mix, 100k total): * pages 41937 (41.9%) · records 26211 (26.2%) · files 15726 (15.7%) * comments 10484 (10.5%) · databases 5242 (5.2%) · user docs 400 (0.4%) * RECORDED CONFLICT (task file): the spec's "5% users" is infeasible — * user docs are workspace-scoped (`user::`), so 200 users × 2 * workspaces yield at most 400 user docs (0.4%). The six other types are * scaled proportionally to keep the 100k total. * BUG FIXED (2026-09-29, S6): the original counts were the UNSCALED * spec-percentage values (39840/24900/14940/9960/4980/400) and summed to * 95,020 — the script's own total===100_000 guard rejected them at boot. * The five non-user types are now rescaled to fill the 100,000-400 * remainder in their original proportion. * - **Permission mix** (spec: 50% workspace / 25% team / 25% private): * the grants snapshot matches the worker's `buildGrantSnapshot` shapes * exactly (packages/search GrantSnapshot) so the query-time permission * filter exercises all three visibility paths. * - **Bodies 200–2,000 chars**, every body contains the shared tokens * "factory" + "search" so the k6 query always has BM25 hits (an empty * result is a different code path than the top-k latency we measure). * * Usage (repo root, Prisma client generated; the Docker/CI search stack up): * * pnpm --dir tests/performance perf:seed:search (reset + seed 100k) * pnpm --dir tests/performance perf:reset:search (drop fixtures only) * tsx tests/performance/fixtures/search-100k.ts status * * Env (all optional — CI sets them; local defaults match the dev stack): * DATABASE_URL Postgres DSN (default postgresql://factory:factory@localhost:5432/factory) * SEARCH_URL OpenSearch base (default http://localhost:9200) * SEARCH_USERNAME / SEARCH_PASSWORD basic auth (empty → none, dev default) * K6_SEARCH_WORKSPACES comma list (default perf-search-a,perf-search-b) */ import { createHash } from "node:crypto"; import { PrismaClient } from "@prisma/client"; import { contentMapping } from "@factory/search"; import type { GrantSnapshot, SearchDoc } from "@factory/search"; // --------------------------------------------------------------------------- // Deterministic fixture config // --------------------------------------------------------------------------- const WORKSPACES = ( process.env.K6_SEARCH_WORKSPACES || "perf-search-a,perf-search-b" ) .split(",") .map((s) => s.trim()) .filter(Boolean); const USER_COUNT = 200; // perf-search-u-000 … perf-search-u-199 const TEAM_PER_WS = WORKSPACES.map((ws) => `perf-search-team-${ws.slice(-1)}`); // 100,000 docs, six types (user docs capped at 200 users × 2 ws = 400 — see // header conflict note). Counts are exact; total === 100000. const DOC_COUNTS = { page: 41_937, record: 26_211, file: 15_726, comment: 10_484, database: 5_242, user: 400, } as const; const TOTAL_DOCS = Object.values(DOC_COUNTS).reduce((a, b) => a + b, 0); if (TOTAL_DOCS !== 100_000) { throw new Error(`fixture mix changed — total ${TOTAL_DOCS} !== 100000; update the mix intentionally`); } const BULK_BATCH = 5_000; // ~30 MB worst case per _bulk request const BASE_TIME = Date.parse("2026-01-01T00:00:00Z"); const SEARCH_URL = (process.env.SEARCH_URL || "http://localhost:9200").replace(/\/+$/, ""); const OS_USER = process.env.SEARCH_USERNAME || ""; const OS_PASS = process.env.SEARCH_PASSWORD || ""; const prisma = new PrismaClient(); const pad = (n: number, w: number) => String(n).padStart(w, "0"); const userPrefix = "perf-search-u-"; const userId = (i: number) => `${userPrefix}${pad(i, 3)}`; const wsOf = (i: number) => WORKSPACES[i % WORKSPACES.length]; const teamOf = (i: number) => TEAM_PER_WS[i % TEAM_PER_WS.length]; const createdByOf = (i: number) => userId(i % USER_COUNT); // Shared filler words; every body carries "factory" + "search" (guaranteed // BM25 hits for the k6 query) plus rotating content so BM25 scores vary. const FILLER = ( "factory search performance baseline latency index workspace document " + "alpha beta gamma delta epsilon zeta eta theta lambda sigma omega query " + "fusion ranking snippet highlight cursor pagination tenant visibility " + "snapshot grant embed vector cosine knn hybrid keyword reciprocal rank" ).split(" "); function bodyText(i: number): string { // 200–2,000 chars, deterministic; ~40 words minimum so the BM25 field has // real length statistics to work with. const len = 200 + ((i * 379) % 1801); const words: string[] = []; let n = 0; let total = 0; let k = i % FILLER.length; while (total < len) { const w = FILLER[k++ % FILLER.length]; words.push(w); total += w.length + 1; n += 1; } return words.join(" ").slice(0, len); } /** Deterministic 50/25/25 permission mix (spec req 2). */ function grantsFor(i: number, ws: string): { grants: GrantSnapshot; teamId?: string } { const bucket = i % 100; if (bucket < 50) { // workspace-visible (the member floor — the default for non-private content) return { grants: { public: false, workspace: true, user: false, teams: [], users: [] }, teamId: undefined }; } if (bucket < 75) { // team grant — visible to members of the workspace's perf team const teamId = teamOf((WORKSPACES.indexOf(ws) + 1) * 997 + i); return { grants: { public: false, workspace: false, user: false, teams: [teamId], users: [] }, teamId }; } // private — visible only to the creator (users: [createdBy]) const creator = createdByOf(i); return { grants: { public: false, workspace: false, user: false, teams: [], users: [creator] }, teamId: undefined }; } const TITLE: Record = { page: (i) => `Perf Page ${pad(i, 5)} factory search fixture`, record: (i) => `Perf Record ${pad(i, 5)} factory search fixture`, file: (i) => `perf-report-${pad(i, 5)}.txt`, comment: (i) => `Perf Comment ${pad(i, 5)} on factory search fixture`, database: (i) => `Perf Database ${pad(i, 5)} factory search fixture`, user: (i) => `Perf User ${pad(i % USER_COUNT, 3)}`, }; const MIME_TYPES = ["text/plain", "application/pdf", "text/markdown"]; function buildDoc(type: keyof typeof DOC_COUNTS, i: number): SearchDoc { // BUG FIXED (2026-09-29, S6): user docs must biject over 200 users × 2 // workspaces (400 DISTINCT `user::` ids). The default // `wsOf(i) = WORKSPACES[i % 2]` + `userId(i % 200)` made i and i+200 // collide (same ws AND same uid) → only 200 distinct user docs landed // (seed count 99,800 ≠ 100,000). Split the 400-slot user range in half: // [0,200) → workspace 0, [200,400) → workspace 1. const ws = type === "user" ? WORKSPACES[Math.floor(i / USER_COUNT) % WORKSPACES.length] : wsOf(i); const { grants, teamId } = grantsFor(i, ws); const createdAt = new Date(BASE_TIME + i * 1_000).toISOString(); const updatedAt = new Date(BASE_TIME + i * 1_000 + 600_000).toISOString(); const createdByName = `Perf User ${pad(i % USER_COUNT, 3)}`; const common = { type, workspaceId: ws, title: TITLE[type](i), body: bodyText(i), grants, archived: false, createdBy: createdByOf(i), createdByName, url: "", createdAt, updatedAt, }; switch (type) { case "page": { const id = `perf-pg-${pad(i, 5)}`; return { ...common, docId: `page:${id}`, id, url: `/w/${ws}/page/${id}`, pageId: id, ...(teamId ? { teamId } : {}) }; } case "record": { const id = `perf-rc-${pad(i, 5)}`; const dbId = `perf-db-${pad(i % DOC_COUNTS.database, 5)}`; return { ...common, docId: `record:${id}`, id, databaseId: dbId, recordId: id, url: `/w/${ws}/databases/${dbId}`, ...(teamId ? { teamId } : {}) }; } case "file": { const id = `perf-fl-${pad(i, 5)}`; const mimeType = MIME_TYPES[i % MIME_TYPES.length]; return { ...common, docId: `file:${id}`, id, size: 1_000 + ((i * 101) % 99_000), mimeType, url: `/w/${ws}`, ...(teamId ? { teamId } : {}) }; } case "comment": { const id = `perf-cm-${pad(i, 5)}`; const pageId = `perf-pg-${pad(i % DOC_COUNTS.page, 5)}`; return { ...common, docId: `comment:${id}`, id, pageId, url: `/w/${ws}/page/${pageId}`, ...(teamId ? { teamId } : {}) }; } case "database": { const id = `perf-db-${pad(i, 5)}`; return { ...common, docId: `database:${id}`, id, databaseId: id, url: `/w/${ws}/databases/${id}`, ...(teamId ? { teamId } : {}) }; } case "user": { // Workspace-scoped user doc (mirrors the worker's buildUserDoc shape: // docId `user::`, url → the workspace). 400 docs = 200 users × 2 ws. const uid = userId(i % USER_COUNT); return { docId: `user:${ws}:${uid}`, id: uid, type: "user", workspaceId: ws, title: TITLE.user(i), body: `${createdByName} — perf fixture member of ${ws}`, grants, archived: false, createdBy: uid, createdByName, url: `/w/${ws}`, createdAt, updatedAt, }; } } } // --------------------------------------------------------------------------- // OpenSearch (raw fetch — the 100k _bulk exceeds the SearchClient's 5 s // timeout; the index bootstrap below reuses the package's canonical mapping) // --------------------------------------------------------------------------- function osHeaders(): Record { const h: Record = { "content-type": "application/json" }; if (OS_USER) h.authorization = "Basic " + Buffer.from(`${OS_USER}:${OS_PASS}`).toString("base64"); return h; } async function os(path: string, init: RequestInit = {}, timeoutMs = 120_000): Promise { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(`${SEARCH_URL}${path}`, { ...init, headers: { ...osHeaders(), ...(init.headers as Record) }, signal: ctrl.signal }); const text = await res.text(); if (!res.ok) throw new Error(`OS ${init.method ?? "GET"} ${path} → ${res.status}: ${text.slice(0, 500)}`); return (text ? (JSON.parse(text) as T) : {}) as T; } finally { clearTimeout(t); } } async function ensureIndex(): Promise { // The canonical mapping from @factory/search (same contentMapping() the // worker's ensureContentIndex uses at startup) — 1536-dim HNSW defaults. const mapping = contentMapping(); // 1) the backing index (idempotent PUT) try { await os("/factory-content-v1", { method: "PUT", body: JSON.stringify(mapping) }); process.stdout.write(" created index factory-content-v1\n"); } catch (err) { if (!/resource_already_exists/.test(String(err))) throw err; } // 2) the alias → backing (atomic swap; idempotent) await os("/_aliases", { method: "POST", body: JSON.stringify({ actions: [{ remove: { index: "factory-content-v1", alias: "factory-content" } }, { add: { index: "factory-content-v1", alias: "factory-content" } }], }), }); } async function bulkIndex(docs: SearchDoc[]): Promise { const lines: string[] = []; for (const d of docs) { lines.push(JSON.stringify({ index: { _index: "factory-content", _id: d.docId } })); lines.push(JSON.stringify(d)); } await os("/_bulk", { method: "POST", body: lines.join("\n") + "\n" }); } async function osCount(where: Record): Promise { const out = await os<{ count: number }>("/factory-content/_count", { method: "POST", body: JSON.stringify({ query: { bool: { filter: [where] } } }) }); return out.count; } // --------------------------------------------------------------------------- // Postgres footprint (JWT-valid users / workspaces / teams) // --------------------------------------------------------------------------- async function seedDb(): Promise { const users = Array.from({ length: USER_COUNT }, (_, i) => ({ id: userId(i), email: `${userId(i)}@factory.local`, name: `Perf User ${pad(i, 3)}`, // passwordHash stays null: the k6 suite MINTS JWTs (k6/crypto) for these // ids — no password login exists for fixtures (spec Security: "fixture // passwords never stored"). })); await prisma.user.createMany({ data: users, skipDuplicates: true }); const workspaces = WORKSPACES.map((id, i) => ({ id, name: `k6 Search ${i + 1} (TASK-0100)`, slug: id, settings: {}, ownerId: userId(0), })); for (const ws of workspaces) { await prisma.workspace.upsert({ where: { id: ws.id }, update: {}, create: ws }); } const members = Array.from({ length: USER_COUNT }, (_, i) => ({ workspaceId: wsOf(i), userId: userId(i), role: "MEMBER" as const, })); await prisma.workspaceMember.createMany({ data: members, skipDuplicates: true }); for (let i = 0; i < WORKSPACES.length; i += 1) { const ws = WORKSPACES[i]; const team = TEAM_PER_WS[i]; await prisma.team.upsert({ where: { id: team }, update: {}, create: { id: team, workspaceId: ws, name: `Perf Team ${i + 1}`, slug: team, position: "a0" }, }); // Every member of the workspace joins its perf team → the k6 users see // the team-granted docs (viewer.teamIds comes from TeamMember rows). const rows = await prisma.workspaceMember.findMany({ where: { workspaceId: ws }, select: { id: true, userId: true } }); const tm = rows.map((r) => ({ teamId: team, workspaceMemberId: r.id, userId: r.userId, role: "MEMBER" as const })); if (tm.length > 0) await prisma.teamMember.createMany({ data: tm, skipDuplicates: true }); } } async function resetDb(): Promise { await prisma.teamMember.deleteMany({ where: { teamId: { in: TEAM_PER_WS } } }); await prisma.team.deleteMany({ where: { id: { in: TEAM_PER_WS } } }); await prisma.workspaceMember.deleteMany({ where: { userId: { startsWith: userPrefix } } }); await prisma.workspace.deleteMany({ where: { id: { in: WORKSPACES } } }); await prisma.user.deleteMany({ where: { id: { startsWith: userPrefix } } }); // Defensive: any mirror rows pointing at fixture workspaces (there are none // from this seeder, but a prior worker-driven run in the same env might // leave some — reset must leave the index + mirror consistent, DoD). await prisma.searchDocument.deleteMany({ where: { workspaceId: { in: WORKSPACES } } }); } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- async function reset(): Promise { const t0 = Date.now(); const out = await os<{ deleted: number }>("/factory-content/_delete_by_query?conflicts=proceed&refresh=true", { method: "POST", body: JSON.stringify({ query: { bool: { filter: [{ terms: { workspaceId: WORKSPACES } }] } } }), }); await resetDb(); process.stdout.write(`reset: removed ${out.deleted} fixture docs from OS + DB fixture rows in ${((Date.now() - t0) / 1000).toFixed(1)} s\n`); } async function seed(): Promise { const t0 = Date.now(); process.stdout.write(`seed: resetting existing fixtures first (idempotent)\n`); await reset(); process.stdout.write("seed: ensuring content index (canonical mapping)\n"); await ensureIndex(); process.stdout.write("seed: seeding DB footprint (2 ws, 200 users, 2 teams)\n"); await seedDb(); process.stdout.write(`seed: indexing ${TOTAL_DOCS.toLocaleString()} docs in _bulk (${BULK_BATCH}/batch)\n`); const t1 = Date.now(); for (const [type, count] of Object.entries(DOC_COUNTS) as [keyof typeof DOC_COUNTS, number][]) { for (let start = 0; start < count; start += BULK_BATCH) { const end = Math.min(start + BULK_BATCH, count); const batch = Array.from({ length: end - start }, (_, j) => buildDoc(type, start + j)); await bulkIndex(batch); process.stdout.write(`\r ${type}: ${end}/${count}`); } process.stdout.write(`\n`); } const osSeconds = ((Date.now() - t1) / 1000).toFixed(1); await os("/factory-content/_refresh", { method: "POST" }); const actual = await osCount({ terms: { workspaceId: WORKSPACES } }); const seconds = (Date.now() - t0) / 1000; process.stdout.write( `seed: done in ${seconds.toFixed(1)} s (OS bulk ${osSeconds} s) — ${actual.toLocaleString()} fixture docs in the index ` + `(expected ${TOTAL_DOCS.toLocaleString()})\n` ); if (actual !== TOTAL_DOCS) { throw new Error(`seed count mismatch: ${actual} !== ${TOTAL_DOCS} — refuse to leave a partial fixture`); } // Determinism anchor (a change to the mix/bodies shifts this; informational). process.stdout.write(`fixture hash: ${createHash("sha1").update(JSON.stringify(DOC_COUNTS)).digest("hex")}\n`); process.stdout.write(`workspaces: ${WORKSPACES.join(", ")}\nk6 user: ${userId(0)} (member of ${wsOf(0)}, team ${teamOf(0)})\n`); } async function status(): Promise { const count = await osCount({ terms: { workspaceId: WORKSPACES } }); const users = await prisma.user.count({ where: { id: { startsWith: userPrefix } } }); const members = await prisma.workspaceMember.count({ where: { userId: { startsWith: userPrefix } } }); process.stdout.write(`status: ${count.toLocaleString()} fixture docs in OS · ${users} fixture users · ${members} memberships (expected 100000/200/200)\n`); } const action = process.argv[2]; const main = async () => { switch (action) { case "seed": await seed(); break; case "reset": await reset(); break; case "status": await status(); break; default: console.error("usage: search-100k.ts "); process.exitCode = 2; } }; main() .catch((err) => { console.error(err); process.exitCode = 1; }) .finally(() => prisma.$disconnect());