@tldraw/sync-core 5.3.0-internal.fba91ed55f6c → 5.3.0-next.91d2a20b898c
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 +3 -154
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +20 -55
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +52 -115
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +2 -27
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +1 -6
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +10 -160
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-esm/index.d.mts +3 -154
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +20 -55
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +52 -115
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +2 -27
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +1 -6
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +10 -160
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- 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 +0 -3
- package/src/lib/InMemorySyncStorage.ts +21 -74
- package/src/lib/RoomSession.ts +2 -7
- package/src/lib/SQLiteSyncStorage.test.ts +2 -29
- package/src/lib/SQLiteSyncStorage.ts +59 -158
- package/src/lib/TLSocketRoom.ts +3 -61
- package/src/lib/TLSyncClient.test.ts +2 -9
- package/src/lib/TLSyncClient.ts +3 -15
- package/src/lib/TLSyncRoom.ts +10 -294
- package/src/lib/TLSyncStorage.ts +0 -8
- package/src/lib/protocol.ts +0 -17
- package/src/test/TLSocketRoom.test.ts +2 -37
- package/src/test/TLSyncRoom.test.ts +0 -25
- package/src/test/TestServer.ts +4 -14
- package/src/test/storageContractSuite.ts +0 -79
- package/src/test/upgradeDowngrade.test.ts +2 -144
- package/src/test/objectStore.test.ts +0 -630
|
@@ -1,630 +0,0 @@
|
|
|
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
|
-
authorizeRecord?: {
|
|
87
|
-
[typeName: string]: (args: {
|
|
88
|
-
session: { sessionId: string; meta: any }
|
|
89
|
-
type: 'create' | 'update' | 'delete'
|
|
90
|
-
prev: any
|
|
91
|
-
next: any
|
|
92
|
-
}) => any
|
|
93
|
-
}
|
|
94
|
-
log?: any
|
|
95
|
-
} = {}
|
|
96
|
-
) {
|
|
97
|
-
const storage = new InMemorySyncStorage<R>({
|
|
98
|
-
snapshot: opts.snapshot ?? {
|
|
99
|
-
documents: [],
|
|
100
|
-
clock: 0,
|
|
101
|
-
documentClock: 0,
|
|
102
|
-
schema: schema.serialize(),
|
|
103
|
-
},
|
|
104
|
-
// keep the storage partition in step with the room's object lane
|
|
105
|
-
objectTypes: opts.objectTypes,
|
|
106
|
-
})
|
|
107
|
-
const room = new TLSyncRoom<R, undefined>({
|
|
108
|
-
schema,
|
|
109
|
-
storage,
|
|
110
|
-
objectTypes: opts.objectTypes,
|
|
111
|
-
onCommittedChanges: opts.onCommittedChanges,
|
|
112
|
-
authorizeRecord: opts.authorizeRecord,
|
|
113
|
-
log: opts.log,
|
|
114
|
-
})
|
|
115
|
-
disposables.push(() => room.close())
|
|
116
|
-
return { storage, room }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function connectSession(
|
|
120
|
-
room: TLSyncRoom<any, any>,
|
|
121
|
-
sessionId: string,
|
|
122
|
-
opts: {
|
|
123
|
-
isReadonly?: boolean
|
|
124
|
-
objectAccess?: 'read' | 'write'
|
|
125
|
-
clear?: boolean
|
|
126
|
-
meta?: any
|
|
127
|
-
} = {}
|
|
128
|
-
): MockSocket {
|
|
129
|
-
const socket = makeSocket()
|
|
130
|
-
room.handleNewSession({
|
|
131
|
-
sessionId,
|
|
132
|
-
socket,
|
|
133
|
-
meta: opts.meta,
|
|
134
|
-
isReadonly: opts.isReadonly ?? false,
|
|
135
|
-
objectAccess: opts.objectAccess,
|
|
136
|
-
})
|
|
137
|
-
room.handleMessage(sessionId, {
|
|
138
|
-
type: 'connect',
|
|
139
|
-
connectRequestId: 'connect-' + sessionId,
|
|
140
|
-
lastServerClock: 0,
|
|
141
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
142
|
-
schema: room.serializedSchema,
|
|
143
|
-
} satisfies TLConnectRequest)
|
|
144
|
-
if (opts.clear !== false) {
|
|
145
|
-
clearSocket(socket)
|
|
146
|
-
}
|
|
147
|
-
return socket
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function push(
|
|
151
|
-
room: TLSyncRoom<any, any>,
|
|
152
|
-
sessionId: string,
|
|
153
|
-
diff: TLPushRequest<any>['diff'],
|
|
154
|
-
clientClock = 1
|
|
155
|
-
) {
|
|
156
|
-
room.handleMessage(sessionId, { type: 'push', clientClock, diff } satisfies TLPushRequest<any>)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function lastPushResult(socket: MockSocket) {
|
|
160
|
-
return sentDataMessages(socket)
|
|
161
|
-
.filter((m) => m.type === 'push_result')
|
|
162
|
-
.at(-1)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function storedIds(storage: InMemorySyncStorage<R>) {
|
|
166
|
-
// span both partitions: the document snapshot no longer contains object-lane records
|
|
167
|
-
return [...storage.getSnapshot().documents, ...storage.getObjectsSnapshot()]
|
|
168
|
-
.map((d) => d.state.id)
|
|
169
|
-
.sort()
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
describe('object store lane', () => {
|
|
173
|
-
describe('type partition', () => {
|
|
174
|
-
it('excludes object types from documentTypes and exposes them via objectTypes', () => {
|
|
175
|
-
const { room } = makeRoom({ objectTypes: ['note'] })
|
|
176
|
-
expect(room.documentTypes.has('doc')).toBe(true)
|
|
177
|
-
expect(room.documentTypes.has('note')).toBe(false)
|
|
178
|
-
expect(room.objectTypes.has('note')).toBe(true)
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('keeps documentTypes intact when no objectTypes are configured', () => {
|
|
182
|
-
const { room } = makeRoom()
|
|
183
|
-
expect(room.documentTypes.has('doc')).toBe(true)
|
|
184
|
-
expect(room.documentTypes.has('note')).toBe(true)
|
|
185
|
-
expect(room.objectTypes.size).toBe(0)
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
it('rejects object types that are not registered in the schema', () => {
|
|
189
|
-
expect(() => makeRoom({ objectTypes: ['nonexistent'] })).toThrow(
|
|
190
|
-
"object type 'nonexistent' is not registered"
|
|
191
|
-
)
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
it('rejects object types that are not document-scoped', () => {
|
|
195
|
-
expect(() => makeRoom({ objectTypes: ['presence'] })).toThrow(
|
|
196
|
-
"object type 'presence' must have scope 'document'"
|
|
197
|
-
)
|
|
198
|
-
})
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
describe('object writes', () => {
|
|
202
|
-
it('commits object puts, broadcasts them, and hydrates them on connect', () => {
|
|
203
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
204
|
-
const writer = connectSession(room, 'writer')
|
|
205
|
-
const observer = connectSession(room, 'observer')
|
|
206
|
-
|
|
207
|
-
const note = Note.create({ text: 'hello' })
|
|
208
|
-
push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
|
|
209
|
-
|
|
210
|
-
expect(lastPushResult(writer)).toMatchObject({ action: 'commit' })
|
|
211
|
-
expect(storedIds(storage)).toEqual([note.id])
|
|
212
|
-
|
|
213
|
-
// broadcast to the other session
|
|
214
|
-
room._flushDataMessages('observer')
|
|
215
|
-
expect(
|
|
216
|
-
sentDataMessages(observer).find(
|
|
217
|
-
(m) => m.type === 'patch' && m.diff[note.id]?.[0] === RecordOpType.Put
|
|
218
|
-
)
|
|
219
|
-
).toBeDefined()
|
|
220
|
-
|
|
221
|
-
// a fresh connect hydrates the object record
|
|
222
|
-
const late = connectSession(room, 'late', { clear: false })
|
|
223
|
-
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
|
|
224
|
-
expect(connectMsg.diff[note.id]).toEqual([RecordOpType.Put, note])
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
it('still rejects unknown record types for writable sessions', () => {
|
|
228
|
-
const { room } = makeRoom({ objectTypes: ['note'] })
|
|
229
|
-
const socket = connectSession(room, 'writer')
|
|
230
|
-
|
|
231
|
-
push(room, 'writer', {
|
|
232
|
-
'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
expect(socket.close).toHaveBeenCalledWith(
|
|
236
|
-
TLSyncErrorCloseEventCode,
|
|
237
|
-
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
238
|
-
)
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
it('skips unknown record types for denied sessions without rejecting (legacy readonly behavior)', () => {
|
|
242
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
243
|
-
const socket = connectSession(room, 'reader', { isReadonly: true })
|
|
244
|
-
|
|
245
|
-
push(room, 'reader', {
|
|
246
|
-
'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
expect(socket.close).not.toHaveBeenCalled()
|
|
250
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
251
|
-
expect(storedIds(storage)).toEqual([])
|
|
252
|
-
})
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
describe('permission axes', () => {
|
|
256
|
-
it('lets a readonly session with object write access write objects but not documents', () => {
|
|
257
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
258
|
-
// "can comment but not edit"
|
|
259
|
-
const socket = connectSession(room, 'commenter', {
|
|
260
|
-
isReadonly: true,
|
|
261
|
-
objectAccess: 'write',
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
const doc = Doc.create({ title: 'nope' })
|
|
265
|
-
const note = Note.create({ text: 'yep' })
|
|
266
|
-
push(room, 'commenter', {
|
|
267
|
-
[doc.id]: [RecordOpType.Put, doc],
|
|
268
|
-
[note.id]: [RecordOpType.Put, note],
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
// only the object op survives, so the client is rebased onto it
|
|
272
|
-
expect(lastPushResult(socket)).toMatchObject({
|
|
273
|
-
action: { rebaseWithDiff: { [note.id]: [RecordOpType.Put, note] } },
|
|
274
|
-
})
|
|
275
|
-
expect(storedIds(storage)).toEqual([note.id])
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
it('blocks object writes for objectAccess: read while document writes still commit', () => {
|
|
279
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
280
|
-
const socket = connectSession(room, 'editor', { objectAccess: 'read' })
|
|
281
|
-
|
|
282
|
-
const doc = Doc.create({ title: 'yep' })
|
|
283
|
-
const note = Note.create({ text: 'nope' })
|
|
284
|
-
push(room, 'editor', {
|
|
285
|
-
[doc.id]: [RecordOpType.Put, doc],
|
|
286
|
-
[note.id]: [RecordOpType.Put, note],
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
expect(lastPushResult(socket)).toMatchObject({
|
|
290
|
-
action: { rebaseWithDiff: { [doc.id]: [RecordOpType.Put, doc] } },
|
|
291
|
-
})
|
|
292
|
-
expect(storedIds(storage)).toEqual([doc.id])
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
it('discards object-only pushes entirely for objectAccess: read', () => {
|
|
296
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
297
|
-
const socket = connectSession(room, 'reader', { objectAccess: 'read' })
|
|
298
|
-
|
|
299
|
-
const note = Note.create({ text: 'nope' })
|
|
300
|
-
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
|
|
301
|
-
|
|
302
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
303
|
-
expect(storedIds(storage)).toEqual([])
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
it('gates object patches and removes by objectAccess', () => {
|
|
307
|
-
const note = Note.create({ text: 'original' })
|
|
308
|
-
const { room, storage } = makeRoom({
|
|
309
|
-
objectTypes: ['note'],
|
|
310
|
-
snapshot: {
|
|
311
|
-
documents: [{ state: note, lastChangedClock: 0 }],
|
|
312
|
-
clock: 0,
|
|
313
|
-
documentClock: 0,
|
|
314
|
-
schema: schema.serialize(),
|
|
315
|
-
},
|
|
316
|
-
})
|
|
317
|
-
const reader = connectSession(room, 'reader', { objectAccess: 'read' })
|
|
318
|
-
|
|
319
|
-
push(room, 'reader', {
|
|
320
|
-
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
|
|
321
|
-
})
|
|
322
|
-
expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
|
|
323
|
-
|
|
324
|
-
push(room, 'reader', { [note.id]: [RecordOpType.Remove] }, 2)
|
|
325
|
-
expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
|
|
326
|
-
expect(storedIds(storage)).toEqual([note.id])
|
|
327
|
-
|
|
328
|
-
// a readonly-but-object-writable session can remove the object record
|
|
329
|
-
connectSession(room, 'commenter', { isReadonly: true, objectAccess: 'write' })
|
|
330
|
-
push(room, 'commenter', { [note.id]: [RecordOpType.Remove] })
|
|
331
|
-
expect(storedIds(storage)).toEqual([])
|
|
332
|
-
})
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
describe('storage partition', () => {
|
|
336
|
-
it('keeps object records out of the document snapshot but in the objects snapshot', () => {
|
|
337
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
338
|
-
connectSession(room, 'writer')
|
|
339
|
-
|
|
340
|
-
const doc = Doc.create({ title: 'canvas' })
|
|
341
|
-
const note = Note.create({ text: 'annotation' })
|
|
342
|
-
push(room, 'writer', {
|
|
343
|
-
[doc.id]: [RecordOpType.Put, doc],
|
|
344
|
-
[note.id]: [RecordOpType.Put, note],
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
// the document snapshot is pure-document by construction
|
|
348
|
-
expect(storage.getSnapshot().documents.map((d) => d.state.id)).toEqual([doc.id])
|
|
349
|
-
// the object lane is persisted separately
|
|
350
|
-
expect(storage.getObjectsSnapshot().map((d) => d.state.id)).toEqual([note.id])
|
|
351
|
-
|
|
352
|
-
// but a fresh connect still hydrates both (shared clock + change feed)
|
|
353
|
-
const late = connectSession(room, 'late', { clear: false })
|
|
354
|
-
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
|
|
355
|
-
expect(Object.keys(connectMsg.diff).sort()).toEqual([doc.id, note.id].sort())
|
|
356
|
-
})
|
|
357
|
-
})
|
|
358
|
-
|
|
359
|
-
describe('connect message', () => {
|
|
360
|
-
it('carries the session objectAccess', () => {
|
|
361
|
-
const { room } = makeRoom({ objectTypes: ['note'] })
|
|
362
|
-
|
|
363
|
-
const writer = connectSession(room, 'writer', { clear: false })
|
|
364
|
-
const writerConnect = writer.__messages.find((m: any) => m.type === 'connect') as any
|
|
365
|
-
expect(writerConnect.objectAccess).toBe('write')
|
|
366
|
-
|
|
367
|
-
const reader = connectSession(room, 'reader', { objectAccess: 'read', clear: false })
|
|
368
|
-
const readerConnect = reader.__messages.find((m: any) => m.type === 'connect') as any
|
|
369
|
-
expect(readerConnect.objectAccess).toBe('read')
|
|
370
|
-
})
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
describe('onCommittedChanges projection hook', () => {
|
|
374
|
-
it('fires with the committed object diff', async () => {
|
|
375
|
-
const onCommittedChanges = vi.fn()
|
|
376
|
-
const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
|
|
377
|
-
connectSession(room, 'writer')
|
|
378
|
-
|
|
379
|
-
const note = Note.create({ text: 'hello' })
|
|
380
|
-
push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
|
|
381
|
-
await Promise.resolve() // the hook fires on a microtask
|
|
382
|
-
|
|
383
|
-
expect(onCommittedChanges).toHaveBeenCalledTimes(1)
|
|
384
|
-
expect(onCommittedChanges.mock.calls[0][0].diff.puts[note.id]).toEqual(note)
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
it('does not fire when every op in the push was denied', async () => {
|
|
388
|
-
const onCommittedChanges = vi.fn()
|
|
389
|
-
const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
|
|
390
|
-
connectSession(room, 'reader', { objectAccess: 'read' })
|
|
391
|
-
|
|
392
|
-
const note = Note.create({ text: 'nope' })
|
|
393
|
-
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
|
|
394
|
-
await Promise.resolve()
|
|
395
|
-
|
|
396
|
-
expect(onCommittedChanges).not.toHaveBeenCalled()
|
|
397
|
-
})
|
|
398
|
-
})
|
|
399
|
-
|
|
400
|
-
describe('authorizeRecord', () => {
|
|
401
|
-
it('rejects a presence typeName key at construction', () => {
|
|
402
|
-
expect(() => makeRoom({ authorizeRecord: { presence: ({ next }: any) => next } })).toThrow(
|
|
403
|
-
/presence/
|
|
404
|
-
)
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
// A per-type authorizer like dotcom's: stamp the note's text from the session's user id on
|
|
408
|
-
// create, keep it immutable on update, allow deletes.
|
|
409
|
-
const authorizeNote = ({ session, type, prev, next }: any) => {
|
|
410
|
-
if (type === 'create') {
|
|
411
|
-
const userId = session.meta?.userId
|
|
412
|
-
if (!userId) return null
|
|
413
|
-
return { ...next, text: `by:${userId}` }
|
|
414
|
-
}
|
|
415
|
-
if (type === 'update') return next.text === prev.text ? next : null
|
|
416
|
-
return prev // delete allowed
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function withNote(authorize: any, extra: any = {}) {
|
|
420
|
-
return makeRoom({ objectTypes: ['note'], authorizeRecord: { note: authorize }, ...extra })
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function seededWithNote(authorize: any) {
|
|
424
|
-
const note = Note.create({ text: 'by:user-alice' })
|
|
425
|
-
const { room, storage } = withNote(authorize, {
|
|
426
|
-
snapshot: {
|
|
427
|
-
documents: [{ state: note, lastChangedClock: 0 }],
|
|
428
|
-
clock: 0,
|
|
429
|
-
documentClock: 0,
|
|
430
|
-
schema: schema.serialize(),
|
|
431
|
-
},
|
|
432
|
-
})
|
|
433
|
-
return { room, storage, note }
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const storedNote = (storage: InMemorySyncStorage<R>, id: string) =>
|
|
437
|
-
storage.getObjectsSnapshot().find((d) => d.state.id === id)?.state as any
|
|
438
|
-
|
|
439
|
-
it('stamps a created record from the session, overriding the client value', () => {
|
|
440
|
-
const { room, storage } = withNote(authorizeNote)
|
|
441
|
-
const socket = connectSession(room, 'alice', { meta: { userId: 'user-alice' } })
|
|
442
|
-
|
|
443
|
-
const note = Note.create({ text: 'forged' })
|
|
444
|
-
push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })
|
|
445
|
-
|
|
446
|
-
// the server rewrote the record, so it rebases the client onto the stamped value
|
|
447
|
-
expect(lastPushResult(socket)).toMatchObject({
|
|
448
|
-
action: {
|
|
449
|
-
rebaseWithDiff: { [note.id]: [RecordOpType.Put, { ...note, text: 'by:user-alice' }] },
|
|
450
|
-
},
|
|
451
|
-
})
|
|
452
|
-
expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
|
|
453
|
-
})
|
|
454
|
-
|
|
455
|
-
it('rejects a create the authorizer denies (returns null)', () => {
|
|
456
|
-
const { room, storage } = withNote(authorizeNote)
|
|
457
|
-
const socket = connectSession(room, 'anon', { meta: { userId: null } })
|
|
458
|
-
|
|
459
|
-
const note = Note.create({ text: 'nope' })
|
|
460
|
-
push(room, 'anon', { [note.id]: [RecordOpType.Put, note] })
|
|
461
|
-
|
|
462
|
-
expect(socket.close).not.toHaveBeenCalled()
|
|
463
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
464
|
-
expect(storedIds(storage)).toEqual([])
|
|
465
|
-
})
|
|
466
|
-
|
|
467
|
-
it('only runs for registered types — other records write through untouched', () => {
|
|
468
|
-
const seen: string[] = []
|
|
469
|
-
const { room, storage } = withNote(({ next }: any) => {
|
|
470
|
-
seen.push('note')
|
|
471
|
-
return next
|
|
472
|
-
})
|
|
473
|
-
connectSession(room, 'writer', { meta: { userId: 'u' } })
|
|
474
|
-
|
|
475
|
-
const doc = Doc.create({ title: 'hi' })
|
|
476
|
-
push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
|
|
477
|
-
|
|
478
|
-
expect(seen).toEqual([])
|
|
479
|
-
expect(storedIds(storage)).toEqual([doc.id])
|
|
480
|
-
})
|
|
481
|
-
|
|
482
|
-
it('authorizes document-lane record types', () => {
|
|
483
|
-
// authorize `doc`, a document-lane record
|
|
484
|
-
const { room, storage } = makeRoom({
|
|
485
|
-
objectTypes: ['note'],
|
|
486
|
-
authorizeRecord: {
|
|
487
|
-
doc: ({ type, next }: any) => (type === 'create' ? { ...next, title: 'stamped' } : next),
|
|
488
|
-
},
|
|
489
|
-
})
|
|
490
|
-
connectSession(room, 'writer', { meta: { userId: 'u' } })
|
|
491
|
-
|
|
492
|
-
const doc = Doc.create({ title: 'client' })
|
|
493
|
-
push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
|
|
494
|
-
|
|
495
|
-
const stored = storage.getSnapshot().documents.find((d) => d.state.id === doc.id)
|
|
496
|
-
?.state as any
|
|
497
|
-
expect(stored?.title).toBe('stamped')
|
|
498
|
-
})
|
|
499
|
-
|
|
500
|
-
it('vetoes an update that changes an immutable field (patch)', () => {
|
|
501
|
-
const { room, storage, note } = seededWithNote(authorizeNote)
|
|
502
|
-
const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
|
|
503
|
-
|
|
504
|
-
push(room, 'mallory', {
|
|
505
|
-
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
|
|
506
|
-
})
|
|
507
|
-
|
|
508
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
509
|
-
expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
it('allows an update the authorizer permits (patch)', () => {
|
|
513
|
-
const { room, storage, note } = seededWithNote(({ type, prev, next }: any) =>
|
|
514
|
-
type === 'delete' ? prev : next
|
|
515
|
-
)
|
|
516
|
-
connectSession(room, 'alice', { meta: { userId: 'u' } })
|
|
517
|
-
|
|
518
|
-
push(room, 'alice', {
|
|
519
|
-
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'edited'] }],
|
|
520
|
-
})
|
|
521
|
-
|
|
522
|
-
expect(storedNote(storage, note.id)?.text).toBe('edited')
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
it('does not consult the authorizer for a no-op patch', () => {
|
|
526
|
-
const authorize = vi.fn(({ type, prev, next }: any) => (type === 'delete' ? prev : next))
|
|
527
|
-
const { room, note } = seededWithNote(authorize)
|
|
528
|
-
connectSession(room, 'alice', { meta: { userId: 'u' } })
|
|
529
|
-
authorize.mockClear()
|
|
530
|
-
|
|
531
|
-
push(room, 'alice', {
|
|
532
|
-
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, note.text] }],
|
|
533
|
-
})
|
|
534
|
-
|
|
535
|
-
expect(authorize).not.toHaveBeenCalled()
|
|
536
|
-
})
|
|
537
|
-
|
|
538
|
-
it('vetoes a delete the authorizer rejects', () => {
|
|
539
|
-
const { room, storage, note } = seededWithNote(({ type, next }: any) =>
|
|
540
|
-
type === 'delete' ? null : next
|
|
541
|
-
)
|
|
542
|
-
const socket = connectSession(room, 'mallory', { meta: { userId: 'm' } })
|
|
543
|
-
|
|
544
|
-
push(room, 'mallory', { [note.id]: [RecordOpType.Remove] })
|
|
545
|
-
|
|
546
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
547
|
-
expect(storedIds(storage)).toEqual([note.id])
|
|
548
|
-
})
|
|
549
|
-
|
|
550
|
-
it('vetoes a put that changes the typeName of an existing record', () => {
|
|
551
|
-
const { room, storage, note } = seededWithNote(authorizeNote)
|
|
552
|
-
const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
|
|
553
|
-
|
|
554
|
-
// a `doc`-shaped record reusing the note's id: the authorizer lookup keys off the incoming
|
|
555
|
-
// typeName, so the swap would consult the doc authorizer (here: none) instead of note's
|
|
556
|
-
push(room, 'mallory', {
|
|
557
|
-
[note.id]: [RecordOpType.Put, { id: note.id, typeName: 'doc', title: 'swapped' } as any],
|
|
558
|
-
})
|
|
559
|
-
|
|
560
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
561
|
-
expect(storedNote(storage, note.id)?.typeName).toBe('note')
|
|
562
|
-
})
|
|
563
|
-
|
|
564
|
-
it('vetoes a put over an existing record that changes an immutable field', () => {
|
|
565
|
-
const { room, storage, note } = seededWithNote(authorizeNote)
|
|
566
|
-
const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
|
|
567
|
-
|
|
568
|
-
push(room, 'mallory', { [note.id]: [RecordOpType.Put, { ...note, text: 'tampered' }] })
|
|
569
|
-
|
|
570
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
571
|
-
expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
|
|
572
|
-
})
|
|
573
|
-
|
|
574
|
-
it('ignores the record returned by an update authorizer (allow/veto only)', () => {
|
|
575
|
-
const { room, storage, note } = seededWithNote(({ type, prev, next }: any) => {
|
|
576
|
-
if (type === 'update') return { ...next, text: 'stamped-on-update' }
|
|
577
|
-
return type === 'delete' ? prev : next
|
|
578
|
-
})
|
|
579
|
-
connectSession(room, 'alice', { meta: { userId: 'u' } })
|
|
580
|
-
|
|
581
|
-
push(room, 'alice', { [note.id]: [RecordOpType.Put, { ...note, text: 'edited' }] })
|
|
582
|
-
|
|
583
|
-
expect(storedNote(storage, note.id)?.text).toBe('edited')
|
|
584
|
-
})
|
|
585
|
-
|
|
586
|
-
it('rejects the write (fail closed) when the authorizer throws, without crashing the push', () => {
|
|
587
|
-
const { room, storage } = withNote(() => {
|
|
588
|
-
throw new Error('boom')
|
|
589
|
-
})
|
|
590
|
-
const socket = connectSession(room, 'alice', { meta: { userId: 'u' } })
|
|
591
|
-
const note = Note.create({ text: 'x' })
|
|
592
|
-
|
|
593
|
-
expect(() => push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })).not.toThrow()
|
|
594
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
595
|
-
expect(storedIds(storage)).toEqual([])
|
|
596
|
-
})
|
|
597
|
-
|
|
598
|
-
it('passes the change type for create, update, and delete', () => {
|
|
599
|
-
const types: string[] = []
|
|
600
|
-
const note = Note.create({ text: 'x' })
|
|
601
|
-
const { room } = withNote(({ type, prev, next }: any) => {
|
|
602
|
-
types.push(type)
|
|
603
|
-
return type === 'delete' ? prev : next
|
|
604
|
-
})
|
|
605
|
-
connectSession(room, 'alice', { meta: { userId: 'u' } })
|
|
606
|
-
|
|
607
|
-
push(room, 'alice', { [note.id]: [RecordOpType.Put, note] }, 1)
|
|
608
|
-
push(room, 'alice', { [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'y'] }] }, 2)
|
|
609
|
-
push(room, 'alice', { [note.id]: [RecordOpType.Remove] }, 3)
|
|
610
|
-
|
|
611
|
-
expect(types).toEqual(['create', 'update', 'delete'])
|
|
612
|
-
})
|
|
613
|
-
|
|
614
|
-
it('logs a warning when a write is vetoed', () => {
|
|
615
|
-
const warn = vi.fn()
|
|
616
|
-
const note = Note.create({ text: 'by:user-alice' })
|
|
617
|
-
const { room } = makeRoom({
|
|
618
|
-
objectTypes: ['note'],
|
|
619
|
-
authorizeRecord: { note: () => null },
|
|
620
|
-
log: { warn },
|
|
621
|
-
})
|
|
622
|
-
connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
|
|
623
|
-
|
|
624
|
-
push(room, 'mallory', { [note.id]: [RecordOpType.Put, note] })
|
|
625
|
-
|
|
626
|
-
expect(warn).toHaveBeenCalledTimes(1)
|
|
627
|
-
expect(warn.mock.calls[0].join(' ')).toContain(note.id)
|
|
628
|
-
})
|
|
629
|
-
})
|
|
630
|
-
})
|