@tldraw/sync-core 5.3.0-internal.d205573d66cb → 5.3.0-next.299378752aaf
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +3 -76
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +20 -55
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +52 -115
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +2 -26
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +1 -6
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +5 -43
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-esm/index.d.mts +3 -76
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +20 -55
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +52 -115
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +2 -26
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +1 -6
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +5 -43
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/index.ts +0 -1
- package/src/lib/InMemorySyncStorage.ts +21 -74
- package/src/lib/RoomSession.ts +2 -7
- package/src/lib/SQLiteSyncStorage.test.ts +2 -29
- package/src/lib/SQLiteSyncStorage.ts +59 -158
- package/src/lib/TLSocketRoom.ts +2 -53
- package/src/lib/TLSyncClient.test.ts +2 -9
- package/src/lib/TLSyncClient.ts +3 -15
- package/src/lib/TLSyncRoom.ts +4 -76
- package/src/lib/TLSyncStorage.ts +0 -8
- package/src/lib/protocol.ts +0 -17
- package/src/test/TLSocketRoom.test.ts +2 -37
- package/src/test/TLSyncRoom.test.ts +0 -25
- package/src/test/TestServer.ts +4 -14
- package/src/test/storageContractSuite.ts +0 -79
- package/src/test/upgradeDowngrade.test.ts +0 -47
- package/src/test/objectStore.test.ts +0 -387
|
@@ -41,7 +41,6 @@ export function makePage(id: string, name = id, index = 'a2') {
|
|
|
41
41
|
export interface StorageContractFactory {
|
|
42
42
|
create(opts?: {
|
|
43
43
|
snapshot?: RoomSnapshot
|
|
44
|
-
objectTypes?: readonly string[]
|
|
45
44
|
onChange?(arg: TLSyncStorageOnChangeCallbackProps): void
|
|
46
45
|
}): TLSyncStorage<TLRecord>
|
|
47
46
|
}
|
|
@@ -62,84 +61,6 @@ export function registerStorageContractTests(factory: StorageContractFactory) {
|
|
|
62
61
|
expect(snapshot.documents.map((d) => d.lastChangedClock)).toEqual([0, 0])
|
|
63
62
|
})
|
|
64
63
|
|
|
65
|
-
describe('object-store lane partition', () => {
|
|
66
|
-
// the storage doesn't care about record semantics, so 'page' stands in as a
|
|
67
|
-
// generic object-lane type here
|
|
68
|
-
const createPartitioned = (snapshot?: RoomSnapshot) =>
|
|
69
|
-
factory.create({ snapshot, objectTypes: ['page'] })
|
|
70
|
-
|
|
71
|
-
it('partitions object-lane records out of getSnapshot into getObjectsSnapshot', () => {
|
|
72
|
-
const storage = createPartitioned(
|
|
73
|
-
makeContractSnapshot(contractRecords, { documentClock: 5 })
|
|
74
|
-
)
|
|
75
|
-
const snapshot = storage.getSnapshot!()
|
|
76
|
-
expect(snapshot.documents.map((d) => d.state.typeName)).toEqual(['document'])
|
|
77
|
-
// clock + schema are unaffected by the partition
|
|
78
|
-
expect(snapshot.documentClock).toBe(5)
|
|
79
|
-
|
|
80
|
-
expect(storage.getObjectsSnapshot!().map((d) => d.state.typeName)).toEqual(['page'])
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('returns an empty objects snapshot when no objectTypes are configured', () => {
|
|
84
|
-
const storage = create(makeContractSnapshot(contractRecords))
|
|
85
|
-
expect(storage.getSnapshot!().documents.length).toBe(2)
|
|
86
|
-
expect(storage.getObjectsSnapshot!()).toEqual([])
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('routes writes by type and shares the clock, tombstones, and change feed', () => {
|
|
90
|
-
const storage = createPartitioned(makeContractSnapshot(contractRecords))
|
|
91
|
-
const newPage = makePage('lane')
|
|
92
|
-
storage.transaction((txn) => txn.set(newPage.id, newPage))
|
|
93
|
-
|
|
94
|
-
// the write landed in the object partition, not the document snapshot
|
|
95
|
-
expect(storage.getSnapshot!().documents.map((d) => d.state.id)).toEqual([TLDOCUMENT_ID])
|
|
96
|
-
expect(storage.getObjectsSnapshot!().map((d) => d.state.id)).toContain(newPage.id)
|
|
97
|
-
|
|
98
|
-
// reads and the change feed span both partitions
|
|
99
|
-
storage.transaction((txn) => {
|
|
100
|
-
expect(txn.get(newPage.id)).toEqual(newPage)
|
|
101
|
-
expect(txn.getChangesSince(0)!.diff.puts[newPage.id]).toEqual(newPage)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
// deleting an object-lane record writes a shared tombstone
|
|
105
|
-
const clockBeforeDelete = storage.getClock()
|
|
106
|
-
storage.transaction((txn) => txn.delete(newPage.id))
|
|
107
|
-
expect(storage.getObjectsSnapshot!().map((d) => d.state.id)).not.toContain(newPage.id)
|
|
108
|
-
storage.transaction((txn) => {
|
|
109
|
-
expect(txn.getChangesSince(clockBeforeDelete)!.diff.deletes).toContain(newPage.id)
|
|
110
|
-
})
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
it('iterates both partitions so schema migrations cover object-lane records', () => {
|
|
114
|
-
const storage = createPartitioned(makeContractSnapshot(contractRecords))
|
|
115
|
-
storage.transaction((txn) => {
|
|
116
|
-
const ids = Array.from(txn.keys()).sort()
|
|
117
|
-
expect(ids).toEqual(contractRecords.map((r) => r.id).sort())
|
|
118
|
-
expect(
|
|
119
|
-
Array.from(txn.values())
|
|
120
|
-
.map((r) => r.id)
|
|
121
|
-
.sort()
|
|
122
|
-
).toEqual(ids)
|
|
123
|
-
expect(
|
|
124
|
-
Array.from(txn.entries())
|
|
125
|
-
.map(([id]) => id)
|
|
126
|
-
.sort()
|
|
127
|
-
).toEqual(ids)
|
|
128
|
-
})
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
it('round-trips through a merged snapshot (persist lanes separately, re-seed together)', () => {
|
|
132
|
-
const storage = createPartitioned(makeContractSnapshot(contractRecords))
|
|
133
|
-
const merged = {
|
|
134
|
-
...storage.getSnapshot!(),
|
|
135
|
-
documents: [...storage.getSnapshot!().documents, ...storage.getObjectsSnapshot!()],
|
|
136
|
-
}
|
|
137
|
-
const reseeded = createPartitioned(merged)
|
|
138
|
-
expect(reseeded.getSnapshot!().documents.map((d) => d.state.typeName)).toEqual(['document'])
|
|
139
|
-
expect(reseeded.getObjectsSnapshot!().map((d) => d.state.typeName)).toEqual(['page'])
|
|
140
|
-
})
|
|
141
|
-
})
|
|
142
|
-
|
|
143
64
|
describe('transactions', () => {
|
|
144
65
|
it('[SS2] returns the callback result along with clock metadata', () => {
|
|
145
66
|
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 7 }))
|
|
@@ -2,7 +2,6 @@ import { computed } from '@tldraw/state'
|
|
|
2
2
|
import {
|
|
3
3
|
BaseRecord,
|
|
4
4
|
RecordId,
|
|
5
|
-
SerializedSchema,
|
|
6
5
|
Store,
|
|
7
6
|
StoreSchema,
|
|
8
7
|
UnknownRecord,
|
|
@@ -11,7 +10,6 @@ import {
|
|
|
11
10
|
createRecordMigrationSequence,
|
|
12
11
|
createRecordType,
|
|
13
12
|
} from '@tldraw/store'
|
|
14
|
-
import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'
|
|
15
13
|
import { vi, type Mock } from 'vitest'
|
|
16
14
|
import { RecordOpType, ValueOpType } from '../lib/diff'
|
|
17
15
|
import { TLSocketServerSentEvent, getTlsyncProtocolVersion } from '../lib/protocol'
|
|
@@ -220,47 +218,6 @@ class TestInstance {
|
|
|
220
218
|
}
|
|
221
219
|
}
|
|
222
220
|
|
|
223
|
-
describe('record types unknown to older clients', () => {
|
|
224
|
-
// Mirrors a deploy that adds the comment record types: stale tabs still run a schema
|
|
225
|
-
// without them, while the server registers them and serves rooms that may contain them. The
|
|
226
|
-
// comment types ship guard migrations (retroactive, no `down`), so the server must reject
|
|
227
|
-
// stale sessions with CLIENT_TOO_OLD (prompting a refresh) rather than sending them record
|
|
228
|
-
// types their store cannot represent.
|
|
229
|
-
const schemaWithComments = createTLSchema({ records: commentSchemaRecords })
|
|
230
|
-
const schemaWithoutComments = createTLSchema()
|
|
231
|
-
|
|
232
|
-
function connectWith(server: TestServer<any>, schema: SerializedSchema) {
|
|
233
|
-
const id = 'stale-tab'
|
|
234
|
-
const socket = mockSocket()
|
|
235
|
-
server.room.handleNewSession({ sessionId: id, socket, meta: undefined, isReadonly: false })
|
|
236
|
-
server.room.handleMessage(id, {
|
|
237
|
-
type: 'connect',
|
|
238
|
-
connectRequestId: 'test',
|
|
239
|
-
lastServerClock: 0,
|
|
240
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
241
|
-
schema,
|
|
242
|
-
})
|
|
243
|
-
return socket
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
it('[HS3] rejects sessions whose schema predates the comment types', () => {
|
|
247
|
-
const server = new TestServer(schemaWithComments as any, undefined, {
|
|
248
|
-
objectTypes: ['comment', 'comment-thread'],
|
|
249
|
-
})
|
|
250
|
-
const socket = connectWith(server, schemaWithoutComments.serialize())
|
|
251
|
-
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
it('[HS3] accepts sessions whose schema registers the comment types', () => {
|
|
255
|
-
const server = new TestServer(schemaWithComments as any, undefined, {
|
|
256
|
-
objectTypes: ['comment', 'comment-thread'],
|
|
257
|
-
})
|
|
258
|
-
const socket = connectWith(server, schemaWithComments.serialize())
|
|
259
|
-
expect(socket.close).not.toHaveBeenCalled()
|
|
260
|
-
expect((socket.sendMessage as Mock).mock.calls[0][0]).toMatchObject({ type: 'connect' })
|
|
261
|
-
})
|
|
262
|
-
})
|
|
263
|
-
|
|
264
221
|
it('[MG2] the server can handle receiving v1 stuff from the client', () => {
|
|
265
222
|
const t = new TestInstance()
|
|
266
223
|
t.oldSocketPair.connect()
|
|
@@ -611,7 +568,6 @@ describe('when the client is too old', () => {
|
|
|
611
568
|
schema: schemaV2.serialize(),
|
|
612
569
|
serverClock: 10,
|
|
613
570
|
isReadonly: false,
|
|
614
|
-
objectAccess: 'write',
|
|
615
571
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
616
572
|
|
|
617
573
|
expect(v1SendMessage).toHaveBeenCalledWith({
|
|
@@ -623,7 +579,6 @@ describe('when the client is too old', () => {
|
|
|
623
579
|
schema: schemaV2.serialize(),
|
|
624
580
|
serverClock: 10,
|
|
625
581
|
isReadonly: false,
|
|
626
|
-
objectAccess: 'write',
|
|
627
582
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
628
583
|
|
|
629
584
|
v2SendMessage.mockClear()
|
|
@@ -1145,7 +1100,6 @@ describe('when the client is the same version', () => {
|
|
|
1145
1100
|
schema: schemaV2.serialize(),
|
|
1146
1101
|
serverClock: 10,
|
|
1147
1102
|
isReadonly: false,
|
|
1148
|
-
objectAccess: 'write',
|
|
1149
1103
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
1150
1104
|
|
|
1151
1105
|
expect(bSocket.sendMessage).toHaveBeenCalledWith({
|
|
@@ -1157,7 +1111,6 @@ describe('when the client is the same version', () => {
|
|
|
1157
1111
|
schema: schemaV2.serialize(),
|
|
1158
1112
|
serverClock: 10,
|
|
1159
1113
|
isReadonly: false,
|
|
1160
|
-
objectAccess: 'write',
|
|
1161
1114
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
1162
1115
|
;(aSocket.sendMessage as Mock).mockClear()
|
|
1163
1116
|
;(bSocket.sendMessage as Mock).mockClear()
|
|
@@ -1,387 +0,0 @@
|
|
|
1
|
-
import { BaseRecord, RecordId, StoreSchema, createRecordType } from '@tldraw/store'
|
|
2
|
-
import { vi, type Mock } from 'vitest'
|
|
3
|
-
import { RecordOpType, ValueOpType } from '../lib/diff'
|
|
4
|
-
import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
|
|
5
|
-
import {
|
|
6
|
-
TLConnectRequest,
|
|
7
|
-
TLPushRequest,
|
|
8
|
-
TLSocketServerSentEvent,
|
|
9
|
-
getTlsyncProtocolVersion,
|
|
10
|
-
} from '../lib/protocol'
|
|
11
|
-
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
|
|
12
|
-
import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
|
|
13
|
-
|
|
14
|
-
// A generic object-lane test schema: `doc` records live in the document lane, `note` records are
|
|
15
|
-
// served through the object-store lane (see `objectTypes` below). Deliberately not comment-shaped —
|
|
16
|
-
// the lane is a generic primitive.
|
|
17
|
-
|
|
18
|
-
interface DocRecord extends BaseRecord<'doc', RecordId<DocRecord>> {
|
|
19
|
-
title: string
|
|
20
|
-
}
|
|
21
|
-
interface NoteRecord extends BaseRecord<'note', RecordId<NoteRecord>> {
|
|
22
|
-
text: string
|
|
23
|
-
}
|
|
24
|
-
interface PresenceRecord extends BaseRecord<'presence', RecordId<PresenceRecord>> {
|
|
25
|
-
name: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const Doc = createRecordType<DocRecord>('doc', {
|
|
29
|
-
scope: 'document',
|
|
30
|
-
validator: { validate: (value) => value as DocRecord },
|
|
31
|
-
})
|
|
32
|
-
const Note = createRecordType<NoteRecord>('note', {
|
|
33
|
-
scope: 'document',
|
|
34
|
-
validator: { validate: (value) => value as NoteRecord },
|
|
35
|
-
})
|
|
36
|
-
const Presence = createRecordType<PresenceRecord>('presence', {
|
|
37
|
-
scope: 'presence',
|
|
38
|
-
validator: { validate: (value) => value as PresenceRecord },
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
type R = DocRecord | NoteRecord | PresenceRecord
|
|
42
|
-
|
|
43
|
-
const schema = StoreSchema.create<R>({ doc: Doc, note: Note, presence: Presence })
|
|
44
|
-
|
|
45
|
-
type MockSocket = TLRoomSocket<any> & {
|
|
46
|
-
__messages: TLSocketServerSentEvent<any>[]
|
|
47
|
-
sendMessage: Mock
|
|
48
|
-
close: Mock
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function makeSocket(): MockSocket {
|
|
52
|
-
const socket: MockSocket = {
|
|
53
|
-
__messages: [],
|
|
54
|
-
// cloning because the room reuses/clears message objects after sending
|
|
55
|
-
sendMessage: vi.fn((msg: TLSocketServerSentEvent<any>) => {
|
|
56
|
-
socket.__messages.push(structuredClone(msg))
|
|
57
|
-
}),
|
|
58
|
-
close: vi.fn(() => {
|
|
59
|
-
socket.isOpen = false
|
|
60
|
-
}),
|
|
61
|
-
isOpen: true,
|
|
62
|
-
}
|
|
63
|
-
return socket
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function sentDataMessages(socket: MockSocket): any[] {
|
|
67
|
-
return socket.__messages.flatMap((msg: any) => (msg.type === 'data' ? msg.data : []))
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function clearSocket(socket: MockSocket) {
|
|
71
|
-
socket.sendMessage.mockClear()
|
|
72
|
-
socket.__messages.length = 0
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const disposables: Array<() => void> = []
|
|
76
|
-
afterEach(() => {
|
|
77
|
-
for (const dispose of disposables) dispose()
|
|
78
|
-
disposables.length = 0
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
function makeRoom(
|
|
82
|
-
opts: {
|
|
83
|
-
snapshot?: RoomSnapshot
|
|
84
|
-
objectTypes?: readonly string[]
|
|
85
|
-
onCommittedChanges?(args: { diff: any; documentClock: number }): void
|
|
86
|
-
} = {}
|
|
87
|
-
) {
|
|
88
|
-
const storage = new InMemorySyncStorage<R>({
|
|
89
|
-
snapshot: opts.snapshot ?? {
|
|
90
|
-
documents: [],
|
|
91
|
-
clock: 0,
|
|
92
|
-
documentClock: 0,
|
|
93
|
-
schema: schema.serialize(),
|
|
94
|
-
},
|
|
95
|
-
// keep the storage partition in step with the room's object lane
|
|
96
|
-
objectTypes: opts.objectTypes,
|
|
97
|
-
})
|
|
98
|
-
const room = new TLSyncRoom<R, undefined>({
|
|
99
|
-
schema,
|
|
100
|
-
storage,
|
|
101
|
-
objectTypes: opts.objectTypes,
|
|
102
|
-
onCommittedChanges: opts.onCommittedChanges,
|
|
103
|
-
})
|
|
104
|
-
disposables.push(() => room.close())
|
|
105
|
-
return { storage, room }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function connectSession(
|
|
109
|
-
room: TLSyncRoom<any, any>,
|
|
110
|
-
sessionId: string,
|
|
111
|
-
opts: {
|
|
112
|
-
isReadonly?: boolean
|
|
113
|
-
objectAccess?: 'read' | 'write'
|
|
114
|
-
clear?: boolean
|
|
115
|
-
} = {}
|
|
116
|
-
): MockSocket {
|
|
117
|
-
const socket = makeSocket()
|
|
118
|
-
room.handleNewSession({
|
|
119
|
-
sessionId,
|
|
120
|
-
socket,
|
|
121
|
-
meta: undefined,
|
|
122
|
-
isReadonly: opts.isReadonly ?? false,
|
|
123
|
-
objectAccess: opts.objectAccess,
|
|
124
|
-
})
|
|
125
|
-
room.handleMessage(sessionId, {
|
|
126
|
-
type: 'connect',
|
|
127
|
-
connectRequestId: 'connect-' + sessionId,
|
|
128
|
-
lastServerClock: 0,
|
|
129
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
130
|
-
schema: room.serializedSchema,
|
|
131
|
-
} satisfies TLConnectRequest)
|
|
132
|
-
if (opts.clear !== false) {
|
|
133
|
-
clearSocket(socket)
|
|
134
|
-
}
|
|
135
|
-
return socket
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function push(
|
|
139
|
-
room: TLSyncRoom<any, any>,
|
|
140
|
-
sessionId: string,
|
|
141
|
-
diff: TLPushRequest<any>['diff'],
|
|
142
|
-
clientClock = 1
|
|
143
|
-
) {
|
|
144
|
-
room.handleMessage(sessionId, { type: 'push', clientClock, diff } satisfies TLPushRequest<any>)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function lastPushResult(socket: MockSocket) {
|
|
148
|
-
return sentDataMessages(socket)
|
|
149
|
-
.filter((m) => m.type === 'push_result')
|
|
150
|
-
.at(-1)
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function storedIds(storage: InMemorySyncStorage<R>) {
|
|
154
|
-
// span both partitions: the document snapshot no longer contains object-lane records
|
|
155
|
-
return [...storage.getSnapshot().documents, ...storage.getObjectsSnapshot()]
|
|
156
|
-
.map((d) => d.state.id)
|
|
157
|
-
.sort()
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
describe('object store lane', () => {
|
|
161
|
-
describe('type partition', () => {
|
|
162
|
-
it('excludes object types from documentTypes and exposes them via objectTypes', () => {
|
|
163
|
-
const { room } = makeRoom({ objectTypes: ['note'] })
|
|
164
|
-
expect(room.documentTypes.has('doc')).toBe(true)
|
|
165
|
-
expect(room.documentTypes.has('note')).toBe(false)
|
|
166
|
-
expect(room.objectTypes.has('note')).toBe(true)
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
it('keeps documentTypes intact when no objectTypes are configured', () => {
|
|
170
|
-
const { room } = makeRoom()
|
|
171
|
-
expect(room.documentTypes.has('doc')).toBe(true)
|
|
172
|
-
expect(room.documentTypes.has('note')).toBe(true)
|
|
173
|
-
expect(room.objectTypes.size).toBe(0)
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
it('rejects object types that are not registered in the schema', () => {
|
|
177
|
-
expect(() => makeRoom({ objectTypes: ['nonexistent'] })).toThrow(
|
|
178
|
-
"object type 'nonexistent' is not registered"
|
|
179
|
-
)
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
it('rejects object types that are not document-scoped', () => {
|
|
183
|
-
expect(() => makeRoom({ objectTypes: ['presence'] })).toThrow(
|
|
184
|
-
"object type 'presence' must have scope 'document'"
|
|
185
|
-
)
|
|
186
|
-
})
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
describe('object writes', () => {
|
|
190
|
-
it('commits object puts, broadcasts them, and hydrates them on connect', () => {
|
|
191
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
192
|
-
const writer = connectSession(room, 'writer')
|
|
193
|
-
const observer = connectSession(room, 'observer')
|
|
194
|
-
|
|
195
|
-
const note = Note.create({ text: 'hello' })
|
|
196
|
-
push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
|
|
197
|
-
|
|
198
|
-
expect(lastPushResult(writer)).toMatchObject({ action: 'commit' })
|
|
199
|
-
expect(storedIds(storage)).toEqual([note.id])
|
|
200
|
-
|
|
201
|
-
// broadcast to the other session
|
|
202
|
-
room._flushDataMessages('observer')
|
|
203
|
-
expect(
|
|
204
|
-
sentDataMessages(observer).find(
|
|
205
|
-
(m) => m.type === 'patch' && m.diff[note.id]?.[0] === RecordOpType.Put
|
|
206
|
-
)
|
|
207
|
-
).toBeDefined()
|
|
208
|
-
|
|
209
|
-
// a fresh connect hydrates the object record
|
|
210
|
-
const late = connectSession(room, 'late', { clear: false })
|
|
211
|
-
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
|
|
212
|
-
expect(connectMsg.diff[note.id]).toEqual([RecordOpType.Put, note])
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
it('still rejects unknown record types for writable sessions', () => {
|
|
216
|
-
const { room } = makeRoom({ objectTypes: ['note'] })
|
|
217
|
-
const socket = connectSession(room, 'writer')
|
|
218
|
-
|
|
219
|
-
push(room, 'writer', {
|
|
220
|
-
'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
expect(socket.close).toHaveBeenCalledWith(
|
|
224
|
-
TLSyncErrorCloseEventCode,
|
|
225
|
-
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
226
|
-
)
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
it('skips unknown record types for denied sessions without rejecting (legacy readonly behavior)', () => {
|
|
230
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
231
|
-
const socket = connectSession(room, 'reader', { isReadonly: true })
|
|
232
|
-
|
|
233
|
-
push(room, 'reader', {
|
|
234
|
-
'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
expect(socket.close).not.toHaveBeenCalled()
|
|
238
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
239
|
-
expect(storedIds(storage)).toEqual([])
|
|
240
|
-
})
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
describe('permission axes', () => {
|
|
244
|
-
it('lets a readonly session with object write access write objects but not documents', () => {
|
|
245
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
246
|
-
// "can comment but not edit"
|
|
247
|
-
const socket = connectSession(room, 'commenter', {
|
|
248
|
-
isReadonly: true,
|
|
249
|
-
objectAccess: 'write',
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
const doc = Doc.create({ title: 'nope' })
|
|
253
|
-
const note = Note.create({ text: 'yep' })
|
|
254
|
-
push(room, 'commenter', {
|
|
255
|
-
[doc.id]: [RecordOpType.Put, doc],
|
|
256
|
-
[note.id]: [RecordOpType.Put, note],
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
// only the object op survives, so the client is rebased onto it
|
|
260
|
-
expect(lastPushResult(socket)).toMatchObject({
|
|
261
|
-
action: { rebaseWithDiff: { [note.id]: [RecordOpType.Put, note] } },
|
|
262
|
-
})
|
|
263
|
-
expect(storedIds(storage)).toEqual([note.id])
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
it('blocks object writes for objectAccess: read while document writes still commit', () => {
|
|
267
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
268
|
-
const socket = connectSession(room, 'editor', { objectAccess: 'read' })
|
|
269
|
-
|
|
270
|
-
const doc = Doc.create({ title: 'yep' })
|
|
271
|
-
const note = Note.create({ text: 'nope' })
|
|
272
|
-
push(room, 'editor', {
|
|
273
|
-
[doc.id]: [RecordOpType.Put, doc],
|
|
274
|
-
[note.id]: [RecordOpType.Put, note],
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
expect(lastPushResult(socket)).toMatchObject({
|
|
278
|
-
action: { rebaseWithDiff: { [doc.id]: [RecordOpType.Put, doc] } },
|
|
279
|
-
})
|
|
280
|
-
expect(storedIds(storage)).toEqual([doc.id])
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
it('discards object-only pushes entirely for objectAccess: read', () => {
|
|
284
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
285
|
-
const socket = connectSession(room, 'reader', { objectAccess: 'read' })
|
|
286
|
-
|
|
287
|
-
const note = Note.create({ text: 'nope' })
|
|
288
|
-
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
|
|
289
|
-
|
|
290
|
-
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
291
|
-
expect(storedIds(storage)).toEqual([])
|
|
292
|
-
})
|
|
293
|
-
|
|
294
|
-
it('gates object patches and removes by objectAccess', () => {
|
|
295
|
-
const note = Note.create({ text: 'original' })
|
|
296
|
-
const { room, storage } = makeRoom({
|
|
297
|
-
objectTypes: ['note'],
|
|
298
|
-
snapshot: {
|
|
299
|
-
documents: [{ state: note, lastChangedClock: 0 }],
|
|
300
|
-
clock: 0,
|
|
301
|
-
documentClock: 0,
|
|
302
|
-
schema: schema.serialize(),
|
|
303
|
-
},
|
|
304
|
-
})
|
|
305
|
-
const reader = connectSession(room, 'reader', { objectAccess: 'read' })
|
|
306
|
-
|
|
307
|
-
push(room, 'reader', {
|
|
308
|
-
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
|
|
309
|
-
})
|
|
310
|
-
expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
|
|
311
|
-
|
|
312
|
-
push(room, 'reader', { [note.id]: [RecordOpType.Remove] }, 2)
|
|
313
|
-
expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
|
|
314
|
-
expect(storedIds(storage)).toEqual([note.id])
|
|
315
|
-
|
|
316
|
-
// a readonly-but-object-writable session can remove the object record
|
|
317
|
-
connectSession(room, 'commenter', { isReadonly: true, objectAccess: 'write' })
|
|
318
|
-
push(room, 'commenter', { [note.id]: [RecordOpType.Remove] })
|
|
319
|
-
expect(storedIds(storage)).toEqual([])
|
|
320
|
-
})
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
describe('storage partition', () => {
|
|
324
|
-
it('keeps object records out of the document snapshot but in the objects snapshot', () => {
|
|
325
|
-
const { room, storage } = makeRoom({ objectTypes: ['note'] })
|
|
326
|
-
connectSession(room, 'writer')
|
|
327
|
-
|
|
328
|
-
const doc = Doc.create({ title: 'canvas' })
|
|
329
|
-
const note = Note.create({ text: 'annotation' })
|
|
330
|
-
push(room, 'writer', {
|
|
331
|
-
[doc.id]: [RecordOpType.Put, doc],
|
|
332
|
-
[note.id]: [RecordOpType.Put, note],
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
// the document snapshot is pure-document by construction
|
|
336
|
-
expect(storage.getSnapshot().documents.map((d) => d.state.id)).toEqual([doc.id])
|
|
337
|
-
// the object lane is persisted separately
|
|
338
|
-
expect(storage.getObjectsSnapshot().map((d) => d.state.id)).toEqual([note.id])
|
|
339
|
-
|
|
340
|
-
// but a fresh connect still hydrates both (shared clock + change feed)
|
|
341
|
-
const late = connectSession(room, 'late', { clear: false })
|
|
342
|
-
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
|
|
343
|
-
expect(Object.keys(connectMsg.diff).sort()).toEqual([doc.id, note.id].sort())
|
|
344
|
-
})
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
describe('connect message', () => {
|
|
348
|
-
it('carries the session objectAccess', () => {
|
|
349
|
-
const { room } = makeRoom({ objectTypes: ['note'] })
|
|
350
|
-
|
|
351
|
-
const writer = connectSession(room, 'writer', { clear: false })
|
|
352
|
-
const writerConnect = writer.__messages.find((m: any) => m.type === 'connect') as any
|
|
353
|
-
expect(writerConnect.objectAccess).toBe('write')
|
|
354
|
-
|
|
355
|
-
const reader = connectSession(room, 'reader', { objectAccess: 'read', clear: false })
|
|
356
|
-
const readerConnect = reader.__messages.find((m: any) => m.type === 'connect') as any
|
|
357
|
-
expect(readerConnect.objectAccess).toBe('read')
|
|
358
|
-
})
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
describe('onCommittedChanges projection hook', () => {
|
|
362
|
-
it('fires with the committed object diff', async () => {
|
|
363
|
-
const onCommittedChanges = vi.fn()
|
|
364
|
-
const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
|
|
365
|
-
connectSession(room, 'writer')
|
|
366
|
-
|
|
367
|
-
const note = Note.create({ text: 'hello' })
|
|
368
|
-
push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
|
|
369
|
-
await Promise.resolve() // the hook fires on a microtask
|
|
370
|
-
|
|
371
|
-
expect(onCommittedChanges).toHaveBeenCalledTimes(1)
|
|
372
|
-
expect(onCommittedChanges.mock.calls[0][0].diff.puts[note.id]).toEqual(note)
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
it('does not fire when every op in the push was denied', async () => {
|
|
376
|
-
const onCommittedChanges = vi.fn()
|
|
377
|
-
const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
|
|
378
|
-
connectSession(room, 'reader', { objectAccess: 'read' })
|
|
379
|
-
|
|
380
|
-
const note = Note.create({ text: 'nope' })
|
|
381
|
-
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
|
|
382
|
-
await Promise.resolve()
|
|
383
|
-
|
|
384
|
-
expect(onCommittedChanges).not.toHaveBeenCalled()
|
|
385
|
-
})
|
|
386
|
-
})
|
|
387
|
-
})
|