@tldraw/sync-core 5.3.0-next.fc37bd96825b → 5.3.1

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 (50) hide show
  1. package/dist-cjs/index.d.ts +165 -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 +66 -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 +130 -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 +178 -12
  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 +165 -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 +66 -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 +130 -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 +178 -12
  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 +86 -21
  36. package/src/lib/RoomSession.ts +7 -2
  37. package/src/lib/SQLiteSyncStorage.test.ts +29 -2
  38. package/src/lib/SQLiteSyncStorage.ts +174 -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 +328 -13
  43. package/src/lib/TLSyncStorage.ts +15 -0
  44. package/src/lib/protocol.ts +17 -0
  45. package/src/test/TLSocketRoom.test.ts +37 -2
  46. package/src/test/TLSyncRoom.test.ts +25 -0
  47. package/src/test/TestServer.ts +14 -4
  48. package/src/test/objectStore.test.ts +679 -0
  49. package/src/test/storageContractSuite.ts +101 -0
  50. package/src/test/upgradeDowngrade.test.ts +144 -2
@@ -117,6 +117,14 @@ export const DEFAULT_INITIAL_SNAPSHOT = {
117
117
  export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {
118
118
  /** @internal */
119
119
  documents: AtomMap<string, { state: R; lastChangedClock: number }>
120
+ /**
121
+ * Object-store lane records, partitioned out of `documents` so they never appear in the
122
+ * document snapshot. They share the same clock, tombstones, and transactions as documents.
123
+ * @internal
124
+ */
125
+ objects: AtomMap<string, { state: R; lastChangedClock: number }>
126
+ /** @internal */
127
+ readonly objectTypes: ReadonlySet<string>
120
128
  /** @internal */
121
129
  tombstones: AtomMap<string, number>
122
130
  /** @internal */
@@ -133,22 +141,38 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
133
141
 
134
142
  constructor({
135
143
  snapshot = DEFAULT_INITIAL_SNAPSHOT,
144
+ objectTypes,
136
145
  onChange,
137
146
  }: {
138
147
  snapshot?: RoomSnapshot
148
+ /**
149
+ * Record type names stored in the object-store lane. Records of these types are routed to
150
+ * a separate partition: excluded from `getSnapshot()`, returned by `getObjectsSnapshot()`.
151
+ */
152
+ objectTypes?: readonly string[]
139
153
  onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
140
154
  } = {}) {
155
+ this.objectTypes = new Set(objectTypes ?? [])
141
156
  const maxClockValue = Math.max(
142
157
  0,
143
158
  ...Object.values(snapshot.tombstones ?? {}),
144
159
  ...Object.values(snapshot.documents.map((d) => d.lastChangedClock))
145
160
  )
161
+ // route snapshot entries into their partitions (a seed snapshot may carry object-lane
162
+ // records merged in, e.g. loaded from a separate persistence lane)
163
+ const toEntry = (
164
+ d: RoomSnapshot['documents'][number]
165
+ ): [string, { state: R; lastChangedClock: number }] => [
166
+ d.state.id,
167
+ { state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
168
+ ]
146
169
  this.documents = new AtomMap(
147
170
  'room documents',
148
- snapshot.documents.map((d) => [
149
- d.state.id,
150
- { state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
151
- ])
171
+ snapshot.documents.filter((d) => !this.objectTypes.has(d.state.typeName)).map(toEntry)
172
+ )
173
+ this.objects = new AtomMap(
174
+ 'room objects',
175
+ snapshot.documents.filter((d) => this.objectTypes.has(d.state.typeName)).map(toEntry)
152
176
  )
153
177
  const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)
154
178
 
@@ -249,6 +273,8 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
249
273
  )
250
274
 
251
275
  getSnapshot(): RoomSnapshot {
276
+ // object-lane records live in their own partition, so the document snapshot is
277
+ // pure-document by construction
252
278
  return {
253
279
  tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),
254
280
  documentClock: this.documentClock.get(),
@@ -257,6 +283,22 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
257
283
  schema: this.schema.get(),
258
284
  }
259
285
  }
286
+
287
+ getObjectsSnapshot(): RoomSnapshot['documents'] {
288
+ return Array.from(this.objects.values())
289
+ }
290
+
291
+ getObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {
292
+ const result: RoomSnapshot['documents'] = []
293
+ const seen = new Set<string>()
294
+ for (const id of ids) {
295
+ if (seen.has(id)) continue
296
+ seen.add(id)
297
+ const doc = this.objects.get(id)
298
+ if (doc) result.push(doc)
299
+ }
300
+ return result
301
+ }
260
302
  }
261
303
 
262
304
  /**
@@ -297,9 +339,16 @@ class InMemorySyncStorageTransaction<
297
339
  return this._clock
298
340
  }
299
341
 
342
+ /** The partition a record with this id currently lives in, if any. */
343
+ private mapContaining(id: string) {
344
+ if (this.storage.documents.has(id)) return this.storage.documents
345
+ if (this.storage.objects.has(id)) return this.storage.objects
346
+ return undefined
347
+ }
348
+
300
349
  get(id: string): R | undefined {
301
350
  this.assertNotClosed()
302
- return this.storage.documents.get(id)?.state
351
+ return (this.storage.documents.get(id) ?? this.storage.objects.get(id))?.state
303
352
  }
304
353
 
305
354
  set(id: string, record: R): void {
@@ -310,7 +359,11 @@ class InMemorySyncStorageTransaction<
310
359
  if (this.storage.tombstones.has(id)) {
311
360
  this.storage.tombstones.delete(id)
312
361
  }
313
- this.storage.documents.set(id, {
362
+ // route by type: object-lane records live in their own partition
363
+ const map = this.storage.objectTypes.has(record.typeName)
364
+ ? this.storage.objects
365
+ : this.storage.documents
366
+ map.set(id, {
314
367
  state: devFreeze(record) as R,
315
368
  lastChangedClock: clock,
316
369
  })
@@ -319,34 +372,43 @@ class InMemorySyncStorageTransaction<
319
372
  delete(id: string): void {
320
373
  this.assertNotClosed()
321
374
  // Only create a tombstone if the record actually exists
322
- if (!this.storage.documents.has(id)) return
375
+ const map = this.mapContaining(id)
376
+ if (!map) return
323
377
  const clock = this.getNextClock()
324
- this.storage.documents.delete(id)
378
+ map.delete(id)
325
379
  this.storage.tombstones.set(id, clock)
326
380
  this.storage.pruneTombstones()
327
381
  }
328
382
 
383
+ // iteration spans both partitions so schema migrations cover object-lane records too
384
+
329
385
  *entries(): IterableIterator<[string, R]> {
330
386
  this.assertNotClosed()
331
- for (const [id, record] of this.storage.documents.entries()) {
332
- this.assertNotClosed()
333
- yield [id, record.state]
387
+ for (const map of [this.storage.documents, this.storage.objects]) {
388
+ for (const [id, record] of map.entries()) {
389
+ this.assertNotClosed()
390
+ yield [id, record.state]
391
+ }
334
392
  }
335
393
  }
336
394
 
337
395
  *keys(): IterableIterator<string> {
338
396
  this.assertNotClosed()
339
- for (const key of this.storage.documents.keys()) {
340
- this.assertNotClosed()
341
- yield key
397
+ for (const map of [this.storage.documents, this.storage.objects]) {
398
+ for (const key of map.keys()) {
399
+ this.assertNotClosed()
400
+ yield key
401
+ }
342
402
  }
343
403
  }
344
404
 
345
405
  *values(): IterableIterator<R> {
346
406
  this.assertNotClosed()
347
- for (const record of this.storage.documents.values()) {
348
- this.assertNotClosed()
349
- yield record.state
407
+ for (const map of [this.storage.documents, this.storage.objects]) {
408
+ for (const record of map.values()) {
409
+ this.assertNotClosed()
410
+ yield record.state
411
+ }
350
412
  }
351
413
  }
352
414
 
@@ -370,10 +432,13 @@ class InMemorySyncStorageTransaction<
370
432
  }
371
433
  const diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }
372
434
  const wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get()
373
- for (const doc of this.storage.documents.values()) {
374
- if (wipeAll || doc.lastChangedClock > sinceClock) {
375
- // For historical changes, we don't have "from" state, so use added
376
- diff.puts[doc.state.id] = doc.state as R
435
+ // both partitions share the clock and tombstones, so the change feed spans both
436
+ for (const map of [this.storage.documents, this.storage.objects]) {
437
+ for (const doc of map.values()) {
438
+ if (wipeAll || doc.lastChangedClock > sinceClock) {
439
+ // For historical changes, we don't have "from" state, so use added
440
+ diff.puts[doc.state.id] = doc.state as R
441
+ }
377
442
  }
378
443
  }
379
444
  if (!wipeAll) {
@@ -1,5 +1,5 @@
1
1
  import { SerializedSchema, UnknownRecord } from '@tldraw/store'
2
- import { TLSocketServerSentDataEvent } from './protocol'
2
+ import { TLObjectStoreAccess, TLSocketServerSentDataEvent } from './protocol'
3
3
  import { TLRoomSocket } from './TLSyncRoom'
4
4
 
5
5
  /**
@@ -78,8 +78,13 @@ export interface RoomSessionBase<R extends UnknownRecord, Meta> {
78
78
  socket: TLRoomSocket<R>
79
79
  /** Custom metadata associated with this session */
80
80
  meta: Meta
81
- /** Whether this session has read-only permissions */
81
+ /** Whether this session has read-only permissions for document-lane records */
82
82
  isReadonly: boolean
83
+ /**
84
+ * Write access for object-store lane record types (see `objectTypes` on the room).
85
+ * Deliberately independent of `isReadonly` so "can comment but not edit" is expressible.
86
+ */
87
+ objectAccess: TLObjectStoreAccess
83
88
  /** Whether this session requires legacy protocol rejection handling */
84
89
  requiresLegacyRejection: boolean
85
90
  /** Whether this session supports string append operations */
@@ -38,6 +38,33 @@ describe('SQLiteSyncStorage', () => {
38
38
  ).toEqual(newPage)
39
39
  })
40
40
 
41
+ it('[SQ1] sweeps pre-partition object-lane rows out of the documents table on reopen', () => {
42
+ // a database written before the objects table existed: object-lane records (here
43
+ // 'page' stands in) live in the documents table
44
+ const sql = createWrapper()
45
+ const storage = new SQLiteSyncStorage<TLRecord>({
46
+ sql,
47
+ snapshot: makeContractSnapshot(contractRecords),
48
+ })
49
+ const extraPage = makePage('legacy')
50
+ storage.transaction((txn) => txn.set(extraPage.id, extraPage))
51
+ expect(storage.getSnapshot().documents).toHaveLength(3)
52
+
53
+ // reopening with objectTypes moves the rows into the objects partition
54
+ const reopened = new SQLiteSyncStorage<TLRecord>({ sql, objectTypes: ['page'] })
55
+ expect(reopened.getSnapshot().documents.map((d) => d.state.typeName)).toEqual(['document'])
56
+ expect(
57
+ reopened
58
+ .getObjectsSnapshot()
59
+ .map((d) => d.state.id)
60
+ .sort()
61
+ ).toEqual([contractRecords[1].id, extraPage.id].sort())
62
+ // lastChangedClock survives the move
63
+ expect(
64
+ reopened.getObjectsSnapshot().find((d) => d.state.id === extraPage.id)?.lastChangedClock
65
+ ).toBe(1)
66
+ })
67
+
41
68
  it('[SQ1] honors the table prefix', () => {
42
69
  const sql = createWrapper({ tablePrefix: 'tldraw_' })
43
70
  const storage = new SQLiteSyncStorage<TLRecord>({
@@ -227,13 +254,13 @@ describe('SQLiteSyncStorage', () => {
227
254
  expect(storage.getSnapshot().documents.length).toBe(3)
228
255
  })
229
256
 
230
- it('[SQ6] fresh databases start at migration version 2', () => {
257
+ it('[SQ6] fresh databases start at migration version 3', () => {
231
258
  const sql = createWrapper()
232
259
  new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeContractSnapshot(contractRecords) })
233
260
  const row = sql
234
261
  .prepare<{ migrationVersion: number }>('SELECT migrationVersion FROM metadata')
235
262
  .all()[0]
236
- expect(row?.migrationVersion).toBe(2)
263
+ expect(row?.migrationVersion).toBe(3)
237
264
  })
238
265
  })
239
266
  })
@@ -86,9 +86,15 @@ export function migrateSqliteSyncStorage(
86
86
  storage: TLSyncSqliteWrapper,
87
87
  {
88
88
  documentsTable = 'documents',
89
+ objectsTable = 'objects',
89
90
  tombstonesTable = 'tombstones',
90
91
  metadataTable = 'metadata',
91
- }: { documentsTable?: string; tombstonesTable?: string; metadataTable?: string } = {}
92
+ }: {
93
+ documentsTable?: string
94
+ objectsTable?: string
95
+ tombstonesTable?: string
96
+ metadataTable?: string
97
+ } = {}
92
98
  ): void {
93
99
  let migrationVersion = 0
94
100
  try {
@@ -144,18 +150,34 @@ export function migrateSqliteSyncStorage(
144
150
  state BLOB NOT NULL,
145
151
  lastChangedClock INTEGER NOT NULL
146
152
  );
147
-
153
+
148
154
  INSERT INTO ${documentsTable}_new (id, state, lastChangedClock)
149
155
  SELECT id, CAST(state AS BLOB), lastChangedClock FROM ${documentsTable};
150
-
156
+
151
157
  DROP TABLE ${documentsTable};
152
-
158
+
153
159
  ALTER TABLE ${documentsTable}_new RENAME TO ${documentsTable};
154
-
160
+
155
161
  CREATE INDEX idx_${documentsTable}_lastChangedClock ON ${documentsTable}(lastChangedClock);
156
162
  `)
157
163
  }
158
164
 
165
+ if (migrationVersion === 2) {
166
+ // Migration 3: Add the object-store lane table. Object-lane records (e.g. comments) are
167
+ // partitioned out of the documents table so the document snapshot never contains them.
168
+ // They share the documents' clock and tombstones.
169
+ migrationVersion++
170
+ storage.exec(`
171
+ CREATE TABLE ${objectsTable} (
172
+ id TEXT PRIMARY KEY,
173
+ state BLOB NOT NULL,
174
+ lastChangedClock INTEGER NOT NULL
175
+ );
176
+
177
+ CREATE INDEX idx_${objectsTable}_lastChangedClock ON ${objectsTable}(lastChangedClock);
178
+ `)
179
+ }
180
+
159
181
  // add more migrations here if and when needed
160
182
 
161
183
  storage.exec(`UPDATE ${metadataTable} SET migrationVersion = ${migrationVersion}`)
@@ -250,22 +272,67 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
250
272
 
251
273
  private readonly sql: TLSyncSqliteWrapper
252
274
 
275
+ /** @internal */
276
+ readonly objectTypes: ReadonlySet<string>
277
+
253
278
  constructor({
254
279
  sql,
255
280
  snapshot,
281
+ objectTypes,
256
282
  onChange,
257
283
  }: {
258
284
  sql: TLSyncSqliteWrapper
259
285
  snapshot?: RoomSnapshot | StoreSnapshot<R>
286
+ /**
287
+ * Record type names stored in the object-store lane. Records of these types are routed to
288
+ * a separate table: excluded from `getSnapshot()`, returned by `getObjectsSnapshot()`.
289
+ * They share the documents' clock, tombstones, and transactions.
290
+ */
291
+ objectTypes?: readonly string[]
260
292
  onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
261
293
  }) {
262
294
  this.sql = sql
295
+ this.objectTypes = new Set(objectTypes ?? [])
263
296
  const prefix = sql.config?.tablePrefix ?? ''
264
297
  const documentsTable = `${prefix}documents`
298
+ const objectsTable = `${prefix}objects`
265
299
  const tombstonesTable = `${prefix}tombstones`
266
300
  const metadataTable = `${prefix}metadata`
267
301
 
268
- migrateSqliteSyncStorage(this.sql, { documentsTable, tombstonesTable, metadataTable })
302
+ migrateSqliteSyncStorage(this.sql, {
303
+ documentsTable,
304
+ objectsTable,
305
+ tombstonesTable,
306
+ metadataTable,
307
+ })
308
+
309
+ // Both record tables have identical shape; prepare the same statements for each.
310
+ const makeRecordTableStmts = (table: string) => ({
311
+ get: this.sql.prepare<{ state: Uint8Array }, [id: string]>(
312
+ `SELECT state FROM ${table} WHERE id = ?`
313
+ ),
314
+ getWithClock: this.sql.prepare<{ state: Uint8Array; lastChangedClock: number }, [id: string]>(
315
+ `SELECT state, lastChangedClock FROM ${table} WHERE id = ?`
316
+ ),
317
+ insert: this.sql.prepare<void, [id: string, state: Uint8Array, lastChangedClock: number]>(
318
+ `INSERT OR REPLACE INTO ${table} (id, state, lastChangedClock) VALUES (?, ?, ?)`
319
+ ),
320
+ delete: this.sql.prepare<void, [id: string]>(`DELETE FROM ${table} WHERE id = ?`),
321
+ exists: this.sql.prepare<{ id: string }, [id: string]>(
322
+ `SELECT id FROM ${table} WHERE id = ?`
323
+ ),
324
+ iterate: this.sql.prepare<{ state: Uint8Array; lastChangedClock: number }>(
325
+ `SELECT state, lastChangedClock FROM ${table}`
326
+ ),
327
+ iterateEntries: this.sql.prepare<{ id: string; state: Uint8Array }>(
328
+ `SELECT id, state FROM ${table}`
329
+ ),
330
+ iterateKeys: this.sql.prepare<{ id: string }>(`SELECT id FROM ${table}`),
331
+ iterateValues: this.sql.prepare<{ state: Uint8Array }>(`SELECT state FROM ${table}`),
332
+ changedSince: this.sql.prepare<{ state: Uint8Array }, [sinceClock: number]>(
333
+ `SELECT state FROM ${table} WHERE lastChangedClock > ?`
334
+ ),
335
+ })
269
336
 
270
337
  // Prepare all statements once
271
338
  this.stmts = {
@@ -285,33 +352,9 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
285
352
  `UPDATE ${metadataTable} SET documentClock = documentClock + 1`
286
353
  ),
287
354
 
288
- // Documents
289
- getDocument: this.sql.prepare<{ state: Uint8Array }, [id: string]>(
290
- `SELECT state FROM ${documentsTable} WHERE id = ?`
291
- ),
292
- insertDocument: this.sql.prepare<
293
- void,
294
- [id: string, state: Uint8Array, lastChangedClock: number]
295
- >(`INSERT OR REPLACE INTO ${documentsTable} (id, state, lastChangedClock) VALUES (?, ?, ?)`),
296
- deleteDocument: this.sql.prepare<void, [id: string]>(
297
- `DELETE FROM ${documentsTable} WHERE id = ?`
298
- ),
299
- documentExists: this.sql.prepare<{ id: string }, [id: string]>(
300
- `SELECT id FROM ${documentsTable} WHERE id = ?`
301
- ),
302
- iterateDocuments: this.sql.prepare<{ state: Uint8Array; lastChangedClock: number }>(
303
- `SELECT state, lastChangedClock FROM ${documentsTable}`
304
- ),
305
- iterateDocumentEntries: this.sql.prepare<{ id: string; state: Uint8Array }>(
306
- `SELECT id, state FROM ${documentsTable}`
307
- ),
308
- iterateDocumentKeys: this.sql.prepare<{ id: string }>(`SELECT id FROM ${documentsTable}`),
309
- iterateDocumentValues: this.sql.prepare<{ state: Uint8Array }>(
310
- `SELECT state FROM ${documentsTable}`
311
- ),
312
- getDocumentsChangedSince: this.sql.prepare<{ state: Uint8Array }, [sinceClock: number]>(
313
- `SELECT state FROM ${documentsTable} WHERE lastChangedClock > ?`
314
- ),
355
+ // Record tables (document lane + object-store lane)
356
+ documents: makeRecordTableStmts(documentsTable),
357
+ objects: makeRecordTableStmts(objectsTable),
315
358
 
316
359
  // Tombstones
317
360
  insertTombstone: this.sql.prepare<void, [id: string, clock: number]>(
@@ -354,12 +397,17 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
354
397
  // Clear existing data
355
398
  this.sql.exec(`
356
399
  DELETE FROM ${documentsTable};
400
+ DELETE FROM ${objectsTable};
357
401
  DELETE FROM ${tombstonesTable};
358
402
  `)
359
403
 
360
- // Insert documents
404
+ // Insert records, routing object-lane types into their partition (a seed snapshot may
405
+ // carry object-lane records merged in, e.g. loaded from a separate persistence lane)
361
406
  for (const doc of snapshot.documents) {
362
- this.stmts.insertDocument.run(doc.state.id, encodeState(doc.state), doc.lastChangedClock)
407
+ const table = this.objectTypes.has(doc.state.typeName)
408
+ ? this.stmts.objects
409
+ : this.stmts.documents
410
+ table.insert.run(doc.state.id, encodeState(doc.state), doc.lastChangedClock)
363
411
  }
364
412
 
365
413
  // Insert tombstones
@@ -375,6 +423,21 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
375
423
  tombstoneHistoryStartsAtClock,
376
424
  JSON.stringify(snapshot.schema)
377
425
  )
426
+ } else {
427
+ // One-time sweep: rooms written before the objects table existed may have object-lane
428
+ // records sitting in the documents table. Record ids are typeName-prefixed, so a PK
429
+ // range scan per object type moves them cheaply (no-op once clean).
430
+ for (const typeName of this.objectTypes) {
431
+ const lo = `${typeName}:`
432
+ // ';' is the next code point after ':' — [lo, hi) covers exactly the `type:` prefix
433
+ const hi = `${typeName};`
434
+ this.sql.exec(`
435
+ INSERT OR REPLACE INTO ${objectsTable} (id, state, lastChangedClock)
436
+ SELECT id, state, lastChangedClock FROM ${documentsTable}
437
+ WHERE id >= '${lo}' AND id < '${hi}';
438
+ DELETE FROM ${documentsTable} WHERE id >= '${lo}' AND id < '${hi}';
439
+ `)
440
+ }
378
441
  }
379
442
  if (onChange) {
380
443
  this.onChange(onChange)
@@ -470,16 +533,38 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
470
533
  )
471
534
 
472
535
  getSnapshot(): RoomSnapshot {
536
+ // object-lane records live in their own table, so the document snapshot is
537
+ // pure-document by construction
473
538
  return {
474
539
  tombstoneHistoryStartsAtClock: this._getTombstoneHistoryStartsAtClock(),
475
540
  documentClock: this.getClock(),
476
- documents: Array.from(this._iterateDocuments()),
541
+ documents: Array.from(this._iterateRecords(this.stmts.documents)),
477
542
  tombstones: Object.fromEntries(this._iterateTombstones()),
478
543
  schema: this._getSchema(),
479
544
  }
480
545
  }
481
- private *_iterateDocuments(): IterableIterator<{ state: R; lastChangedClock: number }> {
482
- for (const row of this.stmts.iterateDocuments.iterate()) {
546
+
547
+ getObjectsSnapshot(): RoomSnapshot['documents'] {
548
+ return Array.from(this._iterateRecords(this.stmts.objects))
549
+ }
550
+
551
+ getObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {
552
+ const result: RoomSnapshot['documents'] = []
553
+ const seen = new Set<string>()
554
+ for (const id of ids) {
555
+ if (seen.has(id)) continue
556
+ seen.add(id)
557
+ const row = this.stmts.objects.getWithClock.all(id)[0]
558
+ if (!row) continue
559
+ result.push({ state: decodeState<R>(row.state), lastChangedClock: row.lastChangedClock })
560
+ }
561
+ return result
562
+ }
563
+
564
+ private *_iterateRecords(
565
+ table: SQLiteSyncStorage<R>['stmts']['documents']
566
+ ): IterableIterator<{ state: R; lastChangedClock: number }> {
567
+ for (const row of table.iterate.iterate()) {
483
568
  yield { state: decodeState<R>(row.state), lastChangedClock: row.lastChangedClock }
484
569
  }
485
570
  }
@@ -531,9 +616,21 @@ class SQLiteSyncStorageTransaction<R extends UnknownRecord> implements TLSyncSto
531
616
  return this._clock
532
617
  }
533
618
 
619
+ /**
620
+ * Which record table an id belongs to. Record ids are typeName-prefixed (`comment:abc`),
621
+ * so the partition is derivable from the id alone.
622
+ */
623
+ private tableFor(id: string) {
624
+ const sep = id.indexOf(':')
625
+ if (sep === -1) return this.stmts.documents
626
+ return this.storage.objectTypes.has(id.slice(0, sep))
627
+ ? this.stmts.objects
628
+ : this.stmts.documents
629
+ }
630
+
534
631
  get(id: string): R | undefined {
535
632
  this.assertNotClosed()
536
- const row = this.stmts.getDocument.all(id)[0]
633
+ const row = this.tableFor(id).get.all(id)[0]
537
634
  if (!row) return undefined
538
635
  return decodeState<R>(row.state)
539
636
  }
@@ -544,41 +641,54 @@ class SQLiteSyncStorageTransaction<R extends UnknownRecord> implements TLSyncSto
544
641
  const clock = this.getNextClock()
545
642
  // Automatically clear tombstone if it exists
546
643
  this.stmts.deleteTombstone.run(id)
547
- this.stmts.insertDocument.run(id, encodeState(record), clock)
644
+ // route by type: object-lane records live in their own table
645
+ const table = this.storage.objectTypes.has(record.typeName)
646
+ ? this.stmts.objects
647
+ : this.stmts.documents
648
+ table.insert.run(id, encodeState(record), clock)
548
649
  }
549
650
 
550
651
  delete(id: string): void {
551
652
  this.assertNotClosed()
653
+ const table = this.tableFor(id)
552
654
  // Only create a tombstone if the record actually exists
553
- const exists = this.stmts.documentExists.all(id)[0]
655
+ const exists = table.exists.all(id)[0]
554
656
  if (!exists) return
555
657
  const clock = this.getNextClock()
556
- this.stmts.deleteDocument.run(id)
658
+ table.delete.run(id)
557
659
  this.stmts.insertTombstone.run(id, clock)
558
660
  this.storage.pruneTombstones()
559
661
  }
560
662
 
663
+ // iteration spans both record tables so schema migrations cover object-lane records too
664
+
561
665
  *entries(): IterableIterator<[string, R]> {
562
666
  this.assertNotClosed()
563
- for (const row of this.stmts.iterateDocumentEntries.iterate()) {
564
- this.assertNotClosed()
565
- yield [row.id, decodeState<R>(row.state)]
667
+ for (const table of [this.stmts.documents, this.stmts.objects]) {
668
+ for (const row of table.iterateEntries.iterate()) {
669
+ this.assertNotClosed()
670
+ yield [row.id, decodeState<R>(row.state)]
671
+ }
566
672
  }
567
673
  }
568
674
 
569
675
  *keys(): IterableIterator<string> {
570
676
  this.assertNotClosed()
571
- for (const row of this.stmts.iterateDocumentKeys.iterate()) {
572
- this.assertNotClosed()
573
- yield row.id
677
+ for (const table of [this.stmts.documents, this.stmts.objects]) {
678
+ for (const row of table.iterateKeys.iterate()) {
679
+ this.assertNotClosed()
680
+ yield row.id
681
+ }
574
682
  }
575
683
  }
576
684
 
577
685
  *values(): IterableIterator<R> {
578
686
  this.assertNotClosed()
579
- for (const row of this.stmts.iterateDocumentValues.iterate()) {
580
- this.assertNotClosed()
581
- yield decodeState<R>(row.state)
687
+ for (const table of [this.stmts.documents, this.stmts.objects]) {
688
+ for (const row of table.iterateValues.iterate()) {
689
+ this.assertNotClosed()
690
+ yield decodeState<R>(row.state)
691
+ }
582
692
  }
583
693
  }
584
694
 
@@ -603,17 +713,22 @@ class SQLiteSyncStorageTransaction<R extends UnknownRecord> implements TLSyncSto
603
713
  const diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }
604
714
  const wipeAll = sinceClock < this.storage._getTombstoneHistoryStartsAtClock()
605
715
 
716
+ // both record tables share the clock and tombstones, so the change feed spans both
606
717
  if (wipeAll) {
607
- // If wipeAll, include all documents
608
- for (const row of this.stmts.iterateDocumentValues.iterate()) {
609
- const state = decodeState<R>(row.state)
610
- diff.puts[state.id] = state
718
+ // If wipeAll, include all records
719
+ for (const table of [this.stmts.documents, this.stmts.objects]) {
720
+ for (const row of table.iterateValues.iterate()) {
721
+ const state = decodeState<R>(row.state)
722
+ diff.puts[state.id] = state
723
+ }
611
724
  }
612
725
  } else {
613
- // Get documents changed since clock
614
- for (const row of this.stmts.getDocumentsChangedSince.iterate(sinceClock)) {
615
- const state = decodeState<R>(row.state)
616
- diff.puts[state.id] = state
726
+ // Get records changed since clock
727
+ for (const table of [this.stmts.documents, this.stmts.objects]) {
728
+ for (const row of table.changedSince.iterate(sinceClock)) {
729
+ const state = decodeState<R>(row.state)
730
+ diff.puts[state.id] = state
731
+ }
617
732
  }
618
733
  // When wipeAll, deletes are redundant (full state is in puts). Only include tombstones otherwise.
619
734
  for (const row of this.stmts.getTombstonesChangedSince.iterate(sinceClock)) {