@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
@@ -31,6 +31,7 @@ import { interval } from './interval'
31
31
  import {
32
32
  getTlsyncProtocolVersion,
33
33
  TLIncompatibilityReason,
34
+ TLObjectStoreAccess,
34
35
  TLSocketClientSentEvent,
35
36
  TLSocketServerSentDataEvent,
36
37
  TLSocketServerSentEvent,
@@ -120,6 +121,69 @@ export interface RoomSnapshot {
120
121
  schema?: SerializedSchema
121
122
  }
122
123
 
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: its host-provided `meta` (e.g. the authenticated user
159
+ * id) and the canvas-lane `isReadonly` state, for hosts whose object-lane policy follows
160
+ * canvas access. */
161
+ session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }
162
+ } & (
163
+ | { type: 'create'; prev: null; next: Rec }
164
+ | { type: 'update'; prev: Rec; next: Rec }
165
+ | { type: 'delete'; prev: Rec; next: null }
166
+ )
167
+ ) => Rec | null
168
+
169
+ /**
170
+ * A map from record `typeName` to a {@link TLRecordAuthorizer} for that record type. Only listed
171
+ * types are authorized; every other record writes through untouched, so this stays off the hot path
172
+ * for the vast majority of writes (shape drags etc.).
173
+ *
174
+ * Each authorizer is typed to its record — e.g. `next` in the `comment` entry is a `TLComment` — so
175
+ * renaming a field on the record makes the authorizer that reads it fail to compile, rather than
176
+ * silently stamp or guard the wrong field.
177
+ *
178
+ * Presence records are never authorized (presence is per-session and ephemeral); registering the
179
+ * presence typeName is a construction-time error.
180
+ *
181
+ * @public
182
+ */
183
+ export type TLRecordAuthorizers<R extends UnknownRecord, SessionMeta> = {
184
+ [K in R['typeName']]?: TLRecordAuthorizer<Extract<R, { typeName: K }>, SessionMeta>
185
+ }
186
+
123
187
  /**
124
188
  * A collaborative workspace that manages multiple client sessions and synchronizes
125
189
  * document changes between them. The room serves as the authoritative source for
@@ -236,22 +300,74 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
236
300
  readonly serializedSchema: SerializedSchema
237
301
 
238
302
  readonly documentTypes: Set<string>
303
+ /**
304
+ * Record types served by the object-store lane. Object records ride the same wire messages
305
+ * as document records but are gated by the session's `objectAccess` instead of `isReadonly`,
306
+ * and are excluded from `documentTypes` so hosts can persist them in a separate lane.
307
+ */
308
+ readonly objectTypes: Set<string>
239
309
  readonly presenceType: RecordType<R, any> | null
240
310
  private log?: TLSyncLog
241
311
  public readonly schema: StoreSchema<R, any>
242
312
  private onPresenceChange?(): void
313
+ private onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
314
+ private readonly authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
243
315
  private readonly sessionIdleTimeout: number
244
316
 
