@tldraw/sync-core 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.fba91ed55f6c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +154 -3
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +55 -20
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +115 -52
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +27 -2
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +6 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +160 -10
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-esm/index.d.mts +154 -3
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +55 -20
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +115 -52
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +27 -2
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +6 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +160 -10
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- 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 +3 -0
- package/src/lib/InMemorySyncStorage.ts +74 -21
- package/src/lib/RoomSession.ts +7 -2
- package/src/lib/SQLiteSyncStorage.test.ts +29 -2
- package/src/lib/SQLiteSyncStorage.ts +158 -59
- package/src/lib/TLSocketRoom.ts +61 -3
- package/src/lib/TLSyncClient.test.ts +9 -2
- package/src/lib/TLSyncClient.ts +15 -3
- package/src/lib/TLSyncRoom.ts +294 -10
- package/src/lib/TLSyncStorage.ts +8 -0
- package/src/lib/protocol.ts +17 -0
- package/src/test/TLSocketRoom.test.ts +37 -2
- package/src/test/TLSyncClientRebase.test.ts +285 -0
- package/src/test/TLSyncRoom.test.ts +25 -0
- package/src/test/TestServer.ts +14 -4
- package/src/test/objectStore.test.ts +630 -0
- package/src/test/storageContractSuite.ts +79 -0
- package/src/test/upgradeDowngrade.test.ts +144 -2
|
@@ -41,6 +41,7 @@ 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[]
|
|
44
45
|
onChange?(arg: TLSyncStorageOnChangeCallbackProps): void
|
|
45
46
|
}): TLSyncStorage<TLRecord>
|
|
46
47
|
}
|
|
@@ -61,6 +62,84 @@ export function registerStorageContractTests(factory: StorageContractFactory) {
|
|
|
61
62
|
expect(snapshot.documents.map((d) => d.lastChangedClock)).toEqual([0, 0])
|
|
62
63
|
})
|
|
63
64
|
|
|
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
|
+
|
|
64
143
|
describe('transactions', () => {
|
|
65
144
|
it('[SS2] returns the callback result along with clock metadata', () => {
|
|
66
145
|
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 7 }))
|
|
@@ -2,6 +2,7 @@ import { computed } from '@tldraw/state'
|
|
|
2
2
|
import {
|
|
3
3
|
BaseRecord,
|
|
4
4
|
RecordId,
|
|
5
|
+
SerializedSchema,
|
|
5
6
|
Store,
|
|
6
7
|
StoreSchema,
|
|
7
8
|
UnknownRecord,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
createRecordMigrationSequence,
|
|
11
12
|
createRecordType,
|
|
12
13
|
} from '@tldraw/store'
|
|
14
|
+
import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'
|
|
13
15
|
import { vi, type Mock } from 'vitest'
|
|
14
16
|
import { RecordOpType, ValueOpType } from '../lib/diff'
|
|
15
17
|
import { TLSocketServerSentEvent, getTlsyncProtocolVersion } from '../lib/protocol'
|
|
@@ -171,8 +173,13 @@ class TestInstance {
|
|
|
171
173
|
|
|
172
174
|
hasLoaded = false
|
|
173
175
|
|
|
174
|
-
constructor(
|
|
175
|
-
|
|
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)
|
|
176
183
|
this.oldSocketPair = new TestSocketPair('test_upgrade_old', this.server)
|
|
177
184
|
this.newSocketPair = new TestSocketPair('test_upgrade_new', this.server)
|
|
178
185
|
|
|
@@ -218,6 +225,47 @@ class TestInstance {
|
|
|
218
225
|
}
|
|
219
226
|
}
|
|
220
227
|
|
|
228
|
+
describe('record types unknown to older clients', () => {
|
|
229
|
+
// Mirrors a deploy that adds the comment record types: stale tabs still run a schema
|
|
230
|
+
// without them, while the server registers them and serves rooms that may contain them. The
|
|
231
|
+
// comment types ship guard migrations (retroactive, no `down`), so the server must reject
|
|
232
|
+
// stale sessions with CLIENT_TOO_OLD (prompting a refresh) rather than sending them record
|
|
233
|
+
// types their store cannot represent.
|
|
234
|
+
const schemaWithComments = createTLSchema({ records: commentSchemaRecords })
|
|
235
|
+
const schemaWithoutComments = createTLSchema()
|
|
236
|
+
|
|
237
|
+
function connectWith(server: TestServer<any>, schema: SerializedSchema) {
|
|
238
|
+
const id = 'stale-tab'
|
|
239
|
+
const socket = mockSocket()
|
|
240
|
+
server.room.handleNewSession({ sessionId: id, socket, meta: undefined, isReadonly: false })
|
|
241
|
+
server.room.handleMessage(id, {
|
|
242
|
+
type: 'connect',
|
|
243
|
+
connectRequestId: 'test',
|
|
244
|
+
lastServerClock: 0,
|
|
245
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
246
|
+
schema,
|
|
247
|
+
})
|
|
248
|
+
return socket
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
it('[HS3] rejects sessions whose schema predates the comment types', () => {
|
|
252
|
+
const server = new TestServer(schemaWithComments as any, undefined, {
|
|
253
|
+
objectTypes: ['comment', 'comment-thread'],
|
|
254
|
+
})
|
|
255
|
+
const socket = connectWith(server, schemaWithoutComments.serialize())
|
|
256
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('[HS3] accepts sessions whose schema registers the comment types', () => {
|
|
260
|
+
const server = new TestServer(schemaWithComments as any, undefined, {
|
|
261
|
+
objectTypes: ['comment', 'comment-thread'],
|
|
262
|
+
})
|
|
263
|
+
const socket = connectWith(server, schemaWithComments.serialize())
|
|
264
|
+
expect(socket.close).not.toHaveBeenCalled()
|
|
265
|
+
expect((socket.sendMessage as Mock).mock.calls[0][0]).toMatchObject({ type: 'connect' })
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
221
269
|
it('[MG2] the server can handle receiving v1 stuff from the client', () => {
|
|
222
270
|
const t = new TestInstance()
|
|
223
271
|
t.oldSocketPair.connect()
|
|
@@ -568,6 +616,7 @@ describe('when the client is too old', () => {
|
|
|
568
616
|
schema: schemaV2.serialize(),
|
|
569
617
|
serverClock: 10,
|
|
570
618
|
isReadonly: false,
|
|
619
|
+
objectAccess: 'write',
|
|
571
620
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
572
621
|
|
|
573
622
|
expect(v1SendMessage).toHaveBeenCalledWith({
|
|
@@ -579,6 +628,7 @@ describe('when the client is too old', () => {
|
|
|
579
628
|
schema: schemaV2.serialize(),
|
|
580
629
|
serverClock: 10,
|
|
581
630
|
isReadonly: false,
|
|
631
|
+
objectAccess: 'write',
|
|
582
632
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
583
633
|
|
|
584
634
|
v2SendMessage.mockClear()
|
|
@@ -1100,6 +1150,7 @@ describe('when the client is the same version', () => {
|
|
|
1100
1150
|
schema: schemaV2.serialize(),
|
|
1101
1151
|
serverClock: 10,
|
|
1102
1152
|
isReadonly: false,
|
|
1153
|
+
objectAccess: 'write',
|
|
1103
1154
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
1104
1155
|
|
|
1105
1156
|
expect(bSocket.sendMessage).toHaveBeenCalledWith({
|
|
@@ -1111,6 +1162,7 @@ describe('when the client is the same version', () => {
|
|
|
1111
1162
|
schema: schemaV2.serialize(),
|
|
1112
1163
|
serverClock: 10,
|
|
1113
1164
|
isReadonly: false,
|
|
1165
|
+
objectAccess: 'write',
|
|
1114
1166
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
1115
1167
|
;(aSocket.sendMessage as Mock).mockClear()
|
|
1116
1168
|
;(bSocket.sendMessage as Mock).mockClear()
|
|
@@ -1185,3 +1237,93 @@ describe('when the client is the same version', () => {
|
|
|
1185
1237
|
} satisfies TLSocketServerSentEvent<RV2>)
|
|
1186
1238
|
})
|
|
1187
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
|
+
})
|