@tldraw/sync-core 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.41291fb579ed

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 (51) hide show
  1. package/dist-cjs/index.d.ts +154 -3
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +55 -20
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +115 -52
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +27 -2
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +6 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +160 -10
  14. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  15. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  16. package/dist-cjs/lib/protocol.js.map +2 -2
  17. package/dist-esm/index.d.mts +154 -3
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +55 -20
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/SQLiteSyncStorage.mjs +115 -52
  24. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  25. package/dist-esm/lib/TLSocketRoom.mjs +27 -2
  26. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncClient.mjs +6 -1
  28. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  29. package/dist-esm/lib/TLSyncRoom.mjs +160 -10
  30. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  31. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  32. package/dist-esm/lib/protocol.mjs.map +2 -2
  33. package/package.json +6 -6
  34. package/src/index.ts +3 -0
  35. package/src/lib/InMemorySyncStorage.ts +74 -21
  36. package/src/lib/RoomSession.ts +7 -2
  37. package/src/lib/SQLiteSyncStorage.test.ts +29 -2
  38. package/src/lib/SQLiteSyncStorage.ts +158 -59
  39. package/src/lib/TLSocketRoom.ts +61 -3
  40. package/src/lib/TLSyncClient.test.ts +9 -2
  41. package/src/lib/TLSyncClient.ts +15 -3
  42. package/src/lib/TLSyncRoom.ts +294 -10
  43. package/src/lib/TLSyncStorage.ts +8 -0
  44. package/src/lib/protocol.ts +17 -0
  45. package/src/test/TLSocketRoom.test.ts +37 -2
  46. package/src/test/TLSyncClientRebase.test.ts +285 -0
  47. package/src/test/TLSyncRoom.test.ts +25 -0
  48. package/src/test/TestServer.ts +14 -4
  49. package/src/test/objectStore.test.ts +630 -0
  50. package/src/test/storageContractSuite.ts +79 -0
  51. package/src/test/upgradeDowngrade.test.ts +144 -2
@@ -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
+ })
@@ -188,6 +188,7 @@ function makeRoom(
188
188
  clientTimeout?: number
189
189
  log?: { warn?: Mock; error?: Mock }
190
190
  onPresenceChange?(): void
191
+ onCommittedChanges?(args: { diff: any; documentClock: number }): void
191
192
  } = {}
192
193
  ) {
193
194
  const storage = new InMemorySyncStorage<TLRecord>({
@@ -199,6 +200,7 @@ function makeRoom(
199
200
  clientTimeout: opts.clientTimeout,
200
201
  log: opts.log,
201
202
  onPresenceChange: opts.onPresenceChange,
203
+ onCommittedChanges: opts.onCommittedChanges,
202
204
  })
203
205
  disposables.push(() => room.close())
204
206
  return { storage, room }
@@ -634,6 +636,29 @@ describe('22. Room construction (RC)', () => {
634
636
  expect(clientBMessages[0].data[0].type).toBe('patch')
635
637
  })
636
638
 
639
+ it('fires onCommittedChanges once with the committed document diff after a push', async () => {
640
+ const onCommittedChanges = vi.fn()
641
+ const { room } = makeRoom({ onCommittedChanges })
642
+ connectSession(room, 'session-a')
643
+
644
+ const newPage = makePage('committed_page', 'Committed Page')
645
+ room.handleMessage('session-a', {
646
+ type: 'push',
647
+ clientClock: 1,
648
+ diff: {
649
+ [newPage.id]: ['put', newPage],
650
+ },
651
+ } as TLPushRequest<TLRecord>)
652
+
653
+ // the tap fires in a microtask, like onPresenceChange
654
+ await Promise.resolve()
655
+
656
+ expect(onCommittedChanges).toHaveBeenCalledTimes(1)
657
+ const arg = onCommittedChanges.mock.calls[0][0]
658
+ expect(arg.diff.puts[newPage.id]).toBeTruthy()
659
+ expect(typeof arg.documentClock).toBe('number')
660
+ })
661
+
637
662
  it('[RC4] handles multiple rapid external changes', async () => {
638
663
  const { room, storage } = makeRoom()
639
664
  const socket = connectSession(room, 'test-session')
@@ -1,12 +1,18 @@
1
1
  import { StoreSchema, UnknownRecord } from '@tldraw/store'
2
2
  import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
3
+ import { TLObjectStoreAccess } from '../lib/protocol'
3
4
  import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom'
4
5
  import { TestSocketPair } from './TestSocketPair'
5
6
 
7
+ type TestRoomOptions<R extends UnknownRecord> = Omit<
8
+ ConstructorParameters<typeof TLSyncRoom<R, undefined>>[0],
9
+ 'schema' | 'storage'
10
+ >
11
+
6
12
  export class TestServer<R extends UnknownRecord, P = unknown> {
7
13
  room: TLSyncRoom<R, undefined>
8
14
  storage: InMemorySyncStorage<R>
9
- constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot) {
15
+ constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot, roomOpts?: TestRoomOptions<R>) {
10
16
  // Use provided snapshot or create an empty one with the current schema
11
17
  this.storage = new InMemorySyncStorage<R>({
12
18
  snapshot: snapshot ?? {
@@ -16,15 +22,19 @@ export class TestServer<R extends UnknownRecord, P = unknown> {
16
22
  schema: schema.serialize(),
17
23
  },
18
24
  })
19
- this.room = new TLSyncRoom<R, undefined>({ schema, storage: this.storage })
25
+ this.room = new TLSyncRoom<R, undefined>({ schema, storage: this.storage, ...roomOpts })
20
26
  }
21
27
 
22
- connect(socketPair: TestSocketPair<R>): void {
28
+ connect(
29
+ socketPair: TestSocketPair<R>,
30
+ opts?: { isReadonly?: boolean; objectAccess?: TLObjectStoreAccess }
31
+ ): void {
23
32
  this.room.handleNewSession({
24
33
  sessionId: socketPair.id,
25
34
  socket: socketPair.roomSocket,
26
35
  meta: undefined,
27
- isReadonly: false,
36
+ isReadonly: opts?.isReadonly ?? false,
37
+ objectAccess: opts?.objectAccess,
28
38
  })
29
39
 
30
40
  socketPair.clientSocket.connectionStatus = 'online'