317
+ /**
318
+ * The authorizer registered for a record type, widened to the room's record union. Each entry in
319
+ * `authorizeRecord` is typed to its specific record; the cast here is the one place we can't
320
+ * statically correlate a runtime `typeName` with its record type, so it lives in the library
321
+ * rather than in every consumer.
322
+ */
323
+ private authorizerFor(typeName: R['typeName']): TLRecordAuthorizer<R, SessionMeta> | undefined {
324
+ const authorize = this.authorizeRecord?.[typeName] as
325
+ | TLRecordAuthorizer<R, SessionMeta>
326
+ | undefined
327
+ if (!authorize) return undefined
328
+ // Fail closed: an authorizer that throws rejects the write (and is logged) rather than
329
+ // aborting the whole push. Authorizers are security-sensitive, so a bug must never let a
330
+ // write through.
331
+ return (args) => {
332
+ try {
333
+ return authorize(args)
334
+ } catch (e) {
335
+ this.log?.error?.('record authorizer threw; rejecting the write', e)
336
+ return null
337
+ }
338
+ }
339
+ }
340
+
245
341
  constructor(opts: {
246
342
  log?: TLSyncLog
247
343
  schema: StoreSchema<R, any>
248
344
  onPresenceChange?(): void
345
+ /**
346
+ * Called once after a client push commits, with the committed document diff. Fires for
347
+ * local and remote pushes. Use this to react to document changes (e.g. persist certain
348
+ * record types to a separate lane, or project them to an external store) as soon as they
349
+ * commit. Best-effort — do not throw; do not block.
350
+ */
351
+ onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
352
+ /**
353
+ * Record type names to serve through the object-store lane instead of the document lane.
354
+ * Each must be a document-scoped type registered in the schema. Object-lane writes are
355
+ * gated per session by `objectAccess` rather than `isReadonly`.
356
+ */
357
+ objectTypes?: readonly string[]
358
+ /**
359
+ * Per-type authorizers for client record writes (create, update, delete): veto or, on
360
+ * create, rewrite. See {@link TLRecordAuthorizers}.
361
+ */
362
+ authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
249
363
  storage: TLSyncStorage<R>
250
364
  clientTimeout?: number
251
365
  }) {
252
366
  this.schema = opts.schema
253
367
  this.log = opts.log
254
368
  this.onPresenceChange = opts.onPresenceChange
369
+ this.onCommittedChanges = opts.onCommittedChanges
370
+ this.authorizeRecord = opts.authorizeRecord
255
371
  this.storage = opts.storage
256
372
  this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
257
373
 
@@ -264,9 +380,20 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
264
380
  // do a json serialization cycle to make sure the schema has no 'undefined' values
265
381
  this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()))
266
382
 
383
+ this.objectTypes = new Set(opts.objectTypes ?? [])
384
+ for (const typeName of this.objectTypes) {
385
+ const type = getOwnProperty(this.schema.types, typeName)
386
+ assert(type, `TLSyncRoom: object type '${typeName}' is not registered in the schema`)
387
+ assert(
388
+ type.scope === 'document',
389
+ `TLSyncRoom: object type '${typeName}' must have scope 'document', got '${type.scope}'`
390
+ )
391
+ }
392
+
393
+ // object-lane types are partitioned out of the document lane
267
394
  this.documentTypes = new Set(
268
395
  Object.values<RecordType<R, any>>(this.schema.types)
269
- .filter((t) => t.scope === 'document')
396
+ .filter((t) => t.scope === 'document' && !this.objectTypes.has(t.typeName))
270
397
  .map((t) => t.typeName)
271
398
  )
272
399
 
@@ -282,6 +409,15 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
282
409
 
283
410
  this.presenceType = presenceTypes.values().next()?.value ?? null
284
411
 
412
+ // The presence lane never consults authorizers, so a presence key in `authorizeRecord`
413
+ // would be a silent no-op — fail loudly at construction instead.
414
+ if (this.presenceType && this.authorizeRecord) {
415
+ assert(
416
+ !getOwnProperty(this.authorizeRecord, this.presenceType.typeName),
417
+ `TLSyncRoom: authorizeRecord['${this.presenceType.typeName}'] is a presence type; presence records are not authorized`
418
+ )
419
+ }
420
+
285
421
  const { documentClock } = this.storage.transaction((txn) => {
286
422
  this.schema.migrateStorage(txn)
287
423
  })
@@ -442,6 +578,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
442
578
  cancellationTime: Date.now(),
443
579
  meta: session.meta,
444
580
  isReadonly: session.isReadonly,
581
+ objectAccess: session.objectAccess,
445
582
  requiresLegacyRejection: session.requiresLegacyRejection,
446
583
  supportsStringAppend: session.supportsStringAppend,
447
584
  })
@@ -552,8 +689,9 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
552
689
  socket: TLRoomSocket<R>
553
690
  meta: SessionMeta
554
691
  isReadonly: boolean
