UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

4 lines • 78.6 kB
{ "version": 3, "sources": ["../../src/lib/TLSyncRoom.ts"], "sourcesContent": ["import { transact, transaction } from '@tldraw/state'\nimport {\n\tAtomMap,\n\tIdOf,\n\tMigrationFailureReason,\n\tRecordType,\n\tSerializedSchema,\n\tStoreSchema,\n\tUnknownRecord,\n} from '@tldraw/store'\nimport { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from '@tldraw/tlschema'\nimport {\n\tIndexKey,\n\tResult,\n\tassert,\n\tassertExists,\n\texhaustiveSwitchError,\n\tgetOwnProperty,\n\thasOwnProperty,\n\tisEqual,\n\tisNativeStructuredClone,\n\tobjectMapEntriesIterable,\n\tstructuredClone,\n} from '@tldraw/utils'\nimport { createNanoEvents } from 'nanoevents'\nimport {\n\tRoomSession,\n\tRoomSessionState,\n\tSESSION_IDLE_TIMEOUT,\n\tSESSION_REMOVAL_WAIT_TIME,\n\tSESSION_START_WAIT_TIME,\n} from './RoomSession'\nimport { TLSyncLog } from './TLSocketRoom'\nimport { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from './TLSyncClient'\nimport {\n\tNetworkDiff,\n\tObjectDiff,\n\tRecordOp,\n\tRecordOpType,\n\tValueOpType,\n\tapplyObjectDiff,\n\tdiffRecord,\n} from './diff'\nimport { findMin } from './findMin'\nimport { interval } from './interval'\nimport {\n\tTLIncompatibilityReason,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentDataEvent,\n\tTLSocketServerSentEvent,\n\tgetTlsyncProtocolVersion,\n} from './protocol'\n\n/**\n * WebSocket interface for server-side room connections. This defines the contract\n * that socket implementations must follow to work with TLSyncRoom.\n *\n * @internal\n */\nexport interface TLRoomSocket<R extends UnknownRecord> {\n\t/**\n\t * Whether the socket connection is currently open and ready to send messages.\n\t */\n\tisOpen: boolean\n\t/**\n\t * Send a message to the connected client through this socket.\n\t *\n\t * @param msg - The server-sent event message to transmit\n\t */\n\tsendMessage(msg: TLSocketServerSentEvent<R>): void\n\t/**\n\t * Close the socket connection with optional status code and reason.\n\t *\n\t * @param code - WebSocket close code (optional)\n\t * @param reason - Human-readable close reason (optional)\n\t */\n\tclose(code?: number, reason?: string): void\n}\n\n/**\n * The maximum number of tombstone records to keep in memory. Tombstones track\n * deleted records to prevent resurrection during sync operations.\n * @public\n */\nexport const MAX_TOMBSTONES = 3000\n\n/**\n * The number of tombstones to delete when pruning occurs after reaching MAX_TOMBSTONES.\n * This buffer prevents frequent pruning operations.\n * @public\n */\nexport const TOMBSTONE_PRUNE_BUFFER_SIZE = 300\n\n/**\n * The minimum time interval (in milliseconds) between sending batched data messages\n * to clients. This debouncing prevents overwhelming clients with rapid updates.\n * @public\n */\nexport const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1000 / 60\n\nconst timeSince = (time: number) => Date.now() - time\n\n/**\n * Represents the state of a document record within a sync room, including\n * its current data and the clock value when it was last modified.\n *\n * @internal\n */\nexport class DocumentState<R extends UnknownRecord> {\n\t/**\n\t * Create a DocumentState instance without validating the record data.\n\t * Used for performance when validation has already been performed.\n\t *\n\t * @param state - The record data\n\t * @param lastChangedClock - Clock value when this record was last modified\n\t * @param recordType - The record type definition for validation\n\t * @returns A new DocumentState instance\n\t */\n\tstatic createWithoutValidating<R extends UnknownRecord>(\n\t\tstate: R,\n\t\tlastChangedClock: number,\n\t\trecordType: RecordType<R, any>\n\t): DocumentState<R> {\n\t\treturn new DocumentState(state, lastChangedClock, recordType)\n\t}\n\n\t/**\n\t * Create a DocumentState instance with validation of the record data.\n\t *\n\t * @param state - The record data to validate\n\t * @param lastChangedClock - Clock value when this record was last modified\n\t * @param recordType - The record type definition for validation\n\t * @returns Result containing the DocumentState or validation error\n\t */\n\tstatic createAndValidate<R extends UnknownRecord>(\n\t\tstate: R,\n\t\tlastChangedClock: number,\n\t\trecordType: RecordType<R, any>\n\t): Result<DocumentState<R>, Error> {\n\t\ttry {\n\t\t\trecordType.validate(state)\n\t\t} catch (error: any) {\n\t\t\treturn Result.err(error)\n\t\t}\n\t\treturn Result.ok(new DocumentState(state, lastChangedClock, recordType))\n\t}\n\n\tprivate constructor(\n\t\tpublic readonly state: R,\n\t\tpublic readonly lastChangedClock: number,\n\t\tprivate readonly recordType: RecordType<R, any>\n\t) {}\n\n\t/**\n\t * Replace the current state with new state and calculate the diff.\n\t *\n\t * @param state - The new record state\n\t * @param clock - The new clock value\n\t * @param legacyAppendMode - If true, string append operations will be converted to Put operations\n\t * @returns Result containing the diff and new DocumentState, or null if no changes, or validation error\n\t */\n\treplaceState(\n\t\tstate: R,\n\t\tclock: number,\n\t\tlegacyAppendMode = false\n\t): Result<[ObjectDiff, DocumentState<R>] | null, Error> {\n\t\tconst diff = diffRecord(this.state, state, legacyAppendMode)\n\t\tif (!diff) return Result.ok(null)\n\t\ttry {\n\t\t\tthis.recordType.validate(state)\n\t\t} catch (error: any) {\n\t\t\treturn Result.err(error)\n\t\t}\n\t\treturn Result.ok([diff, new DocumentState(state, clock, this.recordType)])\n\t}\n\t/**\n\t * Apply a diff to the current state and return the resulting changes.\n\t *\n\t * @param diff - The object diff to apply\n\t * @param clock - The new clock value\n\t * @param legacyAppendMode - If true, string append operations will be converted to Put operations\n\t * @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error\n\t */\n\tmergeDiff(\n\t\tdiff: ObjectDiff,\n\t\tclock: number,\n\t\tlegacyAppendMode = false\n\t): Result<[ObjectDiff, DocumentState<R>] | null, Error> {\n\t\tconst newState = applyObjectDiff(this.state, diff)\n\t\treturn this.replaceState(newState, clock, legacyAppendMode)\n\t}\n}\n\n/**\n * Snapshot of a room's complete state that can be persisted and restored.\n * Contains all documents, tombstones, and metadata needed to reconstruct the room.\n *\n * @public\n */\nexport interface RoomSnapshot {\n\t/**\n\t * The current logical clock value for the room\n\t */\n\tclock: number\n\t/**\n\t * Clock value when document data was last changed (optional for backwards compatibility)\n\t */\n\tdocumentClock?: number\n\t/**\n\t * Array of all document records with their last modification clocks\n\t */\n\tdocuments: Array<{ state: UnknownRecord; lastChangedClock: number }>\n\t/**\n\t * Map of deleted record IDs to their deletion clock values (optional)\n\t */\n\ttombstones?: Record<string, number>\n\t/**\n\t * Clock value where tombstone history begins - older deletions are not tracked (optional)\n\t */\n\ttombstoneHistoryStartsAtClock?: number\n\t/**\n\t * Serialized schema used when creating this snapshot (optional)\n\t */\n\tschema?: SerializedSchema\n}\n\nfunction getDocumentClock(snapshot: RoomSnapshot) {\n\tif (typeof snapshot.documentClock === 'number') {\n\t\treturn snapshot.documentClock\n\t}\n\tlet max = 0\n\tfor (const doc of snapshot.documents) {\n\t\tmax = Math.max(max, doc.lastChangedClock)\n\t}\n\tfor (const tombstone of Object.values(snapshot.tombstones ?? {})) {\n\t\tmax = Math.max(max, tombstone)\n\t}\n\treturn max\n}\n\n/**\n * A collaborative workspace that manages multiple client sessions and synchronizes\n * document changes between them. The room serves as the authoritative source for\n * all document state and handles conflict resolution, schema migrations, and\n * real-time data distribution.\n *\n * @example\n * ```ts\n * const room = new TLSyncRoom({\n * schema: mySchema,\n * onDataChange: () => saveToDatabase(room.getSnapshot()),\n * onPresenceChange: () => updateLiveCursors()\n * })\n *\n * // Handle new client connections\n * room.handleNewSession({\n * sessionId: 'user-123',\n * socket: webSocketAdapter,\n * meta: { userId: '123', name: 'Alice' },\n * isReadonly: false\n * })\n * ```\n *\n * @internal\n */\nexport class TLSyncRoom<R extends UnknownRecord, SessionMeta> {\n\t// A table of connected clients\n\treadonly sessions = new Map<string, RoomSession<R, SessionMeta>>()\n\n\t// eslint-disable-next-line local/prefer-class-methods\n\tpruneSessions = () => {\n\t\tfor (const client of this.sessions.values()) {\n\t\t\tswitch (client.state) {\n\t\t\t\tcase RoomSessionState.Connected: {\n\t\t\t\t\tconst hasTimedOut = timeSince(client.lastInteractionTime) > SESSION_IDLE_TIMEOUT\n\t\t\t\t\tif (hasTimedOut || !client.socket.isOpen) {\n\t\t\t\t\t\tthis.cancelSession(client.sessionId)\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tcase RoomSessionState.AwaitingConnectMessage: {\n\t\t\t\t\tconst hasTimedOut = timeSince(client.sessionStartTime) > SESSION_START_WAIT_TIME\n\t\t\t\t\tif (hasTimedOut || !client.socket.isOpen) {\n\t\t\t\t\t\t// remove immediately\n\t\t\t\t\t\tthis.removeSession(client.sessionId)\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tcase RoomSessionState.AwaitingRemoval: {\n\t\t\t\t\tconst hasTimedOut = timeSince(client.cancellationTime) > SESSION_REMOVAL_WAIT_TIME\n\t\t\t\t\tif (hasTimedOut) {\n\t\t\t\t\t\tthis.removeSession(client.sessionId)\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tdefault: {\n\t\t\t\t\texhaustiveSwitchError(client)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate disposables: Array<() => void> = [interval(this.pruneSessions, 2000)]\n\n\tprivate _isClosed = false\n\n\t/**\n\t * Close the room and clean up all resources. Disconnects all sessions\n\t * and stops background processes.\n\t */\n\tclose() {\n\t\tthis.disposables.forEach((d) => d())\n\t\tthis.sessions.forEach((session) => {\n\t\t\tsession.socket.close()\n\t\t})\n\t\tthis._isClosed = true\n\t}\n\n\t/**\n\t * Check if the room has been closed and is no longer accepting connections.\n\t *\n\t * @returns True if the room is closed\n\t */\n\tisClosed() {\n\t\treturn this._isClosed\n\t}\n\n\treadonly events = createNanoEvents<{\n\t\troom_became_empty(): void\n\t\tsession_removed(args: { sessionId: string; meta: SessionMeta }): void\n\t}>()\n\n\t// Values associated with each uid (must be serializable).\n\t/** @internal */\n\tdocuments: AtomMap<string, DocumentState<R>>\n\ttombstones: AtomMap<string, number>\n\n\t// this clock should start higher than the client, to make sure that clients who sync with their\n\t// initial lastServerClock value get the full state\n\t// in this case clients will start with 0, and the server will start with 1\n\tclock: number\n\tdocumentClock: number\n\ttombstoneHistoryStartsAtClock: number\n\t// map from record id to clock upon deletion\n\n\treadonly serializedSchema: SerializedSchema\n\n\treadonly documentTypes: Set<string>\n\treadonly presenceType: RecordType<R, any> | null\n\tprivate log?: TLSyncLog\n\tpublic readonly schema: StoreSchema<R, any>\n\tprivate onDataChange?(): void\n\tprivate onPresenceChange?(): void\n\n\tconstructor(opts: {\n\t\tlog?: TLSyncLog\n\t\tschema: StoreSchema<R, any>\n\t\tsnapshot?: RoomSnapshot\n\t\tonDataChange?(): void\n\t\tonPresenceChange?(): void\n\t}) {\n\t\tthis.schema = opts.schema\n\t\tlet snapshot = opts.snapshot\n\t\tthis.log = opts.log\n\t\tthis.onDataChange = opts.onDataChange\n\t\tthis.onPresenceChange = opts.onPresenceChange\n\n\t\tassert(\n\t\t\tisNativeStructuredClone,\n\t\t\t'TLSyncRoom is supposed to run either on Cloudflare Workers' +\n\t\t\t\t'or on a 18+ version of Node.js, which both support the native structuredClone API'\n\t\t)\n\n\t\t// do a json serialization cycle to make sure the schema has no 'undefined' values\n\t\tthis.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()))\n\n\t\tthis.documentTypes = new Set(\n\t\t\tObject.values<RecordType<R, any>>(this.schema.types)\n\t\t\t\t.filter((t) => t.scope === 'document')\n\t\t\t\t.map((t) => t.typeName)\n\t\t)\n\n\t\tconst presenceTypes = new Set(\n\t\t\tObject.values<RecordType<R, any>>(this.schema.types).filter((t) => t.scope === 'presence')\n\t\t)\n\n\t\tif (presenceTypes.size > 1) {\n\t\t\tthrow new Error(\n\t\t\t\t`TLSyncRoom: exactly zero or one presence type is expected, but found ${presenceTypes.size}`\n\t\t\t)\n\t\t}\n\n\t\tthis.presenceType = presenceTypes.values().next()?.value ?? null\n\n\t\tif (!snapshot) {\n\t\t\tsnapshot = {\n\t\t\t\tclock: 0,\n\t\t\t\tdocumentClock: 0,\n\t\t\t\tdocuments: [\n\t\t\t\t\t{\n\t\t\t\t\t\tstate: DocumentRecordType.create({ id: TLDOCUMENT_ID }),\n\t\t\t\t\t\tlastChangedClock: 0,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tstate: PageRecordType.create({ name: 'Page 1', index: 'a1' as IndexKey }),\n\t\t\t\t\t\tlastChangedClock: 0,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t}\n\t\t}\n\n\t\tthis.clock = snapshot.clock\n\n\t\tlet didIncrementClock = false\n\t\tconst ensureClockDidIncrement = (_reason: string) => {\n\t\t\tif (!didIncrementClock) {\n\t\t\t\tdidIncrementClock = true\n\t\t\t\tthis.clock++\n\t\t\t}\n\t\t}\n\n\t\tthis.tombstones = new AtomMap(\n\t\t\t'room tombstones',\n\t\t\tobjectMapEntriesIterable(snapshot.tombstones ?? {})\n\t\t)\n\t\tthis.documents = new AtomMap(\n\t\t\t'room documents',\n\t\t\tfunction* (this: TLSyncRoom<R, SessionMeta>) {\n\t\t\t\tfor (const doc of snapshot.documents) {\n\t\t\t\t\tif (this.documentTypes.has(doc.state.typeName)) {\n\t\t\t\t\t\tyield [\n\t\t\t\t\t\t\tdoc.state.id,\n\t\t\t\t\t\t\tDocumentState.createWithoutValidating<R>(\n\t\t\t\t\t\t\t\tdoc.state as R,\n\t\t\t\t\t\t\t\tdoc.lastChangedClock,\n\t\t\t\t\t\t\t\tassertExists(getOwnProperty(this.schema.types, doc.state.typeName))\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t] as const\n\t\t\t\t\t} else {\n\t\t\t\t\t\tensureClockDidIncrement('doc type was not doc type')\n\t\t\t\t\t\tthis.tombstones.set(doc.state.id, this.clock)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}.call(this)\n\t\t)\n\n\t\tthis.tombstoneHistoryStartsAtClock =\n\t\t\tsnapshot.tombstoneHistoryStartsAtClock ?? findMin(this.tombstones.values()) ?? this.clock\n\n\t\tif (this.tombstoneHistoryStartsAtClock === 0) {\n\t\t\t// Before this comment was added, new clients would send '0' as their 'lastServerClock'\n\t\t\t// which was technically an error because clocks start at 0, but the error didn't manifest\n\t\t\t// because we initialized tombstoneHistoryStartsAtClock to 1 and then never updated it.\n\t\t\t// Now that we handle tombstoneHistoryStartsAtClock properly we need to increment it here to make sure old\n\t\t\t// clients still get data when they connect. This if clause can be deleted after a few months.\n\t\t\tthis.tombstoneHistoryStartsAtClock++\n\t\t}\n\n\t\ttransact(() => {\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\t\tconst schema = snapshot.schema ?? this.schema.serializeEarliestVersion()\n\n\t\t\tconst migrationsToApply = this.schema.getMigrationsSince(schema)\n\t\t\tassert(migrationsToApply.ok, 'Failed to get migrations')\n\n\t\t\tif (migrationsToApply.value.length > 0) {\n\t\t\t\t// only bother allocating a snapshot if there are migrations to apply\n\t\t\t\tconst store = {} as Record<IdOf<R>, R>\n\t\t\t\tfor (const [k, v] of this.documents.entries()) {\n\t\t\t\t\tstore[k as IdOf<R>] = v.state\n\t\t\t\t}\n\n\t\t\t\tconst migrationResult = this.schema.migrateStoreSnapshot(\n\t\t\t\t\t{ store, schema },\n\t\t\t\t\t{ mutateInputStore: true }\n\t\t\t\t)\n\n\t\t\t\tif (migrationResult.type === 'error') {\n\t\t\t\t\t// TODO: Fault tolerance\n\t\t\t\t\tthrow new Error('Failed to migrate: ' + migrationResult.reason)\n\t\t\t\t}\n\n\t\t\t\t// use for..in to iterate over the keys of the object because it consumes less memory than\n\t\t\t\t// Object.entries\n\t\t\t\tfor (const id in migrationResult.value) {\n\t\t\t\t\tif (!Object.prototype.hasOwnProperty.call(migrationResult.value, id)) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tconst r = migrationResult.value[id as keyof typeof migrationResult.value]\n\t\t\t\t\tconst existing = this.documents.get(id)\n\t\t\t\t\tif (!existing || !isEqual(existing.state, r)) {\n\t\t\t\t\t\t// record was added or updated during migration\n\t\t\t\t\t\tensureClockDidIncrement('record was added or updated during migration')\n\t\t\t\t\t\tthis.documents.set(\n\t\t\t\t\t\t\tr.id,\n\t\t\t\t\t\t\tDocumentState.createWithoutValidating(\n\t\t\t\t\t\t\t\tr,\n\t\t\t\t\t\t\t\tthis.clock,\n\t\t\t\t\t\t\t\tassertExists(getOwnProperty(this.schema.types, r.typeName)) as any\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tfor (const id of this.documents.keys()) {\n\t\t\t\t\tif (!migrationResult.value[id as keyof typeof migrationResult.value]) {\n\t\t\t\t\t\t// record was removed during migration\n\t\t\t\t\t\tensureClockDidIncrement('record was removed during migration')\n\t\t\t\t\t\tthis.tombstones.set(id, this.clock)\n\t\t\t\t\t\tthis.documents.delete(id)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.pruneTombstones()\n\t\t})\n\n\t\tif (didIncrementClock) {\n\t\t\tthis.documentClock = this.clock\n\t\t\topts.onDataChange?.()\n\t\t} else {\n\t\t\tthis.documentClock = getDocumentClock(snapshot)\n\t\t}\n\t}\n\n\tprivate didSchedulePrune = true\n\t// eslint-disable-next-line local/prefer-class-methods\n\tprivate pruneTombstones = () => {\n\t\tthis.didSchedulePrune = false\n\t\t// avoid blocking any pending responses\n\t\tif (this.tombstones.size > MAX_TOMBSTONES) {\n\t\t\tconst entries = Array.from(this.tombstones.entries())\n\t\t\t// sort entries in ascending order by clock\n\t\t\tentries.sort((a, b) => a[1] - b[1])\n\t\t\tlet idx = entries.length - 1 - MAX_TOMBSTONES + TOMBSTONE_PRUNE_BUFFER_SIZE\n\t\t\tconst cullClock = entries[idx++][1]\n\t\t\twhile (idx < entries.length && entries[idx][1] === cullClock) {\n\t\t\t\tidx++\n\t\t\t}\n\t\t\t// trim off the first bunch\n\t\t\tconst keysToDelete = entries.slice(0, idx).map(([key]) => key)\n\n\t\t\tthis.tombstoneHistoryStartsAtClock = cullClock + 1\n\t\t\tthis.tombstones.deleteMany(keysToDelete)\n\t\t}\n\t}\n\n\tprivate getDocument(id: string) {\n\t\treturn this.documents.get(id)\n\t}\n\n\tprivate addDocument(id: string, state: R, clock: number): Result<void, Error> {\n\t\tif (this.tombstones.has(id)) {\n\t\t\tthis.tombstones.delete(id)\n\t\t}\n\t\tconst createResult = DocumentState.createAndValidate(\n\t\t\tstate,\n\t\t\tclock,\n\t\t\tassertExists(getOwnProperty(this.schema.types, state.typeName))\n\t\t)\n\t\tif (!createResult.ok) return createResult\n\t\tthis.documents.set(id, createResult.value)\n\t\treturn Result.ok(undefined)\n\t}\n\n\tprivate removeDocument(id: string, clock: number) {\n\t\tthis.documents.delete(id)\n\t\tthis.tombstones.set(id, clock)\n\t\tif (!this.didSchedulePrune) {\n\t\t\tthis.didSchedulePrune = true\n\t\t\tsetTimeout(this.pruneTombstones, 0)\n\t\t}\n\t}\n\n\t/**\n\t * Get a complete snapshot of the current room state that can be persisted\n\t * and later used to restore the room.\n\t *\n\t * @returns Room snapshot containing all documents, tombstones, and metadata\n\t * @example\n\t * ```ts\n\t * const snapshot = room.getSnapshot()\n\t * await database.saveRoomSnapshot(roomId, snapshot)\n\t *\n\t * // Later, restore from snapshot\n\t * const restoredRoom = new TLSyncRoom({\n\t * schema: mySchema,\n\t * snapshot: snapshot\n\t * })\n\t * ```\n\t */\n\tgetSnapshot(): RoomSnapshot {\n\t\tconst tombstones = Object.fromEntries(this.tombstones.entries())\n\t\tconst documents = []\n\t\tfor (const doc of this.documents.values()) {\n\t\t\tif (this.documentTypes.has(doc.state.typeName)) {\n\t\t\t\tdocuments.push({\n\t\t\t\t\tstate: doc.state,\n\t\t\t\t\tlastChangedClock: doc.lastChangedClock,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\treturn {\n\t\t\tclock: this.clock,\n\t\t\tdocumentClock: this.documentClock,\n\t\t\ttombstones,\n\t\t\ttombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock,\n\t\t\tschema: this.serializedSchema,\n\t\t\tdocuments,\n\t\t}\n\t}\n\n\t/**\n\t * Send a message to a particular client. Debounces data events\n\t *\n\t * @param sessionId - The id of the session to send the message to.\n\t * @param message - The message to send.\n\t */\n\tprivate sendMessage(\n\t\tsessionId: string,\n\t\tmessage: TLSocketServerSentEvent<R> | TLSocketServerSentDataEvent<R>\n\t) {\n\t\tconst session = this.sessions.get(sessionId)\n\t\tif (!session) {\n\t\t\tthis.log?.warn?.('Tried to send message to unknown session', message.type)\n\t\t\treturn\n\t\t}\n\t\tif (session.state !== RoomSessionState.Connected) {\n\t\t\tthis.log?.warn?.('Tried to send message to disconnected client', message.type)\n\t\t\treturn\n\t\t}\n\t\tif (session.socket.isOpen) {\n\t\t\tif (message.type !== 'patch' && message.type !== 'push_result') {\n\t\t\t\t// this is not a data message\n\t\t\t\tif (message.type !== 'pong') {\n\t\t\t\t\t// non-data messages like \"connect\" might still need to be ordered correctly with\n\t\t\t\t\t// respect to data messages, so it's better to flush just in case\n\t\t\t\t\tthis._flushDataMessages(sessionId)\n\t\t\t\t}\n\t\t\t\tsession.socket.sendMessage(message)\n\t\t\t} else {\n\t\t\t\tif (session.debounceTimer === null) {\n\t\t\t\t\t// this is the first message since the last flush, don't delay it\n\t\t\t\t\tsession.socket.sendMessage({ type: 'data', data: [message] })\n\n\t\t\t\t\tsession.debounceTimer = setTimeout(\n\t\t\t\t\t\t() => this._flushDataMessages(sessionId),\n\t\t\t\t\t\tDATA_MESSAGE_DEBOUNCE_INTERVAL\n\t\t\t\t\t)\n\t\t\t\t} else {\n\t\t\t\t\tsession.outstandingDataMessages.push(message)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tthis.cancelSession(session.sessionId)\n\t\t}\n\t}\n\n\t// needs to accept sessionId and not a session because the session might be dead by the time\n\t// the timer fires\n\t_flushDataMessages(sessionId: string) {\n\t\tconst session = this.sessions.get(sessionId)\n\n\t\tif (!session || session.state !== RoomSessionState.Connected) {\n\t\t\treturn\n\t\t}\n\n\t\tsession.debounceTimer = null\n\n\t\tif (session.outstandingDataMessages.length > 0) {\n\t\t\tsession.socket.sendMessage({ type: 'data', data: session.outstandingDataMessages })\n\t\t\tsession.outstandingDataMessages.length = 0\n\t\t}\n\t}\n\n\t/** @internal */\n\tprivate removeSession(sessionId: string, fatalReason?: string) {\n\t\tconst session = this.sessions.get(sessionId)\n\t\tif (!session) {\n\t\t\tthis.log?.warn?.('Tried to remove unknown session')\n\t\t\treturn\n\t\t}\n\n\t\tthis.sessions.delete(sessionId)\n\n\t\tconst presence = this.getDocument(session.presenceId ?? '')\n\n\t\ttry {\n\t\t\tif (fatalReason) {\n\t\t\t\tsession.socket.close(TLSyncErrorCloseEventCode, fatalReason)\n\t\t\t} else {\n\t\t\t\tsession.socket.close()\n\t\t\t}\n\t\t} catch {\n\t\t\t// noop, calling .close() multiple times is fine\n\t\t}\n\n\t\tif (presence) {\n\t\t\tthis.documents.delete(session.presenceId!)\n\n\t\t\tthis.broadcastPatch({\n\t\t\t\tdiff: { [session.presenceId!]: [RecordOpType.Remove] },\n\t\t\t\tsourceSessionId: sessionId,\n\t\t\t})\n\t\t}\n\n\t\tthis.events.emit('session_removed', { sessionId, meta: session.meta })\n\t\tif (this.sessions.size === 0) {\n\t\t\tthis.events.emit('room_became_empty')\n\t\t}\n\t}\n\n\tprivate cancelSession(sessionId: string) {\n\t\tconst session = this.sessions.get(sessionId)\n\t\tif (!session) {\n\t\t\treturn\n\t\t}\n\n\t\tif (session.state === RoomSessionState.AwaitingRemoval) {\n\t\t\tthis.log?.warn?.('Tried to cancel session that is already awaiting removal')\n\t\t\treturn\n\t\t}\n\n\t\tthis.sessions.set(sessionId, {\n\t\t\tstate: RoomSessionState.AwaitingRemoval,\n\t\t\tsessionId,\n\t\t\tpresenceId: session.presenceId,\n\t\t\tsocket: session.socket,\n\t\t\tcancellationTime: Date.now(),\n\t\t\tmeta: session.meta,\n\t\t\tisReadonly: session.isReadonly,\n\t\t\trequiresLegacyRejection: session.requiresLegacyRejection,\n\t\t\tsupportsStringAppend: session.supportsStringAppend,\n\t\t})\n\n\t\ttry {\n\t\t\tsession.socket.close()\n\t\t} catch {\n\t\t\t// noop, calling .close() multiple times is fine\n\t\t}\n\t}\n\n\t/**\n\t * Broadcast a patch to all connected clients except the one with the sessionId provided.\n\t * Automatically handles schema migration for clients on different versions.\n\t *\n\t * @param message - The broadcast message\n\t * - diff - The network diff to broadcast to all clients\n\t * - sourceSessionId - Optional ID of the session that originated this change (excluded from broadcast)\n\t * @returns This room instance for method chaining\n\t * @example\n\t * ```ts\n\t * room.broadcastPatch({\n\t * diff: { 'shape:123': [RecordOpType.Put, newShapeData] },\n\t * sourceSessionId: 'user-456' // This user won't receive the broadcast\n\t * })\n\t * ```\n\t */\n\tbroadcastPatch(message: { diff: NetworkDiff<R>; sourceSessionId?: string }) {\n\t\tconst { diff, sourceSessionId } = message\n\t\tthis.sessions.forEach((session) => {\n\t\t\tif (session.state !== RoomSessionState.Connected) return\n\t\t\tif (sourceSessionId === session.sessionId) return\n\t\t\tif (!session.socket.isOpen) {\n\t\t\t\tthis.cancelSession(session.sessionId)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst res = this.migrateDiffForSession(session.serializedSchema, diff)\n\n\t\t\tif (!res.ok) {\n\t\t\t\t// disconnect client and send incompatibility error\n\t\t\t\tthis.rejectSession(\n\t\t\t\t\tsession.sessionId,\n\t\t\t\t\tres.error === MigrationFailureReason.TargetVersionTooNew\n\t\t\t\t\t\t? TLSyncErrorCloseEventReason.SERVER_TOO_OLD\n\t\t\t\t\t\t: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.sendMessage(session.sessionId, {\n\t\t\t\ttype: 'patch',\n\t\t\t\tdiff: res.value,\n\t\t\t\tserverClock: this.clock,\n\t\t\t})\n\t\t})\n\t\treturn this\n\t}\n\n\t/**\n\t * Send a custom message to a connected client. Useful for application-specific\n\t * communication that doesn't involve document synchronization.\n\t *\n\t * @param sessionId - The ID of the session to send the message to\n\t * @param data - The custom payload to send (will be JSON serialized)\n\t * @example\n\t * ```ts\n\t * // Send a custom notification\n\t * room.sendCustomMessage('user-123', {\n\t * type: 'notification',\n\t * message: 'Document saved successfully'\n\t * })\n\t *\n\t * // Send user-specific data\n\t * room.sendCustomMessage('user-456', {\n\t * type: 'user_permissions',\n\t * canEdit: true,\n\t * canDelete: false\n\t * })\n\t * ```\n\t */\n\tsendCustomMessage(sessionId: string, data: any): void {\n\t\tthis.sendMessage(sessionId, { type: 'custom', data })\n\t}\n\n\t/**\n\t * Register a new client session with the room. The session will be in an awaiting\n\t * state until it sends a connect message with protocol handshake.\n\t *\n\t * @param opts - Session configuration\n\t * - sessionId - Unique identifier for this session\n\t * - socket - WebSocket adapter for communication\n\t * - meta - Application-specific metadata for this session\n\t * - isReadonly - Whether this session can modify documents\n\t * @returns This room instance for method chaining\n\t * @example\n\t * ```ts\n\t * room.handleNewSession({\n\t * sessionId: crypto.randomUUID(),\n\t * socket: new WebSocketAdapter(ws),\n\t * meta: { userId: '123', name: 'Alice', avatar: 'url' },\n\t * isReadonly: !hasEditPermission\n\t * })\n\t * ```\n\t *\n\t * @internal\n\t */\n\thandleNewSession(opts: {\n\t\tsessionId: string\n\t\tsocket: TLRoomSocket<R>\n\t\tmeta: SessionMeta\n\t\tisReadonly: boolean\n\t}) {\n\t\tconst { sessionId, socket, meta, isReadonly } = opts\n\t\tconst existing = this.sessions.get(sessionId)\n\t\tthis.sessions.set(sessionId, {\n\t\t\tstate: RoomSessionState.AwaitingConnectMessage,\n\t\t\tsessionId,\n\t\t\tsocket,\n\t\t\tpresenceId: existing?.presenceId ?? this.presenceType?.createId() ?? null,\n\t\t\tsessionStartTime: Date.now(),\n\t\t\tmeta,\n\t\t\tisReadonly: isReadonly ?? false,\n\t\t\t// this gets set later during handleConnectMessage\n\t\t\trequiresLegacyRejection: false,\n\t\t\tsupportsStringAppend: true,\n\t\t})\n\t\treturn this\n\t}\n\n\t/**\n\t * Checks if all connected sessions support string append operations (protocol version 8+).\n\t * If any client is on an older version, returns false to enable legacy append mode.\n\t *\n\t * @returns True if all connected sessions are on protocol version 8 or higher\n\t */\n\tgetCanEmitStringAppend(): boolean {\n\t\tfor (const session of this.sessions.values()) {\n\t\t\tif (session.state === RoomSessionState.Connected) {\n\t\t\t\tif (!session.supportsStringAppend) {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n\t/**\n\t * When we send a diff to a client, if that client is on a lower version than us, we need to make\n\t * the diff compatible with their version. At the moment this means migrating each affected record\n\t * to the client's version and sending the whole record again. We can optimize this later by\n\t * keeping the previous versions of records around long enough to recalculate these diffs for\n\t * older client versions.\n\t */\n\tprivate migrateDiffForSession(\n\t\tserializedSchema: SerializedSchema,\n\t\tdiff: NetworkDiff<R>\n\t): Result<NetworkDiff<R>, MigrationFailureReason> {\n\t\t// TODO: optimize this by recalculating patches using the previous versions of records\n\n\t\t// when the client connects we check whether the schema is identical and make sure\n\t\t// to use the same object reference so that === works on this line\n\t\tif (serializedSchema === this.serializedSchema) {\n\t\t\treturn Result.ok(diff)\n\t\t}\n\n\t\tconst result: NetworkDiff<R> = {}\n\t\tfor (const [id, op] of objectMapEntriesIterable(diff)) {\n\t\t\tif (op[0] === RecordOpType.Remove) {\n\t\t\t\tresult[id] = op\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tconst doc = this.getDocument(id)\n\t\t\tif (!doc) {\n\t\t\t\treturn Result.err(MigrationFailureReason.TargetVersionTooNew)\n\t\t\t}\n\t\t\tconst migrationResult = this.schema.migratePersistedRecord(\n\t\t\t\tdoc.state,\n\t\t\t\tserializedSchema,\n\t\t\t\t'down'\n\t\t\t)\n\n\t\t\tif (migrationResult.type === 'error') {\n\t\t\t\treturn Result.err(migrationResult.reason)\n\t\t\t}\n\n\t\t\tresult[id] = [RecordOpType.Put, migrationResult.value]\n\t\t}\n\n\t\treturn Result.ok(result)\n\t}\n\n\t/**\n\t * Process an incoming message from a client session. Handles connection requests,\n\t * data synchronization pushes, and ping/pong for connection health.\n\t *\n\t * @param sessionId - The ID of the session that sent the message\n\t * @param message - The client message to process\n\t * @example\n\t * ```ts\n\t * // Typically called by WebSocket message handlers\n\t * websocket.onMessage((data) => {\n\t * const message = JSON.parse(data)\n\t * room.handleMessage(sessionId, message)\n\t * })\n\t * ```\n\t */\n\tasync handleMessage(sessionId: string, message: TLSocketClientSentEvent<R>) {\n\t\tconst session = this.sessions.get(sessionId)\n\t\tif (!session) {\n\t\t\tthis.log?.warn?.('Received message from unknown session')\n\t\t\treturn\n\t\t}\n\t\tswitch (message.type) {\n\t\t\tcase 'connect': {\n\t\t\t\treturn this.handleConnectRequest(session, message)\n\t\t\t}\n\t\t\tcase 'push': {\n\t\t\t\treturn this.handlePushRequest(session, message)\n\t\t\t}\n\t\t\tcase 'ping': {\n\t\t\t\tif (session.state === RoomSessionState.Connected) {\n\t\t\t\t\tsession.lastInteractionTime = Date.now()\n\t\t\t\t}\n\t\t\t\treturn this.sendMessage(session.sessionId, { type: 'pong' })\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\texhaustiveSwitchError(message)\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Reject and disconnect a session due to incompatibility or other fatal errors.\n\t * Sends appropriate error messages before closing the connection.\n\t *\n\t * @param sessionId - The session to reject\n\t * @param fatalReason - The reason for rejection (optional)\n\t * @example\n\t * ```ts\n\t * // Reject due to version mismatch\n\t * room.rejectSession('user-123', TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)\n\t *\n\t * // Reject due to permission issue\n\t * room.rejectSession('user-456', 'Insufficient permissions')\n\t * ```\n\t */\n\trejectSession(sessionId: string, fatalReason?: TLSyncErrorCloseEventReason | string) {\n\t\tconst session = this.sessions.get(sessionId)\n\t\tif (!session) return\n\t\tif (!fatalReason) {\n\t\t\tthis.removeSession(sessionId)\n\t\t\treturn\n\t\t}\n\t\tif (session.requiresLegacyRejection) {\n\t\t\ttry {\n\t\t\t\tif (session.socket.isOpen) {\n\t\t\t\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\t\t\t\tlet legacyReason: TLIncompatibilityReason\n\t\t\t\t\tswitch (fatalReason) {\n\t\t\t\t\t\tcase TLSyncErrorCloseEventReason.CLIENT_TOO_OLD:\n\t\t\t\t\t\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\t\t\t\t\t\tlegacyReason = TLIncompatibilityReason.ClientTooOld\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\tcase TLSyncErrorCloseEventReason.SERVER_TOO_OLD:\n\t\t\t\t\t\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\t\t\t\t\t\tlegacyReason = TLIncompatibilityReason.ServerTooOld\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\tcase TLSyncErrorCloseEventReason.INVALID_RECORD:\n\t\t\t\t\t\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\t\t\t\t\t\tlegacyReason = TLIncompatibilityReason.InvalidRecord\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\t\t\t\t\t\tlegacyReason = TLIncompatibilityReason.InvalidOperation\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tsession.socket.sendMessage({\n\t\t\t\t\t\ttype: 'incompatibility_error',\n\t\t\t\t\t\treason: legacyReason,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// noop\n\t\t\t} finally {\n\t\t\t\tthis.removeSession(sessionId)\n\t\t\t}\n\t\t} else {\n\t\t\tthis.removeSession(sessionId, fatalReason)\n\t\t}\n\t}\n\n\tprivate handleConnectRequest(\n\t\tsession: RoomSession<R, SessionMeta>,\n\t\tmessage: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }>\n\t) {\n\t\t// if the protocol versions don't match, disconnect the client\n\t\t// we will eventually want to try to make our protocol backwards compatible to some degree\n\t\t// and have a MIN_PROTOCOL_VERSION constant that the TLSyncRoom implements support for\n\t\tlet theirProtocolVersion = message.protocolVersion\n\t\t// 5 is the same as 6\n\t\tif (theirProtocolVersion === 5) {\n\t\t\ttheirProtocolVersion = 6\n\t\t}\n\t\t// 6 is almost the same as 7\n\t\tsession.requiresLegacyRejection = theirProtocolVersion === 6\n\t\tif (theirProtocolVersion === 6) {\n\t\t\ttheirProtocolVersion++\n\t\t}\n\t\tif (theirProtocolVersion === 7) {\n\t\t\ttheirProtocolVersion++\n\t\t\tsession.supportsStringAppend = false\n\t\t}\n\n\t\tif (theirProtocolVersion == null || theirProtocolVersion < getTlsyncProtocolVersion()) {\n\t\t\tthis.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)\n\t\t\treturn\n\t\t} else if (theirProtocolVersion > getTlsyncProtocolVersion()) {\n\t\t\tthis.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.SERVER_TOO_OLD)\n\t\t\treturn\n\t\t}\n\t\t// If the client's store is at a different version to ours, it could cause corruption.\n\t\t// We should disconnect the client and ask them to refresh.\n\t\tif (message.schema == null) {\n\t\t\tthis.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)\n\t\t\treturn\n\t\t}\n\t\tconst migrations = this.schema.getMigrationsSince(message.schema)\n\t\t// if the client's store is at a different version to ours, we can't support them\n\t\tif (!migrations.ok || migrations.value.some((m) => m.scope === 'store' || !m.down)) {\n\t\t\tthis.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)\n\t\t\treturn\n\t\t}\n\n\t\tconst sessionSchema = isEqual(message.schema, this.serializedSchema)\n\t\t\t? this.serializedSchema\n\t\t\t: message.schema\n\n\t\tconst connect = async (msg: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) => {\n\t\t\tthis.sessions.set(session.sessionId, {\n\t\t\t\tstate: RoomSessionState.Connected,\n\t\t\t\tsessionId: session.sessionId,\n\t\t\t\tpresenceId: session.presenceId,\n\t\t\t\tsocket: session.socket,\n\t\t\t\tserializedSchema: sessionSchema,\n\t\t\t\tlastInteractionTime: Date.now(),\n\t\t\t\tdebounceTimer: null,\n\t\t\t\toutstandingDataMessages: [],\n\t\t\t\tsupportsStringAppend: session.supportsStringAppend,\n\t\t\t\tmeta: session.meta,\n\t\t\t\tisReadonly: session.isReadonly,\n\t\t\t\trequiresLegacyRejection: session.requiresLegacyRejection,\n\t\t\t})\n\t\t\tthis.sendMessage(session.sessionId, msg)\n\t\t}\n\n\t\ttransaction((rollback) => {\n\t\t\tif (\n\t\t\t\t// if the client requests changes since a time before we have tombstone history, send them the full state\n\t\t\t\tmessage.lastServerClock < this.tombstoneHistoryStartsAtClock ||\n\t\t\t\t// similarly, if they ask for a time we haven't reached yet, send them the full state\n\t\t\t\t// this will only happen if the DB is reset (or there is no db) and the server restarts\n\t\t\t\t// or if the server exits/crashes with unpersisted changes\n\t\t\t\tmessage.lastServerClock > this.clock\n\t\t\t) {\n\t\t\t\tconst diff: NetworkDiff<R> = {}\n\t\t\t\tfor (const [id, doc] of this.documents.entries()) {\n\t\t\t\t\tif (id !== session.presenceId) {\n\t\t\t\t\t\tdiff[id] = [RecordOpType.Put, doc.state]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst migrated = this.migrateDiffForSession(sessionSchema, diff)\n\t\t\t\tif (!migrated.ok) {\n\t\t\t\t\trollback()\n\t\t\t\t\tthis.rejectSession(\n\t\t\t\t\t\tsession.sessionId,\n\t\t\t\t\t\tmigrated.error === MigrationFailureReason.TargetVersionTooNew\n\t\t\t\t\t\t\t? TLSyncErrorCloseEventReason.SERVER_TOO_OLD\n\t\t\t\t\t\t\t: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD\n\t\t\t\t\t)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconnect({\n\t\t\t\t\ttype: 'connect',\n\t\t\t\t\tconnectRequestId: message.connectRequestId,\n\t\t\t\t\thydrationType: 'wipe_all',\n\t\t\t\t\tprotocolVersion: getTlsyncProtocolVersion(),\n\t\t\t\t\tschema: this.schema.serialize(),\n\t\t\t\t\tserverClock: this.clock,\n\t\t\t\t\tdiff: migrated.value,\n\t\t\t\t\tisReadonly: session.isReadonly,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\t// calculate the changes since the time the client last saw\n\t\t\t\tconst diff: NetworkDiff<R> = {}\n\t\t\t\tfor (const doc of this.documents.values()) {\n\t\t\t\t\tif (doc.lastChangedClock > message.lastServerClock) {\n\t\t\t\t\t\tdiff[doc.state.id] = [RecordOpType.Put, doc.state]\n\t\t\t\t\t} else if (this.presenceType?.isId(doc.state.id) && doc.state.id !== session.presenceId) {\n\t\t\t\t\t\tdiff[doc.state.id] = [RecordOpType.Put, doc.state]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (const [id, deletedAtClock] of this.tombstones.entries()) {\n\t\t\t\t\tif (deletedAtClock > message.lastServerClock) {\n\t\t\t\t\t\tdiff[id] = [RecordOpType.Remove]\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst migrated = this.migrateDiffForSession(sessionSchema, diff)\n\t\t\t\tif (!migrated.ok) {\n\t\t\t\t\trollback()\n\t\t\t\t\tthis.rejectSession(\n\t\t\t\t\t\tsession.sessionId,\n\t\t\t\t\t\tmigrated.error === MigrationFailureReason.TargetVersionTooNew\n\t\t\t\t\t\t\t? TLSyncErrorCloseEventReason.SERVER_TOO_OLD\n\t\t\t\t\t\t\t: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD\n\t\t\t\t\t)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tconnect({\n\t\t\t\t\ttype: 'connect',\n\t\t\t\t\tconnectRequestId: message.connectRequestId,\n\t\t\t\t\thydrationType: 'wipe_presence',\n\t\t\t\t\tschema: this.schema.serialize(),\n\t\t\t\t\tprotocolVersion: getTlsyncProtocolVersion(),\n\t\t\t\t\tserverClock: this.clock,\n\t\t\t\t\tdiff: migrated.value,\n\t\t\t\t\tisReadonly: session.isReadonly,\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t}\n\n\tprivate handlePushRequest(\n\t\tsession: RoomSession<R, SessionMeta> | null,\n\t\tmessage: Extract<TLSocketClientSentEvent<R>, { type: 'push' }>\n\t) {\n\t\t// We must be connected to handle push requests\n\t\tif (session && session.state !== RoomSessionState.Connected) {\n\t\t\treturn\n\t\t}\n\n\t\t// update the last interaction time\n\t\tif (session) {\n\t\t\tsession.lastInteractionTime = Date.now()\n\t\t}\n\n\t\t// increment the clock for this push\n\t\tthis.clock++\n\n\t\tconst initialDocumentClock = this.documentClock\n\t\tlet didPresenceChange = false\n\t\ttransaction((rollback) => {\n\t\t\tconst legacyAppendMode = !this.getCanEmitStringAppend()\n\t\t\t// collect actual ops that resulted from the push\n\t\t\t// these will be broadcast to other users\n\t\t\tinterface ActualChanges {\n\t\t\t\tdiff: NetworkDiff<R> | null\n\t\t\t}\n\t\t\tconst docChanges: ActualChanges = { diff: null }\n\t\t\tconst presenceChanges: ActualChanges = { diff: null }\n\n\t\t\tconst propagateOp = (changes: ActualChanges, id: string, op: RecordOp<R>) => {\n\t\t\t\tif (!changes.diff) changes.diff = {}\n\t\t\t\tchanges.diff[id] = op\n\t\t\t}\n\n\t\t\tconst fail = (\n\t\t\t\treason: TLSyncErrorCloseEventReason,\n\t\t\t\tunderlyingError?: Error\n\t\t\t): Result<void, void> => {\n\t\t\t\trollback()\n\t\t\t\tif (session) {\n\t\t\t\t\tthis.rejectSession(session.sessionId, reason)\n\t\t\t\t} else {\n\t\t\t\t\tthrow new Error('failed to apply changes: ' + reason, underlyingError)\n\t\t\t\t}\n\t\t\t\tif (typeof process !== 'undefined' && process.env.NODE_ENV !== 'test') {\n\t\t\t\t\tthis.log?.error?.('failed to apply push', reason, message, underlyingError)\n\t\t\t\t}\n\t\t\t\treturn Result.err(undefined)\n\t\t\t}\n\n\t\t\tconst addDocument = (changes: ActualChanges, id: string, _state: R): Result<void, void> => {\n\t\t\t\tconst res = session\n\t\t\t\t\t? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')\n\t\t\t\t\t: { type: 'success' as const, value: _state }\n\t\t\t\tif (res.type === 'error') {\n\t\t\t\t\treturn fail(\n\t\t\t\t\t\tres.reason === MigrationFailureReason.TargetVersionTooOld // target version is our version\n\t\t\t\t\t\t\t? TLSyncErrorCloseEventReason.SERVER_TOO_OLD\n\t\t\t\t\t\t\t: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst { value: state } = res\n\n\t\t\t\t// Get the existing document, if any\n\t\t\t\tconst doc = this.getDocument(id)\n\n\t\t\t\tif (doc) {\n\t\t\t\t\t// If there's an existing document, replace it with the new state\n\t\t\t\t\t// but propagate a diff rather than the entire value\n\t\t\t\t\tconst diff = doc.replaceState(state, this.clock, legacyAppendMode)\n\t\t\t\t\tif (!diff.ok) {\n\t\t\t\t\t\treturn fail(TLSyncErrorCloseEventReason.INVALID_RECORD)\n\t\t\t\t\t}\n\t\t\t\t\tif (diff.value) {\n\t\t\t\t\t\tthis.documents.set(id, diff.value[1])\n\t\t\t\t\t\tpropagateOp(changes, id, [RecordOpType.Patch, diff.value[0]])\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Otherwise, if we don't already have a document with this id\n\t\t\t\t\t// create the document and propagate the put op\n\t\t\t\t\tconst result = this.addDocument(id, state, this.clock)\n\t\t\t\t\tif (!result.ok) {\n\t\t\t\t\t\treturn fail(TLSyncErrorCloseEventReason.INVALID_RECORD)\n\t\t\t\t\t}\n\t\t\t\t\tpropagateOp(changes, id, [RecordOpType.Put, state])\n\t\t\t\t}\n\n\t\t\t\treturn Result.ok(undefined)\n\t\t\t}\n\n\t\t\tconst patchDocument = (\n\t\t\t\tchanges: ActualChanges,\n\t\t\t\tid: string,\n\t\t\t\tpatch: ObjectDiff\n\t\t\t): Result<void, void> => {\n\t\t\t\t// if it was already deleted, there's no need to apply the patch\n\t\t\t\tconst doc = this.getDocument(id)\n\t\t\t\tif (!doc) return Result.ok(undefined)\n\t\t\t\t// If the client's version of the record is older than ours,\n\t\t\t\t// we apply the patch to the downgraded version of the record\n\t\t\t\tconst downgraded = session\n\t\t\t\t\t? this.schema.migratePersistedRecord(doc.state, session.serializedSchema, 'down')\n\t\t\t\t\t: { type: 'success' as const, value: doc.state }\n\t\t\t\tif (downgraded.type === 'error') {\n\t\t\t\t\treturn fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)\n\t\t\t\t}\n\n\t\t\t\tif (downgraded.value === doc.state) {\n\t\t\t\t\t// If the versions are compatible, apply the patch and propagate the patch op\n\t\t\t\t\tconst diff = doc.mergeDiff(patch, this.clock, legacyAppendMode)\n\t\t\t\t\tif (!diff.ok) {\n\t\t\t\t\t\treturn fail(TLSyncErrorCloseEventReason.INVALID_RECORD)\n\t\t\t\t\t}\n\t\t\t\t\tif (diff.value) {\n\t\t\t\t\t\tthis.documents.set(id, diff.value[1])\n\t\t\t\t\t\tpropagateOp(changes, id, [RecordOpType.Patch, diff.value[0]])\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// need to apply the patch to the downgraded version and then upgrade it\n\n\t\t\t\t\t// apply the patch to the downgraded version\n\t\t\t\t\tconst patched = applyObjectDiff(downgraded.value, patch)\n\t\t\t\t\t// then upgrade the patched version and use that as the new state\n\t\t\t\t\tconst upgraded = session\n\t\t\t\t\t\t? this.schema.migratePersistedRecord(patched, session.serializedSchema, 'up')\n\t\t\t\t\t\t: { type: 'success' as const, value: patched }\n\t\t\t\t\t// If the client's version is too old, we'll hit an error\n\t\t\t\t\tif (upgraded.type === 'error') {\n\t\t\t\t\t\treturn fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)\n\t\t\t\t\t}\n\t\t\t\t\t// replace the state with the upgraded version and propagate the patch op\n\t\t\t\t\tconst diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode)\n\t\t\t\t\tif (!diff.ok) {\n\t\t\t\t\t\treturn fail(TLSyncErrorCloseEventReason.INVALID_RECORD)\n\t\t\t\t\t}\n\t\t\t\t\tif (diff.value) {\n\t\t\t\t\t\tthis.documents.set(id, diff.value[1])\n\t\t\t\t\t\tpropagateOp(changes, id, [RecordOpType.Patch, diff.value[0]])\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn Result.ok(undefined)\n\t\t\t}\n\n\t\t\tconst { clientClock } = message\n\n\t\t\tif (this.presenceType && session?.presenceId && 'presence' in message && message.presence) {\n\t\t\t\tif (!session) throw new Error('session is required for presence pushes')\n\t\t\t\t// The push request was for the presence scope.\n\t\t\t\tconst id = session.presenceId\n\t\t\t\tconst [type, val] = message.presence\n\t\t\t\tconst { typeName } = this.presenceType\n\t\t\t\tswitch (type) {\n\t\t\t\t\tcase RecordOpType.Put: {\n\t\t\t\t\t\t// Try to put the document. If it fails, stop here.\n\t\t\t\t\t\tconst res = addDocument(presenceChanges, id, { ...val, id, typeName })\n\t\t\t\t\t\t// if res.ok is false here then we already called `fail` and we should stop immediately\n\t\t\t\t\t\tif (!res.ok) return\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tcase RecordOpType.Patch: {\n\t\t\t\t\t\t// Try to patch the document. If it fails, stop here.\n\t\t\t\t\t\tconst res = patchDocument(presenceChanges, id, {\n\t\t\t\t\t\t\t...val,\n\t\t\t\t\t\t\tid: [ValueOpType.Put, id],\n\t\t\t\t\t\t\ttypeName: [ValueOpType.Put, typeName],\n\t\t\t\t\t\t})\n\t\t\t\t\t\t// if res.ok is false here then we already called `fail` and we should stop immediately\n\t\t\t\t\t\tif (!res.ok) return\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (message.diff && !session?.isReadonly) {\n\t\t\t\t// The push request was for the document scope.\n\t\t\t\tfor (const [id, op] of objectMapEntriesIterable(message.diff!)) {\n\t\t\t\t\tswitch (op[0]) {\n\t\t\t\t\t\tcase RecordOpType.Put: {\n\t\t\t\t\t\t\t// Try to add the document.\n\t\t\t\t\t\t\t// If we're putting a record with a type that we don't recognize, fail\n\t\t\t\t\t\t\tif (!this.documentTypes.has(op[1].typeName)) {\n\t\t\t\t\t\t\t\treturn fail(TLSyncErrorCloseEventReason.INVALID_RECORD)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tconst res = addDocument(docChanges, id, op[1])\n\t\t\t\t\t\t\t// if res.ok is false here then we already called `fail` and we should stop immediately\n\t\t\t\t\t\t\tif (!res.ok) return\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcase RecordOpType.Patch: {\n\t\t\t\t\t\t\t// Try to patch the document. If it fails, stop here.\n\t\t\t\t\t\t\tconst res = patchDocument(docChanges, id, op[1])\n\t\t\t\t\t\t\t// if res.ok is false here then we already called `fail` and we should stop immediately\n\t\t\t\t\t\t\tif (!res.ok) return\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcase RecordOpType.Remove: {\n\t\t\t\t\t\t\tconst doc = this.getDocument(id)\n\t\t\t\t\t\t\tif (!doc) {\n\t\t\t\t\t\t\t\t// If the doc was already deleted, don't do anything, no need to propagate a delete op\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t// Delete the document and propagate the delete op\n\t\t\t\t\t\t\tthis.removeDocument(id, this.clock)\n\t\t\t\t\t\t\t// Schedule a pruneTombstones call to happen on the next call stack\n\t\t\t\t\t\t\tpropagateOp(docChanges, id, op)\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Let the client know what action to take based on the results of the push\n\t\t\tif (\n\t\t\t\t// if there was only a presence push, the client doesn't need to do anything aside from\n\t\t\t\t// shift the push request.\n\t\t\t\t!message.diff ||\n\t\t\t\tisEqual(docChanges.diff, message.diff)\n\t\t\t) {\n\t\t\t\t// COMMIT\n\t\t\t\t// Applying the client's changes had the exact same effect on the server as\n\t\t\t\t// they had on the client, so the client should keep the diff\n\t\t\t\tif (session) {\n\t\t\t\t\tthis.sendMessage(session.sessionId, {\n\t\t\t\t\t\ttype: 'push_result',\n\t\t\t\t\t\tserverClock: this.clock,\n\t\t\t\t\t\tclientClock,\n\t\t\t\t\t\taction: 'commit',\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else if (!docChanges.diff) {\n\t\t\t\t// DISCARD\n\t\t\t\t// Applying the client's changes had no effect, so the client should drop the diff\n\t\t\t\tif (session) {\n\t\t\t\t\tthis.sendMessage(session.sessionId, {\n\t\t\t\t\t\ttype: 'push_result',\n\t\t\t\t\t\tserverClock: this.clock,\n\t\t\t\t\t\tclientClock,\n\t\t\t\t\t\taction: 'discard',\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// REBASE\n\t\t\t\t// Applying the client's changes had a different non-empty effect on the server,\n\t\t\t\t// so the client should rebase with our gold-standard / authoritative diff.\n\t\t\t\t// First we need to migrate the diff to the client's version\n\t\t\t\tif (session) {\n\t\t\t\t\tconst migrateResult = this.migrateDiffForSession(\n\t\t\t\t\t\tsession.serializedSchema,\n\t\t\t\t\t\tdocChanges.diff\n\t\t\t\t\t)\n\t\t\t\t\tif (!migrateResult