@tldraw/sync-core 5.3.0-internal.fba91ed55f6c → 5.3.0-next.4123e97459a9

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 +3 -154
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +20 -55
  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 +52 -115
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +2 -27
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +1 -6
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +10 -160
  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 +3 -154
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +20 -55
  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 +52 -115
  24. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  25. package/dist-esm/lib/TLSocketRoom.mjs +2 -27
  26. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncClient.mjs +1 -6
  28. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  29. package/dist-esm/lib/TLSyncRoom.mjs +10 -160
  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 +0 -3
  35. package/src/lib/InMemorySyncStorage.ts +21 -74
  36. package/src/lib/RoomSession.ts +2 -7
  37. package/src/lib/SQLiteSyncStorage.test.ts +2 -29
  38. package/src/lib/SQLiteSyncStorage.ts +59 -158
  39. package/src/lib/TLSocketRoom.ts +3 -61
  40. package/src/lib/TLSyncClient.test.ts +2 -9
  41. package/src/lib/TLSyncClient.ts +3 -15
  42. package/src/lib/TLSyncRoom.ts +10 -294
  43. package/src/lib/TLSyncStorage.ts +0 -8
  44. package/src/lib/protocol.ts +0 -17
  45. package/src/test/TLSocketRoom.test.ts +2 -37
  46. package/src/test/TLSyncRoom.test.ts +0 -25
  47. package/src/test/TestServer.ts +4 -14
  48. package/src/test/storageContractSuite.ts +0 -79
  49. package/src/test/upgradeDowngrade.test.ts +2 -144
  50. package/src/test/objectStore.test.ts +0 -630
