@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.
- package/dist-cjs/index.d.ts +89 -0
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +11 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/SQLiteSyncStorage.js +15 -0
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +1 -0
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +135 -7
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-esm/index.d.mts +89 -0
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +11 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -0
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +1 -0
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +135 -7
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/package.json +6 -6
- package/src/index.ts +2 -0
- package/src/lib/InMemorySyncStorage.ts +12 -0
- package/src/lib/SQLiteSyncStorage.ts +16 -0
- package/src/lib/TLSocketRoom.ts +8 -1
- package/src/lib/TLSyncRoom.ts +252 -9
- package/src/lib/TLSyncStorage.ts +7 -0
- package/src/test/objectStore.test.ts +293 -1
- package/src/test/storageContractSuite.ts +22 -0
- 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:
|
|
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(
|
|
177
|
-
|
|
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
|
+
})
|