/** * TASK-0135 (Phase 15, hot path #5 — record open): the Prisma query-shape * test. * * Spec (docs/tasks/phase-15-production.md): "a lint rule + the query-shape * test asserts the record-open path issues ≤ 4 queries for a 50-block * record page, down from the profiled baseline". * * Profiled baseline (pre-fix): the record row was read TWICE (a wide * `findUnique` before the permission check + an `include` fetch after) and * the property list was read live — 7 PG queries per record open (5 with * a permission-cache hit), plus N+1 per relation link. * * After the fix the record-open path (permission decision served from the * 60 s permission cache — the production steady state — and the column * metadata from the shared 60 s metadata cache) issues EXACTLY: * 1. databaseRecord.findUnique (the row, NARROW select — the page title * + property rows in the same round-trip) * 2. databaseProperty.findMany (the column metadata, cache-miss loader) * 3. relationLink.findMany (the record's links) * 4. database.findMany (target database titles — only when links) * 5. databaseRecord.findMany (target record titles — only when links) * i.e. 3 for a record with no relation links, ≤ 5 with them — and the row * itself is fetched ONCE (the profiled double-fetch is gone), independent * of the record page's block count (50 blocks → no block queries at all; * the k6 `record-open` scenario's second hop is the cached tree load). * * The count is measured on a counting prisma mock (the shape probe); the * 50-block page is represented by the record's `pageId` (its block count * never enters the record-open query plan — that is the point). */ import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; import { ActorContext } from "../../../common/actor-context.js"; import type { AuthContext } from "../../../common/types.js"; import { CacheService } from "../../../common/cache.js"; import { setPermissionCache } from "../../../lib/authorize.js"; import { PrismaClient } from "@prisma/client"; import type { PrismaService } from "../../../common/prisma.service.js"; import { DatabaseQueryService } from "../query.service.js"; const WORKSPACE_ID = "ws_reco"; const USER_ID = "user_reco"; const DB_ID = "db_reco"; const RECORD_ID = "rec_reco"; const PAGE_ID = "pg_reco"; // the record's page (a 50-block record page) /** A prisma mock that COUNTS every statement and answers the record-open * read path (membership/teams/grants for the un-cached permission * control, the property list, the row, the relation links). */ function countingPrisma(opts: { withLinks: boolean }) { const calls: string[] = []; const targetDb = { id: "db_target", title: "Linked DB" }; const targetRecord = { id: "rec_target", page: { title: "Linked Record" } }; const prisma = { // `read()` / `write()` — the replica-routing helpers. `PrismaService.read()` // returns a (possibly separate) PrismaClient; in the counting mock both // route to this same object (dev/test has no replica — read() is the // primary). The record-open path resolves its reads via `prisma.read()`. // Typed as `PrismaClient` (not `typeof prisma` / `this`) to avoid a // circular self-reference (TS7022) and the object-literal `this` ban // (TS2526); the mock casts itself — the service only needs the // delegate surface below. read(): PrismaClient { return prisma as unknown as PrismaClient; }, write(): PrismaClient { return prisma as unknown as PrismaClient; }, // The `relation` delegate — probed by `buildRelationLinkRecords` (which // synthesizes `properties[relPropId].recordIds` from the `RelationLink` // junction, mirroring the production read path). The fixture has no // RELATION property, so the helper short-circuits BEFORE any statement — // zero extra counted queries (the bounds below are unchanged). relation: { async findMany(): Promise { calls.push("relation.findMany"); return []; } }, workspaceMember: { async findUnique(): Promise { calls.push("workspaceMember.findUnique"); return { workspaceId: WORKSPACE_ID, userId: USER_ID, role: "MEMBER" }; } }, teamMember: { async findMany(): Promise { calls.push("teamMember.findMany"); return []; } }, permission: { async findMany(): Promise { calls.push("permission.findMany"); return []; } }, databaseProperty: { async findMany(): Promise { calls.push("databaseProperty.findMany"); return [ { id: "prop_title", databaseId: DB_ID, name: "Name", type: "TITLE", position: "a0", isTitle: true, isVisible: true, required: false, config: {}, default: null, deletedAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, { id: "prop_text", databaseId: DB_ID, name: "Notes", type: "TEXT", position: "a1", isTitle: false, isVisible: true, required: false, config: {}, default: null, deletedAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } ]; } }, databaseRecord: { async findUnique(): Promise { calls.push("databaseRecord.findUnique"); // The narrow row (the 50-block page hangs off `pageId` — never // fetched here; the tree hop is a separate, cached call). return { id: RECORD_ID, workspaceId: WORKSPACE_ID, databaseId: DB_ID, pageId: PAGE_ID, icon: null, coverUrl: null, archivedAt: null, version: 3, position: "a5", createdTime: new Date(), lastEditedTime: new Date(), createdBy: USER_ID, lastEditedBy: USER_ID, createdAt: new Date(), page: { title: "A 50-block record", icon: null }, properties: [{ propertyId: "prop_text", value: { text: "body", marks: [] } }] }; }, async findMany(): Promise { calls.push("databaseRecord.findMany"); return [targetRecord]; } }, relationLink: { async findMany(): Promise { calls.push("relationLink.findMany"); return opts.withLinks ? [{ recordId: RECORD_ID, targetRecordId: targetRecord.id, targetDatabaseId: targetDb.id }] : []; } }, database: { async findMany(): Promise { calls.push("database.findMany"); return [targetDb]; } } }; return { prisma, calls }; } /** A `CacheService` with no Redis (dev/test default) — the metadata * loader runs live (PG), which is exactly what the query plan measures. */ function makeOffCache(): CacheService { return new CacheService(undefined, undefined); } function makeService(prisma: unknown, cache: CacheService) { return new DatabaseQueryService( prisma as unknown as PrismaService, { write: async (): Promise => {}, audit: async (): Promise => {} } as never, { publish: async (): Promise => {} } as never, cache, {} as never ); } const actor: AuthContext = { kind: "user", userId: USER_ID }; afterEach(() => { setPermissionCache(null); }); describe("record-open query shape (TASK-0135, hot path #5)", () => { it("issues ≤ 4 PG queries for a 50-block record page (no links, caches live)", async () => { // The production steady state: the permission decision is served from // the 60 s permission cache (a cache hit — zero PG permission queries). setPermissionCache(async () => ({ level: "EDIT", cached: true })); const { prisma, calls } = countingPrisma({ withLinks: false }); const service = makeService(prisma, makeOffCache()); const dto = await ActorContext.run(actor, () => service.getRecord(RECORD_ID, USER_ID)); expect(dto.id).toBe(RECORD_ID); expect(dto.title).toBe("A 50-block record"); expect(calls).toEqual([ "databaseRecord.findUnique", // the row — NARROW select, ONE fetch "databaseProperty.findMany", // the column metadata (the shared cache miss) "relationLink.findMany" // no links → the target lookups are skipped ]); // The spec bound: ≤ 4 for a 50-block record page. expect(calls.length).toBeLessThanOrEqual(4); // The profiled double-fetch is gone: exactly ONE record-row read. expect(calls.filter((c) => c === "databaseRecord.findUnique").length).toBe(1); }); it("stays ≤ 5 with relation links (the target lookups are batched, not per-link)", async () => { setPermissionCache(async () => ({ level: "EDIT", cached: true })); const { prisma, calls } = countingPrisma({ withLinks: true }); const service = makeService(prisma, makeOffCache()); const dto = await ActorContext.run(actor, () => service.getRecord(RECORD_ID, USER_ID)); expect((dto as { relations?: Record }).relations?.["Linked DB"]).toEqual(["Linked Record"]); expect(calls.length).toBeLessThanOrEqual(5); expect(calls.filter((c) => c === "relationLink.findMany").length).toBe(1); expect(calls.filter((c) => c === "database.findMany").length).toBe(1); expect(calls.filter((c) => c === "databaseRecord.findMany").length).toBe(1); }); it("the un-cached permission path is the control (6 queries) — the cache is what bounds it", async () => { // Control: no permission-cache binding → the live resolution (3 PG // queries: membership + teams + grants). The shape fix alone (single // narrow row read) already drops the baseline 7 → 6; the production // bound (≤ 4) additionally assumes the steady-state permission hit // (the TTL/event contract tested in cache-invalidation.integration.test.ts). const { prisma, calls } = countingPrisma({ withLinks: false }); const service = makeService(prisma, makeOffCache()); await ActorContext.run(actor, () => service.getRecord(RECORD_ID, USER_ID)); expect(calls).toHaveLength(6); expect(calls.filter((c) => c === "databaseRecord.findUnique").length).toBe(1); }); });