1import { Inject, Injectable } from "@nestjs/common";
2import { Prisma, type BlockType as PrismaBlockType } from "@prisma/client";
3
4
5type PrismaJsonValue = Prisma.InputJsonValue;
6import { PrismaService } from "../../common/prisma.service.js";
7import { AuditService } from "../../common/audit.service.js";
8import { EventBus } from "../../common/event-bus.js";
9import { ApiError } from "../../common/errors.js";
10import { EVENTS } from "../../constants.js";
11import {
12 type InternalBlock,
13 type InternalFlushInput,
14 type InternalReadQueryInput
15} from "./internal.dto.js";
16
17
36
37
38function codecToDbType(t: string): PrismaBlockType {
39 return t.toUpperCase() as PrismaBlockType;
40}
41
42
43function dbToCodecType(t: PrismaBlockType | string): string {
44 return t.toLowerCase();
45}
46
47
49function jsonEqual(a: unknown, b: unknown): boolean {
50 return canonicalJson(a) === canonicalJson(b);
51}
52
53function canonicalJson(value: unknown): string {
54 if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
55 if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v)).join(",")}]`;
56 const entries = Object.entries(value as Record<string, unknown>)
57 .filter(([, v]) => v !== undefined)
58 .sort(([ka], [kb]) => (ka < kb ? -1 : ka > kb ? 1 : 0));
59 return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
60}
61
62interface BlockRow {
63 id: string;
64 parentId: string | null;
65 type: PrismaBlockType | string;
66 position: string;
67 content: PrismaJsonValue | null;
68 properties: PrismaJsonValue | null;
69}
70
71
72interface WireBlock {
73 id: string;
74 parentId: string | null;
75 type: string;
76 position: string;
77 content: Record<string, unknown>;
78 properties?: Record<string, unknown>;
79}
80
81function toWireBlock(row: BlockRow): WireBlock {
82 const properties = row.properties as Record<string, unknown> | null;
83 return {
84 id: row.id,
85 parentId: row.parentId,
86 type: dbToCodecType(row.type),
87 position: row.position,
88 content: (row.content ?? {}) as Record<string, unknown>,
89 ...(properties !== null && properties !== undefined && Object.keys(properties).length > 0
90 ? { properties }
91 : {})
92 };
93}
94
95@Injectable()
96export class InternalBlocksService {
97
98
99
100
101
102
103
104
105 constructor(
106 @Inject(PrismaService) private readonly prisma: PrismaService,
107 @Inject(AuditService) private readonly audit: AuditService,
108 @Inject(EventBus) private readonly events: EventBus
109 ) {}
110
111
116 async internalRead(pageId: string, query: InternalReadQueryInput): Promise<{
117 pageId: string;
118 version: number;
119 blocks: WireBlock[];
120 nextCursor: string | null;
121 hasMore: boolean;
122 }> {
123 const pageSize = query.pageSize ?? 500;
124 const page = await this.prisma.page.findUnique({
125 where: { id: pageId },
126 select: { workspaceId: true, version: true, deletedAt: true }
127 });
128 if (page === null || page.deletedAt !== null) {
129 throw ApiError.notFound(`Page ${pageId} not found.`);
130 }
131 const where: Prisma.BlockWhereInput = {
132 pageId,
133 deletedAt: null,
134 ...(query.cursor ? { position: { gt: query.cursor } } : {})
135 };
136 const rows = await this.prisma.block.findMany({
137 where,
138 select: { id: true, parentId: true, type: true, position: true, content: true, properties: true },
139 orderBy: { position: "asc" },
140 take: pageSize + 1
141 });
142 const hasMore = rows.length > pageSize;
143 const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
144 return {
145 pageId,
146 version: page.version,
147 blocks: pageRows.map(toWireBlock),
148 nextCursor: hasMore ? pageRows[pageRows.length - 1].position : null,
149 hasMore
150 };
151 }
152
153
168 async internalFlush(input: InternalFlushInput): Promise<
169 | { version: number }
170 | { version: number; conflict: { externalBlocks: WireBlock[] } }
171 > {
172 const { pageId, baseVersion, blocks } = input;
173 const page = await this.prisma.page.findUnique({
174 where: { id: pageId },
175 select: { workspaceId: true, version: true, deletedAt: true }
176 });
177 if (page === null || page.deletedAt !== null) {
178 throw ApiError.notFound(`Page ${pageId} not found.`);
179 }
180 if (page.version !== baseVersion) {
181
182
183 return {
184 version: page.version,
185 conflict: { externalBlocks: await this.readLiveBlocks(pageId) }
186 };
187 }
188
189
190 const previous = await this.readLiveBlocks(pageId);
191
192 try {
193 const newVersion = await this.prisma.$transaction(async (tx) => {
194
195
196 const cas = await tx.page.updateMany({
197 where: { id: pageId, version: baseVersion },
198 data: { version: baseVersion + 1, updatedAt: new Date() }
199 });
200 if (cas.count !== 1) throw new FlushCasConflict();
201
202 const existing = await tx.block.findMany({
203 where: { pageId },
204 select: { id: true, deletedAt: true }
205 });
206 const liveIds = new Set(blocks.map((b) => b.id));
207 const removed = existing
208 .filter((b) => b.deletedAt === null && !liveIds.has(b.id))
209 .map((b) => b.id);
210 if (removed.length > 0) {
211 await tx.block.updateMany({
212 where: { id: { in: removed }, deletedAt: null },
213 data: { deletedAt: new Date() }
214 });
215 }
216
217
218
219
220
221
222
223
224
225
226
227 const now = new Date();
228 const upsertRows = blocks.map(
229 (b) =>
230 Prisma.sql`(
231 ${b.id}, ${pageId}, ${page.workspaceId}, ${b.parentId ?? null},
232 ${codecToDbType(b.type)}::"BlockType", ${b.position},
233 ${JSON.stringify(b.content)}::jsonb, ${JSON.stringify(b.properties ?? {})}::jsonb, ${now}
234 )`
235 );
236 if (upsertRows.length > 0) {
237 await tx.$executeRaw`
238 INSERT INTO "blocks"
239 ("id", "pageId", "workspaceId", "parentId", "type", "position", "content", "properties", "updatedAt")
240 VALUES ${Prisma.join(upsertRows, ", ")}
241 ON CONFLICT ("id") DO UPDATE SET
242 "parentId" = EXCLUDED."parentId",
243 "type" = EXCLUDED."type",
244 "position" = EXCLUDED."position",
245 "content" = EXCLUDED."content",
246 "properties" = EXCLUDED."properties",
247 "deletedAt" = NULL,
248 "updatedAt" = ${now}
249 `;
250 }
251 return baseVersion + 1;
252 });
253
254 const blockDelta = this.diffCounts(previous, blocks);
255
256
257
258 await this.audit.write({
259 action: "block.flush",
260 resourceType: "block",
261 resourceId: pageId,
262 workspaceId: page.workspaceId,
263 pageId,
264 metadata: { actor: "realtime-flush", version: newVersion, blocks: blocks.length, ...blockDelta }
265 });
266
267
268
269 await this.events.publish(EVENTS.PAGE_UPDATED, {
270 workspaceId: page.workspaceId,
271 pageId,
272 version: newVersion,
273 blockDelta
274 });
275 return { version: newVersion };
276 } catch (err) {
277 if (err instanceof FlushCasConflict) {
278
279
280 const version = await this.prisma.page.findUnique({
281 where: { id: pageId },
282 select: { version: true }
283 });
284 return {
285 version: version?.version ?? baseVersion + 1,
286 conflict: { externalBlocks: await this.readLiveBlocks(pageId) }
287 };
288 }
289 throw err;
290 }
291 }
292
293
294 private async readLiveBlocks(pageId: string): Promise<WireBlock[]> {
295 const rows = await this.prisma.block.findMany({
296 where: { pageId, deletedAt: null },
297 select: { id: true, parentId: true, type: true, position: true, content: true, properties: true },
298 orderBy: { position: "asc" }
299 });
300 return rows.map(toWireBlock);
301 }
302
303
305 private diffCounts(
306 previous: WireBlock[],
307 next: readonly InternalBlock[]
308 ): { inserted: number; updated: number; moved: number; deleted: number } {
309 const prevById = new Map(previous.map((b) => [b.id, b] as const));
310 const nextById = new Map(next.map((b) => [b.id, b] as const));
311 let inserted = 0;
312 let updated = 0;
313 let moved = 0;
314 let deleted = 0;
315 for (const nb of next) {
316 const pb = prevById.get(nb.id);
317 if (pb === undefined) {
318 inserted += 1;
319 continue;
320 }
321 const contentChanged = !jsonEqual(pb.content, nb.content);
322 const prevProps = pb.properties ?? {};
323 const nextProps = nb.properties ?? {};
324 const propertiesChanged = !jsonEqual(prevProps, nextProps);
325 if (contentChanged || propertiesChanged) updated += 1;
326 if (pb.position !== nb.position || pb.parentId !== (nb.parentId ?? null)) moved += 1;
327 }
328 for (const pb of previous) {
329 if (!nextById.has(pb.id)) deleted += 1;
330 }
331 return { inserted, updated, moved, deleted };
332 }
333}
334
335
336class FlushCasConflict extends Error {
337 constructor() {
338 super("pages.version CAS conflict (concurrent external writer)");
339 }
340}
341