@@ -31,7 +31,6 @@ import { interval } from './interval'
31
31
  import {
32
32
  getTlsyncProtocolVersion,
33
33
  TLIncompatibilityReason,
34
- TLObjectStoreAccess,
35
34
  TLSocketClientSentEvent,
36
35
  TLSocketServerSentDataEvent,
37
36
  TLSocketServerSentEvent,
@@ -121,67 +120,6 @@ export interface RoomSnapshot {
121
120
  schema?: SerializedSchema
122
121
  }
123
122
 
124
- /**
125
- * Authorizes a single record write from a client: any per-record, per-session rule the host wants
126
- * to enforce server-side — veto writes the session isn't allowed to make, or rewrite the record on
127
- * create. The session's `meta` carries whatever the rule needs (identity, roles, …); for example,
128
- * force a comment's `authorId` to the signed-in user so nobody can post in someone else's name.
129
- *
130
- * Called on **create**, **update**, and **delete** of records whose `typeName` it's registered for
131
- * (see {@link TLRecordAuthorizers}), and only for client pushes — never for server-initiated writes.
132
- *
133
- * `prev` and `next` are always at the **server's** schema version: client writes are migrated
134
- * before the authorizer runs, so guarding or stamping a field never requires knowing what older
135
- * clients call it. On create, the record you return is what gets stored (after validation) — no
136
- * migration runs afterwards, so stamped fields can't be clobbered.
137
- *
138
- * Return `null` to reject the write — it's skipped and the client self-corrects, exactly like the
139
- * `objectAccess` gate. Otherwise the write is allowed, and:
140
- *
141
- * - on **create**, the record you return is what gets stored, so stamp identity fields here (e.g.
142
- * set `authorId` from `session.meta`);
143
- * - on **update** and **delete**, only allow-vs-reject is used (the returned record's contents are
144
- * ignored), so use them to veto changes to immutable fields or unauthorized deletes — return
145
- * `next`/`prev` to allow, `null` to reject.
146
- *
147
- * ⚠︎ Runs synchronously inside the commit transaction, on the same path as every document edit — it
148
- * must be fast and do **no** I/O. `next`/`prev` are client-controlled records, so treat their
149
- * contents as untrusted; prefer returning `null` to reject over throwing, though a throw is
150
- * caught, logged, and treated as a rejection (fail closed) rather than crashing the push. For
151
- * expensive, async checks (e.g. resolving mentions against who can access a file), react after the
152
- * fact via `onCommittedChanges`.
153
- *
154
- * @public
155
- */
156
- export type TLRecordAuthorizer<Rec extends UnknownRecord, SessionMeta> = (
157
- args: {
158
- /** The session performing the write, including its host-provided `meta` (e.g. the authenticated user id). */
159
- session: { sessionId: string; meta: SessionMeta }
160
- } & (
161
- | { type: 'create'; prev: null; next: Rec }
162
- | { type: 'update'; prev: Rec; next: Rec }
163
- | { type: 'delete'; prev: Rec; next: null }
164
- )
165
- ) => Rec | null
166
-
167
- /**
168
- * A map from record `typeName` to a {@link TLRecordAuthorizer} for that record type. Only listed
169
- * types are authorized; every other record writes through untouched, so this stays off the hot path
170
- * for the vast majority of writes (shape drags etc.).
171
- *
172
- * Each authorizer is typed to its record — e.g. `next` in the `comment` entry is a `TLComment` — so
173
- * renaming a field on the record makes the authorizer that reads it fail to compile, rather than
174
- * silently stamp or guard the wrong field.
175
- *
176
- * Presence records are never authorized (presence is per-session and ephemeral); registering the
177
- * presence typeName is a construction-time error.
178
- *
179
- * @public
180
- */
181
- export type TLRecordAuthorizers<R extends UnknownRecord, SessionMeta> = {
182
- [K in R['typeName']]?: TLRecordAuthorizer<Extract<R, { typeName: K }>, SessionMeta>
183
- }
184
-
185
123
  /**
186
124
  * A collaborative workspace that manages multiple client sessions and synchronizes
187
125
  * document changes between them. The room serves as the authoritative source for
@@ -298,74 +236,22 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
298
236
  readonly serializedSchema: SerializedSchema
299
237
 
300
238
  readonly documentTypes: Set<string>
301
- /**
302
- * Record types served by the object-store lane. Object records ride the same wire messages
303
- * as document records but are gated by the session's `objectAccess` instead of `isReadonly`,
304
- * and are excluded from `documentTypes` so hosts can persist them in a separate lane.
305
- */
306
- readonly objectTypes: Set<string>
307
239
  readonly presenceType: RecordType<R, any> | null
308
240
  private log?: TLSyncLog
309
241
  public readonly schema: StoreSchema<R, any>
310
242
  private onPresenceChange?(): void
311
- private onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
312
- private readonly authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
313
243
  private readonly sessionIdleTimeout: number
314
244
 
315
- /**
316
- * The authorizer registered for a record type, widened to the room's record union. Each entry in
317
- * `authorizeRecord` is typed to its specific record; the cast here is the one place we can't
318
- * statically correlate a runtime `typeName` with its record type, so it lives in the library
319
- * rather than in every consumer.
320
- */
321
- private authorizerFor(typeName: R['typeName']): TLRecordAuthorizer<R, SessionMeta> | undefined {
322
- const authorize = this.authorizeRecord?.[typeName] as
323
- | TLRecordAuthorizer<R, SessionMeta>
324
- | undefined
325
- if (!authorize) return undefined
326
- // Fail closed: an authorizer that throws rejects the write (and is logged) rather than
327
- // aborting the whole push. Authorizers are security-sensitive, so a bug must never let a
328
- // write through.
329
- return (args) => {
330
- try {
331
- return authorize(args)
332
- } catch (e) {
333
- this.log?.error?.('record authorizer threw; rejecting the write', e)
334
- return null
335
- }
336
- }
337
- }
338
-
339
245
  constructor(opts: {
340
246
  log?: TLSyncLog
341
247
  schema: StoreSchema<R, any>
342
248
  onPresenceChange?(): void
343
- /**
344
- * Called once after a client push commits, with the committed document diff. Fires for
345
- * local and remote pushes. Use this to react to document changes (e.g. persist certain
346
- * record types to a separate lane, or project them to an external store) as soon as they
347
- * commit. Best-effort — do not throw; do not block.
348
- */
349
- onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
350
- /**
351
- * Record type names to serve through the object-store lane instead of the document lane.
352
- * Each must be a document-scoped type registered in the schema. Object-lane writes are
353
- * gated per session by `objectAccess` rather than `isReadonly`.
354
- */
355
- objectTypes?: readonly string[]
356
- /**
357
- * Per-type authorizers for client record writes (create, update, delete): veto or, on
358
- * create, rewrite. See {@link TLRecordAuthorizers}.
359
- */
360
- authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
361
249
  storage: TLSyncStorage<R>
362
250
  clientTimeout?: number
363
251
  }) {
364
252
  this.schema = opts.schema
365
253
  this.log = opts.log
366
254
  this.onPresenceChange = opts.onPresenceChange
367
- this.onCommittedChanges = opts.onCommittedChanges
368
- this.authorizeRecord = opts.authorizeRecord
369
255
  this.storage = opts.storage
370
256
  this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
371
257
 
@@ -378,20 +264,9 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
378
264
  // do a json serialization cycle to make sure the schema has no 'undefined' values
379
265
  this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()))
