@echovisionlab/geul-common 0.2.4 → 0.3.1

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,447 @@
1
+ export type FormSchemaPatchScope = "source" | "target";
2
+
3
+ export type FormSchemaPatchErrorReason =
4
+ | "invalid_schema"
5
+ | "schema_identity_changed"
6
+ | "target_topology_changed"
7
+ | "schema_identity_collision";
8
+
9
+ export class FormSchemaPatchError extends Error {
10
+ constructor(readonly reason: FormSchemaPatchErrorReason) {
11
+ super(`form_schema_patch:${reason}`);
12
+ this.name = "FormSchemaPatchError";
13
+ }
14
+ }
15
+
16
+ type NodeKind = "step" | "field" | "option" | "validator";
17
+
18
+ interface SchemaNode {
19
+ kind: NodeKind;
20
+ id: string;
21
+ parent: string;
22
+ order: number;
23
+ properties: Record<string, unknown>;
24
+ validationProperties?: Record<string, unknown>;
25
+ childrenPresent?: boolean;
26
+ validationPresent?: boolean;
27
+ validatorsPresent?: boolean;
28
+ }
29
+
30
+ interface FormSchemaIndex {
31
+ id: string;
32
+ properties: Record<string, unknown>;
33
+ nodes: Map<string, SchemaNode>;
34
+ }
35
+
36
+ const ROOT_PARENT = "form-root";
37
+ const TARGET_LOCALIZED_PROPERTIES: Readonly<
38
+ Record<NodeKind, ReadonlySet<string>>
39
+ > = {
40
+ step: new Set(["title", "description"]),
41
+ field: new Set(["label", "description", "placeholder", "checkboxLabel"]),
42
+ option: new Set(["label"]),
43
+ validator: new Set(["message"]),
44
+ };
45
+
46
+ function fail(): never {
47
+ throw new FormSchemaPatchError("invalid_schema");
48
+ }
49
+
50
+ function isObject(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
+ }
53
+
54
+ function object(value: unknown): Record<string, unknown> {
55
+ return isObject(value) ? value : fail();
56
+ }
57
+
58
+ function array(value: unknown): unknown[] {
59
+ return Array.isArray(value) ? value : fail();
60
+ }
61
+
62
+ function requiredId(value: Record<string, unknown>): string {
63
+ return typeof value.id === "string" && value.id.length > 0
64
+ ? value.id
65
+ : fail();
66
+ }
67
+
68
+ function nodeKey(kind: NodeKind, id: string): string {
69
+ return `${kind}\u0000${id}`;
70
+ }
71
+
72
+ function cloneJson(value: unknown): unknown {
73
+ const serialized = JSON.stringify(value);
74
+ if (serialized === undefined) return fail();
75
+ return JSON.parse(serialized) as unknown;
76
+ }
77
+
78
+ function stableJson(value: unknown): string {
79
+ if (Array.isArray(value)) {
80
+ return `[${value.map(stableJson).join(",")}]`;
81
+ }
82
+ if (isObject(value)) {
83
+ return `{${Object.keys(value)
84
+ .filter((key) => value[key] !== undefined)
85
+ .sort()
86
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
87
+ .join(",")}}`;
88
+ }
89
+ return JSON.stringify(value);
90
+ }
91
+
92
+ function copyProperties(
93
+ value: Record<string, unknown>,
94
+ excluded: ReadonlySet<string>,
95
+ ): Record<string, unknown> {
96
+ const properties: Record<string, unknown> = {};
97
+ for (const [key, entry] of Object.entries(value)) {
98
+ if (excluded.has(key) || entry === undefined) continue;
99
+ properties[key] = cloneJson(entry);
100
+ }
101
+ return properties;
102
+ }
103
+
104
+ function addNode(index: FormSchemaIndex, node: SchemaNode): void {
105
+ const key = nodeKey(node.kind, node.id);
106
+ if ([...index.nodes.values()].some((existing) => existing.id === node.id))
107
+ fail();
108
+ index.nodes.set(key, node);
109
+ }
110
+
111
+ function indexFormSchema(value: unknown): FormSchemaIndex {
112
+ const schema = object(value);
113
+ const id = requiredId(schema);
114
+ if (!Array.isArray(schema.steps)) fail();
115
+ const index: FormSchemaIndex = {
116
+ id,
117
+ properties: copyProperties(schema, new Set(["steps"])),
118
+ nodes: new Map(),
119
+ };
120
+
121
+ array(schema.steps).forEach((rawStep, stepOrder) => {
122
+ const step = object(rawStep);
123
+ const stepId = requiredId(step);
124
+ addNode(index, {
125
+ kind: "step",
126
+ id: stepId,
127
+ parent: ROOT_PARENT,
128
+ order: stepOrder,
129
+ properties: copyProperties(step, new Set(["fields"])),
130
+ ...(Object.hasOwn(step, "fields") && step.fields !== undefined
131
+ ? { childrenPresent: true }
132
+ : {}),
133
+ });
134
+
135
+ if (step.fields !== undefined) {
136
+ array(step.fields).forEach((rawField, fieldOrder) => {
137
+ const field = object(rawField);
138
+ const fieldId = requiredId(field);
139
+ const validation =
140
+ field.validation === undefined ? undefined : object(field.validation);
141
+ const validatorsPresent =
142
+ validation !== undefined && Object.hasOwn(validation, "validators");
143
+ const validationProperties = validation
144
+ ? copyProperties(validation, new Set(["validators"]))
145
+ : undefined;
146
+ addNode(index, {
147
+ kind: "field",
148
+ id: fieldId,
149
+ parent: nodeKey("step", stepId),
150
+ order: fieldOrder,
151
+ properties: copyProperties(field, new Set(["options", "validation"])),
152
+ ...(validationProperties ? { validationProperties } : {}),
153
+ ...(Object.hasOwn(field, "options") && field.options !== undefined
154
+ ? { childrenPresent: true }
155
+ : {}),
156
+ ...(validation !== undefined ? { validationPresent: true } : {}),
157
+ ...(validatorsPresent ? { validatorsPresent: true } : {}),
158
+ });
159
+
160
+ if (field.options !== undefined) {
161
+ array(field.options).forEach((rawOption, optionOrder) => {
162
+ const option = object(rawOption);
163
+ const optionId = requiredId(option);
164
+ addNode(index, {
165
+ kind: "option",
166
+ id: optionId,
167
+ parent: nodeKey("field", fieldId),
168
+ order: optionOrder,
169
+ properties: copyProperties(option, new Set()),
170
+ });
171
+ });
172
+ }
173
+
174
+ if (validatorsPresent) {
175
+ array(validation!.validators).forEach(
176
+ (rawValidator, validatorOrder) => {
177
+ const validator = object(rawValidator);
178
+ const validatorId = requiredId(validator);
179
+ addNode(index, {
180
+ kind: "validator",
181
+ id: validatorId,
182
+ parent: nodeKey("field", fieldId),
183
+ order: validatorOrder,
184
+ properties: copyProperties(validator, new Set()),
185
+ });
186
+ },
187
+ );
188
+ }
189
+ });
190
+ }
191
+ });
192
+ return index;
193
+ }
194
+
195
+ function targetTopology(index: FormSchemaIndex): unknown {
196
+ const nodes = [...index.nodes.values()].map((node) => {
197
+ const properties = { ...node.properties };
198
+ for (const key of TARGET_LOCALIZED_PROPERTIES[node.kind])
199
+ delete properties[key];
200
+ return {
201
+ kind: node.kind,
202
+ id: node.id,
203
+ parent: node.parent,
204
+ order: node.order,
205
+ properties,
206
+ validationProperties: node.validationProperties,
207
+ childrenPresent: node.childrenPresent,
208
+ validationPresent: node.validationPresent,
209
+ validatorsPresent: node.validatorsPresent,
210
+ };
211
+ });
212
+ return { id: index.id, properties: index.properties, nodes };
213
+ }
214
+
215
+ function sameNode(left: SchemaNode, right: SchemaNode): boolean {
216
+ return (
217
+ left.parent === right.parent &&
218
+ left.order === right.order &&
219
+ stableJson(left.properties) === stableJson(right.properties) &&
220
+ stableJson(left.validationProperties) ===
221
+ stableJson(right.validationProperties) &&
222
+ left.childrenPresent === right.childrenPresent &&
223
+ left.validationPresent === right.validationPresent &&
224
+ left.validatorsPresent === right.validatorsPresent
225
+ );
226
+ }
227
+
228
+ function orderedChildren(
229
+ nodes: Map<string, SchemaNode>,
230
+ parent: string,
231
+ kind: NodeKind,
232
+ ): SchemaNode[] {
233
+ return [...nodes.values()]
234
+ .filter((node) => node.parent === parent && node.kind === kind)
235
+ .sort(
236
+ (left, right) =>
237
+ left.order - right.order || left.id.localeCompare(right.id),
238
+ );
239
+ }
240
+
241
+ function materialize(index: FormSchemaIndex): Record<string, unknown> {
242
+ const buildField = (node: SchemaNode): Record<string, unknown> => {
243
+ const field: Record<string, unknown> = { ...node.properties, id: node.id };
244
+ if (node.childrenPresent) {
245
+ field.options = orderedChildren(
246
+ index.nodes,
247
+ nodeKey("field", node.id),
248
+ "option",
249
+ ).map((option) => ({ ...option.properties, id: option.id }));
250
+ }
251
+ if (node.validationPresent) {
252
+ const validationProperties = { ...node.validationProperties };
253
+ if (node.validatorsPresent) {
254
+ validationProperties.validators = orderedChildren(
255
+ index.nodes,
256
+ nodeKey("field", node.id),
257
+ "validator",
258
+ ).map((validator) => ({ ...validator.properties, id: validator.id }));
259
+ }
260
+ field.validation = validationProperties;
261
+ }
262
+ return field;
263
+ };
264
+ const buildStep = (node: SchemaNode): Record<string, unknown> => {
265
+ const step: Record<string, unknown> = { ...node.properties, id: node.id };
266
+ if (node.childrenPresent) {
267
+ step.fields = orderedChildren(
268
+ index.nodes,
269
+ nodeKey("step", node.id),
270
+ "field",
271
+ ).map(buildField);
272
+ }
273
+ return step;
274
+ };
275
+
276
+ // The index is parent-first and only receives validated additions and moves.
277
+ const result: Record<string, unknown> = { ...index.properties, id: index.id };
278
+ result.steps = orderedChildren(index.nodes, ROOT_PARENT, "step").map(
279
+ buildStep,
280
+ );
281
+ return result;
282
+ }
283
+
284
+ /**
285
+ * Applies only the stable-ID property and structural changes between previous
286
+ * and next to the latest server schema. Missing live IDs are never restored
287
+ * by unchanged stale snapshot data. An explicit source parent deletion
288
+ * cascades over its current descendants; target edits are restricted to
289
+ * catalogued localized properties and cannot change topology.
290
+ */
291
+ export function applyFormSchemaPatch(
292
+ currentSchema: unknown,
293
+ previousSchema: unknown,
294
+ nextSchema: unknown,
295
+ scope: FormSchemaPatchScope,
296
+ ): unknown {
297
+ const current = indexFormSchema(currentSchema);
298
+ const previous = indexFormSchema(previousSchema);
299
+ const next = indexFormSchema(nextSchema);
300
+ if (current.id !== previous.id || previous.id !== next.id) {
301
+ throw new FormSchemaPatchError("schema_identity_changed");
302
+ }
303
+ if (
304
+ scope === "target" &&
305
+ stableJson(targetTopology(previous)) !== stableJson(targetTopology(next))
306
+ ) {
307
+ throw new FormSchemaPatchError("target_topology_changed");
308
+ }
309
+
310
+ const result: FormSchemaIndex = {
311
+ id: current.id,
312
+ properties: { ...current.properties },
313
+ nodes: new Map(
314
+ [...current.nodes].map(([key, node]) => [key, structuredClone(node)]),
315
+ ),
316
+ };
317
+
318
+ for (const key of new Set([
319
+ ...Object.keys(previous.properties),
320
+ ...Object.keys(next.properties),
321
+ ])) {
322
+ if (key === "id") continue;
323
+ const beforeHas = Object.hasOwn(previous.properties, key);
324
+ const afterHas = Object.hasOwn(next.properties, key);
325
+ if (
326
+ beforeHas === afterHas &&
327
+ stableJson(previous.properties[key]) === stableJson(next.properties[key])
328
+ ) {
329
+ continue;
330
+ }
331
+ if (afterHas)
332
+ result.properties[key] = structuredClone(next.properties[key]);
333
+ else delete result.properties[key];
334
+ }
335
+
336
+ const added = new Set<string>();
337
+ const skippedAdditions = new Set<string>();
338
+ for (const [key, node] of next.nodes) {
339
+ if (previous.nodes.has(key)) continue;
340
+ if (result.nodes.has(key)) {
341
+ const live = result.nodes.get(key)!;
342
+ if (!sameNode(live, node)) {
343
+ throw new FormSchemaPatchError("schema_identity_collision");
344
+ }
345
+ continue;
346
+ }
347
+ // Schemas are indexed parent-first. A missing parent here is an observed
348
+ // peer deletion or a new parent already skipped beneath one.
349
+ if (
350
+ node.parent !== ROOT_PARENT &&
351
+ (skippedAdditions.has(node.parent) ||
352
+ (previous.nodes.has(node.parent) && !current.nodes.has(node.parent)))
353
+ ) {
354
+ skippedAdditions.add(key);
355
+ continue;
356
+ }
357
+ result.nodes.set(key, structuredClone(node));
358
+ added.add(key);
359
+ }
360
+
361
+ for (const [key, after] of next.nodes) {
362
+ const before = previous.nodes.get(key);
363
+ if (!before || added.has(key)) continue;
364
+ const live = result.nodes.get(key);
365
+ if (!live) continue;
366
+
367
+ for (const property of new Set([
368
+ ...Object.keys(before.properties),
369
+ ...Object.keys(after.properties),
370
+ ])) {
371
+ const beforeHas = Object.hasOwn(before.properties, property);
372
+ const afterHas = Object.hasOwn(after.properties, property);
373
+ if (
374
+ beforeHas === afterHas &&
375
+ stableJson(before.properties[property]) ===
376
+ stableJson(after.properties[property])
377
+ ) {
378
+ continue;
379
+ }
380
+ if (afterHas)
381
+ live.properties[property] = structuredClone(after.properties[property]);
382
+ else delete live.properties[property];
383
+ }
384
+
385
+ const beforeValidation = before.validationProperties ?? {};
386
+ const afterValidation = after.validationProperties ?? {};
387
+ for (const property of new Set([
388
+ ...Object.keys(beforeValidation),
389
+ ...Object.keys(afterValidation),
390
+ ])) {
391
+ const beforeHas = Object.hasOwn(beforeValidation, property);
392
+ const afterHas = Object.hasOwn(afterValidation, property);
393
+ if (
394
+ beforeHas === afterHas &&
395
+ stableJson(beforeValidation[property]) ===
396
+ stableJson(afterValidation[property])
397
+ ) {
398
+ continue;
399
+ }
400
+ const updated = { ...(live.validationProperties ?? {}) };
401
+ if (afterHas)
402
+ updated[property] = structuredClone(afterValidation[property]);
403
+ else delete updated[property];
404
+ live.validationProperties = updated;
405
+ }
406
+
407
+ if (
408
+ before.parent !== after.parent &&
409
+ (after.parent === ROOT_PARENT || result.nodes.has(after.parent))
410
+ ) {
411
+ live.parent = after.parent;
412
+ live.order = after.order;
413
+ } else if (
414
+ before.parent === after.parent &&
415
+ before.order !== after.order &&
416
+ live.parent === before.parent
417
+ ) {
418
+ live.order = after.order;
419
+ }
420
+ if (before.childrenPresent !== after.childrenPresent) {
421
+ live.childrenPresent = after.childrenPresent;
422
+ }
423
+ if (before.validationPresent !== after.validationPresent) {
424
+ live.validationPresent = after.validationPresent;
425
+ }
426
+ if (before.validatorsPresent !== after.validatorsPresent) {
427
+ live.validatorsPresent = after.validatorsPresent;
428
+ }
429
+ }
430
+
431
+ const deleted = new Set(
432
+ [...previous.nodes.keys()].filter((key) => !next.nodes.has(key)),
433
+ );
434
+ let addedDescendant = true;
435
+ while (addedDescendant) {
436
+ addedDescendant = false;
437
+ for (const [key, node] of result.nodes) {
438
+ if (deleted.has(node.parent) && !deleted.has(key)) {
439
+ deleted.add(key);
440
+ addedDescendant = true;
441
+ }
442
+ }
443
+ }
444
+ for (const key of deleted) result.nodes.delete(key);
445
+
446
+ return materialize(result);
447
+ }
@@ -42,6 +42,11 @@ export interface MenuCanonicalSnapshot {
42
42
  requestedLabels: Record<string, string>;
43
43
  }
