@tldraw/sync-core 5.3.0-internal.d205573d66cb → 5.3.0-next.030bf6014f04

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 (34) hide show
  1. package/dist-cjs/index.d.ts +89 -0
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +11 -0
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  6. package/dist-cjs/lib/SQLiteSyncStorage.js +15 -0
  7. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  8. package/dist-cjs/lib/TLSocketRoom.js +1 -0
  9. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  10. package/dist-cjs/lib/TLSyncRoom.js +135 -7
  11. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  12. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  13. package/dist-esm/index.d.mts +89 -0
  14. package/dist-esm/index.mjs +1 -1
  15. package/dist-esm/index.mjs.map +2 -2
  16. package/dist-esm/lib/InMemorySyncStorage.mjs +11 -0
  17. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  18. package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -0
  19. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  20. package/dist-esm/lib/TLSocketRoom.mjs +1 -0
  21. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  22. package/dist-esm/lib/TLSyncRoom.mjs +135 -7
  23. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  24. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  25. package/package.json +6 -6
  26. package/src/index.ts +2 -0
  27. package/src/lib/InMemorySyncStorage.ts +12 -0
  28. package/src/lib/SQLiteSyncStorage.ts +16 -0
  29. package/src/lib/TLSocketRoom.ts +8 -1
  30. package/src/lib/TLSyncRoom.ts +252 -9
  31. package/src/lib/TLSyncStorage.ts +7 -0
  32. package/src/test/objectStore.test.ts +293 -1
  33. package/src/test/storageContractSuite.ts +22 -0
  34. package/src/test/upgradeDowngrade.test.ts +97 -2
@@ -83,6 +83,15 @@ function makeRoom(
83
83
  snapshot?: RoomSnapshot
84
84
  objectTypes?: readonly string[]
85
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
86
95
  } = {}
87
96
  ) {
88
97
  const storage = new InMemorySyncStorage<R>({
@@ -100,6 +109,8 @@ function makeRoom(
100
109
  storage,
101
110
  objectTypes: opts.objectTypes,
102
111
  onCommittedChanges: opts.onCommittedChanges,
112
+ authorizeRecord: opts.authorizeRecord,
113
+ log: opts.log,
103
114
  })
104
115
  disposables.push(() => room.close())
105
116
  return { storage, room }
@@ -112,13 +123,14 @@ function connectSession(
112
123
  isReadonly?: boolean
113
124
  objectAccess?: 'read' | 'write'
114
125
  clear?: boolean
126
+ meta?: any
115
127
  } = {}
116
128
  ): MockSocket {
117
129
  const socket = makeSocket()
118
130
  room.handleNewSession({
119
131
  sessionId,
120
132
  socket,
121
- meta: undefined,
133
+ meta: opts.meta,
122
134
  isReadonly: opts.isReadonly ?? false,
123
135
  objectAccess: opts.objectAccess,
124
136
  })
@@ -384,4 +396,284 @@ describe('object store lane', () => {
384
396
  expect(onCommittedChanges).not.toHaveBeenCalled()
385
397
  })
386
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
+ })
387
679
  })
@@ -128,6 +128,28 @@ export function registerStorageContractTests(factory: StorageContractFactory) {
128
128
  })
129
129
  })
130
130
 
