1import { Inject, Injectable } from "@nestjs/common";
2import { Prisma } from "@prisma/client";
3import { z } from "zod";
4import { PrismaService } from "../../common/prisma.service.js";
5import { AuditService } from "../../common/audit.service.js";
6import { EventBus } from "../../common/event-bus.js";
7import { NotificationsService } from "../notifications/notifications.service.js";
8import { CacheService } from "../../common/cache.js";
9import { ApiError } from "../../common/errors.js";
10import { assertCan } from "../../lib/authorize.js";
11import { between, compare, rebalancePositions } from "../../lib/positions.js";
12import { EVENTS, MAX_BLOCK_TREE } from "../../constants.js";
13import type {
14 BatchBlocksInput,
15 CreateBlockInput,
16 MoveBlockInput,
17 UpdateBlockInput
18} from "./dto.js";
19
20const BLOCK_SELECT = {
21 id: true, pageId: true, workspaceId: true, parentId: true, type: true,
22 position: true, content: true, properties: true, isSynced: true,
23 syncedSourceId: true, version: true, createdAt: true, updatedAt: true,
24 deletedAt: true
25} as const;
26type BlockRow = Prisma.BlockGetPayload<{ select: typeof BLOCK_SELECT }>;
27
28
36const BLOCK_ROW_CACHE = z.object({
37 id: z.string(),
38 pageId: z.string(),
39 workspaceId: z.string(),
40 parentId: z.string().nullable(),
41 type: z.string(),
42 position: z.string(),
43 content: z.unknown(),
44 properties: z.unknown(),
45 isSynced: z.boolean(),
46 syncedSourceId: z.string().nullable(),
47 version: z.number(),
48 createdAt: z.string(),
49 updatedAt: z.string(),
50 deletedAt: z.string().nullable()
51});
52const BLOCK_ROWS_CACHE = z.array(BLOCK_ROW_CACHE);
53
54
59@Injectable()
60export class BlockService {
61 constructor(
62 @Inject(PrismaService) private readonly prisma: PrismaService,
63 @Inject(AuditService) private readonly audit: AuditService,
64 @Inject(EventBus) private readonly events: EventBus,
65 @Inject(CacheService) private readonly cache: CacheService,
66 @Inject(NotificationsService) private readonly notifications: NotificationsService
67 ) {}
68
69 private async getBlock(blockId: string) {
70 const block = await this.prisma.block.findUnique({ where: { id: blockId }, select: BLOCK_SELECT });
71 if (!block || block.deletedAt) throw ApiError.notFound("Block not found.");
72 return block;
73 }
74
75 private async requirePageAccess(pageId: string, userId: string, need: "VIEW" | "EDIT") {
76 const page = await this.prisma.page.findUnique({
77 where: { id: pageId },
78 select: { id: true, workspaceId: true, deletedAt: true, version: true }
79 });
80 if (!page || page.deletedAt) throw ApiError.notFound("Page not found.");
81 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, need);
82 return page;
83 }
84
85
86 private async siblings(tx: Prisma.TransactionClient, pageId: string, parentId: string | null): Promise<BlockRow[]> {
87 return tx.block.findMany({
88 where: { pageId, parentId, deletedAt: null },
89 select: BLOCK_SELECT,
90 orderBy: { position: "asc" }
91 });
92 }
93
94
95
96 async list(pageId: string, userId: string, query: { parentId?: string | null; depth?: string | number }) {
97 const page = await this.requirePageAccess(pageId, userId, "VIEW");
98 const depth = typeof query.depth === "string" ? query.depth : query.depth ? String(query.depth) : "1";
99
100 if (depth === "all") {
101
102
103
104
105 const { data: rowsData } = await this.cache.getTree(page.workspaceId, pageId, async () => {
106
107
108
109
110 const r = await this.prisma.read().block.findMany({
111 where: { pageId, deletedAt: null },
112 select: BLOCK_SELECT,
113 orderBy: { position: "asc" }
114 });
115 return r as unknown[];
116 }, BLOCK_ROWS_CACHE);
117 const flat = rowsData as unknown as BlockRow[];
118 if (flat.length > MAX_BLOCK_TREE) {
119 throw new ApiError("PAYLOAD_TOO_LARGE", 413, `Block tree exceeds ${MAX_BLOCK_TREE} blocks.`, [
120 { maxBlocks: MAX_BLOCK_TREE, received: flat.length }
121 ]);
122 }
123 return { data: flat, tree: this.buildTree(flat), total: flat.length };
124 }
125
126 const parentId = query.parentId ?? null;
127 const rows = await this.prisma.block.findMany({
128 where: { pageId, parentId, deletedAt: null },
129 select: BLOCK_SELECT,
130 orderBy: { position: "asc" },
131 take: 1000
132 });
133 return { data: rows };
134 }
135
136 private buildTree(rows: BlockRow[]) {
137 const byId = new Map<string, BlockRow & { children: unknown[] }>();
138 for (const r of rows) byId.set(r.id, { ...r, children: [] });
139 const roots: (BlockRow & { children: unknown[] })[] = [];
140 for (const r of rows) {
141 const node = byId.get(r.id)!;
142 if (r.parentId && byId.has(r.parentId)) byId.get(r.parentId)!.children.push(node);
143 else roots.push(node);
144 }
145 return roots;
146 }
147
148
149
150 private async resolvePosition(
151 tx: Prisma.TransactionClient,
152 pageId: string,
153 opts: { parentId?: string | null; afterBlockId?: string; beforeBlockId?: string; position?: string }
154 ): Promise<string> {
155 const parentId = opts.parentId ?? null;
156 if (opts.position) return opts.position;
157
158 let before: string | undefined;
159 let after: string | undefined;
160 if (opts.beforeBlockId) {
161 const b = await tx.block.findUnique({ where: { id: opts.beforeBlockId }, select: { position: true, parentId: true, pageId: true, deletedAt: true } });
162 if (!b || b.deletedAt || b.pageId !== pageId) throw ApiError.notFound("beforeBlockId not found.");
163 before = b.position;
164 }
165 if (opts.afterBlockId) {
166 const a = await tx.block.findUnique({ where: { id: opts.afterBlockId }, select: { position: true, parentId: true, pageId: true, deletedAt: true } });
167 if (!a || a.deletedAt || a.pageId !== pageId) throw ApiError.notFound("afterBlockId not found.");
168 after = a.position;
169 }
170
171
172
173
174
175
176 if (before && after) return between(after, before);
177 if (after) {
178
179
180
181
182
183
184
185 const next = await tx.block.findFirst({
186 where: { pageId, parentId, deletedAt: null, position: { gt: after } },
187 orderBy: { position: "asc" },
188 select: { position: true }
189 });
190 return between(after, next?.position);
191 }
192 if (before) {
193
194
195
196 const prev = await tx.block.findFirst({
197 where: { pageId, parentId, deletedAt: null, position: { lt: before } },
198 orderBy: { position: "desc" },
199 select: { position: true }
200 });
201 return between(prev?.position, before);
202 }
203 const last = await tx.block.findFirst({
204 where: { pageId, parentId, deletedAt: null },
205 orderBy: { position: "desc" },
206 select: { position: true }
207 });
208 return between(last?.position);
209 }
210
211
218 private async rebalanceSiblingList(tx: Prisma.TransactionClient, pageId: string, parentId: string | null): Promise<Map<string, string>> {
219 const siblings = await this.siblings(tx, pageId, parentId);
220 const fresh = rebalancePositions(siblings.map((s) => s.position));
221 const byId = new Map<string, string>();
222 for (let i = 0; i < siblings.length; i++) {
223 const s = siblings[i]!;
224 const pos = fresh[i]!;
225 if (s.position !== pos) {
226 await tx.block.update({ where: { id: s.id }, data: { position: pos } });
227 }
228 byId.set(s.id, pos);
229 }
230 return byId;
231 }
232
233
238 private async resolvePositionSafe(tx: Prisma.TransactionClient, pageId: string, opts: { parentId?: string | null; afterBlockId?: string; beforeBlockId?: string; position?: string }): Promise<string> {
239 try {
240 return await this.resolvePosition(tx, pageId, opts);
241 } catch (err) {
242 if (!(err instanceof RangeError)) throw err;
243 await this.rebalanceSiblingList(tx, pageId, opts.parentId ?? null);
244 return this.resolvePosition(tx, pageId, opts);
245 }
246 }
247
248
249 private async fillGap(tx: Prisma.TransactionClient, pageId: string, parentId: string | null, removedPosition: string): Promise<void> {
250 const siblings = await this.siblings(tx, pageId, parentId);
251 const prev = [...siblings].reverse().find((s) => compare(s.position, removedPosition) < 0);
252 const next = siblings.find((s) => compare(s.position, removedPosition) > 0);
253 if (prev && next) {
254 await tx.block.update({ where: { id: prev.id }, data: { position: between(prev.position, next.position) } });
255 } else if (prev) {
256 await tx.block.update({ where: { id: prev.id }, data: { position: between(prev.position) } });
257 }
258 }
259
260
261
262
269 private async seedColumnChildren(tx: Prisma.TransactionClient, block: { id: string; pageId: string; workspaceId: string }, userId: string): Promise<void> {
270 const workspaceId = block.workspaceId;
271 for (const colPos of ["A", between("A")]) {
272 const column = await tx.block.create({
273 data: {
274 pageId: block.pageId,
275 workspaceId,
276 parentId: block.id,
277
278
279
280 type: "COLUMN",
281 position: colPos,
282 content: {} as Prisma.InputJsonValue,
283 properties: {} as object,
284 createdBy: userId
285 }
286 });
287 await tx.block.create({
288 data: {
289 pageId: block.pageId,
290 workspaceId,
291 parentId: column.id,
292 type: "PARAGRAPH",
293 position: "A",
294 content: {} as Prisma.InputJsonValue,
295 properties: {} as object,
296 createdBy: userId
297 }
298 });
299 }
300 }
301
302 async create(pageId: string, userId: string, input: CreateBlockInput, req: any) {
303 const page = await this.requirePageAccess(pageId, userId, "EDIT");
304 const block = await this.prisma.$transaction(async (tx) => {
305 const parentId = input.parentId ?? null;
306 if (parentId) {
307 const parent = await tx.block.findUnique({ where: { id: parentId }, select: { pageId: true, deletedAt: true } });
308 if (!parent || parent.deletedAt || parent.pageId !== pageId) throw ApiError.notFound("parentId not found.");
309 }
310 const position = await this.resolvePositionSafe(tx, pageId, input);
311 const created = await tx.block.create({
312 data: {
313 pageId,
314 workspaceId: page.workspaceId,
315 parentId,
316 type: input.type,
317 position,
318 content: input.content as Prisma.InputJsonValue,
319 properties: input.properties as object,
320 createdBy: userId
321 },
322 select: BLOCK_SELECT
323 });
324 if (created.type === "COLUMN_LIST") {
325
326
327 await this.seedColumnChildren(tx, created, userId);
328 }
329 return created;
330 });
331 await this.audit.audit(req, req?.auth, {
332 action: "block.created",
333 resourceType: "block",
334 resourceId: block.id,
335 workspaceId: page.workspaceId,
336 pageId,
337 metadata: { type: block.type, parentId: block.parentId }
338 });
339 await this.events.publish(EVENTS.PAGE_UPDATED, {
340 workspaceId: page.workspaceId,
341 pageId,
342 blockIds: [block.id],
343 changed: "created"
344 });
345 return block;
346 }
347
348 async update(blockId: string, userId: string, input: UpdateBlockInput, req: any) {
349 const block = await this.getBlock(blockId);
350 await this.requirePageAccess(block.pageId, userId, "EDIT");
351 const data: Prisma.BlockUpdateInput = { version: { increment: 1 }, updatedBy: userId };
352 if (input.content !== undefined) data.content = input.content as object;
353 if (input.type !== undefined) {
354 data.type = input.type;
355
356 data.properties = (input.properties ?? {}) as object;
357 } else if (input.properties !== undefined) {
358 data.properties = input.properties as object;
359 }
360 if (input.archived !== undefined) {
361
362 data.deletedAt = input.archived ? new Date() : null;
363 }
364
365
366
367
368
369 let updated: BlockRow | null;
370 if (input.baseVersion !== undefined) {
371 const res = await this.prisma.block.updateMany({
372 where: { id: blockId, version: input.baseVersion, deletedAt: null },
373 data,
374 });
375 if (res.count === 0) {
376 const current = await this.prisma.block.findUnique({
377 where: { id: blockId },
378 select: { version: true, deletedAt: true }
379 });
380 if (!current || current.deletedAt) throw ApiError.notFound("Block not found.");
381 throw ApiError.conflict("Block version conflict (stale version).", [
382 { reason: "stale_version", blockId, expected: input.baseVersion, actualVersion: current.version }
383 ]);
384 }
385 updated = await this.prisma.block.findUnique({ where: { id: blockId }, select: BLOCK_SELECT });
386 } else {
387 updated = await this.prisma.block.update({ where: { id: blockId }, data, select: BLOCK_SELECT });
388 }
389 if (!updated) throw ApiError.notFound("Block not found.");
390 await this.audit.audit(req, req?.auth, {
391 action: "block.updated",
392 resourceType: "block",
393 resourceId: blockId,
394 workspaceId: block.workspaceId,
395 pageId: block.pageId,
396 metadata: { fields: Object.keys(input) }
397 });
398 await this.events.publish(EVENTS.PAGE_UPDATED, {
399 workspaceId: block.workspaceId,
400 pageId: block.pageId,
401 blockIds: [blockId],
402 changed: "updated"
403 });
404 return updated;
405 }
406
407
408
409 async move(blockId: string, userId: string, input: MoveBlockInput, req: any) {
410 const block = await this.getBlock(blockId);
411 await this.requirePageAccess(block.pageId, userId, "EDIT");
412 const result = await this.prisma.$transaction(async (tx) => {
413 const newParentId = input.parentId ?? block.parentId;
414 if (newParentId) {
415 const parent = await tx.block.findUnique({ where: { id: newParentId }, select: { pageId: true, deletedAt: true } });
416 if (!parent || parent.deletedAt || parent.pageId !== block.pageId) throw ApiError.notFound("Target parent not found.");
417 if (this.isDescendant(await tx.block.findMany({ where: { pageId: block.pageId, deletedAt: null }, select: { id: true, parentId: true } }), blockId, newParentId)) {
418 throw ApiError.validation("Cannot move a block into its own subtree.", [{ issue: "circular" }]);
419 }
420 }
421
422 await tx.block.update({ where: { id: blockId }, data: { parentId: newParentId } });
423 await this.fillGap(tx, block.pageId, block.parentId, block.position);
424 const position = await this.resolvePositionSafe(tx, block.pageId, {
425 parentId: newParentId,
426 afterBlockId: input.afterBlockId,
427 beforeBlockId: input.beforeBlockId,
428 position: input.position
429 });
430 return tx.block.update({ where: { id: blockId }, data: { position }, select: BLOCK_SELECT });
431 });
432 await this.audit.audit(req, req?.auth, {
433 action: "block.moved",
434 resourceType: "block",
435 resourceId: blockId,
436 workspaceId: block.workspaceId,
437 pageId: block.pageId
438 });
439 await this.events.publish(EVENTS.PAGE_UPDATED, {
440 workspaceId: block.workspaceId,
441 pageId: block.pageId,
442 blockIds: [blockId],
443 moved: true
444 });
445 return result;
446 }
447
448 private isDescendant(allBlocks: { id: string; parentId: string | null }[], ancestorId: string, candidateId: string): boolean {
449 let cursor: string | null = candidateId;
450 const seen = new Set<string>();
451 while (cursor) {
452 if (cursor === ancestorId) return true;
453 if (seen.has(cursor)) return false;
454 seen.add(cursor);
455 cursor = allBlocks.find((b) => b.id === cursor)?.parentId ?? null;
456 }
457 return false;
458 }
459
460 async delete(blockId: string, userId: string, req: any) {
461 const block = await this.getBlock(blockId);
462 await this.requirePageAccess(block.pageId, userId, "EDIT");
463 const subtreeIds = await this.subtreeIds(block.pageId, [blockId]);
464 await this.prisma.$transaction(async (tx) => {
465 await tx.block.deleteMany({ where: { pageId: block.pageId, id: { in: subtreeIds } } });
466 await this.fillGap(tx, block.pageId, block.parentId, block.position);
467 });
468 await this.audit.audit(req, req?.auth, {
469 action: "block.deleted",
470 resourceType: "block",
471 resourceId: blockId,
472 workspaceId: block.workspaceId,
473 pageId: block.pageId,
474 metadata: { subtreeSize: subtreeIds.length }
475 });
476 await this.events.publish(EVENTS.PAGE_UPDATED, {
477 workspaceId: block.workspaceId,
478 pageId: block.pageId,
479 blockIds: [blockId],
480 changed: "deleted"
481 });
482 return { deleted: true, blockIds: subtreeIds };
483 }
484
485 private async subtreeIds(pageId: string, roots: string[]): Promise<string[]> {
486 const rows = await this.prisma.block.findMany({
487 where: { pageId, deletedAt: null },
488 select: { id: true, parentId: true }
489 });
490 const childrenOf = new Map<string | null, string[]>();
491 for (const r of rows) {
492 const list = childrenOf.get(r.parentId) ?? [];
493 list.push(r.id);
494 childrenOf.set(r.parentId, list);
495 }
496 const ids: string[] = [];
497 const queue = [...roots];
498 while (queue.length > 0) {
499 const id = queue.shift()!;
500 ids.push(id);
501 queue.push(...(childrenOf.get(id) ?? []));
502 }
503 return ids;
504 }
505
506
507 async duplicate(blockId: string, userId: string, req: any, input?: { parentId?: string | null; afterBlockId?: string }) {
508 const source = await this.getBlock(blockId);
509 await this.requirePageAccess(source.pageId, userId, "EDIT");
510 const rows = await this.prisma.block.findMany({
511 where: { pageId: source.pageId, deletedAt: null },
512 select: { id: true, parentId: true, type: true, content: true, properties: true, position: true }
513 });
514 const subtree = await this.subtreeIds(source.pageId, [blockId]);
515 const set = new Set(subtree);
516 const nodes = rows.filter((r) => set.has(r.id));
517 const idMap = new Map<string, string>();
518 for (const n of nodes) idMap.set(n.id, `blk_${Math.random().toString(36).slice(2, 14)}`);
519
520
521
522 const targetParentId = input?.parentId === undefined ? source.parentId : input.parentId;
523 const afterId = input?.afterBlockId ?? blockId;
524 await this.prisma.$transaction(async (tx) => {
525 const position = await this.resolvePositionSafe(tx, source.pageId, {
526 parentId: targetParentId,
527 afterBlockId: afterId
528 });
529 for (const n of nodes) {
530 await tx.block.create({
531 data: {
532 id: idMap.get(n.id)!,
533 pageId: source.pageId,
534 workspaceId: source.workspaceId,
535
536
537 parentId: n.id === blockId ? targetParentId : (n.parentId ? (idMap.get(n.parentId) ?? n.parentId) : null),
538 type: n.type,
539
540 position: n.id === blockId ? position : n.position,
541 content: n.content as Prisma.InputJsonValue,
542 properties: n.properties as Prisma.InputJsonValue,
543 createdBy: userId
544 }
545 });
546 }
547 });
548 await this.audit.audit(req, req?.auth, {
549 action: "block.duplicated",
550 resourceType: "block",
551 resourceId: blockId,
552 workspaceId: source.workspaceId,
553 pageId: source.pageId,
554 metadata: { subtreeSize: subtree.length }
555 });
556 await this.events.publish(EVENTS.PAGE_UPDATED, {
557 workspaceId: source.workspaceId,
558 pageId: source.pageId,
559 blockIds: [...subtree],
560 changed: "duplicated"
561 });
562 return { duplicated: true, newRootId: idMap.get(blockId), blockIds: subtree };
563 }
564
565
566
567 async batch(pageId: string, userId: string, input: BatchBlocksInput, req: any) {
568 const page = await this.requirePageAccess(pageId, userId, "EDIT");
569
570
571
572
573
574 if (input.baseVersion !== undefined && page.version !== input.baseVersion) {
575 throw ApiError.conflict(
576 "The page changed since you last read it (a concurrent edit — including a realtime flush). Re-read and retry.",
577 [
578 { issue: "stale_base_version", received: input.baseVersion },
579 { currentVersion: page.version },
580 { reason: "flush_wins" }
581 ]
582 );
583 }
584 const touched = new Set<string>();
585 let newVersion: number;
586 try {
587 newVersion = await this.prisma.$transaction(async (tx) => {
588
589
590
591
592
593
594
595
596
597 const base = input.baseVersion ?? page.version;
598 const cas = await tx.page.updateMany({
599 where: { id: pageId, version: base },
600 data: { version: base + 1, updatedAt: new Date() }
601 });
602 if (cas.count !== 1) throw new BatchVersionCasConflict();
603 for (const op of input.ops) {
604 if (op.op === "update") {
605
606
607
608
609
610
611
612
613 const block = await tx.block.findUnique({ where: { id: op.blockId }, select: { id: true, pageId: true, parentId: true, position: true, type: true, deletedAt: true } });
614 if (!block || block.deletedAt || block.pageId !== pageId) throw ApiError.notFound(`block ${op.blockId} not found.`);
615 const patch = op.patch;
616 const data: Prisma.BlockUpdateInput = { version: { increment: 1 }, updatedBy: userId };
617 if (patch.content !== undefined) data.content = patch.content as unknown as object;
618 if (patch.type !== undefined && patch.type !== block.type) {
619 data.type = patch.type;
620 data.properties = (patch.properties ?? {}) as object;
621 } else if (patch.properties !== undefined) {
622 data.properties = patch.properties as object;
623 }
624 if (patch.parentId !== undefined && patch.parentId !== block.parentId) {
625 const newParentId = patch.parentId;
626 if (newParentId) {
627 const parent = await tx.block.findUnique({ where: { id: newParentId }, select: { pageId: true, deletedAt: true } });
628 if (!parent || parent.deletedAt || parent.pageId !== pageId) throw ApiError.notFound(`update parent ${newParentId} not found.`);
629 if (this.isDescendant(await tx.block.findMany({ where: { pageId, deletedAt: null }, select: { id: true, parentId: true } }), op.blockId, newParentId)) {
630 throw ApiError.validation("Cannot move a block into its own subtree.", [{ issue: "circular" }]);
631 }
632 }
633 data.parentId = newParentId;
634 }
635 await tx.block.update({ where: { id: op.blockId }, data });
636 touched.add(op.blockId);
637 } else if (op.op === "upsert") {
638 const b = op.block;
639 const parentId = b.parentId ?? null;
640 if (parentId) {
641 const parent = await tx.block.findUnique({ where: { id: parentId }, select: { pageId: true, deletedAt: true } });
642 if (!parent || parent.deletedAt || parent.pageId !== pageId) throw ApiError.notFound(`parentId ${parentId} not found.`);
643 }
644 const position = await this.resolvePositionSafe(tx, pageId, b);
645 const created = await tx.block.create({
646 data: {
647
648
649
650 ...(b.id ? { id: b.id } : {}),
651 pageId,
652 workspaceId: page.workspaceId,
653 parentId,
654 type: b.type,
655 position,
656 content: b.content as Prisma.InputJsonValue,
657 properties: b.properties as object,
658 createdBy: userId
659 }
660 });
661 touched.add(created.id);
662 } else if (op.op === "delete") {
663 const block = await tx.block.findUnique({ where: { id: op.blockId }, select: { id: true, pageId: true, parentId: true, position: true, deletedAt: true } });
664 if (!block || block.deletedAt || block.pageId !== pageId) throw ApiError.notFound(`block ${op.blockId} not found.`);
665 const ids = await this.subtreeTx(tx, pageId, [op.blockId]);
666 await tx.block.deleteMany({ where: { pageId, id: { in: ids } } });
667 await this.fillGap(tx, pageId, block.parentId, block.position);
668 touched.add(op.blockId);
669 } else if (op.op === "move") {
670 const block = await tx.block.findUnique({ where: { id: op.blockId }, select: { id: true, pageId: true, parentId: true, position: true, deletedAt: true } });
671 if (!block || block.deletedAt || block.pageId !== pageId) throw ApiError.notFound(`block ${op.blockId} not found.`);
672 const mv = op.move;
673 const newParentId = mv.parentId ?? block.parentId;
674 if (newParentId) {
675 const parent = await tx.block.findUnique({ where: { id: newParentId }, select: { pageId: true, deletedAt: true } });
676 if (!parent || parent.deletedAt || parent.pageId !== pageId) throw ApiError.notFound(`move parent ${newParentId} not found.`);
677 }
678 await tx.block.update({ where: { id: op.blockId }, data: { parentId: newParentId } });
679 await this.fillGap(tx, pageId, block.parentId, block.position);
680 const position = await this.resolvePositionSafe(tx, pageId, mv);
681 await tx.block.update({ where: { id: op.blockId }, data: { position } });
682 touched.add(op.blockId);
683 }
684 }
685 return base + 1;
686 });
687 } catch (err) {
688 if (err instanceof BatchVersionCasConflict) {
689
690
691
692 const fresh = await this.prisma.page.findUnique({ where: { id: pageId }, select: { version: true } });
693 const current = fresh?.version ?? page.version + 1;
694 throw ApiError.conflict(
695 "The page changed since you last read it (a concurrent edit — including a realtime flush). Re-read and retry.",
696 [
697 { issue: "stale_base_version", received: input.baseVersion ?? page.version },
698 { currentVersion: current },
699 { reason: "flush_wins" }
700 ]
701 );
702 }
703 throw err;
704 }
705 await this.audit.audit(req, req?.auth, {
706 action: "block.batch",
707 resourceType: "block",
708 resourceId: pageId,
709 workspaceId: page.workspaceId,
710 pageId,
711 metadata: { ops: input.ops.length, version: newVersion }
712 });
713 await this.events.publish(EVENTS.PAGE_UPDATED, {
714 workspaceId: page.workspaceId,
715 pageId,
716 version: newVersion,
717 blockIds: [...touched]
718 });
719
720
721 try { await this.notifications.notifyWatchersForPageUpdate(pageId, userId); } catch { }
722 return { applied: input.ops.length, blockIds: [...touched], version: newVersion };
723 }
724
725 private async subtreeTx(tx: Prisma.TransactionClient, pageId: string, roots: string[]): Promise<string[]> {
726 const rows = await tx.block.findMany({
727 where: { pageId, deletedAt: null },
728 select: { id: true, parentId: true }
729 });
730 const childrenOf = new Map<string | null, string[]>();
731 for (const r of rows) {
732 const list = childrenOf.get(r.parentId) ?? [];
733 list.push(r.id);
734 childrenOf.set(r.parentId, list);
735 }
736 const ids: string[] = [];
737 const queue = [...roots];
738 while (queue.length > 0) {
739 const id = queue.shift()!;
740 ids.push(id);
741 queue.push(...(childrenOf.get(id) ?? []));
742 }
743 return ids;
744 }
745
746
747
748
749 async content(pageId: string, userId: string): Promise<{ pageId: string; text: string; blocks: number }> {
750 await this.requirePageAccess(pageId, userId, "VIEW");
751 const rows = await this.prisma.block.findMany({
752 where: { pageId, deletedAt: null },
753 select: { type: true, content: true, position: true },
754 orderBy: { position: "asc" },
755 take: MAX_BLOCK_TREE
756 });
757 const lines: string[] = [];
758 for (const r of rows) {
759 const c = r.content as { text?: string; title?: string };
760 const text = c.text ?? c.title ?? "";
761 if (text) lines.push(text);
762 }
763 return { pageId, text: lines.join("\n"), blocks: rows.length };
764 }
765}
766
767
773class BatchVersionCasConflict extends Error {
774 constructor() {
775 super("pages.version CAS conflict (concurrent external writer)");
776 }
777}
778