@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.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@echovisionlab/geul-common",
3
3
  "description": "Shared TypeScript contracts and helpers for Geul editors, collaboration, localized content, media blocks, pages, posts, and runtime events.",
4
4
  "author": "state303 <state303@dsub.io>",
5
- "version": "0.2.4",
5
+ "version": "0.3.0",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "repository": {
@@ -40,6 +40,7 @@
40
40
  "./collaboration/document": "./src/collaboration/document.ts",
41
41
  "./collaboration/email-layout": "./src/collaboration/email-layout.ts",
42
42
  "./collaboration/form": "./src/collaboration/form.ts",
43
+ "./collaboration/form-schema-delta": "./src/collaboration/form-schema-delta.ts",
43
44
  "./collaboration/label": "./src/collaboration/label.ts",
44
45
  "./collaboration/map-theme": "./src/collaboration/map-theme.ts",
45
46
  "./collaboration/menu": "./src/collaboration/menu.ts",
@@ -62,7 +63,7 @@
62
63
  },
63
64
  "dependencies": {
64
65
  "@bufbuild/protobuf": "^2.14.0",
65
- "@echovisionlab/geul-proto": "^0.2.0",
66
+ "@echovisionlab/geul-proto": "^0.3.0",
66
67
  "zod": "^4.4.3"
67
68
  },