380
266
 
381
- this.objectTypes = new Set(opts.objectTypes ?? [])
382
- for (const typeName of this.objectTypes) {
383
- const type = getOwnProperty(this.schema.types, typeName)
384
- assert(type, `TLSyncRoom: object type '${typeName}' is not registered in the schema`)
385
- assert(
386
- type.scope === 'document',
387
- `TLSyncRoom: object type '${typeName}' must have scope 'document', got '${type.scope}'`
388
- )
389
- }
390
-
391
- // object-lane types are partitioned out of the document lane
392
267
  this.documentTypes = new Set(
393
268
  Object.values<RecordType<R, any>>(this.schema.types)
394
- .filter((t) => t.scope === 'document' && !this.objectTypes.has(t.typeName))
269
+ .filter((t) => t.scope === 'document')
395
270
  .map((t) => t.typeName)
396
271
  )
397
272
 
@@ -407,15 +282,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
407
282
 
408
283
  this.presenceType = presenceTypes.values().next()?.value ?? null
409
284
 
410
- // The presence lane never consults authorizers, so a presence key in `authorizeRecord`
411
- // would be a silent no-op — fail loudly at construction instead.
412
- if (this.presenceType && this.authorizeRecord) {
413
- assert(
414
- !getOwnProperty(this.authorizeRecord, this.presenceType.typeName),
415
- `TLSyncRoom: authorizeRecord['${this.presenceType.typeName}'] is a presence type; presence records are not authorized`
416
- )
417
- }
418
-
419
285
  const { documentClock } = this.storage.transaction((txn) => {
420
286
  this.schema.migrateStorage(txn)
421
287
  })
@@ -576,7 +442,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
576
442
  cancellationTime: Date.now(),
577
443
  meta: session.meta,
578
444
  isReadonly: session.isReadonly,
579
- objectAccess: session.objectAccess,
580
445
  requiresLegacyRejection: session.requiresLegacyRejection,
581
446
  supportsStringAppend: session.supportsStringAppend,
582
447
  })
@@ -687,9 +552,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
687
552
  socket: TLRoomSocket<R>
688
553
  meta: SessionMeta
689
554
  isReadonly: boolean
690
- objectAccess?: TLObjectStoreAccess
691
555
  }) {
692
- const { sessionId, socket, meta, isReadonly, objectAccess } = opts
556
+ const { sessionId, socket, meta, isReadonly } = opts
693
557
  const existing = this.sessions.get(sessionId)
694
558
  this.sessions.set(sessionId, {
695
559
  state: RoomSessionState.AwaitingConnectMessage,
@@ -699,7 +563,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
699
563
  sessionStartTime: Date.now(),
700
564
  meta,
701
565
  isReadonly: isReadonly ?? false,
702
- objectAccess: objectAccess ?? 'write',
703
566
  // this gets set later during handleConnectMessage
704
567
  requiresLegacyRejection: false,
705
568
  supportsStringAppend: true,
@@ -719,7 +582,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
719
582
  socket: TLRoomSocket<R>
720
583
  meta: SessionMeta
721
584
  isReadonly: boolean
722
- objectAccess?: TLObjectStoreAccess
723
585
  serializedSchema: SerializedSchema
724
586
  presenceId: string | null
725
587
  presenceRecord: UnknownRecord | null
@@ -731,7 +593,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
731
593
  socket,
732
594
  meta,
733
595
  isReadonly,
734
- objectAccess,
735
596
  serializedSchema,
736
597
  presenceId,
737
598
  presenceRecord,
@@ -754,7 +615,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
754
615
  outstandingDataMessages: [],
755
616
  meta,
756
617
  isReadonly,
757
- objectAccess: objectAccess ?? 'write',
758
618
  requiresLegacyRejection,
759
619
  supportsStringAppend,
760
620
  })
@@ -1067,7 +927,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1067
927
  supportsStringAppend: session.supportsStringAppend,
1068
928
  meta: session.meta,
1069
929
  isReadonly: session.isReadonly,
1070
- objectAccess: session.objectAccess,
1071
930
  requiresLegacyRejection: session.requiresLegacyRejection,
1072
931
  })
