1
21
22import { PrismaClient, type Prisma, type BlockType as PrismaBlockType } from "@prisma/client";
23import {
24 resolvePermission,
25 roleFromPrisma,
26 type Grant,
27 type GranteeType,
28 type PermissionLevel,
29 type ResourceType,
30} from "@factory/permissions";
31import type { BlockLike } from "@factory/collaboration/codec";
32import { blockTypeSchema, type BlockType as CodecBlockType } from "@factory/shared";
33import type { DocStore, HydrateResult, PersistResult } from "./rooms.js";
34import type { PermissionResolver, RoomMetaResolver } from "./server.js";
35
36type Level = PermissionLevel;
37
38
51function dbToCodecType(raw: string): CodecBlockType {
52 const parsed = blockTypeSchema.safeParse(raw.toLowerCase());
53 if (!parsed.success) {
54 throw new Error(`PrismaDocStore: block type "${raw}" is not a known codec block type`);
55 }
56 return parsed.data;
57}
58
59function codecToDbType(t: CodecBlockType): PrismaBlockType {
60
61
62
63 return t.toUpperCase() as PrismaBlockType;
64}
65
66
67export class PrismaDocStore implements DocStore {
68 constructor(private readonly prisma: PrismaClient) {}
69
70 async hydrate(pageId: string): Promise<HydrateResult> {
71 const [page, rows] = await Promise.all([
72 this.prisma.page.findUnique({ where: { id: pageId }, select: { version: true } }),
73 this.prisma.block.findMany({
74 where: { pageId, deletedAt: null },
75 select: { id: true, parentId: true, type: true, position: true, content: true, properties: true },
76 orderBy: { position: "asc" },
77 }),
78 ]);
79 return {
80 blocks: rows.length === 0 ? null : this.toBlockLikes(rows),
81
82
83
84 version: page?.version ?? 1,
85 };
86 }
87
88
98 async persist(pageId: string, blocks: readonly BlockLike[], baseVersion: number): Promise<PersistResult> {
99 const page = await this.prisma.page.findUnique({
100 where: { id: pageId },
101 select: { workspaceId: true, version: true },
102 });
103 if (page === null) {
104 throw new Error(`PrismaDocStore.persist: page "${pageId}" not found`);
105 }
106 if (page.version !== baseVersion) {
107
108 return { version: page.version, conflict: { externalBlocks: await this.readExternalBlocks(pageId) } };
109 }
110 try {
111 await this.prisma.$transaction(async (tx) => {
112
113
114 const cas = await tx.page.updateMany({
115 where: { id: pageId, version: baseVersion },
116 data: { version: baseVersion + 1, updatedAt: new Date() },
117 });
118 if (cas.count !== 1) {
119
120 throw new PersistConflictError();
121 }
122 const existing = await tx.block.findMany({
123 where: { pageId },
124 select: { id: true, deletedAt: true },
125 });
126 const liveIds = new Set(blocks.map((b) => b.id));
127 const removed = existing.filter((b) => b.deletedAt === null && !liveIds.has(b.id)).map((b) => b.id);
128 if (removed.length > 0) {
129 await tx.block.updateMany({
130 where: { id: { in: removed }, deletedAt: null },
131 data: { deletedAt: new Date() },
132 });
133 }
134 for (const b of blocks) {
135 await tx.block.upsert({
136 where: { id: b.id },
137 create: {
138 id: b.id,
139 pageId,
140 workspaceId: page.workspaceId,
141 parentId: b.parentId,
142 type: codecToDbType(b.type),
143 position: b.position,
144 content: b.content as Prisma.InputJsonValue,
145 properties: (b.properties ?? {}) as Prisma.InputJsonValue,
146 },
147 update: {
148 parentId: b.parentId,
149 type: codecToDbType(b.type),
150 position: b.position,
151 content: b.content as Prisma.InputJsonValue,
152 properties: (b.properties ?? {}) as Prisma.InputJsonValue,
153 deletedAt: null,
154 },
155 });
156 }
157 });
158 } catch (err) {
159 if (err instanceof PersistConflictError) {
160 return { version: await this.currentVersion(pageId), conflict: { externalBlocks: await this.readExternalBlocks(pageId) } };
161 }
162 throw err;
163 }
164 return { version: baseVersion + 1 };
165 }
166
167 async ping(): Promise<boolean> {
168 try {
169 await this.prisma.$queryRaw`SELECT 1`;
170 return true;
171 } catch {
172 return false;
173 }
174 }
175
176
177 private async currentVersion(pageId: string): Promise<number> {
178 const page = await this.prisma.page.findUnique({ where: { id: pageId }, select: { version: true } });
179 return page?.version ?? 1;
180 }
181
182
183 private async readExternalBlocks(pageId: string): Promise<BlockLike[]> {
184 const rows = await this.prisma.block.findMany({
185 where: { pageId, deletedAt: null },
186 select: { id: true, parentId: true, type: true, position: true, content: true, properties: true },
187 orderBy: { position: "asc" },
188 });
189 return this.toBlockLikes(rows);
190 }
191
192 private toBlockLikes(
193 rows: ReadonlyArray<{
194 id: string;
195 parentId: string | null;
196 type: PrismaBlockType | string;
197 position: string;
198 content: Prisma.JsonValue | null;
199 properties: Prisma.JsonValue | null;
200 }>,
201 ): BlockLike[] {
202 return rows.map((b) => ({
203 id: b.id,
204
205
206
207 type: dbToCodecType(b.type),
208 content: (b.content ?? { text: "" }) as BlockLike["content"],
209 parentId: b.parentId,
210 position: b.position,
211 ...(b.properties !== null && b.properties !== undefined && Object.keys(b.properties).length > 0
212 ? { properties: b.properties as Record<string, unknown> }
213 : {}),
214 }));
215 }
216}
217
218
219class PersistConflictError extends Error {
220 constructor() {
221 super("pages.version CAS conflict (concurrent external writer)");
222 }
223}
224
225
226export function createPrismaMetaResolver(prisma: PrismaClient): RoomMetaResolver {
227 return {
228 async resolve({ kind, id, userId }) {
229 if (kind === "user") {
230
231
232 const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true, displayName: true } });
233 return user === null
234 ? null
235 : { workspaceId: "__own__", userName: user.displayName ?? user.name };
236 }
237 if (kind === "page" || kind === "comment") {
238 const page = await prisma.page.findUnique({
239 where: { id },
240 select: { workspaceId: true, deletedAt: true },
241 });
242 if (page === null || page.deletedAt !== null) return null;
243 const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true, displayName: true } });
244 return {
245 workspaceId: page.workspaceId,
246 userName: user === null ? undefined : (user.displayName ?? user.name),
247 };
248 }
249
250 const db = await prisma.database.findUnique({
251 where: { id },
252 select: { workspaceId: true, isArchived: true },
253 });
254 if (db === null || db.isArchived) return null;
255 const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true, displayName: true } });
256 return {
257 workspaceId: db.workspaceId,
258 userName: user === null ? undefined : (user.displayName ?? user.name),
259 };
260 },
261 };
262}
263
264
271export function createPrismaPermissionResolver(prisma: PrismaClient): PermissionResolver {
272 const prismaLike = prisma;
273 return {
274 async resolveLevel({ userId, workspaceId, resourceType, resourceId }) {
275 const membership = await prismaLike.workspaceMember.findUnique({
276 where: { workspaceId_userId: { workspaceId, userId } },
277 select: { role: true },
278 });
279
280
281
282 if (membership === null) return "none";
283
284
285
286
287 const teams = await prismaLike.teamMember.findMany({
288 where: { userId },
289 select: { teamId: true },
290 });
291 const grants = await prismaLike.permission.findMany({
292 where: {
293 workspaceId,
294 OR: [
295 { granteeType: "user", granteeId: userId },
296 { granteeType: "team", granteeId: { in: teams.map((t) => t.teamId) } },
297 ],
298 },
299 });
300
301
302
303
304
305
306 const applicable: Grant[] = grants
307 .filter((g) => {
308 if (g.resourceType === "workspace") return g.resourceId === workspaceId;
309 if (g.resourceType === resourceType) return g.resourceId === resourceId;
310 return false;
311 })
312 .map((g): Grant | null => {
313 const level = grantLevel(g.level);
314 if (level === "none") return null;
315 return {
316 granteeType: g.granteeType as GranteeType,
317 granteeId: g.granteeId,
318 resourceType: g.resourceType as ResourceType,
319 resourceId: g.resourceId,
320 level,
321 };
322 })
323 .filter((g): g is Grant => g !== null);
324
325
326
327
328 const result = resolvePermission({
329 userId,
330 userTeams: teams.map((t) => t.teamId),
331 workspaceRole: roleFromPrisma(membership.role),
332 workspaceId,
333 resourceType: (resourceType === "page" ? "page" : "database") as ResourceType,
334 resourceId,
335 grants: applicable,
336 shares: [],
337 });
338 return result.level;
339 },
340 };
341}
342
343function grantLevel(raw: string): Level {
344 switch (raw) {
345 case "NONE":
346 return "none";
347 case "VIEW":
348 return "view";
349 case "COMMENT":
350 return "comment";
351 case "EDIT":
352 return "edit";
353 case "FULL_ACCESS":
354 return "full_access";
355 default:
356 return "none";
357 }
358}
359
360