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

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,630 @@
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
+ })