1
53import { createHash } from "node:crypto";
54import { PrismaClient } from "@prisma/client";
55import { contentMapping } from "@factory/search";
56import type { GrantSnapshot, SearchDoc } from "@factory/search";
57
58
59
60
61
62const WORKSPACES = (
63 process.env.K6_SEARCH_WORKSPACES || "perf-search-a,perf-search-b"
64)
65 .split(",")
66 .map((s) => s.trim())
67 .filter(Boolean);
68
69const USER_COUNT = 200;
70const TEAM_PER_WS = WORKSPACES.map((ws) => `perf-search-team-${ws.slice(-1)}`);
71
72
73
74const DOC_COUNTS = {
75 page: 41_937,
76 record: 26_211,
77 file: 15_726,
78 comment: 10_484,
79 database: 5_242,
80 user: 400,
81} as const;
82const TOTAL_DOCS = Object.values(DOC_COUNTS).reduce((a, b) => a + b, 0);
83if (TOTAL_DOCS !== 100_000) {
84 throw new Error(`fixture mix changed — total ${TOTAL_DOCS} !== 100000; update the mix intentionally`);
85}
86
87const BULK_BATCH = 5_000;
88const BASE_TIME = Date.parse("2026-01-01T00:00:00Z");
89
90const SEARCH_URL = (process.env.SEARCH_URL || "http://localhost:9200").replace(/\/+$/, "");
91const OS_USER = process.env.SEARCH_USERNAME || "";
92const OS_PASS = process.env.SEARCH_PASSWORD || "";
93
94const prisma = new PrismaClient();
95
96const pad = (n: number, w: number) => String(n).padStart(w, "0");
97const userPrefix = "perf-search-u-";
98const userId = (i: number) => `${userPrefix}${pad(i, 3)}`;
99const wsOf = (i: number) => WORKSPACES[i % WORKSPACES.length];
100const teamOf = (i: number) => TEAM_PER_WS[i % TEAM_PER_WS.length];
101const createdByOf = (i: number) => userId(i % USER_COUNT);
102
103
104
105const FILLER = (
106 "factory search performance baseline latency index workspace document " +
107 "alpha beta gamma delta epsilon zeta eta theta lambda sigma omega query " +
108 "fusion ranking snippet highlight cursor pagination tenant visibility " +
109 "snapshot grant embed vector cosine knn hybrid keyword reciprocal rank"
110).split(" ");
111
112function bodyText(i: number): string {
113
114
115 const len = 200 + ((i * 379) % 1801);
116 const words: string[] = [];
117 let n = 0;
118 let total = 0;
119 let k = i % FILLER.length;
120 while (total < len) {
121 const w = FILLER[k++ % FILLER.length];
122 words.push(w);
123 total += w.length + 1;
124 n += 1;
125 }
126 return words.join(" ").slice(0, len);
127}
128
129
130function grantsFor(i: number, ws: string): { grants: GrantSnapshot; teamId?: string } {
131 const bucket = i % 100;
132 if (bucket < 50) {
133
134 return { grants: { public: false, workspace: true, user: false, teams: [], users: [] }, teamId: undefined };
135 }
136 if (bucket < 75) {
137
138 const teamId = teamOf((WORKSPACES.indexOf(ws) + 1) * 997 + i);
139 return { grants: { public: false, workspace: false, user: false, teams: [teamId], users: [] }, teamId };
140 }
141
142 const creator = createdByOf(i);
143 return { grants: { public: false, workspace: false, user: false, teams: [], users: [creator] }, teamId: undefined };
144}
145
146const TITLE: Record<keyof typeof DOC_COUNTS, string> = {
147 page: (i) => `Perf Page ${pad(i, 5)} factory search fixture`,
148 record: (i) => `Perf Record ${pad(i, 5)} factory search fixture`,
149 file: (i) => `perf-report-${pad(i, 5)}.txt`,
150 comment: (i) => `Perf Comment ${pad(i, 5)} on factory search fixture`,
151 database: (i) => `Perf Database ${pad(i, 5)} factory search fixture`,
152 user: (i) => `Perf User ${pad(i % USER_COUNT, 3)}`,
153};
154
155const MIME_TYPES = ["text/plain", "application/pdf", "text/markdown"];
156
157function buildDoc(type: keyof typeof DOC_COUNTS, i: number): SearchDoc {
158
159
160
161
162
163
164 const ws =
165 type === "user"
166 ? WORKSPACES[Math.floor(i / USER_COUNT) % WORKSPACES.length]
167 : wsOf(i);
168 const { grants, teamId } = grantsFor(i, ws);
169 const createdAt = new Date(BASE_TIME + i * 1_000).toISOString();
170 const updatedAt = new Date(BASE_TIME + i * 1_000 + 600_000).toISOString();
171 const createdByName = `Perf User ${pad(i % USER_COUNT, 3)}`;
172 const common = {
173 type,
174 workspaceId: ws,
175 title: TITLE[type](i),
176 body: bodyText(i),
177 grants,
178 archived: false,
179 createdBy: createdByOf(i),
180 createdByName,
181 url: "",
182 createdAt,
183 updatedAt,
184 };
185
186 switch (type) {
187 case "page": {
188 const id = `perf-pg-${pad(i, 5)}`;
189 return { ...common, docId: `page:${id}`, id, url: `/w/${ws}/page/${id}`, pageId: id, ...(teamId ? { teamId } : {}) };
190 }
191 case "record": {
192 const id = `perf-rc-${pad(i, 5)}`;
193 const dbId = `perf-db-${pad(i % DOC_COUNTS.database, 5)}`;
194 return { ...common, docId: `record:${id}`, id, databaseId: dbId, recordId: id, url: `/w/${ws}/databases/${dbId}`, ...(teamId ? { teamId } : {}) };
195 }
196 case "file": {
197 const id = `perf-fl-${pad(i, 5)}`;
198 const mimeType = MIME_TYPES[i % MIME_TYPES.length];
199 return { ...common, docId: `file:${id}`, id, size: 1_000 + ((i * 101) % 99_000), mimeType, url: `/w/${ws}`, ...(teamId ? { teamId } : {}) };
200 }
201 case "comment": {
202 const id = `perf-cm-${pad(i, 5)}`;
203 const pageId = `perf-pg-${pad(i % DOC_COUNTS.page, 5)}`;
204 return { ...common, docId: `comment:${id}`, id, pageId, url: `/w/${ws}/page/${pageId}`, ...(teamId ? { teamId } : {}) };
205 }
206 case "database": {
207 const id = `perf-db-${pad(i, 5)}`;
208 return { ...common, docId: `database:${id}`, id, databaseId: id, url: `/w/${ws}/databases/${id}`, ...(teamId ? { teamId } : {}) };
209 }
210 case "user": {
211
212
213 const uid = userId(i % USER_COUNT);
214 return {
215 docId: `user:${ws}:${uid}`,
216 id: uid,
217 type: "user",
218 workspaceId: ws,
219 title: TITLE.user(i),
220 body: `${createdByName} — perf fixture member of ${ws}`,
221 grants,
222 archived: false,
223 createdBy: uid,
224 createdByName,
225 url: `/w/${ws}`,
226 createdAt,
227 updatedAt,
228 };
229 }
230 }
231}
232
233
234
235
236
237
238function osHeaders(): Record<string, string> {
239 const h: Record<string, string> = { "content-type": "application/json" };
240 if (OS_USER) h.authorization = "Basic " + Buffer.from(`${OS_USER}:${OS_PASS}`).toString("base64");
241 return h;
242}
243
244async function os<T = unknown>(path: string, init: RequestInit = {}, timeoutMs = 120_000): Promise<T> {
245 const ctrl = new AbortController();
246 const t = setTimeout(() => ctrl.abort(), timeoutMs);
247 try {
248 const res = await fetch(`${SEARCH_URL}${path}`, { ...init, headers: { ...osHeaders(), ...(init.headers as Record<string, string>) }, signal: ctrl.signal });
249 const text = await res.text();
250 if (!res.ok) throw new Error(`OS ${init.method ?? "GET"} ${path} → ${res.status}: ${text.slice(0, 500)}`);
251 return (text ? (JSON.parse(text) as T) : {}) as T;
252 } finally {
253 clearTimeout(t);
254 }
255}
256
257async function ensureIndex(): Promise<void> {
258
259
260 const mapping = contentMapping();
261
262 try {
263 await os("/factory-content-v1", { method: "PUT", body: JSON.stringify(mapping) });
264 process.stdout.write(" created index factory-content-v1\n");
265 } catch (err) {
266 if (!/resource_already_exists/.test(String(err))) throw err;
267 }
268
269 await os("/_aliases", {
270 method: "POST",
271 body: JSON.stringify({
272 actions: [{ remove: { index: "factory-content-v1", alias: "factory-content" } }, { add: { index: "factory-content-v1", alias: "factory-content" } }],
273 }),
274 });
275}
276
277async function bulkIndex(docs: SearchDoc[]): Promise<void> {
278 const lines: string[] = [];
279 for (const d of docs) {
280 lines.push(JSON.stringify({ index: { _index: "factory-content", _id: d.docId } }));
281 lines.push(JSON.stringify(d));
282 }
283 await os("/_bulk", { method: "POST", body: lines.join("\n") + "\n" });
284}
285
286async function osCount(where: Record<string, unknown>): Promise<number> {
287 const out = await os<{ count: number }>("/factory-content/_count", { method: "POST", body: JSON.stringify({ query: { bool: { filter: [where] } } }) });
288 return out.count;
289}
290
291
292
293
294
295async function seedDb(): Promise<void> {
296 const users = Array.from({ length: USER_COUNT }, (_, i) => ({
297 id: userId(i),
298 email: `${userId(i)}@factory.local`,
299 name: `Perf User ${pad(i, 3)}`,
300
301
302
303 }));
304 await prisma.user.createMany({ data: users, skipDuplicates: true });
305
306 const workspaces = WORKSPACES.map((id, i) => ({
307 id,
308 name: `k6 Search ${i + 1} (TASK-0100)`,
309 slug: id,
310 settings: {},
311 ownerId: userId(0),
312 }));
313 for (const ws of workspaces) {
314 await prisma.workspace.upsert({ where: { id: ws.id }, update: {}, create: ws });
315 }
316
317 const members = Array.from({ length: USER_COUNT }, (_, i) => ({
318 workspaceId: wsOf(i),
319 userId: userId(i),
320 role: "MEMBER" as const,
321 }));
322 await prisma.workspaceMember.createMany({ data: members, skipDuplicates: true });
323
324 for (let i = 0; i < WORKSPACES.length; i += 1) {
325 const ws = WORKSPACES[i];
326 const team = TEAM_PER_WS[i];
327 await prisma.team.upsert({
328 where: { id: team },
329 update: {},
330 create: { id: team, workspaceId: ws, name: `Perf Team ${i + 1}`, slug: team, position: "a0" },
331 });
332
333
334 const rows = await prisma.workspaceMember.findMany({ where: { workspaceId: ws }, select: { id: true, userId: true } });
335 const tm = rows.map((r) => ({ teamId: team, workspaceMemberId: r.id, userId: r.userId, role: "MEMBER" as const }));
336 if (tm.length > 0) await prisma.teamMember.createMany({ data: tm, skipDuplicates: true });
337 }
338}
339
340async function resetDb(): Promise<void> {
341 await prisma.teamMember.deleteMany({ where: { teamId: { in: TEAM_PER_WS } } });
342 await prisma.team.deleteMany({ where: { id: { in: TEAM_PER_WS } } });
343 await prisma.workspaceMember.deleteMany({ where: { userId: { startsWith: userPrefix } } });
344 await prisma.workspace.deleteMany({ where: { id: { in: WORKSPACES } } });
345 await prisma.user.deleteMany({ where: { id: { startsWith: userPrefix } } });
346
347
348
349 await prisma.searchDocument.deleteMany({ where: { workspaceId: { in: WORKSPACES } } });
350}
351
352
353
354
355
356async function reset(): Promise<void> {
357 const t0 = Date.now();
358 const out = await os<{ deleted: number }>("/factory-content/_delete_by_query?conflicts=proceed&refresh=true", {
359 method: "POST",
360 body: JSON.stringify({ query: { bool: { filter: [{ terms: { workspaceId: WORKSPACES } }] } } }),
361 });
362 await resetDb();
363 process.stdout.write(`reset: removed ${out.deleted} fixture docs from OS + DB fixture rows in ${((Date.now() - t0) / 1000).toFixed(1)} s\n`);
364}
365
366async function seed(): Promise<void> {
367 const t0 = Date.now();
368 process.stdout.write(`seed: resetting existing fixtures first (idempotent)\n`);
369 await reset();
370
371 process.stdout.write("seed: ensuring content index (canonical mapping)\n");
372 await ensureIndex();
373
374 process.stdout.write("seed: seeding DB footprint (2 ws, 200 users, 2 teams)\n");
375 await seedDb();
376
377 process.stdout.write(`seed: indexing ${TOTAL_DOCS.toLocaleString()} docs in _bulk (${BULK_BATCH}/batch)\n`);
378 const t1 = Date.now();
379 for (const [type, count] of Object.entries(DOC_COUNTS) as [keyof typeof DOC_COUNTS, number][]) {
380 for (let start = 0; start < count; start += BULK_BATCH) {
381 const end = Math.min(start + BULK_BATCH, count);
382 const batch = Array.from({ length: end - start }, (_, j) => buildDoc(type, start + j));
383 await bulkIndex(batch);
384 process.stdout.write(`\r ${type}: ${end}/${count}`);
385 }
386 process.stdout.write(`\n`);
387 }
388 const osSeconds = ((Date.now() - t1) / 1000).toFixed(1);
389
390 await os("/factory-content/_refresh", { method: "POST" });
391 const actual = await osCount({ terms: { workspaceId: WORKSPACES } });
392 const seconds = (Date.now() - t0) / 1000;
393 process.stdout.write(
394 `seed: done in ${seconds.toFixed(1)} s (OS bulk ${osSeconds} s) — ${actual.toLocaleString()} fixture docs in the index ` +
395 `(expected ${TOTAL_DOCS.toLocaleString()})\n`
396 );
397 if (actual !== TOTAL_DOCS) {
398 throw new Error(`seed count mismatch: ${actual} !== ${TOTAL_DOCS} — refuse to leave a partial fixture`);
399 }
400
401 process.stdout.write(`fixture hash: ${createHash("sha1").update(JSON.stringify(DOC_COUNTS)).digest("hex")}\n`);
402 process.stdout.write(`workspaces: ${WORKSPACES.join(", ")}\nk6 user: ${userId(0)} (member of ${wsOf(0)}, team ${teamOf(0)})\n`);
403}
404
405async function status(): Promise<void> {
406 const count = await osCount({ terms: { workspaceId: WORKSPACES } });
407 const users = await prisma.user.count({ where: { id: { startsWith: userPrefix } } });
408 const members = await prisma.workspaceMember.count({ where: { userId: { startsWith: userPrefix } } });
409 process.stdout.write(`status: ${count.toLocaleString()} fixture docs in OS · ${users} fixture users · ${members} memberships (expected 100000/200/200)\n`);
410}
411
412const action = process.argv[2];
413const main = async () => {
414 switch (action) {
415 case "seed":
416 await seed();
417 break;
418 case "reset":
419 await reset();
420 break;
421 case "status":
422 await status();
423 break;
424 default:
425 console.error("usage: search-100k.ts <seed|reset|status>");
426 process.exitCode = 2;
427 }
428};
429
430main()
431 .catch((err) => {
432 console.error(err);
433 process.exitCode = 1;
434 })
435 .finally(() => prisma.$disconnect());
436