692
+ objectAccess?: TLObjectStoreAccess
555
693
  }) {
556
- const { sessionId, socket, meta, isReadonly } = opts
694
+ const { sessionId, socket, meta, isReadonly, objectAccess } = opts
557
695
  const existing = this.sessions.get(sessionId)
558
696
  this.sessions.set(sessionId, {
559
697
  state: RoomSessionState.AwaitingConnectMessage,
@@ -563,6 +701,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
563
701
  sessionStartTime: Date.now(),
564
702
  meta,
565
703
  isReadonly: isReadonly ?? false,
704
+ objectAccess: objectAccess ?? 'write',
566
705
  // this gets set later during handleConnectMessage
567
706
  requiresLegacyRejection: false,
568
707
  supportsStringAppend: true,
@@ -582,6 +721,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
582
721
  socket: TLRoomSocket<R>
583
722
  meta: SessionMeta
584
723
  isReadonly: boolean
724
+ objectAccess?: TLObjectStoreAccess
585
725
  serializedSchema: SerializedSchema
586
726
  presenceId: string | null
587
727
  presenceRecord: UnknownRecord | null
@@ -593,6 +733,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
593
733
  socket,
594
734
  meta,
595
735
  isReadonly,
736
+ objectAccess,
596
737
  serializedSchema,
597
738
  presenceId,
598
739
  presenceRecord,
@@ -615,6 +756,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
615
756
  outstandingDataMessages: [],
616
757
  meta,
617
758
  isReadonly,
759
+ objectAccess: objectAccess ?? 'write',
618
760
  requiresLegacyRejection,
619
761
  supportsStringAppend,
620
762
  })
@@ -927,6 +1069,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
927
1069
  supportsStringAppend: session.supportsStringAppend,
928
1070
  meta: session.meta,
929
1071
  isReadonly: session.isReadonly,
1072
+ objectAccess: session.objectAccess,
930
1073
  requiresLegacyRejection: session.requiresLegacyRejection,
931
1074
  })
932
1075
  this._unsafe_sendMessage(session.sessionId, msg)
@@ -977,6 +1120,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
977
1120
  serverClock: txn.getClock(),
978
1121
  diff: { ...presenceDiff.value, ...docDiff },
979
1122
  isReadonly: session.isReadonly,
1123
+ objectAccess: session.objectAccess,
980
1124
  } satisfies Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>
981
1125
  }) // no id needed because this only reads, no writes.
982
1126
 
@@ -1038,7 +1182,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1038
1182
  storage: MinimalDocStore<R>,
1039
1183
  changes: ActualChanges,
1040
1184
  id: string,
1041
- _state: R
1185
+ _state: R,
1186
+ authorize?: (prev: R | null, next: R) => R | null,
1187
+ // The existing document if the caller already fetched it; `null` for fetched-and-absent,
1188
+ // `undefined` for not fetched. Saves a second SELECT + JSON.parse on the push hot path.
1189
+ prevDoc?: R | null
1042
1190
  ): Result<void, void> => {
1043
1191
  const res = session
1044
1192
  ? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
@@ -1046,10 +1194,19 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1046
1194
  if (res.type === 'error') {
1047
1195
  throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
1048
1196
  }
1049
- const { value: state } = res
1197
+ let { value: state } = res
1050
1198
 
1051
1199
  // Get the existing document, if any
1052
- const doc = storage.get(id) as R | undefined
1200
+ const doc =
1201
+ prevDoc !== undefined ? (prevDoc ?? undefined) : (storage.get(id) as R | undefined)
1202
+
1203
+ // Authorize on the up-migrated record; on create the authorizer's return is stored as-is,
1204
+ // so no later migration can clobber stamped fields.
1205
+ if (authorize) {
1206
+ const result = authorize(doc ?? null, state)
1207
+ if (!result) return Result.ok(undefined) // vetoed: skip the op, the client self-corrects
1208
+ if (!doc) state = result // create: store the authorizer's (stamped) record
1209
+ }
1053
1210
 
1054
1211
  if (doc) {
1055
1212
  // If there's an existing document, replace it with the new state
@@ -1077,10 +1234,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1077
1234
  storage: MinimalDocStore<R>,
1078
1235
  changes: ActualChanges,
1079
1236
  id: string,
1080
- patch: ObjectDiff
1237
+ patch: ObjectDiff,
1238
+ authorize?: (prev: R, next: R) => R | null,
1239
+ // The existing document if the caller already fetched it (see `addDocument`).
1240
+ prevDoc?: R
1081
1241
  ) => {
1082
1242
  // if it was already deleted, there's no need to apply the patch
1083
- const doc = storage.get(id) as R | undefined
1243
+ const doc = prevDoc ?? (storage.get(id) as R | undefined)
1084
1244
  if (!doc) return
1085
1245
 
1086
1246
  const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
@@ -1097,6 +1257,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1097
1257
  // If the versions are compatible, apply the patch and propagate the patch op
1098
1258
  const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode)
1099
1259
  if (diff) {
1260
+ // Authorize on the committed candidate — the record that will actually be stored.
1261
+ if (authorize && !authorize(doc, diff[1])) return
1100
1262
  storage.set(id, diff[1])
1101
1263
  propagateOp(changes, id, [RecordOpType.Patch, diff[0]], doc, diff[1])
1102
1264
  }
@@ -1116,6 +1278,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1116
1278
  // replace the state with the upgraded version and propagate the patch op
1117
1279
  const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode)