1073
932
  this._unsafe_sendMessage(session.sessionId, msg)
@@ -1118,7 +977,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1118
977
  serverClock: txn.getClock(),
1119
978
  diff: { ...presenceDiff.value, ...docDiff },
1120
979
  isReadonly: session.isReadonly,
1121
- objectAccess: session.objectAccess,
1122
980
  } satisfies Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>
1123
981
  }) // no id needed because this only reads, no writes.
1124
982
 
@@ -1180,8 +1038,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1180
1038
  storage: MinimalDocStore<R>,
1181
1039
  changes: ActualChanges,
1182
1040
  id: string,
1183
- _state: R,
1184
- authorize?: (prev: R | null, next: R) => R | null
1041
+ _state: R
1185
1042
  ): Result<void, void> => {
1186
1043
  const res = session
1187
1044
  ? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
@@ -1189,19 +1046,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1189
1046
  if (res.type === 'error') {
1190
1047
  throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
1191
1048
  }
1192
- let { value: state } = res
1049
+ const { value: state } = res
1193
1050
 
1194
1051
  // Get the existing document, if any
1195
1052
  const doc = storage.get(id) as R | undefined
1196
1053
 
1197
- // Authorize on the up-migrated record; on create the authorizer's return is stored as-is,
1198
- // so no later migration can clobber stamped fields.
1199
- if (authorize) {
1200
- const result = authorize(doc ?? null, state)
1201
- if (!result) return Result.ok(undefined) // vetoed: skip the op, the client self-corrects
1202
- if (!doc) state = result // create: store the authorizer's (stamped) record
1203
- }
1204
-
1205
1054
  if (doc) {
1206
1055
  // If there's an existing document, replace it with the new state
1207
1056
  // but propagate a diff rather than the entire value
@@ -1228,8 +1077,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1228
1077
  storage: MinimalDocStore<R>,
1229
1078
  changes: ActualChanges,
1230
1079
  id: string,
1231
- patch: ObjectDiff,
1232
- authorize?: (prev: R, next: R) => R | null
1080
+ patch: ObjectDiff
1233
1081
  ) => {
1234
1082
  // if it was already deleted, there's no need to apply the patch
1235
1083
  const doc = storage.get(id) as R | undefined
@@ -1249,8 +1097,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1249
1097
  // If the versions are compatible, apply the patch and propagate the patch op
1250
1098
  const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode)
1251
1099
  if (diff) {
1252
- // Authorize on the committed candidate — the record that will actually be stored.
1253
- if (authorize && !authorize(doc, diff[1])) return
1254
1100
  storage.set(id, diff[1])
1255
1101
  propagateOp(changes, id, [RecordOpType.Patch, diff[0]], doc, diff[1])
1256
1102
  }
@@ -1270,8 +1116,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1270
1116
  // replace the state with the upgraded version and propagate the patch op
1271
1117
  const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode)
1272
1118
  if (diff) {
1273
- // Authorize on the committed candidate — the upgraded record, not a raw preview.
1274
- if (authorize && !authorize(doc, upgraded.value)) return
1275
1119
  storage.set(id, upgraded.value)
1276
1120
  propagateOp(changes, id, [RecordOpType.Patch, diff], doc, upgraded.value)
1277
1121
  }
