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