1118
1280
  if (diff) {
1281
+ // Authorize on the committed candidate — the upgraded record, not a raw preview.
1282
+ if (authorize && !authorize(doc, upgraded.value)) return
1119
1283
  storage.set(id, upgraded.value)
1120
1284
  propagateOp(changes, id, [RecordOpType.Patch, diff], doc, upgraded.value)
1121
1285
  }
@@ -1158,33 +1322,173 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1158
1322
  }
1159
1323
  }
1160
1324
  }
1161
- if (message.diff && !session?.isReadonly) {
1325
+ // Per-op write gate: document-lane records are gated by `isReadonly`, object-lane
1326
+ // records by `objectAccess`. Denied ops are skipped (the client is corrected by the
1327
+ // resulting discard/rebase push_result, exactly as whole-diff readonly skips were).
1328
+ // Server-initiated pushes (no session) are always allowed.
1329
+ const canWrite = (typeName: string) =>
1330
+ !session ||
1331
+ (this.objectTypes.has(typeName) ? session.objectAccess !== 'read' : !session.isReadonly)
1332
+
1333
+ if (message.diff) {
1162
1334
  // The push request was for the document scope.
1163
1335
  for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
1164
1336
  switch (op[0]) {
1165
1337
  case RecordOpType.Put: {
1338
+ // The write gate is checked before type validation so that a denied
1339
+ // session's ops are skipped without rejection, matching the previous
1340
+ // whole-diff readonly behavior.
1341
+ if (!canWrite(op[1].typeName)) continue
1166
1342
  // Try to add the document.
1167
1343
  // If we're putting a record with a type that we don't recognize, fail
1168
- if (!this.documentTypes.has(op[1].typeName)) {
1344
+ if (
1345
+ !this.documentTypes.has(op[1].typeName) &&
1346
+ !this.objectTypes.has(op[1].typeName)
1347
+ ) {
1169
1348
  throw new TLSyncError(
1170
1349
  'invalid record',
1171
1350
  TLSyncErrorCloseEventReason.INVALID_RECORD
1172
1351
  )
1173
1352
  }
1174
- addDocument(txn, docChanges, id, op[1])
1353
+ const record = op[1]
1354
+ // A put must not change the record's typeName. Two reasons, and the first
1355
+ // applies whether or not authorizers are configured: the write gate above
1356
+ // keyed off the *incoming* typeName, so a swap between lanes (document <->
1357
+ // object) would let a session denied on one lane write through the other by
1358
+ // relabeling an existing record. The second is that the authorizer lookup
1359
+ // also keys off the incoming typeName while the replace path validates
1360
+ // against the stored one, so a swap would consult the wrong authorizer (or
1361
+ // none). Skip it like a veto; the client self-corrects.
1362
+ // `undefined` (no session) means the guard didn't fetch, so addDocument will.
1363
+ const prevRecord = session ? ((txn.get(id) as R | undefined) ?? null) : undefined
1364
+ if (session && prevRecord && prevRecord.typeName !== record.typeName) {
1365
+ this.log?.warn?.(
1366
+ 'skipping put that changes typeName',
1367
+ `${prevRecord.typeName} -> ${record.typeName}`,
1368
+ id,
1369
+ 'session:',
1370
+ session.sessionId
1371
+ )
1372
+ continue
1373
+ }
1374
+ // Per-type authorizer: stamp/veto the write from the session's identity.
1375
+ // Client pushes only; runs inside `addDocument`, on the up-migrated record.
1376
+ let authorize: ((prev: R | null, next: R) => R | null) | undefined
1377
+ if (session && this.authorizeRecord) {
1378
+ const authorizePut = this.authorizerFor(record.typeName)
1379
+ if (authorizePut) {
1380
+ authorize = (prevRec, next) => {
1381
+ const result = authorizePut(
1382
+ prevRec
1383
+ ? {
1384
+ session: {
1385
+ sessionId: session.sessionId,
1386
+ isReadonly: session.isReadonly,
1387
+ meta: session.meta,
1388
+ },
1389
+ type: 'update',
1390
+ prev: prevRec,
1391
+ next,
1392
+ }
1393
+ : {
1394
+ session: {
1395
+ sessionId: session.sessionId,
1396
+ isReadonly: session.isReadonly,
1397
+ meta: session.meta,
1398
+ },
1399
+ type: 'create',
1400
+ prev: null,
1401
+ next,
1402
+ }
1403
+ )
1404
+ if (!result) {
1405
+ this.log?.warn?.(
1406
+ 'authorizer vetoed put',
1407
+ record.typeName,
1408
+ id,
1409
+ 'session:',
1410
+ session.sessionId
1411
+ )
1412
+ return null
1413
+ }
1414
+ // create: store the stamped record; update: allow/veto only
1415
+ return prevRec ? next : result
1416
+ }
1417
+ }
1418
+ }
1419
+ // The guard above already fetched this, so don't hit storage again.
1420
+ addDocument(txn, docChanges, id, record, authorize, prevRecord)
1175
1421
  break
1176
1422
  }
1177
1423
  case RecordOpType.Patch: {
1178
- // Try to patch the document. If it fails, stop here.
1179
- patchDocument(txn, docChanges, id, op[1])
1424
+ const doc = txn.get(id) as R | undefined
1425
+ // if it was already deleted, there's no need to apply the patch
1426
+ if (!doc) continue
1427
+ if (!canWrite(doc.typeName)) continue
1428
+ // Per-type authorizer (update, allow/veto only): runs inside `patchDocument`
1429
+ // on the committed candidate, never on a raw client-version preview.
1430
+ const authorizePatch = session && this.authorizerFor(doc.typeName)
1431
+ const authorize = authorizePatch
1432
+ ? (prev: R, next: R) => {
1433
+ const result = authorizePatch({
1434
+ session: {
1435
+ sessionId: session.sessionId,
1436
+ isReadonly: session.isReadonly,
1437
+ meta: session.meta,
1438
+ },
1439
+ type: 'update',
1440
+ prev,
1441
+ next,
1442
+ })
1443
+ if (!result) {
1444
+ this.log?.warn?.(
1445
+ 'authorizer vetoed patch',
1446
+ doc.typeName,
1447
+ id,
1448
+ 'session:',
1449
+ session.sessionId
1450
+ )
1451
+ }
1452
+ return result
1453
+ }
1454
+ : undefined
1455
+ // Try to patch the document. If it fails, stop here. The write gate above
1456
+ // already fetched the record, so pass it through.
1457
+ patchDocument(txn, docChanges, id, op[1], authorize, doc)
1180
1458
  break
1181
1459
  }
1182
1460
  case RecordOpType.Remove: {
1183
- const doc = txn.get(id)
1461
+ const doc = txn.get(id) as R | undefined
1184
1462
  if (!doc) {
1185
1463
  // If the doc was already deleted, don't do anything, no need to propagate a delete op
1186
1464
  continue
1187
1465
  }
1466
+ if (!canWrite(doc.typeName)) continue
1467
+ // Per-type authorizer (delete): veto deletes the session isn't allowed to make,
1468
+ // e.g. deleting someone else's comment. Allow/veto only.
1469
+ const authorizeRemove = session && this.authorizerFor(doc.typeName)
1470
+ if (
1471
+ authorizeRemove &&
1472
+ !authorizeRemove({
1473
+ session: {
1474
+ sessionId: session.sessionId,
1475
+ isReadonly: session.isReadonly,
1476
+ meta: session.meta,
1477
+ },
1478
+ type: 'delete',
1479
+ prev: doc,
1480
+ next: null,
1481
+ })
1482
+ ) {
1483
+ this.log?.warn?.(
1484
+ 'authorizer vetoed delete',
1485
+ doc.typeName,
1486
+ id,
1487
+ 'session:',
1488
+ session.sessionId
1489
+ )
1490
+ continue
1491
+ }
1188
1492
 
1189
1493
  // Delete the document and propagate the delete op
1190
1494
  // delete automatically creates tombstones
@@ -1272,6 +1576,17 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1272
1576
  this.onPresenceChange?.()
1273
1577
  })
1274
1578
  }
1579
+
1580
+ if (result.docChanges.diffs && this.onCommittedChanges) {
1581
+ const diff = result.docChanges.diffs.diff
1582
+ queueMicrotask(() => {
1583
+ try {
1584
+ this.onCommittedChanges?.({ diff, documentClock })
1585
+ } catch (e) {
1586
+ this.log?.error?.('onCommittedChanges threw', e)
1587
+ }
1588
+ })
1589
+ }
1275
1590
  }
1276
1591
 
1277
1592
  /**
@@ -83,7 +83,22 @@ 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. */
86
87
  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
+
96
+ /**
97
+ * The same entries for the given ids, for callers that need a handful of records rather than the
98
+ * whole (parsed) lane. Ids with no object-lane record are omitted — document-lane ids included —
99
+ * and duplicates yield one entry.
100
+ */
101
+ getObjectsByIds?(ids: Iterable<string>): RoomSnapshot['documents']
87
102
  }