@@ -1314,150 +1158,33 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1314
1158
  }
1315
1159
  }
1316
1160
  }
1317
- // Per-op write gate: document-lane records are gated by `isReadonly`, object-lane
1318
- // records by `objectAccess`. Denied ops are skipped (the client is corrected by the
1319
- // resulting discard/rebase push_result, exactly as whole-diff readonly skips were).
1320
- // Server-initiated pushes (no session) are always allowed.
1321
- const canWrite = (typeName: string) =>
1322
- !session ||
1323
- (this.objectTypes.has(typeName) ? session.objectAccess !== 'read' : !session.isReadonly)
1324
-
1325
- if (message.diff) {
1161
+ if (message.diff && !session?.isReadonly) {
1326
1162
  // The push request was for the document scope.
1327
1163
  for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
1328
1164
  switch (op[0]) {
1329
1165
  case RecordOpType.Put: {
1330
- // The write gate is checked before type validation so that a denied
1331
- // session's ops are skipped without rejection, matching the previous
1332
- // whole-diff readonly behavior.
1333
- if (!canWrite(op[1].typeName)) continue
1334
1166
  // Try to add the document.
1335
1167
  // If we're putting a record with a type that we don't recognize, fail
1336
- if (
1337
- !this.documentTypes.has(op[1].typeName) &&
1338
- !this.objectTypes.has(op[1].typeName)
1339
- ) {
1168
+ if (!this.documentTypes.has(op[1].typeName)) {
1340
1169
  throw new TLSyncError(
1341
1170
  'invalid record',
1342
1171
  TLSyncErrorCloseEventReason.INVALID_RECORD
1343
1172
  )
1344
1173
  }
1345
- const record = op[1]
1346
- // Per-type authorizer: stamp/veto the write from the session's identity.
1347
- // Client pushes only; runs inside `addDocument`, on the up-migrated record.
1348
- let authorize: ((prev: R | null, next: R) => R | null) | undefined
1349
- if (session && this.authorizeRecord) {
1350
- const prev = (txn.get(id) as R | undefined) ?? null
1351
- // A put must not change the record's typeName: the authorizer lookup keys
1352
- // off the incoming typeName while the replace path validates against the
1353
- // stored one, so a swap would consult the wrong authorizer (or none).
1354
- // Skip it like a veto; the client self-corrects.
1355
- if (prev && prev.typeName !== record.typeName) {
1356
- this.log?.warn?.(
1357
- 'skipping put that changes typeName',
1358
- `${prev.typeName} -> ${record.typeName}`,
1359
- id,
1360
- 'session:',
1361
- session.sessionId
1362
- )
1363
- continue
1364
- }
1365
- const authorizePut = this.authorizerFor(record.typeName)
1366
- if (authorizePut) {
1367
- authorize = (prevRec, next) => {
1368
- const result = authorizePut(
1369
- prevRec
1370
- ? {
1371
- session: { sessionId: session.sessionId, meta: session.meta },
1372
- type: 'update',
1373
- prev: prevRec,
1374
- next,
1375
- }
1376
- : {
1377
- session: { sessionId: session.sessionId, meta: session.meta },
1378
- type: 'create',
1379
- prev: null,
1380
- next,
1381
- }
1382
- )
1383
- if (!result) {
1384
- this.log?.warn?.(
1385
- 'authorizer vetoed put',
1386
- record.typeName,
1387
- id,
1388
- 'session:',
1389
- session.sessionId
1390
- )
1391
- return null
1392
- }
1393
- // create: store the stamped record; update: allow/veto only
1394
- return prevRec ? next : result
1395
- }
1396
- }
1397
- }
1398
- addDocument(txn, docChanges, id, record, authorize)
1174
+ addDocument(txn, docChanges, id, op[1])
1399
1175
  break
1400
1176
  }
1401
1177
  case RecordOpType.Patch: {
1402
- const doc = txn.get(id) as R | undefined
1403
- // if it was already deleted, there's no need to apply the patch
1404
- if (!doc) continue
1405
- if (!canWrite(doc.typeName)) continue
1406
- // Per-type authorizer (update, allow/veto only): runs inside `patchDocument`
1407
- // on the committed candidate, never on a raw client-version preview.
1408
- const authorizePatch = session && this.authorizerFor(doc.typeName)
1409
- const authorize = authorizePatch
1410
- ? (prev: R, next: R) => {
1411
- const result = authorizePatch({
1412
- session: { sessionId: session.sessionId, meta: session.meta },
1413
- type: 'update',
1414
- prev,
1415
- next,
1416
- })
1417
- if (!result) {
1418
- this.log?.warn?.(
1419
- 'authorizer vetoed patch',
1420
- doc.typeName,
1421
- id,
1422
- 'session:',
1423
- session.sessionId
1424
- )
1425
- }
1426
- return result
1427
- }
1428
- : undefined
1429
1178
  // Try to patch the document. If it fails, stop here.
1430
- patchDocument(txn, docChanges, id, op[1], authorize)
1179
+ patchDocument(txn, docChanges, id, op[1])
1431
1180
  break
1432
1181
  }
1433
1182
  case RecordOpType.Remove: {
1434
- const doc = txn.get(id) as R | undefined
1183
+ const doc = txn.get(id)
1435
1184
  if (!doc) {
1436
1185
  // If the doc was already deleted, don't do anything, no need to propagate a delete op
1437
1186
  continue
1438
1187
  }
1439
- if (!canWrite(doc.typeName)) continue
1440
- // Per-type authorizer (delete): veto deletes the session isn't allowed to make,
1441
- // e.g. deleting someone else's comment. Allow/veto only.
1442
- const authorizeRemove = session && this.authorizerFor(doc.typeName)
1443
- if (
1444
- authorizeRemove &&
1445
- !authorizeRemove({
1446
- session: { sessionId: session.sessionId, meta: session.meta },
1447
- type: 'delete',
1448
- prev: doc,
1449
- next: null,
1450
- })
1451
- ) {
1452
- this.log?.warn?.(
1453
- 'authorizer vetoed delete',
1454
- doc.typeName,
1455
- id,
1456
- 'session:',
1457
- session.sessionId
1458
- )
1459
- continue
1460
- }
1461
1188
 
1462
1189
  // Delete the document and propagate the delete op
1463
1190
  // delete automatically creates tombstones
@@ -1545,17 +1272,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1545
1272
  this.onPresenceChange?.()
1546
1273
  })
1547
1274
  }
1548
-
1549
- if (result.docChanges.diffs && this.onCommittedChanges) {
1550
- const diff = result.docChanges.diffs.diff
1551
- queueMicrotask(() => {
1552
- try {
1553
- this.onCommittedChanges?.({ diff, documentClock })
1554
- } catch (e) {
1555
- this.log?.error?.('onCommittedChanges threw', e)
1556
- }
1557
- })
1558
- }
1559
1275
  }
1560
1276
 
1561
1277
  /**
@@ -83,15 +83,7 @@ export interface TLSyncStorage<R extends UnknownRecord> {
83
83
 
84
84
  onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void
85
85
 
86
- /** A snapshot of the document lane only — never contains object-store lane records. */
87
86
  getSnapshot?(): RoomSnapshot
88
-
89
- /**
90
- * A snapshot of the object-store lane (see `objectTypes` on the storage), for hosts that
91
- * persist object-lane records separately from the document. Same entry shape as
92
- * `RoomSnapshot['documents']` so a lane can be persisted and re-seeded via a merged snapshot.
93
- */
94
- getObjectsSnapshot?(): RoomSnapshot['documents']
95
87
  }
