@tldraw/sync-core 5.5.0 → 5.6.0-canary.07d34be92012

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 (35) hide show
  1. package/dist-cjs/index.d.ts +1 -2
  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/TLSyncClient.js +5 -0
  6. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncRoom.js +30 -44
  8. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncStorage.js +2 -2
  10. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  11. package/dist-cjs/lib/diff.js +15 -7
  12. package/dist-cjs/lib/diff.js.map +2 -2
  13. package/dist-esm/index.d.mts +1 -2
  14. package/dist-esm/index.mjs +1 -1
  15. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  16. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  17. package/dist-esm/lib/TLSyncClient.mjs +5 -0
  18. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  19. package/dist-esm/lib/TLSyncRoom.mjs +30 -44
  20. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  21. package/dist-esm/lib/TLSyncStorage.mjs +2 -2
  22. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  23. package/dist-esm/lib/diff.mjs +16 -8
  24. package/dist-esm/lib/diff.mjs.map +2 -2
  25. package/package.json +18 -18
  26. package/src/lib/InMemorySyncStorage.ts +2 -2
  27. package/src/lib/SQLiteSyncStorage.ts +2 -2
  28. package/src/lib/TLSyncClient.test.ts +61 -0
  29. package/src/lib/TLSyncClient.ts +9 -0
  30. package/src/lib/TLSyncRoom.ts +51 -48
  31. package/src/lib/TLSyncStorage.ts +7 -2
  32. package/src/lib/diff.test.ts +91 -1
  33. package/src/lib/diff.ts +30 -9
  34. package/src/test/TLSyncRoom.test.ts +124 -0
  35. 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": "AACA,SAAS,gBAAgB,SAAS,kBAAkB,uBAAuB;AAQpE,MAAM,eAAe;AAAA,EAC3B,KAAK;AAAA,EACL,OAAO;AAAA,EACP,QAAQ;AACT;AA4DO,SAAS,eACf,MACwB;AACxB,MAAI,MAA6B;AAEjC,aAAW,CAAC,GAAG,CAAC,KAAK,iBAAiB,KAAK,KAAK,GAAG;AAClD,QAAI,CAAC,IAAK,OAAM,CAAC;AACjB,QAAI,CAAC,IAAI,CAAC,aAAa,KAAK,CAAC;AAAA,EAC9B;AAEA,aAAW,CAAC,MAAM,EAAE,KAAK,gBAAgB,KAAK,OAAO,GAAG;AACvD,UAAMA,QAAO,WAAW,MAAM,EAAE;AAChC,QAAIA,OAAM;AACT,UAAI,CAAC,IAAK,OAAM,CAAC;AACjB,UAAI,GAAG,EAAE,IAAI,CAAC,aAAa,OAAOA,KAAI;AAAA,IACvC;AAAA,EACD;AAEA,aAAW,WAAW,OAAO,KAAK,KAAK,OAAO,GAAG;AAChD,QAAI,CAAC,IAAK,OAAM,CAAC;AACjB,QAAI,OAAO,IAAI,CAAC,aAAa,MAAM;AAAA,EACpC;AAEA,SAAO;AACR;AAQO,MAAM,cAAc;AAAA,EAC1B,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,OAAO;AACR;AA0EO,SAAS,WACf,MACA,MACA,mBAAmB,OACC;AACpB,SAAO,WAAW,MAAM,MAAM,oBAAI,IAAI,CAAC,SAAS,MAAM,CAAC,GAAG,gBAAgB;AAC3E;AAEA,SAAS,WACR,MACA,MACA,YACA,kBACoB;AACpB,MAAI,SAAS,MAAM;AAClB,WAAO;AAAA,EACR;AACA,MAAI,SAA4B;AAKhC,aAAW,OAAO,OAAO,KAAK,IAAI,GAAG;AAEpC,QAAI,QAAQ,YAAa;AACzB,UAAM,YAAa,KAAa,GAAG;AACnC,QAAI,cAAc,OAAW;AAC7B,UAAM,YAAY,eAAe,MAAM,GAAG,IAAK,KAAa,GAAG,IAAI;AACnE,QAAI,cAAc,QAAW;AAC5B,UAAI,CAAC,OAAQ,UAAS,CAAC;AACvB,aAAO,GAAG,IAAI,CAAC,YAAY,MAAM;AACjC;AAAA,IACD;AACA,QACC,YAAY,IAAI,GAAG,KAClB,MAAM,QAAQ,SAAS,KAAK,MAAM,QAAQ,SAAS,KACnD,OAAO,cAAc,YAAY,OAAO,cAAc,UACtD;AAED,YAAM,OAAO,UAAU,WAAW,WAAW,gBAAgB;AAC7D,UAAI,MAAM;AACT,YAAI,CAAC,OAAQ,UAAS,CAAC;AACvB,eAAO,GAAG,IAAI;AAAA,MACf;AAAA,IACD,WAAW,CAAC,QAAQ,WAAW,SAAS,GAAG;AAC1C,UAAI,CAAC,OAAQ,UAAS,CAAC;AACvB,aAAO,GAAG,IAAI,CAAC,YAAY,KAAK,SAAS;AAAA,IAC1C;AAAA,EACD;AACA,aAAW,OAAO,OAAO,KAAK,IAAI,GAAG;AACpC,QAAI,QAAQ,YAAa;AACzB,UAAM,YAAa,KAAa,GAAG;AACnC,QAAI,cAAc,OAAW;AAE7B,QAAI,CAAC,eAAe,MAAM,GAAG,KAAM,KAAa,GAAG,MAAM,QAAW;AACnE,UAAI,CAAC,OAAQ,UAAS,CAAC;AACvB,aAAO,GAAG,IAAI,CAAC,YAAY,KAAK,SAAS;AAAA,IAC1C;AAAA,EACD;AACA,SAAO;AACR;AAEA,SAAS,UAAU,QAAiB,QAAiB,kBAA2C;AAC/F,MAAI,OAAO,GAAG,QAAQ,MAAM,EAAG,QAAO;AACtC,MAAI,MAAM,QAAQ,MAAM,KAAK,MAAM,QAAQ,MAAM,GAAG;AACnD,WAAO,UAAU,QAAQ,QAAQ,gBAAgB;AAAA,EAClD,WAAW,OAAO,WAAW,YAAY,OAAO,WAAW,UAAU;AACpE,QAAI,CAAC,oBAAoB,OAAO,WAAW,MAAM,GAAG;AACnD,YAAM,eAAe,OAAO,MAAM,OAAO,MAAM;AAC/C,aAAO,CAAC,YAAY,QAAQ,cAAc,OAAO,MAAM;AAAA,IACxD;AACA,WAAO,CAAC,YAAY,KAAK,MAAM;AAAA,EAChC,WACC,CAAC,UACD,CAAC,UACD,OAAO,WAAW,YAClB,OAAO,WAAW;AAAA;AAAA,EAGlB,MAAM,QAAQ,MAAM,MAAM,MAAM,QAAQ,MAAM,GAC7C;AACD,WAAO,QAAQ,QAAQ,MAAM,IAAI,OAAO,CAAC,YAAY,KAAK,MAAM;AAAA,EACjE,OAAO;AACN,UAAM,OAAO,WAAW,QAAQ,QAAQ,QAAW,gBAAgB;AACnE,WAAO,OAAO,CAAC,YAAY,OAAO,IAAI,IAAI;AAAA,EAC3C;AACD;AAEA,SAAS,UACR,WACA,WACA,kBACoC;AACpC,MAAI,OAAO,GAAG,WAAW,SAAS,EAAG,QAAO;AAE5C,MAAI,UAAU,WAAW,UAAU,QAAQ;AAE1C,UAAM,kBAAkB,KAAK,IAAI,UAAU,SAAS,GAAG,CAAC;AACxD,UAAM,iBAAiB,CAAC;AACxB,aAAS,IAAI,GAAG,IAAI,UAAU,QAAQ,KAAK;AAC1C,UAAI,CAAC,QAAQ,UAAU,CAAC,GAAG,UAAU,CAAC,CAAC,GAAG;AACzC,uBAAe,KAAK,CAAC;AACrB,YAAI,eAAe,SAAS,iBAAiB;AAC5C,iBAAO,CAAC,YAAY,KAAK,SAAS;AAAA,QACnC;AAAA,MACD;AAAA,IACD;AACA,QAAI,eAAe,WAAW,GAAG;AAEhC,aAAO;AAAA,IACR;AACA,UAAM,OAAmB,CAAC;AAC1B,eAAW,KAAK,gBAAgB;AAC/B,YAAM,WAAW,UAAU,CAAC;AAC5B,YAAM,WAAW,UAAU,CAAC;AAC5B,UAAI,CAAC,YAAY,CAAC,UAAU;AAC3B,aAAK,CAAC,IAAI,CAAC,YAAY,KAAK,QAAQ;AAAA,MACrC,WAAW,OAAO,aAAa,YAAY,OAAO,aAAa,UAAU;AACxE,cAAM,KAAK,UAAU,UAAU,UAAU,gBAAgB;AACzD,YAAI,IAAI;AACP,eAAK,CAAC,IAAI;AAAA,QACX;AAAA,MACD,OAAO;AACN,aAAK,CAAC,IAAI,CAAC,YAAY,KAAK,QAAQ;AAAA,MACrC;AAAA,IACD;AACA,WAAO,CAAC,YAAY,OAAO,IAAI;AAAA,EAChC;AAIA,WAAS,IAAI,GAAG,IAAI,UAAU,QAAQ,KAAK;AAC1C,QAAI,CAAC,QAAQ,UAAU,CAAC,GAAG,UAAU,CAAC,CAAC,GAAG;AACzC,aAAO,CAAC,YAAY,KAAK,SAAS;AAAA,IACnC;AAAA,EACD;AAEA,SAAO,CAAC,YAAY,QAAQ,UAAU,MAAM,UAAU,MAAM,GAAG,UAAU,MAAM;AAChF;AAyBO,SAAS,gBAAkC,QAAW,YAA2B;AAEvF,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAClD,QAAM,UAAU,MAAM,QAAQ,MAAM;AACpC,MAAI,YAA6B;AACjC,QAAM,MAAM,CAAC,GAAQ,MAAW;AAC/B,QAAI,CAAC,WAAW;AACf,UAAI,SAAS;AACZ,oBAAY,CAAC,GAAG,MAAM;AAAA,MACvB,OAAO;AACN,oBAAY,EAAE,GAAG,OAAO;AAAA,MACzB;AAAA,IACD;AACA,QAAI,SAAS;AACZ,gBAAU,OAAO,CAAC,CAAC,IAAI;AAAA,IACxB,OAAO;AACN,gBAAU,CAAC,IAAI;AAAA,IAChB;AAAA,EACD;AACA,aAAW,CAAC,KAAK,EAAE,KAAK,OAAO,QAAQ,UAAU,GAAG;AAInD,QAAI,QAAQ,YAAa;AACzB,YAAQ,GAAG,CAAC,GAAG;AAAA,MACd,KAAK,YAAY,KAAK;AACrB,cAAM,QAAQ,GAAG,CAAC;AAClB,YAAI,CAAC,QAAQ,OAAO,GAAc,GAAG,KAAK,GAAG;AAC5C,cAAI,KAAK,KAAK;AAAA,QACf;AACA;AAAA,MACD;AAAA,MACA,KAAK,YAAY,QAAQ;AACxB,cAAM,QAAQ,GAAG,CAAC;AAClB,cAAM,SAAS,GAAG,CAAC;AACnB,cAAM,eAAe,OAAO,GAAc;AAC1C,YAAI,MAAM,QAAQ,YAAY,KAAK,MAAM,QAAQ,KAAK,KAAK,aAAa,WAAW,QAAQ;AAC1F,cAAI,KAAK,CAAC,GAAG,cAAc,GAAG,KAAK,CAAC;AAAA,QACrC,WACC,OAAO,iBAAiB,YACxB,OAAO,UAAU,YACjB,aAAa,WAAW,QACvB;AACD,cAAI,KAAK,eAAe,KAAK;AAAA,QAC9B;AAEA;AAAA,MACD;AAAA,MACA,KAAK,YAAY,OAAO;AACvB,YAAI,OAAO,GAAc,KAAK,OAAO,OAAO,GAAc,MAAM,UAAU;AACzE,gBAAM,OAAO,GAAG,CAAC;AACjB,gBAAM,UAAU,gBAAgB,OAAO,GAAc,GAAa,IAAI;AACtE,cAAI,YAAY,OAAO,GAAc,GAAG;AACvC,gBAAI,KAAK,OAAO;AAAA,UACjB;AAAA,QACD;AACA;AAAA,MACD;AAAA,MACA,KAAK,YAAY,QAAQ;AACxB,YAAI,eAAe,QAAQ,GAAG,GAAG;AAChC,cAAI,CAAC,WAAW;AACf,gBAAI,SAAS;AACZ,sBAAQ,MAAM,wDAAwD;AACtE,0BAAY,CAAC,GAAG,MAAM;AAAA,YACvB,OAAO;AACN,0BAAY,EAAE,GAAG,OAAO;AAAA,YACzB;AAAA,UACD;AACA,iBAAO,UAAU,GAAG;AAAA,QACrB;AAAA,MACD;AAAA,IACD;AAAA,EACD;AAEA,SAAO,aAAa;AACrB;",
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.0",
4
+ "version": "5.6.0-canary.07d34be92012",
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.0",
44
+ "@tldraw/editor": "5.6.0-canary.07d34be92012",
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.0",
53
+ "tldraw": "5.6.0-canary.07d34be92012",
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.0",
71
- "@tldraw/store": "5.5.0",
72
- "@tldraw/tlschema": "5.5.0",
73
- "@tldraw/utils": "5.5.0",
60
+ "@tldraw/state": "5.6.0-canary.07d34be92012",
61
+ "@tldraw/store": "5.6.0-canary.07d34be92012",
62
+ "@tldraw/tlschema": "5.6.0-canary.07d34be92012",
63
+ "@tldraw/utils": "5.6.0-canary.07d34be92012",
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
@@ -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 = {
@@ -146,13 +146,18 @@ export interface TLSyncForwardDiff<R extends UnknownRecord> {
146
146
  }
147
147
 
148
148
  /**
149
+ * @param legacyAppendMode - When true, string appends are emitted as puts (see `diffRecord`); the
150
+ * room sets this whenever a connected client predates string-append support.
149
151
  * @internal
150
152
  */
151
- export function toNetworkDiff<R extends UnknownRecord>(diff: TLSyncForwardDiff<R>): NetworkDiff<R> {
153
+ export function toNetworkDiff<R extends UnknownRecord>(
154
+ diff: TLSyncForwardDiff<R>,
155
+ legacyAppendMode = false
156
+ ): NetworkDiff<R> {
152
157
  const networkDiff: NetworkDiff<R> = {}
153
158
  for (const [id, put] of objectMapEntriesIterable(diff.puts)) {
154
159
  if (Array.isArray(put)) {
155
- const patch = diffRecord(put[0], put[1])
160
+ const patch = diffRecord(put[0], put[1], legacyAppendMode)
156
161
  if (patch) {
157
162
  networkDiff[id] = [RecordOpType.Patch, patch]
158
163
  }
@@ -39,6 +39,52 @@ describe('computing diffs (D)', () => {
39
39
  expect(patch).toEqual({ c: [ValueOpType.Put, 3] })
40
40
  expect(applyObjectDiff(a, patch!)).toEqual(b)
41
41
  })
42
+
43
+ it('[D2] treats a key whose value is undefined as absent', () => {
44
+ // undefined does not survive JSON, so `['put', undefined]` would arrive as
45
+ // `['put', null]` and fail validators that only accept the key being missing
46
+ const present = { a: 1, props: { foo: 'x' } }
47
+ const explicitlyUndefined = { a: 1, props: { foo: undefined } }
48
+ const absent = { a: 1, props: {} }
49
+
50
+ expect(diffRecord(present, explicitlyUndefined)).toEqual({
51
+ props: [ValueOpType.Patch, { foo: [ValueOpType.Delete] }],
52
+ })
53
+ expect(diffRecord(explicitlyUndefined, present)).toEqual({
54
+ props: [ValueOpType.Patch, { foo: [ValueOpType.Put, 'x'] }],
55
+ })
56
+ expect(diffRecord(absent, explicitlyUndefined)).toBeNull()
57
+ expect(diffRecord(explicitlyUndefined, absent)).toBeNull()
58
+ expect(diffRecord({ a: undefined }, { a: undefined })).toBeNull()
59
+ })
60
+
61
+ it('[D2] only considers own keys, not Object.prototype members', () => {
62
+ const withCtor = { meta: { constructor: 1, toString: 2 } }
63
+ const without = { meta: {} }
64
+
65
+ expect(diffRecord(withCtor, without)).toEqual({
66
+ meta: [
67
+ ValueOpType.Patch,
68
+ { constructor: [ValueOpType.Delete], toString: [ValueOpType.Delete] },
69
+ ],
70
+ })
71
+ expect(diffRecord(without, withCtor)).toEqual({
72
+ meta: [
73
+ ValueOpType.Patch,
74
+ { constructor: [ValueOpType.Put, 1], toString: [ValueOpType.Put, 2] },
75
+ ],
76
+ })
77
+ })
78
+
79
+ it('[D2] ignores __proto__ keys, which applyObjectDiff would drop (AD8)', () => {
80
+ // JSON.parse creates '__proto__' as an own key; diffing it would store it on the server
81
+ // while peers receive an op they drop
82
+ const polluted = JSON.parse('{"__proto__":{"evil":1},"meta":{"__proto__":{"evil":1}}}')
83
+ const clean = { meta: {} }
84
+
85
+ expect(diffRecord(clean, polluted)).toBeNull()
86
+ expect(diffRecord(polluted, clean)).toBeNull()
87
+ })
42
88
  })
43
89
 
44
90
  describe('top-level keys vs props/meta (D3)', () => {
@@ -102,7 +148,8 @@ describe('computing diffs (D)', () => {
102
148
 
103
149
  const diff = diffRecord(prev, next)
104
150
  expect(diff).toBeTruthy()
105
- expect(diff!.optional).toEqual([ValueOpType.Put, undefined])
151
+ // undefined is not representable on the wire, so it reads as the key being absent (D2)
152
+ expect(diff!.optional).toEqual([ValueOpType.Delete])
106
153
  expect(diff!.nullable).toEqual([ValueOpType.Put, 'value'])
107
154
  })
108
155
  })
@@ -378,6 +425,25 @@ describe('computing diffs (D)', () => {
378
425
  arr: [ValueOpType.Patch, { '0': [ValueOpType.Put, null] }],
379
426
  })
380
427
  })
428
+
429
+ it('[D6] puts a changed index when an item switches between array and object', () => {
430
+ // index deletes and named puts don't apply cleanly across shapes, so a patch would
431
+ // leave the receiver with a sparse array / an object where an array is expected
432
+ const arrayItem = { meta: { items: [[1, 2]] } }
433
+ const objectItem = { meta: { items: [{ x: 1 }] } }
434
+
435
+ const toObject = diffRecord(arrayItem, objectItem)!
436
+ expect(toObject).toEqual({
437
+ meta: [
438
+ ValueOpType.Patch,
439
+ { items: [ValueOpType.Patch, { '0': [ValueOpType.Put, { x: 1 }] }] },
440
+ ],
441
+ })
442
+ expect(applyObjectDiff(arrayItem, toObject)).toEqual(objectItem)
443
+
444
+ const toArray = diffRecord(objectItem, arrayItem)!
445
+ expect(applyObjectDiff(objectItem, toArray)).toEqual(arrayItem)
446
+ })
381
447
  })
