@tldraw/sync-core 4.2.0-next.f100cedfc45b → 4.3.0-canary.d8da2a99f394

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.
@@ -30,6 +30,70 @@ import { UnknownRecord } from '@tldraw/store';
30
30
 
31
31
  /* Excluded from this release type: getTlsyncProtocolVersion */
32
32
 
33
+ /**
34
+ * Assembles chunked JSON messages back into complete objects.
35
+ * Handles both regular JSON messages and chunked messages created by the chunk() function.
36
+ * Maintains internal state to track partially received chunked messages.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const assembler = new JsonChunkAssembler()
41
+ *
42
+ * // Handle regular JSON message
43
+ * const result1 = assembler.handleMessage('{"hello": "world"}')
44
+ * // Returns: { data: { hello: "world" }, stringified: '{"hello": "world"}' }
45
+ *
46
+ * // Handle chunked message
47
+ * assembler.handleMessage('1_hello') // Returns: null (partial)
48
+ * const result2 = assembler.handleMessage('0_ world')
49
+ * // Returns: { data: "hello world", stringified: "hello world" }
50
+ * ```
51
+ *
52
+ * @public
53
+ */
54
+ export declare class JsonChunkAssembler {
55
+ /**
56
+ * Current assembly state - either 'idle' or tracking chunks being received
57
+ */
58
+ state: 'idle' | {
59
+ chunksReceived: string[];
60
+ totalChunks: number;
61
+ };
62
+ /**
63
+ * Processes a single message, which can be either a complete JSON object or a chunk.
64
+ * For complete JSON objects (starting with '\{'), parses immediately.
65
+ * For chunks (prefixed with "\{number\}_"), accumulates until all chunks received.
66
+ *
67
+ * @param msg - The message to process, either JSON or chunk format
68
+ * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
69
+ * - `\{ data: object, stringified: string \}` - Successfully parsed complete message
70
+ * - `\{ error: Error \}` - Parse error or invalid chunk sequence
71
+ * - `null` - Chunk received but more chunks expected
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * const assembler = new JsonChunkAssembler()
76
+ *
77
+ * // Complete JSON message
78
+ * const result = assembler.handleMessage('{"key": "value"}')
79
+ * if (result && 'data' in result) {
80
+ * console.log(result.data) // { key: "value" }
81
+ * }
82
+ *
83
+ * // Chunked message sequence
84
+ * assembler.handleMessage('2_hel') // null - more chunks expected
85
+ * assembler.handleMessage('1_lo ') // null - more chunks expected
86
+ * assembler.handleMessage('0_wor') // { data: "hello wor", stringified: "hello wor" }
87
+ * ```
88
+ */
89
+ handleMessage(msg: string): {
90
+ data: object;
91
+ stringified: string;
92
+ } | {
93
+ error: Error;
94
+ } | null;
95
+ }
96
+
33
97
  /* Excluded from this release type: NetworkDiff */
34
98
 
35
99
  /* Excluded from this release type: ObjectDiff */
