@tldraw/sync-core 5.3.0-next.75e2874c4b3f → 5.3.0-next.7654e7ac2a02

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 (50) hide show
  1. package/dist-cjs/index.d.ts +165 -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 +66 -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 +130 -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 +178 -12
  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 +165 -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 +66 -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 +130 -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 +178 -12
  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 +86 -21
  36. package/src/lib/RoomSession.ts +7 -2
  37. package/src/lib/SQLiteSyncStorage.test.ts +29 -2
  38. package/src/lib/SQLiteSyncStorage.ts +174 -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 +328 -13
  43. package/src/lib/TLSyncStorage.ts +15 -0
  44. package/src/lib/protocol.ts +17 -0
  45. package/src/test/TLSocketRoom.test.ts +37 -2
  46. package/src/test/TLSyncRoom.test.ts +25 -0
  47. package/src/test/TestServer.ts +14 -4
  48. package/src/test/objectStore.test.ts +679 -0
  49. package/src/test/storageContractSuite.ts +101 -0
  50. package/src/test/upgradeDowngrade.test.ts +144 -2
@@ -0,0 +1,679 @@
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; isReadonly: boolean; 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('exposes the session isReadonly flag to the authorizer', () => {
440
+ const authorize = vi.fn(({ next }: any) => next)
441
+ const { room } = withNote(authorize)
442
+ // a canvas-readonly session can still write the object lane (objectAccess defaults to
443
+ // 'write'), which is exactly why authorizers need to see isReadonly independently
444
+ connectSession(room, 'reader', { isReadonly: true, meta: { userId: 'u' } })
445
+
446
+ const note = Note.create({ text: 'x' })
447
+ push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
448
+
449
+ expect(authorize.mock.calls[0][0].session.isReadonly).toBe(true)
450
+
451
+ authorize.mockClear()
452
+ connectSession(room, 'writer', { isReadonly: false, meta: { userId: 'u' } })
453
+ const note2 = Note.create({ text: 'y' })
454
+ push(room, 'writer', { [note2.id]: [RecordOpType.Put, note2] })
455
+
456
+ expect(authorize.mock.calls[0][0].session.isReadonly).toBe(false)
457
+ })
458
+
459
+ it('stamps a created record from the session, overriding the client value', () => {
460
+ const { room, storage } = withNote(authorizeNote)
461
+ const socket = connectSession(room, 'alice', { meta: { userId: 'user-alice' } })
462
+
463
+ const note = Note.create({ text: 'forged' })
464
+ push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })
465
+
466
+ // the server rewrote the record, so it rebases the client onto the stamped value
467
+ expect(lastPushResult(socket)).toMatchObject({
468
+ action: {
469
+ rebaseWithDiff: { [note.id]: [RecordOpType.Put, { ...note, text: 'by:user-alice' }] },
470
+ },
471
+ })
472
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
473
+ })
474
+
475
+ it('rejects a create the authorizer denies (returns null)', () => {
476
+ const { room, storage } = withNote(authorizeNote)
477
+ const socket = connectSession(room, 'anon', { meta: { userId: null } })
478
+
479
+ const note = Note.create({ text: 'nope' })
480
+ push(room, 'anon', { [note.id]: [RecordOpType.Put, note] })
481
+
482
+ expect(socket.close).not.toHaveBeenCalled()
483
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
484
+ expect(storedIds(storage)).toEqual([])
485
+ })
486
+
487
+ it('only runs for registered types — other records write through untouched', () => {
488
+ const seen: string[] = []
489
+ const { room, storage } = withNote(({ next }: any) => {
490
+ seen.push('note')
491
+ return next
492
+ })
493
+ connectSession(room, 'writer', { meta: { userId: 'u' } })
494
+
495
+ const doc = Doc.create({ title: 'hi' })
496
+ push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
497
+
498
+ expect(seen).toEqual([])
499
+ expect(storedIds(storage)).toEqual([doc.id])
500
+ })
501
+
502
+ it('authorizes document-lane record types', () => {
503
+ // authorize `doc`, a document-lane record
504
+ const { room, storage } = makeRoom({
505
+ objectTypes: ['note'],
506
+ authorizeRecord: {
507
+ doc: ({ type, next }: any) => (type === 'create' ? { ...next, title: 'stamped' } : next),
508
+ },
509
+ })
510
+ connectSession(room, 'writer', { meta: { userId: 'u' } })
511
+
512
+ const doc = Doc.create({ title: 'client' })
513
+ push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
514
+
515
+ const stored = storage.getSnapshot().documents.find((d) => d.state.id === doc.id)
516
+ ?.state as any
517
+ expect(stored?.title).toBe('stamped')
518
+ })
519
+
520
+ it('vetoes an update that changes an immutable field (patch)', () => {
521
+ const { room, storage, note } = seededWithNote(authorizeNote)
522
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
523
+
524
+ push(room, 'mallory', {
525
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
526
+ })
527
+
528
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
529
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
530
+ })
531
+
532
+ it('allows an update the authorizer permits (patch)', () => {
533
+ const { room, storage, note } = seededWithNote(({ type, prev, next }: any) =>
534
+ type === 'delete' ? prev : next
535
+ )
536
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
537
+
538
+ push(room, 'alice', {
539
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'edited'] }],
540
+ })
541
+
542
+ expect(storedNote(storage, note.id)?.text).toBe('edited')
543
+ })
544
+
545
+ it('does not consult the authorizer for a no-op patch', () => {
546
+ const authorize = vi.fn(({ type, prev, next }: any) => (type === 'delete' ? prev : next))
547
+ const { room, note } = seededWithNote(authorize)
548
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
549
+ authorize.mockClear()
550
+
551
+ push(room, 'alice', {
552
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, note.text] }],
553
+ })
554
+
555
+ expect(authorize).not.toHaveBeenCalled()
556
+ })
557
+
558
+ it('vetoes a delete the authorizer rejects', () => {
559
+ const { room, storage, note } = seededWithNote(({ type, next }: any) =>
560
+ type === 'delete' ? null : next
561
+ )
562
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'm' } })
563
+
564
+ push(room, 'mallory', { [note.id]: [RecordOpType.Remove] })
565
+
566
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
567
+ expect(storedIds(storage)).toEqual([note.id])
568
+ })
569
+
570
+ it('vetoes a put that changes the typeName of an existing record', () => {
571
+ const { room, storage, note } = seededWithNote(authorizeNote)
572
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
573
+
574
+ // a `doc`-shaped record reusing the note's id: the authorizer lookup keys off the incoming
575
+ // typeName, so the swap would consult the doc authorizer (here: none) instead of note's
576
+ push(room, 'mallory', {
577
+ [note.id]: [RecordOpType.Put, { id: note.id, typeName: 'doc', title: 'swapped' } as any],
578
+ })
579
+
580
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
581
+ expect(storedNote(storage, note.id)?.typeName).toBe('note')
582
+ })
583
+
584
+ // The typeName guard must not depend on authorizers being configured: the write gate keys
585
+ // off the *incoming* typeName, so relabeling an existing object-lane record as a document
586
+ // type would otherwise let an objectAccess:'read' session write through the document lane.
587
+ it('vetoes a typeName swap even with no authorizeRecord configured', () => {
588
+ const note = Note.create({ text: 'by:user-alice' })
589
+ const { room, storage } = makeRoom({
590
+ objectTypes: ['note'],
591
+ snapshot: {
592
+ documents: [{ state: note, lastChangedClock: 0 }],
593
+ clock: 0,
594
+ documentClock: 0,
595
+ schema: schema.serialize(),
596
+ },
597
+ })
598
+ // denied on the object lane, but allowed on the document lane
599
+ const socket = connectSession(room, 'mallory', {
600
+ objectAccess: 'read',
601
+ meta: { userId: 'user-mallory' },
602
+ })
603
+
604
+ push(room, 'mallory', {
605
+ [note.id]: [RecordOpType.Put, { id: note.id, typeName: 'doc', title: 'swapped' } as any],
606
+ })
607
+
608
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
609
+ expect(storedNote(storage, note.id)?.typeName).toBe('note')
610
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
611
+ })
612
+
613
+ it('vetoes a put over an existing record that changes an immutable field', () => {
614
+ const { room, storage, note } = seededWithNote(authorizeNote)
615
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
616
+
617
+ push(room, 'mallory', { [note.id]: [RecordOpType.Put, { ...note, text: 'tampered' }] })
618
+
619
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
620
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
621
+ })
622
+
623
+ it('ignores the record returned by an update authorizer (allow/veto only)', () => {
624
+ const { room, storage, note } = seededWithNote(({ type, prev, next }: any) => {
625
+ if (type === 'update') return { ...next, text: 'stamped-on-update' }
626
+ return type === 'delete' ? prev : next
627
+ })
628
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
629
+
630
+ push(room, 'alice', { [note.id]: [RecordOpType.Put, { ...note, text: 'edited' }] })
631
+
632
+ expect(storedNote(storage, note.id)?.text).toBe('edited')
633
+ })
634
+
635
+ it('rejects the write (fail closed) when the authorizer throws, without crashing the push', () => {
636
+ const { room, storage } = withNote(() => {
637
+ throw new Error('boom')
638
+ })
639
+ const socket = connectSession(room, 'alice', { meta: { userId: 'u' } })
640
+ const note = Note.create({ text: 'x' })
641
+
642
+ expect(() => push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })).not.toThrow()
643
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
644
+ expect(storedIds(storage)).toEqual([])
645
+ })
646
+
647
+ it('passes the change type for create, update, and delete', () => {
648
+ const types: string[] = []
649
+ const note = Note.create({ text: 'x' })
650
+ const { room } = withNote(({ type, prev, next }: any) => {
651
+ types.push(type)
652
+ return type === 'delete' ? prev : next
653
+ })
654
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
655
+
656
+ push(room, 'alice', { [note.id]: [RecordOpType.Put, note] }, 1)
657
+ push(room, 'alice', { [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'y'] }] }, 2)
658
+ push(room, 'alice', { [note.id]: [RecordOpType.Remove] }, 3)
659
+
660
+ expect(types).toEqual(['create', 'update', 'delete'])
661
+ })
662
+
663
+ it('logs a warning when a write is vetoed', () => {
664
+ const warn = vi.fn()
665
+ const note = Note.create({ text: 'by:user-alice' })
666
+ const { room } = makeRoom({
667
+ objectTypes: ['note'],
668
+ authorizeRecord: { note: () => null },
669
+ log: { warn },
670
+ })
671
+ connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
672
+
673
+ push(room, 'mallory', { [note.id]: [RecordOpType.Put, note] })
674
+
675
+ expect(warn).toHaveBeenCalledTimes(1)
676
+ expect(warn.mock.calls[0].join(' ')).toContain(note.id)
677
+ })
678
+ })
679
+ })