/** * @factory/collaboration — the block type ↔ Yjs node mapping (TASK-0092 * R4: "the Phase-4 node set registered with the y-prosemirror mapping — each * Phase-4 node → its Yjs counterpart; the mapping table is the single source * for the codec's node list — a new block type added in Phase 4+ must * extend the mapping or the round-trip test fails, the structural gate"). * * Derivation: the table is **built from `BLOCK_CONTENT_SCHEMAS`** * (`@factory/shared`) at module load — the shared DTO is the single source * of truth, so a block type added to the shared enum automatically extends * this mapping (the structural gate test asserts the two agree exactly). * * Per-block-type split of `content` keys: * - **text slots** — `text` / `language` / `caption` / `title` → a * `Y.XmlText` named slot (CRDT-mergeable text — concurrent typing merges * character-level); * - **rows slot** — table `rows` → a `Y.Array` of `Y.Array` * (row/cell text merges; structural row moves are CRDT); * - **attrs** — everything else (`fileId`, `url`, `height`, `width`, * `databaseId`, `viewId`, `chartId`, `formId`, `sourceBlockId`, * `pageId`, `direction`) → plain node attrs (atomic metadata — the * doc §4.4 rule: record values / file metadata are not CRDT-ed). */ import { BLOCK_CONTENT_SCHEMAS, blockTypeSchema, type BlockType, } from "@factory/shared/dto"; import type { z } from "zod"; /** The Y.XmlElement type name for every block (one node type, attrs carry * the block type — the phase-07 codec rule). */ export const BLOCK_Y_NODE_TYPE = "factoryBlock"; /** Content keys that become `Y.XmlText` named slots. */ export const TEXT_SLOT_KEYS: readonly string[] = ["text", "language", "caption", "title"]; /** The table rows content key (becomes a `Y.Array` named slot). */ export const ROWS_KEY = "rows"; export interface BlockYMapping { readonly blockType: BlockType; /** Y.XmlElement type name (always `factoryBlock`). */ readonly yNodeType: string; /** Content keys mapped to `Y.XmlText` slots. */ readonly textSlots: readonly string[]; /** True for `table` (the `rows` content key → a rows slot). */ readonly hasRows: boolean; /** Content keys mapped to plain node attrs. */ readonly attrKeys: readonly string[]; } function schemaKeys(schema: z.ZodTypeAny): readonly string[] { const shape = (schema as { shape?: Record }).shape; if (shape === undefined || shape === null) return []; return Object.keys(shape); } function mappingFor(type: BlockType): BlockYMapping { const keys = schemaKeys(BLOCK_CONTENT_SCHEMAS[type]); const textSlots = keys.filter((k) => TEXT_SLOT_KEYS.includes(k)); const hasRows = keys.includes(ROWS_KEY); const attrKeys = keys.filter((k) => !TEXT_SLOT_KEYS.includes(k) && k !== ROWS_KEY); return { blockType: type, yNodeType: BLOCK_Y_NODE_TYPE, textSlots, hasRows, attrKeys }; } /** * The full mapping table, keyed by block type. Built from * `BLOCK_CONTENT_SCHEMAS` — one entry per block type (30), in the shared * enum's declaration order. */ export const BLOCK_Y_MAPPINGS: Readonly> = Object.fromEntries( blockTypeSchema.options.map((type) => [type, mappingFor(type)]), ) as Readonly>; /** Look up a block type's mapping (throws on an unknown type — the gate). */ export function mappingForType(type: string): BlockYMapping { const mapping = BLOCK_Y_MAPPINGS[type as BlockType]; if (mapping === undefined) { throw new Error( `mapping: block type "${type}" is not in the Yjs mapping table — ` + `extend BLOCK_CONTENT_SCHEMAS (shared) to add it`, ); } return mapping; }