68
69
  "peerDependencies": {
@@ -354,24 +354,50 @@ function identityArray(
354
354
  value: JsonValue,
355
355
  collection: string,
356
356
  identity: string,
357
- nestedCollection?: string,
358
- nestedIdentity?: string,
359
357
  ): unknown[] {
360
358
  const object = jsonObject(value, "locale_identity:payload");
361
359
  const children = object[collection];
362
360
  if (!Array.isArray(children)) return [];
361
+ return children.map(
362
+ (child) => jsonObject(child, "locale_identity:item")[identity],
363
+ );
364
+ }
365
+
366
+ const tableIdentityPattern =
367
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
368
+
369
+ function tableIdentityArray(
370
+ value: JsonValue,
371
+ collection: string,
372
+ identity: string,
373
+ nestedIdentity: string,
374
+ ): unknown[] {
375
+ const object = jsonObject(value, "locale_identity:payload");
376
+ const children = object[collection];
377
+ if (!Array.isArray(children)) return [];
378
+ const seenRows = new Set<string>();
379
+ const seenCells = new Set<string>();
363
380
  return children.map((child) => {
364
381
  const item = jsonObject(child, "locale_identity:item");
365
- if (!nestedCollection || !nestedIdentity) return item[identity];
366
- const nested = item[nestedCollection];
382
+ const rowId = item[identity];
383
+ if (typeof rowId !== "string" || !tableIdentityPattern.test(rowId))
384
+ fail("locale_identity:table_row_id");
385
+ if (seenRows.has(rowId)) fail("locale_identity:table_duplicate_row_id");
386
+ seenRows.add(rowId);
387
+ const nested = item.cells;
388
+ const cells = Array.isArray(nested) ? nested : [];
367
389
  return {
368
- id: item[identity],
369
- children: Array.isArray(nested)
370
- ? nested.map(
371
- (entry) =>
372
- jsonObject(entry, "locale_identity:nested")[nestedIdentity],
373
- )
374
- : [],
390
+ id: rowId,
391
+ children: cells.map((entry) => {
392
+ const cell = jsonObject(entry, "locale_identity:nested");
393
+ const cellId = cell[nestedIdentity];
394
+ if (typeof cellId !== "string" || !tableIdentityPattern.test(cellId))
395
+ fail("locale_identity:table_cell_id");
396
+ if (seenCells.has(cellId))
397
+ fail("locale_identity:table_duplicate_cell_id");
398
+ seenCells.add(cellId);
399
+ return cellId;
400
+ }),
375
401
  };
376
402
  });
377
403
  }
@@ -402,18 +428,11 @@ export function assertBlockRoomLocaleProjectionParity(yDocument: Y.Doc): void {
402
428
  jsonObject(localePayload, `locale:${id}:payload`).content,
403
429
  `locale:${id}:content`,
404
430
  );
405
- const baseIdentity = identityArray(
406
- baseContent,
407
- "rows",
408
- "id",
409
- "cells",
410
- "id",
411
- );
412
- const localeIdentity = identityArray(
431
+ const baseIdentity = tableIdentityArray(baseContent, "rows", "id", "id");
432
+ const localeIdentity = tableIdentityArray(
413
433
  localeContent,
414
434
  "rows",
415
435
  "rowId",
416
- "cells",
417
436
  "cellId",
418
437
  );
419
438
  if (JSON.stringify(baseIdentity) !== JSON.stringify(localeIdentity))
@@ -0,0 +1,195 @@
1
+ import * as Y from "yjs";
2
+ import {
3
+ blockRoomBaseNodes,
4
+ type CanonicalBlockRoomSnapshot,
5
+ } from "./materialization.ts";
6
+ import {
7
+ deleteBlockRoomBaseNode,
8
+ movePageSectionNode,
9
+ moveRichTextBlockNode,
10
+ } from "./structure-mutations.ts";
11
+ import { fail, type BlockRoomDocumentType } from "./internal.ts";
12
+ import { applyPayloadRootDiff } from "./replay-payload.ts";
13
+ import {
14
+ baseDepth,
15
+ currentBaseNodes,
16
+ currentNodeType,
17
+ insertBaseNode,
18
+ movedBaseIds,
19
+ nodeMap,
20
+ safeIndex,
21
+ sameContainer,
22
+ sameNodeType,
23
+ sameValue,
24
+ stableTreeAnchor,
25
+ destinationUnderDeletedObservedAncestor,
26
+ } from "./replay-structure.ts";
27
+
28
+ export function applyBaseAdditions(
29
+ document: Y.Doc,
30
+ type: BlockRoomDocumentType,
31
+ before: CanonicalBlockRoomSnapshot,
32
+ after: CanonicalBlockRoomSnapshot,
33
+ ): void {
34
+ const beforeBase = nodeMap(before.baseNodes);
35
+ const afterBase = nodeMap(after.baseNodes);
36
+ const newlyAddedIds = new Set(
37
+ [...afterBase.keys()].filter((id) => !beforeBase.has(id)),
38
+ );
39
+ const currentBeforeChanges = currentBaseNodes(document, type);
40
+ for (const [id, node] of afterBase) {
41
+ if (beforeBase.has(id)) continue;
42
+ const current = currentBeforeChanges.get(id);
43
+ if (current) {
44
+ if (!sameNodeType(current, node)) fail(`replay:kind_changed:${id}`);
45
+ // A replay may be retried after its prior insert became resident. Keep
46
+ // that already-existing node and its later peer edits intact.
47
+ newlyAddedIds.delete(id);
48
+ }
49
+ }
50
+ const additions = [...newlyAddedIds]
51
+ .map((id) => afterBase.get(id)!)
52
+ .sort(
53
+ (left, right) =>
54
+ baseDepth(afterBase, left.id) - baseDepth(afterBase, right.id) ||
55
+ left.position - right.position ||
56
+ left.id.localeCompare(right.id),
57
+ );
58
+ for (const node of additions) {
59
+ if (blockRoomBaseNodes(document).has(node.id)) continue;
60
+ if (
61
+ destinationUnderDeletedObservedAncestor(
62
+ node.id,
63
+ node.parentId,
64
+ beforeBase,
65
+ afterBase,
66
+ new Set(blockRoomBaseNodes(document).keys()),
67
+ )
68
+ )
69
+ continue;
70
+ insertBaseNode(document, type, after, node, newlyAddedIds);
71
+ }
72
+ }
73
+
74
+ export function applyBaseChanges(
75
+ document: Y.Doc,
76
+ before: CanonicalBlockRoomSnapshot,
77
+ after: CanonicalBlockRoomSnapshot,
78
+ ): void {
79
+ const beforeBase = nodeMap(before.baseNodes);
80
+ const afterBase = nodeMap(after.baseNodes);
81
+ const moved = movedBaseIds(before.baseNodes, after.baseNodes);
82
+ const escaping = [...moved]
83
+ .filter((id) => {
84
+ const oldNode = beforeBase.get(id)!;
85
+ const newNode = afterBase.get(id)!;
86
+ let ancestor = oldNode.parentId;
87
+ while (ancestor !== null) {
88
+ if (!afterBase.has(ancestor)) return true;
89
+ ancestor = beforeBase.get(ancestor)?.parentId ?? null;
90
+ }
91
+ return (
92
+ !sameContainer(oldNode, newNode) &&
93
+ !afterBase.has(oldNode.parentId ?? "")
94
+ );
95
+ })
96
+ .sort(
97
+ (left, right) =>
98
+ baseDepth(beforeBase, left) - baseDepth(beforeBase, right),
99
+ );
100
+ const moveNode = (id: string) => {
101
+ const desired = afterBase.get(id)!;
102
+ if (
103
+ destinationUnderDeletedObservedAncestor(
104
+ id,
105
+ desired.parentId,
106
+ beforeBase,
107
+ afterBase,
108
+ new Set(blockRoomBaseNodes(document).keys()),
109
+ )
110
+ )
111
+ return;
112
+ const existing = currentNodeType(blockRoomBaseNodes(document), id);
113
+ if (!existing) return;
114
+ const baseline = beforeBase.get(id)!;
115
+ if (!sameNodeType(existing, baseline)) fail(`replay:kind_changed:${id}`);
116
+ if (desired.family === "page_section") {
117
+ const anchor = stableTreeAnchor(document, after.baseNodes, desired);
118
+ movePageSectionNode(
119
+ document,
120
+ id,
121
+ {
122
+ ...(desired.parentId ? { parentSectionId: desired.parentId } : {}),
123
+ ...(desired.columnId ? { columnId: desired.columnId } : {}),
124
+ index: anchor ? desired.position : safeIndex(document, desired),
125
+ },
126
+ anchor ? { anchor } : {},
127
+ );
128
+ } else {
129
+ const anchor = stableTreeAnchor(document, after.baseNodes, desired);
130
+ moveRichTextBlockNode(
131
+ document,
132
+ id,
133
+ {
134
+ ...(desired.parentId &&
135
+ afterBase.get(desired.parentId)?.family === "rich_text"
136
+ ? { parentBlockId: desired.parentId }
137
+ : {}),
138
+ index: anchor ? desired.position : safeIndex(document, desired),
139
+ },
140
+ {
141
+ ...(desired.parentId &&
142
+ afterBase.get(desired.parentId)?.family === "page_section"
143
+ ? { pageSectionId: desired.parentId }
144
+ : {}),
145
+ ...(anchor ? { anchor } : {}),
146
+ },
147
+ );
148
+ }
149
+ };
150
+ for (const id of escaping) moveNode(id);
151
+
152
+ const deletedIds = new Set(
153
+ [...beforeBase.keys()].filter((id) => !afterBase.has(id)),
154
+ );
155
+ const topLevelDeletes = [...deletedIds]
156
+ .filter((id) => {
157
+ let parentId = beforeBase.get(id)!.parentId;
158
+ while (parentId !== null) {
159
+ if (deletedIds.has(parentId)) return false;
160
+ parentId = beforeBase.get(parentId)?.parentId ?? null;
161
+ }
162
+ return true;
163
+ })
164
+ .sort(
165
+ (left, right) =>
166
+ baseDepth(beforeBase, left) - baseDepth(beforeBase, right),
167
+ );
168
+ for (const id of topLevelDeletes) {
169
+ if (blockRoomBaseNodes(document).has(id))
170
+ deleteBlockRoomBaseNode(document, id);
171
+ }
172
+
173
+ const movedInAfterOrder = [...moved]
174
+ .filter((id) => !escaping.includes(id))
175
+ .sort(
176
+ (left, right) =>
177
+ afterBase.get(right)!.position - afterBase.get(left)!.position,
178
+ );
179
+ for (const id of movedInAfterOrder) moveNode(id);
180
+
181
+ for (const [id, oldNode] of beforeBase) {
182
+ const next = afterBase.get(id);
183
+ if (!next || sameValue(oldNode.payload, next.payload)) continue;
184
+ const existing = currentNodeType(blockRoomBaseNodes(document), id);
185
+ if (!existing) continue;
186
+ if (!sameNodeType(existing, oldNode) || !sameNodeType(next, oldNode))
187
+ fail(`replay:kind_changed:${id}`);
188
+ applyPayloadRootDiff(
189
+ document,
190
+ { id, family: oldNode.family },
191
+ oldNode.payload,
192
+ next.payload,
193
+ );
194
+ }
195
+ }
@@ -0,0 +1,182 @@
1
+ import type { JsonValue } from "@bufbuild/protobuf";
2
+ import * as Y from "yjs";
3
+ import {
4
+ allBlockRoomLocaleValueTargets,
5
+ blockRoomLocaleValueIsEncoded,
6
+ markBlockRoomLocaleValuePresent,
7
+ } from "./locale-presence.ts";
8
+ import {
9
+ blockRoomBaseNodes,
10
+ blockRoomLocaleOverlay,
11
+ type CanonicalBlockRoomSnapshot,
12
+ } from "./materialization.ts";
13
+ import { applyPayloadRootDiff } from "./replay-payload.ts";
14
+ import {
15
+ currentNodeType,
16
+ insertLocaleNode,
17
+ nodeMap,
18
+ sameNodeType,
19
+ sameValue,
20
+ } from "./replay-structure.ts";
21
+ import { fail } from "./internal.ts";
22
+
23
+ interface LocaleIntentTarget {
24
+ target: ReturnType<typeof allBlockRoomLocaleValueTargets>[number];
25
+ }
26
+
27
+ function jsonObject(
28
+ value: JsonValue | undefined,
29
+ ): Record<string, JsonValue> | undefined {
30
+ return value === undefined ? undefined : (value as Record<string, JsonValue>);
31
+ }
32
+
33
+ function targetPath(
34
+ target: LocaleIntentTarget["target"],
35
+ ): Array<readonly ["field" | "item", string]> {
36
+ return target.path.map(({ selector }) => {
37
+ if (selector.case === "fieldHandle")
38
+ return ["field", selector.value] as const;
39
+ // Locale targets are generated by allBlockRoomLocaleValueTargets from
40
+ // canonical field/item handles, so the only other selector is itemHandle.
41
+ return ["item", selector.value as string] as const;
42
+ });
43
+ }
44
+
45
+ function rawLocaleValue(
46
+ snapshot: CanonicalBlockRoomSnapshot,
47
+ target: LocaleIntentTarget["target"],
48
+ ): JsonValue | undefined {
49
+ const ownerId = target.owner.value as string;
50
+ const overlay = snapshot.localeOverlay.find((node) => node.id === ownerId);
51
+ if (!overlay) return undefined;
52
+ const payload = jsonObject(overlay.payload)!;
53
+ const path = targetPath(target);
54
+ if (overlay.family === "rich_text") {
55
+ if (target.fieldHandle === "content") return payload.content;
56
+ if (target.fieldHandle === "tableContent") {
57
+ const rows = jsonObject(payload.content)!.rows as JsonValue[];
58
+ const row = rows.find(
59
+ (candidate) => jsonObject(candidate)!.rowId === path[1]?.[1],
60
+ );
61
+ if (row === undefined) return undefined;
62
+ const cells = jsonObject(row)!.cells as JsonValue[];
63
+ const cell = cells.find(
64
+ (candidate) => jsonObject(candidate)!.cellId === path[3]?.[1],
65
+ );
66
+ if (cell === undefined) return undefined;
67
+ return jsonObject(cell)!.content;
68
+ }
69
+ return jsonObject(payload.props)?.[target.fieldHandle];
70
+ }
71
+ if (path[0]?.[1] === "props") return jsonObject(payload.props)?.[path[1]![1]];
72
+ const units = payload.units as JsonValue[];
73
+ const unit = units.find(
74
+ (candidate) => jsonObject(candidate)!.unitId === path[1]![1],
75
+ );
76
+ if (unit === undefined) return undefined;
77
+ return jsonObject(jsonObject(unit)!.props)?.[path[3]![1]];
78
+ }
79
+
80
+ function changedLocaleIntentTargets(
81
+ document: Y.Doc,
82
+ before: CanonicalBlockRoomSnapshot,
83
+ after: CanonicalBlockRoomSnapshot,
84
+ ): LocaleIntentTarget[] {
85
+ const oldNodes = nodeMap(before.localeOverlay);
86
+ const nextNodes = nodeMap(after.localeOverlay);
87
+ const changedNodeIds = new Set<string>();
88
+ for (const id of new Set([...oldNodes.keys(), ...nextNodes.keys()])) {
89
+ const oldNode = oldNodes.get(id);
90
+ const nextNode = nextNodes.get(id);
91
+ if (
92
+ !oldNode ||
93
+ !nextNode ||
94
+ !sameNodeType(oldNode, nextNode) ||
95
+ !sameValue(oldNode.payload, nextNode.payload)
96
+ )
97
+ changedNodeIds.add(id);
98
+ }
99
+ if (changedNodeIds.size === 0) return [];
100
+ const changed: LocaleIntentTarget[] = [];
101
+ for (const target of allBlockRoomLocaleValueTargets(document)) {
102
+ if (!changedNodeIds.has(target.owner.value as string)) continue;
103
+ const nextValue = rawLocaleValue(after, target);
104
+ const oldValue = rawLocaleValue(before, target);
105
+ if (
106
+ !sameValue(oldValue, nextValue) &&
107
+ blockRoomLocaleValueIsEncoded(document, target)
108
+ )
109
+ changed.push({ target });
110
+ }
111
+ return changed;
112
+ }
113
+
114
+ export function applyLocaleAdditions(
115
+ document: Y.Doc,
116
+ before: CanonicalBlockRoomSnapshot,
117
+ after: CanonicalBlockRoomSnapshot,
118
+ isTarget: boolean,
119
+ ): void {
120
+ // Canonical source rooms add shared base nodes and their locale projections
121
+ // together. Target rooms cannot add base nodes (replay.ts checks parity).
122
+ if (isTarget) return;
123
+
124
+ const beforeLocale = nodeMap(before.localeOverlay);
125
+ const afterLocale = nodeMap(after.localeOverlay);
126
+ const newlyAddedLocaleIds = new Set(
127
+ [...afterLocale.keys()].filter((id) => !beforeLocale.has(id)),
128
+ );
129
+ for (const id of afterLocale.keys()) {
130
+ if (beforeLocale.has(id)) continue;
131
+ // Base insertion may have been skipped because a peer deleted an observed
132
+ // ancestor. Keep that subtree absent in both projections.
133
+ if (!blockRoomBaseNodes(document).has(id)) continue;
134
+ if (blockRoomLocaleOverlay(document).has(id)) {
135
+ newlyAddedLocaleIds.delete(id);
136
+ }
137
+ }
138
+ const localeAdds = [...newlyAddedLocaleIds]
139
+ .map((id) => afterLocale.get(id)!)
140
+ .sort(
141
+ (left, right) =>
142
+ (left.family === "page_section" ? 0 : 1) -
143
+ (right.family === "page_section" ? 0 : 1),
144
+ );
145
+ for (const node of localeAdds) {
146
+ // Inserting a Page section can include all newly-added descendant locale
147
+ // nodes, so a later entry in this same pass may already be resident.
148
+ if (blockRoomLocaleOverlay(document).has(node.id)) continue;
149
+ insertLocaleNode(document, after, node, newlyAddedLocaleIds);
150
+ }
151
+ }
152
+
153
+ export function applyLocaleChanges(
154
+ document: Y.Doc,
155
+ before: CanonicalBlockRoomSnapshot,
156
+ after: CanonicalBlockRoomSnapshot,
157
+ ): void {
158
+ const beforeLocale = nodeMap(before.localeOverlay);
159
+ const afterLocale = nodeMap(after.localeOverlay);
160
+ const currentBaseIds = new Set(blockRoomBaseNodes(document).keys());
161
+ // Canonical snapshots have one locale overlay per base node. Source base
162
+ // deletion already removes that projection; target snapshots cannot delete
163
+ // it without changing the base, which replay.ts rejects.
164
+
165
+ for (const [id, oldNode] of beforeLocale) {
166
+ const next = afterLocale.get(id);
167
+ if (!next || sameValue(oldNode.payload, next.payload)) continue;
168
+ if (!currentBaseIds.has(id)) continue;
169
+ const existing = currentNodeType(blockRoomLocaleOverlay(document), id)!;
170
+ if (!sameNodeType(existing, oldNode) || !sameNodeType(next, oldNode))
171
+ fail(`replay:kind_changed:${id}`);
172
+ applyPayloadRootDiff(
173
+ document,
174
+ { id, family: oldNode.family, locale: true },
175
+ oldNode.payload,
176
+ next.payload,
177
+ );
178
+ }
179
+
180
+ for (const { target } of changedLocaleIntentTargets(document, before, after))
181
+ markBlockRoomLocaleValuePresent(document, target);
182
+ }