382
448
 
383
449
  describe('different-length arrays (D7)', () => {
@@ -747,6 +813,30 @@ describe('applying diffs (AD)', () => {
747
813
 
748
814
  expect(applyObjectDiff(obj, diff)).toBe(obj)
749
815
  })
816
+
817
+ it('[AD5] deleting an inherited key has no effect', () => {
818
+ const obj = { a: 1 }
819
+ // `toString` collides with Object.prototype's own member in the literal's type, hence the cast
820
+ const diff = { toString: [ValueOpType.Delete] } as unknown as ObjectDiff
821
+
822
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
823
+ })
824
+ })
825
+
826
+ describe('untrusted keys (AD8)', () => {
827
+ it('[AD8] ignores __proto__ keys instead of changing the prototype', () => {
828
+ // JSON.parse produces '__proto__' as an own key, so a peer can put it in a diff
829
+ const obj = { a: 1, props: { b: 2 } }
830
+ const diff: ObjectDiff = JSON.parse(
831
+ '{"__proto__":["put",{"isAdmin":true}],"props":["patch",{"__proto__":["put",{"polluted":1}]}]}'
832
+ )
833
+
834
+ const result: any = applyObjectDiff(obj, diff)
835
+ expect(result).toBe(obj)
836
+ expect(Object.getPrototypeOf(result)).toBe(Object.prototype)
837
+ expect(result.isAdmin).toBeUndefined()
838
+ expect(result.props.polluted).toBeUndefined()
839
+ })
750
840
  })
751
841
 
752
842
  describe('patching non-objects (AD6)', () => {