@tldraw/sync-core 5.5.0 → 5.6.0-canary.5bfc37740468
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.
- package/dist-cjs/index.d.ts +1 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +5 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +30 -44
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js +2 -2
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/diff.js +15 -7
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-esm/index.d.mts +1 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +5 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +30 -44
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs +16 -8
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/package.json +18 -18
- package/src/lib/InMemorySyncStorage.ts +2 -2
- package/src/lib/SQLiteSyncStorage.ts +2 -2
- package/src/lib/TLSyncClient.test.ts +61 -0
- package/src/lib/TLSyncClient.ts +9 -0
- package/src/lib/TLSyncRoom.ts +51 -48
- package/src/lib/TLSyncStorage.ts +7 -2
- package/src/lib/diff.test.ts +91 -1
- package/src/lib/diff.ts +30 -9
- package/src/test/TLSyncRoom.test.ts +124 -0
- 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": "AACA,SAAS,SAAS,kBAAkB,uBAAuB;
|
|
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.
|
|
4
|
+
"version": "5.6.0-canary.5bfc37740468",
|
|
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.
|
|
44
|
+
"@tldraw/editor": "5.6.0-canary.5bfc37740468",
|
|
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.
|
|
53
|
+
"tldraw": "5.6.0-canary.5bfc37740468",
|
|
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.
|
|
71
|
-
"@tldraw/store": "5.
|
|
72
|
-
"@tldraw/tlschema": "5.
|
|
73
|
-
"@tldraw/utils": "5.
|
|
60
|
+
"@tldraw/state": "5.6.0-canary.5bfc37740468",
|
|
61
|
+
"@tldraw/store": "5.6.0-canary.5bfc37740468",
|
|
62
|
+
"@tldraw/tlschema": "5.6.0-canary.5bfc37740468",
|
|
63
|
+
"@tldraw/utils": "5.6.0-canary.5bfc37740468",
|
|
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
|
|
package/src/lib/TLSyncClient.ts
CHANGED
|
@@ -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(() => {
|
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -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
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
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 (
|
|
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
|
-
|
|
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 = {
|
|
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 = {
|
package/src/lib/TLSyncStorage.ts
CHANGED
|
@@ -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>(
|
|
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
|
}
|
package/src/lib/diff.test.ts
CHANGED
|
@@ -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
|
-
|
|
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)', () => {
|