@tldraw/sync-core 5.3.0-canary.9a74cc3f19c1 → 5.3.0-canary.b7898239508e
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.js +1 -1
- package/dist-esm/index.mjs +1 -1
- package/package.json +6 -6
- package/src/test/TLSyncClientRebase.test.ts +285 -0
package/dist-cjs/index.js
CHANGED
|
@@ -61,7 +61,7 @@ var import_TLSyncRoom = require("./lib/TLSyncRoom");
|
|
|
61
61
|
var import_TLSyncStorage = require("./lib/TLSyncStorage");
|
|
62
62
|
(0, import_utils.registerTldrawLibraryVersion)(
|
|
63
63
|
"@tldraw/sync-core",
|
|
64
|
-
"5.3.0-canary.
|
|
64
|
+
"5.3.0-canary.b7898239508e",
|
|
65
65
|
"cjs"
|
|
66
66
|
);
|
|
67
67
|
//# sourceMappingURL=index.js.map
|
package/dist-esm/index.mjs
CHANGED
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.3.0-canary.
|
|
4
|
+
"version": "5.3.0-canary.b7898239508e",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw GB Ltd.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -49,17 +49,17 @@
|
|
|
49
49
|
"@types/uuid-readable": "^0.0.3",
|
|
50
50
|
"react": "^19.2.1",
|
|
51
51
|
"react-dom": "^19.2.1",
|
|
52
|
-
"tldraw": "5.3.0-canary.
|
|
52
|
+
"tldraw": "5.3.0-canary.b7898239508e",
|
|
53
53
|
"typescript": "^5.8.3",
|
|
54
54
|
"uuid-by-string": "^4.0.0",
|
|
55
55
|
"uuid-readable": "^0.0.2",
|
|
56
56
|
"vitest": "^4.1.7"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@tldraw/state": "5.3.0-canary.
|
|
60
|
-
"@tldraw/store": "5.3.0-canary.
|
|
61
|
-
"@tldraw/tlschema": "5.3.0-canary.
|
|
62
|
-
"@tldraw/utils": "5.3.0-canary.
|
|
59
|
+
"@tldraw/state": "5.3.0-canary.b7898239508e",
|
|
60
|
+
"@tldraw/store": "5.3.0-canary.b7898239508e",
|
|
61
|
+
"@tldraw/tlschema": "5.3.0-canary.b7898239508e",
|
|
62
|
+
"@tldraw/utils": "5.3.0-canary.b7898239508e",
|
|
63
63
|
"nanoevents": "^7.0.1",
|
|
64
64
|
"ws": "^8.18.0"
|
|
65
65
|
},
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { computed } from '@tldraw/state'
|
|
2
|
+
import {
|
|
3
|
+
BaseRecord,
|
|
4
|
+
RecordId,
|
|
5
|
+
RecordsDiff,
|
|
6
|
+
Store,
|
|
7
|
+
StoreSchema,
|
|
8
|
+
createRecordType,
|
|
9
|
+
} from '@tldraw/store'
|
|
10
|
+
import { isEqual } from '@tldraw/utils'
|
|
11
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
12
|
+
import { ValueOpType } from '../lib/diff'
|
|
13
|
+
import { TLPushRequest } from '../lib/protocol'
|
|
14
|
+
import { TLSyncClient } from '../lib/TLSyncClient'
|
|
15
|
+
import { TestServer } from './TestServer'
|
|
16
|
+
import { TestSocketPair } from './TestSocketPair'
|
|
17
|
+
|
|
18
|
+
// These tests pin two invariants of TLSyncClient.rebase that hold *emergently* — they fall out of
|
|
19
|
+
// the tldraw Store's mergeRemoteChanges / history squashing rather than being asserted anywhere.
|
|
20
|
+
// They only manifest on STAGGERED commit acks: when the client has several pending (unacked) pushes
|
|
21
|
+
// and the server commits them one at a time, each ack triggering its own rebase() while the later
|
|
22
|
+
// pushes are still in flight.
|
|
23
|
+
//
|
|
24
|
+
// Invariant 1 — a staggered commit ack emits no net change to store listeners. rebase() rewinds the
|
|
25
|
+
// speculative changes, applies the committed push, then re-applies the still-pending pushes, all
|
|
26
|
+
// inside a single store.mergeRemoteChanges. The rewind and re-apply must cancel, so a listener
|
|
27
|
+
// registered before the ack observes no net document change (either no history entry, or a
|
|
28
|
+
// net-zero 'remote' entry whose before/after are value-equal), while the store still holds the full
|
|
29
|
+
// optimistic state.
|
|
30
|
+
//
|
|
31
|
+
// Invariant 2 — after such an ack, speculativeChanges must span *from the committed (server-acked)
|
|
32
|
+
// state*, not from a mid-flight state. reverseRecordsDiff(speculativeChanges) is used as the rewind
|
|
33
|
+
// on the next incoming message; if its `from` were a mid-flight state, base-relative ops (string
|
|
34
|
+
// Append ops, per-index array patches — see diffArray in ../lib/diff.ts) would be applied to the
|
|
35
|
+
// wrong base and silently corrupt records, because the apply guards skip mismatched appends rather
|
|
36
|
+
// than erroring. To exercise those base-relative ops, every change here is append-shaped.
|
|
37
|
+
//
|
|
38
|
+
// Harness: a real TestServer / TestSocketPair (as in TLSyncRoom.test.ts). Because the throttles are
|
|
39
|
+
// pass-through under NODE_ENV=test, every store change, push, and rebase runs synchronously — no
|
|
40
|
+
// fake timers needed. We deliver each client push to the server individually so the server emits one
|
|
41
|
+
// push_result at a time, letting us drive genuinely staggered acks.
|
|
42
|
+
|
|
43
|
+
interface TestDoc extends BaseRecord<'doc', RecordId<TestDoc>> {
|
|
44
|
+
text: string
|
|
45
|
+
items: number[]
|
|
46
|
+
}
|
|
47
|
+
const TestDocType = createRecordType<TestDoc>('doc', {
|
|
48
|
+
scope: 'document',
|
|
49
|
+
validator: { validate: (value) => value as TestDoc },
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
interface TestPresence extends BaseRecord<'presence', RecordId<TestPresence>> {
|
|
53
|
+
name: string
|
|
54
|
+
}
|
|
55
|
+
const TestPresenceType = createRecordType<TestPresence>('presence', {
|
|
56
|
+
scope: 'presence',
|
|
57
|
+
validator: { validate: (value) => value as TestPresence },
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const testSchema = StoreSchema.create<TestDoc | TestPresence>({
|
|
61
|
+
doc: TestDocType,
|
|
62
|
+
presence: TestPresenceType,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
type R = TestDoc | TestPresence
|
|
66
|
+
|
|
67
|
+
interface Instance {
|
|
68
|
+
server: TestServer<R>
|
|
69
|
+
socketPair: TestSocketPair<R>
|
|
70
|
+
client: TLSyncClient<R>
|
|
71
|
+
store: Store<R>
|
|
72
|
+
docId: RecordId<TestDoc>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const disposables: Array<() => void> = []
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
while (disposables.length) disposables.pop()!()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stand up a connected client + server whose single confirmed (server-acked) document is `doc0`.
|
|
82
|
+
* Pre-seeding the server means the connect hydration installs `doc0` as a synced record, so the
|
|
83
|
+
* subsequent appends are updates against a known server base rather than fresh creations.
|
|
84
|
+
*/
|
|
85
|
+
function makeInstance(doc0: TestDoc): Instance {
|
|
86
|
+
const server = new TestServer<R>(testSchema, {
|
|
87
|
+
documents: [{ state: doc0, lastChangedClock: 0 }],
|
|
88
|
+
clock: 0,
|
|
89
|
+
documentClock: 0,
|
|
90
|
+
schema: testSchema.serialize(),
|
|
91
|
+
})
|
|
92
|
+
const socketPair = new TestSocketPair<R>('rebase-test', server)
|
|
93
|
+
socketPair.connect()
|
|
94
|
+
|
|
95
|
+
const store = new Store<R>({ schema: testSchema, props: {} })
|
|
96
|
+
const client = new TLSyncClient<R>({
|
|
97
|
+
store,
|
|
98
|
+
socket: socketPair.clientSocket,
|
|
99
|
+
presence: computed('presence', () => null),
|
|
100
|
+
onLoad: () => {},
|
|
101
|
+
onSyncError: (reason) => {
|
|
102
|
+
throw new Error(`unexpected sync error: ${reason}`)
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
disposables.push(() => client.close())
|
|
106
|
+
|
|
107
|
+
// complete the connect handshake (no pushes are in flight yet, so this only hydrates)
|
|
108
|
+
while (socketPair.getNeedsFlushing()) {
|
|
109
|
+
socketPair.flushClientSentEvents()
|
|
110
|
+
socketPair.flushServerSentEvents()
|
|
111
|
+
}
|
|
112
|
+
expect(store.get(doc0.id)).toEqual(doc0)
|
|
113
|
+
|
|
114
|
+
return { server, socketPair, client, store, docId: doc0.id }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getQueuedPushes(inst: Instance): TLPushRequest<R>[] {
|
|
118
|
+
return inst.socketPair.clientSentEventQueue.filter(
|
|
119
|
+
(m): m is TLPushRequest<R> => m.type === 'push'
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pendingPushCount(inst: Instance): number {
|
|
124
|
+
return (inst.client as any).pendingPushRequests.length
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function speculativeChanges(inst: Instance): RecordsDiff<R> {
|
|
128
|
+
return (inst.client as any).speculativeChanges
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Deliver exactly one queued client push to the server, then hand the server's resulting
|
|
133
|
+
* push_result(s) back to the client — driving a single rebase() while the later pushes stay
|
|
134
|
+
* pending. Returns the push_results the client received. flushDebouncingMessages resets the
|
|
135
|
+
* server's data-message debounce so each push_result is emitted immediately in its own message.
|
|
136
|
+
*/
|
|
137
|
+
function ackNextPush(inst: Instance) {
|
|
138
|
+
const { server, socketPair } = inst
|
|
139
|
+
server.flushDebouncingMessages()
|
|
140
|
+
|
|
141
|
+
const clientMsg = socketPair.clientSentEventQueue.shift()
|
|
142
|
+
if (!clientMsg || clientMsg.type !== 'push') {
|
|
143
|
+
throw new Error(`expected a queued push, got ${clientMsg?.type ?? 'nothing'}`)
|
|
144
|
+
}
|
|
145
|
+
socketPair.didReceiveFromClient!(clientMsg)
|
|
146
|
+
|
|
147
|
+
server.flushDebouncingMessages()
|
|
148
|
+
const serverMsgs = socketPair.serverSentEventQueue.splice(0)
|
|
149
|
+
for (const msg of serverMsgs) socketPair.callbacks.onReceiveMessage!(msg)
|
|
150
|
+
|
|
151
|
+
const results: any[] = []
|
|
152
|
+
for (const msg of serverMsgs) {
|
|
153
|
+
if (msg.type === 'data') {
|
|
154
|
+
for (const d of msg.data) if (d.type === 'push_result') results.push(d)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return results
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True when a diff represents no net change: no adds, no removes, and every update is value-equal. */
|
|
161
|
+
function isNetZero(diff: RecordsDiff<R>): boolean {
|
|
162
|
+
if (Object.keys(diff.added).length > 0) return false
|
|
163
|
+
if (Object.keys(diff.removed).length > 0) return false
|
|
164
|
+
return Object.values(diff.updated).every(([from, to]) => isEqual(from, to))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
describe('TLSyncClient staggered rebase invariants', () => {
|
|
168
|
+
it('invariant 1: a staggered commit ack for an in-flight push emits no net document change', () => {
|
|
169
|
+
const inst = makeInstance(TestDocType.create({ text: '', items: [] }))
|
|
170
|
+
const { store, docId } = inst
|
|
171
|
+
|
|
172
|
+
// three sequential appends to the same record — three in-flight pushes
|
|
173
|
+
store.update(docId, (d) => ({ ...d, text: d.text + 'A' }))
|
|
174
|
+
store.update(docId, (d) => ({ ...d, text: d.text + 'B' }))
|
|
175
|
+
store.update(docId, (d) => ({ ...d, text: d.text + 'C' }))
|
|
176
|
+
|
|
177
|
+
const pushes = getQueuedPushes(inst)
|
|
178
|
+
expect(pushes.map((p) => p.clientClock)).toEqual([0, 1, 2])
|
|
179
|
+
// the pushes carry base-relative Append ops (not base-independent Puts)
|
|
180
|
+
expect(pushes[0].diff![docId][0]).toBe('patch')
|
|
181
|
+
expect((pushes[0].diff![docId][1] as any).text).toEqual([ValueOpType.Append, 'A', 0])
|
|
182
|
+
expect(pendingPushCount(inst)).toBe(3)
|
|
183
|
+
|
|
184
|
+
const before = store.get(docId)
|
|
185
|
+
expect((before as TestDoc).text).toBe('ABC')
|
|
186
|
+
|
|
187
|
+
// register a listener AFTER the optimistic state is in place, then commit only the first push
|
|
188
|
+
const received: Array<{ changes: RecordsDiff<R>; source: string }> = []
|
|
189
|
+
const unlisten = store.listen((entry) => received.push(entry as any), {
|
|
190
|
+
scope: 'document',
|
|
191
|
+
source: 'all',
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const results = ackNextPush(inst)
|
|
195
|
+
expect(results).toHaveLength(1)
|
|
196
|
+
expect(results[0]).toMatchObject({ type: 'push_result', clientClock: 0, action: 'commit' })
|
|
197
|
+
|
|
198
|
+
// (a) the ack produced no net change for listeners — any entry seen is a net-zero remote entry
|
|
199
|
+
for (const entry of received) {
|
|
200
|
+
expect(entry.source).toBe('remote')
|
|
201
|
+
expect(isNetZero(entry.changes)).toBe(true)
|
|
202
|
+
}
|
|
203
|
+
// (b) the store still holds the full optimistic state after all three local changes
|
|
204
|
+
expect(store.get(docId)).toEqual(before)
|
|
205
|
+
expect((store.get(docId) as TestDoc).text).toBe('ABC')
|
|
206
|
+
// the ack drained exactly one pending push; the other two remain in flight
|
|
207
|
+
expect(pendingPushCount(inst)).toBe(2)
|
|
208
|
+
|
|
209
|
+
unlisten()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('invariant 2: after a staggered commit, speculativeChanges spans from the committed state, not a mid-flight one', () => {
|
|
213
|
+
const inst = makeInstance(TestDocType.create({ text: '', items: [] }))
|
|
214
|
+
const { store, docId } = inst
|
|
215
|
+
|
|
216
|
+
store.update(docId, (d) => ({ ...d, text: d.text + 'A' }))
|
|
217
|
+
store.update(docId, (d) => ({ ...d, text: d.text + 'B' }))
|
|
218
|
+
store.update(docId, (d) => ({ ...d, text: d.text + 'C' }))
|
|
219
|
+
expect((store.get(docId) as TestDoc).text).toBe('ABC')
|
|
220
|
+
|
|
221
|
+
// commit push 1 only (the other two stay pending)
|
|
222
|
+
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
|
|
223
|
+
expect(pendingPushCount(inst)).toBe(2)
|
|
224
|
+
|
|
225
|
+
// speculativeChanges must now be the squash of the two REMAINING pushes, with `from` equal to
|
|
226
|
+
// the committed (server-acked) state — the state after push 1 ('A'), NOT after push 2 ('AB').
|
|
227
|
+
const spec1 = speculativeChanges(inst)
|
|
228
|
+
expect((spec1.updated[docId][0] as TestDoc).text).toBe('A')
|
|
229
|
+
expect((spec1.updated[docId][1] as TestDoc).text).toBe('ABC')
|
|
230
|
+
// the optimistic state is untouched by the ack
|
|
231
|
+
expect((store.get(docId) as TestDoc).text).toBe('ABC')
|
|
232
|
+
|
|
233
|
+
// commit push 2: `from` advances to the new committed state ('AB'), still base-correct
|
|
234
|
+
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
|
|
235
|
+
expect(pendingPushCount(inst)).toBe(1)
|
|
236
|
+
const spec2 = speculativeChanges(inst)
|
|
237
|
+
expect((spec2.updated[docId][0] as TestDoc).text).toBe('AB')
|
|
238
|
+
expect((spec2.updated[docId][1] as TestDoc).text).toBe('ABC')
|
|
239
|
+
expect((store.get(docId) as TestDoc).text).toBe('ABC')
|
|
240
|
+
|
|
241
|
+
// commit push 3: nothing pending, so speculativeChanges is empty and the value has converged
|
|
242
|
+
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
|
|
243
|
+
expect(pendingPushCount(inst)).toBe(0)
|
|
244
|
+
expect(speculativeChanges(inst).updated[docId]).toBeUndefined()
|
|
245
|
+
expect((store.get(docId) as TestDoc).text).toBe('ABC')
|
|
246
|
+
|
|
247
|
+
// the server converged to the exact final value across the staggered commits
|
|
248
|
+
const serverDoc = inst.server.storage.getSnapshot().documents.find((d) => d.state.id === docId)
|
|
249
|
+
?.state as TestDoc
|
|
250
|
+
expect(serverDoc.text).toBe('ABC')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('invariant 2: base-relative array appends converge across staggered commit acks', () => {
|
|
254
|
+
const inst = makeInstance(TestDocType.create({ text: '', items: [] }))
|
|
255
|
+
const { store, docId } = inst
|
|
256
|
+
|
|
257
|
+
store.update(docId, (d) => ({ ...d, items: [...d.items, 1] }))
|
|
258
|
+
store.update(docId, (d) => ({ ...d, items: [...d.items, 2] }))
|
|
259
|
+
store.update(docId, (d) => ({ ...d, items: [...d.items, 3] }))
|
|
260
|
+
expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
|
|
261
|
+
|
|
262
|
+
// the array pushes carry base-relative Append ops (offset-relative to the diff base)
|
|
263
|
+
const pushes = getQueuedPushes(inst)
|
|
264
|
+
expect((pushes[0].diff![docId][1] as any).items).toEqual([ValueOpType.Append, [1], 0])
|
|
265
|
+
expect((pushes[1].diff![docId][1] as any).items).toEqual([ValueOpType.Append, [2], 1])
|
|
266
|
+
|
|
267
|
+
// commit push 1: `from` of the remaining speculative diff is the committed array state [1]
|
|
268
|
+
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
|
|
269
|
+
const spec1 = speculativeChanges(inst)
|
|
270
|
+
expect((spec1.updated[docId][0] as TestDoc).items).toEqual([1])
|
|
271
|
+
expect((spec1.updated[docId][1] as TestDoc).items).toEqual([1, 2, 3])
|
|
272
|
+
expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
|
|
273
|
+
|
|
274
|
+
// commit the rest, one staggered ack at a time; base-relative appends stay aligned
|
|
275
|
+
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
|
|
276
|
+
expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
|
|
277
|
+
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
|
|
278
|
+
expect(pendingPushCount(inst)).toBe(0)
|
|
279
|
+
expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
|
|
280
|
+
|
|
281
|
+
const serverDoc = inst.server.storage.getSnapshot().documents.find((d) => d.state.id === docId)
|
|
282
|
+
?.state as TestDoc
|
|
283
|
+
expect(serverDoc.items).toEqual([1, 2, 3])
|
|
284
|
+
})
|
|
285
|
+
})
|