@tldraw/sync-core 5.5.4 → 5.6.0-canary.0091c91994d6

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 (41) hide show
  1. package/dist-cjs/index.d.ts +23 -8
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  4. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  5. package/dist-cjs/lib/TLSocketRoom.js +40 -11
  6. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncClient.js +5 -0
  8. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncRoom.js +30 -44
  10. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncStorage.js +2 -2
  12. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  13. package/dist-cjs/lib/diff.js +15 -7
  14. package/dist-cjs/lib/diff.js.map +2 -2
  15. package/dist-esm/index.d.mts +23 -8
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  18. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  19. package/dist-esm/lib/TLSocketRoom.mjs +40 -11
  20. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  21. package/dist-esm/lib/TLSyncClient.mjs +5 -0
  22. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  23. package/dist-esm/lib/TLSyncRoom.mjs +30 -44
  24. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncStorage.mjs +2 -2
  26. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  27. package/dist-esm/lib/diff.mjs +16 -8
  28. package/dist-esm/lib/diff.mjs.map +2 -2
  29. package/package.json +18 -18
  30. package/src/lib/InMemorySyncStorage.ts +2 -2
  31. package/src/lib/SQLiteSyncStorage.ts +2 -2
  32. package/src/lib/TLSocketRoom.ts +50 -11
  33. package/src/lib/TLSyncClient.test.ts +61 -0
  34. package/src/lib/TLSyncClient.ts +9 -0
  35. package/src/lib/TLSyncRoom.ts +51 -48
  36. package/src/lib/TLSyncStorage.ts +7 -2
  37. package/src/lib/diff.test.ts +91 -1
  38. package/src/lib/diff.ts +30 -9
  39. package/src/test/TLSocketRoom.test.ts +132 -1
  40. package/src/test/TLSyncRoom.test.ts +124 -0
  41. package/src/test/upgradeDowngrade.test.ts +21 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/diff.ts"],