44
44
 
45
+ export interface MenuCanonicalSourceSnapshot {
46
+ name: string;
47
+ items: readonly MenuCollaborationItem[];
48
+ }
49
+
45
50
  export function hydrateMenuCanonicalRoom(input: MenuCanonicalRoomInput): Y.Doc {
46
51
  const document = new Y.Doc();
47
52
  document.transact(() => {
@@ -50,7 +55,7 @@ export function hydrateMenuCanonicalRoom(input: MenuCanonicalRoomInput): Y.Doc {
50
55
  context.set("locale", input.locale);
51
56
  context.set("localeExists", input.localeExists);
52
57
  document.getMap<string>(MENU_ROOT_MAP_NAME).set("name", input.name);
53
- replaceMenuStructure(document, input.items);
58
+ replaceMenuStructure(document, [], input.items);
54
59
  const sourceLabels = document.getMap<string>(MENU_SOURCE_LABELS_MAP_NAME);
55
60
  const requestedLabels = menuLocaleLabelsMap(document);
56
61
  for (const [id, label] of Object.entries(input.sourceLabels))
@@ -125,27 +130,65 @@ export function replaceMenuCanonicalSource(
125
130
  document: Y.Doc,
126
131
  name: string,
127
132
  items: readonly MenuCollaborationItem[],
133
+ previous: MenuCanonicalSourceSnapshot,
128
134
  ): void {
129
- document.getMap<string>(MENU_ROOT_MAP_NAME).set("name", name);
130
- replaceMenuStructure(document, items);
135
+ assertMenuCanonicalSourceSnapshot(previous);
136
+ const root = document.getMap<string>(MENU_ROOT_MAP_NAME);
137
+ if (previous.name !== name) root.set("name", name);
138
+ const deletedItemIds = replaceMenuStructure(document, previous.items, items);
131
139
  const labels = menuLocaleLabelsMap(document);
140
+ const sourceLabels = document.getMap<string>(MENU_SOURCE_LABELS_MAP_NAME);
132
141
  const locale = document
133
142
  .getMap<string | boolean>(MENU_CONTEXT_MAP_NAME)
134
143
  .get("locale");
135
144
  if (typeof locale !== "string") {
136
145
  throw new Error("Menu collaboration locale is required");
137
146
  }
138
- const ids = new Set(flattenMenuItems(items).map(({ item }) => item.id));
139
- for (const id of [...labels.keys()]) if (!ids.has(id)) labels.delete(id);
140
- for (const { item } of flattenMenuItems(items)) {
141
- if (!menuItemOwnsLocaleLabel(item, locale)) {
142
- labels.delete(item.id);
143
- } else if (item.label !== undefined) {
144
- labels.set(item.id, item.label);
147
+ const previousById = new Map(
148
+ flattenMenuItems(previous.items).map((entry) => [
149
+ entry.item.id,
150
+ entry.item,
151
+ ]),
152
+ );
153
+ const nextById = new Map(
154
+ flattenMenuItems(items).map((entry) => [entry.item.id, entry.item]),
155
+ );
156
+ for (const id of deletedItemIds) {
157
+ labels.delete(id);
158
+ sourceLabels.delete(id);
159
+ }
160
+ for (const [id, item] of nextById) {
161
+ if (!document.getMap<string>(MENU_ITEMS_MAP_NAME).has(id)) continue;
162
+ const before = previousById.get(id);
163
+ const previouslyOwned = before
164
+ ? menuItemOwnsLocaleLabel(before, locale)
165
+ : false;
166
+ const currentlyOwned = menuItemOwnsLocaleLabel(item, locale);
167
+ if (!currentlyOwned) {
168
+ if (previouslyOwned) labels.delete(id);
169
+ } else if (!before || !previouslyOwned || before.label !== item.label) {
170
+ if (item.label !== undefined) labels.set(id, item.label);
145
171
  }
146
172
  }
147
173
  }
148
174
 
175
+ function assertMenuCanonicalSourceSnapshot(
176
+ value: unknown,
177
+ ): asserts value is MenuCanonicalSourceSnapshot {
178
+ if (
179
+ !value ||
180
+ typeof value !== "object" ||
181
+ !("name" in value) ||
182
+ typeof value.name !== "string" ||
183
+ !("items" in value) ||
184
+ !Array.isArray(value.items)
185
+ ) {
186
+ throw new Error(
187
+ "Menu source replacement requires an observed previous snapshot",
188
+ );
189
+ }
190
+ }
191
+
149
192
  export function setMenuLocaleLabel(
150
193
  document: Y.Doc,
151
194
  itemId: string,
@@ -180,29 +223,96 @@ export function extractMenuCanonicalSnapshot(
180
223
 
181
224
  function replaceMenuStructure(
182
225
  document: Y.Doc,
226
+ previousItems: readonly MenuCollaborationItem[],
183
227
  nextItems: readonly MenuCollaborationItem[],
184
- ): void {
228
+ ): Set<string> {
229
+ const previous = flattenMenuItems(previousItems);
185
230
  const flattened = flattenMenuItems(nextItems);
231
+ const previousById = new Map(previous.map((entry) => [entry.item.id, entry]));
186
232
  const nextIDs = new Set(flattened.map(({ item }) => item.id));
187
233
  if (nextIDs.size !== flattened.length || nextIDs.has(MENU_ROOT_PARENT)) {
188
234
  throw new Error("Invalid Menu collaboration item identity");
189
235
  }
236
+ const previousIDs = new Set(previous.map(({ item }) => item.id));
237
+ if (
238
+ previousIDs.size !== previous.length ||
239
+ previousIDs.has(MENU_ROOT_PARENT)
240
+ ) {
241
+ throw new Error("Invalid Menu collaboration item identity");
242
+ }
190
243
  const items = document.getMap<string>(MENU_ITEMS_MAP_NAME);
191
244
  const parents = document.getMap<string>(MENU_PARENTS_MAP_NAME);
192
245
  const orders = document.getMap<number>(MENU_ORDERS_MAP_NAME);
193
- for (const id of [...items.keys()]) {
194
- if (!nextIDs.has(id)) {
195
- items.delete(id);
196
- parents.delete(id);
197
- orders.delete(id);
246
+ const liveIDs = new Set(items.keys());
247
+ const peerDeletedObservedIDs = new Set(
248
+ [...previousIDs].filter((id) => !liveIDs.has(id)),
249
+ );
250
+ const childrenByParent = new Map<string, string[]>();
251
+ for (const id of items.keys()) {
252
+ const parent = parents.get(id) ?? MENU_ROOT_PARENT;
253
+ const children = childrenByParent.get(parent) ?? [];
254
+ children.push(id);
255
+ childrenByParent.set(parent, children);
256
+ }
257
+
258
+ const deletedIDs = new Set([...previousIDs].filter((id) => !nextIDs.has(id)));
259
+ for (const id of peerDeletedObservedIDs) deletedIDs.add(id);
260
+ const skippedNextIDs = new Set<string>();
261
+ const blockedNextParents = new Set(peerDeletedObservedIDs);
262
+ for (const { item, parent } of flattened) {
263
+ if (blockedNextParents.has(parent) || peerDeletedObservedIDs.has(item.id)) {
264
+ skippedNextIDs.add(item.id);
265
+ blockedNextParents.add(item.id);
266
+ }
267
+ }
268
+ const pendingDeletedIDs = [...deletedIDs];
269
+ for (let index = 0; index < pendingDeletedIDs.length; index += 1) {
270
+ const deletedParent = pendingDeletedIDs[index]!;
271
+ for (const childId of childrenByParent.get(deletedParent) ?? []) {
272
+ if (
273
+ (nextIDs.has(childId) && !skippedNextIDs.has(childId)) ||
274
+ deletedIDs.has(childId)
275
+ ) {
276
+ continue;
277
+ }
278
+ deletedIDs.add(childId);
279
+ pendingDeletedIDs.push(childId);
198
280
  }
199
281
  }
282
+ for (const id of deletedIDs) {
283
+ items.delete(id);
284
+ parents.delete(id);
285
+ orders.delete(id);
286
+ }
200
287
  for (const { item, parent, order } of flattened) {
288
+ if (skippedNextIDs.has(item.id)) continue;
289
+ const before = previousById.get(item.id);
201
290
  const stored = menuItemStructure(item);
202
- items.set(item.id, JSON.stringify(stored));
203
- parents.set(item.id, parent);
204
- orders.set(item.id, order);
291
+ if (
292
+ !before ||
293
+ stableMenuJson(menuItemStructure(before.item)) !== stableMenuJson(stored)
294
+ ) {
295
+ items.set(item.id, JSON.stringify(stored));
296
+ }
297
+ if (!before || before.parent !== parent) parents.set(item.id, parent);
298
+ if (!before || before.order !== order) orders.set(item.id, order);
299
+ }
300
+ return deletedIDs;
301
+ }
302
+
303
+ function stableMenuJson(value: unknown): string {
304
+ if (Array.isArray(value)) {
305
+ return `[${value.map(stableMenuJson).join(",")}]`;
306
+ }
307
+ if (typeof value === "object" && value !== null) {
308
+ const record = value as Record<string, unknown>;
309
+ return `{${Object.keys(record)
310
+ .filter((key) => record[key] !== undefined)
311
+ .sort()
312
+ .map((key) => `${JSON.stringify(key)}:${stableMenuJson(record[key])}`)
313
+ .join(",")}}`;
205
314
  }
315
+ return JSON.stringify(value);
206
316
  }
207
317
 
208
318
  function flattenMenuItems(items: readonly MenuCollaborationItem[]): Array<{
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./collaboration/block-room-codec.ts";
4
4
  export * from "./collaboration/document-layout.ts";
5
5
  export * from "./collaboration/document.ts";
6
6
  export * from "./collaboration/form.ts";
7
+ export * from "./collaboration/form-schema-delta.ts";
7
8
  export * from "./collaboration/label.ts";
8
9
  export * from "./collaboration/map-theme.ts";
9
10
  export * from "./collaboration/metadata-ai.ts";