/** * Production seams for the realtime server (TASK-0091 notes 14/17): * * - {@link PrismaDocStore} — the `DocStore` persistence seam over * `@prisma/client` (blocks table → the codec's `BlockLike`). The REST * `BlockModule` remains for non-collaborative writes; both serialize * on `pages.version` (doc §4.3 — the flush wins, the REST path sees * fresh state on retry with CONFLICT). * - {@link createPrismaMetaResolver} — `resourceId → workspaceId` + * the joining user's display name (note 14). * - {@link createPrismaPermissionResolver} — the effective-level * assembly (the API's `lib/authorize.ts` algorithm, run through the * SHARED `@factory/permissions` engine — the architecture doc's * "same engine as the API guard" requirement; the conflict between * the doc and the API's local `authorize.ts` copy is recorded in the * task file). * * `@prisma/client` is imported here (not in `server.ts`) so the server * stays importable without a generated client (unit tests / dev). */ import { PrismaClient, type Prisma, type BlockType as PrismaBlockType } from "@prisma/client"; import { resolvePermission, roleFromPrisma, type Grant, type GranteeType, type PermissionLevel, type ResourceType, } from "@factory/permissions"; import type { BlockLike } from "@factory/collaboration/codec"; import { blockTypeSchema, type BlockType as CodecBlockType } from "@factory/shared"; import type { DocStore, HydrateResult, PersistResult } from "./rooms.js"; import type { PermissionResolver, RoomMetaResolver } from "./server.js"; type Level = PermissionLevel; /** * Block-type translation at the persistence seam. * * The Prisma `BlockType` enum is stored SCREAMING_SNAKE (`PARAGRAPH`) * while the codec/DTO `BlockType` (PRODUCT-SPEC §4, `block.dto.ts`) is * lowercase snake_case (`paragraph`) — a pre-existing cross-cutting * inconsistency (the API's `block.service.ts` writes the DTO value * straight to Prisma; recorded in the task file). The realtime store * translates here at the one boundary where the codec doc meets the DB, * keeping the Yjs codec untouched. The two value sets are 1:1 by case, * so the mapping is a case fold; values outside the shared schema fail * closed (the hydrating page is refused, not half-mapped). */ function dbToCodecType(raw: string): CodecBlockType { const parsed = blockTypeSchema.safeParse(raw.toLowerCase()); if (!parsed.success) { throw new Error(`PrismaDocStore: block type "${raw}" is not a known codec block type`); } return parsed.data; } function codecToDbType(t: CodecBlockType): PrismaBlockType { // The DB enum members are exactly the uppercase form of the codec // values (1:1, verified against `prisma/schema.prisma` BlockType); // `t` is a codec value by type, so the fold is always a valid member. return t.toUpperCase() as PrismaBlockType; } /** The `DocStore` seam backed by Postgres (blocks + pages tables). */ export class PrismaDocStore implements DocStore { constructor(private readonly prisma: PrismaClient) {} async hydrate(pageId: string): Promise { const [page, rows] = await Promise.all([ this.prisma.page.findUnique({ where: { id: pageId }, select: { version: true } }), this.prisma.block.findMany({ where: { pageId, deletedAt: null }, select: { id: true, parentId: true, type: true, position: true, content: true, properties: true }, orderBy: { position: "asc" }, }), ]); return { blocks: rows.length === 0 ? null : this.toBlockLikes(rows), // A missing page hydrates as empty at the schema default version // (persist still throws — the page id is validated by the handshake // room meta, this is a safety fallback, not a creation path). version: page?.version ?? 1, }; } /** * Persist under the `pages.version` CAS (TASK-0092 R2, doc §4.3): * `UPDATE pages SET version = version + 1 WHERE id = ? AND version = * baseVersion`. Zero rows → a concurrent non-Yjs writer (REST batch) * bumped the version; the persist is a no-op and the result carries the * store's current blocks (the external state) so the room can rebase. * The version bump is the FIRST statement of the transaction: if the CAS * races a writer between our check and the update, the transaction * aborts (nothing is written) and we report the conflict. */ async persist(pageId: string, blocks: readonly BlockLike[], baseVersion: number): Promise { const page = await this.prisma.page.findUnique({ where: { id: pageId }, select: { workspaceId: true, version: true }, }); if (page === null) { throw new Error(`PrismaDocStore.persist: page "${pageId}" not found`); } if (page.version !== baseVersion) { // Fast path: the conflict is already visible before the transaction. return { version: page.version, conflict: { externalBlocks: await this.readExternalBlocks(pageId) } }; } try { await this.prisma.$transaction(async (tx) => { // The atomic CAS guard (the authoritative check — the pre-check // above is an optimization, not the guard). const cas = await tx.page.updateMany({ where: { id: pageId, version: baseVersion }, data: { version: baseVersion + 1, updatedAt: new Date() }, }); if (cas.count !== 1) { // A concurrent writer won the race → abort the transaction. throw new PersistConflictError(); } const existing = await tx.block.findMany({ where: { pageId }, select: { id: true, deletedAt: true }, }); const liveIds = new Set(blocks.map((b) => b.id)); const removed = existing.filter((b) => b.deletedAt === null && !liveIds.has(b.id)).map((b) => b.id); if (removed.length > 0) { await tx.block.updateMany({ where: { id: { in: removed }, deletedAt: null }, data: { deletedAt: new Date() }, }); } for (const b of blocks) { await tx.block.upsert({ where: { id: b.id }, create: { id: b.id, pageId, workspaceId: page.workspaceId, parentId: b.parentId, type: codecToDbType(b.type), position: b.position, content: b.content as Prisma.InputJsonValue, properties: (b.properties ?? {}) as Prisma.InputJsonValue, }, update: { parentId: b.parentId, type: codecToDbType(b.type), position: b.position, content: b.content as Prisma.InputJsonValue, properties: (b.properties ?? {}) as Prisma.InputJsonValue, deletedAt: null, }, }); } }); } catch (err) { if (err instanceof PersistConflictError) { return { version: await this.currentVersion(pageId), conflict: { externalBlocks: await this.readExternalBlocks(pageId) } }; } throw err; } return { version: baseVersion + 1 }; } async ping(): Promise { try { await this.prisma.$queryRaw`SELECT 1`; return true; } catch { return false; } } /** The page's current version (conflict result — the room's new CAS base). */ private async currentVersion(pageId: string): Promise { const page = await this.prisma.page.findUnique({ where: { id: pageId }, select: { version: true } }); return page?.version ?? 1; } /** The store's current live blocks (the external state for a rebase). */ private async readExternalBlocks(pageId: string): Promise { const rows = await this.prisma.block.findMany({ where: { pageId, deletedAt: null }, select: { id: true, parentId: true, type: true, position: true, content: true, properties: true }, orderBy: { position: "asc" }, }); return this.toBlockLikes(rows); } private toBlockLikes( rows: ReadonlyArray<{ id: string; parentId: string | null; type: PrismaBlockType | string; position: string; content: Prisma.JsonValue | null; properties: Prisma.JsonValue | null; }>, ): BlockLike[] { return rows.map((b) => ({ id: b.id, // The DB `BlockType` enum is SCREAMING_SNAKE; the codec uses the // PRODUCT-SPEC §4 lowercase snake_case — translated at this seam // (see `dbToCodecType`). type: dbToCodecType(b.type), content: (b.content ?? { text: "" }) as BlockLike["content"], parentId: b.parentId, position: b.position, ...(b.properties !== null && b.properties !== undefined && Object.keys(b.properties).length > 0 ? { properties: b.properties as Record } : {}), })); } } /** Internal: the persist transaction's CAS guard failed (a concurrent writer). */ class PersistConflictError extends Error { constructor() { super("pages.version CAS conflict (concurrent external writer)"); } } /** `resourceId → workspaceId` + the joining user's display name. */ export function createPrismaMetaResolver(prisma: PrismaClient): RoomMetaResolver { return { async resolve({ kind, id, userId }) { if (kind === "user") { // The control room needs no resource lookup (own-id is checked // in the handshake); the name still rides the roster. const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true, displayName: true } }); return user === null ? null : { workspaceId: "__own__", userName: user.displayName ?? user.name }; } if (kind === "page" || kind === "comment") { const page = await prisma.page.findUnique({ where: { id }, select: { workspaceId: true, deletedAt: true }, }); if (page === null || page.deletedAt !== null) return null; const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true, displayName: true } }); return { workspaceId: page.workspaceId, userName: user === null ? undefined : (user.displayName ?? user.name), }; } // database const db = await prisma.database.findUnique({ where: { id }, select: { workspaceId: true, isArchived: true }, }); if (db === null || db.isArchived) return null; const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true, displayName: true } }); return { workspaceId: db.workspaceId, userName: user === null ? undefined : (user.displayName ?? user.name), }; }, }; } /** * The effective-level resolver — the API's `authorize.ts` algorithm * (role baseline + the most-specific applicable grant, maxed) run * THROUGH the shared `@factory/permissions` engine (the architecture * doc's "same engine" rule; the conflict with the API's local copy is * recorded in the task file). */ export function createPrismaPermissionResolver(prisma: PrismaClient): PermissionResolver { const prismaLike = prisma; return { async resolveLevel({ userId, workspaceId, resourceType, resourceId }) { const membership = await prismaLike.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } }, select: { role: true }, }); // No membership at all → none (a shared/public page is Phase-14; // the realtime handshake requires a token whose workspaces[] // already passed, so a member is implied). if (membership === null) return "none"; // Teams FIRST (the grant query references the team ids — fetching // both in one `Promise.all` would use `teams` before its // declaration resolves). Then the applicable grants. const teams = await prismaLike.teamMember.findMany({ where: { userId }, select: { teamId: true }, }); const grants = await prismaLike.permission.findMany({ where: { workspaceId, OR: [ { granteeType: "user", granteeId: userId }, { granteeType: "team", granteeId: { in: teams.map((t) => t.teamId) } }, ], }, }); // The grants that apply to this resource (workspace-scoped // grants cover everything; page/database-scoped grants must // match the target; teamspace/record/file do not apply to a // room join — the API remains the source of truth for // record-scoped writes). const applicable: Grant[] = grants .filter((g) => { if (g.resourceType === "workspace") return g.resourceId === workspaceId; if (g.resourceType === resourceType) return g.resourceId === resourceId; return false; }) .map((g): Grant | null => { const level = grantLevel(g.level); if (level === "none") return null; return { granteeType: g.granteeType as GranteeType, granteeId: g.granteeId, resourceType: g.resourceType as ResourceType, resourceId: g.resourceId, level, }; }) .filter((g): g is Grant => g !== null); // The shared engine resolves role baseline + these grants + // (no shares at room scope — shares are a Phase-14 sharing // concern; the doc's join matrix is role + explicit grants). const result = resolvePermission({ userId, userTeams: teams.map((t) => t.teamId), workspaceRole: roleFromPrisma(membership.role), workspaceId, resourceType: (resourceType === "page" ? "page" : "database") as ResourceType, resourceId, grants: applicable, shares: [], }); return result.level; }, }; } function grantLevel(raw: string): Level { switch (raw) { case "NONE": return "none"; case "VIEW": return "view"; case "COMMENT": return "comment"; case "EDIT": return "edit"; case "FULL_ACCESS": return "full_access"; default: return "none"; } }