@tldraw/sync-core 5.3.0-internal.fba91ed55f6c → 5.3.0-next.030bf6014f04
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 +12 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/InMemorySyncStorage.js +11 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/SQLiteSyncStorage.js +15 -0
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +37 -21
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-esm/index.d.mts +12 -1
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/InMemorySyncStorage.mjs +11 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -0
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +37 -21
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/package.json +6 -6
- package/src/lib/InMemorySyncStorage.ts +12 -0
- package/src/lib/SQLiteSyncStorage.ts +16 -0
- package/src/lib/TLSyncRoom.ts +59 -28
- package/src/lib/TLSyncStorage.ts +7 -0
- package/src/test/objectStore.test.ts +50 -1
- package/src/test/storageContractSuite.ts +22 -0
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -155,8 +155,10 @@ export interface RoomSnapshot {
|
|
|
155
155
|
*/
|
|
156
156
|
export type TLRecordAuthorizer<Rec extends UnknownRecord, SessionMeta> = (
|
|
157
157
|
args: {
|
|
158
|
-
/** The session performing the write
|
|
159
|
-
|
|
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 }
|
|
160
162
|
} & (
|
|
161
163
|
| { type: 'create'; prev: null; next: Rec }
|
|
162
164
|
| { type: 'update'; prev: Rec; next: Rec }
|
|
@@ -1181,7 +1183,10 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1181
1183
|
changes: ActualChanges,
|
|
1182
1184
|
id: string,
|
|
1183
1185
|
_state: R,
|
|
1184
|
-
authorize?: (prev: R | null, next: R) => R | null
|
|
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
|
|
1185
1190
|
): Result<void, void> => {
|
|
1186
1191
|
const res = session
|
|
1187
1192
|
? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
|
|
@@ -1192,7 +1197,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1192
1197
|
let { value: state } = res
|
|
1193
1198
|
|
|
1194
1199
|
// Get the existing document, if any
|
|
1195
|
-
const doc =
|
|
1200
|
+
const doc =
|
|
1201
|
+
prevDoc !== undefined ? (prevDoc ?? undefined) : (storage.get(id) as R | undefined)
|
|
1196
1202
|
|
|
1197
1203
|
// Authorize on the up-migrated record; on create the authorizer's return is stored as-is,
|
|
1198
1204
|
// so no later migration can clobber stamped fields.
|
|
@@ -1229,10 +1235,12 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1229
1235
|
changes: ActualChanges,
|
|
1230
1236
|
id: string,
|
|
1231
1237
|
patch: ObjectDiff,
|
|
1232
|
-
authorize?: (prev: R, next: R) => R | null
|
|
1238
|
+
authorize?: (prev: R, next: R) => R | null,
|
|
1239
|
+
// The existing document if the caller already fetched it (see `addDocument`).
|
|
1240
|
+
prevDoc?: R
|
|
1233
1241
|
) => {
|
|
1234
1242
|
// if it was already deleted, there's no need to apply the patch
|
|
1235
|
-
const doc = storage.get(id) as R | undefined
|
|
1243
|
+
const doc = prevDoc ?? (storage.get(id) as R | undefined)
|
|
1236
1244
|
if (!doc) return
|
|
1237
1245
|
|
|
1238
1246
|
const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
|
|
@@ -1343,38 +1351,51 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1343
1351
|
)
|
|
1344
1352
|
}
|
|
1345
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
|
+
}
|
|
1346
1374
|
// Per-type authorizer: stamp/veto the write from the session's identity.
|
|
1347
1375
|
// Client pushes only; runs inside `addDocument`, on the up-migrated record.
|
|
1348
1376
|
let authorize: ((prev: R | null, next: R) => R | null) | undefined
|
|
1349
1377
|
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
1378
|
const authorizePut = this.authorizerFor(record.typeName)
|
|
1366
1379
|
if (authorizePut) {
|
|
1367
1380
|
authorize = (prevRec, next) => {
|
|
1368
1381
|
const result = authorizePut(
|
|
1369
1382
|
prevRec
|
|
1370
1383
|
? {
|
|
1371
|
-
session: {
|
|
1384
|
+
session: {
|
|
1385
|
+
sessionId: session.sessionId,
|
|
1386
|
+
isReadonly: session.isReadonly,
|
|
1387
|
+
meta: session.meta,
|
|
1388
|
+
},
|
|
1372
1389
|
type: 'update',
|
|
1373
1390
|
prev: prevRec,
|
|
1374
1391
|
next,
|
|
1375
1392
|
}
|
|
1376
1393
|
: {
|
|
1377
|
-
session: {
|
|
1394
|
+
session: {
|
|
1395
|
+
sessionId: session.sessionId,
|
|
1396
|
+
isReadonly: session.isReadonly,
|
|
1397
|
+
meta: session.meta,
|
|
1398
|
+
},
|
|
1378
1399
|
type: 'create',
|
|
1379
1400
|
prev: null,
|
|
1380
1401
|
next,
|
|
@@ -1395,7 +1416,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1395
1416
|
}
|
|
1396
1417
|
}
|
|
1397
1418
|
}
|
|
1398
|
-
|
|
1419
|
+
// The guard above already fetched this, so don't hit storage again.
|
|
1420
|
+
addDocument(txn, docChanges, id, record, authorize, prevRecord)
|
|
1399
1421
|
break
|
|
1400
1422
|
}
|
|
1401
1423
|
case RecordOpType.Patch: {
|
|
@@ -1409,7 +1431,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1409
1431
|
const authorize = authorizePatch
|
|
1410
1432
|
? (prev: R, next: R) => {
|
|
1411
1433
|
const result = authorizePatch({
|
|
1412
|
-
session: {
|
|
1434
|
+
session: {
|
|
1435
|
+
sessionId: session.sessionId,
|
|
1436
|
+
isReadonly: session.isReadonly,
|
|
1437
|
+
meta: session.meta,
|
|
1438
|
+
},
|
|
1413
1439
|
type: 'update',
|
|
1414
1440
|
prev,
|
|
1415
1441
|
next,
|
|
@@ -1426,8 +1452,9 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1426
1452
|
return result
|
|
1427
1453
|
}
|
|
1428
1454
|
: undefined
|
|
1429
|
-
// Try to patch the document. If it fails, stop here.
|
|
1430
|
-
|
|
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)
|
|
1431
1458
|
break
|
|
1432
1459
|
}
|
|
1433
1460
|
case RecordOpType.Remove: {
|
|
@@ -1443,7 +1470,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1443
1470
|
if (
|
|
1444
1471
|
authorizeRemove &&
|
|
1445
1472
|
!authorizeRemove({
|
|
1446
|
-
session: {
|
|
1473
|
+
session: {
|
|
1474
|
+
sessionId: session.sessionId,
|
|
1475
|
+
isReadonly: session.isReadonly,
|
|
1476
|
+
meta: session.meta,
|
|
1477
|
+
},
|
|
1447
1478
|
type: 'delete',
|
|
1448
1479
|
prev: doc,
|
|
1449
1480
|
next: null,
|
package/src/lib/TLSyncStorage.ts
CHANGED
|
@@ -92,6 +92,13 @@ export interface TLSyncStorage<R extends UnknownRecord> {
|
|
|
92
92
|
* `RoomSnapshot['documents']` so a lane can be persisted and re-seeded via a merged snapshot.
|
|
93
93
|
*/
|
|
94
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']
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
/**
|
|
@@ -85,7 +85,7 @@ function makeRoom(
|
|
|
85
85
|
onCommittedChanges?(args: { diff: any; documentClock: number }): void
|
|
86
86
|
authorizeRecord?: {
|
|
87
87
|
[typeName: string]: (args: {
|
|
88
|
-
session: { sessionId: string; meta: any }
|
|
88
|
+
session: { sessionId: string; isReadonly: boolean; meta: any }
|
|
89
89
|
type: 'create' | 'update' | 'delete'
|
|
90
90
|
prev: any
|
|
91
91
|
next: any
|
|
@@ -436,6 +436,26 @@ describe('object store lane', () => {
|
|
|
436
436
|
const storedNote = (storage: InMemorySyncStorage<R>, id: string) =>
|
|
437
437
|
storage.getObjectsSnapshot().find((d) => d.state.id === id)?.state as any
|
|
438
438
|
|
|
439
|
+
it('exposes the session isReadonly flag to the authorizer', () => {
|
|
440
|
+
const authorize = vi.fn(({ next }: any) => next)
|
|
441
|
+
const { room } = withNote(authorize)
|
|
442
|
+
// a canvas-readonly session can still write the object lane (objectAccess defaults to
|
|
443
|
+
// 'write'), which is exactly why authorizers need to see isReadonly independently
|
|
444
|
+
connectSession(room, 'reader', { isReadonly: true, meta: { userId: 'u' } })
|
|
445
|
+
|
|
446
|
+
const note = Note.create({ text: 'x' })
|
|
447
|
+
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
|
|
448
|
+
|
|
449
|
+
expect(authorize.mock.calls[0][0].session.isReadonly).toBe(true)
|
|
450
|
+
|
|
451
|
+
authorize.mockClear()
|
|
452
|
+
connectSession(room, 'writer', { isReadonly: false, meta: { userId: 'u' } })
|
|
453
|
+
const note2 = Note.create({ text: 'y' })
|
|
454
|
+
push(room, 'writer', { [note2.id]: [RecordOpType.Put, note2] })
|
|
455
|
+
|
|
456
|
+
expect(authorize.mock.calls[0][0].session.isReadonly).toBe(false)
|
|
457
|
+
})
|
|
458
|
+
|
|
439
459
|
it('stamps a created record from the session, overriding the client value', () => {
|
|
440
460
|
const { room, storage } = withNote(authorizeNote)
|
|
441
461
|
const socket = connectSession(room, 'alice', { meta: { userId: 'user-alice' } })
|
|
@@ -561,6 +581,35 @@ describe('object store lane', () => {
|
|
|
561
581
|
expect(storedNote(storage, note.id)?.typeName).toBe('note')
|
|
562
582
|
})
|
|
563
583
|
|
|
584
|
+
// The typeName guard must not depend on authorizers being configured: the write gate keys
|
|
585
|
+
// off the *incoming* typeName, so relabeling an existing object-lane record as a document
|
|
586
|
+
// type would otherwise let an objectAccess:'read' session write through the document lane.
|
|
587
|
+
it('vetoes a typeName swap even with no authorizeRecord configured', () => {
|
|
588
|
+
const note = Note.create({ text: 'by:user-alice' })
|
|
589
|
+
const { room, storage } = makeRoom({
|
|
590
|
+
objectTypes: ['note'],
|
|
591
|
+
snapshot: {
|
|
592
|
+
documents: [{ state: note, lastChangedClock: 0 }],
|
|
593
|
+
clock: 0,
|
|
594
|
+
documentClock: 0,
|
|
595
|
+
schema: schema.serialize(),
|
|
596
|
+
},
|
|
597
|
+
})
|
|
598
|
+
// denied on the object lane, but allowed on the document lane
|
|
599
|
+
const socket = connectSession(room, 'mallory', {
|
|
600
|
+
objectAccess: 'read',
|
|
601
|
+
meta: { userId: 'user-mallory' },
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
push(room, 'mallory', {
|
|
605
|
+
[note.id]: [RecordOpType.Put, { id: note.id, typeName: 'doc', title: 'swapped' } as any],
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
|
|
609
|
+
expect(storedNote(storage, note.id)?.typeName).toBe('note')
|
|
610
|
+
expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
|
|
611
|
+
})
|
|
612
|
+
|
|
564
613
|
it('vetoes a put over an existing record that changes an immutable field', () => {
|
|
565
614
|
const { room, storage, note } = seededWithNote(authorizeNote)
|
|
566
615
|
const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
|
|
@@ -128,6 +128,28 @@ export function registerStorageContractTests(factory: StorageContractFactory) {
|
|
|
128
128
|
})
|
|
129
129
|
})
|
|
130
130
|
|
|
131
|
+
it('returns targeted object-lane entries by id from getObjectsByIds', () => {
|
|
132
|
+
const storage = createPartitioned(makeContractSnapshot(contractRecords))
|
|
133
|
+
const newPage = makePage('lane')
|
|
134
|
+
storage.transaction((txn) => txn.set(newPage.id, newPage))
|
|
135
|
+
|
|
136
|
+
const pageId = contractRecords[1].id
|
|
137
|
+
const entries = storage.getObjectsByIds!([newPage.id, pageId])
|
|
138
|
+
expect(entries.map((d) => d.state.id).sort()).toEqual([newPage.id, pageId].sort())
|
|
139
|
+
// same entry shape as getObjectsSnapshot: state + lastChangedClock
|
|
140
|
+
const laneEntry = entries.find((d) => d.state.id === newPage.id)!
|
|
141
|
+
expect(laneEntry.state).toEqual(newPage)
|
|
142
|
+
expect(laneEntry.lastChangedClock).toBe(storage.getClock())
|
|
143
|
+
|
|
144
|
+
// unknown ids and document-lane ids are omitted; duplicates yield one entry
|
|
145
|
+
expect(storage.getObjectsByIds!(['page:nope', TLDOCUMENT_ID])).toEqual([])
|
|
146
|
+
expect(storage.getObjectsByIds!([newPage.id, newPage.id]).length).toBe(1)
|
|
147
|
+
|
|
148
|
+
// a deleted record stops being returned
|
|
149
|
+
storage.transaction((txn) => txn.delete(newPage.id))
|
|
150
|
+
expect(storage.getObjectsByIds!([newPage.id])).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
131
153
|
it('round-trips through a merged snapshot (persist lanes separately, re-seed together)', () => {
|
|
132
154
|
const storage = createPartitioned(makeContractSnapshot(contractRecords))
|
|
133
155
|
const merged = {
|