@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.
- package/dist-cjs/index.d.ts +3 -154
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +20 -55
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +52 -115
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +2 -27
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +1 -6
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +10 -160
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-esm/index.d.mts +3 -154
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +20 -55
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +52 -115
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +2 -27
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +1 -6
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +10 -160
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/index.ts +0 -3
- package/src/lib/InMemorySyncStorage.ts +21 -74
- package/src/lib/RoomSession.ts +2 -7
- package/src/lib/SQLiteSyncStorage.test.ts +2 -29
- package/src/lib/SQLiteSyncStorage.ts +59 -158
- package/src/lib/TLSocketRoom.ts +3 -61
- package/src/lib/TLSyncClient.test.ts +2 -9
- package/src/lib/TLSyncClient.ts +3 -15
- package/src/lib/TLSyncRoom.ts +10 -294
- package/src/lib/TLSyncStorage.ts +0 -8
- package/src/lib/protocol.ts +0 -17
- package/src/test/TLSocketRoom.test.ts +2 -37
- package/src/test/TLSyncRoom.test.ts +0 -25
- package/src/test/TestServer.ts +4 -14
- package/src/test/storageContractSuite.ts +0 -79
- package/src/test/upgradeDowngrade.test.ts +2 -144
- package/src/test/objectStore.test.ts +0 -630
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -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'
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
1179
|
+
patchDocument(txn, docChanges, id, op[1])
|
|
1431
1180
|
break
|
|
1432
1181
|
}
|
|
1433
1182
|
case RecordOpType.Remove: {
|
|
1434
|
-
const doc = txn.get(id)
|
|
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
|
/**
|
package/src/lib/TLSyncStorage.ts
CHANGED
|
@@ -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
|
/**
|
package/src/lib/protocol.ts
CHANGED
|
@@ -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
|
|
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')
|
package/src/test/TestServer.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
37
|
-
objectAccess: opts?.objectAccess,
|
|
27
|
+
isReadonly: false,
|
|
38
28
|
})
|
|
39
29
|
|
|
40
30
|
socketPair.clientSocket.connectionStatus = 'online'
|