4
- "sourcesContent": ["import { RecordsDiff, UnknownRecord } from '@tldraw/store'\nimport { isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'\n\n/**\n * Constants representing the types of operations that can be applied to records in network diffs.\n * These operations describe how a record has been modified during synchronization.\n *\n * @internal\n */\nexport const RecordOpType = {\n\tPut: 'put',\n\tPatch: 'patch',\n\tRemove: 'remove',\n} as const\n\n/**\n * Union type of all possible record operation types.\n *\n * @internal\n */\nexport type RecordOpType = (typeof RecordOpType)[keyof typeof RecordOpType]\n\n/**\n * Represents a single operation to be applied to a record during synchronization.\n *\n * @param R - The record type being operated on\n *\n * @internal\n */\nexport type RecordOp<R extends UnknownRecord> =\n\t| [typeof RecordOpType.Put, R]\n\t| [typeof RecordOpType.Patch, ObjectDiff]\n\t| [typeof RecordOpType.Remove]\n\n/**\n * A one-way (non-reversible) diff designed for small json footprint. These are mainly intended to\n * be sent over the wire. Either as push requests from the client to the server, or as patch\n * operations in the opposite direction.\n *\n * Each key in this object is the id of a record that has been added, updated, or removed.\n *\n * @internal\n */\nexport interface NetworkDiff<R extends UnknownRecord> {\n\t[id: string]: RecordOp<R>\n}\n\n/**\n * Converts a (reversible, verbose) RecordsDiff into a (non-reversible, concise) NetworkDiff\n * suitable for transmission over the network. This function optimizes the diff representation\n * for minimal bandwidth usage while maintaining all necessary change information.\n *\n * @param diff - The RecordsDiff containing added, updated, and removed records\n * @returns A compact NetworkDiff for network transmission, or null if no changes exist\n *\n * @example\n * ```ts\n * const recordsDiff = {\n * added: { 'shape:1': newShape },\n * updated: { 'shape:2': [oldShape, updatedShape] },\n * removed: { 'shape:3': removedShape }\n * }\n *\n * const networkDiff = getNetworkDiff(recordsDiff)\n * // Returns: {\n * // 'shape:1': ['put', newShape],\n * // 'shape:2': ['patch', { x: ['put', 100] }],\n * // 'shape:3': ['remove']\n * // }\n * ```\n *\n * @internal\n */\nexport function getNetworkDiff<R extends UnknownRecord>(\n\tdiff: RecordsDiff<R>\n): NetworkDiff<R> | null {\n\tlet res: NetworkDiff<R> | null = null\n\n\tfor (const [k, v] of objectMapEntries(diff.added)) {\n\t\tif (!res) res = {}\n\t\tres[k] = [RecordOpType.Put, v]\n\t}\n\n\tfor (const [from, to] of objectMapValues(diff.updated)) {\n\t\tconst diff = diffRecord(from, to)\n\t\tif (diff) {\n\t\t\tif (!res) res = {}\n\t\t\tres[to.id] = [RecordOpType.Patch, diff]\n\t\t}\n\t}\n\n\tfor (const removed of Object.keys(diff.removed)) {\n\t\tif (!res) res = {}\n\t\tres[removed] = [RecordOpType.Remove]\n\t}\n\n\treturn res\n}\n\n/**\n * Constants representing the types of operations that can be applied to individual values\n * within object diffs. These operations describe how object properties have changed.\n *\n * @internal\n */\nexport const ValueOpType = {\n\tPut: 'put',\n\tDelete: 'delete',\n\tAppend: 'append',\n\tPatch: 'patch',\n} as const\n/**\n * Union type of all possible value operation types.\n *\n * @internal\n */\nexport type ValueOpType = (typeof ValueOpType)[keyof typeof ValueOpType]\n\n/**\n * Operation that replaces a value entirely with a new value.\n *\n * @internal\n */\nexport type PutOp = [type: typeof ValueOpType.Put, value: unknown]\n/**\n * Operation that appends new values to the end of an array or string.\n *\n * @internal\n */\nexport type AppendOp = [type: typeof ValueOpType.Append, value: unknown[] | string, offset: number]\n/**\n * Operation that applies a nested diff to an object or array.\n *\n * @internal\n */\nexport type PatchOp = [type: typeof ValueOpType.Patch, diff: ObjectDiff]\n/**\n * Operation that removes a property from an object.\n *\n * @internal\n */\nexport type DeleteOp = [type: typeof ValueOpType.Delete]\n\n/**\n * Union type representing any value operation that can be applied during diffing.\n *\n * @internal\n */\nexport type ValueOp = PutOp | AppendOp | PatchOp | DeleteOp\n\n/**\n * Represents the differences between two objects as a mapping of property names\n * to the operations needed to transform one object into another.\n *\n * @internal\n */\nexport interface ObjectDiff {\n\t[k: string]: ValueOp\n}\n\n/**\n * Computes the difference between two record objects, generating an ObjectDiff\n * that describes how to transform the previous record into the next record.\n * This function is optimized for tldraw records and treats 'props' as a nested object.\n *\n * @param prev - The previous version of the record\n * @param next - The next version of the record\n * @param legacyAppendMode - If true, string append operations will be converted to Put operations\n * @returns An ObjectDiff describing the changes, or null if no changes exist\n *\n * @example\n * ```ts\n * const oldShape = { id: 'shape:1', x: 100, y: 200, props: { color: 'red' } }\n * const newShape = { id: 'shape:1', x: 150, y: 200, props: { color: 'blue' } }\n *\n * const diff = diffRecord(oldShape, newShape)\n * // Returns: {\n * // x: ['put', 150],\n * // props: ['patch', { color: ['put', 'blue'] }]\n * // }\n * ```\n *\n * @internal\n */\nexport function diffRecord(\n\tprev: object,\n\tnext: object,\n\tlegacyAppendMode = false\n): ObjectDiff | null {\n\treturn diffObject(prev, next, new Set(['props', 'meta']), legacyAppendMode)\n}\n\nfunction diffObject(\n\tprev: object,\n\tnext: object,\n\tnestedKeys: Set<string> | undefined,\n\tlegacyAppendMode: boolean\n): ObjectDiff | null {\n\tif (prev === next) {\n\t\treturn null\n\t}\n\tlet result: ObjectDiff | null = null\n\tfor (const key of Object.keys(prev)) {\n\t\t// if key is not in next then it was deleted\n\t\tif (!(key in next)) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Delete]\n\t\t\tcontinue\n\t\t}\n\t\tconst prevValue = (prev as any)[key]\n\t\tconst nextValue = (next as any)[key]\n\t\tif (\n\t\t\tnestedKeys?.has(key) ||\n\t\t\t(Array.isArray(prevValue) && Array.isArray(nextValue)) ||\n\t\t\t(typeof prevValue === 'string' && typeof nextValue === 'string')\n\t\t) {\n\t\t\t// if key is in both places, then compare values\n\t\t\tconst diff = diffValue(prevValue, nextValue, legacyAppendMode)\n\t\t\tif (diff) {\n\t\t\t\tif (!result) result = {}\n\t\t\t\tresult[key] = diff\n\t\t\t}\n\t\t} else if (!isEqual(prevValue, nextValue)) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Put, nextValue]\n\t\t}\n\t}\n\tfor (const key of Object.keys(next)) {\n\t\t// if key is in next but not in prev then it was added\n\t\tif (!(key in prev)) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Put, (next as any)[key]]\n\t\t}\n\t}\n\treturn result\n}\n\nfunction diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean): ValueOp | null {\n\tif (Object.is(valueA, valueB)) return null\n\tif (Array.isArray(valueA) && Array.isArray(valueB)) {\n\t\treturn diffArray(valueA, valueB, legacyAppendMode)\n\t} else if (typeof valueA === 'string' && typeof valueB === 'string') {\n\t\tif (!legacyAppendMode && valueB.startsWith(valueA)) {\n\t\t\tconst appendedText = valueB.slice(valueA.length)\n\t\t\treturn [ValueOpType.Append, appendedText, valueA.length]\n\t\t}\n\t\treturn [ValueOpType.Put, valueB]\n\t} else if (!valueA || !valueB || typeof valueA !== 'object' || typeof valueB !== 'object') {\n\t\treturn isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]\n\t} else {\n\t\tconst diff = diffObject(valueA, valueB, undefined, legacyAppendMode)\n\t\treturn diff ? [ValueOpType.Patch, diff] : null\n\t}\n}\n\nfunction diffArray(\n\tprevArray: unknown[],\n\tnextArray: unknown[],\n\tlegacyAppendMode: boolean\n): PutOp | AppendOp | PatchOp | null {\n\tif (Object.is(prevArray, nextArray)) return null\n\t// if lengths are equal, check for patch operation\n\tif (prevArray.length === nextArray.length) {\n\t\t// bail out if more than len/5 items need patching\n\t\tconst maxPatchIndexes = Math.max(prevArray.length / 5, 1)\n\t\tconst toPatchIndexes = []\n\t\tfor (let i = 0; i < prevArray.length; i++) {\n\t\t\tif (!isEqual(prevArray[i], nextArray[i])) {\n\t\t\t\ttoPatchIndexes.push(i)\n\t\t\t\tif (toPatchIndexes.length > maxPatchIndexes) {\n\t\t\t\t\treturn [ValueOpType.Put, nextArray]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (toPatchIndexes.length === 0) {\n\t\t\t// same length and no items changed, so no diff\n\t\t\treturn null\n\t\t}\n\t\tconst diff: ObjectDiff = {}\n\t\tfor (const i of toPatchIndexes) {\n\t\t\tconst prevItem = prevArray[i]\n\t\t\tconst nextItem = nextArray[i]\n\t\t\tif (!prevItem || !nextItem) {\n\t\t\t\tdiff[i] = [ValueOpType.Put, nextItem]\n\t\t\t} else if (typeof prevItem === 'object' && typeof nextItem === 'object') {\n\t\t\t\tconst op = diffValue(prevItem, nextItem, legacyAppendMode)\n\t\t\t\tif (op) {\n\t\t\t\t\tdiff[i] = op\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdiff[i] = [ValueOpType.Put, nextItem]\n\t\t\t}\n\t\t}\n\t\treturn [ValueOpType.Patch, diff]\n\t}\n\n\t// if lengths are not equal, check for append operation, and bail out\n\t// to replace whole array if any shared elems changed\n\tfor (let i = 0; i < prevArray.length; i++) {\n\t\tif (!isEqual(prevArray[i], nextArray[i])) {\n\t\t\treturn [ValueOpType.Put, nextArray]\n\t\t}\n\t}\n\n\treturn [ValueOpType.Append, nextArray.slice(prevArray.length), prevArray.length]\n}\n\n/**\n * Applies an ObjectDiff to an object, returning a new object with the changes applied.\n * This function handles all value operation types and creates a shallow copy when modifications\n * are needed. If no changes are required, the original object is returned.\n *\n * @param object - The object to apply the diff to\n * @param objectDiff - The ObjectDiff containing the operations to apply\n * @returns A new object with the diff applied, or the original object if no changes were needed\n *\n * @example\n * ```ts\n * const original = { x: 100, y: 200, props: { color: 'red' } }\n * const diff = {\n * x: ['put', 150],\n * props: ['patch', { color: ['put', 'blue'] }]\n * }\n *\n * const updated = applyObjectDiff(original, diff)\n * // Returns: { x: 150, y: 200, props: { color: 'blue' } }\n * ```\n *\n * @internal\n */\nexport function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectDiff): T {\n\t// don't patch nulls\n\tif (!object || typeof object !== 'object') return object\n\tconst isArray = Array.isArray(object)\n\tlet newObject: any | undefined = undefined\n\tconst set = (k: any, v: any) => {\n\t\tif (!newObject) {\n\t\t\tif (isArray) {\n\t\t\t\tnewObject = [...object]\n\t\t\t} else {\n\t\t\t\tnewObject = { ...object }\n\t\t\t}\n\t\t}\n\t\tif (isArray) {\n\t\t\tnewObject[Number(k)] = v\n\t\t} else {\n\t\t\tnewObject[k] = v\n\t\t}\n\t}\n\tfor (const [key, op] of Object.entries(objectDiff)) {\n\t\tswitch (op[0]) {\n\t\t\tcase ValueOpType.Put: {\n\t\t\t\tconst value = op[1]\n\t\t\t\tif (!isEqual(object[key as keyof T], value)) {\n\t\t\t\t\tset(key, value)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Append: {\n\t\t\t\tconst value = op[1]\n\t\t\t\tconst offset = op[2]\n\t\t\t\tconst currentValue = object[key as keyof T]\n\t\t\t\tif (Array.isArray(currentValue) && Array.isArray(value) && currentValue.length === offset) {\n\t\t\t\t\tset(key, [...currentValue, ...value])\n\t\t\t\t} else if (\n\t\t\t\t\ttypeof currentValue === 'string' &&\n\t\t\t\t\ttypeof value === 'string' &&\n\t\t\t\t\tcurrentValue.length === offset\n\t\t\t\t) {\n\t\t\t\t\tset(key, currentValue + value)\n\t\t\t\t}\n\t\t\t\t// If validation fails (type mismatch or length mismatch), silently ignore\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Patch: {\n\t\t\t\tif (object[key as keyof T] && typeof object[key as keyof T] === 'object') {\n\t\t\t\t\tconst diff = op[1]\n\t\t\t\t\tconst patched = applyObjectDiff(object[key as keyof T] as object, diff)\n\t\t\t\t\tif (patched !== object[key as keyof T]) {\n\t\t\t\t\t\tset(key, patched)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Delete: {\n\t\t\t\tif (key in object) {\n\t\t\t\t\tif (!newObject) {\n\t\t\t\t\t\tif (isArray) {\n\t\t\t\t\t\t\tconsole.error(\"Can't delete array item yet (this should never happen)\")\n\t\t\t\t\t\t\tnewObject = [...object]\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tnewObject = { ...object }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdelete newObject[key]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn newObject ?? object\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { RecordsDiff, UnknownRecord } from '@tldraw/store'\nimport { hasOwnProperty, isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'\n\n/**\n * Constants representing the types of operations that can be applied to records in network diffs.\n * These operations describe how a record has been modified during synchronization.\n *\n * @internal\n */\nexport const RecordOpType = {\n\tPut: 'put',\n\tPatch: 'patch',\n\tRemove: 'remove',\n} as const\n\n/**\n * Union type of all possible record operation types.\n *\n * @internal\n */\nexport type RecordOpType = (typeof RecordOpType)[keyof typeof RecordOpType]\n\n/**\n * Represents a single operation to be applied to a record during synchronization.\n *\n * @param R - The record type being operated on\n *\n * @internal\n */\nexport type RecordOp<R extends UnknownRecord> =\n\t| [typeof RecordOpType.Put, R]\n\t| [typeof RecordOpType.Patch, ObjectDiff]\n\t| [typeof RecordOpType.Remove]\n\n/**\n * A one-way (non-reversible) diff designed for small json footprint. These are mainly intended to\n * be sent over the wire. Either as push requests from the client to the server, or as patch\n * operations in the opposite direction.\n *\n * Each key in this object is the id of a record that has been added, updated, or removed.\n *\n * @internal\n */\nexport interface NetworkDiff<R extends UnknownRecord> {\n\t[id: string]: RecordOp<R>\n}\n\n/**\n * Converts a (reversible, verbose) RecordsDiff into a (non-reversible, concise) NetworkDiff\n * suitable for transmission over the network. This function optimizes the diff representation\n * for minimal bandwidth usage while maintaining all necessary change information.\n *\n * @param diff - The RecordsDiff containing added, updated, and removed records\n * @returns A compact NetworkDiff for network transmission, or null if no changes exist\n *\n * @example\n * ```ts\n * const recordsDiff = {\n * added: { 'shape:1': newShape },\n * updated: { 'shape:2': [oldShape, updatedShape] },\n * removed: { 'shape:3': removedShape }\n * }\n *\n * const networkDiff = getNetworkDiff(recordsDiff)\n * // Returns: {\n * // 'shape:1': ['put', newShape],\n * // 'shape:2': ['patch', { x: ['put', 100] }],\n * // 'shape:3': ['remove']\n * // }\n * ```\n *\n * @internal\n */\nexport function getNetworkDiff<R extends UnknownRecord>(\n\tdiff: RecordsDiff<R>\n): NetworkDiff<R> | null {\n\tlet res: NetworkDiff<R> | null = null\n\n\tfor (const [k, v] of objectMapEntries(diff.added)) {\n\t\tif (!res) res = {}\n\t\tres[k] = [RecordOpType.Put, v]\n\t}\n\n\tfor (const [from, to] of objectMapValues(diff.updated)) {\n\t\tconst diff = diffRecord(from, to)\n\t\tif (diff) {\n\t\t\tif (!res) res = {}\n\t\t\tres[to.id] = [RecordOpType.Patch, diff]\n\t\t}\n\t}\n\n\tfor (const removed of Object.keys(diff.removed)) {\n\t\tif (!res) res = {}\n\t\tres[removed] = [RecordOpType.Remove]\n\t}\n\n\treturn res\n}\n\n/**\n * Constants representing the types of operations that can be applied to individual values\n * within object diffs. These operations describe how object properties have changed.\n *\n * @internal\n */\nexport const ValueOpType = {\n\tPut: 'put',\n\tDelete: 'delete',\n\tAppend: 'append',\n\tPatch: 'patch',\n} as const\n/**\n * Union type of all possible value operation types.\n *\n * @internal\n */\nexport type ValueOpType = (typeof ValueOpType)[keyof typeof ValueOpType]\n\n/**\n * Operation that replaces a value entirely with a new value.\n *\n * @internal\n */\nexport type PutOp = [type: typeof ValueOpType.Put, value: unknown]\n/**\n * Operation that appends new values to the end of an array or string.\n *\n * @internal\n */\nexport type AppendOp = [type: typeof ValueOpType.Append, value: unknown[] | string, offset: number]\n/**\n * Operation that applies a nested diff to an object or array.\n *\n * @internal\n */\nexport type PatchOp = [type: typeof ValueOpType.Patch, diff: ObjectDiff]\n/**\n * Operation that removes a property from an object.\n *\n * @internal\n */\nexport type DeleteOp = [type: typeof ValueOpType.Delete]\n\n/**\n * Union type representing any value operation that can be applied during diffing.\n *\n * @internal\n */\nexport type ValueOp = PutOp | AppendOp | PatchOp | DeleteOp\n\n/**\n * Represents the differences between two objects as a mapping of property names\n * to the operations needed to transform one object into another.\n *\n * @internal\n */\nexport interface ObjectDiff {\n\t[k: string]: ValueOp\n}\n\n/**\n * Computes the difference between two record objects, generating an ObjectDiff\n * that describes how to transform the previous record into the next record.\n * This function is optimized for tldraw records and treats 'props' as a nested object.\n *\n * @param prev - The previous version of the record\n * @param next - The next version of the record\n * @param legacyAppendMode - If true, string append operations will be converted to Put operations\n * @returns An ObjectDiff describing the changes, or null if no changes exist\n *\n * @example\n * ```ts\n * const oldShape = { id: 'shape:1', x: 100, y: 200, props: { color: 'red' } }\n * const newShape = { id: 'shape:1', x: 150, y: 200, props: { color: 'blue' } }\n *\n * const diff = diffRecord(oldShape, newShape)\n * // Returns: {\n * // x: ['put', 150],\n * // props: ['patch', { color: ['put', 'blue'] }]\n * // }\n * ```\n *\n * @internal\n */\nexport function diffRecord(\n\tprev: object,\n\tnext: object,\n\tlegacyAppendMode = false\n): ObjectDiff | null {\n\treturn diffObject(prev, next, new Set(['props', 'meta']), legacyAppendMode)\n}\n\nfunction diffObject(\n\tprev: object,\n\tnext: object,\n\tnestedKeys: Set<string> | undefined,\n\tlegacyAppendMode: boolean\n): ObjectDiff | null {\n\tif (prev === next) {\n\t\treturn null\n\t}\n\tlet result: ObjectDiff | null = null\n\t// Own-property checks throughout: `in` would see Object.prototype members, so a user key named\n\t// e.g. 'constructor' would never diff as an add or delete. And a key whose value is `undefined`\n\t// is treated as absent: `undefined` doesn't survive JSON, so `['put', undefined]` would arrive\n\t// as `['put', null]` and fail validators that accept only the missing key.\n\tfor (const key of Object.keys(prev)) {\n\t\t// `result['__proto__'] = op` would set the diff's prototype, and applyObjectDiff drops the key (AD8)\n\t\tif (key === '__proto__') continue\n\t\tconst prevValue = (prev as any)[key]\n\t\tif (prevValue === undefined) continue\n\t\tconst nextValue = hasOwnProperty(next, key) ? (next as any)[key] : undefined\n\t\tif (nextValue === undefined) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Delete]\n\t\t\tcontinue\n\t\t}\n\t\tif (\n\t\t\tnestedKeys?.has(key) ||\n\t\t\t(Array.isArray(prevValue) && Array.isArray(nextValue)) ||\n\t\t\t(typeof prevValue === 'string' && typeof nextValue === 'string')\n\t\t) {\n\t\t\t// if key is in both places, then compare values\n\t\t\tconst diff = diffValue(prevValue, nextValue, legacyAppendMode)\n\t\t\tif (diff) {\n\t\t\t\tif (!result) result = {}\n\t\t\t\tresult[key] = diff\n\t\t\t}\n\t\t} else if (!isEqual(prevValue, nextValue)) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Put, nextValue]\n\t\t}\n\t}\n\tfor (const key of Object.keys(next)) {\n\t\tif (key === '__proto__') continue\n\t\tconst nextValue = (next as any)[key]\n\t\tif (nextValue === undefined) continue\n\t\t// if key is in next but not in prev then it was added\n\t\tif (!hasOwnProperty(prev, key) || (prev as any)[key] === undefined) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Put, nextValue]\n\t\t}\n\t}\n\treturn result\n}\n\nfunction diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean): ValueOp | null {\n\tif (Object.is(valueA, valueB)) return null\n\tif (Array.isArray(valueA) && Array.isArray(valueB)) {\n\t\treturn diffArray(valueA, valueB, legacyAppendMode)\n\t} else if (typeof valueA === 'string' && typeof valueB === 'string') {\n\t\tif (!legacyAppendMode && valueB.startsWith(valueA)) {\n\t\t\tconst appendedText = valueB.slice(valueA.length)\n\t\t\treturn [ValueOpType.Append, appendedText, valueA.length]\n\t\t}\n\t\treturn [ValueOpType.Put, valueB]\n\t} else if (\n\t\t!valueA ||\n\t\t!valueB ||\n\t\ttypeof valueA !== 'object' ||\n\t\ttypeof valueB !== 'object' ||\n\t\t// an array on one side and a plain object on the other can't be expressed as a patch:\n\t\t// index deletes and named puts don't apply cleanly to either shape\n\t\tArray.isArray(valueA) !== Array.isArray(valueB)\n\t) {\n\t\treturn isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]\n\t} else {\n\t\tconst diff = diffObject(valueA, valueB, undefined, legacyAppendMode)\n\t\treturn diff ? [ValueOpType.Patch, diff] : null\n\t}\n}\n\nfunction diffArray(\n\tprevArray: unknown[],\n\tnextArray: unknown[],\n\tlegacyAppendMode: boolean\n): PutOp | AppendOp | PatchOp | null {\n\tif (Object.is(prevArray, nextArray)) return null\n\t// if lengths are equal, check for patch operation\n\tif (prevArray.length === nextArray.length) {\n\t\t// bail out if more than len/5 items need patching\n\t\tconst maxPatchIndexes = Math.max(prevArray.length / 5, 1)\n\t\tconst toPatchIndexes = []\n\t\tfor (let i = 0; i < prevArray.length; i++) {\n\t\t\tif (!isEqual(prevArray[i], nextArray[i])) {\n\t\t\t\ttoPatchIndexes.push(i)\n\t\t\t\tif (toPatchIndexes.length > maxPatchIndexes) {\n\t\t\t\t\treturn [ValueOpType.Put, nextArray]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (toPatchIndexes.length === 0) {\n\t\t\t// same length and no items changed, so no diff\n\t\t\treturn null\n\t\t}\n\t\tconst diff: ObjectDiff = {}\n\t\tfor (const i of toPatchIndexes) {\n\t\t\tconst prevItem = prevArray[i]\n\t\t\tconst nextItem = nextArray[i]\n\t\t\tif (!prevItem || !nextItem) {\n\t\t\t\tdiff[i] = [ValueOpType.Put, nextItem]\n\t\t\t} else if (typeof prevItem === 'object' && typeof nextItem === 'object') {\n\t\t\t\tconst op = diffValue(prevItem, nextItem, legacyAppendMode)\n\t\t\t\tif (op) {\n\t\t\t\t\tdiff[i] = op\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdiff[i] = [ValueOpType.Put, nextItem]\n\t\t\t}\n\t\t}\n\t\treturn [ValueOpType.Patch, diff]\n\t}\n\n\t// if lengths are not equal, check for append operation, and bail out\n\t// to replace whole array if any shared elems changed\n\tfor (let i = 0; i < prevArray.length; i++) {\n\t\tif (!isEqual(prevArray[i], nextArray[i])) {\n\t\t\treturn [ValueOpType.Put, nextArray]\n\t\t}\n\t}\n\n\treturn [ValueOpType.Append, nextArray.slice(prevArray.length), prevArray.length]\n}\n\n/**\n * Applies an ObjectDiff to an object, returning a new object with the changes applied.\n * This function handles all value operation types and creates a shallow copy when modifications\n * are needed. If no changes are required, the original object is returned.\n *\n * @param object - The object to apply the diff to\n * @param objectDiff - The ObjectDiff containing the operations to apply\n * @returns A new object with the diff applied, or the original object if no changes were needed\n *\n * @example\n * ```ts\n * const original = { x: 100, y: 200, props: { color: 'red' } }\n * const diff = {\n * x: ['put', 150],\n * props: ['patch', { color: ['put', 'blue'] }]\n * }\n *\n * const updated = applyObjectDiff(original, diff)\n * // Returns: { x: 150, y: 200, props: { color: 'blue' } }\n * ```\n *\n * @internal\n */\nexport function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectDiff): T {\n\t// don't patch nulls\n\tif (!object || typeof object !== 'object') return object\n\tconst isArray = Array.isArray(object)\n\tlet newObject: any | undefined = undefined\n\tconst set = (k: any, v: any) => {\n\t\tif (!newObject) {\n\t\t\tif (isArray) {\n\t\t\t\tnewObject = [...object]\n\t\t\t} else {\n\t\t\t\tnewObject = { ...object }\n\t\t\t}\n\t\t}\n\t\tif (isArray) {\n\t\t\tnewObject[Number(k)] = v\n\t\t} else {\n\t\t\tnewObject[k] = v\n\t\t}\n\t}\n\tfor (const [key, op] of Object.entries(objectDiff)) {\n\t\t// Diffs come off the wire from untrusted peers. `newObject['__proto__'] = v` would set the\n\t\t// record's prototype (JSON.parse creates '__proto__' as an own key), so a client could give\n\t\t// server-held records attacker-controlled inherited fields; skip such keys outright.\n\t\tif (key === '__proto__') continue\n\t\tswitch (op[0]) {\n\t\t\tcase ValueOpType.Put: {\n\t\t\t\tconst value = op[1]\n\t\t\t\tif (!isEqual(object[key as keyof T], value)) {\n\t\t\t\t\tset(key, value)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Append: {\n\t\t\t\tconst value = op[1]\n\t\t\t\tconst offset = op[2]\n\t\t\t\tconst currentValue = object[key as keyof T]\n\t\t\t\tif (Array.isArray(currentValue) && Array.isArray(value) && currentValue.length === offset) {\n\t\t\t\t\tset(key, [...currentValue, ...value])\n\t\t\t\t} else if (\n\t\t\t\t\ttypeof currentValue === 'string' &&\n\t\t\t\t\ttypeof value === 'string' &&\n\t\t\t\t\tcurrentValue.length === offset\n\t\t\t\t) {\n\t\t\t\t\tset(key, currentValue + value)\n\t\t\t\t}\n\t\t\t\t// If validation fails (type mismatch or length mismatch), silently ignore\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Patch: {\n\t\t\t\tif (object[key as keyof T] && typeof object[key as keyof T] === 'object') {\n\t\t\t\t\tconst diff = op[1]\n\t\t\t\t\tconst patched = applyObjectDiff(object[key as keyof T] as object, diff)\n\t\t\t\t\tif (patched !== object[key as keyof T]) {\n\t\t\t\t\t\tset(key, patched)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Delete: {\n\t\t\t\tif (hasOwnProperty(object, key)) {\n\t\t\t\t\tif (!newObject) {\n\t\t\t\t\t\tif (isArray) {\n\t\t\t\t\t\t\tconsole.error(\"Can't delete array item yet (this should never happen)\")\n\t\t\t\t\t\t\tnewObject = [...object]\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tnewObject = { ...object }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdelete newObject[key]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn newObject ?? object\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["diff"]
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/sync-core",
3
3
  "description": "tldraw infinite canvas SDK (multiplayer sync).",
4
- "version": "5.5.4",
4
+ "version": "5.6.0-canary.0091c91994d6",
5
5
  "author": {
6
6
  "name": "tldraw GB Ltd.",
7
7
  "email": "hello@tldraw.com"
@@ -39,38 +39,28 @@
39
39
  "src",
40
40
  "DOCS.md"
41
41
  ],
42
- "scripts": {
43
- "test-ci": "yarn run -T vitest run --passWithNoTests",
44
- "test": "yarn run -T vitest --passWithNoTests",
45
- "test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
46
- "lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
47
- "build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
48
- "build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
49
- "prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
50
- "postpack": "../../internal/scripts/postpack.sh",
51
- "pack-tarball": "yarn pack"
52
- },
53
42
  "devDependencies": {
54
43
  "@peculiar/webcrypto": "^1.5.0",
55
- "@tldraw/editor": "5.5.4",
44
+ "@tldraw/editor": "5.6.0-canary.0091c91994d6",
56
45
  "@types/react": "^19.2.7",
57
46
  "@types/react-dom": "^19.2.3",
58
47
  "@types/uuid-readable": "^0.0.3",
48
+ "@types/ws": "^8.18.1",
59
49
  "fake-indexeddb": "^4.0.2",
60
50
  "react": "^19.2.1",
61
51
  "react-dom": "^19.2.1",
62
52
  "resize-observer-polyfill": "^1.5.1",
63
- "tldraw": "5.5.4",
53
+ "tldraw": "5.6.0-canary.0091c91994d6",
64
54
  "typescript": "^7.0.2",
65
55
  "uuid-by-string": "^4.0.0",
66
56
  "uuid-readable": "^0.0.2",
67
57
  "vitest": "^5.0.0"
68
58
  },
69
59
  "dependencies": {
70
- "@tldraw/state": "5.5.4",
71
- "@tldraw/store": "5.5.4",
72
- "@tldraw/tlschema": "5.5.4",
73
- "@tldraw/utils": "5.5.4",
60
+ "@tldraw/state": "5.6.0-canary.0091c91994d6",
61
+ "@tldraw/store": "5.6.0-canary.0091c91994d6",
62
+ "@tldraw/tlschema": "5.6.0-canary.0091c91994d6",
63
+ "@tldraw/utils": "5.6.0-canary.0091c91994d6",
74
64
  "nanoevents": "^7.0.1",
75
65
  "ws": "^8.18.0"
76
66
  },
@@ -88,5 +78,15 @@
88
78
  "import": "./dist-esm/index.mjs",
89
79
  "require": "./dist-cjs/index.js"
90
80
  }
