1
28process.env.PERF_CACHE = "on";
29
30import { describe, it, expect, beforeAll, afterAll } from "vitest";
31import Redis from "ioredis";
32import { optionIdForName } from "@factory/shared";
33import { createTestApp, type TestAppHandle } from "./helpers/api";
34import { getRedisUrl } from "./helpers/containers";
35import {
36 createUser,
37 createWorkspace,
38 addMemberViaSeam,
39 authHeaders
40} from "./helpers/seed";
41
42let app: TestAppHandle;
43let redis: Redis;
44
45beforeAll(async () => {
46 app = await createTestApp();
47 redis = new Redis(await getRedisUrl(), { maxRetriesPerRequest: null });
48}, 240_000);
49
50afterAll(async () => {
51 try {
52 await redis?.quit();
53 } catch {
54
55 }
56 await app?.close();
57});
58
59
60async function until(fn: () => Promise<boolean>, ms = 5000, label = "condition"): Promise<void> {
61 const start = Date.now();
62 for (;;) {
63 if (await fn()) return;
64 if (Date.now() - start > ms) throw new Error(`timeout waiting for ${label}`);
65 await new Promise((r) => setTimeout(r, 50));
66 }
67}
68
69describe("cache invalidation (live, PERF_CACHE=on)", () => {
70 let ownerId: string;
71 let ownerToken: string;
72 let ownerHeaders: Record<string, string>;
73 let workspaceId: string;
74 let pageId: string;
75
76 beforeAll(async () => {
77 const owner = await createUser(app.request, { name: "Cache Owner" });
78 ownerId = owner.userId;
79 ownerToken = owner.accessToken!;
80 ownerHeaders = authHeaders(ownerToken);
81 const ws = await createWorkspace(app.request, ownerHeaders, `Cache WS ${Date.now()}`);
82 workspaceId = ws.workspaceId;
83 const page = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
84 headers: ownerHeaders,
85 body: { title: "Cache Test Page" }
86 });
87 expect(page.status).toBeLessThan(300);
88 pageId = ((await page.json()) as { id: string }).id;
89 });
90
91 it("tree: a repeated read is served from the cache (a live HIT key exists)", async () => {
92 const read = async () => {
93 const res = await app.request("GET", `/api/v1/pages/${pageId}/blocks?depth=all`, { headers: ownerHeaders });
94 expect(res.status).toBe(200);
95 return (await res.json()) as { data: unknown[] };
96 };
97 await read();
98 await read();
99
100
101
102 await until(
103 async () => {
104 const keys = await redis.keys(`perf:tree:${workspaceId}:${pageId}:*`);
105 return keys.length > 0;
106 },
107 5000,
108 "a perf:tree key to exist"
109 );
110 });
111
112 it("tree: a write bumps the version → the next read is fresh (no stale read)", async () => {
113
114 const created = await app.request("POST", `/api/v1/pages/${pageId}/blocks`, {
115 headers: ownerHeaders,
116 body: { type: "paragraph", content: { text: "first" }, properties: {} }
117 });
118 expect(created.status).toBeLessThan(300);
119 const blockId = ((await created.json()) as { id: string }).id;
120
121
122 const r1 = (await app.request("GET", `/api/v1/pages/${pageId}/blocks?depth=all`, { headers: ownerHeaders })).json() as Promise<{ data: unknown[] }>;
123 const before = (await r1).data.length;
124
125
126 const created2 = await app.request("POST", `/api/v1/pages/${pageId}/blocks`, {
127 headers: ownerHeaders,
128 body: { type: "paragraph", content: { text: "second" }, properties: {} }
129 });
130 expect(created2.status).toBeLessThan(300);
131
132
133 await until(async () => {
134 const res = await app.request("GET", `/api/v1/pages/${pageId}/blocks?depth=all`, { headers: ownerHeaders });
135 const body = (await res.json()) as { data: unknown[] };
136
137 return body.data.length === before + 1;
138 }, 5000, "the fresh read to include the new block");
139
140
141 await until(
142 async () => (await redis.keys(`perf:tree:${workspaceId}:${pageId}:*`)).length >= 2,
143 5000,
144 "a second version key to exist"
145 );
146 });
147
148 it("tree: concurrent writers never leave a stale read (the live race)", async () => {
149
150
151
152
153
154
155
156
157
158
159
160 const pg = app.prisma;
161 const N = 5;
162 const seen: { served: string[]; pg: string[] }[] = [];
163 const writer = (i: number) =>
164 app.request("POST", `/api/v1/pages/${pageId}/blocks`, {
165 headers: ownerHeaders,
166 body: { type: "paragraph", content: { text: `race-${i}-${Date.now()}` }, properties: {} }
167 }).then(async (r) => {
168 if (r.status >= 300) throw new Error(`writer ${i} failed: ${r.status}`);
169
170 await new Promise((r) => setTimeout(r, 80));
171 const res = await app.request("GET", `/api/v1/pages/${pageId}/blocks?depth=all`, { headers: ownerHeaders });
172 expect(res.status).toBe(200);
173 const body = (await res.json()) as { data: { id: string }[] };
174 const pgIds = new Set(
175 (await pg.block.findMany({ where: { pageId, deletedAt: null }, select: { id: true } })).map((b) => b.id)
176 );
177 seen.push({ served: body.data.map((b) => b.id), pg: [...pgIds] });
178 });
179 await Promise.all(Array.from({ length: N }, (_, i) => writer(i)));
180
181
182 for (const snap of seen) {
183 const pgSet = new Set(snap.pg);
184 const phantoms = snap.served.filter((id) => !pgSet.has(id));
185 expect(phantoms, `served a row PG no longer has: ${phantoms}`).toEqual([]);
186 }
187
188 const finalRes = await app.request("GET", `/api/v1/pages/${pageId}/blocks?depth=all`, { headers: ownerHeaders });
189 const finalBody = (await finalRes.json()) as { data: { id: string }[] };
190 const finalPg = (await pg.block.findMany({ where: { pageId, deletedAt: null }, select: { id: true } })).map((b) => b.id).sort();
191 expect(finalBody.data.map((b) => b.id).sort()).toEqual(finalPg);
192 });
193
194 it("permission: a grant change busts the decision → fresh on the NEXT check (not after the TTL)", async () => {
195
196 const member = await createUser(app.request, { name: "Cache Member" });
197 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER");
198 const memberHeaders = authHeaders(member.accessToken!);
199
200
201
202 const read = async () => {
203 const res = await app.request("GET", `/api/v1/pages/${pageId}/blocks?depth=all`, { headers: memberHeaders });
204 return res.status;
205 };
206 expect(await read()).toBe(200);
207 expect(await read()).toBe(200);
208
209
210
211
212
213
214
215 const del = await app.request(`DELETE`, `/api/v1/workspaces/${workspaceId}/members/${member.userId}`, {
216 headers: ownerHeaders
217 });
218 expect(del.status).toBeLessThan(300);
219
220
221
222 await until(
223 async () => (await read()) !== 200,
224 5000,
225 "the member's access to be denied after removal"
226 );
227 });
228
229 it("metadata: a property change busts the column list → fresh on the NEXT read", async () => {
230
231
232
233 const dbPage = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
234 headers: ownerHeaders,
235 body: { title: "Cache Meta DB", type: "database" }
236 });
237 expect(dbPage.status).toBeLessThan(300);
238 const pageObj = (await dbPage.json()) as { id: string };
239 const db = await app.prisma.database.findFirst({ where: { pageId: pageObj.id } });
240 expect(db).not.toBeNull();
241 const dbId = db!.id;
242
243 const listProps = async () => {
244 const res = await app.request("GET", `/api/v1/databases/${dbId}/properties`, { headers: ownerHeaders });
245 expect(res.status).toBe(200);
246 return ((await res.json()) as { data: { name: string }[] }).data;
247 };
248
249 const before = await listProps();
250 const beforeCount = before.length;
251 expect(beforeCount).toBeGreaterThan(0);
252 await listProps();
253
254
255 const prop = await app.request("POST", `/api/v1/databases/${dbId}/properties`, {
256 headers: ownerHeaders,
257 body: { name: "Cached Col", type: "TEXT" }
258 });
259 expect(prop.status).toBeLessThan(300);
260
261
262
263
264 await until(
265 async () => (await listProps()).length === beforeCount + 1,
266 5000,
267 "the property list to include the new column"
268 );
269 });
270
271 it("metadata: record-open and listProperties share ONE normalized entry (no id-less pollution)", async () => {
272
273
274
275
276
277
278
279
280
281
282
283
284 const dbPage = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
285 headers: ownerHeaders,
286 body: { title: "Cache Polluted DB", type: "database" }
287 });
288 expect(dbPage.status).toBeLessThan(300);
289 const dbPageObj = (await dbPage.json()) as { id: string };
290 const db = await app.prisma.database.findFirst({ where: { pageId: dbPageObj.id } });
291 expect(db).not.toBeNull();
292 const dbId = db!.id;
293 const statusProp = await app.prisma.databaseProperty.findFirst({
294 where: { databaseId: dbId, name: "Status" }
295 });
296 expect(statusProp).not.toBeNull();
297
298
299 expect(
300 ((statusProp!.config as { options?: Array<{ id?: string }> }).options ?? []).every(
301 (o) => !o.id,
302 ),
303 ).toBe(true);
304
305 interface PropDto {
306 id: string;
307 name: string;
308 config?: { options?: Array<{ id?: string; name?: string }> };
309 }
310 const listProps = async (): Promise<PropDto[]> => {
311 const res = await app.request("GET", `/api/v1/databases/${dbId}/properties`, { headers: ownerHeaders });
312 expect(res.status).toBe(200);
313 return ((await res.json()) as { data: PropDto[] }).data;
314 };
315 const statusOptions = (props: PropDto[]) => props.find((p) => p.id === statusProp.id)?.config?.options ?? [];
316
317
318 const rec = await app.request("POST", `/api/v1/databases/${dbId}/records`, {
319 headers: ownerHeaders,
320 body: { title: "Polluted Row", properties: {}, links: {} }
321 });
322 expect(rec.status).toBeLessThan(300);
323
324 const recordId = ((await rec.json()) as { id: string }).id;
325
326
327
328
329
330 const open = await app.request("GET", `/api/v1/records/${recordId}`, { headers: ownerHeaders });
331 expect(open.status).toBe(200);
332
333
334
335 await until(
336 async () => {
337 const opts = statusOptions(await listProps());
338 return (
339 opts.length === 3 &&
340 opts.map((o) => o.id).join(",") ===
341 [optionIdForName("To Do"), optionIdForName("In Progress"), optionIdForName("Done")].join(",")
342 );
343 },
344 5000,
345 "the shared metadata entry to carry normalized option ids"
346 );
347 });
348});
349