88
103
 
89
104
  /**
@@ -24,6 +24,17 @@ 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
+
27
38
  /**
28
39
  * Constants defining the different types of protocol incompatibility reasons.
29
40
  *
@@ -108,6 +119,12 @@ export type TLSocketServerSentEvent<R extends UnknownRecord> =
108
119
  diff: NetworkDiff<R>
109
120
  serverClock: number
110
121
  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
111
128
  }
112
129
  | {
113
130
  type: 'incompatibility_error'
@@ -48,8 +48,13 @@ function createMockSocket(overrides: Partial<WebSocketMinimal> = {}): WebSocketM
48
48
  }
49
49
 
50
50
  // Connect a session and complete the connect handshake
51
- function connectSession(room: TLSocketRoom<any, any>, sessionId: string, socket: WebSocketMinimal) {
52
- room.handleSocketConnect({ sessionId, socket })
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 })
53
58
  const connectRequest = {
54
59
  type: 'connect' as const,
55
60
  connectRequestId: `connect-${sessionId}`,
@@ -997,11 +1002,41 @@ describe('28. TLSocketRoom (SR)', () => {
997
1002
  expect(snapshot).not.toBeNull()
998
1003
  expect(snapshot!.serializedSchema).toBeDefined()
999
1004
  expect(snapshot!.isReadonly).toBe(false)
1005
+ expect(snapshot!.objectAccess).toBe('write')
1000
1006
  expect(snapshot!.presenceId).toBeDefined()
1001
1007
  expect(snapshot!.requiresLegacyRejection).toBe(false)
1002
1008
  expect(snapshot!.supportsStringAppend).toBe(true)
1003
1009
  })
1004
1010
 
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
+
1005
1040
  it('[SR13] includes presence record when present', () => {
1006
1041
  const store = getStore()
1007
1042
  store.ensureStoreIsUsable()
@@ -188,6 +188,7 @@ 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
191
192
  } = {}
192
193
  ) {
193
194
  const storage = new InMemorySyncStorage<TLRecord>({
@@ -199,6 +200,7 @@ function makeRoom(
199
200
  clientTimeout: opts.clientTimeout,
200
201
  log: opts.log,
201
202
  onPresenceChange: opts.onPresenceChange,
203
+ onCommittedChanges: opts.onCommittedChanges,
202
204
  })
203
205
  disposables.push(() => room.close())
204
206
  return { storage, room }
@@ -634,6 +636,29 @@ describe('22. Room construction (RC)', () => {
634
636
  expect(clientBMessages[0].data[0].type).toBe('patch')
635
637
  })
636
638
 
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
+
637
662
  it('[RC4] handles multiple rapid external changes', async () => {
638
663
  const { room, storage } = makeRoom()
639
664
  const socket = connectSession(room, 'test-session')