81
+ },
82
+ "scripts": {
83
+ "test-ci": "pnpm exec vitest run --passWithNoTests",
84
+ "test": "pnpm exec vitest --passWithNoTests",
85
+ "test-coverage": "pnpm exec vitest run --coverage --passWithNoTests",
86
+ "lint": "pnpm exec tsx ../../internal/scripts/lint.ts",
87
+ "build": "pnpm exec tsx ../../internal/scripts/build-package.ts",
88
+ "build-api": "pnpm exec tsx ../../internal/scripts/build-api.ts",
89
+ "pack-tarball": "pnpm pack",
90
+ "build-types": "tsc --build"
91
91
  }
92
92
  }
@@ -7,7 +7,7 @@ import {
7
7
  TLDOCUMENT_ID,
8
8
  TLPageId,
9
9
  } from '@tldraw/tlschema'
10
- import { assert, IndexKey, objectMapEntries, throttle } from '@tldraw/utils'
10
+ import { assert, IndexKey, objectMapEntries, throttle, type ThrottledFunction } from '@tldraw/utils'
11
11
  import { MicrotaskNotifier } from './MicrotaskNotifier'
12
12
  import { RoomSnapshot } from './TLSyncRoom'
13
13
  import {
@@ -247,7 +247,7 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
247
247
  }