131
+ it('returns targeted object-lane entries by id from getObjectsByIds', () => {
132
+ const storage = createPartitioned(makeContractSnapshot(contractRecords))
133
+ const newPage = makePage('lane')
134
+ storage.transaction((txn) => txn.set(newPage.id, newPage))
135
+
136
+ const pageId = contractRecords[1].id
137
+ const entries = storage.getObjectsByIds!([newPage.id, pageId])
138
+ expect(entries.map((d) => d.state.id).sort()).toEqual([newPage.id, pageId].sort())
139
+ // same entry shape as getObjectsSnapshot: state + lastChangedClock
140
+ const laneEntry = entries.find((d) => d.state.id === newPage.id)!
141
+ expect(laneEntry.state).toEqual(newPage)
142
+ expect(laneEntry.lastChangedClock).toBe(storage.getClock())
143
+
144
+ // unknown ids and document-lane ids are omitted; duplicates yield one entry
145
+ expect(storage.getObjectsByIds!(['page:nope', TLDOCUMENT_ID])).toEqual([])
146
+ expect(storage.getObjectsByIds!([newPage.id, newPage.id]).length).toBe(1)
147
+
148
+ // a deleted record stops being returned
149
+ storage.transaction((txn) => txn.delete(newPage.id))
150
+ expect(storage.getObjectsByIds!([newPage.id])).toEqual([])
151
+ })
152
+
131
153
  it('round-trips through a merged snapshot (persist lanes separately, re-seed together)', () => {
132
154
  const storage = createPartitioned(makeContractSnapshot(contractRecords))
133
155
  const merged = {
@@ -173,8 +173,13 @@ class TestInstance {
173
173
 
174
174
  hasLoaded = false
175
175
 
176
- constructor(snapshot?: RoomSnapshot, oldSchema = schemaV1, newSchema = schemaV2) {
177
- this.server = new TestServer(newSchema, snapshot)
176
+ constructor(
177
+ snapshot?: RoomSnapshot,
178
+ oldSchema = schemaV1,
179
+ newSchema = schemaV2,
180
+ roomOpts?: ConstructorParameters<typeof TestServer<RV2>>[2]
181
+ ) {
182
+ this.server = new TestServer(newSchema, snapshot, roomOpts)
178
183
  this.oldSocketPair = new TestSocketPair('test_upgrade_old', this.server)
179
184
  this.newSocketPair = new TestSocketPair('test_upgrade_new', this.server)
180
185
 
@@ -1232,3 +1237,93 @@ describe('when the client is the same version', () => {
1232
1237
  } satisfies TLSocketServerSentEvent<RV2>)
1233
1238
  })
1234
1239
  })
1240
+
1241
+ describe('record authorizers see server-schema records', () => {
1242
+ function makeInstance(authorizeUser: (args: any) => any) {
1243
+ return new TestInstance(undefined, schemaV1, schemaV2, {
1244
+ authorizeRecord: { user: authorizeUser },
1245
+ })
1246
+ }
1247
+
1248
+ it('a put from an old client reaches the authorizer up-migrated', () => {
1249
+ const seen: any[] = []
1250
+ const t = makeInstance((args: any) => {
1251
+ seen.push(structuredClone({ type: args.type, prev: args.prev, next: args.next }))
1252
+ return args.next
1253
+ })
1254
+ t.oldSocketPair.connect()
1255
+ t.flush()
1256
+
1257
+ const user = UserV1.create({ name: 'bob', age: 10 })
1258
+ t.oldClient.store.put([user])
1259
+ t.flush()
1260
+
1261
+ expect(seen).toHaveLength(1)
1262
+ expect(seen[0].type).toBe('create')
1263
+ expect(seen[0].next).toMatchObject({ name: 'bob', birthdate: null })
1264
+ expect(seen[0].next).not.toHaveProperty('age')
1265
+ })
1266
+
1267
+ it('a stamp applied on create survives — no migration runs after the authorizer', () => {
1268
+ const t = makeInstance((args: any) =>
1269
+ args.type === 'create' ? { ...args.next, birthdate: '2001-02-03' } : args.next
1270
+ )
1271
+ t.oldSocketPair.connect()
1272
+ t.flush()
1273
+
1274
+ const user = UserV1.create({ name: 'bob', age: 10 })
1275
+ t.oldClient.store.put([user])
1276
+ t.flush()
1277
+
1278
+ expect(t.server.storage.documents.get(user.id)?.state).toMatchObject({
1279
+ name: 'bob',
1280
+ birthdate: '2001-02-03',
1281
+ })
1282
+ })
1283
+
1284
+ it('a patch from an old client reaches the authorizer as the upgraded committed candidate', () => {
1285
+ const seen: any[] = []
1286
+ const t = makeInstance((args: any) => {
1287
+ seen.push(structuredClone({ type: args.type, prev: args.prev, next: args.next }))
1288
+ return args.next
1289
+ })
1290
+ t.oldSocketPair.connect()
1291
+ t.newSocketPair.connect()
1292
+ t.flush()
1293
+
1294
+ const user = UserV2.create({ name: 'bob', birthdate: '2022-01-09' })
1295
+ t.newClient.store.put([user])
1296
+ t.flush()
1297
+ seen.length = 0
1298
+
1299
+ t.oldClient.store.update(user.id as any, (u: any) => ({ ...u, name: 'bobby' }))
1300
+ t.flush()
1301
+
1302
+ expect(seen).toHaveLength(1)
1303
+ expect(seen[0].type).toBe('update')
1304
+ expect(seen[0].prev).toMatchObject({ name: 'bob', birthdate: '2022-01-09' })
1305
+ // The down→patch→up round-trip through the lossy v1 schema resets birthdate to null.
1306
+ // The authorizer must see exactly what will be committed — not a preview mixing the
1307
+ // server record with the raw v1 diff (which would still show the old birthdate).
1308
+ expect(seen[0].next).toMatchObject({ name: 'bobby', birthdate: null })
1309
+ expect(seen[0].next).not.toHaveProperty('age')
1310
+ })
1311
+
1312
+ it('vetoes based on the server-schema record', () => {
1313
+ const t = makeInstance((args: any) =>
1314
+ args.type === 'update' && args.next.name === 'forged' ? null : args.next
1315
+ )
1316
+ t.oldSocketPair.connect()
1317
+ t.newSocketPair.connect()
1318
+ t.flush()
1319
+
1320
+ const user = UserV2.create({ name: 'bob', birthdate: '2022-01-09' })
1321
+ t.newClient.store.put([user])
1322
+ t.flush()
1323
+
1324
+ t.oldClient.store.update(user.id as any, (u: any) => ({ ...u, name: 'forged' }))
1325
+ t.flush()
1326
+
1327
+ expect(t.server.storage.documents.get(user.id)?.state).toMatchObject({ name: 'bob' })
1328
+ })
1329
+ })