@echovisionlab/geul-common 0.1.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.
Files changed (47) hide show
  1. package/LICENSE.md +6 -0
  2. package/README.md +37 -0
  3. package/package.json +89 -0
  4. package/src/collaboration/artist.ts +63 -0
  5. package/src/collaboration/block-room-codec/ai-document-applicator.ts +319 -0
  6. package/src/collaboration/block-room-codec/ai-document-field-mutations.ts +557 -0
  7. package/src/collaboration/block-room-codec/ai-document-page-structure-mutations.ts +449 -0
  8. package/src/collaboration/block-room-codec/ai-document-values.ts +368 -0
  9. package/src/collaboration/block-room-codec/hydration.ts +257 -0
  10. package/src/collaboration/block-room-codec/internal.ts +432 -0
  11. package/src/collaboration/block-room-codec/locale-change-validation.ts +237 -0
  12. package/src/collaboration/block-room-codec/locale-presence.ts +827 -0
  13. package/src/collaboration/block-room-codec/materialization.ts +450 -0
  14. package/src/collaboration/block-room-codec/observation.ts +518 -0
  15. package/src/collaboration/block-room-codec/payload-mutations.ts +347 -0
  16. package/src/collaboration/block-room-codec/room-access.ts +154 -0
  17. package/src/collaboration/block-room-codec/structure-mutations.ts +456 -0
  18. package/src/collaboration/block-room-codec.ts +86 -0
  19. package/src/collaboration/campaign.ts +37 -0
  20. package/src/collaboration/document-layout.ts +75 -0
  21. package/src/collaboration/document.ts +185 -0
  22. package/src/collaboration/email-layout.ts +150 -0
  23. package/src/collaboration/form.ts +513 -0
  24. package/src/collaboration/label.ts +49 -0
  25. package/src/collaboration/map-theme.ts +157 -0
  26. package/src/collaboration/member-id.ts +9 -0
  27. package/src/collaboration/menu.ts +258 -0
  28. package/src/collaboration/metadata-ai.ts +99 -0
  29. package/src/collaboration/page.ts +483 -0
  30. package/src/collaboration/post-series.ts +96 -0
  31. package/src/collaboration/post.ts +59 -0
  32. package/src/collaboration/release.ts +221 -0
  33. package/src/collaboration/runtime-events.ts +547 -0
  34. package/src/collaboration/work.ts +107 -0
  35. package/src/editor/link-normalization.ts +212 -0
  36. package/src/editor/materialized-blocks.ts +58 -0
  37. package/src/index.ts +22 -0
  38. package/src/media/block-schemas.ts +78 -0
  39. package/src/media/hydration.ts +226 -0
  40. package/src/page/block-fixtures.ts +586 -0
  41. package/src/page/index.ts +3 -0
  42. package/src/page/types.ts +57 -0
  43. package/src/post/index.ts +1 -0
  44. package/src/post/types.ts +13 -0
  45. package/src/test/random-id.ts +9 -0
  46. package/src/translation/release.ts +88 -0
  47. package/src/types.ts +14 -0
