@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.
- package/package.json +4 -2
- package/src/collaboration/block-room-codec/internal.ts +39 -20
- package/src/collaboration/block-room-codec/replay-base.ts +195 -0
- package/src/collaboration/block-room-codec/replay-inline-merge.ts +341 -0
- package/src/collaboration/block-room-codec/replay-locale.ts +182 -0
- package/src/collaboration/block-room-codec/replay-payload.ts +348 -0
- package/src/collaboration/block-room-codec/replay-structure.ts +504 -0
- package/src/collaboration/block-room-codec/replay.ts +75 -0
- package/src/collaboration/block-room-codec.ts +1 -0
- package/src/collaboration/form-schema-delta.ts +447 -0
- package/src/collaboration/menu.ts +129 -19
- package/src/index.ts +1 -0
|
@@ -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
|
-
|
|
130
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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";
|