#!/usr/bin/env node /** * AI Software Factory — 100k-record performance fixture seeder (TASK-0135). * * Idempotent: a marker row in the `test_seeds` table records the seeded ids. * Re-runs are no-ops (they read the marker and print the same ids). Force a * re-seed with `K6_RESEED=1` (drops + rebuilds the fixture). * * Builds, directly via Prisma `createMany` (1,000-record batches — the API * record endpoint is far too slow at this scale; this is a fixture, not a * product path): * - one dedicated workspace `perf-load` (the perf user is OWNER); * - one 100,000-record database with FIVE properties (Title TITLE, * Amount NUMBER, DueDate DATE, Status SELECT, Tag TEXT) — the table- * scroll / record-open / create workloads target it; * - one 10,000-block page (the tree-load / editor path — a single page * with ten thousand top-level blocks); * - 200 pages (the search + sidebar-tree workloads; a handful nested to * form a shallow tree). * * Every record ALSO gets its own Page + a title Block (a record IS a page — * the record-open path is record → page → blocks), so the fixture matches * the production data shape the hot paths profile. * * Usage (repo root, Prisma client generated, DB reachable): * node tests/performance/seed-100k.mjs * * Env: * DATABASE_URL Postgres DSN (default postgresql://factory:factory@localhost:5432/factory?schema=public) * K6_API_URL API base (default http://localhost:4000) — used to ensure * the perf user exists + is OWNER of a workspace * K6_EMAIL perf user (default demo@factory.local) * K6_PASSWORD perf user password (default factory-demo-123) * K6_RECORDS record count (default 100000) * K6_DBS number of full databases to build (default 1; 5 for the * api-load "5 distinct 100k-adjacent DBs" profile) * K6_BLOCKS big-page block count (default 10000) * K6_PAGES search/tree hub page count (default 200) * K6_CHILDREN_PER_HUB children per hub page (default 8) * K6_RESEED "1" to drop + rebuild (default 0) */ import { PrismaClient, Prisma } from "@prisma/client"; const API_URL = process.env.K6_API_URL || "http://localhost:4000"; // The seeded demo user (prisma/seed.ts) — the password is the one the seed // writes, NOT the stale `factory-demo-123` the pre-TASK-0135 suites defaulted // to (those runs had to pass K6_PASSWORD explicitly). const EMAIL = process.env.K6_EMAIL || "demo@factory.local"; const PASSWORD = process.env.K6_PASSWORD || "Factory!2345"; // K6_DBS: number of databases with the full record set. 1 = the spec's seed // ("one 100k-record database"); 5 = the api-load matrix's "5 distinct // 100k-adjacent DBs" profile. Each extra DB multiplies the ~3-min record // insert, so the default stays 1. const DBS = parseInt(process.env.K6_DBS || "1", 10); const RECORDS = parseInt(process.env.K6_RECORDS || "100000", 10); const BIG_BLOCKS = parseInt(process.env.K6_BLOCKS || "10000", 10); const PAGES = parseInt(process.env.K6_PAGES || "200", 10); const RESEED = process.env.K6_RESEED === "1"; const MARKER = "seed-100k-v1"; const BATCH = 1000; const prisma = new PrismaClient(); // ---- deterministic id generator (cuid-shaped, sortable, unique) ----------- function makeIdGen(prefix) { let n = 0; const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; return () => { n += 1; let s = n.toString(36); // pad to a stable width so lexicographic order == numeric order while (s.length < 16) s = alphabet[0] + s; return `${prefix}_${Date.now().toString(36).slice(-4)}${s}`; }; } // ---- ensure the perf user exists (API) and return its id ------------------ async function ensureUser() { let res = await fetch(`${API_URL}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: EMAIL, password: PASSWORD }), }); if (!res.ok) { const reg = await fetch(`${API_URL}/api/v1/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: EMAIL, password: PASSWORD, name: "Perf Seeder" }), }); if (!reg.ok) throw new Error(`register failed: ${reg.status} ${await reg.text()}`); res = await fetch(`${API_URL}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: EMAIL, password: PASSWORD }), }); } if (!res.ok) throw new Error(`login failed: ${res.status} ${await res.text()}`); const body = await res.json(); const userId = body.user?.id; if (!userId) throw new Error("no user id in login response"); return userId; } async function ensureMarkerTable() { await prisma.$executeRaw`CREATE TABLE IF NOT EXISTS test_seeds ( key TEXT PRIMARY KEY, value JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() )`; } async function readMarker() { const rows = await prisma.$queryRaw`SELECT value FROM test_seeds WHERE key = ${MARKER}`; if (rows.length > 0) return rows[0].value; return null; } async function main() { const t0 = Date.now(); const WS_SLUG = "perf-load"; await ensureMarkerTable(); const existing = await readMarker(); if (RESEED) { // K6_RESEED=1: drop the previous fixture (whole workspace cascades to // pages/blocks/database/records/properties/members) so a rebuild is clean, // then clear the marker. Delete by slug (authoritative) AND by marker id // (defence against a stale/missing marker). const stale = await prisma.workspace.findFirst({ where: { slug: WS_SLUG } }); const target = stale?.id || existing?.workspaceId; if (target) { console.log(`K6_RESEED=1 — dropping fixture workspace ${target} (cascade)`); await prisma.workspace .delete({ where: { id: target } }) .catch((e) => console.warn(" (workspace delete skipped:", e.message?.slice(0, 80), ")")); } await prisma.$executeRaw`DELETE FROM test_seeds WHERE key = ${MARKER}`; } else if (existing) { console.log(`Already seeded (marker ${MARKER}). No-op.\n${JSON.stringify(existing, null, 2)}`); return existing; } const userId = await ensureUser(); console.log(`perf user: ${userId}`); // ---- dedicated workspace (idempotent by slug) --------------------------- let workspace = await prisma.workspace.findFirst({ where: { slug: WS_SLUG } }); if (!workspace) { workspace = await prisma.workspace.create({ data: { name: "Perf Load", slug: WS_SLUG, isPersonal: false, ownerId: userId }, }); await prisma.workspaceMember.create({ data: { workspaceId: workspace.id, userId, role: "OWNER" }, }); } const wsId = workspace.id; console.log(`workspace: ${wsId}`); // ---- the 100k-record database(s) ---------------------------------------- const genDb = makeIdGen("db"); const genPage = makeIdGen("pg"); const genBlock = makeIdGen("blk"); const genProp = makeIdGen("prop"); const genRec = makeIdGen("rpt"); const genRecProp = makeIdGen("rp"); const dbRecordsPer = Math.ceil(RECORDS / DBS); const dbs = []; for (let d = 0; d < DBS; d++) { // Each database is a Page + a Database row + a DATABASE block + the five // properties (Title/Amount/DueDate/Status/Tag) — mirroring the API's // create path, written directly (a fixture, not a product path). const dbPageId = genPage(); const dbId = genDb(); const props = { title: genProp(), amount: genProp(), date: genProp(), status: genProp(), tag: genProp(), }; await prisma.$transaction([ prisma.page.create({ data: { id: dbPageId, workspaceId: wsId, title: `Perf 100k DB ${d}`, position: `B${d}`, createdBy: userId }, }), prisma.database.create({ data: { id: dbId, workspaceId: wsId, pageId: dbPageId, title: `Perf 100k DB ${d}` }, }), prisma.block.create({ data: { id: genBlock(), pageId: dbPageId, workspaceId: wsId, type: "DATABASE", position: "A", content: { databaseId: dbId } }, }), prisma.databaseProperty.create({ data: { id: props.title, databaseId: dbId, name: "Title", type: "TITLE", isTitle: true, position: "A" }, }), prisma.databaseProperty.create({ data: { id: props.amount, databaseId: dbId, name: "Amount", type: "NUMBER", position: "AA" }, }), prisma.databaseProperty.create({ data: { id: props.date, databaseId: dbId, name: "DueDate", type: "DATE", position: "AB" }, }), prisma.databaseProperty.create({ data: { id: props.status, databaseId: dbId, name: "Status", type: "SELECT", position: "AC", config: { options: [{ name: "todo" }, { name: "doing" }, { name: "done" }] }, }, }), prisma.databaseProperty.create({ data: { id: props.tag, databaseId: dbId, name: "Tag", type: "TEXT", position: "AD" }, }), ]); dbs.push({ id: dbId, props }); console.log(`database ${d}: ${dbId} (5 props)`); } // ---- the 10,000-block page (tree-load / editor) -------------------------- const bigPageId = genPage(); await prisma.page.create({ data: { id: bigPageId, workspaceId: wsId, title: "Perf Big Block Page", position: "B", createdBy: userId }, }); { const blocks = []; for (let i = 0; i < BIG_BLOCKS; i++) { blocks.push({ id: genBlock(), pageId: bigPageId, workspaceId: wsId, type: i % 5 === 0 ? "HEADING_2" : "PARAGRAPH", position: String(i).padStart(6, "0"), content: { text: `perf big block ${i}` }, }); } for (let o = 0; o < blocks.length; o += BATCH) { await prisma.block.createMany({ data: blocks.slice(o, o + BATCH) }); } } console.log(`big block page: ${bigPageId} (${BIG_BLOCKS} blocks)`); // ---- 200 search/tree pages (each a hub with a small subtree) ------------ // The PAGES "hub" pages are the 200 pages the tree-load + search workloads // target (K6_PAGE_IDS = the hub ids). Each hub gets CHILDREN_PER_HUB // children so a sidebar tree-load is non-trivial: GET /workspaces/:id/pages // ?parentId= returns the children (the real tree-load endpoint — the // web's /page-tree route is not in the API). const CHILDREN_PER_HUB = parseInt(process.env.K6_CHILDREN_PER_HUB || "8", 10); const pageIds = []; { const allPages = []; for (let h = 0; h < PAGES; h++) { const hubId = genPage(); allPages.push({ id: hubId, title: `Perf Page ${h}`, parentId: null, position: String(h).padStart(4, "0") }); for (let c = 0; c < CHILDREN_PER_HUB; c++) { const id = genPage(); allPages.push({ id, title: `Perf Page ${h} child ${c}`, parentId: hubId, position: String(c).padStart(4, "0"), }); } } pageIds.push(...allPages.filter((p) => p.parentId === null).map((p) => p.id)); for (let o = 0; o < allPages.length; o += BATCH) { await prisma.page.createMany({ data: allPages.slice(o, o + BATCH).map((p) => ({ id: p.id, workspaceId: wsId, title: p.title, parentId: p.parentId, position: p.position, createdBy: userId, })), }); } } console.log(`search/tree pages: ${PAGES} hubs x (1 + ${CHILDREN_PER_HUB} children) = ${PAGES * (1 + CHILDREN_PER_HUB)}`); // ---- the records (each with a page + title block + 5 props), spread across // ---- the DB(s) ----------------------------------------------------------- const STATUSES = ["todo", "doing", "done"]; const dateBase = Date.UTC(2025, 0, 1); let inserted = 0; const tRec = Date.now(); for (let offset = 0; offset < RECORDS; offset += BATCH) { const n = Math.min(BATCH, RECORDS - offset); // Which DB does this batch's records land in (round-robin by index). const recPages = []; const recBlocks = []; const recs = []; const recProps = []; for (let i = 0; i < n; i++) { const globalIdx = offset + i; const db = dbs[globalIdx % DBS]; const pageId = genPage(); const recId = genRec(); recPages.push({ id: pageId, workspaceId: wsId, title: `Perf Rec ${globalIdx}`, parentId: null, position: String(globalIdx).padStart(7, "0"), createdBy: userId, }); recBlocks.push({ id: genBlock(), pageId, workspaceId: wsId, type: "PARAGRAPH", position: "A", content: { text: `Perf Rec ${globalIdx}` }, }); recs.push({ id: recId, workspaceId: wsId, databaseId: db.id, pageId, position: String(globalIdx).padStart(7, "0"), createdBy: userId, }); // 5 property values (title is a RecordProperty — the API derives the // record's `title` from the TITLE property). recProps.push( { id: genRecProp(), recordId: recId, propertyId: db.props.title, value: `Perf Rec ${globalIdx}` }, { id: genRecProp(), recordId: recId, propertyId: db.props.amount, value: globalIdx % 1000 }, { id: genRecProp(), recordId: recId, propertyId: db.props.date, value: new Date(dateBase + globalIdx * 86_400_000).toISOString().slice(0, 10) }, { id: genRecProp(), recordId: recId, propertyId: db.props.status, value: STATUSES[globalIdx % 3] }, { id: genRecProp(), recordId: recId, propertyId: db.props.tag, value: `tag-${globalIdx % 25}` } ); } // Order matters for FKs: pages → blocks → records → record props. await prisma.page.createMany({ data: recPages }); await prisma.block.createMany({ data: recBlocks }); await prisma.databaseRecord.createMany({ data: recs }); await prisma.recordProperty.createMany({ data: recProps }); inserted += n; if (inserted % 20_000 === 0 || offset + n >= RECORDS) { const rate = inserted / ((Date.now() - tRec) / 1000); console.log(` records ${inserted}/${RECORDS} (${Math.round(rate)}/s, elapsed ${((Date.now() - tRec) / 1000).toFixed(0)}s)`); } } const finalCount = await prisma.databaseRecord.count({ where: { workspaceId: wsId } }); console.log(`Done: ${finalCount} records across ${DBS} DB(s) in ${((Date.now() - t0) / 1000).toFixed(1)}s total.`); const marker = { version: 1, seededAt: new Date().toISOString(), workspaceId: wsId, // The primary (first) database — the default target of database-load. databaseId: dbs[0].id, // All databases (the api-load record-list scenario cycles these). databaseIds: dbs.map((d) => d.id), // Property ids per database (for suites that need to build filters/props). dbProps: dbs.map((d) => ({ id: d.id, props: d.props })), bigPageId, pageIds, records: finalCount, }; await prisma.$executeRaw`INSERT INTO test_seeds (key, value) VALUES (${MARKER}, ${JSON.stringify(marker)}::jsonb) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, created_at = now()`; console.log(`\nMarker written. Run the suites with:\n K6_DB_ID=${dbs[0].id} K6_WORKSPACE_ID=${wsId} K6_BIG_PAGE_ID=${bigPageId} K6_PAGE_IDS=${pageIds.slice(0, 20).join(",")}…`); return marker; } main() .catch((err) => { console.error(err); process.exitCode = 1; }) .finally(async () => { await prisma.$disconnect(); });