@@ -65,6 +129,8 @@ export declare type OmitVoid<T, KS extends keyof T = keyof T> = {
65
129
 
66
130
  /* Excluded from this release type: RoomSession */
67
131
 
132
+ /* Excluded from this release type: RoomSessionBase */
133
+
68
134
  /* Excluded from this release type: RoomSessionState */
69
135
 
70
136
  /**
package/dist-cjs/index.js CHANGED
@@ -20,6 +20,7 @@ var index_exports = {};
20
20
  __export(index_exports, {
21
21
  ClientWebSocketAdapter: () => import_ClientWebSocketAdapter.ClientWebSocketAdapter,
22
22
  DocumentState: () => import_TLSyncRoom.DocumentState,
23
+ JsonChunkAssembler: () => import_chunk.JsonChunkAssembler,
23
24
  ReconnectManager: () => import_ClientWebSocketAdapter.ReconnectManager,
24
25
  RecordOpType: () => import_diff.RecordOpType,
25
26
  RoomSessionState: () => import_RoomSession.RoomSessionState,
@@ -50,7 +51,7 @@ var import_TLSyncClient = require("./lib/TLSyncClient");
50
51
  var import_TLSyncRoom = require("./lib/TLSyncRoom");
51
52
  (0, import_utils.registerTldrawLibraryVersion)(
52
53
  "@tldraw/sync-core",
53
- "4.2.0-next.f100cedfc45b",
54
+ "4.3.0-canary.d8da2a99f394",
54
55
  "cjs"
55
56
  );
56
57
  //# sourceMappingURL=index.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["import { registerTldrawLibraryVersion } from '@tldraw/utils'\nexport { chunk } from './lib/chunk'\nexport { ClientWebSocketAdapter, ReconnectManager } from './lib/ClientWebSocketAdapter'\nexport {\n\tapplyObjectDiff,\n\tdiffRecord,\n\tgetNetworkDiff,\n\tRecordOpType,\n\tValueOpType,\n\ttype AppendOp,\n\ttype DeleteOp,\n\ttype NetworkDiff,\n\ttype ObjectDiff,\n\ttype PatchOp,\n\ttype PutOp,\n\ttype RecordOp,\n\ttype ValueOp,\n} from './lib/diff'\nexport {\n\tgetTlsyncProtocolVersion,\n\tTLIncompatibilityReason,\n\ttype TLConnectRequest,\n\ttype TLPingRequest,\n\ttype TLPushRequest,\n\ttype TLSocketClientSentEvent,\n\ttype TLSocketServerSentDataEvent,\n\ttype TLSocketServerSentEvent,\n} from './lib/protocol'\nexport { RoomSessionState, type RoomSession } from './lib/RoomSession'\nexport type { PersistedRoomSnapshotForSupabase } from './lib/server-types'\nexport type { WebSocketMinimal } from './lib/ServerSocketAdapter'\nexport { TLRemoteSyncError } from './lib/TLRemoteSyncError'\nexport { TLSocketRoom, type OmitVoid, type TLSyncLog } from './lib/TLSocketRoom'\nexport {\n\tTLSyncClient,\n\tTLSyncErrorCloseEventCode,\n\tTLSyncErrorCloseEventReason,\n\ttype SubscribingFn,\n\ttype TLCustomMessageHandler,\n\ttype TLPersistentClientSocket,\n\ttype TLPersistentClientSocketStatus,\n\ttype TLPresenceMode,\n\ttype TLSocketStatusChangeEvent,\n\ttype TLSocketStatusListener,\n} from './lib/TLSyncClient'\nexport {\n\tDocumentState,\n\tTLSyncRoom,\n\ttype RoomSnapshot,\n\ttype RoomStoreMethods,\n\ttype TLRoomSocket,\n} from './lib/TLSyncRoom'\n\nregisterTldrawLibraryVersion(\n\t(globalThis as any).TLDRAW_LIBRARY_NAME,\n\t(globalThis as any).TLDRAW_LIBRARY_VERSION,\n\t(globalThis as any).TLDRAW_LIBRARY_MODULES\n)\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA6C;AAC7C,mBAAsB;AACtB,oCAAyD;AACzD,kBAcO;AACP,sBASO;AACP,yBAAmD;AAGnD,+BAAkC;AAClC,0BAA4D;AAC5D,0BAWO;AACP,wBAMO;AAAA,IAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AACF;",
4
+ "sourcesContent": ["import { registerTldrawLibraryVersion } from '@tldraw/utils'\nexport { chunk, JsonChunkAssembler } from './lib/chunk'\nexport { ClientWebSocketAdapter, ReconnectManager } from './lib/ClientWebSocketAdapter'\nexport {\n\tapplyObjectDiff,\n\tdiffRecord,\n\tgetNetworkDiff,\n\tRecordOpType,\n\tValueOpType,\n\ttype AppendOp,\n\ttype DeleteOp,\n\ttype NetworkDiff,\n\ttype ObjectDiff,\n\ttype PatchOp,\n\ttype PutOp,\n\ttype RecordOp,\n\ttype ValueOp,\n} from './lib/diff'\nexport {\n\tgetTlsyncProtocolVersion,\n\tTLIncompatibilityReason,\n\ttype TLConnectRequest,\n\ttype TLPingRequest,\n\ttype TLPushRequest,\n\ttype TLSocketClientSentEvent,\n\ttype TLSocketServerSentDataEvent,\n\ttype TLSocketServerSentEvent,\n} from './lib/protocol'\nexport { RoomSessionState, type RoomSession, type RoomSessionBase } from './lib/RoomSession'\nexport type { PersistedRoomSnapshotForSupabase } from './lib/server-types'\nexport type { WebSocketMinimal } from './lib/ServerSocketAdapter'\nexport { TLRemoteSyncError } from './lib/TLRemoteSyncError'\nexport { TLSocketRoom, type OmitVoid, type TLSyncLog } from './lib/TLSocketRoom'\nexport {\n\tTLSyncClient,\n\tTLSyncErrorCloseEventCode,\n\tTLSyncErrorCloseEventReason,\n\ttype SubscribingFn,\n\ttype TLCustomMessageHandler,\n\ttype TLPersistentClientSocket,\n\ttype TLPersistentClientSocketStatus,\n\ttype TLPresenceMode,\n\ttype TLSocketStatusChangeEvent,\n\ttype TLSocketStatusListener,\n} from './lib/TLSyncClient'\nexport {\n\tDocumentState,\n\tTLSyncRoom,\n\ttype RoomSnapshot,\n\ttype RoomStoreMethods,\n\ttype TLRoomSocket,\n} from './lib/TLSyncRoom'\n\nregisterTldrawLibraryVersion(\n\t(globalThis as any).TLDRAW_LIBRARY_NAME,\n\t(globalThis as any).TLDRAW_LIBRARY_VERSION,\n\t(globalThis as any).TLDRAW_LIBRARY_MODULES\n)\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA6C;AAC7C,mBAA0C;AAC1C,oCAAyD;AACzD,kBAcO;AACP,sBASO;AACP,yBAAyE;AAGzE,+BAAkC;AAClC,0BAA4D;AAC5D,0BAWO;AACP,wBAMO;AAAA,IAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AACF;",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/RoomSession.ts"],
4
- "sourcesContent": ["import { SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport { TLRoomSocket } from './TLSyncRoom'\nimport { TLSocketServerSentDataEvent } from './protocol'\n\n/**\n * Enumeration of possible states for a room session during its lifecycle.\n *\n * Room sessions progress through these states as clients connect, authenticate,\n * and disconnect from collaborative rooms.\n *\n * @internal\n */\nexport const RoomSessionState = {\n\t/** Session is waiting for the initial connect message from the client */\n\tAwaitingConnectMessage: 'awaiting-connect-message',\n\t/** Session is disconnected but waiting for final cleanup before removal */\n\tAwaitingRemoval: 'awaiting-removal',\n\t/** Session is fully connected and actively synchronizing */\n\tConnected: 'connected',\n} as const\n\n/**\n * Type representing the possible states a room session can be in.\n *\n * @example\n * ```ts\n * const sessionState: RoomSessionState = RoomSessionState.Connected\n * if (sessionState === RoomSessionState.AwaitingConnectMessage) {\n * console.log('Session waiting for connect message')\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSessionState = (typeof RoomSessionState)[keyof typeof RoomSessionState]\n\n/**\n * Maximum time in milliseconds to wait for a connect message after socket connection.\n *\n * If a client connects but doesn't send a connect message within this time,\n * the session will be terminated.\n *\n * @public\n */\nexport const SESSION_START_WAIT_TIME = 10000\n\n/**\n * Time in milliseconds to wait before completely removing a disconnected session.\n *\n * This grace period allows for quick reconnections without losing session state,\n * which is especially helpful for brief network interruptions.\n *\n * @public\n */\nexport const SESSION_REMOVAL_WAIT_TIME = 5000\n\n/**\n * Maximum time in milliseconds a connected session can remain idle before cleanup.\n *\n * Sessions that don't receive any messages or interactions for this duration\n * may be considered for cleanup to free server resources.\n *\n * @public\n */\nexport const SESSION_IDLE_TIMEOUT = 20000\n\n/**\n * Represents a client session within a collaborative room, tracking the connection\n * state, permissions, and synchronization details for a single user.\n *\n * Each session corresponds to one WebSocket connection and progresses through\n * different states during its lifecycle. The session type is a discriminated union\n * based on the current state, ensuring type safety when accessing state-specific properties.\n *\n * @example\n * ```ts\n * // Check session state and access appropriate properties\n * function handleSession(session: RoomSession<MyRecord, UserMeta>) {\n * switch (session.state) {\n * case RoomSessionState.AwaitingConnectMessage:\n * console.log(`Session ${session.sessionId} started at ${session.sessionStartTime}`)\n * break\n * case RoomSessionState.Connected:\n * console.log(`Connected session has ${session.outstandingDataMessages.length} pending messages`)\n * break\n * case RoomSessionState.AwaitingRemoval:\n * console.log(`Session will be removed at ${session.cancellationTime}`)\n * break\n * }\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSession<R extends UnknownRecord, Meta> =\n\t| {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingConnectMessage\n\t\t\t/** Unique identifier for this session */\n\t\t\tsessionId: string\n\t\t\t/** Presence identifier for live cursor/selection tracking, if available */\n\t\t\tpresenceId: string | null\n\t\t\t/** WebSocket connection wrapper for this session */\n\t\t\tsocket: TLRoomSocket<R>\n\t\t\t/** Timestamp when the session was created */\n\t\t\tsessionStartTime: number\n\t\t\t/** Custom metadata associated with this session */\n\t\t\tmeta: Meta\n\t\t\t/** Whether this session has read-only permissions */\n\t\t\tisReadonly: boolean\n\t\t\t/** Whether this session requires legacy protocol rejection handling */\n\t\t\trequiresLegacyRejection: boolean\n\t }\n\t| {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingRemoval\n\t\t\t/** Unique identifier for this session */\n\t\t\tsessionId: string\n\t\t\t/** Presence identifier for live cursor/selection tracking, if available */\n\t\t\tpresenceId: string | null\n\t\t\t/** WebSocket connection wrapper for this session */\n\t\t\tsocket: TLRoomSocket<R>\n\t\t\t/** Timestamp when the session was marked for removal */\n\t\t\tcancellationTime: number\n\t\t\t/** Custom metadata associated with this session */\n\t\t\tmeta: Meta\n\t\t\t/** Whether this session has read-only permissions */\n\t\t\tisReadonly: boolean\n\t\t\t/** Whether this session requires legacy protocol rejection handling */\n\t\t\trequiresLegacyRejection: boolean\n\t }\n\t| {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.Connected\n\t\t\t/** Unique identifier for this session */\n\t\t\tsessionId: string\n\t\t\t/** Presence identifier for live cursor/selection tracking, if available */\n\t\t\tpresenceId: string | null\n\t\t\t/** WebSocket connection wrapper for this session */\n\t\t\tsocket: TLRoomSocket<R>\n\t\t\t/** Serialized schema information for this connected session */\n\t\t\tserializedSchema: SerializedSchema\n\t\t\t/** Timestamp of the last interaction or message from this session */\n\t\t\tlastInteractionTime: number\n\t\t\t/** Timer for debouncing operations, if active */\n\t\t\tdebounceTimer: ReturnType<typeof setTimeout> | null\n\t\t\t/** Queue of data messages waiting to be sent to this session */\n\t\t\toutstandingDataMessages: TLSocketServerSentDataEvent<R>[]\n\t\t\t/** Custom metadata associated with this session */\n\t\t\tmeta: Meta\n\t\t\t/** Whether this session has read-only permissions */\n\t\t\tisReadonly: boolean\n\t\t\t/** Whether this session requires legacy protocol rejection handling */\n\t\t\trequiresLegacyRejection: boolean\n\t }\n"],
4
+ "sourcesContent": ["import { SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport { TLRoomSocket } from './TLSyncRoom'\nimport { TLSocketServerSentDataEvent } from './protocol'\n\n/**\n * Enumeration of possible states for a room session during its lifecycle.\n *\n * Room sessions progress through these states as clients connect, authenticate,\n * and disconnect from collaborative rooms.\n *\n * @internal\n */\nexport const RoomSessionState = {\n\t/** Session is waiting for the initial connect message from the client */\n\tAwaitingConnectMessage: 'awaiting-connect-message',\n\t/** Session is disconnected but waiting for final cleanup before removal */\n\tAwaitingRemoval: 'awaiting-removal',\n\t/** Session is fully connected and actively synchronizing */\n\tConnected: 'connected',\n} as const\n\n/**\n * Type representing the possible states a room session can be in.\n *\n * @example\n * ```ts\n * const sessionState: RoomSessionState = RoomSessionState.Connected\n * if (sessionState === RoomSessionState.AwaitingConnectMessage) {\n * console.log('Session waiting for connect message')\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSessionState = (typeof RoomSessionState)[keyof typeof RoomSessionState]\n\n/**\n * Maximum time in milliseconds to wait for a connect message after socket connection.\n *\n * If a client connects but doesn't send a connect message within this time,\n * the session will be terminated.\n *\n * @public\n */\nexport const SESSION_START_WAIT_TIME = 10000\n\n/**\n * Time in milliseconds to wait before completely removing a disconnected session.\n *\n * This grace period allows for quick reconnections without losing session state,\n * which is especially helpful for brief network interruptions.\n *\n * @public\n */\nexport const SESSION_REMOVAL_WAIT_TIME = 5000\n\n/**\n * Maximum time in milliseconds a connected session can remain idle before cleanup.\n *\n * Sessions that don't receive any messages or interactions for this duration\n * may be considered for cleanup to free server resources.\n *\n * @public\n */\nexport const SESSION_IDLE_TIMEOUT = 20000\n\n/**\n * Base properties shared by all room session states.\n *\n * @internal\n */\nexport interface RoomSessionBase<R extends UnknownRecord, Meta> {\n\t/** Unique identifier for this session */\n\tsessionId: string\n\t/** Presence identifier for live cursor/selection tracking, if available */\n\tpresenceId: string | null\n\t/** WebSocket connection wrapper for this session */\n\tsocket: TLRoomSocket<R>\n\t/** Custom metadata associated with this session */\n\tmeta: Meta\n\t/** Whether this session has read-only permissions */\n\tisReadonly: boolean\n\t/** Whether this session requires legacy protocol rejection handling */\n\trequiresLegacyRejection: boolean\n\t/** Whether this session supports string append operations */\n\tsupportsStringAppend: boolean\n}\n\n/**\n * Represents a client session within a collaborative room, tracking the connection\n * state, permissions, and synchronization details for a single user.\n *\n * Each session corresponds to one WebSocket connection and progresses through\n * different states during its lifecycle. The session type is a discriminated union\n * based on the current state, ensuring type safety when accessing state-specific properties.\n *\n * @example\n * ```ts\n * // Check session state and access appropriate properties\n * function handleSession(session: RoomSession<MyRecord, UserMeta>) {\n * switch (session.state) {\n * case RoomSessionState.AwaitingConnectMessage:\n * console.log(`Session ${session.sessionId} started at ${session.sessionStartTime}`)\n * break\n * case RoomSessionState.Connected:\n * console.log(`Connected session has ${session.outstandingDataMessages.length} pending messages`)\n * break\n * case RoomSessionState.AwaitingRemoval:\n * console.log(`Session will be removed at ${session.cancellationTime}`)\n * break\n * }\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSession<R extends UnknownRecord, Meta> =\n\t| (RoomSessionBase<R, Meta> & {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingConnectMessage\n\t\t\t/** Timestamp when the session was created */\n\t\t\tsessionStartTime: number\n\t })\n\t| (RoomSessionBase<R, Meta> & {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingRemoval\n\t\t\t/** Timestamp when the session was marked for removal */\n\t\t\tcancellationTime: number\n\t })\n\t| (RoomSessionBase<R, Meta> & {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.Connected\n\t\t\t/** Serialized schema information for this connected session */\n\t\t\tserializedSchema: SerializedSchema\n\t\t\t/** Timestamp of the last interaction or message from this session */\n\t\t\tlastInteractionTime: number\n\t\t\t/** Timer for debouncing operations, if active */\n\t\t\tdebounceTimer: ReturnType<typeof setTimeout> | null\n\t\t\t/** Queue of data messages waiting to be sent to this session */\n\t\t\toutstandingDataMessages: TLSocketServerSentDataEvent<R>[]\n\t })\n"],
5
5
  "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYO,MAAM,mBAAmB;AAAA;AAAA,EAE/B,wBAAwB;AAAA;AAAA,EAExB,iBAAiB;AAAA;AAAA,EAEjB,WAAW;AACZ;AAyBO,MAAM,0BAA0B;AAUhC,MAAM,4BAA4B;AAUlC,MAAM,uBAAuB;",
