@tldraw/sync-core 4.4.0-next.84d68f44c848 → 4.4.0-next.bde73a32273d
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 +2 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TLSyncClient.js +60 -55
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-esm/index.d.mts +2 -1
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TLSyncClient.mjs +68 -57
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/package.json +7 -8
- package/src/lib/TLSyncClient.test.ts +591 -0
- package/src/lib/TLSyncClient.ts +87 -88
- package/src/test/FuzzEditor.ts +6 -4
|
@@ -836,4 +836,595 @@ describe('TLSyncClient', () => {
|
|
|
836
836
|
// Should track the latest server clock internally
|
|
837
837
|
})
|
|
838
838
|
})
|
|
839
|
+
|
|
840
|
+
describe('Offline and Reconnection Behavior', () => {
|
|
841
|
+
beforeEach(() => {
|
|
842
|
+
client = createClient()
|
|
843
|
+
socket.mockServerMessage(createConnectMessage())
|
|
844
|
+
socket.clearSentMessages()
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('does not send changes made while offline', () => {
|
|
848
|
+
// Go offline
|
|
849
|
+
socket.mockConnectionStatus('offline')
|
|
850
|
+
socket.clearSentMessages()
|
|
851
|
+
|
|
852
|
+
// Make changes while offline
|
|
853
|
+
const pageId = PageRecordType.createId()
|
|
854
|
+
store.put([
|
|
855
|
+
PageRecordType.create({
|
|
856
|
+
id: pageId,
|
|
857
|
+
name: 'Offline Page',
|
|
858
|
+
index: 'a1' as any,
|
|
859
|
+
}),
|
|
860
|
+
])
|
|
861
|
+
|
|
862
|
+
vi.advanceTimersByTime(100)
|
|
863
|
+
|
|
864
|
+
// Should not have sent any messages
|
|
865
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
866
|
+
|
|
867
|
+
// But the page should exist locally
|
|
868
|
+
expect(store.has(pageId)).toBe(true)
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
it('re-applies offline changes after reconnection and pushes them to server', () => {
|
|
872
|
+
// Make a change while offline - this is a truly speculative change
|
|
873
|
+
socket.mockConnectionStatus('offline')
|
|
874
|
+
socket.clearSentMessages()
|
|
875
|
+
|
|
876
|
+
const pageId = PageRecordType.createId()
|
|
877
|
+
store.put([
|
|
878
|
+
PageRecordType.create({
|
|
879
|
+
id: pageId,
|
|
880
|
+
name: 'Offline Page',
|
|
881
|
+
index: 'a1' as any,
|
|
882
|
+
}),
|
|
883
|
+
])
|
|
884
|
+
vi.advanceTimersByTime(100)
|
|
885
|
+
|
|
886
|
+
// Page exists locally, no messages sent (offline)
|
|
887
|
+
expect(store.has(pageId)).toBe(true)
|
|
888
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
889
|
+
|
|
890
|
+
// Come back online
|
|
891
|
+
socket.mockConnectionStatus('online')
|
|
892
|
+
|
|
893
|
+
// Get the connect message
|
|
894
|
+
const connectMsg = socket.getSentMessages().find((m) => m.type === 'connect')
|
|
895
|
+
expect(connectMsg).toBeDefined()
|
|
896
|
+
|
|
897
|
+
// Clear messages before server response
|
|
898
|
+
socket.clearSentMessages()
|
|
899
|
+
|
|
900
|
+
// Server responds with wipe_all (simulating fresh sync)
|
|
901
|
+
socket.mockServerMessage(
|
|
902
|
+
createConnectMessage({
|
|
903
|
+
connectRequestId: (connectMsg as any).connectRequestId,
|
|
904
|
+
hydrationType: 'wipe_all',
|
|
905
|
+
diff: {}, // Server has no pages
|
|
906
|
+
})
|
|
907
|
+
)
|
|
908
|
+
vi.advanceTimersByTime(100)
|
|
909
|
+
|
|
910
|
+
// The page should still exist locally
|
|
911
|
+
expect(store.has(pageId)).toBe(true)
|
|
912
|
+
expect(store.get(pageId)?.name).toBe('Offline Page')
|
|
913
|
+
|
|
914
|
+
// The speculative change should have been pushed to the server
|
|
915
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
916
|
+
const pushWithPage = messages.find(
|
|
917
|
+
(msg) => msg.type === 'push' && msg.diff && msg.diff[pageId]
|
|
918
|
+
)
|
|
919
|
+
expect(pushWithPage).toBeDefined()
|
|
920
|
+
expect(pushWithPage!.diff![pageId][0]).toBe(RecordOpType.Put)
|
|
921
|
+
expect((pushWithPage!.diff![pageId][1] as any).name).toBe('Offline Page')
|
|
922
|
+
})
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
describe('Push Coalescing (Multiple push() calls → single network message)', () => {
|
|
926
|
+
/**
|
|
927
|
+
* These tests verify that multiple store changes (each triggering push())
|
|
928
|
+
* get coalesced into a single TLPushRequest when the throttle fires.
|
|
929
|
+
*
|
|
930
|
+
* We enable RAF mode so the throttle actually delays execution,
|
|
931
|
+
* allowing changes to accumulate before sending.
|
|
932
|
+
*/
|
|
933
|
+
|
|
934
|
+
let rafCallbacks: Array<FrameRequestCallback>
|
|
935
|
+
let rafId: number
|
|
936
|
+
|
|
937
|
+
function flushOneRaf() {
|
|
938
|
+
if (rafCallbacks.length > 0) {
|
|
939
|
+
const callbacks = rafCallbacks.splice(0, rafCallbacks.length)
|
|
940
|
+
const now = performance.now()
|
|
941
|
+
for (const callback of callbacks) {
|
|
942
|
+
callback(now)
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function flushThrottle() {
|
|
948
|
+
// FpsScheduler needs: advance time + flush RAF (potentially twice for tick + flush)
|
|
949
|
+
// Also need to clear any stale callbacks and keep flushing until stable
|
|
950
|
+
for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) {
|
|
951
|
+
vi.advanceTimersByTime(100)
|
|
952
|
+
flushOneRaf()
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
beforeEach(() => {
|
|
957
|
+
// Reset timer to a known state to avoid pollution from previous tests
|
|
958
|
+
vi.setSystemTime(0)
|
|
959
|
+
|
|
960
|
+
// Force RAF behavior so throttle actually delays
|
|
961
|
+
// @ts-expect-error - testing flag
|
|
962
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
|
963
|
+
|
|
964
|
+
rafCallbacks = []
|
|
965
|
+
rafId = 0
|
|
966
|
+
|
|
967
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
968
|
+
const id = ++rafId
|
|
969
|
+
rafCallbacks.push(callback)
|
|
970
|
+
return id
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
vi.stubGlobal('cancelAnimationFrame', (_id: number) => {})
|
|
974
|
+
|
|
975
|
+
// Create client with RAF mode enabled
|
|
976
|
+
client = createClient()
|
|
977
|
+
socket.mockServerMessage(createConnectMessage())
|
|
978
|
+
|
|
979
|
+
// Flush initial setup and clear
|
|
980
|
+
flushThrottle()
|
|
981
|
+
socket.clearSentMessages()
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
afterEach(() => {
|
|
985
|
+
// @ts-expect-error - testing flag
|
|
986
|
+
delete globalThis.__FORCE_RAF_IN_TESTS__
|
|
987
|
+
vi.unstubAllGlobals()
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
it('coalesces 5 store.put() calls into 1 push request', () => {
|
|
991
|
+
expect((client as any).isConnectedToRoom).toBe(true)
|
|
992
|
+
|
|
993
|
+
// Make 5 separate store changes synchronously
|
|
994
|
+
const pageIds: string[] = []
|
|
995
|
+
for (let i = 0; i < 5; i++) {
|
|
996
|
+
const pageId = PageRecordType.createId()
|
|
997
|
+
pageIds.push(pageId)
|
|
998
|
+
store.put([
|
|
999
|
+
PageRecordType.create({
|
|
1000
|
+
id: pageId,
|
|
1001
|
+
name: `Page ${i}`,
|
|
1002
|
+
index: `a${i}` as any,
|
|
1003
|
+
}),
|
|
1004
|
+
])
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Before throttle fires: no messages should be sent yet
|
|
1008
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1009
|
+
|
|
1010
|
+
// Flush the throttle
|
|
1011
|
+
flushThrottle()
|
|
1012
|
+
|
|
1013
|
+
// Should have sent exactly ONE push request containing all 5 pages
|
|
1014
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1015
|
+
expect(messages).toHaveLength(1)
|
|
1016
|
+
expect(messages[0].type).toBe('push')
|
|
1017
|
+
|
|
1018
|
+
// The single message should contain all 5 page IDs
|
|
1019
|
+
const diff = messages[0].diff || {}
|
|
1020
|
+
expect(Object.keys(diff)).toHaveLength(5)
|
|
1021
|
+
for (const pageId of pageIds) {
|
|
1022
|
+
expect(diff[pageId]).toBeDefined()
|
|
1023
|
+
}
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
it('coalesces create + multiple updates into 1 push with final state', () => {
|
|
1027
|
+
expect((client as any).isConnectedToRoom).toBe(true)
|
|
1028
|
+
|
|
1029
|
+
const pageId = PageRecordType.createId()
|
|
1030
|
+
|
|
1031
|
+
// Create and update the same record multiple times synchronously
|
|
1032
|
+
store.put([
|
|
1033
|
+
PageRecordType.create({
|
|
1034
|
+
id: pageId,
|
|
1035
|
+
name: 'Version 1',
|
|
1036
|
+
index: 'a1' as any,
|
|
1037
|
+
}),
|
|
1038
|
+
])
|
|
1039
|
+
store.update(pageId, (p) => ({ ...p, name: 'Version 2' }))
|
|
1040
|
+
store.update(pageId, (p) => ({ ...p, name: 'Version 3' }))
|
|
1041
|
+
store.update(pageId, (p) => ({ ...p, name: 'Final Version' }))
|
|
1042
|
+
|
|
1043
|
+
// Before throttle fires: no messages
|
|
1044
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1045
|
+
|
|
1046
|
+
// Flush the throttle
|
|
1047
|
+
flushThrottle()
|
|
1048
|
+
|
|
1049
|
+
// Should have exactly ONE push request
|
|
1050
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1051
|
+
expect(messages).toHaveLength(1)
|
|
1052
|
+
|
|
1053
|
+
// The diff should have the final state
|
|
1054
|
+
const diff = messages[0].diff!
|
|
1055
|
+
expect(diff[pageId]).toBeDefined()
|
|
1056
|
+
expect(diff[pageId][0]).toBe(RecordOpType.Put)
|
|
1057
|
+
expect((diff[pageId][1] as any).name).toBe('Final Version')
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
it('coalesces create + delete into no diff (cancels out)', () => {
|
|
1061
|
+
const pageId = PageRecordType.createId()
|
|
1062
|
+
|
|
1063
|
+
// Create then immediately delete
|
|
1064
|
+
store.put([
|
|
1065
|
+
PageRecordType.create({
|
|
1066
|
+
id: pageId,
|
|
1067
|
+
name: 'Ephemeral',
|
|
1068
|
+
index: 'a1' as any,
|
|
1069
|
+
}),
|
|
1070
|
+
])
|
|
1071
|
+
store.remove([pageId])
|
|
1072
|
+
|
|
1073
|
+
// Before throttle fires: no messages
|
|
1074
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1075
|
+
|
|
1076
|
+
// Flush the throttle
|
|
1077
|
+
flushThrottle()
|
|
1078
|
+
|
|
1079
|
+
// Either no message, or message with empty/no diff for this page
|
|
1080
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1081
|
+
if (messages.length > 0) {
|
|
1082
|
+
const diff = messages[0].diff || {}
|
|
1083
|
+
// The page should NOT be in the diff (add + remove = no-op)
|
|
1084
|
+
expect(diff[pageId]).toBeUndefined()
|
|
1085
|
+
}
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
it('coalesces multiple presence updates into 1 push with final presence (first is Put)', () => {
|
|
1089
|
+
// Multiple presence updates synchronously - first presence ever sent
|
|
1090
|
+
presence.set({
|
|
1091
|
+
id: 'presence:u1' as any,
|
|
1092
|
+
typeName: 'instance_presence',
|
|
1093
|
+
cursor: { x: 0, y: 0 },
|
|
1094
|
+
} as any)
|
|
1095
|
+
presence.set({
|
|
1096
|
+
id: 'presence:u1' as any,
|
|
1097
|
+
typeName: 'instance_presence',
|
|
1098
|
+
cursor: { x: 50, y: 50 },
|
|
1099
|
+
} as any)
|
|
1100
|
+
presence.set({
|
|
1101
|
+
id: 'presence:u1' as any,
|
|
1102
|
+
typeName: 'instance_presence',
|
|
1103
|
+
cursor: { x: 100, y: 100 },
|
|
1104
|
+
} as any)
|
|
1105
|
+
|
|
1106
|
+
// Before throttle fires: no messages
|
|
1107
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1108
|
+
|
|
1109
|
+
// Flush the throttle
|
|
1110
|
+
flushThrottle()
|
|
1111
|
+
|
|
1112
|
+
// Should have exactly ONE push with final presence
|
|
1113
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1114
|
+
expect(messages).toHaveLength(1)
|
|
1115
|
+
expect(messages[0].presence).toBeDefined()
|
|
1116
|
+
// First presence is always a Put (full record)
|
|
1117
|
+
expect(messages[0].presence![0]).toBe(RecordOpType.Put)
|
|
1118
|
+
expect((messages[0].presence![1] as any).cursor).toEqual({ x: 100, y: 100 })
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('sends subsequent presence updates as Patch after initial Put', () => {
|
|
1122
|
+
// Send initial presence
|
|
1123
|
+
presence.set({
|
|
1124
|
+
id: 'presence:u1' as any,
|
|
1125
|
+
typeName: 'instance_presence',
|
|
1126
|
+
cursor: { x: 0, y: 0 },
|
|
1127
|
+
} as any)
|
|
1128
|
+
flushThrottle()
|
|
1129
|
+
|
|
1130
|
+
// Verify first presence was a Put
|
|
1131
|
+
const firstMessages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1132
|
+
expect(firstMessages).toHaveLength(1)
|
|
1133
|
+
expect(firstMessages[0].presence![0]).toBe(RecordOpType.Put)
|
|
1134
|
+
|
|
1135
|
+
socket.clearSentMessages()
|
|
1136
|
+
|
|
1137
|
+
// Now send multiple presence updates
|
|
1138
|
+
presence.set({
|
|
1139
|
+
id: 'presence:u1' as any,
|
|
1140
|
+
typeName: 'instance_presence',
|
|
1141
|
+
cursor: { x: 50, y: 50 },
|
|
1142
|
+
} as any)
|
|
1143
|
+
presence.set({
|
|
1144
|
+
id: 'presence:u1' as any,
|
|
1145
|
+
typeName: 'instance_presence',
|
|
1146
|
+
cursor: { x: 100, y: 100 },
|
|
1147
|
+
} as any)
|
|
1148
|
+
|
|
1149
|
+
// Before throttle fires: no messages
|
|
1150
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1151
|
+
|
|
1152
|
+
// Flush the throttle
|
|
1153
|
+
flushThrottle()
|
|
1154
|
+
|
|
1155
|
+
// Should have exactly ONE push with final presence as a Patch
|
|
1156
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1157
|
+
expect(messages).toHaveLength(1)
|
|
1158
|
+
expect(messages[0].presence).toBeDefined()
|
|
1159
|
+
// Subsequent presence updates should be Patches (only changed fields)
|
|
1160
|
+
expect(messages[0].presence![0]).toBe(RecordOpType.Patch)
|
|
1161
|
+
// The patch should contain the cursor update
|
|
1162
|
+
expect((messages[0].presence![1] as any).cursor).toBeDefined()
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
it('coalesces document changes + presence into 1 push request', () => {
|
|
1166
|
+
const pageId = PageRecordType.createId()
|
|
1167
|
+
|
|
1168
|
+
// Document change
|
|
1169
|
+
store.put([
|
|
1170
|
+
PageRecordType.create({
|
|
1171
|
+
id: pageId,
|
|
1172
|
+
name: 'Test Page',
|
|
1173
|
+
index: 'a1' as any,
|
|
1174
|
+
}),
|
|
1175
|
+
])
|
|
1176
|
+
|
|
1177
|
+
// Presence change
|
|
1178
|
+
presence.set({
|
|
1179
|
+
id: 'presence:u1' as any,
|
|
1180
|
+
typeName: 'instance_presence',
|
|
1181
|
+
cursor: { x: 42, y: 42 },
|
|
1182
|
+
} as any)
|
|
1183
|
+
|
|
1184
|
+
// Before throttle fires: no messages
|
|
1185
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1186
|
+
|
|
1187
|
+
// Flush the throttle
|
|
1188
|
+
flushThrottle()
|
|
1189
|
+
|
|
1190
|
+
// Should have exactly ONE push with both document and presence
|
|
1191
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1192
|
+
expect(messages).toHaveLength(1)
|
|
1193
|
+
|
|
1194
|
+
// Has document diff
|
|
1195
|
+
expect(messages[0].diff).toBeDefined()
|
|
1196
|
+
expect(messages[0].diff![pageId]).toBeDefined()
|
|
1197
|
+
|
|
1198
|
+
// Has presence
|
|
1199
|
+
expect(messages[0].presence).toBeDefined()
|
|
1200
|
+
})
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
describe('Rebase Behavior', () => {
|
|
1204
|
+
beforeEach(() => {
|
|
1205
|
+
client = createClient()
|
|
1206
|
+
socket.mockServerMessage(createConnectMessage())
|
|
1207
|
+
socket.clearSentMessages()
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
it('preserves local changes when receiving server patches for other records', () => {
|
|
1211
|
+
// Make a local change
|
|
1212
|
+
const pageId = PageRecordType.createId()
|
|
1213
|
+
store.put([
|
|
1214
|
+
PageRecordType.create({
|
|
1215
|
+
id: pageId,
|
|
1216
|
+
name: 'Local Page',
|
|
1217
|
+
index: 'a1' as any,
|
|
1218
|
+
}),
|
|
1219
|
+
])
|
|
1220
|
+
vi.advanceTimersByTime(100)
|
|
1221
|
+
|
|
1222
|
+
// Receive a server patch for a different record
|
|
1223
|
+
const serverPageId = PageRecordType.createId()
|
|
1224
|
+
socket.mockServerMessage({
|
|
1225
|
+
type: 'data',
|
|
1226
|
+
data: [
|
|
1227
|
+
{
|
|
1228
|
+
type: 'patch',
|
|
1229
|
+
serverClock: 2,
|
|
1230
|
+
diff: {
|
|
1231
|
+
[serverPageId]: [
|
|
1232
|
+
RecordOpType.Put,
|
|
1233
|
+
PageRecordType.create({
|
|
1234
|
+
id: serverPageId,
|
|
1235
|
+
name: 'Server Page',
|
|
1236
|
+
index: 'a2' as any,
|
|
1237
|
+
}),
|
|
1238
|
+
],
|
|
1239
|
+
},
|
|
1240
|
+
},
|
|
1241
|
+
],
|
|
1242
|
+
})
|
|
1243
|
+
vi.advanceTimersByTime(100)
|
|
1244
|
+
|
|
1245
|
+
// Both local and server pages should coexist
|
|
1246
|
+
expect(store.has(pageId)).toBe(true)
|
|
1247
|
+
expect(store.get(pageId)?.name).toBe('Local Page')
|
|
1248
|
+
expect(store.has(serverPageId)).toBe(true)
|
|
1249
|
+
expect(store.get(serverPageId)?.name).toBe('Server Page')
|
|
1250
|
+
})
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
describe('Solo Mode FPS Optimization', () => {
|
|
1254
|
+
it('does not send presence in solo mode', () => {
|
|
1255
|
+
presenceMode.set('solo')
|
|
1256
|
+
client = createClient()
|
|
1257
|
+
socket.mockServerMessage(createConnectMessage())
|
|
1258
|
+
socket.clearSentMessages()
|
|
1259
|
+
|
|
1260
|
+
const presenceRecord = {
|
|
1261
|
+
id: 'presence:user1' as any,
|
|
1262
|
+
typeName: 'instance_presence',
|
|
1263
|
+
cursor: { x: 100, y: 200 },
|
|
1264
|
+
} as TestRecord
|
|
1265
|
+
presence.set(presenceRecord)
|
|
1266
|
+
|
|
1267
|
+
vi.advanceTimersByTime(2000)
|
|
1268
|
+
|
|
1269
|
+
// Should not have sent any presence updates
|
|
1270
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1271
|
+
const presenceMessages = messages.filter((msg) => msg.presence)
|
|
1272
|
+
expect(presenceMessages).toHaveLength(0)
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
it('still sends document changes in solo mode', () => {
|
|
1276
|
+
presenceMode.set('solo')
|
|
1277
|
+
client = createClient()
|
|
1278
|
+
socket.mockServerMessage(createConnectMessage())
|
|
1279
|
+
socket.clearSentMessages()
|
|
1280
|
+
|
|
1281
|
+
const pageId = PageRecordType.createId()
|
|
1282
|
+
store.put([
|
|
1283
|
+
PageRecordType.create({
|
|
1284
|
+
id: pageId,
|
|
1285
|
+
name: 'Solo Page',
|
|
1286
|
+
index: 'a1' as any,
|
|
1287
|
+
}),
|
|
1288
|
+
])
|
|
1289
|
+
|
|
1290
|
+
vi.advanceTimersByTime(2000)
|
|
1291
|
+
|
|
1292
|
+
// Should have sent the document change
|
|
1293
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1294
|
+
const docMessages = messages.filter((msg) => msg.diff && Object.keys(msg.diff).length > 0)
|
|
1295
|
+
expect(docMessages.length).toBeGreaterThan(0)
|
|
1296
|
+
})
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
describe('Edge Cases', () => {
|
|
1300
|
+
beforeEach(() => {
|
|
1301
|
+
client = createClient()
|
|
1302
|
+
socket.mockServerMessage(createConnectMessage())
|
|
1303
|
+
socket.clearSentMessages()
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
it('handles update-then-delete of server record correctly', () => {
|
|
1307
|
+
// Create a page via server
|
|
1308
|
+
const pageId = PageRecordType.createId()
|
|
1309
|
+
socket.mockServerMessage({
|
|
1310
|
+
type: 'data',
|
|
1311
|
+
data: [
|
|
1312
|
+
{
|
|
1313
|
+
type: 'patch',
|
|
1314
|
+
serverClock: 2,
|
|
1315
|
+
diff: {
|
|
1316
|
+
[pageId]: [
|
|
1317
|
+
RecordOpType.Put,
|
|
1318
|
+
PageRecordType.create({
|
|
1319
|
+
id: pageId,
|
|
1320
|
+
name: 'Server Page',
|
|
1321
|
+
index: 'a1' as any,
|
|
1322
|
+
}),
|
|
1323
|
+
],
|
|
1324
|
+
},
|
|
1325
|
+
},
|
|
1326
|
+
],
|
|
1327
|
+
})
|
|
1328
|
+
vi.advanceTimersByTime(100)
|
|
1329
|
+
socket.clearSentMessages()
|
|
1330
|
+
|
|
1331
|
+
// Update then delete
|
|
1332
|
+
store.update(pageId, (p) => ({ ...p, name: 'Updated' }))
|
|
1333
|
+
vi.advanceTimersByTime(100)
|
|
1334
|
+
socket.clearSentMessages()
|
|
1335
|
+
|
|
1336
|
+
store.remove([pageId])
|
|
1337
|
+
vi.advanceTimersByTime(100)
|
|
1338
|
+
|
|
1339
|
+
// Page should not exist locally
|
|
1340
|
+
expect(store.has(pageId)).toBe(false)
|
|
1341
|
+
|
|
1342
|
+
// Should have sent a remove operation
|
|
1343
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1344
|
+
const removeMsg = messages.find(
|
|
1345
|
+
(msg) => msg.diff && msg.diff[pageId] && msg.diff[pageId][0] === RecordOpType.Remove
|
|
1346
|
+
)
|
|
1347
|
+
expect(removeMsg).toBeDefined()
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
it('handles delete-then-recreate of server record with same ID', () => {
|
|
1351
|
+
// Create a page via server
|
|
1352
|
+
const pageId = PageRecordType.createId()
|
|
1353
|
+
socket.mockServerMessage({
|
|
1354
|
+
type: 'data',
|
|
1355
|
+
data: [
|
|
1356
|
+
{
|
|
1357
|
+
type: 'patch',
|
|
1358
|
+
serverClock: 2,
|
|
1359
|
+
diff: {
|
|
1360
|
+
[pageId]: [
|
|
1361
|
+
RecordOpType.Put,
|
|
1362
|
+
PageRecordType.create({
|
|
1363
|
+
id: pageId,
|
|
1364
|
+
name: 'Original',
|
|
1365
|
+
index: 'a1' as any,
|
|
1366
|
+
}),
|
|
1367
|
+
],
|
|
1368
|
+
},
|
|
1369
|
+
},
|
|
1370
|
+
],
|
|
1371
|
+
})
|
|
1372
|
+
vi.advanceTimersByTime(100)
|
|
1373
|
+
socket.clearSentMessages()
|
|
1374
|
+
|
|
1375
|
+
// Delete then recreate with same ID
|
|
1376
|
+
store.remove([pageId])
|
|
1377
|
+
store.put([
|
|
1378
|
+
PageRecordType.create({
|
|
1379
|
+
id: pageId,
|
|
1380
|
+
name: 'Recreated',
|
|
1381
|
+
index: 'a1' as any,
|
|
1382
|
+
}),
|
|
1383
|
+
])
|
|
1384
|
+
vi.advanceTimersByTime(100)
|
|
1385
|
+
|
|
1386
|
+
// Page should exist with new name
|
|
1387
|
+
expect(store.has(pageId)).toBe(true)
|
|
1388
|
+
expect(store.get(pageId)?.name).toBe('Recreated')
|
|
1389
|
+
})
|
|
1390
|
+
|
|
1391
|
+
it('sends patches (not full puts) for updates to server records', () => {
|
|
1392
|
+
// Create a page via server
|
|
1393
|
+
const pageId = PageRecordType.createId()
|
|
1394
|
+
socket.mockServerMessage({
|
|
1395
|
+
type: 'data',
|
|
1396
|
+
data: [
|
|
1397
|
+
{
|
|
1398
|
+
type: 'patch',
|
|
1399
|
+
serverClock: 2,
|
|
1400
|
+
diff: {
|
|
1401
|
+
[pageId]: [
|
|
1402
|
+
RecordOpType.Put,
|
|
1403
|
+
PageRecordType.create({
|
|
1404
|
+
id: pageId,
|
|
1405
|
+
name: 'Server Name',
|
|
1406
|
+
index: 'a1' as any,
|
|
1407
|
+
}),
|
|
1408
|
+
],
|
|
1409
|
+
},
|
|
1410
|
+
},
|
|
1411
|
+
],
|
|
1412
|
+
})
|
|
1413
|
+
vi.advanceTimersByTime(100)
|
|
1414
|
+
socket.clearSentMessages()
|
|
1415
|
+
|
|
1416
|
+
// Local user updates only the name
|
|
1417
|
+
store.update(pageId, (p) => ({ ...p, name: 'Local Name' }))
|
|
1418
|
+
vi.advanceTimersByTime(100)
|
|
1419
|
+
|
|
1420
|
+
// Should send a Patch operation (not Put)
|
|
1421
|
+
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1422
|
+
const pushWithPage = messages.find((msg) => msg.diff && msg.diff[pageId])
|
|
1423
|
+
expect(pushWithPage).toBeDefined()
|
|
1424
|
+
|
|
1425
|
+
const op = pushWithPage!.diff![pageId]
|
|
1426
|
+
expect(op[0]).toBe(RecordOpType.Patch)
|
|
1427
|
+
expect((op[1] as any).name).toBeDefined()
|
|
1428
|
+
})
|
|
1429
|
+
})
|
|
839
1430
|
})
|