1
18import http from "k6/http";
19import { check } from "k6";
20import { Trend } from "k6/metrics";
21
22const tableMs = new Trend("table_page_ms", true);
23const groupMs = new Trend("board_group_ms", true);
24const filteredMs = new Trend("filtered_query_ms", true);
25const createMs = new Trend("record_create_ms", true);
26
27const API_URL = __ENV.K6_API_URL || "http://localhost:4000";
28const EMAIL = __ENV.K6_EMAIL || "demo@factory.local";
29const PASSWORD = __ENV.K6_PASSWORD || "factory-demo-123";
30const DB_ID = __ENV.K6_DB_ID || "";
31const READ_DURATION = parseInt(__ENV.K6_DURATION || "120", 10);
32
33export const options = {
34 scenarios: {
35 reads: {
36 executor: "ramping-arrival-rate",
37 startRate: 20,
38 timeUnit: "1s",
39 preAllocatedVUs: 20,
40 maxVUs: 400,
41 exec: "readWorkload",
42 tags: { workload: "read" },
43 stages: [
44 { target: 100, duration: "30s" },
45 { target: 200, duration: `${READ_DURATION}s` },
46 ],
47 },
48 writes: {
49 executor: "constant-arrival-rate",
50 rate: 100,
51 timeUnit: "1s",
52 preAllocatedVUs: 20,
53 maxVUs: 200,
54 exec: "writeWorkload",
55 tags: { workload: "write" },
56 duration: "60s",
57 },
58 },
59 thresholds: {
60 table_page_ms: ["p(95)<500"],
61 board_group_ms: ["p(95)<500"],
62 filtered_query_ms: ["p(95)<500"],
63 record_create_ms: ["p(95)<300"],
64 http_req_failed: ["rate<0.001"],
65 },
66};
67
68export function setup() {
69 if (!DB_ID) {
70 throw new Error(
71 "K6_DB_ID is required — run `node helpers/seed-100k.mjs` first " +
72 "(it prints the database id to seed).",
73 );
74 }
75 let login = http.post(`${API_URL}/api/v1/auth/login`, JSON.stringify({ email: EMAIL, password: PASSWORD }), {
76 headers: { "Content-Type": "application/json" },
77 });
78 if (login.status !== 200) {
79 const reg = http.post(`${API_URL}/api/v1/auth/register`, JSON.stringify({ email: EMAIL, password: PASSWORD, name: "K6 DB" }), {
80 headers: { "Content-Type": "application/json" },
81 });
82 if (reg.status === 201 || reg.status === 200) {
83 login = http.post(`${API_URL}/api/v1/auth/login`, JSON.stringify({ email: EMAIL, password: PASSWORD }), {
84 headers: { "Content-Type": "application/json" },
85 });
86 }
87 }
88 if (login.status !== 200) throw new Error(`setup: login failed (${login.status})`);
89 const body = login.json();
90 const token = body.accessToken || body.access_token || body.token;
91 if (!token) throw new Error("setup: no access token in login response");
92
93
94
95 const probe = http.get(`${API_URL}/api/v1/databases/${DB_ID}/records?limit=1`, {
96 headers: { Authorization: `Bearer ${token}` },
97 });
98 if (probe.status !== 200) {
99 throw new Error(`setup: database ${DB_ID} not readable (${probe.status})`);
100 }
101 const probeBody = probe.json();
102 const total = (probeBody && (probeBody.total ?? probeBody.count)) || 0;
103 if (total < 100_000) {
104 throw new Error(`setup: expected >= 100000 records, found ${total} — re-run helpers/seed-100k.mjs`);
105 }
106
107
108
109 const db = http.get(`${API_URL}/api/v1/databases/${DB_ID}`, {
110 headers: { Authorization: `Bearer ${token}` },
111 });
112 if (db.status !== 200) throw new Error(`setup: cannot read database (${db.status})`);
113 const dbBody = db.json();
114 const props = (dbBody.database && dbBody.database.properties) || dbBody.properties || [];
115 const statusProp = props.find((p) => p.name === "Status");
116 if (!statusProp) throw new Error("setup: seeded database lacks a 'Status' property");
117 return { token, dbId: DB_ID, statusPropId: statusProp.id, total };
118}
119
120const H = (data) => ({ "Content-Type": "application/json", Authorization: `Bearer ${data.token}` });
121
122export function readWorkload(data) {
123 const roll = Math.random();
124 if (roll < 0.4) {
125
126 const started = Date.now();
127 const res = http.get(`${API_URL}/api/v1/databases/${data.dbId}/records?limit=50&offset=0`, {
128 headers: H(data),
129 });
130 tableMs.add(Date.now() - started);
131 check(res, { "table: 200": (r) => r.status === 200 });
132 } else if (roll < 0.7) {
133
134 const started = Date.now();
135 const res = http.get(
136 `${API_URL}/api/v1/databases/${data.dbId}/records?limit=50&group=${encodeURIComponent(
137 JSON.stringify([{ propertyId: data.statusPropId, direction: "asc" }]),
138 )}`,
139 { headers: H(data) },
140 );
141 groupMs.add(Date.now() - started);
142 check(res, { "group: 200": (r) => r.status === 200 });
143 } else {
144
145 const started = Date.now();
146 const filter = JSON.stringify({
147 op: "and",
148 children: [{ propertyId: data.statusPropId, op: "=", value: "doing" }],
149 });
150 const res = http.get(
151 `${API_URL}/api/v1/databases/${data.dbId}/records?limit=50&filter=${encodeURIComponent(filter)}`,
152 { headers: H(data) },
153 );
154 filteredMs.add(Date.now() - started);
155 check(res, { "filtered: 200": (r) => r.status === 200 });
156 }
157}
158
159export function writeWorkload(data) {
160 const started = Date.now();
161 const res = http.post(
162 `${API_URL}/api/v1/databases/${data.dbId}/records`,
163 JSON.stringify({
164 properties: {
165 Title: `k6 write ${Date.now()}-${Math.random().toString(36).slice(2)}`,
166 Status: "todo",
167 },
168 }),
169 { headers: H(data) },
170 );
171 createMs.add(Date.now() - started);
172 check(res, { "create: 2xx": (r) => r.status === 200 || r.status === 201 });
173}
174