96
88
 
97
89
  /**
@@ -24,17 +24,6 @@ export function getTlsyncProtocolVersion() {
24
24
  return TLSYNC_PROTOCOL_VERSION
25
25
  }
26
26
 
27
- /**
28
- * Access level for object-store lane record types (see `objectTypes` on the room).
29
- *
30
- * Object-lane records (e.g. comments) are gated by this per-session value instead of the
31
- * document-lane `isReadonly` flag, so a session can be allowed to write objects without
32
- * being allowed to edit the document ("can comment but not edit"), or vice versa.
33
- *
34
- * @public
35
- */
36
- export type TLObjectStoreAccess = 'read' | 'write'
37
-
38
27
  /**
39
28
  * Constants defining the different types of protocol incompatibility reasons.
40
29
  *
@@ -119,12 +108,6 @@ export type TLSocketServerSentEvent<R extends UnknownRecord> =
119
108
  diff: NetworkDiff<R>
120
109
  serverClock: number
121
110
  isReadonly: boolean
122
- /**
123
- * Write access for object-store lane record types, when the room has an object lane.
124
- * Optional and additive: servers without an object lane (or older servers) omit it, and
125
- * older clients ignore it — no protocol version bump needed.
126
- */
127
- objectAccess?: TLObjectStoreAccess
128
111
  }
