@tldraw/sync-core 5.3.0-canary.ecf14a605aef → 5.3.0-internal.d205573d66cb
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 +76 -3
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +55 -20
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +115 -52
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +26 -2
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +6 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +43 -5
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-esm/index.d.mts +76 -3
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +55 -20
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +115 -52
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +26 -2
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +6 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +43 -5
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/index.ts +1 -0
- package/src/lib/InMemorySyncStorage.ts +74 -21
- package/src/lib/RoomSession.ts +7 -2
- package/src/lib/SQLiteSyncStorage.test.ts +29 -2
- package/src/lib/SQLiteSyncStorage.ts +158 -59
- package/src/lib/TLSocketRoom.ts +53 -2
- package/src/lib/TLSyncClient.test.ts +9 -2
- package/src/lib/TLSyncClient.ts +15 -3
- package/src/lib/TLSyncRoom.ts +76 -4
- package/src/lib/TLSyncStorage.ts +8 -0
- package/src/lib/protocol.ts +17 -0
- package/src/test/TLSocketRoom.test.ts +37 -2
- package/src/test/TLSyncClientRebase.test.ts +285 -0
- package/src/test/TLSyncRoom.test.ts +25 -0
- package/src/test/TestServer.ts +14 -4
- package/src/test/objectStore.test.ts +387 -0
- package/src/test/storageContractSuite.ts +79 -0
- package/src/test/upgradeDowngrade.test.ts +47 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { BaseRecord, RecordId, StoreSchema, createRecordType } from '@tldraw/store'
|
|
2
|
+
import { vi, type Mock } from 'vitest'
|
|
3
|
+
import { RecordOpType, ValueOpType } from '../lib/diff'
|
|
4
|
+
import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
|
|
5
|
+
import {
|
|
6
|
+
TLConnectRequest,
|
|
7
|
+
TLPushRequest,
|
|
8
|
+
TLSocketServerSentEvent,
|
|
9
|
+
getTlsyncProtocolVersion,
|
|
10
|
+
} from '../lib/protocol'
|
|
11
|
+
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
|
|
12
|
+
import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
|
|
13
|
+
|
|
14
|
+
// A generic object-lane test schema: `doc` records live in the document lane, `note` records are
|
|
15
|
+
// served through the object-store lane (see `objectTypes` below). Deliberately not comment-shaped —
|
|
16
|
+
// the lane is a generic primitive.
|
|
17
|
+
|
|
18
|
+
interface DocRecord extends BaseRecord<'doc', RecordId<DocRecord>> {
|
|
19
|
+
title: string
|
|
20
|
+
}
|
|
21
|
+
interface NoteRecord extends BaseRecord<'note', RecordId<NoteRecord>> {
|
|
22
|
+
text: string
|
|
23
|
+
}
|
|
24
|
+
interface PresenceRecord extends BaseRecord<'presence', RecordId<PresenceRecord>> {
|
|
25
|
+
name: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const Doc = createRecordType<DocRecord>('doc', {
|
|
29
|
+
scope: 'document',
|
|
30
|
+
validator: { validate: (value) => value as DocRecord },
|
|
31
|
+
})
|
|
32
|
+
const Note = createRecordType<NoteRecord>('note', {
|
|
33
|
+
scope: 'document',
|
|
34
|
+
validator: { validate: (value) => value as NoteRecord },
|
|
35
|
+
})
|
|
36
|
+
const Presence = createRecordType<PresenceRecord>('presence', {
|
|
37
|
+
scope: 'presence',
|
|
38
|
+
validator: { validate: (value) => value as PresenceRecord },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
type R = DocRecord | NoteRecord | PresenceRecord
|
|
42
|
+
|
|
43
|
+
const schema = StoreSchema.create<R>({ doc: Doc, note: Note, presence: Presence })
|
|
44
|
+
|
|
45
|
+
type MockSocket = TLRoomSocket<any> & {
|
|
46
|
+
__messages: TLSocketServerSentEvent<any>[]
|
|
47
|
+
sendMessage: Mock
|
|
48
|
+
close: Mock
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeSocket(): MockSocket {
|
|
52
|
+
const socket: MockSocket = {
|
|
53
|
+
__messages: [],
|
|
54
|
+
// cloning because the room reuses/clears message objects after sending
|
|
55
|
+
sendMessage: vi.fn((msg: TLSocketServerSentEvent<any>) => {
|
|
56
|
+
socket.__messages.push(structuredClone(msg))
|
|
57
|
+
}),
|
|
58
|
+
close: vi.fn(() => {
|
|
59
|
+
socket.isOpen = false
|
|
60
|
+
}),
|
|
61
|
+
isOpen: true,
|
|
62
|
+
}
|
|
63
|
+
return socket
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sentDataMessages(socket: MockSocket): any[] {
|
|
67
|
+
return socket.__messages.flatMap((msg: any) => (msg.type === 'data' ? msg.data : []))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clearSocket(socket: MockSocket) {
|
|
71
|
+
socket.sendMessage.mockClear()
|
|
72
|
+
socket.__messages.length = 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const disposables: Array<() => void> = []
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
for (const dispose of disposables) dispose()
|
|
78
|
+
disposables.length = 0
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
function makeRoom(
|
|
82
|
+
opts: {
|
|
83
|
+
snapshot?: RoomSnapshot
|
|
84
|
+
objectTypes?: readonly string[]
|
|
85
|
+
onCommittedChanges?(args: { diff: any; documentClock: number }): void
|
|
86
|
+
} = {}
|
|
87
|
+
) {
|
|
88
|
+
const storage = new InMemorySyncStorage<R>({
|
|
89
|
+
snapshot: opts.snapshot ?? {
|
|
90
|
+
documents: [],
|
|
91
|
+
clock: 0,
|
|
92
|
+
documentClock: 0,
|
|
93
|
+
schema: schema.serialize(),
|
|
94
|
+
},
|
|
95
|
+
// keep the storage partition in step with the room's object lane
|
|
96
|
+
objectTypes: opts.objectTypes,
|
|
97
|
+
})
|
|
98
|
+
const room = new TLSyncRoom<R, undefined>({
|
|
99
|
+
schema,
|
|
100
|
+
storage,
|
|
101
|
+
objectTypes: opts.objectTypes,
|
|
102
|
+
onCommittedChanges: opts.onCommittedChanges,
|
|
103
|
+
})
|
|
104
|
+
disposables.push(() => room.close())
|
|
105
|
+
return { storage, room }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function connectSession(
|
|
109
|
+
room: TLSyncRoom<any, any>,
|
|
110
|
+
sessionId: string,
|
|
111
|
+
opts: {
|
|
112
|
+
isReadonly?: boolean
|
|
113
|
+
objectAccess?: 'read' | 'write'
|
|
114
|
+
clear?: boolean
|
|
115
|
+
} = {}
|
|
116
|
+
): MockSocket {
|
|
117
|
+
const socket = makeSocket()
|
|
118
|
+
room.handleNewSession({
|
|
119
|
+
sessionId,
|
|
120
|
+
socket,
|
|
121
|
+
meta: undefined,
|
|
122
|
+
isReadonly: opts.isReadonly ?? false,
|
|
123
|
+
objectAccess: opts.objectAccess,
|
|
124
|
+
})
|
|
125
|
+
room.handleMessage(sessionId, {
|
|
126
|
+
type: 'connect',
|
|
127
|
+
connectRequestId: 'connect-' + sessionId,
|
|
128
|
+
lastServerClock: 0,
|
|
129
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
130
|
+
schema: room.serializedSchema,
|
|
131
|
+
} satisfies TLConnectRequest)
|
|
132
|
+
if (opts.clear !== false) {
|
|
133
|
+
clearSocket(socket)
|
|
134
|
+
}
|
|
135
|
+
return socket
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function push(
|
|
139
|
+
room: TLSyncRoom<any, any>,
|
|
140
|
+
sessionId: string,
|
|
141
|
+
diff: TLPushRequest<any>['diff'],
|
|
142
|
+
clientClock = 1
|
|
143
|
+
) {
|
|
144
|
+
room.handleMessage(sessionId, { type: 'push', clientClock, diff } satisfies TLPushRequest<any>)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function lastPushResult(socket: MockSocket) {
|
|
148
|
+
return sentDataMessages(socket)
|
|
149
|
+
.filter((m) => m.type === 'push_result')
|
|
150
|
+
.at(-1)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function storedIds(storage: InMemorySyncStorage<R>) {
|
|
154
|
+
// span both partitions: the document snapshot no longer contains object-lane records
|
|
155
|
+
return [...storage.getSnapshot().documents, ...storage.getObjectsSnapshot()]
|
|
156
|
+
.map((d) => d.state.id)
|
|
157
|
+
.sort()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
describe('object store lane', () => {
|
|
161
|
+
describe('type partition', () => {
|
|
162
|
+
it('excludes object types from documentTypes and exposes them via objectTypes', () => {
|
|
163
|
+
const { room } = makeRoom({ objectTypes: ['note'] })
|
|
164
|
+
expect(room.documentTypes.has('doc')).toBe(true)
|
|
165
|
+
expect(room.documentTypes.has('note')).toBe(false)
|
|
166
|
+
expect(room.objectTypes.has('note')).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('keeps documentTypes intact when no objectTypes are configured', () => {
|
|
170
|
+
const { room } = makeRoom()
|
|
171
|
+
expect(room.documentTypes.has('doc')).toBe(true)
|
|
172
|
+
expect(room.documentTypes.has('note')).toBe(true)
|
|
173
|
+
expect(room.objectTypes.size).toBe(0)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('rejects object types that are not registered in the schema', () => {
|
|
177
|
+
expect(() => makeRoom({ objectTypes: ['nonexistent'] })).toThrow(
|
|
178
|
+
"object type 'nonexistent' is not registered"
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('rejects object types that are not document-scoped', () => {
|
|
183
|
+
expect(() => makeRoom({ objectTypes: ['presence'] })).toThrow(
|
|
184
|
+
"object type 'presence' must have scope 'document'"
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('object writes', () => {
|
|
190
|
+
it('commits object puts, broadcasts them, and hydrates them on connect', () => {
|
|
191
|
+
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
192
|
+
const writer = connectSession(room, 'writer')
|
|
193
|
+
const observer = connectSession(room, 'observer')
|
|
194
|
+
|
|
195
|
+
const note = Note.create({ text: 'hello' })
|
|
196
|
+
push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
|
|
197
|
+
|
|
198
|
+
expect(lastPushResult(writer)).toMatchObject({ action: 'commit' })
|
|
199
|
+
expect(storedIds(storage)).toEqual([note.id])
|
|
200
|
+
|
|
201
|
+
// broadcast to the other session
|
|
202
|
+
room._flushDataMessages('observer')
|
|
203
|
+
expect(
|
|
204
|
+
sentDataMessages(observer).find(
|
|
205
|
+
(m) => m.type === 'patch' && m.diff[note.id]?.[0] === RecordOpType.Put
|
|
206
|
+
)
|
|
207
|
+
).toBeDefined()
|
|
208
|
+
|
|
209
|
+
// a fresh connect hydrates the object record
|
|
210
|
+
const late = connectSession(room, 'late', { clear: false })
|
|
211
|
+
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
|
|
212
|
+
expect(connectMsg.diff[note.id]).toEqual([RecordOpType.Put, note])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('still rejects unknown record types for writable sessions', () => {
|
|
216
|
+
const { room } = makeRoom({ objectTypes: ['note'] })
|
|
217
|
+
const socket = connectSession(room, 'writer')
|
|
218
|
+
|
|
219
|
+
push(room, 'writer', {
|
|
220
|
+
'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
224
|
+
TLSyncErrorCloseEventCode,
|
|
225
|
+
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
226
|
+
)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('skips unknown record types for denied sessions without rejecting (legacy readonly behavior)', () => {
|
|
230
|
+
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
231
|
+
const socket = connectSession(room, 'reader', { isReadonly: true })
|
|
232
|
+
|
|
233
|
+
push(room, 'reader', {
|
|
234
|
+
'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
expect(socket.close).not.toHaveBeenCalled()
|
|
238
|
+
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
239
|
+
expect(storedIds(storage)).toEqual([])
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('permission axes', () => {
|
|
244
|
+
it('lets a readonly session with object write access write objects but not documents', () => {
|
|
245
|
+
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
246
|
+
// "can comment but not edit"
|
|
247
|
+
const socket = connectSession(room, 'commenter', {
|
|
248
|
+
isReadonly: true,
|
|
249
|
+
objectAccess: 'write',
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const doc = Doc.create({ title: 'nope' })
|
|
253
|
+
const note = Note.create({ text: 'yep' })
|
|
254
|
+
push(room, 'commenter', {
|
|
255
|
+
[doc.id]: [RecordOpType.Put, doc],
|
|
256
|
+
[note.id]: [RecordOpType.Put, note],
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// only the object op survives, so the client is rebased onto it
|
|
260
|
+
expect(lastPushResult(socket)).toMatchObject({
|
|
261
|
+
action: { rebaseWithDiff: { [note.id]: [RecordOpType.Put, note] } },
|
|
262
|
+
})
|
|
263
|
+
expect(storedIds(storage)).toEqual([note.id])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('blocks object writes for objectAccess: read while document writes still commit', () => {
|
|
267
|
+
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
268
|
+
const socket = connectSession(room, 'editor', { objectAccess: 'read' })
|
|
269
|
+
|
|
270
|
+
const doc = Doc.create({ title: 'yep' })
|
|
271
|
+
const note = Note.create({ text: 'nope' })
|
|
272
|
+
push(room, 'editor', {
|
|
273
|
+
[doc.id]: [RecordOpType.Put, doc],
|
|
274
|
+
[note.id]: [RecordOpType.Put, note],
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
expect(lastPushResult(socket)).toMatchObject({
|
|
278
|
+
action: { rebaseWithDiff: { [doc.id]: [RecordOpType.Put, doc] } },
|
|
279
|
+
})
|
|
280
|
+
expect(storedIds(storage)).toEqual([doc.id])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('discards object-only pushes entirely for objectAccess: read', () => {
|
|
284
|
+
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
285
|
+
const socket = connectSession(room, 'reader', { objectAccess: 'read' })
|
|
286
|
+
|
|
287
|
+
const note = Note.create({ text: 'nope' })
|
|
288
|
+
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
|
|
289
|
+
|
|
290
|
+
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
291
|
+
expect(storedIds(storage)).toEqual([])
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('gates object patches and removes by objectAccess', () => {
|
|
295
|
+
const note = Note.create({ text: 'original' })
|
|
296
|
+
const { room, storage } = makeRoom({
|
|
297
|
+
objectTypes: ['note'],
|
|
298
|
+
snapshot: {
|
|
299
|
+
documents: [{ state: note, lastChangedClock: 0 }],
|
|
300
|
+
clock: 0,
|
|
301
|
+
documentClock: 0,
|
|
302
|
+
schema: schema.serialize(),
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
const reader = connectSession(room, 'reader', { objectAccess: 'read' })
|
|
306
|
+
|
|
307
|
+
push(room, 'reader', {
|
|
308
|
+
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
|
|
309
|
+
})
|
|
310
|
+
expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
|
|
311
|
+
|
|
312
|
+
push(room, 'reader', { [note.id]: [RecordOpType.Remove] }, 2)
|
|
313
|
+
expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
|
|
314
|
+
expect(storedIds(storage)).toEqual([note.id])
|
|
315
|
+
|
|
316
|
+
// a readonly-but-object-writable session can remove the object record
|
|
317
|
+
connectSession(room, 'commenter', { isReadonly: true, objectAccess: 'write' })
|
|
318
|
+
push(room, 'commenter', { [note.id]: [RecordOpType.Remove] })
|
|
319
|
+
expect(storedIds(storage)).toEqual([])
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe('storage partition', () => {
|
|
324
|
+
it('keeps object records out of the document snapshot but in the objects snapshot', () => {
|
|
325
|
+
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
326
|
+
connectSession(room, 'writer')
|
|
327
|
+
|
|
328
|
+
const doc = Doc.create({ title: 'canvas' })
|
|
329
|
+
const note = Note.create({ text: 'annotation' })
|
|
330
|
+
push(room, 'writer', {
|
|
331
|
+
[doc.id]: [RecordOpType.Put, doc],
|
|
332
|
+
[note.id]: [RecordOpType.Put, note],
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// the document snapshot is pure-document by construction
|
|
336
|
+
expect(storage.getSnapshot().documents.map((d) => d.state.id)).toEqual([doc.id])
|
|
337
|
+
// the object lane is persisted separately
|
|
338
|
+
expect(storage.getObjectsSnapshot().map((d) => d.state.id)).toEqual([note.id])
|
|
339
|
+
|
|
340
|
+
// but a fresh connect still hydrates both (shared clock + change feed)
|
|
341
|
+
const late = connectSession(room, 'late', { clear: false })
|
|
342
|
+
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
|
|
343
|
+
expect(Object.keys(connectMsg.diff).sort()).toEqual([doc.id, note.id].sort())
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('connect message', () => {
|
|
348
|
+
it('carries the session objectAccess', () => {
|
|
349
|
+
const { room } = makeRoom({ objectTypes: ['note'] })
|
|
350
|
+
|
|
351
|
+
const writer = connectSession(room, 'writer', { clear: false })
|
|
352
|
+
const writerConnect = writer.__messages.find((m: any) => m.type === 'connect') as any
|
|
353
|
+
expect(writerConnect.objectAccess).toBe('write')
|
|
354
|
+
|
|
355
|
+
const reader = connectSession(room, 'reader', { objectAccess: 'read', clear: false })
|
|
356
|
+
const readerConnect = reader.__messages.find((m: any) => m.type === 'connect') as any
|
|
357
|
+
expect(readerConnect.objectAccess).toBe('read')
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
describe('onCommittedChanges projection hook', () => {
|
|
362
|
+
it('fires with the committed object diff', async () => {
|
|
363
|
+
const onCommittedChanges = vi.fn()
|
|
364
|
+
const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
|
|
365
|
+
connectSession(room, 'writer')
|
|
366
|
+
|
|
367
|
+
const note = Note.create({ text: 'hello' })
|
|
368
|
+
push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
|
|
369
|
+
await Promise.resolve() // the hook fires on a microtask
|
|
370
|
+
|
|
371
|
+
expect(onCommittedChanges).toHaveBeenCalledTimes(1)
|
|
372
|
+
expect(onCommittedChanges.mock.calls[0][0].diff.puts[note.id]).toEqual(note)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('does not fire when every op in the push was denied', async () => {
|
|
376
|
+
const onCommittedChanges = vi.fn()
|
|
377
|
+
const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
|
|
378
|
+
connectSession(room, 'reader', { objectAccess: 'read' })
|
|
379
|
+
|
|
380
|
+
const note = Note.create({ text: 'nope' })
|
|
381
|
+
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
|
|
382
|
+
await Promise.resolve()
|
|
383
|
+
|
|
384
|
+
expect(onCommittedChanges).not.toHaveBeenCalled()
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
})
|
|
@@ -41,6 +41,7 @@ export function makePage(id: string, name = id, index = 'a2') {
|
|
|
41
41
|
export interface StorageContractFactory {
|
|
42
42
|
create(opts?: {
|
|
43
43
|
snapshot?: RoomSnapshot
|
|
44
|
+
objectTypes?: readonly string[]
|
|
44
45
|
onChange?(arg: TLSyncStorageOnChangeCallbackProps): void
|
|
45
46
|
}): TLSyncStorage<TLRecord>
|
|
46
47
|
}
|
|
@@ -61,6 +62,84 @@ export function registerStorageContractTests(factory: StorageContractFactory) {
|
|
|
61
62
|
expect(snapshot.documents.map((d) => d.lastChangedClock)).toEqual([0, 0])
|
|
62
63
|
})
|
|
63
64
|
|
|
65
|
+
describe('object-store lane partition', () => {
|
|
66
|
+
// the storage doesn't care about record semantics, so 'page' stands in as a
|
|
67
|
+
// generic object-lane type here
|
|
68
|
+
const createPartitioned = (snapshot?: RoomSnapshot) =>
|
|
69
|
+
factory.create({ snapshot, objectTypes: ['page'] })
|
|
70
|
+
|
|
71
|
+
it('partitions object-lane records out of getSnapshot into getObjectsSnapshot', () => {
|
|
72
|
+
const storage = createPartitioned(
|
|
73
|
+
makeContractSnapshot(contractRecords, { documentClock: 5 })
|
|
74
|
+
)
|
|
75
|
+
const snapshot = storage.getSnapshot!()
|
|
76
|
+
expect(snapshot.documents.map((d) => d.state.typeName)).toEqual(['document'])
|
|
77
|
+
// clock + schema are unaffected by the partition
|
|
78
|
+
expect(snapshot.documentClock).toBe(5)
|
|
79
|
+
|
|
80
|
+
expect(storage.getObjectsSnapshot!().map((d) => d.state.typeName)).toEqual(['page'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('returns an empty objects snapshot when no objectTypes are configured', () => {
|
|
84
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
85
|
+
expect(storage.getSnapshot!().documents.length).toBe(2)
|
|
86
|
+
expect(storage.getObjectsSnapshot!()).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('routes writes by type and shares the clock, tombstones, and change feed', () => {
|
|
90
|
+
const storage = createPartitioned(makeContractSnapshot(contractRecords))
|
|
91
|
+
const newPage = makePage('lane')
|
|
92
|
+
storage.transaction((txn) => txn.set(newPage.id, newPage))
|
|
93
|
+
|
|
94
|
+
// the write landed in the object partition, not the document snapshot
|
|
95
|
+
expect(storage.getSnapshot!().documents.map((d) => d.state.id)).toEqual([TLDOCUMENT_ID])
|
|
96
|
+
expect(storage.getObjectsSnapshot!().map((d) => d.state.id)).toContain(newPage.id)
|
|
97
|
+
|
|
98
|
+
// reads and the change feed span both partitions
|
|
99
|
+
storage.transaction((txn) => {
|
|
100
|
+
expect(txn.get(newPage.id)).toEqual(newPage)
|
|
101
|
+
expect(txn.getChangesSince(0)!.diff.puts[newPage.id]).toEqual(newPage)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// deleting an object-lane record writes a shared tombstone
|
|
105
|
+
const clockBeforeDelete = storage.getClock()
|
|
106
|
+
storage.transaction((txn) => txn.delete(newPage.id))
|
|
107
|
+
expect(storage.getObjectsSnapshot!().map((d) => d.state.id)).not.toContain(newPage.id)
|
|
108
|
+
storage.transaction((txn) => {
|
|
109
|
+
expect(txn.getChangesSince(clockBeforeDelete)!.diff.deletes).toContain(newPage.id)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('iterates both partitions so schema migrations cover object-lane records', () => {
|
|
114
|
+
const storage = createPartitioned(makeContractSnapshot(contractRecords))
|
|
115
|
+
storage.transaction((txn) => {
|
|
116
|
+
const ids = Array.from(txn.keys()).sort()
|
|
117
|
+
expect(ids).toEqual(contractRecords.map((r) => r.id).sort())
|
|
118
|
+
expect(
|
|
119
|
+
Array.from(txn.values())
|
|
120
|
+
.map((r) => r.id)
|
|
121
|
+
.sort()
|
|
122
|
+
).toEqual(ids)
|
|
123
|
+
expect(
|
|
124
|
+
Array.from(txn.entries())
|
|
125
|
+
.map(([id]) => id)
|
|
126
|
+
.sort()
|
|
127
|
+
).toEqual(ids)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('round-trips through a merged snapshot (persist lanes separately, re-seed together)', () => {
|
|
132
|
+
const storage = createPartitioned(makeContractSnapshot(contractRecords))
|
|
133
|
+
const merged = {
|
|
134
|
+
...storage.getSnapshot!(),
|
|
135
|
+
documents: [...storage.getSnapshot!().documents, ...storage.getObjectsSnapshot!()],
|
|
136
|
+
}
|
|
137
|
+
const reseeded = createPartitioned(merged)
|
|
138
|
+
expect(reseeded.getSnapshot!().documents.map((d) => d.state.typeName)).toEqual(['document'])
|
|
139
|
+
expect(reseeded.getObjectsSnapshot!().map((d) => d.state.typeName)).toEqual(['page'])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
64
143
|
describe('transactions', () => {
|
|
65
144
|
it('[SS2] returns the callback result along with clock metadata', () => {
|
|
66
145
|
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 7 }))
|
|
@@ -2,6 +2,7 @@ import { computed } from '@tldraw/state'
|
|
|
2
2
|
import {
|
|
3
3
|
BaseRecord,
|
|
4
4
|
RecordId,
|
|
5
|
+
SerializedSchema,
|
|
5
6
|
Store,
|
|
6
7
|
StoreSchema,
|
|
7
8
|
UnknownRecord,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
createRecordMigrationSequence,
|
|
11
12
|
createRecordType,
|
|
12
13
|
} from '@tldraw/store'
|
|
14
|
+
import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'
|
|
13
15
|
import { vi, type Mock } from 'vitest'
|
|
14
16
|
import { RecordOpType, ValueOpType } from '../lib/diff'
|
|
15
17
|
import { TLSocketServerSentEvent, getTlsyncProtocolVersion } from '../lib/protocol'
|
|
@@ -218,6 +220,47 @@ class TestInstance {
|
|
|
218
220
|
}
|
|
219
221
|
}
|
|
220
222
|
|
|
223
|
+
describe('record types unknown to older clients', () => {
|
|
224
|
+
// Mirrors a deploy that adds the comment record types: stale tabs still run a schema
|
|
225
|
+
// without them, while the server registers them and serves rooms that may contain them. The
|
|
226
|
+
// comment types ship guard migrations (retroactive, no `down`), so the server must reject
|
|
227
|
+
// stale sessions with CLIENT_TOO_OLD (prompting a refresh) rather than sending them record
|
|
228
|
+
// types their store cannot represent.
|
|
229
|
+
const schemaWithComments = createTLSchema({ records: commentSchemaRecords })
|
|
230
|
+
const schemaWithoutComments = createTLSchema()
|
|
231
|
+
|
|
232
|
+
function connectWith(server: TestServer<any>, schema: SerializedSchema) {
|
|
233
|
+
const id = 'stale-tab'
|
|
234
|
+
const socket = mockSocket()
|
|
235
|
+
server.room.handleNewSession({ sessionId: id, socket, meta: undefined, isReadonly: false })
|
|
236
|
+
server.room.handleMessage(id, {
|
|
237
|
+
type: 'connect',
|
|
238
|
+
connectRequestId: 'test',
|
|
239
|
+
lastServerClock: 0,
|
|
240
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
241
|
+
schema,
|
|
242
|
+
})
|
|
243
|
+
return socket
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
it('[HS3] rejects sessions whose schema predates the comment types', () => {
|
|
247
|
+
const server = new TestServer(schemaWithComments as any, undefined, {
|
|
248
|
+
objectTypes: ['comment', 'comment-thread'],
|
|
249
|
+
})
|
|
250
|
+
const socket = connectWith(server, schemaWithoutComments.serialize())
|
|
251
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('[HS3] accepts sessions whose schema registers the comment types', () => {
|
|
255
|
+
const server = new TestServer(schemaWithComments as any, undefined, {
|
|
256
|
+
objectTypes: ['comment', 'comment-thread'],
|
|
257
|
+
})
|
|
258
|
+
const socket = connectWith(server, schemaWithComments.serialize())
|
|
259
|
+
expect(socket.close).not.toHaveBeenCalled()
|
|
260
|
+
expect((socket.sendMessage as Mock).mock.calls[0][0]).toMatchObject({ type: 'connect' })
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
221
264
|
it('[MG2] the server can handle receiving v1 stuff from the client', () => {
|
|
222
265
|
const t = new TestInstance()
|
|
223
266
|
t.oldSocketPair.connect()
|
|
@@ -568,6 +611,7 @@ describe('when the client is too old', () => {
|
|
|
568
611
|
schema: schemaV2.serialize(),
|
|
569
612
|
serverClock: 10,
|
|
570
613
|
isReadonly: false,
|
|
614
|
+
objectAccess: 'write',
|
|
571
615
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
572
616
|
|
|
573
617
|
expect(v1SendMessage).toHaveBeenCalledWith({
|
|
@@ -579,6 +623,7 @@ describe('when the client is too old', () => {
|
|
|
579
623
|
schema: schemaV2.serialize(),
|
|
580
624
|
serverClock: 10,
|
|
581
625
|
isReadonly: false,
|
|
626
|
+
objectAccess: 'write',
|
|
582
627
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
583
628
|
|
|
584
629
|
v2SendMessage.mockClear()
|
|
@@ -1100,6 +1145,7 @@ describe('when the client is the same version', () => {
|
|
|
1100
1145
|
schema: schemaV2.serialize(),
|
|
1101
1146
|
serverClock: 10,
|
|
1102
1147
|
isReadonly: false,
|
|
1148
|
+
objectAccess: 'write',
|
|
1103
1149
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
1104
1150
|
|
|
1105
1151
|
expect(bSocket.sendMessage).toHaveBeenCalledWith({
|
|
@@ -1111,6 +1157,7 @@ describe('when the client is the same version', () => {
|
|
|
1111
1157
|
schema: schemaV2.serialize(),
|
|
1112
1158
|
serverClock: 10,
|
|
1113
1159
|
isReadonly: false,
|
|
1160
|
+
objectAccess: 'write',
|
|
1114
1161
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
1115
1162
|
;(aSocket.sendMessage as Mock).mockClear()
|
|
1116
1163
|
;(bSocket.sendMessage as Mock).mockClear()
|