@tldraw/sync-core 5.5.0-canary.b5a47bc9d1b5 → 5.5.0-canary.b5beee5fe538
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.js +1 -1
- package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/package.json +7 -7
- package/src/lib/ClientWebSocketAdapter.test.ts +46 -19
- package/src/lib/InMemorySyncStorage.test.ts +21 -0
- package/src/lib/InMemorySyncStorage.ts +20 -22
package/dist-cjs/index.js
CHANGED
|
@@ -61,7 +61,7 @@ var import_TLSyncRoom = require("./lib/TLSyncRoom");
|
|
|
61
61
|
var import_TLSyncStorage = require("./lib/TLSyncStorage");
|
|
62
62
|
(0, import_utils.registerTldrawLibraryVersion)(
|
|
63
63
|
"@tldraw/sync-core",
|
|
64
|
-
"5.5.0-canary.
|
|
64
|
+
"5.5.0-canary.b5beee5fe538",
|
|
65
65
|
"cjs"
|
|
66
66
|
);
|
|
67
67
|
//# sourceMappingURL=index.js.map
|
|
@@ -98,23 +98,23 @@ class InMemorySyncStorage {
|
|
|
98
98
|
onChange
|
|
99
99
|
} = {}) {
|
|
100
100
|
this.objectTypes = new Set(objectTypes ?? []);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
101
|
+
let maxClockValue = 0;
|
|
102
|
+
const documentEntries = [];
|
|
103
|
+
const objectEntries = [];
|
|
104
|
+
for (const d of snapshot.documents) {
|
|
105
|
+
if (d.lastChangedClock > maxClockValue) maxClockValue = d.lastChangedClock;
|
|
106
|
+
const entries = this.objectTypes.has(d.state.typeName) ? objectEntries : documentEntries;
|
|
107
|
+
entries.push([
|
|
108
|
+
d.state.id,
|
|
109
|
+
{ state: (0, import_store.devFreeze)(d.state), lastChangedClock: d.lastChangedClock }
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
const tombstoneEntries = (0, import_utils.objectMapEntries)(snapshot.tombstones ?? {});
|
|
113
|
+
for (const [, clock] of tombstoneEntries) {
|
|
114
|
+
if (clock > maxClockValue) maxClockValue = clock;
|
|
115
|
+
}
|
|
116
|
+
this.documents = new import_store.AtomMap("room documents", documentEntries);
|
|
117
|
+
this.objects = new import_store.AtomMap("room objects", objectEntries);
|
|
118
118
|
const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0);
|
|
119
119
|
this.documentClock = (0, import_state.atom)("document clock", documentClock);
|
|
120
120
|
const tombstoneHistoryStartsAtClock = Math.min(
|
|
@@ -130,7 +130,7 @@ class InMemorySyncStorage {
|
|
|
130
130
|
"room tombstones",
|
|
131
131
|
// If the tombstone history starts now (or we didn't have the
|
|
132
132
|
// tombstoneHistoryStartsAtClock) then there are no tombstones
|
|
133
|
-
tombstoneHistoryStartsAtClock === documentClock ? [] :
|
|
133
|
+
tombstoneHistoryStartsAtClock === documentClock ? [] : tombstoneEntries
|
|
134
134
|
);
|
|
135
135
|
if (onChange) {
|
|
136
136
|
this.onChange(onChange);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/InMemorySyncStorage.ts"],
|
|
4
|
-
"sourcesContent": ["import { atom, Atom, transaction } from '@tldraw/state'\nimport { AtomMap, devFreeze, SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport {\n\tcreateTLSchema,\n\tDocumentRecordType,\n\tPageRecordType,\n\tTLDOCUMENT_ID,\n\tTLPageId,\n} from '@tldraw/tlschema'\nimport { assert, IndexKey, objectMapEntries, throttle } from '@tldraw/utils'\nimport { MicrotaskNotifier } from './MicrotaskNotifier'\nimport { RoomSnapshot } from './TLSyncRoom'\nimport {\n\tTLSyncForwardDiff,\n\tTLSyncStorage,\n\tTLSyncStorageGetChangesSinceResult,\n\tTLSyncStorageOnChangeCallbackProps,\n\tTLSyncStorageTransaction,\n\tTLSyncStorageTransactionCallback,\n\tTLSyncStorageTransactionOptions,\n\tTLSyncStorageTransactionResult,\n} from './TLSyncStorage'\n\n/** @internal */\nexport const TOMBSTONE_PRUNE_BUFFER_SIZE = 1000\n/** @internal */\nexport const MAX_TOMBSTONES = 5000\n\n/**\n * Result of computing which tombstones to prune.\n * @internal\n */\nexport interface TombstonePruneResult {\n\t/** The new value for tombstoneHistoryStartsAtClock */\n\tnewTombstoneHistoryStartsAtClock: number\n\t/** IDs of tombstones to delete */\n\tidsToDelete: string[]\n}\n\n/**\n * Computes which tombstones should be pruned, avoiding partial history for any clock value.\n * Returns null if no pruning is needed (tombstone count <= maxTombstones).\n *\n * @param tombstones - Array of tombstones sorted by clock ascending (oldest first)\n * @param documentClock - Current document clock (used as fallback if all tombstones are deleted)\n * @param maxTombstones - Maximum number of tombstones to keep (default: MAX_TOMBSTONES)\n * @param pruneBufferSize - Extra tombstones to prune beyond the threshold (default: TOMBSTONE_PRUNE_BUFFER_SIZE)\n * @returns Pruning result or null if no pruning needed\n *\n * @internal\n */\nexport function computeTombstonePruning({\n\ttombstones,\n\tdocumentClock,\n\tmaxTombstones = MAX_TOMBSTONES,\n\tpruneBufferSize = TOMBSTONE_PRUNE_BUFFER_SIZE,\n}: {\n\ttombstones: Array<{ id: string; clock: number }>\n\tdocumentClock: number\n\tmaxTombstones?: number\n\tpruneBufferSize?: number\n}): TombstonePruneResult | null {\n\tif (tombstones.length <= maxTombstones) {\n\t\treturn null\n\t}\n\n\t// Determine how many to delete, avoiding partial history for a clock value\n\tlet cutoff = pruneBufferSize + tombstones.length - maxTombstones\n\twhile (\n\t\tcutoff < tombstones.length &&\n\t\ttombstones[cutoff - 1]?.clock === tombstones[cutoff]?.clock\n\t) {\n\t\tcutoff++\n\t}\n\n\t// Set history start to the oldest remaining tombstone's clock\n\t// (or documentClock if we're deleting everything)\n\tconst oldestRemaining = tombstones[cutoff]\n\tconst newTombstoneHistoryStartsAtClock = oldestRemaining?.clock ?? documentClock\n\n\t// Collect the oldest tombstones to delete (first cutoff entries)\n\tconst idsToDelete = tombstones.slice(0, cutoff).map((t) => t.id)\n\n\treturn { newTombstoneHistoryStartsAtClock, idsToDelete }\n}\n\n/**\n * Default initial snapshot for a new room.\n * @public\n */\nexport const DEFAULT_INITIAL_SNAPSHOT = {\n\tdocumentClock: 0,\n\ttombstoneHistoryStartsAtClock: 0,\n\tschema: createTLSchema().serialize(),\n\tdocuments: [\n\t\t{\n\t\t\tstate: DocumentRecordType.create({ id: TLDOCUMENT_ID }),\n\t\t\tlastChangedClock: 0,\n\t\t},\n\t\t{\n\t\t\tstate: PageRecordType.create({\n\t\t\t\tid: 'page:page' as TLPageId,\n\t\t\t\tname: 'Page 1',\n\t\t\t\tindex: 'a1' as IndexKey,\n\t\t\t}),\n\t\t\tlastChangedClock: 0,\n\t\t},\n\t],\n}\n\n/**\n * In-memory implementation of TLSyncStorage using AtomMap for documents and tombstones,\n * and atoms for clock values. This is the default storage implementation used by TLSyncRoom.\n *\n * @public\n */\nexport class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {\n\t/** @internal */\n\tdocuments: AtomMap<string, { state: R; lastChangedClock: number }>\n\t/**\n\t * Object-store lane records, partitioned out of `documents` so they never appear in the\n\t * document snapshot. They share the same clock, tombstones, and transactions as documents.\n\t * @internal\n\t */\n\tobjects: AtomMap<string, { state: R; lastChangedClock: number }>\n\t/** @internal */\n\treadonly objectTypes: ReadonlySet<string>\n\t/** @internal */\n\ttombstones: AtomMap<string, number>\n\t/** @internal */\n\tschema: Atom<SerializedSchema>\n\t/** @internal */\n\tdocumentClock: Atom<number>\n\t/** @internal */\n\ttombstoneHistoryStartsAtClock: Atom<number>\n\n\tprivate notifier = new MicrotaskNotifier<[TLSyncStorageOnChangeCallbackProps]>()\n\tonChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void {\n\t\treturn this.notifier.register(callback)\n\t}\n\n\tconstructor({\n\t\tsnapshot = DEFAULT_INITIAL_SNAPSHOT,\n\t\tobjectTypes,\n\t\tonChange,\n\t}: {\n\t\tsnapshot?: RoomSnapshot\n\t\t/**\n\t\t * Record type names stored in the object-store lane. Records of these types are routed to\n\t\t * a separate partition: excluded from `getSnapshot()`, returned by `getObjectsSnapshot()`.\n\t\t */\n\t\tobjectTypes?: readonly string[]\n\t\tonChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown\n\t} = {}) {\n\t\tthis.objectTypes = new Set(objectTypes ?? [])\n\t\tconst maxClockValue = Math.max(\n\t\t\t0,\n\t\t\t...Object.values(snapshot.tombstones ?? {}),\n\t\t\t...Object.values(snapshot.documents.map((d) => d.lastChangedClock))\n\t\t)\n\t\t// route snapshot entries into their partitions (a seed snapshot may carry object-lane\n\t\t// records merged in, e.g. loaded from a separate persistence lane)\n\t\tconst toEntry = (\n\t\t\td: RoomSnapshot['documents'][number]\n\t\t): [string, { state: R; lastChangedClock: number }] => [\n\t\t\td.state.id,\n\t\t\t{ state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },\n\t\t]\n\t\tthis.documents = new AtomMap(\n\t\t\t'room documents',\n\t\t\tsnapshot.documents.filter((d) => !this.objectTypes.has(d.state.typeName)).map(toEntry)\n\t\t)\n\t\tthis.objects = new AtomMap(\n\t\t\t'room objects',\n\t\t\tsnapshot.documents.filter((d) => this.objectTypes.has(d.state.typeName)).map(toEntry)\n\t\t)\n\t\tconst documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)\n\n\t\tthis.documentClock = atom('document clock', documentClock)\n\t\t// math.min to make sure the tombstone history starts at or before the document clock\n\t\tconst tombstoneHistoryStartsAtClock = Math.min(\n\t\t\tsnapshot.tombstoneHistoryStartsAtClock ?? documentClock,\n\t\t\tdocumentClock\n\t\t)\n\t\tthis.tombstoneHistoryStartsAtClock = atom(\n\t\t\t'tombstone history starts at clock',\n\t\t\ttombstoneHistoryStartsAtClock\n\t\t)\n\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\tthis.schema = atom('schema', snapshot.schema ?? createTLSchema().serializeEarliestVersion())\n\t\tthis.tombstones = new AtomMap(\n\t\t\t'room tombstones',\n\t\t\t// If the tombstone history starts now (or we didn't have the\n\t\t\t// tombstoneHistoryStartsAtClock) then there are no tombstones\n\t\t\ttombstoneHistoryStartsAtClock === documentClock\n\t\t\t\t? []\n\t\t\t\t: objectMapEntries(snapshot.tombstones ?? {})\n\t\t)\n\t\tif (onChange) {\n\t\t\tthis.onChange(onChange)\n\t\t}\n\t}\n\n\ttransaction<T>(\n\t\tcallback: TLSyncStorageTransactionCallback<R, T>,\n\t\topts?: TLSyncStorageTransactionOptions\n\t): TLSyncStorageTransactionResult<T, R> {\n\t\tconst clockBefore = this.documentClock.get()\n\t\tconst trackChanges = opts?.emitChanges === 'always'\n\t\tconst txn = new InMemorySyncStorageTransaction<R>(this)\n\t\tlet result: T\n\t\tlet changes: TLSyncForwardDiff<R> | undefined\n\t\ttry {\n\t\t\tresult = transaction(() => {\n\t\t\t\treturn callback(txn as any)\n\t\t\t}) as T\n\t\t\tif (trackChanges) {\n\t\t\t\tchanges = txn.getChangesSince(clockBefore)?.diff\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error('Error in transaction', error)\n\t\t\tthrow error\n\t\t} finally {\n\t\t\ttxn.close()\n\t\t}\n\t\tif (\n\t\t\ttypeof result === 'object' &&\n\t\t\tresult &&\n\t\t\t'then' in result &&\n\t\t\ttypeof result.then === 'function'\n\t\t) {\n\t\t\tconst err = new Error('Transaction must return a value, not a promise')\n\t\t\tconsole.error(err)\n\t\t\tthrow err\n\t\t}\n\n\t\tconst clockAfter = this.documentClock.get()\n\t\tconst didChange = clockAfter > clockBefore\n\t\tif (didChange) {\n\t\t\tthis.notifier.notify({ id: opts?.id, documentClock: clockAfter })\n\t\t}\n\t\t// InMemorySyncStorage applies changes verbatim, so we only emit changes\n\t\t// when 'always' is specified (not for 'when-different')\n\t\treturn { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }\n\t}\n\n\tgetClock(): number {\n\t\treturn this.documentClock.get()\n\t}\n\n\t/** @internal */\n\tpruneTombstones = throttle(\n\t\t() => {\n\t\t\tif (this.tombstones.size > MAX_TOMBSTONES) {\n\t\t\t\t// Convert to array and sort by clock ascending (oldest first)\n\t\t\t\tconst tombstones = Array.from(this.tombstones.entries())\n\t\t\t\t\t.map(([id, clock]) => ({ id, clock }))\n\t\t\t\t\t.sort((a, b) => a.clock - b.clock)\n\n\t\t\t\tconst result = computeTombstonePruning({\n\t\t\t\t\ttombstones,\n\t\t\t\t\tdocumentClock: this.documentClock.get(),\n\t\t\t\t})\n\t\t\t\tif (result) {\n\t\t\t\t\tthis.tombstoneHistoryStartsAtClock.set(result.newTombstoneHistoryStartsAtClock)\n\t\t\t\t\tthis.tombstones.deleteMany(result.idsToDelete)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t1000,\n\t\t// prevent this from running synchronously to avoid blocking requests\n\t\t{ leading: false }\n\t)\n\n\tgetSnapshot(): RoomSnapshot {\n\t\t// object-lane records live in their own partition, so the document snapshot is\n\t\t// pure-document by construction\n\t\treturn {\n\t\t\ttombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),\n\t\t\tdocumentClock: this.documentClock.get(),\n\t\t\tdocuments: Array.from(this.documents.values()),\n\t\t\ttombstones: Object.fromEntries(this.tombstones.entries()),\n\t\t\tschema: this.schema.get(),\n\t\t}\n\t}\n\n\tgetObjectsSnapshot(): RoomSnapshot['documents'] {\n\t\treturn Array.from(this.objects.values())\n\t}\n\n\tgetObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {\n\t\tconst result: RoomSnapshot['documents'] = []\n\t\tconst seen = new Set<string>()\n\t\tfor (const id of ids) {\n\t\t\tif (seen.has(id)) continue\n\t\t\tseen.add(id)\n\t\t\tconst doc = this.objects.get(id)\n\t\t\tif (doc) result.push(doc)\n\t\t}\n\t\treturn result\n\t}\n}\n\n/**\n * Transaction implementation for InMemorySyncStorage.\n * Provides access to documents, tombstones, and metadata within a transaction.\n *\n * @internal\n */\nclass InMemorySyncStorageTransaction<\n\tR extends UnknownRecord,\n> implements TLSyncStorageTransaction<R> {\n\tprivate _clock\n\tprivate _closed = false\n\n\tconstructor(private storage: InMemorySyncStorage<R>) {\n\t\tthis._clock = this.storage.documentClock.get()\n\t}\n\n\t/** @internal */\n\tclose() {\n\t\tthis._closed = true\n\t}\n\n\tprivate assertNotClosed() {\n\t\tassert(!this._closed, 'Transaction has ended, iterator cannot be consumed')\n\t}\n\n\tgetClock(): number {\n\t\treturn this._clock\n\t}\n\n\tprivate didIncrementClock: boolean = false\n\tprivate getNextClock(): number {\n\t\tif (!this.didIncrementClock) {\n\t\t\tthis.didIncrementClock = true\n\t\t\tthis._clock = this.storage.documentClock.set(this.storage.documentClock.get() + 1)\n\t\t}\n\t\treturn this._clock\n\t}\n\n\t/** The partition a record with this id currently lives in, if any. */\n\tprivate mapContaining(id: string) {\n\t\tif (this.storage.documents.has(id)) return this.storage.documents\n\t\tif (this.storage.objects.has(id)) return this.storage.objects\n\t\treturn undefined\n\t}\n\n\tget(id: string): R | undefined {\n\t\tthis.assertNotClosed()\n\t\treturn (this.storage.documents.get(id) ?? this.storage.objects.get(id))?.state\n\t}\n\n\tset(id: string, record: R): void {\n\t\tthis.assertNotClosed()\n\t\tassert(id === record.id, `Record id mismatch: key does not match record.id`)\n\t\tconst clock = this.getNextClock()\n\t\t// Automatically clear tombstone if it exists\n\t\tif (this.storage.tombstones.has(id)) {\n\t\t\tthis.storage.tombstones.delete(id)\n\t\t}\n\t\t// route by type: object-lane records live in their own partition\n\t\tconst map = this.storage.objectTypes.has(record.typeName)\n\t\t\t? this.storage.objects\n\t\t\t: this.storage.documents\n\t\tmap.set(id, {\n\t\t\tstate: devFreeze(record) as R,\n\t\t\tlastChangedClock: clock,\n\t\t})\n\t}\n\n\tdelete(id: string): void {\n\t\tthis.assertNotClosed()\n\t\t// Only create a tombstone if the record actually exists\n\t\tconst map = this.mapContaining(id)\n\t\tif (!map) return\n\t\tconst clock = this.getNextClock()\n\t\tmap.delete(id)\n\t\tthis.storage.tombstones.set(id, clock)\n\t\tthis.storage.pruneTombstones()\n\t}\n\n\t// iteration spans both partitions so schema migrations cover object-lane records too\n\n\t*entries(): IterableIterator<[string, R]> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const [id, record] of map.entries()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield [id, record.state]\n\t\t\t}\n\t\t}\n\t}\n\n\t*keys(): IterableIterator<string> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const key of map.keys()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield key\n\t\t\t}\n\t\t}\n\t}\n\n\t*values(): IterableIterator<R> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const record of map.values()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield record.state\n\t\t\t}\n\t\t}\n\t}\n\n\tgetSchema(): SerializedSchema {\n\t\tthis.assertNotClosed()\n\t\treturn this.storage.schema.get()\n\t}\n\n\tsetSchema(schema: SerializedSchema): void {\n\t\tthis.assertNotClosed()\n\t\tthis.storage.schema.set(schema)\n\t}\n\n\tgetChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {\n\t\tthis.assertNotClosed()\n\t\tconst clock = this.storage.documentClock.get()\n\t\tif (sinceClock === clock) return undefined\n\t\tif (sinceClock > clock) {\n\t\t\t// something went wrong, wipe the slate clean\n\t\t\tsinceClock = -1\n\t\t}\n\t\tconst diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }\n\t\tconst wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get()\n\t\t// both partitions share the clock and tombstones, so the change feed spans both\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const doc of map.values()) {\n\t\t\t\tif (wipeAll || doc.lastChangedClock > sinceClock) {\n\t\t\t\t\t// For historical changes, we don't have \"from\" state, so use added\n\t\t\t\t\tdiff.puts[doc.state.id] = doc.state as R\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!wipeAll) {\n\t\t\tfor (const [id, clock] of this.storage.tombstones.entries()) {\n\t\t\t\tif (clock > sinceClock) {\n\t\t\t\t\t// For tombstones, we don't have the removed record, use placeholder\n\t\t\t\t\tdiff.deletes.push(id)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn { diff, wipeAll }\n\t}\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAAwC;AACxC,mBAAoE;AACpE,sBAMO;AACP,mBAA6D;AAC7D,+BAAkC;AAc3B,MAAM,8BAA8B;AAEpC,MAAM,iBAAiB;AAyBvB,SAAS,wBAAwB;AAAA,EACvC;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB,kBAAkB;AACnB,GAKgC;AAC/B,MAAI,WAAW,UAAU,eAAe;AACvC,WAAO;AAAA,EACR;AAGA,MAAI,SAAS,kBAAkB,WAAW,SAAS;AACnD,SACC,SAAS,WAAW,UACpB,WAAW,SAAS,CAAC,GAAG,UAAU,WAAW,MAAM,GAAG,OACrD;AACD;AAAA,EACD;AAIA,QAAM,kBAAkB,WAAW,MAAM;AACzC,QAAM,mCAAmC,iBAAiB,SAAS;AAGnE,QAAM,cAAc,WAAW,MAAM,GAAG,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE;AAE/D,SAAO,EAAE,kCAAkC,YAAY;AACxD;AAMO,MAAM,2BAA2B;AAAA,EACvC,eAAe;AAAA,EACf,+BAA+B;AAAA,EAC/B,YAAQ,gCAAe,EAAE,UAAU;AAAA,EACnC,WAAW;AAAA,IACV;AAAA,MACC,OAAO,mCAAmB,OAAO,EAAE,IAAI,8BAAc,CAAC;AAAA,MACtD,kBAAkB;AAAA,IACnB;AAAA,IACA;AAAA,MACC,OAAO,+BAAe,OAAO;AAAA,QAC5B,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,OAAO;AAAA,MACR,CAAC;AAAA,MACD,kBAAkB;AAAA,IACnB;AAAA,EACD;AACD;AAQO,MAAM,oBAAyE;AAAA;AAAA,EAErF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA;AAAA;AAAA,EAES;AAAA;AAAA,EAET;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA,EAEQ,WAAW,IAAI,2CAAwD;AAAA,EAC/E,SAAS,UAA4E;AACpF,WAAO,KAAK,SAAS,SAAS,QAAQ;AAAA,EACvC;AAAA,EAEA,YAAY;AAAA,IACX,WAAW;AAAA,IACX;AAAA,IACA;AAAA,EACD,IAQI,CAAC,GAAG;AACP,SAAK,cAAc,IAAI,IAAI,eAAe,CAAC,CAAC;
|
|
4
|
+
"sourcesContent": ["import { atom, Atom, transaction } from '@tldraw/state'\nimport { AtomMap, devFreeze, SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport {\n\tcreateTLSchema,\n\tDocumentRecordType,\n\tPageRecordType,\n\tTLDOCUMENT_ID,\n\tTLPageId,\n} from '@tldraw/tlschema'\nimport { assert, IndexKey, objectMapEntries, throttle } from '@tldraw/utils'\nimport { MicrotaskNotifier } from './MicrotaskNotifier'\nimport { RoomSnapshot } from './TLSyncRoom'\nimport {\n\tTLSyncForwardDiff,\n\tTLSyncStorage,\n\tTLSyncStorageGetChangesSinceResult,\n\tTLSyncStorageOnChangeCallbackProps,\n\tTLSyncStorageTransaction,\n\tTLSyncStorageTransactionCallback,\n\tTLSyncStorageTransactionOptions,\n\tTLSyncStorageTransactionResult,\n} from './TLSyncStorage'\n\n/** @internal */\nexport const TOMBSTONE_PRUNE_BUFFER_SIZE = 1000\n/** @internal */\nexport const MAX_TOMBSTONES = 5000\n\n/**\n * Result of computing which tombstones to prune.\n * @internal\n */\nexport interface TombstonePruneResult {\n\t/** The new value for tombstoneHistoryStartsAtClock */\n\tnewTombstoneHistoryStartsAtClock: number\n\t/** IDs of tombstones to delete */\n\tidsToDelete: string[]\n}\n\n/**\n * Computes which tombstones should be pruned, avoiding partial history for any clock value.\n * Returns null if no pruning is needed (tombstone count <= maxTombstones).\n *\n * @param tombstones - Array of tombstones sorted by clock ascending (oldest first)\n * @param documentClock - Current document clock (used as fallback if all tombstones are deleted)\n * @param maxTombstones - Maximum number of tombstones to keep (default: MAX_TOMBSTONES)\n * @param pruneBufferSize - Extra tombstones to prune beyond the threshold (default: TOMBSTONE_PRUNE_BUFFER_SIZE)\n * @returns Pruning result or null if no pruning needed\n *\n * @internal\n */\nexport function computeTombstonePruning({\n\ttombstones,\n\tdocumentClock,\n\tmaxTombstones = MAX_TOMBSTONES,\n\tpruneBufferSize = TOMBSTONE_PRUNE_BUFFER_SIZE,\n}: {\n\ttombstones: Array<{ id: string; clock: number }>\n\tdocumentClock: number\n\tmaxTombstones?: number\n\tpruneBufferSize?: number\n}): TombstonePruneResult | null {\n\tif (tombstones.length <= maxTombstones) {\n\t\treturn null\n\t}\n\n\t// Determine how many to delete, avoiding partial history for a clock value\n\tlet cutoff = pruneBufferSize + tombstones.length - maxTombstones\n\twhile (\n\t\tcutoff < tombstones.length &&\n\t\ttombstones[cutoff - 1]?.clock === tombstones[cutoff]?.clock\n\t) {\n\t\tcutoff++\n\t}\n\n\t// Set history start to the oldest remaining tombstone's clock\n\t// (or documentClock if we're deleting everything)\n\tconst oldestRemaining = tombstones[cutoff]\n\tconst newTombstoneHistoryStartsAtClock = oldestRemaining?.clock ?? documentClock\n\n\t// Collect the oldest tombstones to delete (first cutoff entries)\n\tconst idsToDelete = tombstones.slice(0, cutoff).map((t) => t.id)\n\n\treturn { newTombstoneHistoryStartsAtClock, idsToDelete }\n}\n\n/**\n * Default initial snapshot for a new room.\n * @public\n */\nexport const DEFAULT_INITIAL_SNAPSHOT = {\n\tdocumentClock: 0,\n\ttombstoneHistoryStartsAtClock: 0,\n\tschema: createTLSchema().serialize(),\n\tdocuments: [\n\t\t{\n\t\t\tstate: DocumentRecordType.create({ id: TLDOCUMENT_ID }),\n\t\t\tlastChangedClock: 0,\n\t\t},\n\t\t{\n\t\t\tstate: PageRecordType.create({\n\t\t\t\tid: 'page:page' as TLPageId,\n\t\t\t\tname: 'Page 1',\n\t\t\t\tindex: 'a1' as IndexKey,\n\t\t\t}),\n\t\t\tlastChangedClock: 0,\n\t\t},\n\t],\n}\n\n/**\n * In-memory implementation of TLSyncStorage using AtomMap for documents and tombstones,\n * and atoms for clock values. This is the default storage implementation used by TLSyncRoom.\n *\n * @public\n */\nexport class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {\n\t/** @internal */\n\tdocuments: AtomMap<string, { state: R; lastChangedClock: number }>\n\t/**\n\t * Object-store lane records, partitioned out of `documents` so they never appear in the\n\t * document snapshot. They share the same clock, tombstones, and transactions as documents.\n\t * @internal\n\t */\n\tobjects: AtomMap<string, { state: R; lastChangedClock: number }>\n\t/** @internal */\n\treadonly objectTypes: ReadonlySet<string>\n\t/** @internal */\n\ttombstones: AtomMap<string, number>\n\t/** @internal */\n\tschema: Atom<SerializedSchema>\n\t/** @internal */\n\tdocumentClock: Atom<number>\n\t/** @internal */\n\ttombstoneHistoryStartsAtClock: Atom<number>\n\n\tprivate notifier = new MicrotaskNotifier<[TLSyncStorageOnChangeCallbackProps]>()\n\tonChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void {\n\t\treturn this.notifier.register(callback)\n\t}\n\n\tconstructor({\n\t\tsnapshot = DEFAULT_INITIAL_SNAPSHOT,\n\t\tobjectTypes,\n\t\tonChange,\n\t}: {\n\t\tsnapshot?: RoomSnapshot\n\t\t/**\n\t\t * Record type names stored in the object-store lane. Records of these types are routed to\n\t\t * a separate partition: excluded from `getSnapshot()`, returned by `getObjectsSnapshot()`.\n\t\t */\n\t\tobjectTypes?: readonly string[]\n\t\tonChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown\n\t} = {}) {\n\t\tthis.objectTypes = new Set(objectTypes ?? [])\n\t\t// a loop rather than `Math.max(0, ...clocks)`: spreading a large room's clocks as call\n\t\t// arguments overflows the stack at roughly 100k+ records/tombstones\n\t\tlet maxClockValue = 0\n\t\t// route snapshot entries into their partitions (a seed snapshot may carry object-lane\n\t\t// records merged in, e.g. loaded from a separate persistence lane)\n\t\tconst documentEntries: [string, { state: R; lastChangedClock: number }][] = []\n\t\tconst objectEntries: [string, { state: R; lastChangedClock: number }][] = []\n\t\tfor (const d of snapshot.documents) {\n\t\t\tif (d.lastChangedClock > maxClockValue) maxClockValue = d.lastChangedClock\n\t\t\tconst entries = this.objectTypes.has(d.state.typeName) ? objectEntries : documentEntries\n\t\t\tentries.push([\n\t\t\t\td.state.id,\n\t\t\t\t{ state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },\n\t\t\t])\n\t\t}\n\t\tconst tombstoneEntries = objectMapEntries(snapshot.tombstones ?? {})\n\t\tfor (const [, clock] of tombstoneEntries) {\n\t\t\tif (clock > maxClockValue) maxClockValue = clock\n\t\t}\n\t\tthis.documents = new AtomMap('room documents', documentEntries)\n\t\tthis.objects = new AtomMap('room objects', objectEntries)\n\t\tconst documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)\n\n\t\tthis.documentClock = atom('document clock', documentClock)\n\t\t// math.min to make sure the tombstone history starts at or before the document clock\n\t\tconst tombstoneHistoryStartsAtClock = Math.min(\n\t\t\tsnapshot.tombstoneHistoryStartsAtClock ?? documentClock,\n\t\t\tdocumentClock\n\t\t)\n\t\tthis.tombstoneHistoryStartsAtClock = atom(\n\t\t\t'tombstone history starts at clock',\n\t\t\ttombstoneHistoryStartsAtClock\n\t\t)\n\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\tthis.schema = atom('schema', snapshot.schema ?? createTLSchema().serializeEarliestVersion())\n\t\tthis.tombstones = new AtomMap(\n\t\t\t'room tombstones',\n\t\t\t// If the tombstone history starts now (or we didn't have the\n\t\t\t// tombstoneHistoryStartsAtClock) then there are no tombstones\n\t\t\ttombstoneHistoryStartsAtClock === documentClock ? [] : tombstoneEntries\n\t\t)\n\t\tif (onChange) {\n\t\t\tthis.onChange(onChange)\n\t\t}\n\t}\n\n\ttransaction<T>(\n\t\tcallback: TLSyncStorageTransactionCallback<R, T>,\n\t\topts?: TLSyncStorageTransactionOptions\n\t): TLSyncStorageTransactionResult<T, R> {\n\t\tconst clockBefore = this.documentClock.get()\n\t\tconst trackChanges = opts?.emitChanges === 'always'\n\t\tconst txn = new InMemorySyncStorageTransaction<R>(this)\n\t\tlet result: T\n\t\tlet changes: TLSyncForwardDiff<R> | undefined\n\t\ttry {\n\t\t\tresult = transaction(() => {\n\t\t\t\treturn callback(txn as any)\n\t\t\t}) as T\n\t\t\tif (trackChanges) {\n\t\t\t\tchanges = txn.getChangesSince(clockBefore)?.diff\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error('Error in transaction', error)\n\t\t\tthrow error\n\t\t} finally {\n\t\t\ttxn.close()\n\t\t}\n\t\tif (\n\t\t\ttypeof result === 'object' &&\n\t\t\tresult &&\n\t\t\t'then' in result &&\n\t\t\ttypeof result.then === 'function'\n\t\t) {\n\t\t\tconst err = new Error('Transaction must return a value, not a promise')\n\t\t\tconsole.error(err)\n\t\t\tthrow err\n\t\t}\n\n\t\tconst clockAfter = this.documentClock.get()\n\t\tconst didChange = clockAfter > clockBefore\n\t\tif (didChange) {\n\t\t\tthis.notifier.notify({ id: opts?.id, documentClock: clockAfter })\n\t\t}\n\t\t// InMemorySyncStorage applies changes verbatim, so we only emit changes\n\t\t// when 'always' is specified (not for 'when-different')\n\t\treturn { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }\n\t}\n\n\tgetClock(): number {\n\t\treturn this.documentClock.get()\n\t}\n\n\t/** @internal */\n\tpruneTombstones = throttle(\n\t\t() => {\n\t\t\tif (this.tombstones.size > MAX_TOMBSTONES) {\n\t\t\t\t// Convert to array and sort by clock ascending (oldest first)\n\t\t\t\tconst tombstones = Array.from(this.tombstones.entries())\n\t\t\t\t\t.map(([id, clock]) => ({ id, clock }))\n\t\t\t\t\t.sort((a, b) => a.clock - b.clock)\n\n\t\t\t\tconst result = computeTombstonePruning({\n\t\t\t\t\ttombstones,\n\t\t\t\t\tdocumentClock: this.documentClock.get(),\n\t\t\t\t})\n\t\t\t\tif (result) {\n\t\t\t\t\tthis.tombstoneHistoryStartsAtClock.set(result.newTombstoneHistoryStartsAtClock)\n\t\t\t\t\tthis.tombstones.deleteMany(result.idsToDelete)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t1000,\n\t\t// prevent this from running synchronously to avoid blocking requests\n\t\t{ leading: false }\n\t)\n\n\tgetSnapshot(): RoomSnapshot {\n\t\t// object-lane records live in their own partition, so the document snapshot is\n\t\t// pure-document by construction\n\t\treturn {\n\t\t\ttombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),\n\t\t\tdocumentClock: this.documentClock.get(),\n\t\t\tdocuments: Array.from(this.documents.values()),\n\t\t\ttombstones: Object.fromEntries(this.tombstones.entries()),\n\t\t\tschema: this.schema.get(),\n\t\t}\n\t}\n\n\tgetObjectsSnapshot(): RoomSnapshot['documents'] {\n\t\treturn Array.from(this.objects.values())\n\t}\n\n\tgetObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {\n\t\tconst result: RoomSnapshot['documents'] = []\n\t\tconst seen = new Set<string>()\n\t\tfor (const id of ids) {\n\t\t\tif (seen.has(id)) continue\n\t\t\tseen.add(id)\n\t\t\tconst doc = this.objects.get(id)\n\t\t\tif (doc) result.push(doc)\n\t\t}\n\t\treturn result\n\t}\n}\n\n/**\n * Transaction implementation for InMemorySyncStorage.\n * Provides access to documents, tombstones, and metadata within a transaction.\n *\n * @internal\n */\nclass InMemorySyncStorageTransaction<\n\tR extends UnknownRecord,\n> implements TLSyncStorageTransaction<R> {\n\tprivate _clock\n\tprivate _closed = false\n\n\tconstructor(private storage: InMemorySyncStorage<R>) {\n\t\tthis._clock = this.storage.documentClock.get()\n\t}\n\n\t/** @internal */\n\tclose() {\n\t\tthis._closed = true\n\t}\n\n\tprivate assertNotClosed() {\n\t\tassert(!this._closed, 'Transaction has ended, iterator cannot be consumed')\n\t}\n\n\tgetClock(): number {\n\t\treturn this._clock\n\t}\n\n\tprivate didIncrementClock: boolean = false\n\tprivate getNextClock(): number {\n\t\tif (!this.didIncrementClock) {\n\t\t\tthis.didIncrementClock = true\n\t\t\tthis._clock = this.storage.documentClock.set(this.storage.documentClock.get() + 1)\n\t\t}\n\t\treturn this._clock\n\t}\n\n\t/** The partition a record with this id currently lives in, if any. */\n\tprivate mapContaining(id: string) {\n\t\tif (this.storage.documents.has(id)) return this.storage.documents\n\t\tif (this.storage.objects.has(id)) return this.storage.objects\n\t\treturn undefined\n\t}\n\n\tget(id: string): R | undefined {\n\t\tthis.assertNotClosed()\n\t\treturn (this.storage.documents.get(id) ?? this.storage.objects.get(id))?.state\n\t}\n\n\tset(id: string, record: R): void {\n\t\tthis.assertNotClosed()\n\t\tassert(id === record.id, `Record id mismatch: key does not match record.id`)\n\t\tconst clock = this.getNextClock()\n\t\t// Automatically clear tombstone if it exists\n\t\tif (this.storage.tombstones.has(id)) {\n\t\t\tthis.storage.tombstones.delete(id)\n\t\t}\n\t\t// route by type: object-lane records live in their own partition\n\t\tconst map = this.storage.objectTypes.has(record.typeName)\n\t\t\t? this.storage.objects\n\t\t\t: this.storage.documents\n\t\tmap.set(id, {\n\t\t\tstate: devFreeze(record) as R,\n\t\t\tlastChangedClock: clock,\n\t\t})\n\t}\n\n\tdelete(id: string): void {\n\t\tthis.assertNotClosed()\n\t\t// Only create a tombstone if the record actually exists\n\t\tconst map = this.mapContaining(id)\n\t\tif (!map) return\n\t\tconst clock = this.getNextClock()\n\t\tmap.delete(id)\n\t\tthis.storage.tombstones.set(id, clock)\n\t\tthis.storage.pruneTombstones()\n\t}\n\n\t// iteration spans both partitions so schema migrations cover object-lane records too\n\n\t*entries(): IterableIterator<[string, R]> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const [id, record] of map.entries()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield [id, record.state]\n\t\t\t}\n\t\t}\n\t}\n\n\t*keys(): IterableIterator<string> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const key of map.keys()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield key\n\t\t\t}\n\t\t}\n\t}\n\n\t*values(): IterableIterator<R> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const record of map.values()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield record.state\n\t\t\t}\n\t\t}\n\t}\n\n\tgetSchema(): SerializedSchema {\n\t\tthis.assertNotClosed()\n\t\treturn this.storage.schema.get()\n\t}\n\n\tsetSchema(schema: SerializedSchema): void {\n\t\tthis.assertNotClosed()\n\t\tthis.storage.schema.set(schema)\n\t}\n\n\tgetChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {\n\t\tthis.assertNotClosed()\n\t\tconst clock = this.storage.documentClock.get()\n\t\tif (sinceClock === clock) return undefined\n\t\tif (sinceClock > clock) {\n\t\t\t// something went wrong, wipe the slate clean\n\t\t\tsinceClock = -1\n\t\t}\n\t\tconst diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }\n\t\tconst wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get()\n\t\t// both partitions share the clock and tombstones, so the change feed spans both\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const doc of map.values()) {\n\t\t\t\tif (wipeAll || doc.lastChangedClock > sinceClock) {\n\t\t\t\t\t// For historical changes, we don't have \"from\" state, so use added\n\t\t\t\t\tdiff.puts[doc.state.id] = doc.state as R\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!wipeAll) {\n\t\t\tfor (const [id, clock] of this.storage.tombstones.entries()) {\n\t\t\t\tif (clock > sinceClock) {\n\t\t\t\t\t// For tombstones, we don't have the removed record, use placeholder\n\t\t\t\t\tdiff.deletes.push(id)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn { diff, wipeAll }\n\t}\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAAwC;AACxC,mBAAoE;AACpE,sBAMO;AACP,mBAA6D;AAC7D,+BAAkC;AAc3B,MAAM,8BAA8B;AAEpC,MAAM,iBAAiB;AAyBvB,SAAS,wBAAwB;AAAA,EACvC;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB,kBAAkB;AACnB,GAKgC;AAC/B,MAAI,WAAW,UAAU,eAAe;AACvC,WAAO;AAAA,EACR;AAGA,MAAI,SAAS,kBAAkB,WAAW,SAAS;AACnD,SACC,SAAS,WAAW,UACpB,WAAW,SAAS,CAAC,GAAG,UAAU,WAAW,MAAM,GAAG,OACrD;AACD;AAAA,EACD;AAIA,QAAM,kBAAkB,WAAW,MAAM;AACzC,QAAM,mCAAmC,iBAAiB,SAAS;AAGnE,QAAM,cAAc,WAAW,MAAM,GAAG,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE;AAE/D,SAAO,EAAE,kCAAkC,YAAY;AACxD;AAMO,MAAM,2BAA2B;AAAA,EACvC,eAAe;AAAA,EACf,+BAA+B;AAAA,EAC/B,YAAQ,gCAAe,EAAE,UAAU;AAAA,EACnC,WAAW;AAAA,IACV;AAAA,MACC,OAAO,mCAAmB,OAAO,EAAE,IAAI,8BAAc,CAAC;AAAA,MACtD,kBAAkB;AAAA,IACnB;AAAA,IACA;AAAA,MACC,OAAO,+BAAe,OAAO;AAAA,QAC5B,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,OAAO;AAAA,MACR,CAAC;AAAA,MACD,kBAAkB;AAAA,IACnB;AAAA,EACD;AACD;AAQO,MAAM,oBAAyE;AAAA;AAAA,EAErF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA;AAAA;AAAA,EAES;AAAA;AAAA,EAET;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA,EAEQ,WAAW,IAAI,2CAAwD;AAAA,EAC/E,SAAS,UAA4E;AACpF,WAAO,KAAK,SAAS,SAAS,QAAQ;AAAA,EACvC;AAAA,EAEA,YAAY;AAAA,IACX,WAAW;AAAA,IACX;AAAA,IACA;AAAA,EACD,IAQI,CAAC,GAAG;AACP,SAAK,cAAc,IAAI,IAAI,eAAe,CAAC,CAAC;AAG5C,QAAI,gBAAgB;AAGpB,UAAM,kBAAsE,CAAC;AAC7E,UAAM,gBAAoE,CAAC;AAC3E,eAAW,KAAK,SAAS,WAAW;AACnC,UAAI,EAAE,mBAAmB,cAAe,iBAAgB,EAAE;AAC1D,YAAM,UAAU,KAAK,YAAY,IAAI,EAAE,MAAM,QAAQ,IAAI,gBAAgB;AACzE,cAAQ,KAAK;AAAA,QACZ,EAAE,MAAM;AAAA,QACR,EAAE,WAAO,wBAAU,EAAE,KAAK,GAAQ,kBAAkB,EAAE,iBAAiB;AAAA,MACxE,CAAC;AAAA,IACF;AACA,UAAM,uBAAmB,+BAAiB,SAAS,cAAc,CAAC,CAAC;AACnE,eAAW,CAAC,EAAE,KAAK,KAAK,kBAAkB;AACzC,UAAI,QAAQ,cAAe,iBAAgB;AAAA,IAC5C;AACA,SAAK,YAAY,IAAI,qBAAQ,kBAAkB,eAAe;AAC9D,SAAK,UAAU,IAAI,qBAAQ,gBAAgB,aAAa;AACxD,UAAM,gBAAgB,KAAK,IAAI,eAAe,SAAS,iBAAiB,SAAS,SAAS,CAAC;AAE3F,SAAK,oBAAgB,mBAAK,kBAAkB,aAAa;AAEzD,UAAM,gCAAgC,KAAK;AAAA,MAC1C,SAAS,iCAAiC;AAAA,MAC1C;AAAA,IACD;AACA,SAAK,oCAAgC;AAAA,MACpC;AAAA,MACA;AAAA,IACD;AAEA,SAAK,aAAS,mBAAK,UAAU,SAAS,cAAU,gCAAe,EAAE,yBAAyB,CAAC;AAC3F,SAAK,aAAa,IAAI;AAAA,MACrB;AAAA;AAAA;AAAA,MAGA,kCAAkC,gBAAgB,CAAC,IAAI;AAAA,IACxD;AACA,QAAI,UAAU;AACb,WAAK,SAAS,QAAQ;AAAA,IACvB;AAAA,EACD;AAAA,EAEA,YACC,UACA,MACuC;AACvC,UAAM,cAAc,KAAK,cAAc,IAAI;AAC3C,UAAM,eAAe,MAAM,gBAAgB;AAC3C,UAAM,MAAM,IAAI,+BAAkC,IAAI;AACtD,QAAI;AACJ,QAAI;AACJ,QAAI;AACH,mBAAS,0BAAY,MAAM;AAC1B,eAAO,SAAS,GAAU;AAAA,MAC3B,CAAC;AACD,UAAI,cAAc;AACjB,kBAAU,IAAI,gBAAgB,WAAW,GAAG;AAAA,MAC7C;AAAA,IACD,SAAS,OAAO;AACf,cAAQ,MAAM,wBAAwB,KAAK;AAC3C,YAAM;AAAA,IACP,UAAE;AACD,UAAI,MAAM;AAAA,IACX;AACA,QACC,OAAO,WAAW,YAClB,UACA,UAAU,UACV,OAAO,OAAO,SAAS,YACtB;AACD,YAAM,MAAM,IAAI,MAAM,gDAAgD;AACtE,cAAQ,MAAM,GAAG;AACjB,YAAM;AAAA,IACP;AAEA,UAAM,aAAa,KAAK,cAAc,IAAI;AAC1C,UAAM,YAAY,aAAa;AAC/B,QAAI,WAAW;AACd,WAAK,SAAS,OAAO,EAAE,IAAI,MAAM,IAAI,eAAe,WAAW,CAAC;AAAA,IACjE;AAGA,WAAO,EAAE,eAAe,YAAY,WAAW,aAAa,aAAa,QAAQ,QAAQ;AAAA,EAC1F;AAAA,EAEA,WAAmB;AAClB,WAAO,KAAK,cAAc,IAAI;AAAA,EAC/B;AAAA;AAAA,EAGA,sBAAkB;AAAA,IACjB,MAAM;AACL,UAAI,KAAK,WAAW,OAAO,gBAAgB;AAE1C,cAAM,aAAa,MAAM,KAAK,KAAK,WAAW,QAAQ,CAAC,EACrD,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,EAAE,IAAI,MAAM,EAAE,EACpC,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;AAElC,cAAM,SAAS,wBAAwB;AAAA,UACtC;AAAA,UACA,eAAe,KAAK,cAAc,IAAI;AAAA,QACvC,CAAC;AACD,YAAI,QAAQ;AACX,eAAK,8BAA8B,IAAI,OAAO,gCAAgC;AAC9E,eAAK,WAAW,WAAW,OAAO,WAAW;AAAA,QAC9C;AAAA,MACD;AAAA,IACD;AAAA,IACA;AAAA;AAAA,IAEA,EAAE,SAAS,MAAM;AAAA,EAClB;AAAA,EAEA,cAA4B;AAG3B,WAAO;AAAA,MACN,+BAA+B,KAAK,8BAA8B,IAAI;AAAA,MACtE,eAAe,KAAK,cAAc,IAAI;AAAA,MACtC,WAAW,MAAM,KAAK,KAAK,UAAU,OAAO,CAAC;AAAA,MAC7C,YAAY,OAAO,YAAY,KAAK,WAAW,QAAQ,CAAC;AAAA,MACxD,QAAQ,KAAK,OAAO,IAAI;AAAA,IACzB;AAAA,EACD;AAAA,EAEA,qBAAgD;AAC/C,WAAO,MAAM,KAAK,KAAK,QAAQ,OAAO,CAAC;AAAA,EACxC;AAAA,EAEA,gBAAgB,KAAkD;AACjE,UAAM,SAAoC,CAAC;AAC3C,UAAM,OAAO,oBAAI,IAAY;AAC7B,eAAW,MAAM,KAAK;AACrB,UAAI,KAAK,IAAI,EAAE,EAAG;AAClB,WAAK,IAAI,EAAE;AACX,YAAM,MAAM,KAAK,QAAQ,IAAI,EAAE;AAC/B,UAAI,IAAK,QAAO,KAAK,GAAG;AAAA,IACzB;AACA,WAAO;AAAA,EACR;AACD;AAQA,MAAM,+BAEmC;AAAA,EAIxC,YAAoB,SAAiC;AAAjC;AACnB,SAAK,SAAS,KAAK,QAAQ,cAAc,IAAI;AAAA,EAC9C;AAAA,EAFoB;AAAA,EAHZ;AAAA,EACA,UAAU;AAAA;AAAA,EAOlB,QAAQ;AACP,SAAK,UAAU;AAAA,EAChB;AAAA,EAEQ,kBAAkB;AACzB,6BAAO,CAAC,KAAK,SAAS,oDAAoD;AAAA,EAC3E;AAAA,EAEA,WAAmB;AAClB,WAAO,KAAK;AAAA,EACb;AAAA,EAEQ,oBAA6B;AAAA,EAC7B,eAAuB;AAC9B,QAAI,CAAC,KAAK,mBAAmB;AAC5B,WAAK,oBAAoB;AACzB,WAAK,SAAS,KAAK,QAAQ,cAAc,IAAI,KAAK,QAAQ,cAAc,IAAI,IAAI,CAAC;AAAA,IAClF;AACA,WAAO,KAAK;AAAA,EACb;AAAA;AAAA,EAGQ,cAAc,IAAY;AACjC,QAAI,KAAK,QAAQ,UAAU,IAAI,EAAE,EAAG,QAAO,KAAK,QAAQ;AACxD,QAAI,KAAK,QAAQ,QAAQ,IAAI,EAAE,EAAG,QAAO,KAAK,QAAQ;AACtD,WAAO;AAAA,EACR;AAAA,EAEA,IAAI,IAA2B;AAC9B,SAAK,gBAAgB;AACrB,YAAQ,KAAK,QAAQ,UAAU,IAAI,EAAE,KAAK,KAAK,QAAQ,QAAQ,IAAI,EAAE,IAAI;AAAA,EAC1E;AAAA,EAEA,IAAI,IAAY,QAAiB;AAChC,SAAK,gBAAgB;AACrB,6BAAO,OAAO,OAAO,IAAI,kDAAkD;AAC3E,UAAM,QAAQ,KAAK,aAAa;AAEhC,QAAI,KAAK,QAAQ,WAAW,IAAI,EAAE,GAAG;AACpC,WAAK,QAAQ,WAAW,OAAO,EAAE;AAAA,IAClC;AAEA,UAAM,MAAM,KAAK,QAAQ,YAAY,IAAI,OAAO,QAAQ,IACrD,KAAK,QAAQ,UACb,KAAK,QAAQ;AAChB,QAAI,IAAI,IAAI;AAAA,MACX,WAAO,wBAAU,MAAM;AAAA,MACvB,kBAAkB;AAAA,IACnB,CAAC;AAAA,EACF;AAAA,EAEA,OAAO,IAAkB;AACxB,SAAK,gBAAgB;AAErB,UAAM,MAAM,KAAK,cAAc,EAAE;AACjC,QAAI,CAAC,IAAK;AACV,UAAM,QAAQ,KAAK,aAAa;AAChC,QAAI,OAAO,EAAE;AACb,SAAK,QAAQ,WAAW,IAAI,IAAI,KAAK;AACrC,SAAK,QAAQ,gBAAgB;AAAA,EAC9B;AAAA;AAAA,EAIA,CAAC,UAAyC;AACzC,SAAK,gBAAgB;AACrB,eAAW,OAAO,CAAC,KAAK,QAAQ,WAAW,KAAK,QAAQ,OAAO,GAAG;AACjE,iBAAW,CAAC,IAAI,MAAM,KAAK,IAAI,QAAQ,GAAG;AACzC,aAAK,gBAAgB;AACrB,cAAM,CAAC,IAAI,OAAO,KAAK;AAAA,MACxB;AAAA,IACD;AAAA,EACD;AAAA,EAEA,CAAC,OAAiC;AACjC,SAAK,gBAAgB;AACrB,eAAW,OAAO,CAAC,KAAK,QAAQ,WAAW,KAAK,QAAQ,OAAO,GAAG;AACjE,iBAAW,OAAO,IAAI,KAAK,GAAG;AAC7B,aAAK,gBAAgB;AACrB,cAAM;AAAA,MACP;AAAA,IACD;AAAA,EACD;AAAA,EAEA,CAAC,SAA8B;AAC9B,SAAK,gBAAgB;AACrB,eAAW,OAAO,CAAC,KAAK,QAAQ,WAAW,KAAK,QAAQ,OAAO,GAAG;AACjE,iBAAW,UAAU,IAAI,OAAO,GAAG;AAClC,aAAK,gBAAgB;AACrB,cAAM,OAAO;AAAA,MACd;AAAA,IACD;AAAA,EACD;AAAA,EAEA,YAA8B;AAC7B,SAAK,gBAAgB;AACrB,WAAO,KAAK,QAAQ,OAAO,IAAI;AAAA,EAChC;AAAA,EAEA,UAAU,QAAgC;AACzC,SAAK,gBAAgB;AACrB,SAAK,QAAQ,OAAO,IAAI,MAAM;AAAA,EAC/B;AAAA,EAEA,gBAAgB,YAAuE;AACtF,SAAK,gBAAgB;AACrB,UAAM,QAAQ,KAAK,QAAQ,cAAc,IAAI;AAC7C,QAAI,eAAe,MAAO,QAAO;AACjC,QAAI,aAAa,OAAO;AAEvB,mBAAa;AAAA,IACd;AACA,UAAM,OAA6B,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC,EAAE;AAC3D,UAAM,UAAU,aAAa,KAAK,QAAQ,8BAA8B,IAAI;AAE5E,eAAW,OAAO,CAAC,KAAK,QAAQ,WAAW,KAAK,QAAQ,OAAO,GAAG;AACjE,iBAAW,OAAO,IAAI,OAAO,GAAG;AAC/B,YAAI,WAAW,IAAI,mBAAmB,YAAY;AAEjD,eAAK,KAAK,IAAI,MAAM,EAAE,IAAI,IAAI;AAAA,QAC/B;AAAA,MACD;AAAA,IACD;AACA,QAAI,CAAC,SAAS;AACb,iBAAW,CAAC,IAAIA,MAAK,KAAK,KAAK,QAAQ,WAAW,QAAQ,GAAG;AAC5D,YAAIA,SAAQ,YAAY;AAEvB,eAAK,QAAQ,KAAK,EAAE;AAAA,QACrB;AAAA,MACD;AAAA,IACD;AACA,WAAO,EAAE,MAAM,QAAQ;AAAA,EACxB;AACD;",
|
|
6
6
|
"names": ["clock"]
|
|
7
7
|
}
|
package/dist-esm/index.mjs
CHANGED
|
@@ -76,23 +76,23 @@ class InMemorySyncStorage {
|
|
|
76
76
|
onChange
|
|
77
77
|
} = {}) {
|
|
78
78
|
this.objectTypes = new Set(objectTypes ?? []);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
);
|
|
79
|
+
let maxClockValue = 0;
|
|
80
|
+
const documentEntries = [];
|
|
81
|
+
const objectEntries = [];
|
|
82
|
+
for (const d of snapshot.documents) {
|
|
83
|
+
if (d.lastChangedClock > maxClockValue) maxClockValue = d.lastChangedClock;
|
|
84
|
+
const entries = this.objectTypes.has(d.state.typeName) ? objectEntries : documentEntries;
|
|
85
|
+
entries.push([
|
|
86
|
+
d.state.id,
|
|
87
|
+
{ state: devFreeze(d.state), lastChangedClock: d.lastChangedClock }
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
const tombstoneEntries = objectMapEntries(snapshot.tombstones ?? {});
|
|
91
|
+
for (const [, clock] of tombstoneEntries) {
|
|
92
|
+
if (clock > maxClockValue) maxClockValue = clock;
|
|
93
|
+
}
|
|
94
|
+
this.documents = new AtomMap("room documents", documentEntries);
|
|
95
|
+
this.objects = new AtomMap("room objects", objectEntries);
|
|
96
96
|
const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0);
|
|
97
97
|
this.documentClock = atom("document clock", documentClock);
|
|
98
98
|
const tombstoneHistoryStartsAtClock = Math.min(
|
|
@@ -108,7 +108,7 @@ class InMemorySyncStorage {
|
|
|
108
108
|
"room tombstones",
|
|
109
109
|
// If the tombstone history starts now (or we didn't have the
|
|
110
110
|
// tombstoneHistoryStartsAtClock) then there are no tombstones
|
|
111
|
-
tombstoneHistoryStartsAtClock === documentClock ? [] :
|
|
111
|
+
tombstoneHistoryStartsAtClock === documentClock ? [] : tombstoneEntries
|
|
112
112
|
);
|
|
113
113
|
if (onChange) {
|
|
114
114
|
this.onChange(onChange);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/InMemorySyncStorage.ts"],
|
|
4
|
-
"sourcesContent": ["import { atom, Atom, transaction } from '@tldraw/state'\nimport { AtomMap, devFreeze, SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport {\n\tcreateTLSchema,\n\tDocumentRecordType,\n\tPageRecordType,\n\tTLDOCUMENT_ID,\n\tTLPageId,\n} from '@tldraw/tlschema'\nimport { assert, IndexKey, objectMapEntries, throttle } from '@tldraw/utils'\nimport { MicrotaskNotifier } from './MicrotaskNotifier'\nimport { RoomSnapshot } from './TLSyncRoom'\nimport {\n\tTLSyncForwardDiff,\n\tTLSyncStorage,\n\tTLSyncStorageGetChangesSinceResult,\n\tTLSyncStorageOnChangeCallbackProps,\n\tTLSyncStorageTransaction,\n\tTLSyncStorageTransactionCallback,\n\tTLSyncStorageTransactionOptions,\n\tTLSyncStorageTransactionResult,\n} from './TLSyncStorage'\n\n/** @internal */\nexport const TOMBSTONE_PRUNE_BUFFER_SIZE = 1000\n/** @internal */\nexport const MAX_TOMBSTONES = 5000\n\n/**\n * Result of computing which tombstones to prune.\n * @internal\n */\nexport interface TombstonePruneResult {\n\t/** The new value for tombstoneHistoryStartsAtClock */\n\tnewTombstoneHistoryStartsAtClock: number\n\t/** IDs of tombstones to delete */\n\tidsToDelete: string[]\n}\n\n/**\n * Computes which tombstones should be pruned, avoiding partial history for any clock value.\n * Returns null if no pruning is needed (tombstone count <= maxTombstones).\n *\n * @param tombstones - Array of tombstones sorted by clock ascending (oldest first)\n * @param documentClock - Current document clock (used as fallback if all tombstones are deleted)\n * @param maxTombstones - Maximum number of tombstones to keep (default: MAX_TOMBSTONES)\n * @param pruneBufferSize - Extra tombstones to prune beyond the threshold (default: TOMBSTONE_PRUNE_BUFFER_SIZE)\n * @returns Pruning result or null if no pruning needed\n *\n * @internal\n */\nexport function computeTombstonePruning({\n\ttombstones,\n\tdocumentClock,\n\tmaxTombstones = MAX_TOMBSTONES,\n\tpruneBufferSize = TOMBSTONE_PRUNE_BUFFER_SIZE,\n}: {\n\ttombstones: Array<{ id: string; clock: number }>\n\tdocumentClock: number\n\tmaxTombstones?: number\n\tpruneBufferSize?: number\n}): TombstonePruneResult | null {\n\tif (tombstones.length <= maxTombstones) {\n\t\treturn null\n\t}\n\n\t// Determine how many to delete, avoiding partial history for a clock value\n\tlet cutoff = pruneBufferSize + tombstones.length - maxTombstones\n\twhile (\n\t\tcutoff < tombstones.length &&\n\t\ttombstones[cutoff - 1]?.clock === tombstones[cutoff]?.clock\n\t) {\n\t\tcutoff++\n\t}\n\n\t// Set history start to the oldest remaining tombstone's clock\n\t// (or documentClock if we're deleting everything)\n\tconst oldestRemaining = tombstones[cutoff]\n\tconst newTombstoneHistoryStartsAtClock = oldestRemaining?.clock ?? documentClock\n\n\t// Collect the oldest tombstones to delete (first cutoff entries)\n\tconst idsToDelete = tombstones.slice(0, cutoff).map((t) => t.id)\n\n\treturn { newTombstoneHistoryStartsAtClock, idsToDelete }\n}\n\n/**\n * Default initial snapshot for a new room.\n * @public\n */\nexport const DEFAULT_INITIAL_SNAPSHOT = {\n\tdocumentClock: 0,\n\ttombstoneHistoryStartsAtClock: 0,\n\tschema: createTLSchema().serialize(),\n\tdocuments: [\n\t\t{\n\t\t\tstate: DocumentRecordType.create({ id: TLDOCUMENT_ID }),\n\t\t\tlastChangedClock: 0,\n\t\t},\n\t\t{\n\t\t\tstate: PageRecordType.create({\n\t\t\t\tid: 'page:page' as TLPageId,\n\t\t\t\tname: 'Page 1',\n\t\t\t\tindex: 'a1' as IndexKey,\n\t\t\t}),\n\t\t\tlastChangedClock: 0,\n\t\t},\n\t],\n}\n\n/**\n * In-memory implementation of TLSyncStorage using AtomMap for documents and tombstones,\n * and atoms for clock values. This is the default storage implementation used by TLSyncRoom.\n *\n * @public\n */\nexport class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {\n\t/** @internal */\n\tdocuments: AtomMap<string, { state: R; lastChangedClock: number }>\n\t/**\n\t * Object-store lane records, partitioned out of `documents` so they never appear in the\n\t * document snapshot. They share the same clock, tombstones, and transactions as documents.\n\t * @internal\n\t */\n\tobjects: AtomMap<string, { state: R; lastChangedClock: number }>\n\t/** @internal */\n\treadonly objectTypes: ReadonlySet<string>\n\t/** @internal */\n\ttombstones: AtomMap<string, number>\n\t/** @internal */\n\tschema: Atom<SerializedSchema>\n\t/** @internal */\n\tdocumentClock: Atom<number>\n\t/** @internal */\n\ttombstoneHistoryStartsAtClock: Atom<number>\n\n\tprivate notifier = new MicrotaskNotifier<[TLSyncStorageOnChangeCallbackProps]>()\n\tonChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void {\n\t\treturn this.notifier.register(callback)\n\t}\n\n\tconstructor({\n\t\tsnapshot = DEFAULT_INITIAL_SNAPSHOT,\n\t\tobjectTypes,\n\t\tonChange,\n\t}: {\n\t\tsnapshot?: RoomSnapshot\n\t\t/**\n\t\t * Record type names stored in the object-store lane. Records of these types are routed to\n\t\t * a separate partition: excluded from `getSnapshot()`, returned by `getObjectsSnapshot()`.\n\t\t */\n\t\tobjectTypes?: readonly string[]\n\t\tonChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown\n\t} = {}) {\n\t\tthis.objectTypes = new Set(objectTypes ?? [])\n\t\tconst maxClockValue = Math.max(\n\t\t\t0,\n\t\t\t...Object.values(snapshot.tombstones ?? {}),\n\t\t\t...Object.values(snapshot.documents.map((d) => d.lastChangedClock))\n\t\t)\n\t\t// route snapshot entries into their partitions (a seed snapshot may carry object-lane\n\t\t// records merged in, e.g. loaded from a separate persistence lane)\n\t\tconst toEntry = (\n\t\t\td: RoomSnapshot['documents'][number]\n\t\t): [string, { state: R; lastChangedClock: number }] => [\n\t\t\td.state.id,\n\t\t\t{ state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },\n\t\t]\n\t\tthis.documents = new AtomMap(\n\t\t\t'room documents',\n\t\t\tsnapshot.documents.filter((d) => !this.objectTypes.has(d.state.typeName)).map(toEntry)\n\t\t)\n\t\tthis.objects = new AtomMap(\n\t\t\t'room objects',\n\t\t\tsnapshot.documents.filter((d) => this.objectTypes.has(d.state.typeName)).map(toEntry)\n\t\t)\n\t\tconst documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)\n\n\t\tthis.documentClock = atom('document clock', documentClock)\n\t\t// math.min to make sure the tombstone history starts at or before the document clock\n\t\tconst tombstoneHistoryStartsAtClock = Math.min(\n\t\t\tsnapshot.tombstoneHistoryStartsAtClock ?? documentClock,\n\t\t\tdocumentClock\n\t\t)\n\t\tthis.tombstoneHistoryStartsAtClock = atom(\n\t\t\t'tombstone history starts at clock',\n\t\t\ttombstoneHistoryStartsAtClock\n\t\t)\n\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\tthis.schema = atom('schema', snapshot.schema ?? createTLSchema().serializeEarliestVersion())\n\t\tthis.tombstones = new AtomMap(\n\t\t\t'room tombstones',\n\t\t\t// If the tombstone history starts now (or we didn't have the\n\t\t\t// tombstoneHistoryStartsAtClock) then there are no tombstones\n\t\t\ttombstoneHistoryStartsAtClock === documentClock\n\t\t\t\t? []\n\t\t\t\t: objectMapEntries(snapshot.tombstones ?? {})\n\t\t)\n\t\tif (onChange) {\n\t\t\tthis.onChange(onChange)\n\t\t}\n\t}\n\n\ttransaction<T>(\n\t\tcallback: TLSyncStorageTransactionCallback<R, T>,\n\t\topts?: TLSyncStorageTransactionOptions\n\t): TLSyncStorageTransactionResult<T, R> {\n\t\tconst clockBefore = this.documentClock.get()\n\t\tconst trackChanges = opts?.emitChanges === 'always'\n\t\tconst txn = new InMemorySyncStorageTransaction<R>(this)\n\t\tlet result: T\n\t\tlet changes: TLSyncForwardDiff<R> | undefined\n\t\ttry {\n\t\t\tresult = transaction(() => {\n\t\t\t\treturn callback(txn as any)\n\t\t\t}) as T\n\t\t\tif (trackChanges) {\n\t\t\t\tchanges = txn.getChangesSince(clockBefore)?.diff\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error('Error in transaction', error)\n\t\t\tthrow error\n\t\t} finally {\n\t\t\ttxn.close()\n\t\t}\n\t\tif (\n\t\t\ttypeof result === 'object' &&\n\t\t\tresult &&\n\t\t\t'then' in result &&\n\t\t\ttypeof result.then === 'function'\n\t\t) {\n\t\t\tconst err = new Error('Transaction must return a value, not a promise')\n\t\t\tconsole.error(err)\n\t\t\tthrow err\n\t\t}\n\n\t\tconst clockAfter = this.documentClock.get()\n\t\tconst didChange = clockAfter > clockBefore\n\t\tif (didChange) {\n\t\t\tthis.notifier.notify({ id: opts?.id, documentClock: clockAfter })\n\t\t}\n\t\t// InMemorySyncStorage applies changes verbatim, so we only emit changes\n\t\t// when 'always' is specified (not for 'when-different')\n\t\treturn { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }\n\t}\n\n\tgetClock(): number {\n\t\treturn this.documentClock.get()\n\t}\n\n\t/** @internal */\n\tpruneTombstones = throttle(\n\t\t() => {\n\t\t\tif (this.tombstones.size > MAX_TOMBSTONES) {\n\t\t\t\t// Convert to array and sort by clock ascending (oldest first)\n\t\t\t\tconst tombstones = Array.from(this.tombstones.entries())\n\t\t\t\t\t.map(([id, clock]) => ({ id, clock }))\n\t\t\t\t\t.sort((a, b) => a.clock - b.clock)\n\n\t\t\t\tconst result = computeTombstonePruning({\n\t\t\t\t\ttombstones,\n\t\t\t\t\tdocumentClock: this.documentClock.get(),\n\t\t\t\t})\n\t\t\t\tif (result) {\n\t\t\t\t\tthis.tombstoneHistoryStartsAtClock.set(result.newTombstoneHistoryStartsAtClock)\n\t\t\t\t\tthis.tombstones.deleteMany(result.idsToDelete)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t1000,\n\t\t// prevent this from running synchronously to avoid blocking requests\n\t\t{ leading: false }\n\t)\n\n\tgetSnapshot(): RoomSnapshot {\n\t\t// object-lane records live in their own partition, so the document snapshot is\n\t\t// pure-document by construction\n\t\treturn {\n\t\t\ttombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),\n\t\t\tdocumentClock: this.documentClock.get(),\n\t\t\tdocuments: Array.from(this.documents.values()),\n\t\t\ttombstones: Object.fromEntries(this.tombstones.entries()),\n\t\t\tschema: this.schema.get(),\n\t\t}\n\t}\n\n\tgetObjectsSnapshot(): RoomSnapshot['documents'] {\n\t\treturn Array.from(this.objects.values())\n\t}\n\n\tgetObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {\n\t\tconst result: RoomSnapshot['documents'] = []\n\t\tconst seen = new Set<string>()\n\t\tfor (const id of ids) {\n\t\t\tif (seen.has(id)) continue\n\t\t\tseen.add(id)\n\t\t\tconst doc = this.objects.get(id)\n\t\t\tif (doc) result.push(doc)\n\t\t}\n\t\treturn result\n\t}\n}\n\n/**\n * Transaction implementation for InMemorySyncStorage.\n * Provides access to documents, tombstones, and metadata within a transaction.\n *\n * @internal\n */\nclass InMemorySyncStorageTransaction<\n\tR extends UnknownRecord,\n> implements TLSyncStorageTransaction<R> {\n\tprivate _clock\n\tprivate _closed = false\n\n\tconstructor(private storage: InMemorySyncStorage<R>) {\n\t\tthis._clock = this.storage.documentClock.get()\n\t}\n\n\t/** @internal */\n\tclose() {\n\t\tthis._closed = true\n\t}\n\n\tprivate assertNotClosed() {\n\t\tassert(!this._closed, 'Transaction has ended, iterator cannot be consumed')\n\t}\n\n\tgetClock(): number {\n\t\treturn this._clock\n\t}\n\n\tprivate didIncrementClock: boolean = false\n\tprivate getNextClock(): number {\n\t\tif (!this.didIncrementClock) {\n\t\t\tthis.didIncrementClock = true\n\t\t\tthis._clock = this.storage.documentClock.set(this.storage.documentClock.get() + 1)\n\t\t}\n\t\treturn this._clock\n\t}\n\n\t/** The partition a record with this id currently lives in, if any. */\n\tprivate mapContaining(id: string) {\n\t\tif (this.storage.documents.has(id)) return this.storage.documents\n\t\tif (this.storage.objects.has(id)) return this.storage.objects\n\t\treturn undefined\n\t}\n\n\tget(id: string): R | undefined {\n\t\tthis.assertNotClosed()\n\t\treturn (this.storage.documents.get(id) ?? this.storage.objects.get(id))?.state\n\t}\n\n\tset(id: string, record: R): void {\n\t\tthis.assertNotClosed()\n\t\tassert(id === record.id, `Record id mismatch: key does not match record.id`)\n\t\tconst clock = this.getNextClock()\n\t\t// Automatically clear tombstone if it exists\n\t\tif (this.storage.tombstones.has(id)) {\n\t\t\tthis.storage.tombstones.delete(id)\n\t\t}\n\t\t// route by type: object-lane records live in their own partition\n\t\tconst map = this.storage.objectTypes.has(record.typeName)\n\t\t\t? this.storage.objects\n\t\t\t: this.storage.documents\n\t\tmap.set(id, {\n\t\t\tstate: devFreeze(record) as R,\n\t\t\tlastChangedClock: clock,\n\t\t})\n\t}\n\n\tdelete(id: string): void {\n\t\tthis.assertNotClosed()\n\t\t// Only create a tombstone if the record actually exists\n\t\tconst map = this.mapContaining(id)\n\t\tif (!map) return\n\t\tconst clock = this.getNextClock()\n\t\tmap.delete(id)\n\t\tthis.storage.tombstones.set(id, clock)\n\t\tthis.storage.pruneTombstones()\n\t}\n\n\t// iteration spans both partitions so schema migrations cover object-lane records too\n\n\t*entries(): IterableIterator<[string, R]> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const [id, record] of map.entries()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield [id, record.state]\n\t\t\t}\n\t\t}\n\t}\n\n\t*keys(): IterableIterator<string> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const key of map.keys()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield key\n\t\t\t}\n\t\t}\n\t}\n\n\t*values(): IterableIterator<R> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const record of map.values()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield record.state\n\t\t\t}\n\t\t}\n\t}\n\n\tgetSchema(): SerializedSchema {\n\t\tthis.assertNotClosed()\n\t\treturn this.storage.schema.get()\n\t}\n\n\tsetSchema(schema: SerializedSchema): void {\n\t\tthis.assertNotClosed()\n\t\tthis.storage.schema.set(schema)\n\t}\n\n\tgetChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {\n\t\tthis.assertNotClosed()\n\t\tconst clock = this.storage.documentClock.get()\n\t\tif (sinceClock === clock) return undefined\n\t\tif (sinceClock > clock) {\n\t\t\t// something went wrong, wipe the slate clean\n\t\t\tsinceClock = -1\n\t\t}\n\t\tconst diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }\n\t\tconst wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get()\n\t\t// both partitions share the clock and tombstones, so the change feed spans both\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const doc of map.values()) {\n\t\t\t\tif (wipeAll || doc.lastChangedClock > sinceClock) {\n\t\t\t\t\t// For historical changes, we don't have \"from\" state, so use added\n\t\t\t\t\tdiff.puts[doc.state.id] = doc.state as R\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!wipeAll) {\n\t\t\tfor (const [id, clock] of this.storage.tombstones.entries()) {\n\t\t\t\tif (clock > sinceClock) {\n\t\t\t\t\t// For tombstones, we don't have the removed record, use placeholder\n\t\t\t\t\tdiff.deletes.push(id)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn { diff, wipeAll }\n\t}\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,MAAY,mBAAmB;AACxC,SAAS,SAAS,iBAAkD;AACpE;AAAA,EACC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEM;AACP,SAAS,QAAkB,kBAAkB,gBAAgB;AAC7D,SAAS,yBAAyB;AAc3B,MAAM,8BAA8B;AAEpC,MAAM,iBAAiB;AAyBvB,SAAS,wBAAwB;AAAA,EACvC;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB,kBAAkB;AACnB,GAKgC;AAC/B,MAAI,WAAW,UAAU,eAAe;AACvC,WAAO;AAAA,EACR;AAGA,MAAI,SAAS,kBAAkB,WAAW,SAAS;AACnD,SACC,SAAS,WAAW,UACpB,WAAW,SAAS,CAAC,GAAG,UAAU,WAAW,MAAM,GAAG,OACrD;AACD;AAAA,EACD;AAIA,QAAM,kBAAkB,WAAW,MAAM;AACzC,QAAM,mCAAmC,iBAAiB,SAAS;AAGnE,QAAM,cAAc,WAAW,MAAM,GAAG,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE;AAE/D,SAAO,EAAE,kCAAkC,YAAY;AACxD;AAMO,MAAM,2BAA2B;AAAA,EACvC,eAAe;AAAA,EACf,+BAA+B;AAAA,EAC/B,QAAQ,eAAe,EAAE,UAAU;AAAA,EACnC,WAAW;AAAA,IACV;AAAA,MACC,OAAO,mBAAmB,OAAO,EAAE,IAAI,cAAc,CAAC;AAAA,MACtD,kBAAkB;AAAA,IACnB;AAAA,IACA;AAAA,MACC,OAAO,eAAe,OAAO;AAAA,QAC5B,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,OAAO;AAAA,MACR,CAAC;AAAA,MACD,kBAAkB;AAAA,IACnB;AAAA,EACD;AACD;AAQO,MAAM,oBAAyE;AAAA;AAAA,EAErF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA;AAAA;AAAA,EAES;AAAA;AAAA,EAET;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA,EAEQ,WAAW,IAAI,kBAAwD;AAAA,EAC/E,SAAS,UAA4E;AACpF,WAAO,KAAK,SAAS,SAAS,QAAQ;AAAA,EACvC;AAAA,EAEA,YAAY;AAAA,IACX,WAAW;AAAA,IACX;AAAA,IACA;AAAA,EACD,IAQI,CAAC,GAAG;AACP,SAAK,cAAc,IAAI,IAAI,eAAe,CAAC,CAAC;
|
|
4
|
+
"sourcesContent": ["import { atom, Atom, transaction } from '@tldraw/state'\nimport { AtomMap, devFreeze, SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport {\n\tcreateTLSchema,\n\tDocumentRecordType,\n\tPageRecordType,\n\tTLDOCUMENT_ID,\n\tTLPageId,\n} from '@tldraw/tlschema'\nimport { assert, IndexKey, objectMapEntries, throttle } from '@tldraw/utils'\nimport { MicrotaskNotifier } from './MicrotaskNotifier'\nimport { RoomSnapshot } from './TLSyncRoom'\nimport {\n\tTLSyncForwardDiff,\n\tTLSyncStorage,\n\tTLSyncStorageGetChangesSinceResult,\n\tTLSyncStorageOnChangeCallbackProps,\n\tTLSyncStorageTransaction,\n\tTLSyncStorageTransactionCallback,\n\tTLSyncStorageTransactionOptions,\n\tTLSyncStorageTransactionResult,\n} from './TLSyncStorage'\n\n/** @internal */\nexport const TOMBSTONE_PRUNE_BUFFER_SIZE = 1000\n/** @internal */\nexport const MAX_TOMBSTONES = 5000\n\n/**\n * Result of computing which tombstones to prune.\n * @internal\n */\nexport interface TombstonePruneResult {\n\t/** The new value for tombstoneHistoryStartsAtClock */\n\tnewTombstoneHistoryStartsAtClock: number\n\t/** IDs of tombstones to delete */\n\tidsToDelete: string[]\n}\n\n/**\n * Computes which tombstones should be pruned, avoiding partial history for any clock value.\n * Returns null if no pruning is needed (tombstone count <= maxTombstones).\n *\n * @param tombstones - Array of tombstones sorted by clock ascending (oldest first)\n * @param documentClock - Current document clock (used as fallback if all tombstones are deleted)\n * @param maxTombstones - Maximum number of tombstones to keep (default: MAX_TOMBSTONES)\n * @param pruneBufferSize - Extra tombstones to prune beyond the threshold (default: TOMBSTONE_PRUNE_BUFFER_SIZE)\n * @returns Pruning result or null if no pruning needed\n *\n * @internal\n */\nexport function computeTombstonePruning({\n\ttombstones,\n\tdocumentClock,\n\tmaxTombstones = MAX_TOMBSTONES,\n\tpruneBufferSize = TOMBSTONE_PRUNE_BUFFER_SIZE,\n}: {\n\ttombstones: Array<{ id: string; clock: number }>\n\tdocumentClock: number\n\tmaxTombstones?: number\n\tpruneBufferSize?: number\n}): TombstonePruneResult | null {\n\tif (tombstones.length <= maxTombstones) {\n\t\treturn null\n\t}\n\n\t// Determine how many to delete, avoiding partial history for a clock value\n\tlet cutoff = pruneBufferSize + tombstones.length - maxTombstones\n\twhile (\n\t\tcutoff < tombstones.length &&\n\t\ttombstones[cutoff - 1]?.clock === tombstones[cutoff]?.clock\n\t) {\n\t\tcutoff++\n\t}\n\n\t// Set history start to the oldest remaining tombstone's clock\n\t// (or documentClock if we're deleting everything)\n\tconst oldestRemaining = tombstones[cutoff]\n\tconst newTombstoneHistoryStartsAtClock = oldestRemaining?.clock ?? documentClock\n\n\t// Collect the oldest tombstones to delete (first cutoff entries)\n\tconst idsToDelete = tombstones.slice(0, cutoff).map((t) => t.id)\n\n\treturn { newTombstoneHistoryStartsAtClock, idsToDelete }\n}\n\n/**\n * Default initial snapshot for a new room.\n * @public\n */\nexport const DEFAULT_INITIAL_SNAPSHOT = {\n\tdocumentClock: 0,\n\ttombstoneHistoryStartsAtClock: 0,\n\tschema: createTLSchema().serialize(),\n\tdocuments: [\n\t\t{\n\t\t\tstate: DocumentRecordType.create({ id: TLDOCUMENT_ID }),\n\t\t\tlastChangedClock: 0,\n\t\t},\n\t\t{\n\t\t\tstate: PageRecordType.create({\n\t\t\t\tid: 'page:page' as TLPageId,\n\t\t\t\tname: 'Page 1',\n\t\t\t\tindex: 'a1' as IndexKey,\n\t\t\t}),\n\t\t\tlastChangedClock: 0,\n\t\t},\n\t],\n}\n\n/**\n * In-memory implementation of TLSyncStorage using AtomMap for documents and tombstones,\n * and atoms for clock values. This is the default storage implementation used by TLSyncRoom.\n *\n * @public\n */\nexport class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {\n\t/** @internal */\n\tdocuments: AtomMap<string, { state: R; lastChangedClock: number }>\n\t/**\n\t * Object-store lane records, partitioned out of `documents` so they never appear in the\n\t * document snapshot. They share the same clock, tombstones, and transactions as documents.\n\t * @internal\n\t */\n\tobjects: AtomMap<string, { state: R; lastChangedClock: number }>\n\t/** @internal */\n\treadonly objectTypes: ReadonlySet<string>\n\t/** @internal */\n\ttombstones: AtomMap<string, number>\n\t/** @internal */\n\tschema: Atom<SerializedSchema>\n\t/** @internal */\n\tdocumentClock: Atom<number>\n\t/** @internal */\n\ttombstoneHistoryStartsAtClock: Atom<number>\n\n\tprivate notifier = new MicrotaskNotifier<[TLSyncStorageOnChangeCallbackProps]>()\n\tonChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void {\n\t\treturn this.notifier.register(callback)\n\t}\n\n\tconstructor({\n\t\tsnapshot = DEFAULT_INITIAL_SNAPSHOT,\n\t\tobjectTypes,\n\t\tonChange,\n\t}: {\n\t\tsnapshot?: RoomSnapshot\n\t\t/**\n\t\t * Record type names stored in the object-store lane. Records of these types are routed to\n\t\t * a separate partition: excluded from `getSnapshot()`, returned by `getObjectsSnapshot()`.\n\t\t */\n\t\tobjectTypes?: readonly string[]\n\t\tonChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown\n\t} = {}) {\n\t\tthis.objectTypes = new Set(objectTypes ?? [])\n\t\t// a loop rather than `Math.max(0, ...clocks)`: spreading a large room's clocks as call\n\t\t// arguments overflows the stack at roughly 100k+ records/tombstones\n\t\tlet maxClockValue = 0\n\t\t// route snapshot entries into their partitions (a seed snapshot may carry object-lane\n\t\t// records merged in, e.g. loaded from a separate persistence lane)\n\t\tconst documentEntries: [string, { state: R; lastChangedClock: number }][] = []\n\t\tconst objectEntries: [string, { state: R; lastChangedClock: number }][] = []\n\t\tfor (const d of snapshot.documents) {\n\t\t\tif (d.lastChangedClock > maxClockValue) maxClockValue = d.lastChangedClock\n\t\t\tconst entries = this.objectTypes.has(d.state.typeName) ? objectEntries : documentEntries\n\t\t\tentries.push([\n\t\t\t\td.state.id,\n\t\t\t\t{ state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },\n\t\t\t])\n\t\t}\n\t\tconst tombstoneEntries = objectMapEntries(snapshot.tombstones ?? {})\n\t\tfor (const [, clock] of tombstoneEntries) {\n\t\t\tif (clock > maxClockValue) maxClockValue = clock\n\t\t}\n\t\tthis.documents = new AtomMap('room documents', documentEntries)\n\t\tthis.objects = new AtomMap('room objects', objectEntries)\n\t\tconst documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)\n\n\t\tthis.documentClock = atom('document clock', documentClock)\n\t\t// math.min to make sure the tombstone history starts at or before the document clock\n\t\tconst tombstoneHistoryStartsAtClock = Math.min(\n\t\t\tsnapshot.tombstoneHistoryStartsAtClock ?? documentClock,\n\t\t\tdocumentClock\n\t\t)\n\t\tthis.tombstoneHistoryStartsAtClock = atom(\n\t\t\t'tombstone history starts at clock',\n\t\t\ttombstoneHistoryStartsAtClock\n\t\t)\n\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\tthis.schema = atom('schema', snapshot.schema ?? createTLSchema().serializeEarliestVersion())\n\t\tthis.tombstones = new AtomMap(\n\t\t\t'room tombstones',\n\t\t\t// If the tombstone history starts now (or we didn't have the\n\t\t\t// tombstoneHistoryStartsAtClock) then there are no tombstones\n\t\t\ttombstoneHistoryStartsAtClock === documentClock ? [] : tombstoneEntries\n\t\t)\n\t\tif (onChange) {\n\t\t\tthis.onChange(onChange)\n\t\t}\n\t}\n\n\ttransaction<T>(\n\t\tcallback: TLSyncStorageTransactionCallback<R, T>,\n\t\topts?: TLSyncStorageTransactionOptions\n\t): TLSyncStorageTransactionResult<T, R> {\n\t\tconst clockBefore = this.documentClock.get()\n\t\tconst trackChanges = opts?.emitChanges === 'always'\n\t\tconst txn = new InMemorySyncStorageTransaction<R>(this)\n\t\tlet result: T\n\t\tlet changes: TLSyncForwardDiff<R> | undefined\n\t\ttry {\n\t\t\tresult = transaction(() => {\n\t\t\t\treturn callback(txn as any)\n\t\t\t}) as T\n\t\t\tif (trackChanges) {\n\t\t\t\tchanges = txn.getChangesSince(clockBefore)?.diff\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error('Error in transaction', error)\n\t\t\tthrow error\n\t\t} finally {\n\t\t\ttxn.close()\n\t\t}\n\t\tif (\n\t\t\ttypeof result === 'object' &&\n\t\t\tresult &&\n\t\t\t'then' in result &&\n\t\t\ttypeof result.then === 'function'\n\t\t) {\n\t\t\tconst err = new Error('Transaction must return a value, not a promise')\n\t\t\tconsole.error(err)\n\t\t\tthrow err\n\t\t}\n\n\t\tconst clockAfter = this.documentClock.get()\n\t\tconst didChange = clockAfter > clockBefore\n\t\tif (didChange) {\n\t\t\tthis.notifier.notify({ id: opts?.id, documentClock: clockAfter })\n\t\t}\n\t\t// InMemorySyncStorage applies changes verbatim, so we only emit changes\n\t\t// when 'always' is specified (not for 'when-different')\n\t\treturn { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }\n\t}\n\n\tgetClock(): number {\n\t\treturn this.documentClock.get()\n\t}\n\n\t/** @internal */\n\tpruneTombstones = throttle(\n\t\t() => {\n\t\t\tif (this.tombstones.size > MAX_TOMBSTONES) {\n\t\t\t\t// Convert to array and sort by clock ascending (oldest first)\n\t\t\t\tconst tombstones = Array.from(this.tombstones.entries())\n\t\t\t\t\t.map(([id, clock]) => ({ id, clock }))\n\t\t\t\t\t.sort((a, b) => a.clock - b.clock)\n\n\t\t\t\tconst result = computeTombstonePruning({\n\t\t\t\t\ttombstones,\n\t\t\t\t\tdocumentClock: this.documentClock.get(),\n\t\t\t\t})\n\t\t\t\tif (result) {\n\t\t\t\t\tthis.tombstoneHistoryStartsAtClock.set(result.newTombstoneHistoryStartsAtClock)\n\t\t\t\t\tthis.tombstones.deleteMany(result.idsToDelete)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t1000,\n\t\t// prevent this from running synchronously to avoid blocking requests\n\t\t{ leading: false }\n\t)\n\n\tgetSnapshot(): RoomSnapshot {\n\t\t// object-lane records live in their own partition, so the document snapshot is\n\t\t// pure-document by construction\n\t\treturn {\n\t\t\ttombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),\n\t\t\tdocumentClock: this.documentClock.get(),\n\t\t\tdocuments: Array.from(this.documents.values()),\n\t\t\ttombstones: Object.fromEntries(this.tombstones.entries()),\n\t\t\tschema: this.schema.get(),\n\t\t}\n\t}\n\n\tgetObjectsSnapshot(): RoomSnapshot['documents'] {\n\t\treturn Array.from(this.objects.values())\n\t}\n\n\tgetObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {\n\t\tconst result: RoomSnapshot['documents'] = []\n\t\tconst seen = new Set<string>()\n\t\tfor (const id of ids) {\n\t\t\tif (seen.has(id)) continue\n\t\t\tseen.add(id)\n\t\t\tconst doc = this.objects.get(id)\n\t\t\tif (doc) result.push(doc)\n\t\t}\n\t\treturn result\n\t}\n}\n\n/**\n * Transaction implementation for InMemorySyncStorage.\n * Provides access to documents, tombstones, and metadata within a transaction.\n *\n * @internal\n */\nclass InMemorySyncStorageTransaction<\n\tR extends UnknownRecord,\n> implements TLSyncStorageTransaction<R> {\n\tprivate _clock\n\tprivate _closed = false\n\n\tconstructor(private storage: InMemorySyncStorage<R>) {\n\t\tthis._clock = this.storage.documentClock.get()\n\t}\n\n\t/** @internal */\n\tclose() {\n\t\tthis._closed = true\n\t}\n\n\tprivate assertNotClosed() {\n\t\tassert(!this._closed, 'Transaction has ended, iterator cannot be consumed')\n\t}\n\n\tgetClock(): number {\n\t\treturn this._clock\n\t}\n\n\tprivate didIncrementClock: boolean = false\n\tprivate getNextClock(): number {\n\t\tif (!this.didIncrementClock) {\n\t\t\tthis.didIncrementClock = true\n\t\t\tthis._clock = this.storage.documentClock.set(this.storage.documentClock.get() + 1)\n\t\t}\n\t\treturn this._clock\n\t}\n\n\t/** The partition a record with this id currently lives in, if any. */\n\tprivate mapContaining(id: string) {\n\t\tif (this.storage.documents.has(id)) return this.storage.documents\n\t\tif (this.storage.objects.has(id)) return this.storage.objects\n\t\treturn undefined\n\t}\n\n\tget(id: string): R | undefined {\n\t\tthis.assertNotClosed()\n\t\treturn (this.storage.documents.get(id) ?? this.storage.objects.get(id))?.state\n\t}\n\n\tset(id: string, record: R): void {\n\t\tthis.assertNotClosed()\n\t\tassert(id === record.id, `Record id mismatch: key does not match record.id`)\n\t\tconst clock = this.getNextClock()\n\t\t// Automatically clear tombstone if it exists\n\t\tif (this.storage.tombstones.has(id)) {\n\t\t\tthis.storage.tombstones.delete(id)\n\t\t}\n\t\t// route by type: object-lane records live in their own partition\n\t\tconst map = this.storage.objectTypes.has(record.typeName)\n\t\t\t? this.storage.objects\n\t\t\t: this.storage.documents\n\t\tmap.set(id, {\n\t\t\tstate: devFreeze(record) as R,\n\t\t\tlastChangedClock: clock,\n\t\t})\n\t}\n\n\tdelete(id: string): void {\n\t\tthis.assertNotClosed()\n\t\t// Only create a tombstone if the record actually exists\n\t\tconst map = this.mapContaining(id)\n\t\tif (!map) return\n\t\tconst clock = this.getNextClock()\n\t\tmap.delete(id)\n\t\tthis.storage.tombstones.set(id, clock)\n\t\tthis.storage.pruneTombstones()\n\t}\n\n\t// iteration spans both partitions so schema migrations cover object-lane records too\n\n\t*entries(): IterableIterator<[string, R]> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const [id, record] of map.entries()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield [id, record.state]\n\t\t\t}\n\t\t}\n\t}\n\n\t*keys(): IterableIterator<string> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const key of map.keys()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield key\n\t\t\t}\n\t\t}\n\t}\n\n\t*values(): IterableIterator<R> {\n\t\tthis.assertNotClosed()\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const record of map.values()) {\n\t\t\t\tthis.assertNotClosed()\n\t\t\t\tyield record.state\n\t\t\t}\n\t\t}\n\t}\n\n\tgetSchema(): SerializedSchema {\n\t\tthis.assertNotClosed()\n\t\treturn this.storage.schema.get()\n\t}\n\n\tsetSchema(schema: SerializedSchema): void {\n\t\tthis.assertNotClosed()\n\t\tthis.storage.schema.set(schema)\n\t}\n\n\tgetChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {\n\t\tthis.assertNotClosed()\n\t\tconst clock = this.storage.documentClock.get()\n\t\tif (sinceClock === clock) return undefined\n\t\tif (sinceClock > clock) {\n\t\t\t// something went wrong, wipe the slate clean\n\t\t\tsinceClock = -1\n\t\t}\n\t\tconst diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }\n\t\tconst wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get()\n\t\t// both partitions share the clock and tombstones, so the change feed spans both\n\t\tfor (const map of [this.storage.documents, this.storage.objects]) {\n\t\t\tfor (const doc of map.values()) {\n\t\t\t\tif (wipeAll || doc.lastChangedClock > sinceClock) {\n\t\t\t\t\t// For historical changes, we don't have \"from\" state, so use added\n\t\t\t\t\tdiff.puts[doc.state.id] = doc.state as R\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!wipeAll) {\n\t\t\tfor (const [id, clock] of this.storage.tombstones.entries()) {\n\t\t\t\tif (clock > sinceClock) {\n\t\t\t\t\t// For tombstones, we don't have the removed record, use placeholder\n\t\t\t\t\tdiff.deletes.push(id)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn { diff, wipeAll }\n\t}\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["clock"]
|
|
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.5.0-canary.
|
|
4
|
+
"version": "5.5.0-canary.b5beee5fe538",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw GB Ltd.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -56,17 +56,17 @@
|
|
|
56
56
|
"@types/uuid-readable": "^0.0.3",
|
|
57
57
|
"react": "^19.2.1",
|
|
58
58
|
"react-dom": "^19.2.1",
|
|
59
|
-
"tldraw": "5.5.0-canary.
|
|
59
|
+
"tldraw": "5.5.0-canary.b5beee5fe538",
|
|
60
60
|
"typescript": "^7.0.2",
|
|
61
61
|
"uuid-by-string": "^4.0.0",
|
|
62
62
|
"uuid-readable": "^0.0.2",
|
|
63
|
-
"vitest": "^
|
|
63
|
+
"vitest": "^5.0.0"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@tldraw/state": "5.5.0-canary.
|
|
67
|
-
"@tldraw/store": "5.5.0-canary.
|
|
68
|
-
"@tldraw/tlschema": "5.5.0-canary.
|
|
69
|
-
"@tldraw/utils": "5.5.0-canary.
|
|
66
|
+
"@tldraw/state": "5.5.0-canary.b5beee5fe538",
|
|
67
|
+
"@tldraw/store": "5.5.0-canary.b5beee5fe538",
|
|
68
|
+
"@tldraw/tlschema": "5.5.0-canary.b5beee5fe538",
|
|
69
|
+
"@tldraw/utils": "5.5.0-canary.b5beee5fe538",
|
|
70
70
|
"nanoevents": "^7.0.1",
|
|
71
71
|
"ws": "^8.18.0"
|
|
72
72
|
},
|
|
@@ -73,24 +73,38 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
73
73
|
connectedServerSocket = socket
|
|
74
74
|
})
|
|
75
75
|
|
|
76
|
+
// Bound per test on an OS-assigned port. A fixed port raced against the previous test's
|
|
77
|
+
// server, which closes asynchronously, and intermittently failed with EADDRINUSE.
|
|
78
|
+
let port: number
|
|
79
|
+
|
|
76
80
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
|
77
|
-
beforeEach(() => {
|
|
81
|
+
beforeEach(async () => {
|
|
78
82
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
wsServer = new WebSocketServer({ port: 0 })
|
|
84
|
+
// Attach before the adapter exists: the server is already listening by the time the
|
|
85
|
+
// adapter is constructed, so a handler added afterwards can miss the first connection.
|
|
81
86
|
wsServer.on('connection', connectMock as any)
|
|
87
|
+
await new Promise<void>((resolve, reject) => {
|
|
88
|
+
wsServer.once('listening', resolve)
|
|
89
|
+
wsServer.once('error', reject)
|
|
90
|
+
})
|
|
91
|
+
port = (wsServer.address() as { port: number }).port
|
|
92
|
+
adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
82
93
|
})
|
|
83
94
|
|
|
84
95
|
afterEach(() => {
|
|
85
96
|
consoleWarnSpy.mockRestore()
|
|
86
97
|
adapter.close()
|
|
98
|
+
// Not awaited: the next test binds its own OS-assigned port, so a server still closing
|
|
99
|
+
// here can't collide with it. Awaiting would deliver in-flight messages to the socket
|
|
100
|
+
// adapter.close() just orphaned.
|
|
87
101
|
wsServer.close()
|
|
88
102
|
connectMock.mockClear()
|
|
89
103
|
})
|
|
90
104
|
|
|
91
105
|
describe('connection and initial state (CW1, CW2)', () => {
|
|
92
106
|
it('[CW2] starts with connectionStatus offline while the internal status is initial', () => {
|
|
93
|
-
const newAdapter = new ClientWebSocketAdapter(() =>
|
|
107
|
+
const newAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
94
108
|
try {
|
|
95
109
|
expect(newAdapter._connectionStatus.get()).toBe('initial')
|
|
96
110
|
expect(newAdapter.connectionStatus).toBe('offline')
|
|
@@ -113,7 +127,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
113
127
|
let uriCallCount = 0
|
|
114
128
|
const dynamicAdapter = new ClientWebSocketAdapter(() => {
|
|
115
129
|
uriCallCount++
|
|
116
|
-
return `ws://localhost
|
|
130
|
+
return `ws://localhost:${port}?attempt=${uriCallCount}`
|
|
117
131
|
})
|
|
118
132
|
try {
|
|
119
133
|
await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN)
|
|
@@ -143,7 +157,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
143
157
|
// no socket can be created until the promise resolves
|
|
144
158
|
expect(asyncAdapter._ws).toBeNull()
|
|
145
159
|
|
|
146
|
-
resolveUri!(
|
|
160
|
+
resolveUri!(`ws://localhost:${port}`)
|
|
147
161
|
|
|
148
162
|
await waitFor(() => asyncAdapter._ws?.readyState === WebSocket.OPEN)
|
|
149
163
|
expect(asyncAdapter.connectionStatus).toBe('online')
|
|
@@ -301,7 +315,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
301
315
|
|
|
302
316
|
describe('URI conversion (CW1)', () => {
|
|
303
317
|
it('[CW1] converts http URIs to ws and connects', async () => {
|
|
304
|
-
const httpAdapter = new ClientWebSocketAdapter(() =>
|
|
318
|
+
const httpAdapter = new ClientWebSocketAdapter(() => `http://localhost:${port}`)
|
|
305
319
|
try {
|
|
306
320
|
await waitFor(() => httpAdapter._ws?.readyState === WebSocket.OPEN)
|
|
307
321
|
expect(httpAdapter._ws!.url).toMatch(/^ws:\/\//)
|
|
@@ -312,7 +326,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
312
326
|
})
|
|
313
327
|
|
|
314
328
|
it('[CW1] converts https URIs to wss', async () => {
|
|
315
|
-
const httpsAdapter = new ClientWebSocketAdapter(() =>
|
|
329
|
+
const httpsAdapter = new ClientWebSocketAdapter(() => `https://localhost:${port}`)
|
|
316
330
|
try {
|
|
317
331
|
await waitFor(() => httpsAdapter._ws !== null)
|
|
318
332
|
expect(httpsAdapter._ws!.url).toMatch(/^wss:\/\//)
|
|
@@ -529,7 +543,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
529
543
|
})
|
|
530
544
|
|
|
531
545
|
it('[CW9][RM5] closing immediately does not start another getUri call', async () => {
|
|
532
|
-
const getUri = vi.fn(() =>
|
|
546
|
+
const getUri = vi.fn(() => `ws://localhost:${port}`)
|
|
533
547
|
const testAdapter = new ClientWebSocketAdapter(getUri)
|
|
534
548
|
testAdapter.close()
|
|
535
549
|
const callsAtClose = getUri.mock.calls.length
|
|
@@ -544,7 +558,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
544
558
|
let uriCallCount = 0
|
|
545
559
|
const testAdapter = new ClientWebSocketAdapter(() => {
|
|
546
560
|
uriCallCount++
|
|
547
|
-
return
|
|
561
|
+
return `ws://localhost:${port}`
|
|
548
562
|
})
|
|
549
563
|
const onStatusChange = vi.fn()
|
|
550
564
|
testAdapter.onStatusChange(onStatusChange)
|
|
@@ -606,17 +620,26 @@ describe('ReconnectManager', () => {
|
|
|
606
620
|
connectedServerSocket = socket
|
|
607
621
|
})
|
|
608
622
|
|
|
623
|
+
// See the note on the other suite's port: bound per test on an OS-assigned port.
|
|
624
|
+
let port: number
|
|
625
|
+
|
|
609
626
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
|
610
|
-
beforeEach(() => {
|
|
627
|
+
beforeEach(async () => {
|
|
611
628
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
612
|
-
|
|
613
|
-
wsServer = new WebSocketServer({ port: 2234 })
|
|
629
|
+
wsServer = new WebSocketServer({ port: 0 })
|
|
614
630
|
wsServer.on('connection', connectMock as any)
|
|
631
|
+
await new Promise<void>((resolve, reject) => {
|
|
632
|
+
wsServer.once('listening', resolve)
|
|
633
|
+
wsServer.once('error', reject)
|
|
634
|
+
})
|
|
635
|
+
port = (wsServer.address() as { port: number }).port
|
|
636
|
+
adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
615
637
|
})
|
|
616
638
|
|
|
617
639
|
afterEach(() => {
|
|
618
640
|
consoleWarnSpy.mockRestore()
|
|
619
641
|
adapter.close()
|
|
642
|
+
// Not awaited - see the note on the other suite's teardown.
|
|
620
643
|
wsServer.close()
|
|
621
644
|
connectMock.mockClear()
|
|
622
645
|
})
|
|
@@ -631,7 +654,7 @@ describe('ReconnectManager', () => {
|
|
|
631
654
|
})
|
|
632
655
|
|
|
633
656
|
it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => {
|
|
634
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
657
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
635
658
|
try {
|
|
636
659
|
const manager = testAdapter._reconnectManager
|
|
637
660
|
// make every disconnected() call take the "attempt now" branch
|
|
@@ -656,7 +679,7 @@ describe('ReconnectManager', () => {
|
|
|
656
679
|
|
|
657
680
|
it('[RM1] uses the inactive delay bounds when the tab is hidden', () => {
|
|
658
681
|
const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
|
|
659
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
682
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
660
683
|
try {
|
|
661
684
|
const manager = testAdapter._reconnectManager
|
|
662
685
|
;(manager as any).lastAttemptStart = Date.now() - 1_000_000
|
|
@@ -828,7 +851,7 @@ describe('ReconnectManager', () => {
|
|
|
828
851
|
if (failure === 'throws') throw error
|
|
829
852
|
return Promise.reject(error)
|
|
830
853
|
}
|
|
831
|
-
return
|
|
854
|
+
return `ws://localhost:${port}`
|
|
832
855
|
})
|
|
833
856
|
try {
|
|
834
857
|
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
@@ -843,7 +866,7 @@ describe('ReconnectManager', () => {
|
|
|
843
866
|
)
|
|
844
867
|
|
|
845
868
|
it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
|
|
846
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
869
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
847
870
|
const manager = testAdapter._reconnectManager
|
|
848
871
|
|
|
849
872
|
testAdapter.close()
|
|
@@ -862,6 +885,10 @@ describe('ReconnectManager', () => {
|
|
|
862
885
|
})
|
|
863
886
|
|
|
864
887
|
describe('URI failure boundaries', () => {
|
|
888
|
+
// These tests never open a real socket - the adapter is closed first, or the socket is a
|
|
889
|
+
// mock - so the URI only has to parse. No server, hence no port to bind.
|
|
890
|
+
const UNUSED_URI = 'ws://localhost:1'
|
|
891
|
+
|
|
865
892
|
it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => {
|
|
866
893
|
const uri = promiseWithResolve<string>()
|
|
867
894
|
const getUri = vi.fn(() => uri)
|
|
@@ -872,7 +899,7 @@ describe('URI failure boundaries', () => {
|
|
|
872
899
|
try {
|
|
873
900
|
await Promise.resolve()
|
|
874
901
|
testAdapter.close()
|
|
875
|
-
if (outcome === 'resolves') uri.resolve(
|
|
902
|
+
if (outcome === 'resolves') uri.resolve(UNUSED_URI)
|
|
876
903
|
else uri.reject(new Error('token endpoint unavailable'))
|
|
877
904
|
await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
|
|
878
905
|
|
|
@@ -890,7 +917,7 @@ describe('URI failure boundaries', () => {
|
|
|
890
917
|
it.each([
|
|
891
918
|
{ uri: 'not a URL', openSocket: false, error: 'Invalid URL' },
|
|
892
919
|
{
|
|
893
|
-
uri:
|
|
920
|
+
uri: UNUSED_URI,
|
|
894
921
|
openSocket: true,
|
|
895
922
|
error: 'There should be no connection attempts while already connected',
|
|
896
923
|
},
|
|
@@ -51,6 +51,27 @@ describe('InMemorySyncStorage', () => {
|
|
|
51
51
|
expect(storage.getClock()).toBe(25)
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
+
it('[IM1] handles snapshots with far more records and tombstones than fit in a spread call', () => {
|
|
55
|
+
const documents = []
|
|
56
|
+
for (let i = 0; i < 150_000; i++) {
|
|
57
|
+
documents.push({
|
|
58
|
+
state: { ...contractRecords[0], id: `shape:${i}` } as TLRecord,
|
|
59
|
+
lastChangedClock: i,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
const tombstones: Record<string, number> = {}
|
|
63
|
+
for (let i = 0; i < 150_000; i++) tombstones[`shape:gone-${i}`] = 150_000 + i
|
|
64
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
65
|
+
snapshot: makeContractSnapshot(contractRecords, {
|
|
66
|
+
documents,
|
|
67
|
+
tombstones,
|
|
68
|
+
documentClock: 0,
|
|
69
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
expect(storage.getClock()).toBe(299_999)
|
|
73
|
+
})
|
|
74
|
+
|
|
54
75
|
it('[IM2] clamps tombstoneHistoryStartsAtClock down to the document clock', () => {
|
|
55
76
|
const storage = new InMemorySyncStorage<TLRecord>({
|
|
56
77
|
snapshot: makeContractSnapshot(contractRecords, {
|
|
@@ -153,27 +153,27 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
|
|
|
153
153
|
onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
|
|
154
154
|
} = {}) {
|
|
155
155
|
this.objectTypes = new Set(objectTypes ?? [])
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...Object.values(snapshot.documents.map((d) => d.lastChangedClock))
|
|
160
|
-
)
|
|
156
|
+
// a loop rather than `Math.max(0, ...clocks)`: spreading a large room's clocks as call
|
|
157
|
+
// arguments overflows the stack at roughly 100k+ records/tombstones
|
|
158
|
+
let maxClockValue = 0
|
|
161
159
|
// route snapshot entries into their partitions (a seed snapshot may carry object-lane
|
|
162
160
|
// records merged in, e.g. loaded from a separate persistence lane)
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
d.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
161
|
+
const documentEntries: [string, { state: R; lastChangedClock: number }][] = []
|
|
162
|
+
const objectEntries: [string, { state: R; lastChangedClock: number }][] = []
|
|
163
|
+
for (const d of snapshot.documents) {
|
|
164
|
+
if (d.lastChangedClock > maxClockValue) maxClockValue = d.lastChangedClock
|
|
165
|
+
const entries = this.objectTypes.has(d.state.typeName) ? objectEntries : documentEntries
|
|
166
|
+
entries.push([
|
|
167
|
+
d.state.id,
|
|
168
|
+
{ state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
|
|
169
|
+
])
|
|
170
|
+
}
|
|
171
|
+
const tombstoneEntries = objectMapEntries(snapshot.tombstones ?? {})
|
|
172
|
+
for (const [, clock] of tombstoneEntries) {
|
|
173
|
+
if (clock > maxClockValue) maxClockValue = clock
|
|
174
|
+
}
|
|
175
|
+
this.documents = new AtomMap('room documents', documentEntries)
|
|
176
|
+
this.objects = new AtomMap('room objects', objectEntries)
|
|
177
177
|
const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)
|
|
178
178
|
|
|
179
179
|
this.documentClock = atom('document clock', documentClock)
|
|
@@ -192,9 +192,7 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
|
|
|
192
192
|
'room tombstones',
|
|
193
193
|
// If the tombstone history starts now (or we didn't have the
|
|
194
194
|
// tombstoneHistoryStartsAtClock) then there are no tombstones
|
|
195
|
-
tombstoneHistoryStartsAtClock === documentClock
|
|
196
|
-
? []
|
|
197
|
-
: objectMapEntries(snapshot.tombstones ?? {})
|
|
195
|
+
tombstoneHistoryStartsAtClock === documentClock ? [] : tombstoneEntries
|
|
198
196
|
)
|
|
199
197
|
if (onChange) {
|
|
200
198
|
this.onChange(onChange)
|