129
112
  | {
130
113
  type: 'incompatibility_error'
@@ -48,13 +48,8 @@ function createMockSocket(overrides: Partial<WebSocketMinimal> = {}): WebSocketM
48
48
  }
49
49
 
50
50
  // Connect a session and complete the connect handshake
51
- function connectSession(
52
- room: TLSocketRoom<any, any>,
53
- sessionId: string,
54
- socket: WebSocketMinimal,
55
- opts: { objectAccess?: 'read' | 'write' } = {}
56
- ) {
57
- room.handleSocketConnect({ sessionId, socket, ...opts })
51
+ function connectSession(room: TLSocketRoom<any, any>, sessionId: string, socket: WebSocketMinimal) {
52
+ room.handleSocketConnect({ sessionId, socket })
58
53
  const connectRequest = {
59
54
  type: 'connect' as const,
60
55
  connectRequestId: `connect-${sessionId}`,
@@ -1002,41 +997,11 @@ describe('28. TLSocketRoom (SR)', () => {
1002
997
  expect(snapshot).not.toBeNull()
1003
998
  expect(snapshot!.serializedSchema).toBeDefined()
1004
999
  expect(snapshot!.isReadonly).toBe(false)
1005
- expect(snapshot!.objectAccess).toBe('write')
1006
1000
  expect(snapshot!.presenceId).toBeDefined()
1007
1001
  expect(snapshot!.requiresLegacyRejection).toBe(false)
1008
1002
  expect(snapshot!.supportsStringAppend).toBe(true)
1009
1003
  })
1010
1004
 
1011
- it('[SR13] round-trips objectAccess through snapshot and resume', () => {
1012
- const room = new TLSocketRoom({})
1013
- const socket = createMockSocket()
1014
- connectSession(room, 'test', socket, { objectAccess: 'read' })
1015
-
1016
- const snapshot = room.getSessionSnapshot('test')!
1017
- expect(snapshot.objectAccess).toBe('read')
1018
-
1019
- // Simulate hibernation: resume in a new room
1020
- const room2 = new TLSocketRoom({})
1021
- room2.handleSocketResume({ sessionId: 'test', socket: createMockSocket(), snapshot })
1022
- expect(room2.getSessions()[0].objectAccess).toBe('read')
1023
- })
1024
-
1025
- it('[SR13] resumes legacy snapshots without objectAccess as read (fail closed)', () => {
1026
- const room = new TLSocketRoom({})
1027
- const socket = createMockSocket()
1028
- connectSession(room, 'test', socket)
1029
-
1030
- const snapshot = room.getSessionSnapshot('test')!
1031
- // simulate a snapshot persisted before the objectAccess field existed
1032
- delete snapshot.objectAccess
1033
-
1034
- const room2 = new TLSocketRoom({})
1035
- room2.handleSocketResume({ sessionId: 'test', socket: createMockSocket(), snapshot })
1036
- // such sessions predate the record types gated by objectAccess, so they have nothing to write anyway
1037
- expect(room2.getSessions()[0].objectAccess).toBe('read')
1038
- })
1039
-
1040
1005
  it('[SR13] includes presence record when present', () => {
1041
1006
  const store = getStore()
1042
1007
  store.ensureStoreIsUsable()
@@ -188,7 +188,6 @@ function makeRoom(
188
188
  clientTimeout?: number
189
189
  log?: { warn?: Mock; error?: Mock }
190
190
  onPresenceChange?(): void
191
- onCommittedChanges?(args: { diff: any; documentClock: number }): void
192
191
  } = {}
193
192
  ) {
194
193
  const storage = new InMemorySyncStorage<TLRecord>({
@@ -200,7 +199,6 @@ function makeRoom(
200
199
  clientTimeout: opts.clientTimeout,
201
200
  log: opts.log,
202
201
  onPresenceChange: opts.onPresenceChange,
203
- onCommittedChanges: opts.onCommittedChanges,
204
202
  })
205
203
  disposables.push(() => room.close())
206
204
  return { storage, room }
@@ -636,29 +634,6 @@ describe('22. Room construction (RC)', () => {
636
634
  expect(clientBMessages[0].data[0].type).toBe('patch')
637
635
  })
638
636
 
639
- it('fires onCommittedChanges once with the committed document diff after a push', async () => {
640
- const onCommittedChanges = vi.fn()
641
- const { room } = makeRoom({ onCommittedChanges })
642
- connectSession(room, 'session-a')
643
-
644
- const newPage = makePage('committed_page', 'Committed Page')
645
- room.handleMessage('session-a', {
646
- type: 'push',
647
- clientClock: 1,
648
- diff: {
649
- [newPage.id]: ['put', newPage],
650
- },
651
- } as TLPushRequest<TLRecord>)
652
-
653
- // the tap fires in a microtask, like onPresenceChange
654
- await Promise.resolve()
655
-
656
- expect(onCommittedChanges).toHaveBeenCalledTimes(1)
657
- const arg = onCommittedChanges.mock.calls[0][0]
658
- expect(arg.diff.puts[newPage.id]).toBeTruthy()
659
- expect(typeof arg.documentClock).toBe('number')
660
- })
661
-
662
637
  it('[RC4] handles multiple rapid external changes', async () => {
663
638
  const { room, storage } = makeRoom()
664
639
  const socket = connectSession(room, 'test-session')
@@ -1,18 +1,12 @@
1
1
  import { StoreSchema, UnknownRecord } from '@tldraw/store'
2
2
  import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
3
- import { TLObjectStoreAccess } from '../lib/protocol'
4
3
  import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom'
5
4
  import { TestSocketPair } from './TestSocketPair'
6
5
 
7
- type TestRoomOptions<R extends UnknownRecord> = Omit<
8
- ConstructorParameters<typeof TLSyncRoom<R, undefined>>[0],
9
- 'schema' | 'storage'
10
- >
11
-
12
6
  export class TestServer<R extends UnknownRecord, P = unknown> {
13
7
  room: TLSyncRoom<R, undefined>
14
8
  storage: InMemorySyncStorage<R>
15
- constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot, roomOpts?: TestRoomOptions<R>) {
9
+ constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot) {
16
10
  // Use provided snapshot or create an empty one with the current schema
17
11
  this.storage = new InMemorySyncStorage<R>({
18
12
  snapshot: snapshot ?? {
@@ -22,19 +16,15 @@ export class TestServer<R extends UnknownRecord, P = unknown> {
22
16
  schema: schema.serialize(),
23
17
  },
24
18
  })
25
- this.room = new TLSyncRoom<R, undefined>({ schema, storage: this.storage, ...roomOpts })
19
+ this.room = new TLSyncRoom<R, undefined>({ schema, storage: this.storage })
26
20
  }
27
21
 
28
- connect(
29
- socketPair: TestSocketPair<R>,
30
- opts?: { isReadonly?: boolean; objectAccess?: TLObjectStoreAccess }
31
- ): void {
22
+ connect(socketPair: TestSocketPair<R>): void {
32
23
  this.room.handleNewSession({
33
24
  sessionId: socketPair.id,
34
25
  socket: socketPair.roomSocket,
35
26
  meta: undefined,
36
- isReadonly: opts?.isReadonly ?? false,
37
- objectAccess: opts?.objectAccess,
27
+ isReadonly: false,
38
28
  })
39
29
 
40
30
  socketPair.clientSocket.connectionStatus = 'online'