@tldraw/sync-core 5.3.0-internal.d205573d66cb → 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 +89 -0
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- 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/TLSocketRoom.js +1 -0
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +135 -7
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-esm/index.d.mts +89 -0
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- 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/TLSocketRoom.mjs +1 -0
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +135 -7
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/package.json +6 -6
- package/src/index.ts +2 -0
- package/src/lib/InMemorySyncStorage.ts +12 -0
- package/src/lib/SQLiteSyncStorage.ts +16 -0
- package/src/lib/TLSocketRoom.ts +8 -1
- package/src/lib/TLSyncRoom.ts +252 -9
- package/src/lib/TLSyncStorage.ts +7 -0
- package/src/test/objectStore.test.ts +293 -1
- package/src/test/storageContractSuite.ts +22 -0
- package/src/test/upgradeDowngrade.test.ts +97 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/TLSyncStorage.ts"],
|
|
4
|
-
"sourcesContent": ["import { StoreSchema, SynchronousStorage, UnknownRecord } from '@tldraw/store'\nimport { assert, isEqual, objectMapEntriesIterable, objectMapValues } from '@tldraw/utils'\nimport { TLStoreSnapshot } from 'tldraw'\nimport { diffRecord, NetworkDiff, RecordOpType } from './diff'\nimport { RoomSnapshot } from './TLSyncRoom'\n\n/**\n * Transaction interface for storage operations. Provides methods to read and modify\n * documents, tombstones, and metadata within a transaction.\n *\n * @public\n */\nexport interface TLSyncStorageTransaction<R extends UnknownRecord> extends SynchronousStorage<R> {\n\t/**\n\t * Get the current clock value.\n\t * If the clock has incremented during the transaction,\n\t * the incremented value will be returned.\n\t *\n\t * @returns The current clock value\n\t */\n\tgetClock(): number\n\n\t/**\n\t * Get all changes (document updates and deletions) since a given clock time.\n\t * This is the main method for calculating diffs for client sync.\n\t *\n\t * @param sinceClock - The clock time to get changes since\n\t * @returns Changes since the specified clock time\n\t */\n\tgetChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined\n}\n\n/**\n * Options for a transaction.\n * @public\n */\nexport interface TLSyncStorageTransactionOptions {\n\t/**\n\t * Use this if you need to identify the transaction for logging or debugging purposes\n\t * or for ignoring certain changes in onChange callbacks\n\t */\n\tid?: string\n\t/**\n\t * Controls when the storage layer should emit the actual changes that occurred during the transaction.\n\t *\n\t * - `'always'` - Always emit the changes, regardless of whether they were applied verbatim\n\t * - `'when-different'` - Only emit changes if the storage layer modified/embellished the records\n\t * (e.g., added server timestamps, normalized data, etc.)\n\t *\n\t * When changes are emitted, they will be available in the `changes` field of the transaction result.\n\t * This is useful when the storage layer may transform records and the caller needs to know\n\t * what actually changed rather than what was requested.\n\t */\n\temitChanges?: 'always' | 'when-different'\n}\n\n/**\n * Callback type for a transaction.\n * The conditional return type ensures that the callback is synchronous.\n * @public\n */\nexport type TLSyncStorageTransactionCallback<R extends UnknownRecord, T> = (\n\ttxn: TLSyncStorageTransaction<R>\n) => T extends Promise<any>\n\t? {\n\t\t\t__error: 'Transaction callbacks cannot be async. Use synchronous operations only.'\n\t\t}\n\t: T\n\n/**\n * Pluggable synchronous transactional storage layer for TLSyncRoom.\n * Provides methods for managing documents, tombstones, and clocks within transactions.\n *\n * @public\n */\nexport interface TLSyncStorage<R extends UnknownRecord> {\n\ttransaction<T>(\n\t\tcallback: TLSyncStorageTransactionCallback<R, T>,\n\t\topts?: TLSyncStorageTransactionOptions\n\t): TLSyncStorageTransactionResult<T, R>\n\n\tgetClock(): number\n\n\tonChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void\n\n\t/** A snapshot of the document lane only \u2014 never contains object-store lane records. */\n\tgetSnapshot?(): RoomSnapshot\n\n\t/**\n\t * A snapshot of the object-store lane (see `objectTypes` on the storage), for hosts that\n\t * persist object-lane records separately from the document. Same entry shape as\n\t * `RoomSnapshot['documents']` so a lane can be persisted and re-seeded via a merged snapshot.\n\t */\n\tgetObjectsSnapshot?(): RoomSnapshot['documents']\n}\n\n/**\n * Properties passed to the onChange callback.\n * @public\n */\nexport interface TLSyncStorageOnChangeCallbackProps {\n\t/**\n\t * The ID of the transaction that caused the change.\n\t * This is useful for ignoring certain changes in onChange callbacks.\n\t */\n\tid?: string\n\tdocumentClock: number\n}\n\n/**\n * Result returned from a storage transaction.\n * @public\n */\nexport interface TLSyncStorageTransactionResult<T, R extends UnknownRecord = UnknownRecord> {\n\tdocumentClock: number\n\tdidChange: boolean\n\tresult: T\n\t/**\n\t * The actual changes that occurred during the transaction, if requested via `emitChanges` option.\n\t * This is a RecordsDiff where:\n\t * - `added` contains records that were put (we don't have \"from\" state for emitted changes)\n\t * - `removed` contains records that were deleted (with placeholder values since we only have IDs)\n\t * - `updated` is empty (emitted changes don't track before/after pairs)\n\t *\n\t * Only populated when:\n\t * - `emitChanges: 'always'` was specified, or\n\t * - `emitChanges: 'when-different'` was specified and the storage layer modified records\n\t */\n\tchanges?: TLSyncForwardDiff<R>\n}\n\n/**\n * Respresents a diff of puts and deletes.\n * @public\n */\nexport interface TLSyncForwardDiff<R extends UnknownRecord> {\n\tputs: Record<string, R | [before: R, after: R]>\n\tdeletes: string[]\n}\n\n/**\n * @internal\n */\nexport function toNetworkDiff<R extends UnknownRecord>(diff: TLSyncForwardDiff<R>): NetworkDiff<R> {\n\tconst networkDiff: NetworkDiff<R> = {}\n\tfor (const [id, put] of objectMapEntriesIterable(diff.puts)) {\n\t\tif (Array.isArray(put)) {\n\t\t\tconst patch = diffRecord(put[0], put[1])\n\t\t\tif (patch) {\n\t\t\t\tnetworkDiff[id] = [RecordOpType.Patch, patch]\n\t\t\t}\n\t\t} else {\n\t\t\tnetworkDiff[id] = [RecordOpType.Put, put]\n\t\t}\n\t}\n\tfor (const id of diff.deletes) {\n\t\tnetworkDiff[id] = [RecordOpType.Remove]\n\t}\n\treturn networkDiff\n}\n\n/**\n * Result returned from getChangesSince, containing all changes since a given clock time.\n * @public\n */\nexport interface TLSyncStorageGetChangesSinceResult<R extends UnknownRecord> {\n\t/**\n\t * The changes as a TLSyncForwardDiff.\n\t */\n\tdiff: TLSyncForwardDiff<R>\n\t/**\n\t * If true, the client should wipe all local data and replace with the server's state.\n\t * This happens when the client's clock is too old and we've lost tombstone history.\n\t */\n\twipeAll: boolean\n}\n\n/**\n * Loads a snapshot into storage during a transaction.\n * Migrates the snapshot to the current schema and loads it into storage.\n *\n * @public\n * @param txn - The transaction to load the snapshot into\n * @param schema - The current schema\n * @param snapshot - The snapshot to load\n */\nexport function loadSnapshotIntoStorage<R extends UnknownRecord>(\n\ttxn: TLSyncStorageTransaction<R>,\n\tschema: StoreSchema<R, any>,\n\tsnapshot: RoomSnapshot | TLStoreSnapshot\n) {\n\tsnapshot = convertStoreSnapshotToRoomSnapshot(snapshot)\n\tassert(snapshot.schema, 'Schema is required')\n\tconst docIds = new Set<string>()\n\tfor (const doc of snapshot.documents) {\n\t\tdocIds.add(doc.state.id)\n\t\tconst existing = txn.get(doc.state.id)\n\t\tif (isEqual(existing, doc.state)) continue\n\t\ttxn.set(doc.state.id, doc.state as R)\n\t}\n\tfor (const id of txn.keys()) {\n\t\tif (!docIds.has(id)) {\n\t\t\ttxn.delete(id)\n\t\t}\n\t}\n\ttxn.setSchema(snapshot.schema)\n\tschema.migrateStorage(txn)\n}\n\nexport function convertStoreSnapshotToRoomSnapshot(\n\tsnapshot: RoomSnapshot | TLStoreSnapshot\n): RoomSnapshot {\n\tif ('documents' in snapshot) return snapshot\n\treturn {\n\t\tclock: 0,\n\t\tdocumentClock: 0,\n\t\tdocuments: objectMapValues(snapshot.store).map((state) => ({\n\t\t\tstate,\n\t\t\tlastChangedClock: 0,\n\t\t})),\n\t\tschema: snapshot.schema,\n\t\ttombstones: {},\n\t}\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,QAAQ,SAAS,0BAA0B,uBAAuB;AAE3E,SAAS,YAAyB,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { StoreSchema, SynchronousStorage, UnknownRecord } from '@tldraw/store'\nimport { assert, isEqual, objectMapEntriesIterable, objectMapValues } from '@tldraw/utils'\nimport { TLStoreSnapshot } from 'tldraw'\nimport { diffRecord, NetworkDiff, RecordOpType } from './diff'\nimport { RoomSnapshot } from './TLSyncRoom'\n\n/**\n * Transaction interface for storage operations. Provides methods to read and modify\n * documents, tombstones, and metadata within a transaction.\n *\n * @public\n */\nexport interface TLSyncStorageTransaction<R extends UnknownRecord> extends SynchronousStorage<R> {\n\t/**\n\t * Get the current clock value.\n\t * If the clock has incremented during the transaction,\n\t * the incremented value will be returned.\n\t *\n\t * @returns The current clock value\n\t */\n\tgetClock(): number\n\n\t/**\n\t * Get all changes (document updates and deletions) since a given clock time.\n\t * This is the main method for calculating diffs for client sync.\n\t *\n\t * @param sinceClock - The clock time to get changes since\n\t * @returns Changes since the specified clock time\n\t */\n\tgetChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined\n}\n\n/**\n * Options for a transaction.\n * @public\n */\nexport interface TLSyncStorageTransactionOptions {\n\t/**\n\t * Use this if you need to identify the transaction for logging or debugging purposes\n\t * or for ignoring certain changes in onChange callbacks\n\t */\n\tid?: string\n\t/**\n\t * Controls when the storage layer should emit the actual changes that occurred during the transaction.\n\t *\n\t * - `'always'` - Always emit the changes, regardless of whether they were applied verbatim\n\t * - `'when-different'` - Only emit changes if the storage layer modified/embellished the records\n\t * (e.g., added server timestamps, normalized data, etc.)\n\t *\n\t * When changes are emitted, they will be available in the `changes` field of the transaction result.\n\t * This is useful when the storage layer may transform records and the caller needs to know\n\t * what actually changed rather than what was requested.\n\t */\n\temitChanges?: 'always' | 'when-different'\n}\n\n/**\n * Callback type for a transaction.\n * The conditional return type ensures that the callback is synchronous.\n * @public\n */\nexport type TLSyncStorageTransactionCallback<R extends UnknownRecord, T> = (\n\ttxn: TLSyncStorageTransaction<R>\n) => T extends Promise<any>\n\t? {\n\t\t\t__error: 'Transaction callbacks cannot be async. Use synchronous operations only.'\n\t\t}\n\t: T\n\n/**\n * Pluggable synchronous transactional storage layer for TLSyncRoom.\n * Provides methods for managing documents, tombstones, and clocks within transactions.\n *\n * @public\n */\nexport interface TLSyncStorage<R extends UnknownRecord> {\n\ttransaction<T>(\n\t\tcallback: TLSyncStorageTransactionCallback<R, T>,\n\t\topts?: TLSyncStorageTransactionOptions\n\t): TLSyncStorageTransactionResult<T, R>\n\n\tgetClock(): number\n\n\tonChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void\n\n\t/** A snapshot of the document lane only \u2014 never contains object-store lane records. */\n\tgetSnapshot?(): RoomSnapshot\n\n\t/**\n\t * A snapshot of the object-store lane (see `objectTypes` on the storage), for hosts that\n\t * persist object-lane records separately from the document. Same entry shape as\n\t * `RoomSnapshot['documents']` so a lane can be persisted and re-seeded via a merged snapshot.\n\t */\n\tgetObjectsSnapshot?(): RoomSnapshot['documents']\n\n\t/**\n\t * The same entries for the given ids, for callers that need a handful of records rather than the\n\t * whole (parsed) lane. Ids with no object-lane record are omitted \u2014 document-lane ids included \u2014\n\t * and duplicates yield one entry.\n\t */\n\tgetObjectsByIds?(ids: Iterable<string>): RoomSnapshot['documents']\n}\n\n/**\n * Properties passed to the onChange callback.\n * @public\n */\nexport interface TLSyncStorageOnChangeCallbackProps {\n\t/**\n\t * The ID of the transaction that caused the change.\n\t * This is useful for ignoring certain changes in onChange callbacks.\n\t */\n\tid?: string\n\tdocumentClock: number\n}\n\n/**\n * Result returned from a storage transaction.\n * @public\n */\nexport interface TLSyncStorageTransactionResult<T, R extends UnknownRecord = UnknownRecord> {\n\tdocumentClock: number\n\tdidChange: boolean\n\tresult: T\n\t/**\n\t * The actual changes that occurred during the transaction, if requested via `emitChanges` option.\n\t * This is a RecordsDiff where:\n\t * - `added` contains records that were put (we don't have \"from\" state for emitted changes)\n\t * - `removed` contains records that were deleted (with placeholder values since we only have IDs)\n\t * - `updated` is empty (emitted changes don't track before/after pairs)\n\t *\n\t * Only populated when:\n\t * - `emitChanges: 'always'` was specified, or\n\t * - `emitChanges: 'when-different'` was specified and the storage layer modified records\n\t */\n\tchanges?: TLSyncForwardDiff<R>\n}\n\n/**\n * Respresents a diff of puts and deletes.\n * @public\n */\nexport interface TLSyncForwardDiff<R extends UnknownRecord> {\n\tputs: Record<string, R | [before: R, after: R]>\n\tdeletes: string[]\n}\n\n/**\n * @internal\n */\nexport function toNetworkDiff<R extends UnknownRecord>(diff: TLSyncForwardDiff<R>): NetworkDiff<R> {\n\tconst networkDiff: NetworkDiff<R> = {}\n\tfor (const [id, put] of objectMapEntriesIterable(diff.puts)) {\n\t\tif (Array.isArray(put)) {\n\t\t\tconst patch = diffRecord(put[0], put[1])\n\t\t\tif (patch) {\n\t\t\t\tnetworkDiff[id] = [RecordOpType.Patch, patch]\n\t\t\t}\n\t\t} else {\n\t\t\tnetworkDiff[id] = [RecordOpType.Put, put]\n\t\t}\n\t}\n\tfor (const id of diff.deletes) {\n\t\tnetworkDiff[id] = [RecordOpType.Remove]\n\t}\n\treturn networkDiff\n}\n\n/**\n * Result returned from getChangesSince, containing all changes since a given clock time.\n * @public\n */\nexport interface TLSyncStorageGetChangesSinceResult<R extends UnknownRecord> {\n\t/**\n\t * The changes as a TLSyncForwardDiff.\n\t */\n\tdiff: TLSyncForwardDiff<R>\n\t/**\n\t * If true, the client should wipe all local data and replace with the server's state.\n\t * This happens when the client's clock is too old and we've lost tombstone history.\n\t */\n\twipeAll: boolean\n}\n\n/**\n * Loads a snapshot into storage during a transaction.\n * Migrates the snapshot to the current schema and loads it into storage.\n *\n * @public\n * @param txn - The transaction to load the snapshot into\n * @param schema - The current schema\n * @param snapshot - The snapshot to load\n */\nexport function loadSnapshotIntoStorage<R extends UnknownRecord>(\n\ttxn: TLSyncStorageTransaction<R>,\n\tschema: StoreSchema<R, any>,\n\tsnapshot: RoomSnapshot | TLStoreSnapshot\n) {\n\tsnapshot = convertStoreSnapshotToRoomSnapshot(snapshot)\n\tassert(snapshot.schema, 'Schema is required')\n\tconst docIds = new Set<string>()\n\tfor (const doc of snapshot.documents) {\n\t\tdocIds.add(doc.state.id)\n\t\tconst existing = txn.get(doc.state.id)\n\t\tif (isEqual(existing, doc.state)) continue\n\t\ttxn.set(doc.state.id, doc.state as R)\n\t}\n\tfor (const id of txn.keys()) {\n\t\tif (!docIds.has(id)) {\n\t\t\ttxn.delete(id)\n\t\t}\n\t}\n\ttxn.setSchema(snapshot.schema)\n\tschema.migrateStorage(txn)\n}\n\nexport function convertStoreSnapshotToRoomSnapshot(\n\tsnapshot: RoomSnapshot | TLStoreSnapshot\n): RoomSnapshot {\n\tif ('documents' in snapshot) return snapshot\n\treturn {\n\t\tclock: 0,\n\t\tdocumentClock: 0,\n\t\tdocuments: objectMapValues(snapshot.store).map((state) => ({\n\t\t\tstate,\n\t\t\tlastChangedClock: 0,\n\t\t})),\n\t\tschema: snapshot.schema,\n\t\ttombstones: {},\n\t}\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,QAAQ,SAAS,0BAA0B,uBAAuB;AAE3E,SAAS,YAAyB,oBAAoB;AAmJ/C,SAAS,cAAuC,MAA4C;AAClG,QAAM,cAA8B,CAAC;AACrC,aAAW,CAAC,IAAI,GAAG,KAAK,yBAAyB,KAAK,IAAI,GAAG;AAC5D,QAAI,MAAM,QAAQ,GAAG,GAAG;AACvB,YAAM,QAAQ,WAAW,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC;AACvC,UAAI,OAAO;AACV,oBAAY,EAAE,IAAI,CAAC,aAAa,OAAO,KAAK;AAAA,MAC7C;AAAA,IACD,OAAO;AACN,kBAAY,EAAE,IAAI,CAAC,aAAa,KAAK,GAAG;AAAA,IACzC;AAAA,EACD;AACA,aAAW,MAAM,KAAK,SAAS;AAC9B,gBAAY,EAAE,IAAI,CAAC,aAAa,MAAM;AAAA,EACvC;AACA,SAAO;AACR;AA2BO,SAAS,wBACf,KACA,QACA,UACC;AACD,aAAW,mCAAmC,QAAQ;AACtD,SAAO,SAAS,QAAQ,oBAAoB;AAC5C,QAAM,SAAS,oBAAI,IAAY;AAC/B,aAAW,OAAO,SAAS,WAAW;AACrC,WAAO,IAAI,IAAI,MAAM,EAAE;AACvB,UAAM,WAAW,IAAI,IAAI,IAAI,MAAM,EAAE;AACrC,QAAI,QAAQ,UAAU,IAAI,KAAK,EAAG;AAClC,QAAI,IAAI,IAAI,MAAM,IAAI,IAAI,KAAU;AAAA,EACrC;AACA,aAAW,MAAM,IAAI,KAAK,GAAG;AAC5B,QAAI,CAAC,OAAO,IAAI,EAAE,GAAG;AACpB,UAAI,OAAO,EAAE;AAAA,IACd;AAAA,EACD;AACA,MAAI,UAAU,SAAS,MAAM;AAC7B,SAAO,eAAe,GAAG;AAC1B;AAEO,SAAS,mCACf,UACe;AACf,MAAI,eAAe,SAAU,QAAO;AACpC,SAAO;AAAA,IACN,OAAO;AAAA,IACP,eAAe;AAAA,IACf,WAAW,gBAAgB,SAAS,KAAK,EAAE,IAAI,CAAC,WAAW;AAAA,MAC1D;AAAA,MACA,kBAAkB;AAAA,IACnB,EAAE;AAAA,IACF,QAAQ,SAAS;AAAA,IACjB,YAAY,CAAC;AAAA,EACd;AACD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/sync-core",
|
|
3
3
|
"description": "tldraw infinite canvas SDK (multiplayer sync).",
|
|
4
|
-
"version": "5.3.0-
|
|
4
|
+
"version": "5.3.0-next.030bf6014f04",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw GB Ltd.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -49,17 +49,17 @@
|
|
|
49
49
|
"@types/uuid-readable": "^0.0.3",
|
|
50
50
|
"react": "^19.2.1",
|
|
51
51
|
"react-dom": "^19.2.1",
|
|
52
|
-
"tldraw": "5.3.0-
|
|
52
|
+
"tldraw": "5.3.0-next.030bf6014f04",
|
|
53
53
|
"typescript": "^5.8.3",
|
|
54
54
|
"uuid-by-string": "^4.0.0",
|
|
55
55
|
"uuid-readable": "^0.0.2",
|
|
56
56
|
"vitest": "^4.1.7"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@tldraw/state": "5.3.0-
|
|
60
|
-
"@tldraw/store": "5.3.0-
|
|
61
|
-
"@tldraw/tlschema": "5.3.0-
|
|
62
|
-
"@tldraw/utils": "5.3.0-
|
|
59
|
+
"@tldraw/state": "5.3.0-next.030bf6014f04",
|
|
60
|
+
"@tldraw/store": "5.3.0-next.030bf6014f04",
|
|
61
|
+
"@tldraw/tlschema": "5.3.0-next.030bf6014f04",
|
|
62
|
+
"@tldraw/utils": "5.3.0-next.030bf6014f04",
|
|
63
63
|
"nanoevents": "^7.0.1",
|
|
64
64
|
"ws": "^8.18.0"
|
|
65
65
|
},
|
package/src/index.ts
CHANGED
|
@@ -287,6 +287,18 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
|
|
|
287
287
|
getObjectsSnapshot(): RoomSnapshot['documents'] {
|
|
288
288
|
return Array.from(this.objects.values())
|
|
289
289
|
}
|
|
290
|
+
|
|
291
|
+
getObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {
|
|
292
|
+
const result: RoomSnapshot['documents'] = []
|
|
293
|
+
const seen = new Set<string>()
|
|
294
|
+
for (const id of ids) {
|
|
295
|
+
if (seen.has(id)) continue
|
|
296
|
+
seen.add(id)
|
|
297
|
+
const doc = this.objects.get(id)
|
|
298
|
+
if (doc) result.push(doc)
|
|
299
|
+
}
|
|
300
|
+
return result
|
|
301
|
+
}
|
|
290
302
|
}
|
|
291
303
|
|
|
292
304
|
/**
|
|
@@ -311,6 +311,9 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
|
|
|
311
311
|
get: this.sql.prepare<{ state: Uint8Array }, [id: string]>(
|
|
312
312
|
`SELECT state FROM ${table} WHERE id = ?`
|
|
313
313
|
),
|
|
314
|
+
getWithClock: this.sql.prepare<{ state: Uint8Array; lastChangedClock: number }, [id: string]>(
|
|
315
|
+
`SELECT state, lastChangedClock FROM ${table} WHERE id = ?`
|
|
316
|
+
),
|
|
314
317
|
insert: this.sql.prepare<void, [id: string, state: Uint8Array, lastChangedClock: number]>(
|
|
315
318
|
`INSERT OR REPLACE INTO ${table} (id, state, lastChangedClock) VALUES (?, ?, ?)`
|
|
316
319
|
),
|
|
@@ -545,6 +548,19 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
|
|
|
545
548
|
return Array.from(this._iterateRecords(this.stmts.objects))
|
|
546
549
|
}
|
|
547
550
|
|
|
551
|
+
getObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {
|
|
552
|
+
const result: RoomSnapshot['documents'] = []
|
|
553
|
+
const seen = new Set<string>()
|
|
554
|
+
for (const id of ids) {
|
|
555
|
+
if (seen.has(id)) continue
|
|
556
|
+
seen.add(id)
|
|
557
|
+
const row = this.stmts.objects.getWithClock.all(id)[0]
|
|
558
|
+
if (!row) continue
|
|
559
|
+
result.push({ state: decodeState<R>(row.state), lastChangedClock: row.lastChangedClock })
|
|
560
|
+
}
|
|
561
|
+
return result
|
|
562
|
+
}
|
|
563
|
+
|
|
548
564
|
private *_iterateRecords(
|
|
549
565
|
table: SQLiteSyncStorage<R>['stmts']['documents']
|
|
550
566
|
): IterableIterator<{ state: R; lastChangedClock: number }> {
|
package/src/lib/TLSocketRoom.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { TLObjectStoreAccess, TLSocketServerSentEvent } from './protocol'
|
|
|
7
7
|
import { RoomSessionState } from './RoomSession'
|
|
8
8
|
import { ServerSocketAdapter, WebSocketMinimal } from './ServerSocketAdapter'
|
|
9
9
|
import { TLSyncErrorCloseEventReason } from './TLSyncClient'
|
|
10
|
-
import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom'
|
|
10
|
+
import { RoomSnapshot, TLRecordAuthorizers, TLSyncRoom } from './TLSyncRoom'
|
|
11
11
|
import {
|
|
12
12
|
convertStoreSnapshotToRoomSnapshot,
|
|
13
13
|
loadSnapshotIntoStorage,
|
|
@@ -144,6 +144,12 @@ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
|
|
|
144
144
|
* `isReadonly`, so e.g. a session can be allowed to comment without being allowed to edit.
|
|
145
145
|
*/
|
|
146
146
|
objectTypes?: readonly string[]
|
|
147
|
+
/**
|
|
148
|
+
* Per-type authorizers for client record writes (create, update, delete): veto writes the
|
|
149
|
+
* session isn't allowed to make, or rewrite the record on create — e.g. force a comment's
|
|
150
|
+
* `authorId` to the signed-in user. See {@link TLRecordAuthorizers}.
|
|
151
|
+
*/
|
|
152
|
+
authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
|
|
147
153
|
/**
|
|
148
154
|
* When set, the room will call {@link TLSocketRoom.getSessionSnapshot} after
|
|
149
155
|
* no message activity for a session for 5s and pass the result to this callback.
|
|
@@ -271,6 +277,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
271
277
|
onPresenceChange: opts.onPresenceChange,
|
|
272
278
|
onCommittedChanges: opts.onCommittedChanges,
|
|
273
279
|
objectTypes: opts.objectTypes,
|
|
280
|
+
authorizeRecord: opts.authorizeRecord,
|
|
274
281
|
schema: opts.schema ?? (createTLSchema() as any),
|
|
275
282
|
log: opts.log,
|
|
276
283
|
storage,
|
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -121,6 +121,69 @@ export interface RoomSnapshot {
|
|
|
121
121
|
schema?: SerializedSchema
|
|
122
122
|
}
|
|
123
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
|
+
|
|
124
187
|
/**
|
|
125
188
|
* A collaborative workspace that manages multiple client sessions and synchronizes
|
|
126
189
|
* document changes between them. The room serves as the authoritative source for
|
|
@@ -248,8 +311,33 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
248
311
|
public readonly schema: StoreSchema<R, any>
|
|
249
312
|
private onPresenceChange?(): void
|
|
250
313
|
private onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
|
|
314
|
+
private readonly authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
|
|
251
315
|
private readonly sessionIdleTimeout: number
|
|
252
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
|
+
|
|
253
341
|
constructor(opts: {
|
|
254
342
|
log?: TLSyncLog
|
|
255
343
|
schema: StoreSchema<R, any>
|
|
@@ -267,6 +355,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
267
355
|
* gated per session by `objectAccess` rather than `isReadonly`.
|
|
268
356
|
*/
|
|
269
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>
|
|
270
363
|
storage: TLSyncStorage<R>
|
|
271
364
|
clientTimeout?: number
|
|
272
365
|
}) {
|
|
@@ -274,6 +367,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
274
367
|
this.log = opts.log
|
|
275
368
|
this.onPresenceChange = opts.onPresenceChange
|
|
276
369
|
this.onCommittedChanges = opts.onCommittedChanges
|
|
370
|
+
this.authorizeRecord = opts.authorizeRecord
|
|
277
371
|
this.storage = opts.storage
|
|
278
372
|
this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
|
|
279
373
|
|
|
@@ -315,6 +409,15 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
315
409
|
|
|
316
410
|
this.presenceType = presenceTypes.values().next()?.value ?? null
|
|
317
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
|
+
|
|
318
421
|
const { documentClock } = this.storage.transaction((txn) => {
|
|
319
422
|
this.schema.migrateStorage(txn)
|
|
320
423
|
})
|
|
@@ -1079,7 +1182,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1079
1182
|
storage: MinimalDocStore<R>,
|
|
1080
1183
|
changes: ActualChanges,
|
|
1081
1184
|
id: string,
|
|
1082
|
-
_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
|
|
1083
1190
|
): Result<void, void> => {
|
|
1084
1191
|
const res = session
|
|
1085
1192
|
? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
|
|
@@ -1087,10 +1194,19 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1087
1194
|
if (res.type === 'error') {
|
|
1088
1195
|
throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
1089
1196
|
}
|
|
1090
|
-
|
|
1197
|
+
let { value: state } = res
|
|
1091
1198
|
|
|
1092
1199
|
// Get the existing document, if any
|
|
1093
|
-
const doc =
|
|
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
|
+
}
|
|
1094
1210
|
|
|
1095
1211
|
if (doc) {
|
|
1096
1212
|
// If there's an existing document, replace it with the new state
|
|
@@ -1118,10 +1234,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1118
1234
|
storage: MinimalDocStore<R>,
|
|
1119
1235
|
changes: ActualChanges,
|
|
1120
1236
|
id: string,
|
|
1121
|
-
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
|
|
1122
1241
|
) => {
|
|
1123
1242
|
// if it was already deleted, there's no need to apply the patch
|
|
1124
|
-
const doc = storage.get(id) as R | undefined
|
|
1243
|
+
const doc = prevDoc ?? (storage.get(id) as R | undefined)
|
|
1125
1244
|
if (!doc) return
|
|
1126
1245
|
|
|
1127
1246
|
const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
|
|
@@ -1138,6 +1257,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1138
1257
|
// If the versions are compatible, apply the patch and propagate the patch op
|
|
1139
1258
|
const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode)
|
|
1140
1259
|
if (diff) {
|
|
1260
|
+
// Authorize on the committed candidate — the record that will actually be stored.
|
|
1261
|
+
if (authorize && !authorize(doc, diff[1])) return
|
|
1141
1262
|
storage.set(id, diff[1])
|
|
1142
1263
|
propagateOp(changes, id, [RecordOpType.Patch, diff[0]], doc, diff[1])
|
|
1143
1264
|
}
|
|
@@ -1157,6 +1278,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1157
1278
|
// replace the state with the upgraded version and propagate the patch op
|
|
1158
1279
|
const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode)
|
|
1159
1280
|
if (diff) {
|
|
1281
|
+
// Authorize on the committed candidate — the upgraded record, not a raw preview.
|
|
1282
|
+
if (authorize && !authorize(doc, upgraded.value)) return
|
|
1160
1283
|
storage.set(id, upgraded.value)
|
|
1161
1284
|
propagateOp(changes, id, [RecordOpType.Patch, diff], doc, upgraded.value)
|
|
1162
1285
|
}
|
|
@@ -1227,7 +1350,74 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1227
1350
|
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
1228
1351
|
)
|
|
1229
1352
|
}
|
|
1230
|
-
|
|
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)
|
|
1231
1421
|
break
|
|
1232
1422
|
}
|
|
1233
1423
|
case RecordOpType.Patch: {
|
|
@@ -1235,17 +1425,70 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1235
1425
|
// if it was already deleted, there's no need to apply the patch
|
|
1236
1426
|
if (!doc) continue
|
|
1237
1427
|
if (!canWrite(doc.typeName)) continue
|
|
1238
|
-
//
|
|
1239
|
-
|
|
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)
|
|
1240
1458
|
break
|
|
1241
1459
|
}
|
|
1242
1460
|
case RecordOpType.Remove: {
|
|
1243
|
-
const doc = txn.get(id)
|
|
1461
|
+
const doc = txn.get(id) as R | undefined
|
|
1244
1462
|
if (!doc) {
|
|
1245
1463
|
// If the doc was already deleted, don't do anything, no need to propagate a delete op
|
|
1246
1464
|
continue
|
|
1247
1465
|
}
|
|
1248
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
|
+
}
|
|
1249
1492
|
|
|
1250
1493
|
// Delete the document and propagate the delete op
|
|
1251
1494
|
// delete automatically creates tombstones
|
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
|
/**
|