248
248
 
249
249
  /** @internal */
250
- pruneTombstones = throttle(
250
+ pruneTombstones: ThrottledFunction<() => void> = throttle(
251
251
  () => {
252
252
  if (this.tombstones.size > MAX_TOMBSTONES) {
253
253
  // Convert to array and sort by clock ascending (oldest first)
@@ -1,6 +1,6 @@
1
1
  import { transaction } from '@tldraw/state'
2
2
  import { SerializedSchema, StoreSnapshot, UnknownRecord } from '@tldraw/store'
3
- import { assert, objectMapEntries, throttle } from '@tldraw/utils'
3
+ import { assert, objectMapEntries, throttle, type ThrottledFunction } from '@tldraw/utils'
4
4
  import {
5
5
  computeTombstonePruning,
6
6
  DEFAULT_INITIAL_SNAPSHOT,
@@ -508,7 +508,7 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
508
508
  }
509
509
 
510
510
  /** @internal */
511
- pruneTombstones = throttle(
511
+ pruneTombstones: ThrottledFunction<() => void> = throttle(
512
512
  () => {
513
513
  // Runs from a timer, so a host that closed the database right after the last delete
514
514
  // (e.g. `room.close(); db.close()` in onSessionRemoved) would otherwise get an uncaught
@@ -355,10 +355,12 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
355
355
  ) {
356
356
  const { sessionId, socket, isReadonly = false, objectAccess = 'write' } = opts
357
357
  const handleSocketMessage = (event: MessageEvent) =>
358
- this.handleSocketMessage(sessionId, event.data)
359
- const handleSocketError = this.handleSocketError.bind(this, sessionId)
360
- const handleSocketClose = this.handleSocketClose.bind(this, sessionId)
358
+ this.handleSocketMessage(sessionId, event.data, socket)
359
+ const handleSocketError = () => this.handleSocketError(sessionId, socket)
360
+ const handleSocketClose = () => this.handleSocketClose(sessionId, socket)
361
361
 
362
+ // A reconnect under the same session id can leave the old socket open.
363
+ this.sessions.get(sessionId)?.unlisten()
362
364
  this.sessions.set(sessionId, {
363
365
  assembler: new JsonChunkAssembler(),
364
366
  socket,
@@ -422,8 +424,14 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
422
424
  * The method handles message chunking/reassembly and forwards complete messages
423
425
  * to the underlying sync room for processing.
424
426
  *
427
+ * Pass the socket the message came from whenever you have it. A client that reconnects under
428
+ * the same session id can leave its previous socket delivering messages for a while; with the
429
+ * socket given, those are dropped instead of being spliced into the new socket's chunked
430
+ * messages.
431
+ *
425
432
  * @param sessionId - Session identifier matching the one used in handleSocketConnect
426
433
  * @param message - Raw message data from the client (string or binary)
434
+ * @param socket - The socket the message arrived on, when known
427
435
  *
428
436
  * @example
429
437
  * ```ts
@@ -435,17 +443,30 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
435
443
  * },
436
444
  * message(ws, message) {
437
445
  * const { sessionId, room } = ws.data
438
- * room.handleSocketMessage(sessionId, message)
446
+ * room.handleSocketMessage(sessionId, message, ws)
439
447
  * }
440
448
  * })
441
449
  * ```
442
450
  */
443
- handleSocketMessage(sessionId: string, message: string | AllowSharedBufferSource) {
444
- const assembler = this.sessions.get(sessionId)?.assembler
445
- if (!assembler) {
451
+ handleSocketMessage(
452
+ sessionId: string,
453
+ message: string | AllowSharedBufferSource,
454
+ socket?: WebSocketMinimal
455
+ ) {
456
+ const entry = this.sessions.get(sessionId)
457
+ if (!entry) {
446
458
  this.log?.warn?.('Received message from unknown session', sessionId)
447
459
  return
448
460
  }
461
+ // Each socket carries its own chunk sequence, but the assembler belongs to the session id.
462
+ // Feeding a superseded socket's in-flight chunks into the replacement's assembler breaks
463
+ // both messages ("Unexpected non-chunk message", "Chunks received in wrong order", or a
464
+ // lone tail chunk parsed as JSON) — see #10132.
465
+ if (socket && entry.socket !== socket) {
466
+ this.log?.warn?.('Ignoring message from superseded socket', sessionId)
467
+ return
468
+ }
469
+ const assembler = entry.assembler
449
470
 
450
471
  try {
451
472
  const messageString =
@@ -500,17 +521,22 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
500
521
  * where socket event listeners cannot be attached directly. This will initiate cleanup
501
522
  * and session removal for the affected client.
502
523
  *
524
+ * Pass the socket the error came from whenever you have it, so a superseded socket's error
525
+ * after a same-id reconnect doesn't cancel the replacement session.
526
+ *
503
527
  * @param sessionId - Session identifier matching the one used in handleSocketConnect
528
+ * @param socket - The socket the error came from, when known
504
529
  *
505
530
  * @example
506
531
  * ```ts
507
532
  * // In a custom WebSocket handler
508
533
  * socket.addEventListener('error', () => {
509
- * room.handleSocketError(sessionId)
534
+ * room.handleSocketError(sessionId, socket)
510
535
  * })
511
536
  * ```
512
537
  */
513
- handleSocketError(sessionId: string) {
538
+ handleSocketError(sessionId: string, socket?: WebSocketMinimal) {
539
+ if (this.isSupersededSocket(sessionId, socket)) return
514
540
  this.clearSnapshotTimer(sessionId)
515
541
  this.room.handleClose(sessionId)
516
542
  }
@@ -520,21 +546,33 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
520
546
  * environments where socket event listeners cannot be attached directly. This will
521
547
  * initiate cleanup and session removal for the disconnected client.
522
548
  *
549
+ * Pass the socket the close came from whenever you have it. A client that reconnects under the
550
+ * same session id can leave its previous socket half-open, and that socket's late close would
551
+ * otherwise cancel the new, live session.
552
+ *
523
553
  * @param sessionId - Session identifier matching the one used in handleSocketConnect
554
+ * @param socket - The socket that closed, when known
524
555
  *
525
556
  * @example
526
557
  * ```ts
527
558
  * // In a custom WebSocket handler
528
559
  * socket.addEventListener('close', () => {
529
- * room.handleSocketClose(sessionId)
560
+ * room.handleSocketClose(sessionId, socket)
530
561
  * })
531
562
  * ```
532
563
  */
533
- handleSocketClose(sessionId: string) {
564
+ handleSocketClose(sessionId: string, socket?: WebSocketMinimal) {
565
+ if (this.isSupersededSocket(sessionId, socket)) return
534
566
  this.clearSnapshotTimer(sessionId)
535
567
  this.room.handleClose(sessionId)
536
568
  }
537
569
 
570
+ private isSupersededSocket(sessionId: string, socket: WebSocketMinimal | undefined) {
571
+ if (!socket) return false
572
+ const entry = this.sessions.get(sessionId)
573
+ return !!entry && entry.socket !== socket
574
+ }
575
+
538
576
  /**
539
577
  * Resumes a previously-connected session directly into `Connected` state, bypassing
540
578
  * the connect handshake. Use this after server hibernation (e.g., Cloudflare Durable
@@ -576,6 +614,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
576
614
  ) {
577
615
  const { sessionId, socket, snapshot } = opts
578
616
 
617
+ this.sessions.get(sessionId)?.unlisten()
579
618
  this.sessions.set(sessionId, {
580
619
  assembler: new JsonChunkAssembler(),
581
620
  socket,
@@ -375,6 +375,67 @@ describe('TLSyncClient', () => {
375
375
  expect(pushWithPage!.diff![localPage.id][0]).toBe(RecordOpType.Put)
376
376
  })
377
377
 
378
+ it('[CL5] a reconnect flushes pending store history first, so a change queued behind the frame throttle is neither lost nor resurrected', () => {
379
+ connectClient()
380
+
381
+ socket.mockConnectionStatus('offline')
382
+ const localPage = makePage('Offline Page')
383
+ store.put([localPage]) // reaches speculativeChanges
384
+
385
+ // In the browser the store delivers history on the next animation frame, so the client's
386
+ // view of speculative changes can lag the store. Hold the next flush back the same way.
387
+ const flushHistory = store._flushHistory
388
+ store._flushHistory = () => {}
389
+ store.remove([localPage.id]) // ...so this delete is still queued when we reconnect
390
+ store._flushHistory = flushHistory
391
+
392
+ socket.mockConnectionStatus('online')
393
+ socket.mockServerMessage(
394
+ createConnectMessage({ hydrationType: 'wipe_presence', serverClock: 2, diff: {} })
395
+ )
396
+ vi.advanceTimersByTime(100)
397
+
398
+ // the user deleted the page: it must not come back, and must not be pushed as a put
399
+ expect(store.get(localPage.id)).toBeUndefined()
400
+ expect(
401
+ getSentPushes().some((msg) => msg.diff?.[localPage.id]?.[0] === RecordOpType.Put)
402
+ ).toBe(false)
403
+ })
404
+
405
+ it('[CL5] a store listener that throws during the reconnect flush does not stall the connect', () => {
406
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
407
+ try {
408
+ client = createClient()
409
+ let listenerThrown = false
410
+ store.listen(() => {
411
+ if (listenerThrown) return
412
+ listenerThrown = true
413
+ throw new Error('listener error')
414
+ })
415
+
416
+ // queue a local change behind the frame throttle so the connect flush delivers it
417
+ const flushHistory = store._flushHistory
418
+ store._flushHistory = () => {}
419
+ const localPage = makePage('Local Page', 'a5')
420
+ store.put([localPage])
421
+ store._flushHistory = flushHistory
422
+
423
+ const serverDiff = documentScopeDiff()
424
+ delete serverDiff[localPage.id] // the server never saw it
425
+ socket.mockServerMessage(createConnectMessage({ diff: serverDiff }))
426
+ vi.advanceTimersByTime(100)
427
+
428
+ expect(client.isConnectedToRoom).toBe(true)
429
+ expect(onLoad).toHaveBeenCalled()
430
+ expect(store.get(localPage.id)).toEqual(localPage)
431
+ expect(
432
+ getSentPushes().some((msg) => msg.diff?.[localPage.id]?.[0] === RecordOpType.Put)
433
+ ).toBe(true)
434
+ } finally {
435
+ consoleSpy.mockRestore()
436
+ }
437
+ })
438
+
378
439
  it('[CL6] wipe_all reconnect wipes document records before applying the server diff', () => {
379
440
  client = createClient()
380
441
 
@@ -754,6 +754,15 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
754
754
  this.resetConnection(true)
755
755
  return
756
756
  }
757
+ // Local changes reach `push` (and so `speculativeChanges`) on the store's frame-throttled
758
+ // history flush. Anything still queued there would be neither reverted below nor
759
+ // re-applied on top of the server state, so bring speculativeChanges up to date first.
760
+ try {
761
+ this.store._flushHistory()
762
+ } catch (e) {
763
+ console.error(e)
764
+ }
765
+
757
766
  // at the end of this process we want to have at most one pending push request
758
767
  // based on anything inside this.speculativeChanges
759
768
  transact(() => {
@@ -16,6 +16,7 @@ import {
16
16
  objectMapEntriesIterable,
17
17
  Result,
18
18
  throttle,
19
+ type ThrottledFunction,
19
20
  } from '@tldraw/utils'
20
21
  import { createNanoEvents } from 'nanoevents'
21
22
  import {
@@ -240,7 +241,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
240
241
 
241
242
  private pruneTimer: ReturnType<typeof setTimeout> | null = null
242
243
 
243
- pruneSessions = throttle(() => {
244
+ pruneSessions: ThrottledFunction<() => void> = throttle(() => {
244
245
  if (this.pruneTimer) {
245
246
  clearTimeout(this.pruneTimer)
246
247
  this.pruneTimer = null
@@ -656,9 +657,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
656
657
  networkDiff?: NetworkDiff<R> | null,
657
658
  sourceSessionId?: string
658
659
  ) {
659
- // Pre-compute network diff if not provided
660
- const unmigrated = networkDiff ?? toNetworkDiff(diff)
661
- if (!unmigrated) return this
660
+ // Computed once and shared by every session that needs no down-migration; the push path
661
+ // hands us the diff it already computed (in legacy append mode when needed), and re-deriving
662
+ // it per session would both repeat the diffing work and lose that legacy handling.
663
+ const legacyAppendMode = !this.getCanEmitStringAppend()
664
+ const unmigrated = networkDiff ?? toNetworkDiff(diff, legacyAppendMode)
662
665
 
663
666
  this.sessions.forEach((session) => {
664
667
  if (session.state !== RoomSessionState.Connected) return
@@ -672,7 +675,9 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
672
675
  session.sessionId,
673
676
  session.serializedSchema,
674
677
  session.requiresDownMigrations,
675
- diff
678
+ diff,
679
+ unmigrated,
680
+ legacyAppendMode
676
681
  )
677
682
  if (!diffResult.ok) return
678
683
 
@@ -821,9 +826,22 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
821
826
  supportsStringAppend,
822
827
  })
823
828
 
829
+ // Restore presence before the schema checks: other clients still hold it from before the
830
+ // socket slept, and a rejection only broadcasts its removal if it's in the store
824
831
  if (presenceRecord && presenceId) {
825
832
  this.presenceStore.set(presenceId, presenceRecord as R)
826
833
  }
834
+
835
+ // The server may have changed builds while the socket slept, so re-run the handshake's
836
+ // schema checks (HS3): a schema we can no longer reconcile must not be served raw diffs.
837
+ if (!migrations.ok) {
838
+ this.rejectSession(sessionId, this.getVersionMismatchReason(serializedSchema))
839
+ return
840
+ }
841
+ if (migrations.value.some((m) => m.scope !== 'record' || !m.down)) {
842
+ this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
843
+ return
844
+ }
827
845
  }
828
846
 
829
847
  /**
@@ -858,6 +876,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
858
876
  * @param requiresDownMigrations - Whether the client needs down migrations
859
877
  * @param diff - The TLSyncForwardDiff containing full records to migrate
860
878
  * @param unmigrated - Optional pre-computed NetworkDiff for when no migration is needed
879
+ * @param legacyAppendMode - Emit string appends as puts (SES5); defaults to the room-wide state
861
880
  * @returns A NetworkDiff with migrated records, or a migration failure
862
881
  */
863
882
  private migrateDiffOrRejectSession(
@@ -865,10 +884,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
865
884
  serializedSchema: SerializedSchema,
866
885
  requiresDownMigrations: boolean,
867
886
  diff: TLSyncForwardDiff<R>,
868
- unmigrated?: NetworkDiff<R>
887
+ unmigrated?: NetworkDiff<R>,
888
+ legacyAppendMode = !this.getCanEmitStringAppend()
869
889
  ): Result<NetworkDiff<R>, MigrationFailureReason> {
870
890
  if (!requiresDownMigrations) {
871
- return Result.ok(unmigrated ?? toNetworkDiff(diff) ?? {})
891
+ return Result.ok(unmigrated ?? toNetworkDiff(diff, legacyAppendMode))
872
892
  }
873
893
 
874
894
  const result: NetworkDiff<R> = {}
@@ -888,7 +908,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
888
908
  this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
889
909
  return Result.err(toResult.reason)
890
910
  }
891
- const patch = diffRecord(fromResult.value, toResult.value)
911
+ const patch = diffRecord(fromResult.value, toResult.value, legacyAppendMode)
892
912
  if (patch) {
893
913
  result[id] = [RecordOpType.Patch, patch]
894
914
  }
@@ -1056,7 +1076,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1056
1076
  return TLSyncErrorCloseEventReason.SERVER_TOO_OLD
1057
1077
  }
1058
1078
 
1059
- if (theirSchema.schemaVersion === 2 && ourSchema.schemaVersion === 2) {
1079
+ if (
1080
+ theirSchema.schemaVersion === 2 &&
1081
+ ourSchema.schemaVersion === 2 &&
1082
+ // a malformed client schema must reject the session, not throw
1083
+ typeof theirSchema.sequences === 'object' &&
1084
+ theirSchema.sequences !== null
1085
+ ) {
1060
1086
  for (const [sequenceId, theirVersion] of Object.entries(theirSchema.sequences)) {
1061
1087
  const ourVersion = ourSchema.sequences[sequenceId]
1062
1088
  if (ourVersion === undefined || theirVersion > ourVersion) {
@@ -1261,7 +1287,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1261
1287
  }
1262
1288
  let { value: state } = res
1263
1289
 
1264
- // Get the existing document, if any
1265
1290
  const doc =
1266
1291
  prevDoc !== undefined ? (prevDoc ?? undefined) : (storage.get(id) as R | undefined)
1267
1292
 
@@ -1277,7 +1302,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1277
1302
  // If there's an existing document, replace it with the new state
1278
1303
  // but propagate a diff rather than the entire value
1279
1304
  const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
1280
- const diff = diffAndValidateRecord(doc, state, recordType)
1305
+ const diff = diffAndValidateRecord(doc, state, recordType, legacyAppendMode)
1281
1306
  if (diff) {
1282
1307
  storage.set(id, state)
1283
1308
  propagateOp(changes, id, [RecordOpType.Patch, diff], doc, state)
@@ -1395,6 +1420,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1395
1420
  !session ||
1396
1421
  (this.objectTypes.has(typeName) ? session.objectAccess !== 'read' : !session.isReadonly)
1397
1422
 
1423
+ // what authorizers see of the pushing session, shared by every authorized op in this push
1424
+ const authSession = session
1425
+ ? { sessionId: session.sessionId, isReadonly: session.isReadonly, meta: session.meta }
1426
+ : null
1427
+
1398
1428
  if (message.diff) {
1399
1429
  // The push request was for the document scope.
1400
1430
  for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
@@ -1445,26 +1475,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1445
1475
  authorize = (prevRec, next) => {
1446
1476
  const result = authorizePut(
1447
1477
  prevRec
1448
- ? {
1449
- session: {
1450
- sessionId: session.sessionId,
1451
- isReadonly: session.isReadonly,
1452
- meta: session.meta,
1453
- },
1454
- type: 'update',
1455
- prev: prevRec,
1456
- next,
1457
- }
1458
- : {
1459
- session: {
1460
- sessionId: session.sessionId,
1461
- isReadonly: session.isReadonly,
1462
- meta: session.meta,
1463
- },
1464
- type: 'create',
1465
- prev: null,
1466
- next,
1467
- }
1478
+ ? { session: authSession!, type: 'update', prev: prevRec, next }
1479
+ : { session: authSession!, type: 'create', prev: null, next }
1468
1480
  )
1469
1481
  if (!result) {
1470
1482
  this.log?.warn?.(
@@ -1496,11 +1508,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1496
1508
  const authorize = authorizePatch
1497
1509
  ? (prev: R, next: R) => {
1498
1510
  const result = authorizePatch({
1499
- session: {
1500
- sessionId: session.sessionId,
1501
- isReadonly: session.isReadonly,
1502
- meta: session.meta,
1503
- },
1511
+ session: authSession!,
1504
1512
  type: 'update',
1505
1513
  prev,
1506
1514
  next,
@@ -1534,16 +1542,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1534
1542
  const authorizeRemove = session && this.authorizerFor(doc.typeName)
1535
1543
  if (
1536
1544
  authorizeRemove &&
1537
- !authorizeRemove({
1538
- session: {
1539
- sessionId: session.sessionId,
1540
- isReadonly: session.isReadonly,
1541
- meta: session.meta,
1542
- },
1543
- type: 'delete',
1544
- prev: doc,
1545
- next: null,
1546
- })
1545
+ !authorizeRemove({ session: authSession!, type: 'delete', prev: doc, next: null })
1547
1546
  ) {
1548
1547
  this.log?.warn?.(
1549
1548
  'authorizer vetoed delete',
@@ -1575,7 +1574,10 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1575
1574
  let pushResult: TLSocketServerSentEvent<R> | undefined
1576
1575
  if (changes && session) {
1577
1576
  // txn did not apply verbatim so we should broadcast the actual changes
1578
- result.docChanges.diffs = { networkDiff: toNetworkDiff(changes) ?? {}, diff: changes }
1577
+ result.docChanges.diffs = {
1578
+ networkDiff: toNetworkDiff(changes, legacyAppendMode),
1579
+ diff: changes,
1580
+ }
1579
1581
  }
1580
1582
 
1581
1583
  if (isEqual(result.docChanges.diffs?.networkDiff, message.diff)) {
@@ -1600,7 +1602,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1600
1602
  session.serializedSchema,
1601
1603
  session.requiresDownMigrations,
1602
1604
  result.docChanges.diffs.diff,
1603
- result.docChanges.diffs.networkDiff
1605
+ result.docChanges.diffs.networkDiff,
1606
+ legacyAppendMode
1604
1607
  )
1605
1608
  if (diff.ok) {
1606
1609
  pushResult = {