@echovisionlab/geul-common 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,504 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import {
3
+ ContentBlockPlacementSchema,
4
+ PageSectionPlacementSchema,
5
+ type PageSectionLocale,
6
+ type PageSectionNode,
7
+ type RichTextBlockLocale,
8
+ type RichTextBlockNode,
9
+ } from "@echovisionlab/geul-proto/content/block_content_pb.ts";
10
+ import * as Y from "yjs";
11
+ import {
12
+ blockRoomBaseNodes,
13
+ blockRoomBaseOrder,
14
+ decodeCanonicalBlockRoom,
15
+ type BlockRoomBaseNodeSnapshot,
16
+ type BlockRoomLocaleNodeSnapshot,
17
+ type CanonicalBlockRoomSnapshot,
18
+ } from "./materialization.ts";
19
+ import {
20
+ createBlockRoomInsertionAnchor,
21
+ insertPageSectionLocale,
22
+ insertPageSectionNode,
23
+ insertRichTextBlockLocale,
24
+ insertRichTextBlockNode,
25
+ } from "./structure-mutations.ts";
26
+ import {
27
+ fail,
28
+ orderContainerKey,
29
+ type BlockRoomDocumentType,
30
+ type BlockRoomTypedDocument,
31
+ } from "./internal.ts";
32
+
33
+ export function sameValue(left: unknown, right: unknown): boolean {
34
+ return JSON.stringify(left) === JSON.stringify(right);
35
+ }
36
+
37
+ export function nodeMap<T extends { id: string }>(
38
+ nodes: readonly T[],
39
+ ): Map<string, T> {
40
+ const result = new Map<string, T>();
41
+ for (const node of nodes) {
42
+ if (result.has(node.id)) fail(`replay:duplicate_id:${node.id}`);
43
+ result.set(node.id, node);
44
+ }
45
+ return result;
46
+ }
47
+
48
+ export function sameContainer(
49
+ left: BlockRoomBaseNodeSnapshot,
50
+ right: BlockRoomBaseNodeSnapshot,
51
+ ): boolean {
52
+ return (
53
+ left.parentId === right.parentId &&
54
+ left.containerSlot === right.containerSlot &&
55
+ left.columnId === right.columnId
56
+ );
57
+ }
58
+
59
+ export function sameNodeType(
60
+ left: { family: string; kind: string },
61
+ right: { family: string; kind: string },
62
+ ): boolean {
63
+ return left.family === right.family && left.kind === right.kind;
64
+ }
65
+
66
+ export function compareBaseState(
67
+ left: readonly BlockRoomBaseNodeSnapshot[],
68
+ right: readonly BlockRoomBaseNodeSnapshot[],
69
+ ): boolean {
70
+ const leftById = nodeMap(left);
71
+ const rightById = nodeMap(right);
72
+ if (leftById.size !== rightById.size) return false;
73
+ for (const [id, node] of leftById) {
74
+ const other = rightById.get(id);
75
+ if (
76
+ !other ||
77
+ !sameNodeType(node, other) ||
78
+ !sameContainer(node, other) ||
79
+ node.position !== other.position ||
80
+ !sameValue(node.payload, other.payload)
81
+ )
82
+ return false;
83
+ }
84
+ return true;
85
+ }
86
+
87
+ export function longestCommonSubsequence<T>(
88
+ left: readonly T[],
89
+ right: readonly T[],
90
+ ): Set<T> {
91
+ const lengths = Array.from(
92
+ { length: left.length + 1 },
93
+ () => new Uint32Array(right.length + 1),
94
+ );
95
+ for (let leftIndex = left.length - 1; leftIndex >= 0; leftIndex -= 1) {
96
+ for (let rightIndex = right.length - 1; rightIndex >= 0; rightIndex -= 1) {
97
+ lengths[leftIndex]![rightIndex] = Object.is(
98
+ left[leftIndex],
99
+ right[rightIndex],
100
+ )
101
+ ? lengths[leftIndex + 1]![rightIndex + 1]! + 1
102
+ : Math.max(
103
+ lengths[leftIndex + 1]![rightIndex]!,
104
+ lengths[leftIndex]![rightIndex + 1]!,
105
+ );
106
+ }
107
+ }
108
+ const retained = new Set<T>();
109
+ let leftIndex = 0;
110
+ let rightIndex = 0;
111
+ while (leftIndex < left.length && rightIndex < right.length) {
112
+ if (Object.is(left[leftIndex], right[rightIndex])) {
113
+ retained.add(left[leftIndex]!);
114
+ leftIndex += 1;
115
+ rightIndex += 1;
116
+ } else if (
117
+ lengths[leftIndex + 1]![rightIndex]! >=
118
+ lengths[leftIndex]![rightIndex + 1]!
119
+ ) {
120
+ leftIndex += 1;
121
+ } else {
122
+ rightIndex += 1;
123
+ }
124
+ }
125
+ return retained;
126
+ }
127
+
128
+ function sameMembershipOrder(
129
+ before: readonly BlockRoomBaseNodeSnapshot[],
130
+ after: readonly BlockRoomBaseNodeSnapshot[],
131
+ ids: ReadonlySet<string>,
132
+ ): Set<string> {
133
+ const previous = before
134
+ .filter((node) => ids.has(node.id))
135
+ .sort((left, right) => left.position - right.position)
136
+ .map((node) => node.id);
137
+ const next = after
138
+ .filter((node) => ids.has(node.id))
139
+ .sort((left, right) => left.position - right.position)
140
+ .map((node) => node.id);
141
+ return longestCommonSubsequence(previous, next);
142
+ }
143
+
144
+ export function movedBaseIds(
145
+ before: readonly BlockRoomBaseNodeSnapshot[],
146
+ after: readonly BlockRoomBaseNodeSnapshot[],
147
+ ): Set<string> {
148
+ const previous = nodeMap(before);
149
+ const next = nodeMap(after);
150
+ const moved = new Set<string>();
151
+ const commonByContainer = new Map<string, Set<string>>();
152
+ for (const [id, oldNode] of previous) {
153
+ const newNode = next.get(id);
154
+ if (!newNode) continue;
155
+ if (!sameNodeType(oldNode, newNode)) fail(`replay:kind_changed:${id}`);
156
+ if (!sameContainer(oldNode, newNode)) {
157
+ moved.add(id);
158
+ continue;
159
+ }
160
+ const key = orderContainerKey(oldNode.parentId, oldNode.containerSlot);
161
+ const ids = commonByContainer.get(key) ?? new Set<string>();
162
+ ids.add(id);
163
+ commonByContainer.set(key, ids);
164
+ }
165
+ for (const ids of commonByContainer.values()) {
166
+ const retained = sameMembershipOrder(before, after, ids);
167
+ for (const id of ids) if (!retained.has(id)) moved.add(id);
168
+ }
169
+ return moved;
170
+ }
171
+
172
+ export function currentBaseNodes(
173
+ document: Y.Doc,
174
+ type: BlockRoomDocumentType,
175
+ ): Map<string, BlockRoomBaseNodeSnapshot> {
176
+ return nodeMap(decodeCanonicalBlockRoom(document, type).baseNodes);
177
+ }
178
+
179
+ export function currentNodeType(
180
+ nodes: Y.Map<unknown>,
181
+ id: string,
182
+ ): { family: string; kind: string } | undefined {
183
+ const raw = nodes.get(id);
184
+ if (raw === undefined) return undefined;
185
+ if (!(raw instanceof Y.Map)) fail(`replay:node_shape:${id}`);
186
+ const family = raw.get("family");
187
+ const kind = raw.get("kind");
188
+ if (
189
+ (family !== "page_section" && family !== "rich_text") ||
190
+ typeof kind !== "string" ||
191
+ kind.length === 0
192
+ )
193
+ fail(`replay:node_type:${id}`);
194
+ return { family, kind };
195
+ }
196
+
197
+ function containerOrder(
198
+ document: Y.Doc,
199
+ node: BlockRoomBaseNodeSnapshot,
200
+ ): Y.Array<string> | undefined {
201
+ const value = blockRoomBaseOrder(document).get(
202
+ orderContainerKey(node.parentId, node.containerSlot),
203
+ );
204
+ if (value === undefined) return undefined;
205
+ if (!(value instanceof Y.Array)) fail(`replay:order_shape:${node.id}`);
206
+ return value as Y.Array<string>;
207
+ }
208
+
209
+ export function stableTreeAnchor(
210
+ document: Y.Doc,
211
+ afterNodes: readonly BlockRoomBaseNodeSnapshot[],
212
+ node: BlockRoomBaseNodeSnapshot,
213
+ ): Y.RelativePosition | undefined {
214
+ const order = containerOrder(document, node);
215
+ if (!order) return undefined;
216
+ const afterSiblings = afterNodes
217
+ .filter(
218
+ (candidate) => candidate.id !== node.id && sameContainer(candidate, node),
219
+ )
220
+ .sort((left, right) => left.position - right.position);
221
+ const currentIds = new Set(order.toArray());
222
+ const following = afterSiblings.find(
223
+ (candidate) =>
224
+ candidate.position > node.position && currentIds.has(candidate.id),
225
+ );
226
+ const anchorIndex = following
227
+ ? order.toArray().indexOf(following.id)
228
+ : order.length;
229
+ return createBlockRoomInsertionAnchor(
230
+ document,
231
+ { parentId: node.parentId, containerSlot: node.containerSlot },
232
+ anchorIndex,
233
+ );
234
+ }
235
+
236
+ export function safeIndex(
237
+ document: Y.Doc,
238
+ node: BlockRoomBaseNodeSnapshot,
239
+ ): number {
240
+ return containerOrder(document, node)?.length ?? 0;
241
+ }
242
+
243
+ export function baseDepth(
244
+ nodes: ReadonlyMap<string, BlockRoomBaseNodeSnapshot>,
245
+ id: string,
246
+ ): number {
247
+ const visited = new Set<string>();
248
+ let depth = 0;
249
+ let node = nodes.get(id);
250
+ while (node?.parentId !== null && node?.parentId !== undefined) {
251
+ if (visited.has(node.id)) fail(`replay:parent_cycle:${id}`);
252
+ visited.add(node.id);
253
+ const parent = nodes.get(node.parentId);
254
+ if (!parent) fail(`replay:missing_parent:${id}:${node.parentId}`);
255
+ depth += 1;
256
+ node = parent;
257
+ }
258
+ return depth;
259
+ }
260
+
261
+ /**
262
+ * Returns true when the intended container is below an ancestor observed in
263
+ * the local baseline that is absent from the current resident. A valid local
264
+ * after-snapshot is required so an unknown parent remains a hard error.
265
+ */
266
+ export function destinationUnderDeletedObservedAncestor(
267
+ nodeId: string,
268
+ parentId: string | null,
269
+ before: ReadonlyMap<string, BlockRoomBaseNodeSnapshot>,
270
+ after: ReadonlyMap<string, BlockRoomBaseNodeSnapshot>,
271
+ currentIds: ReadonlySet<string>,
272
+ ): boolean {
273
+ const visited = new Set<string>([nodeId]);
274
+ let blocked = false;
275
+ while (parentId !== null) {
276
+ if (visited.has(parentId)) fail(`replay:parent_cycle:${nodeId}`);
277
+ visited.add(parentId);
278
+ const parent = after.get(parentId);
279
+ if (!parent) fail(`replay:missing_parent:${nodeId}:${parentId}`);
280
+ if (before.has(parentId) && !currentIds.has(parentId)) blocked = true;
281
+ parentId = parent.parentId;
282
+ }
283
+ return blocked;
284
+ }
285
+
286
+ function typedBaseNode(
287
+ document: BlockRoomTypedDocument,
288
+ id: string,
289
+ ): PageSectionNode | RichTextBlockNode | undefined {
290
+ if (document.$typeName === "api.content.v1.LocalizedPageDocument") {
291
+ // replayBlockRoomChanges validates the canonical base graph before helpers run.
292
+ for (const section of document.base!.nodes) {
293
+ if (section.section?.id === id) return section;
294
+ if (section.section?.value.case !== "richText") continue;
295
+ const block = section.section.value.value.blocks?.nodes.find(
296
+ (candidate) => candidate.block?.id === id,
297
+ );
298
+ if (block) return block;
299
+ }
300
+ return undefined;
301
+ }
302
+ return document.base?.nodes.find((node) => node.block?.id === id);
303
+ }
304
+
305
+ function filterPageBaseChildren(
306
+ node: PageSectionNode,
307
+ include: ReadonlySet<string>,
308
+ ): PageSectionNode {
309
+ if (node.section?.value.case !== "richText") return node;
310
+ const section = node.section.value.value;
311
+ const blocks = section.blocks;
312
+ return {
313
+ ...node,
314
+ section: {
315
+ ...node.section,
316
+ value: {
317
+ case: "richText",
318
+ value: {
319
+ ...section,
320
+ blocks: blocks
321
+ ? {
322
+ ...blocks,
323
+ nodes: blocks.nodes.filter((block) =>
324
+ include.has(block.block?.id ?? ""),
325
+ ),
326
+ }
327
+ : undefined,
328
+ },
329
+ },
330
+ },
331
+ } as PageSectionNode;
332
+ }
333
+
334
+ function typedLocaleNode(
335
+ document: BlockRoomTypedDocument,
336
+ id: string,
337
+ ): PageSectionLocale | RichTextBlockLocale | undefined {
338
+ if (document.$typeName === "api.content.v1.LocalizedPageDocument") {
339
+ // replayBlockRoomChanges validates the canonical locale graph before helpers run.
340
+ for (const section of document.localeOverlay!.sections) {
341
+ if (section.sectionId === id) return section;
342
+ if (section.value.case !== "richText") continue;
343
+ const block = section.value.value.blocks?.blocks.find(
344
+ (candidate) => candidate.blockId === id,
345
+ );
346
+ if (block) return block;
347
+ }
348
+ return undefined;
349
+ }
350
+ return document.localeOverlay?.blocks.find((block) => block.blockId === id);
351
+ }
352
+
353
+ function filterPageLocaleChildren(
354
+ node: PageSectionLocale,
355
+ include: ReadonlySet<string>,
356
+ ): PageSectionLocale {
357
+ if (node.value.case !== "richText") return node;
358
+ const section = node.value.value;
359
+ const blocks = section.blocks;
360
+ return {
361
+ ...node,
362
+ value: {
363
+ case: "richText",
364
+ value: {
365
+ ...section,
366
+ blocks: blocks
367
+ ? {
368
+ ...blocks,
369
+ blocks: blocks.blocks.filter((block) =>
370
+ include.has(block.blockId),
371
+ ),
372
+ }
373
+ : undefined,
374
+ },
375
+ },
376
+ } as PageSectionLocale;
377
+ }
378
+
379
+ export function insertBaseNode(
380
+ document: Y.Doc,
381
+ type: BlockRoomDocumentType,
382
+ after: CanonicalBlockRoomSnapshot,
383
+ node: BlockRoomBaseNodeSnapshot,
384
+ newlyAddedIds: ReadonlySet<string>,
385
+ ): void {
386
+ const typed = typedBaseNode(after.document, node.id);
387
+ if (!typed) fail(`replay:typed_node_missing:${node.id}`);
388
+ if (
389
+ node.parentId !== null &&
390
+ !blockRoomBaseNodes(document).has(node.parentId)
391
+ )
392
+ fail(`replay:missing_parent:${node.id}:${node.parentId}`);
393
+ const anchor = stableTreeAnchor(document, after.baseNodes, node);
394
+ const opts = {
395
+ ...(anchor ? { anchor } : {}),
396
+ };
397
+ if (node.family === "page_section") {
398
+ if (type !== "page" || !("section" in typed))
399
+ fail(`replay:family:${node.id}`);
400
+ const filtered = filterPageBaseChildren(typed, newlyAddedIds);
401
+ const placement = create(
402
+ PageSectionPlacementSchema,
403
+ filtered.placement ?? {},
404
+ );
405
+ placement.index = anchor
406
+ ? (filtered.placement?.index ?? 0)
407
+ : safeIndex(document, node);
408
+ const safe = {
409
+ ...filtered,
410
+ placement,
411
+ } as PageSectionNode;
412
+ insertPageSectionNode(document, safe, opts);
413
+ return;
414
+ }
415
+ if (!("block" in typed)) fail(`replay:family:${node.id}`);
416
+ const placement = create(ContentBlockPlacementSchema, typed.placement ?? {});
417
+ placement.index = anchor
418
+ ? (typed.placement?.index ?? 0)
419
+ : safeIndex(document, node);
420
+ const safe = {
421
+ ...typed,
422
+ placement,
423
+ } as RichTextBlockNode;
424
+ insertRichTextBlockNode(document, safe, {
425
+ ...(node.parentId &&
426
+ node.parentId !== null &&
427
+ node.containerSlot === "content"
428
+ ? { pageSectionId: node.parentId }
429
+ : {}),
430
+ ...opts,
431
+ });
432
+ }
433
+
434
+ export function insertLocaleNode(
435
+ document: Y.Doc,
436
+ after: CanonicalBlockRoomSnapshot,
437
+ node: BlockRoomLocaleNodeSnapshot,
438
+ newlyAddedIds: ReadonlySet<string>,
439
+ ): void {
440
+ const typed = typedLocaleNode(after.document, node.id);
441
+ if (!typed) fail(`replay:typed_locale_missing:${node.id}`);
442
+ if (!blockRoomBaseNodes(document).has(node.id)) return;
443
+ if (node.family === "page_section") {
444
+ if (!("sectionId" in typed)) fail(`replay:family:${node.id}`);
445
+ const filtered = filterPageLocaleChildren(typed, newlyAddedIds);
446
+ insertPageSectionLocale(document, filtered);
447
+ return;
448
+ }
449
+ if (!("blockId" in typed)) fail(`replay:family:${node.id}`);
450
+ insertRichTextBlockLocale(document, typed);
451
+ }
452
+
453
+ export function validateSnapshotDocument(
454
+ snapshot: CanonicalBlockRoomSnapshot,
455
+ type: BlockRoomDocumentType,
456
+ locale: string,
457
+ ): void {
458
+ const expectedName =
459
+ type === "page"
460
+ ? "api.content.v1.LocalizedPageDocument"
461
+ : "api.content.v1.LocalizedRichTextDocument";
462
+ if (snapshot.document.$typeName !== expectedName)
463
+ fail(`replay:document_type:${type}`);
464
+ if (snapshot.document.locale !== locale) fail("replay:locale_changed");
465
+ nodeMap(snapshot.baseNodes);
466
+ nodeMap(snapshot.localeOverlay);
467
+ }
468
+
469
+ export function localeNodeDeleteOrder(
470
+ baseNodes: readonly BlockRoomBaseNodeSnapshot[],
471
+ localeNodes: readonly BlockRoomLocaleNodeSnapshot[],
472
+ deletedIds: ReadonlySet<string>,
473
+ ): string[] {
474
+ const nodes = nodeMap(localeNodes);
475
+ const base = nodeMap(baseNodes);
476
+ const isDescendantOf = (candidate: string, ancestor: string): boolean => {
477
+ let node = base.get(candidate);
478
+ const visited = new Set<string>();
479
+ while (node?.parentId !== null && node?.parentId !== undefined) {
480
+ if (visited.has(node.id)) fail(`replay:parent_cycle:${candidate}`);
481
+ visited.add(node.id);
482
+ if (node.parentId === ancestor) return true;
483
+ node = base.get(node.parentId);
484
+ }
485
+ return false;
486
+ };
487
+ return [...deletedIds].sort((left, right) => {
488
+ const leftNode = nodes.get(left);
489
+ const rightNode = nodes.get(right);
490
+ if (
491
+ leftNode?.family === "page_section" &&
492
+ rightNode?.family === "rich_text"
493
+ )
494
+ return -1;
495
+ if (
496
+ leftNode?.family === "rich_text" &&
497
+ rightNode?.family === "page_section"
498
+ )
499
+ return 1;
500
+ if (isDescendantOf(left, right)) return 1;
501
+ if (isDescendantOf(right, left)) return -1;
502
+ return left.localeCompare(right);
503
+ });
504
+ }
@@ -0,0 +1,75 @@
1
+ import * as Y from "yjs";
2
+ import {
3
+ decodeCanonicalBlockRoom,
4
+ type CanonicalBlockRoomSnapshot,
5
+ } from "./materialization.ts";
6
+ import { fail, type BlockRoomDocumentType } from "./internal.ts";
7
+ import {
8
+ roomDocumentType,
9
+ roomLocale,
10
+ roomLocaleRole,
11
+ type BlockRoomMutationOptions,
12
+ } from "./room-access.ts";
13
+ import {
14
+ compareBaseState,
15
+ validateSnapshotDocument,
16
+ } from "./replay-structure.ts";
17
+ import { applyBaseAdditions, applyBaseChanges } from "./replay-base.ts";
18
+ import { applyLocaleAdditions, applyLocaleChanges } from "./replay-locale.ts";
19
+
20
+ function applyOneChange(
21
+ document: Y.Doc,
22
+ type: BlockRoomDocumentType,
23
+ before: CanonicalBlockRoomSnapshot,
24
+ after: CanonicalBlockRoomSnapshot,
25
+ ): void {
26
+ const isTarget = roomLocaleRole(document) === "target";
27
+ if (isTarget && !compareBaseState(before.baseNodes, after.baseNodes))
28
+ fail("replay:target_base_changed");
29
+
30
+ if (!isTarget) applyBaseAdditions(document, type, before, after);
31
+ // New base nodes need their locale projection before structural and payload
32
+ // replay decodes the staged document.
33
+ applyLocaleAdditions(document, before, after, isTarget);
34
+ if (!isTarget) applyBaseChanges(document, before, after);
35
+ applyLocaleChanges(document, before, after);
36
+ }
37
+
38
+ /** Replays stable-node user intent onto the current canonical Block room. */
39
+ export function replayBlockRoomChanges(
40
+ current: Y.Doc,
41
+ changes: readonly {
42
+ before: CanonicalBlockRoomSnapshot;
43
+ after: CanonicalBlockRoomSnapshot;
44
+ }[],
45
+ options: BlockRoomMutationOptions = {},
46
+ ): void {
47
+ if (changes.length === 0) return;
48
+ const type = roomDocumentType(current);
49
+ const locale = roomLocale(current);
50
+ for (const change of changes) {
51
+ validateSnapshotDocument(change.before, type, locale);
52
+ validateSnapshotDocument(change.after, type, locale);
53
+ }
54
+
55
+ // Stage first so malformed or incompatible intent cannot leave a partially
56
+ // replayed resident. Apply the resulting update to the caller's Y.Doc in one
57
+ // transaction so its recovery journal sees one atomic replay boundary.
58
+ const staged = new Y.Doc();
59
+ Y.applyUpdate(staged, Y.encodeStateAsUpdate(current));
60
+ try {
61
+ staged.transact(() => {
62
+ for (const change of changes)
63
+ applyOneChange(staged, type, change.before, change.after);
64
+ decodeCanonicalBlockRoom(staged, type);
65
+ }, options.origin);
66
+ const update = Y.encodeStateAsUpdate(staged, Y.encodeStateVector(current));
67
+ if (update.length > 0) {
68
+ current.transact(() => {
69
+ Y.applyUpdate(current, update, options.origin);
70
+ }, options.origin);
71
+ }
72
+ } finally {
73
+ staged.destroy();
74
+ }
75
+ }
@@ -7,6 +7,7 @@ export {
7
7
  type RichTextBlockRoomDocumentType,
8
8
  } from "./block-room-codec/internal.ts";
9
9
  export { hydrateCanonicalBlockRoom } from "./block-room-codec/hydration.ts";
10
+ export { replayBlockRoomChanges } from "./block-room-codec/replay.ts";
10
11
  export {
11
12
  applyAIDocumentOperationsToBlockRoom,
12
13
  type ApplyAIDocumentOperationsOptions,