@tldraw/sync-core 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.c5c7f1d817d0

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 (51) hide show
  1. package/dist-cjs/index.d.ts +154 -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 +55 -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 +115 -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 +160 -10
  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 +154 -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 +55 -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 +115 -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 +160 -10
  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 +74 -21
  36. package/src/lib/RoomSession.ts +7 -2
  37. package/src/lib/SQLiteSyncStorage.test.ts +29 -2
  38. package/src/lib/SQLiteSyncStorage.ts +158 -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 +294 -10
  43. package/src/lib/TLSyncStorage.ts +8 -0
  44. package/src/lib/protocol.ts +17 -0
  45. package/src/test/TLSocketRoom.test.ts +37 -2
  46. package/src/test/TLSyncClientRebase.test.ts +285 -0
  47. package/src/test/TLSyncRoom.test.ts +25 -0
  48. package/src/test/TestServer.ts +14 -4
  49. package/src/test/objectStore.test.ts +630 -0
  50. package/src/test/storageContractSuite.ts +79 -0
  51. 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(snapshot?: RoomSnapshot, oldSchema = schemaV1, newSchema = schemaV2) {
175
- 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)
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
+ })