@@ -0,0 +1,432 @@
1
+ import { fromJson, toJson, type JsonValue } from "@bufbuild/protobuf";
2
+ import {
3
+ canonicalLocalizedPageDocumentBytes,
4
+ canonicalLocalizedRichTextDocumentBytes,
5
+ isPageSectionCollaborativeTextPath,
6
+ isRichTextCollaborativeTextPath,
7
+ pageSectionKindByProtoCase,
8
+ richTextBlockKindByProtoCase,
9
+ validateLocalizedPageDocument,
10
+ validateLocalizedRichTextDocument,
11
+ type PageSectionKind,
12
+ type RichTextBlockKind,
13
+ } from "@echovisionlab/geul-proto/content/block_catalog.ts";
14
+ import {
15
+ LocalizedPageDocumentSchema,
16
+ LocalizedRichTextDocumentSchema,
17
+ RichTextProfile,
18
+ type LocalizedPageDocument,
19
+ type LocalizedRichTextDocument,
20
+ } from "@echovisionlab/geul-proto/content/block_content_pb.ts";
21
+ import * as Y from "yjs";
22
+
23
+ export const BLOCK_ROOM_ROOT = "block-document";
24
+ export const BLOCK_ROOM_BASE_NODES = "baseNodes";
25
+ export const BLOCK_ROOM_BASE_ORDER = "baseOrder";
26
+ export const BLOCK_ROOM_LOCALE_OVERLAY = "localeOverlay";
27
+ export const BLOCK_ROOM_LOCALE_PRESENCE = "localePresence";
28
+
29
+ export type BlockRoomDocumentType =
30
+ | "post"
31
+ | "page"
32
+ | "work"
33
+ | "program-event"
34
+ | "artist"
35
+ | "label"
36
+ | "release"
37
+ | "campaign"
38
+ | "email-template"
39
+ | "terms-history"
40
+ | "privacy-history";
41
+ export type BlockRoomTypedDocument =
42
+ LocalizedRichTextDocument | LocalizedPageDocument;
43
+ export type RichTextBlockRoomDocumentType = Exclude<
44
+ BlockRoomDocumentType,
45
+ "page"
46
+ >;
47
+ export type BlockRoomLocaleRole = "source" | "target";
48
+ export type BlockRoomNodeFamily = "page_section" | "rich_text";
49
+ export type JsonObject = { [key: string]: JsonValue };
50
+ export type CollaborativeTextPredicate = (path: string) => boolean;
51
+
52
+ const richTextProfileByDocumentType = {
53
+ post: RichTextProfile.POST,
54
+ work: RichTextProfile.WORK,
55
+ "program-event": RichTextProfile.PROGRAM_EVENT,
56
+ artist: RichTextProfile.COMPACT,
57
+ label: RichTextProfile.COMPACT,
58
+ release: RichTextProfile.COMPACT,
59
+ campaign: RichTextProfile.EMAIL,
60
+ "email-template": RichTextProfile.EMAIL,
61
+ "terms-history": RichTextProfile.POLICY,
62
+ "privacy-history": RichTextProfile.POLICY,
63
+ } as const satisfies Record<
64
+ Exclude<BlockRoomDocumentType, "page">,
65
+ RichTextProfile
66
+ >;
67
+
68
+ export const blockRoomDocumentTypes = new Set<BlockRoomDocumentType>([
69
+ "post",
70
+ "page",
71
+ "work",
72
+ "program-event",
73
+ "artist",
74
+ "label",
75
+ "release",
76
+ "campaign",
77
+ "email-template",
78
+ "terms-history",
79
+ "privacy-history",
80
+ ]);
81
+
82
+ export const ROOT_KEYS = new Set([
83
+ "documentType",
84
+ "blockCatalogFingerprint",
85
+ "profile",
86
+ "sourceLocale",
87
+ "roomLocale",
88
+ BLOCK_ROOM_BASE_NODES,
89
+ BLOCK_ROOM_BASE_ORDER,
90
+ BLOCK_ROOM_LOCALE_OVERLAY,
91
+ BLOCK_ROOM_LOCALE_PRESENCE,
92
+ ]);
93
+ export const BASE_NODE_KEYS = new Set([
94
+ "family",
95
+ "kind",
96
+ "payload",
97
+ "parentId",
98
+ "containerSlot",
99
+ "columnId",
100
+ ]);
101
+ export const LOCALE_NODE_KEYS = new Set(["family", "kind", "payload"]);
102
+
103
+ export function fail(reason: string): never {
104
+ throw new Error(`block_room_invalid:${reason}`);
105
+ }
106
+
107
+ export function jsonObject(
108
+ value: JsonValue | undefined,
109
+ reason: string,
110
+ ): JsonObject {
111
+ if (!value || typeof value !== "object" || Array.isArray(value))
112
+ return fail(reason);
113
+ return value as JsonObject;
114
+ }
115
+
116
+ export function stringValue(value: unknown, reason: string): string {
117
+ return typeof value === "string" && value !== "" ? value : fail(reason);
118
+ }
119
+
120
+ export function integerValue(value: unknown, reason: string): number {
121
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
122
+ ? value
123
+ : fail(reason);
124
+ }
125
+
126
+ export function assertExactKeys(
127
+ map: Y.Map<unknown>,
128
+ allowed: ReadonlySet<string>,
129
+ reason: string,
130
+ ): void {
131
+ for (const key of map.keys()) {
132
+ if (!allowed.has(key)) fail(`${reason}:unknown_key:${key}`);
133
+ }
134
+ }
135
+
136
+ export function yMap(value: unknown, reason: string): Y.Map<unknown> {
137
+ return value instanceof Y.Map ? value : fail(reason);
138
+ }
139
+
140
+ function childPath(parent: string, child: string): string {
141
+ return parent ? `${parent}.${child}` : child;
142
+ }
143
+
144
+ export function arrayPath(parent: string, index: number): string {
145
+ return `${parent}[${index}]`;
146
+ }
147
+
148
+ export function toYValue(
149
+ value: JsonValue,
150
+ path: string,
151
+ isCollaborativeText: CollaborativeTextPredicate,
152
+ ): unknown {
153
+ if (typeof value === "string") {
154
+ if (!isCollaborativeText(path)) return value;
155
+ const text = new Y.Text();
156
+ if (value) text.insert(0, value);
157
+ return text;
158
+ }
159
+ if (Array.isArray(value)) {
160
+ const array = new Y.Array<unknown>();
161
+ if (value.length > 0) {
162
+ array.insert(
163
+ 0,
164
+ value.map((child, index) =>
165
+ toYValue(child, arrayPath(path, index), isCollaborativeText),
166
+ ),
167
+ );
168
+ }
169
+ return array;
170
+ }
171
+ if (value && typeof value === "object") {
172
+ const map = new Y.Map<unknown>();
173
+ for (const [key, child] of Object.entries(value).sort(([left], [right]) =>
174
+ left.localeCompare(right),
175
+ )) {
176
+ map.set(key, toYValue(child, childPath(path, key), isCollaborativeText));
177
+ }
178
+ return map;
179
+ }
180
+ return value;
181
+ }
182
+
183
+ export function fromYValue(value: unknown): JsonValue {
184
+ if (value instanceof Y.Text) return value.toString();
185
+ if (value instanceof Y.Array) return value.toArray().map(fromYValue);
186
+ if (value instanceof Y.Map) {
187
+ return Object.fromEntries(
188
+ [...value.entries()]
189
+ .sort(([left], [right]) => left.localeCompare(right))
190
+ .map(([key, child]) => [key, fromYValue(child)]),
191
+ );
192
+ }
193
+ if (
194
+ value === null ||
195
+ typeof value === "string" ||
196
+ typeof value === "boolean" ||
197
+ (typeof value === "number" && Number.isFinite(value))
198
+ )
199
+ return value;
200
+ return fail("non_json_value");
201
+ }
202
+
203
+ export function oneofPayload(
204
+ message: JsonObject,
205
+ idField: string,
206
+ reason: string,
207
+ ): { id: string; kind: string; payload: JsonValue } {
208
+ const id = stringValue(message[idField], `${reason}:missing_id`);
209
+ const cases = Object.keys(message).filter((key) => key !== idField);
210
+ if (cases.length !== 1) fail(`${reason}:invalid_oneof`);
211
+ const kind = cases[0]!;
212
+ return { id, kind, payload: message[kind]! };
213
+ }
214
+
215
+ export function pageSectionPayload(
216
+ message: JsonObject,
217
+ reason: string,
218
+ ): { id: string; kind: string; payload: JsonValue } {
219
+ const id = stringValue(message.id, `${reason}:missing_id`);
220
+ const cases = Object.keys(message).filter(
221
+ (key) => key !== "id" && key !== "settings",
222
+ );
223
+ if (cases.length !== 1) fail(`${reason}:invalid_oneof`);
224
+ const kind = cases[0]!;
225
+ const value = jsonObject(message[kind], `${reason}:${kind}`);
226
+ const settings = message.settings;
227
+ return {
228
+ id,
229
+ kind,
230
+ payload: settings === undefined ? value : { ...value, settings },
231
+ };
232
+ }
233
+
234
+ export function materializePageSectionPayload(
235
+ payload: JsonValue,
236
+ reason: string,
237
+ ): { value: JsonObject; settings?: JsonValue } {
238
+ const object = jsonObject(payload, reason);
239
+ const { settings, ...value } = object;
240
+ return settings === undefined ? { value } : { value, settings };
241
+ }
242
+
243
+ export function isCollaborativeTextPath(
244
+ family: BlockRoomNodeFamily,
245
+ kind: string,
246
+ path: string,
247
+ ): boolean {
248
+ if (family === "rich_text") {
249
+ const catalogKind =
250
+ richTextBlockKindByProtoCase[
251
+ kind as keyof typeof richTextBlockKindByProtoCase
252
+ ];
253
+ return (
254
+ catalogKind !== undefined &&
255
+ isRichTextCollaborativeTextPath(catalogKind as RichTextBlockKind, path)
256
+ );
257
+ }
258
+ const catalogKind =
259
+ pageSectionKindByProtoCase[kind as keyof typeof pageSectionKindByProtoCase];
260
+ return (
261
+ catalogKind !== undefined &&
262
+ isPageSectionCollaborativeTextPath(catalogKind as PageSectionKind, path)
263
+ );
264
+ }
265
+
266
+ export function setNodePayload(
267
+ target: Y.Map<unknown>,
268
+ family: BlockRoomNodeFamily,
269
+ kind: string,
270
+ payload: JsonValue,
271
+ ): void {
272
+ target.set("family", family);
273
+ target.set("kind", kind);
274
+ target.set(
275
+ "payload",
276
+ toYValue(payload, "", (path) =>
277
+ isCollaborativeTextPath(family, kind, path),
278
+ ),
279
+ );
280
+ }
281
+
282
+ export function withoutField(
283
+ value: JsonValue,
284
+ field: string,
285
+ reason: string,
286
+ ): JsonObject {
287
+ const object = jsonObject(value, reason);
288
+ return Object.fromEntries(
289
+ Object.entries(object).filter(([key]) => key !== field),
290
+ );
291
+ }
292
+
293
+ export function richTextSlot(): string {
294
+ return "content";
295
+ }
296
+
297
+ export function pageSectionSlot(
298
+ parentId: string | undefined,
299
+ columnId?: string,
300
+ ): string {
301
+ if (!parentId) {
302
+ if (columnId) fail("page_node:orphan_column");
303
+ return "sections";
304
+ }
305
+ return columnId ? `column-${columnId}` : fail("page_node:missing_column");
306
+ }
307
+
308
+ export function orderContainerKey(
309
+ parentId: string | null,
310
+ containerSlot: string,
311
+ ): string {
312
+ return JSON.stringify([parentId, containerSlot]);
313
+ }
314
+
315
+ export function normalizeBlockDocument(
316
+ documentType: BlockRoomDocumentType,
317
+ document: BlockRoomTypedDocument,
318
+ ): BlockRoomTypedDocument {
319
+ if (documentType === "page") {
320
+ if (document.$typeName !== "api.content.v1.LocalizedPageDocument")
321
+ fail("document_type");
322
+ const normalized = fromJson(
323
+ LocalizedPageDocumentSchema,
324
+ toJson(LocalizedPageDocumentSchema, document),
325
+ ) as LocalizedPageDocument;
326
+ validateLocalizedPageDocument(normalized);
327
+ return normalized;
328
+ }
329
+ if (document.$typeName !== "api.content.v1.LocalizedRichTextDocument")
330
+ fail("document_type");
331
+ if (document.profile !== richTextProfileByDocumentType[documentType])
332
+ fail("document_profile");
333
+ const normalized = fromJson(
334
+ LocalizedRichTextDocumentSchema,
335
+ toJson(LocalizedRichTextDocumentSchema, document),
336
+ ) as LocalizedRichTextDocument;
337
+ validateLocalizedRichTextDocument(normalized);
338
+ return normalized;
339
+ }
340
+
341
+ export function canonicalBlockRoomDocumentBytes(
342
+ documentType: BlockRoomDocumentType,
343
+ document: BlockRoomTypedDocument,
344
+ ): Uint8Array {
345
+ const normalized = normalizeBlockDocument(documentType, document);
346
+ return documentType === "page"
347
+ ? canonicalLocalizedPageDocumentBytes(normalized as LocalizedPageDocument)
348
+ : canonicalLocalizedRichTextDocumentBytes(
349
+ normalized as LocalizedRichTextDocument,
350
+ );
351
+ }
352
+
353
+ function identityArray(
354
+ value: JsonValue,
355
+ collection: string,
356
+ identity: string,
357
+ nestedCollection?: string,
358
+ nestedIdentity?: string,
359
+ ): unknown[] {
360
+ const object = jsonObject(value, "locale_identity:payload");
361
+ const children = object[collection];
362
+ if (!Array.isArray(children)) return [];
363
+ return children.map((child) => {
364
+ const item = jsonObject(child, "locale_identity:item");
365
+ if (!nestedCollection || !nestedIdentity) return item[identity];
366
+ const nested = item[nestedCollection];
367
+ return {
368
+ id: item[identity],
369
+ children: Array.isArray(nested)
370
+ ? nested.map(
371
+ (entry) =>
372
+ jsonObject(entry, "locale_identity:nested")[nestedIdentity],
373
+ )
374
+ : [],
375
+ };
376
+ });
377
+ }
378
+
379
+ /** Exact resident projections carry one locale wrapper for every base node. */
380
+ export function assertBlockRoomLocaleProjectionParity(yDocument: Y.Doc): void {
381
+ const root = yDocument.getMap<unknown>(BLOCK_ROOM_ROOT);
382
+ const baseNodes = yMap(root.get(BLOCK_ROOM_BASE_NODES), "base_nodes");
383
+ const localeNodes = yMap(
384
+ root.get(BLOCK_ROOM_LOCALE_OVERLAY),
385
+ "locale_overlay",
386
+ );
387
+ for (const [id, rawBase] of baseNodes.entries()) {
388
+ const base = yMap(rawBase, `base_node:${id}`);
389
+ const locale = yMap(localeNodes.get(id), `locale:${id}:missing`);
390
+ const family = stringValue(base.get("family"), `base_node:${id}:family`);
391
+ const kind = stringValue(base.get("kind"), `base_node:${id}:kind`);
392
+ if (locale.get("family") !== family || locale.get("kind") !== kind)
393
+ fail(`locale:${id}:identity_mismatch`);
394
+ const basePayload = fromYValue(base.get("payload"));
395
+ const localePayload = fromYValue(locale.get("payload"));
396
+ if (family === "rich_text" && kind === "table") {
397
+ const baseContent = jsonObject(
398
+ jsonObject(basePayload, `base_node:${id}:payload`).content,
399
+ `base_node:${id}:content`,
400
+ );
401
+ const localeContent = jsonObject(
402
+ jsonObject(localePayload, `locale:${id}:payload`).content,
403
+ `locale:${id}:content`,
404
+ );
405
+ const baseIdentity = identityArray(
406
+ baseContent,
407
+ "rows",
408
+ "id",
409
+ "cells",
410
+ "id",
411
+ );
412
+ const localeIdentity = identityArray(
413
+ localeContent,
414
+ "rows",
415
+ "rowId",
416
+ "cells",
417
+ "cellId",
418
+ );
419
+ if (JSON.stringify(baseIdentity) !== JSON.stringify(localeIdentity))
420
+ fail(`locale:${id}:table_identity_mismatch`);
421
+ }
422
+ if (family === "page_section" && kind === "immersiveScene") {
423
+ const baseIdentity = identityArray(basePayload, "units", "id");
424
+ const localeIdentity = identityArray(localePayload, "units", "unitId");
425
+ if (JSON.stringify(baseIdentity) !== JSON.stringify(localeIdentity))
426
+ fail(`locale:${id}:immersive_identity_mismatch`);
427
+ }
428
+ }
429
+ for (const id of localeNodes.keys()) {
430
+ if (!baseNodes.has(id)) fail(`locale:${id}:missing_base`);
431
+ }
432
+ }
@@ -0,0 +1,237 @@
1
+ import * as Y from "yjs";
2
+ import { type BlockRoomDocumentType, type JsonObject } from "./internal.ts";
3
+ import {
4
+ blockRoomBaseNodes,
5
+ blockRoomLocaleOverlay,
6
+ decodeCanonicalBlockRoom,
7
+ } from "./materialization.ts";
8
+ import {
9
+ roomDocumentType,
10
+ roomLocale,
11
+ roomLocaleRole,
12
+ roomSourceLocale,
13
+ } from "./room-access.ts";
14
+ import {
15
+ assertBlockRoomLocalePresenceNotRemoved,
16
+ assertBlockRoomLocaleValueChangesPresent,
17
+ blockRoomLocaleValueIsEncoded,
18
+ blockRoomLocaleValueTargetBlockId,
19
+ blockRoomPresentLocaleValues,
20
+ canonicalBlockRoomLocaleValueTarget,
21
+ } from "./locale-presence.ts";
22
+
23
+ function sameValue(left: unknown, right: unknown): boolean {
24
+ return JSON.stringify(left) === JSON.stringify(right);
25
+ }
26
+
27
+ export const BlockRoomLocaleChangeRejectionReason = {
28
+ NonSourceStructure: "NON_SOURCE_STRUCTURE",
29
+ RoomLocaleMismatch: "ROOM_LOCALE_MISMATCH",
30
+ NonSourceSharedField: "NON_SOURCE_SHARED_FIELD",
31
+ NonSourceFileRelation: "NON_SOURCE_FILE_RELATION",
32
+ NonSourceDocumentMetadata: "NON_SOURCE_DOCUMENT_METADATA",
33
+ } as const;
34
+
35
+ export type BlockRoomLocaleChangeRejectionReason =
36
+ (typeof BlockRoomLocaleChangeRejectionReason)[keyof typeof BlockRoomLocaleChangeRejectionReason];
37
+
38
+ export class BlockRoomLocaleChangeError extends Error {
39
+ readonly reason: BlockRoomLocaleChangeRejectionReason;
40
+
41
+ constructor(reason: BlockRoomLocaleChangeRejectionReason, detail: string) {
42
+ super(`block_room_invalid:${detail}`);
43
+ this.name = "BlockRoomLocaleChangeError";
44
+ this.reason = reason;
45
+ }
46
+ }
47
+
48
+ function reject(
49
+ reason: BlockRoomLocaleChangeRejectionReason,
50
+ detail: string,
51
+ ): never {
52
+ throw new BlockRoomLocaleChangeError(reason, detail);
53
+ }
54
+
55
+ function structure(nodes: readonly { payload: unknown }[]) {
56
+ return nodes.map((node) => {
57
+ const result: Partial<typeof node> = { ...node };
58
+ delete result.payload;
59
+ return result;
60
+ });
61
+ }
62
+
63
+ function withoutFileIdentities(value: unknown): unknown {
64
+ if (Array.isArray(value)) return value.map(withoutFileIdentities);
65
+ if (!value || typeof value !== "object") return value;
66
+ const result: JsonObject = {};
67
+ for (const [key, child] of Object.entries(value)) {
68
+ if (key === "activeFileId") continue;
69
+ result[key] = withoutFileIdentities(child) as never;
70
+ }
71
+ return result;
72
+ }
73
+
74
+ function hasRemovedEncodedLocaleValue(before: Y.Doc, after: Y.Doc): boolean {
75
+ return blockRoomPresentLocaleValues(before).some((target) => {
76
+ if (!blockRoomLocaleValueIsEncoded(before, target)) return false;
77
+ const blockId = blockRoomLocaleValueTargetBlockId(target);
78
+ const beforeOwner = blockRoomBaseNodes(before).get(blockId);
79
+ const afterOwner = blockRoomBaseNodes(after).get(blockId);
80
+ if (!(beforeOwner instanceof Y.Map) || !(afterOwner instanceof Y.Map))
81
+ return false;
82
+ if (
83
+ beforeOwner.get("family") !== afterOwner.get("family") ||
84
+ beforeOwner.get("kind") !== afterOwner.get("kind")
85
+ )
86
+ return false;
87
+ try {
88
+ canonicalBlockRoomLocaleValueTarget(after, target);
89
+ } catch {
90
+ return false;
91
+ }
92
+ return !blockRoomLocaleValueIsEncoded(after, target);
93
+ });
94
+ }
95
+
96
+ function containsNullishYValue(value: unknown): boolean {
97
+ if (value === null || value === undefined) return true;
98
+ if (value instanceof Y.Map)
99
+ return [...value.values()].some(containsNullishYValue);
100
+ if (value instanceof Y.Array)
101
+ return value.toArray().some(containsNullishYValue);
102
+ return false;
103
+ }
104
+
105
+ function localeOverlayContainsNullish(yDocument: Y.Doc): boolean {
106
+ return [...blockRoomLocaleOverlay(yDocument).values()].some(
107
+ containsNullishYValue,
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Validates an already-applied candidate room against its prior state.
113
+ * Target rooms may only change their locale overlay; their shared graph and
114
+ * exact-room identity remain immutable.
115
+ */
116
+ export function assertBlockRoomLocaleChangeAllowed(
117
+ before: Y.Doc,
118
+ after: Y.Doc,
119
+ documentType: BlockRoomDocumentType,
120
+ ): void {
121
+ if (
122
+ roomDocumentType(before) !== documentType ||
123
+ roomDocumentType(after) !== documentType
124
+ ) {
125
+ reject(
126
+ BlockRoomLocaleChangeRejectionReason.RoomLocaleMismatch,
127
+ "document_type",
128
+ );
129
+ }
130
+ const beforeSourceLocale = roomSourceLocale(before);
131
+ const beforeRoomLocale = roomLocale(before);
132
+ if (
133
+ roomSourceLocale(after) !== beforeSourceLocale ||
134
+ roomLocale(after) !== beforeRoomLocale ||
135
+ roomLocaleRole(after) !== roomLocaleRole(before)
136
+ ) {
137
+ reject(
138
+ BlockRoomLocaleChangeRejectionReason.RoomLocaleMismatch,
139
+ "locale_room:identity_changed",
140
+ );
141
+ }
142
+ const beforeRoot = before.getMap<unknown>("block-document");
143
+ const afterRoot = after.getMap<unknown>("block-document");
144
+ if (
145
+ beforeRoot.get("blockCatalogFingerprint") !==
146
+ afterRoot.get("blockCatalogFingerprint") ||
147
+ beforeRoot.get("profile") !== afterRoot.get("profile")
148
+ ) {
149
+ reject(
150
+ BlockRoomLocaleChangeRejectionReason.NonSourceDocumentMetadata,
151
+ "target_room:document_metadata_changed",
152
+ );
153
+ }
154
+ const targetRoom = roomLocaleRole(before) === "target";
155
+ if (localeOverlayContainsNullish(after)) {
156
+ reject(
157
+ BlockRoomLocaleChangeRejectionReason.NonSourceSharedField,
158
+ `${targetRoom ? "target_room" : "locale_room"}:locale_value_removed`,
159
+ );
160
+ }
161
+ let beforeSnapshot: ReturnType<typeof decodeCanonicalBlockRoom>;
162
+ let afterSnapshot: ReturnType<typeof decodeCanonicalBlockRoom>;
163
+ try {
164
+ beforeSnapshot = decodeCanonicalBlockRoom(before, documentType);
165
+ afterSnapshot = decodeCanonicalBlockRoom(after, documentType);
166
+ } catch (error) {
167
+ const projectionDetail =
168
+ error instanceof Error &&
169
+ error.message.includes("table_identity_mismatch")
170
+ ? "table_identity_mismatch"
171
+ : error instanceof Error &&
172
+ error.message.includes("immersive_identity_mismatch")
173
+ ? "immersive_identity_mismatch"
174
+ : "invalid_projection";
175
+ reject(
176
+ BlockRoomLocaleChangeRejectionReason.NonSourceStructure,
177
+ `${targetRoom ? "target_room" : "locale_room"}:${projectionDetail}`,
178
+ );
179
+ }
180
+ if (hasRemovedEncodedLocaleValue(before, after)) {
181
+ reject(
182
+ BlockRoomLocaleChangeRejectionReason.NonSourceSharedField,
183
+ `${targetRoom ? "target_room" : "locale_room"}:locale_value_removed`,
184
+ );
185
+ }
186
+ try {
187
+ assertBlockRoomLocalePresenceNotRemoved(before, after);
188
+ } catch {
189
+ reject(
190
+ BlockRoomLocaleChangeRejectionReason.NonSourceSharedField,
191
+ `${targetRoom ? "target_room" : "locale_room"}:locale_presence_removed`,
192
+ );
193
+ }
194
+ if (!targetRoom) {
195
+ try {
196
+ assertBlockRoomLocaleValueChangesPresent(before, after);
197
+ } catch {
198
+ reject(
199
+ BlockRoomLocaleChangeRejectionReason.NonSourceSharedField,
200
+ "locale_room:unmarked_locale_value_change",
201
+ );
202
+ }
203
+ return;
204
+ }
205
+ if (
206
+ !sameValue(
207
+ structure(beforeSnapshot.baseNodes),
208
+ structure(afterSnapshot.baseNodes),
209
+ )
210
+ )
211
+ reject(
212
+ BlockRoomLocaleChangeRejectionReason.NonSourceStructure,
213
+ "target_room:shared_graph_changed",
214
+ );
215
+ const beforePayloads = beforeSnapshot.baseNodes.map(({ payload }) => payload);
216
+ const afterPayloads = afterSnapshot.baseNodes.map(({ payload }) => payload);
217
+ if (!sameValue(beforePayloads, afterPayloads)) {
218
+ const fileOnly = sameValue(
219
+ withoutFileIdentities(beforePayloads),
220
+ withoutFileIdentities(afterPayloads),
221
+ );
222
+ reject(
223
+ fileOnly
224
+ ? BlockRoomLocaleChangeRejectionReason.NonSourceFileRelation
225
+ : BlockRoomLocaleChangeRejectionReason.NonSourceSharedField,
226
+ "target_room:shared_graph_changed",
227
+ );
228
+ }
229
+ try {
230
+ assertBlockRoomLocaleValueChangesPresent(before, after);
231
+ } catch {
232
+ reject(
233
+ BlockRoomLocaleChangeRejectionReason.NonSourceSharedField,
234
+ "target_room:unmarked_locale_value_change",
235
+ );
236
+ }
237
+ }