6
6
  "names": []
7
7
  }
@@ -79,10 +79,11 @@ class DocumentState {
79
79
  *
80
80
  * @param state - The new record state
81
81
  * @param clock - The new clock value
82
+ * @param legacyAppendMode - If true, string append operations will be converted to Put operations
82
83
  * @returns Result containing the diff and new DocumentState, or null if no changes, or validation error
83
84
  */
84
- replaceState(state, clock) {
85
- const diff = (0, import_diff.diffRecord)(this.state, state);
85
+ replaceState(state, clock, legacyAppendMode = false) {
86
+ const diff = (0, import_diff.diffRecord)(this.state, state, legacyAppendMode);
86
87
  if (!diff) return import_utils.Result.ok(null);
87
88
  try {
88
89
  this.recordType.validate(state);
@@ -96,11 +97,12 @@ class DocumentState {
96
97
  *
97
98
  * @param diff - The object diff to apply
98
99
  * @param clock - The new clock value
100
+ * @param legacyAppendMode - If true, string append operations will be converted to Put operations
99
101
  * @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error
100
102
  */
101
- mergeDiff(diff, clock) {
103
+ mergeDiff(diff, clock, legacyAppendMode = false) {
102
104
  const newState = (0, import_diff.applyObjectDiff)(this.state, diff);
103
- return this.replaceState(newState, clock);
105
+ return this.replaceState(newState, clock, legacyAppendMode);
104
106
  }
105
107
  }
106
108
  function getDocumentClock(snapshot) {
@@ -488,7 +490,8 @@ class TLSyncRoom {
488
490
  cancellationTime: Date.now(),
489
491
  meta: session.meta,
490
492
  isReadonly: session.isReadonly,
491
- requiresLegacyRejection: session.requiresLegacyRejection
493
+ requiresLegacyRejection: session.requiresLegacyRejection,
494
+ supportsStringAppend: session.supportsStringAppend
492
495
  });
493
496
  try {
494
497
  session.socket.close();
@@ -595,10 +598,27 @@ class TLSyncRoom {
595
598
  meta,
596
599
  isReadonly: isReadonly ?? false,
597
600
  // this gets set later during handleConnectMessage
598
- requiresLegacyRejection: false
601
+ requiresLegacyRejection: false,
602
+ supportsStringAppend: true
599
603
  });
600
604
  return this;
601
605
  }
606
+ /**
607
+ * Checks if all connected sessions support string append operations (protocol version 8+).
608
+ * If any client is on an older version, returns false to enable legacy append mode.
609
+ *
610
+ * @returns True if all connected sessions are on protocol version 8 or higher
611
+ */
612
+ getCanEmitStringAppend() {
613
+ for (const session of this.sessions.values()) {
614
+ if (session.state === import_RoomSession.RoomSessionState.Connected) {
615
+ if (!session.supportsStringAppend) {
616
+ return false;
617
+ }
618
+ }
619
+ }
620
+ return true;
621
+ }
602
622
  /**
603
623
  * When we send a diff to a client, if that client is on a lower version than us, we need to make
604
624
  * the diff compatible with their version. At the moment this means migrating each affected record
@@ -733,6 +753,10 @@ class TLSyncRoom {
733
753
  if (theirProtocolVersion === 6) {
734
754
  theirProtocolVersion++;
735
755
  }
756
+ if (theirProtocolVersion === 7) {
757
+ theirProtocolVersion++;
758
+ session.supportsStringAppend = false;
759
+ }
736
760
  if (theirProtocolVersion == null || theirProtocolVersion < (0, import_protocol.getTlsyncProtocolVersion)()) {
737
761
  this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
738
762
  return;
@@ -760,6 +784,7 @@ class TLSyncRoom {
760
784
  lastInteractionTime: Date.now(),
761
785
  debounceTimer: null,
762
786
  outstandingDataMessages: [],
787
+ supportsStringAppend: session.supportsStringAppend,
763
788
  meta: session.meta,
764
789
  isReadonly: session.isReadonly,
765
790
  requiresLegacyRejection: session.requiresLegacyRejection
@@ -846,6 +871,7 @@ class TLSyncRoom {
846
871
  const initialDocumentClock = this.documentClock;
847
872
  let didPresenceChange = false;
848
873
  (0, import_state.transaction)((rollback) => {
874
+ const legacyAppendMode = !this.getCanEmitStringAppend();
849
875
  const docChanges = { diff: null };
850
876
  const presenceChanges = { diff: null };
851
877
  const propagateOp = (changes, id, op) => {
@@ -874,7 +900,7 @@ class TLSyncRoom {
874
900
  const { value: state } = res;
875
901
  const doc = this.getDocument(id);
876
902
  if (doc) {
877
- const diff = doc.replaceState(state, this.clock);
903
+ const diff = doc.replaceState(state, this.clock, legacyAppendMode);
878
904
  if (!diff.ok) {
879
905
  return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
880
906
  }
@@ -899,7 +925,7 @@ class TLSyncRoom {
899
925
  return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
900
926
  }
901
927
  if (downgraded.value === doc.state) {
902
- const diff = doc.mergeDiff(patch, this.clock);
928
+ const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode);
903
929
  if (!diff.ok) {
904
930
  return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
905
931
  }
@@ -913,7 +939,7 @@ class TLSyncRoom {
913
939
  if (upgraded.type === "error") {
914
940
  return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
915
941
  }
916
- const diff = doc.replaceState(upgraded.value, this.clock);
942
+ const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode);
917
943
  if (!diff.ok) {
918
944
  return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
919
945
  }