@tldraw/sync-core 4.3.0-canary.cf5673a789a1 → 4.3.0-canary.d039f3a1ab8f
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 +239 -57
- package/dist-cjs/index.js +7 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +117 -69
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +7 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +357 -688
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js +76 -0
- package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
- package/dist-cjs/lib/recordDiff.js +52 -0
- package/dist-cjs/lib/recordDiff.js.map +7 -0
- package/dist-esm/index.d.mts +239 -57
- package/dist-esm/index.mjs +12 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +121 -70
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +7 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +370 -702
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs +56 -0
- package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
- package/dist-esm/lib/recordDiff.mjs +32 -0
- package/dist-esm/lib/recordDiff.mjs.map +7 -0
- package/package.json +6 -6
- package/src/index.ts +21 -3
- package/src/lib/InMemorySyncStorage.ts +357 -0
- package/src/lib/RoomSession.test.ts +1 -0
- package/src/lib/RoomSession.ts +2 -0
- package/src/lib/TLSocketRoom.ts +228 -114
- package/src/lib/TLSyncClient.ts +12 -0
- package/src/lib/TLSyncRoom.ts +473 -913
- package/src/lib/TLSyncStorage.ts +216 -0
- package/src/lib/recordDiff.ts +73 -0
- package/src/test/InMemorySyncStorage.test.ts +1674 -0
- package/src/test/TLSocketRoom.test.ts +255 -49
- package/src/test/TLSyncRoom.test.ts +1021 -533
- package/src/test/TestServer.ts +12 -1
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/upgradeDowngrade.test.ts +282 -8
- package/src/test/validation.test.ts +10 -10
- package/src/test/pruneTombstones.test.ts +0 -178
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { atom, transaction } from "@tldraw/state";
|
|
2
|
+
import { AtomMap, devFreeze } from "@tldraw/store";
|
|
3
|
+
import {
|
|
4
|
+
createTLSchema,
|
|
5
|
+
DocumentRecordType,
|
|
6
|
+
PageRecordType,
|
|
7
|
+
TLDOCUMENT_ID
|
|
8
|
+
} from "@tldraw/tlschema";
|
|
9
|
+
import { assert, objectMapEntries, throttle } from "@tldraw/utils";
|
|
10
|
+
const TOMBSTONE_PRUNE_BUFFER_SIZE = 1e3;
|
|
11
|
+
const MAX_TOMBSTONES = 5e3;
|
|
12
|
+
const DEFAULT_INITIAL_SNAPSHOT = {
|
|
13
|
+
documentClock: 0,
|
|
14
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
15
|
+
schema: createTLSchema().serialize(),
|
|
16
|
+
documents: [
|
|
17
|
+
{
|
|
18
|
+
state: DocumentRecordType.create({ id: TLDOCUMENT_ID }),
|
|
19
|
+
lastChangedClock: 0
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
state: PageRecordType.create({
|
|
23
|
+
id: "page:page",
|
|
24
|
+
name: "Page 1",
|
|
25
|
+
index: "a1"
|
|
26
|
+
}),
|
|
27
|
+
lastChangedClock: 0
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
};
|
|
31
|
+
class InMemorySyncStorage {
|
|
32
|
+
/** @internal */
|
|
33
|
+
documents;
|
|
34
|
+
/** @internal */
|
|
35
|
+
tombstones;
|
|
36
|
+
/** @internal */
|
|
37
|
+
schema;
|
|
38
|
+
/** @internal */
|
|
39
|
+
documentClock;
|
|
40
|
+
/** @internal */
|
|
41
|
+
tombstoneHistoryStartsAtClock;
|
|
42
|
+
listeners = /* @__PURE__ */ new Set();
|
|
43
|
+
onChange(callback) {
|
|
44
|
+
let didDelete = false;
|
|
45
|
+
queueMicrotask(() => {
|
|
46
|
+
if (didDelete) return;
|
|
47
|
+
this.listeners.add(callback);
|
|
48
|
+
});
|
|
49
|
+
return () => {
|
|
50
|
+
if (didDelete) return;
|
|
51
|
+
didDelete = true;
|
|
52
|
+
this.listeners.delete(callback);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
constructor({
|
|
56
|
+
snapshot = DEFAULT_INITIAL_SNAPSHOT,
|
|
57
|
+
onChange
|
|
58
|
+
} = {}) {
|
|
59
|
+
const maxClockValue = Math.max(
|
|
60
|
+
0,
|
|
61
|
+
...Object.values(snapshot.tombstones ?? {}),
|
|
62
|
+
...Object.values(snapshot.documents.map((d) => d.lastChangedClock))
|
|
63
|
+
);
|
|
64
|
+
this.documents = new AtomMap(
|
|
65
|
+
"room documents",
|
|
66
|
+
snapshot.documents.map((d) => [
|
|
67
|
+
d.state.id,
|
|
68
|
+
{ state: devFreeze(d.state), lastChangedClock: d.lastChangedClock }
|
|
69
|
+
])
|
|
70
|
+
);
|
|
71
|
+
const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0);
|
|
72
|
+
this.documentClock = atom("document clock", documentClock);
|
|
73
|
+
const tombstoneHistoryStartsAtClock = Math.min(
|
|
74
|
+
snapshot.tombstoneHistoryStartsAtClock ?? documentClock,
|
|
75
|
+
documentClock
|
|
76
|
+
);
|
|
77
|
+
this.tombstoneHistoryStartsAtClock = atom(
|
|
78
|
+
"tombstone history starts at clock",
|
|
79
|
+
tombstoneHistoryStartsAtClock
|
|
80
|
+
);
|
|
81
|
+
this.schema = atom("schema", snapshot.schema ?? createTLSchema().serializeEarliestVersion());
|
|
82
|
+
this.tombstones = new AtomMap(
|
|
83
|
+
"room tombstones",
|
|
84
|
+
// If the tombstone history starts now (or we didn't have the
|
|
85
|
+
// tombstoneHistoryStartsAtClock) then there are no tombstones
|
|
86
|
+
tombstoneHistoryStartsAtClock === documentClock ? [] : objectMapEntries(snapshot.tombstones ?? {})
|
|
87
|
+
);
|
|
88
|
+
if (onChange) {
|
|
89
|
+
this.onChange(onChange);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
transaction(callback, opts) {
|
|
93
|
+
const clockBefore = this.documentClock.get();
|
|
94
|
+
const trackChanges = opts?.emitChanges === "always";
|
|
95
|
+
const txn = new InMemorySyncStorageTransaction(this);
|
|
96
|
+
let result;
|
|
97
|
+
let changes;
|
|
98
|
+
try {
|
|
99
|
+
result = transaction(() => {
|
|
100
|
+
return callback(txn);
|
|
101
|
+
});
|
|
102
|
+
if (trackChanges) {
|
|
103
|
+
changes = txn.getChangesSince(clockBefore)?.diff;
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error("Error in transaction", error);
|
|
107
|
+
throw error;
|
|
108
|
+
} finally {
|
|
109
|
+
txn.close();
|
|
110
|
+
}
|
|
111
|
+
if (typeof result === "object" && result && "then" in result && typeof result.then === "function") {
|
|
112
|
+
const err = new Error("Transaction must return a value, not a promise");
|
|
113
|
+
console.error(err);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
const clockAfter = this.documentClock.get();
|
|
117
|
+
const didChange = clockAfter > clockBefore;
|
|
118
|
+
if (didChange) {
|
|
119
|
+
queueMicrotask(() => {
|
|
120
|
+
const props = {
|
|
121
|
+
id: opts?.id,
|
|
122
|
+
documentClock: clockAfter
|
|
123
|
+
};
|
|
124
|
+
for (const listener of this.listeners) {
|
|
125
|
+
try {
|
|
126
|
+
listener(props);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error("Error in onChange callback", error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes };
|
|
134
|
+
}
|
|
135
|
+
getClock() {
|
|
136
|
+
return this.documentClock.get();
|
|
137
|
+
}
|
|
138
|
+
/** @internal */
|
|
139
|
+
pruneTombstones = throttle(
|
|
140
|
+
() => {
|
|
141
|
+
if (this.tombstones.size > MAX_TOMBSTONES) {
|
|
142
|
+
const tombstones = Array.from(this.tombstones);
|
|
143
|
+
tombstones.sort((a, b) => a[1] - b[1]);
|
|
144
|
+
let cutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + this.tombstones.size - MAX_TOMBSTONES;
|
|
145
|
+
while (cutoff < tombstones.length && tombstones[cutoff - 1][1] === tombstones[cutoff][1]) {
|
|
146
|
+
cutoff++;
|
|
147
|
+
}
|
|
148
|
+
const oldestRemaining = tombstones[cutoff];
|
|
149
|
+
this.tombstoneHistoryStartsAtClock.set(oldestRemaining?.[1] ?? this.documentClock.get());
|
|
150
|
+
const toDelete = tombstones.slice(0, cutoff);
|
|
151
|
+
this.tombstones.deleteMany(toDelete.map(([id]) => id));
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
1e3,
|
|
155
|
+
// prevent this from running synchronously to avoid blocking requests
|
|
156
|
+
{ leading: false }
|
|
157
|
+
);
|
|
158
|
+
getSnapshot() {
|
|
159
|
+
return {
|
|
160
|
+
tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),
|
|
161
|
+
documentClock: this.documentClock.get(),
|
|
162
|
+
documents: Array.from(this.documents.values()),
|
|
163
|
+
tombstones: Object.fromEntries(this.tombstones.entries()),
|
|
164
|
+
schema: this.schema.get()
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
class InMemorySyncStorageTransaction {
|
|
169
|
+
constructor(storage) {
|
|
170
|
+
this.storage = storage;
|
|
171
|
+
this._clock = this.storage.documentClock.get();
|
|
172
|
+
}
|
|
173
|
+
_clock;
|
|
174
|
+
_closed = false;
|
|
175
|
+
/** @internal */
|
|
176
|
+
close() {
|
|
177
|
+
this._closed = true;
|
|
178
|
+
}
|
|
179
|
+
assertNotClosed() {
|
|
180
|
+
assert(!this._closed, "Transaction has ended, iterator cannot be consumed");
|
|
181
|
+
}
|
|
182
|
+
getClock() {
|
|
183
|
+
return this._clock;
|
|
184
|
+
}
|
|
185
|
+
didIncrementClock = false;
|
|
186
|
+
getNextClock() {
|
|
187
|
+
if (!this.didIncrementClock) {
|
|
188
|
+
this.didIncrementClock = true;
|
|
189
|
+
this._clock = this.storage.documentClock.set(this.storage.documentClock.get() + 1);
|
|
190
|
+
}
|
|
191
|
+
return this._clock;
|
|
192
|
+
}
|
|
193
|
+
get(id) {
|
|
194
|
+
this.assertNotClosed();
|
|
195
|
+
return this.storage.documents.get(id)?.state;
|
|
196
|
+
}
|
|
197
|
+
set(id, record) {
|
|
198
|
+
this.assertNotClosed();
|
|
199
|
+
assert(id === record.id, `Record id mismatch: key does not match record.id`);
|
|
200
|
+
const clock = this.getNextClock();
|
|
201
|
+
if (this.storage.tombstones.has(id)) {
|
|
202
|
+
this.storage.tombstones.delete(id);
|
|
203
|
+
}
|
|
204
|
+
this.storage.documents.set(id, {
|
|
205
|
+
state: devFreeze(record),
|
|
206
|
+
lastChangedClock: clock
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
delete(id) {
|
|
210
|
+
this.assertNotClosed();
|
|
211
|
+
if (!this.storage.documents.has(id)) return;
|
|
212
|
+
const clock = this.getNextClock();
|
|
213
|
+
this.storage.documents.delete(id);
|
|
214
|
+
this.storage.tombstones.set(id, clock);
|
|
215
|
+
this.storage.pruneTombstones();
|
|
216
|
+
}
|
|
217
|
+
*entries() {
|
|
218
|
+
this.assertNotClosed();
|
|
219
|
+
for (const [id, record] of this.storage.documents.entries()) {
|
|
220
|
+
this.assertNotClosed();
|
|
221
|
+
yield [id, record.state];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
*keys() {
|
|
225
|
+
this.assertNotClosed();
|
|
226
|
+
for (const key of this.storage.documents.keys()) {
|
|
227
|
+
this.assertNotClosed();
|
|
228
|
+
yield key;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
*values() {
|
|
232
|
+
this.assertNotClosed();
|
|
233
|
+
for (const record of this.storage.documents.values()) {
|
|
234
|
+
this.assertNotClosed();
|
|
235
|
+
yield record.state;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
getSchema() {
|
|
239
|
+
this.assertNotClosed();
|
|
240
|
+
return this.storage.schema.get();
|
|
241
|
+
}
|
|
242
|
+
setSchema(schema) {
|
|
243
|
+
this.assertNotClosed();
|
|
244
|
+
this.storage.schema.set(schema);
|
|
245
|
+
}
|
|
246
|
+
getChangesSince(sinceClock) {
|
|
247
|
+
this.assertNotClosed();
|
|
248
|
+
const clock = this.storage.documentClock.get();
|
|
249
|
+
if (sinceClock === clock) return void 0;
|
|
250
|
+
if (sinceClock > clock) {
|
|
251
|
+
sinceClock = -1;
|
|
252
|
+
}
|
|
253
|
+
const diff = { puts: {}, deletes: [] };
|
|
254
|
+
const wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get();
|
|
255
|
+
for (const doc of this.storage.documents.values()) {
|
|
256
|
+
if (wipeAll || doc.lastChangedClock > sinceClock) {
|
|
257
|
+
diff.puts[doc.state.id] = doc.state;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const [id, clock2] of this.storage.tombstones.entries()) {
|
|
261
|
+
if (clock2 > sinceClock) {
|
|
262
|
+
diff.deletes.push(id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { diff, wipeAll };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export {
|
|
269
|
+
DEFAULT_INITIAL_SNAPSHOT,
|
|
270
|
+
InMemorySyncStorage,
|
|
271
|
+
MAX_TOMBSTONES,
|
|
272
|
+
TOMBSTONE_PRUNE_BUFFER_SIZE
|
|
273
|
+
};
|
|
274
|
+
//# sourceMappingURL=InMemorySyncStorage.mjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 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 { 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 * 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/** @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 listeners = new Set<(arg: TLSyncStorageOnChangeCallbackProps) => unknown>()\n\tonChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void {\n\t\tlet didDelete = false\n\t\t// we put the callback registration in a microtask because the callback is invoked\n\t\t// in a microtask, and so this makes sure the callback is invoked after all the updates\n\t\t// that happened in the current callstack before this onChange registration have been processed.\n\t\tqueueMicrotask(() => {\n\t\t\tif (didDelete) return\n\t\t\tthis.listeners.add(callback)\n\t\t})\n\t\treturn () => {\n\t\t\tif (didDelete) return\n\t\t\tdidDelete = true\n\t\t\tthis.listeners.delete(callback)\n\t\t}\n\t}\n\n\tconstructor({\n\t\tsnapshot = DEFAULT_INITIAL_SNAPSHOT,\n\t\tonChange,\n\t}: {\n\t\tsnapshot?: RoomSnapshot\n\t\tonChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown\n\t} = {}) {\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\tthis.documents = new AtomMap(\n\t\t\t'room documents',\n\t\t\tsnapshot.documents.map((d) => [\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 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\t// todo: batch these updates\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tconst props: TLSyncStorageOnChangeCallbackProps = {\n\t\t\t\t\tid: opts?.id,\n\t\t\t\t\tdocumentClock: clockAfter,\n\t\t\t\t}\n\t\t\t\tfor (const listener of this.listeners) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tlistener(props)\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tconsole.error('Error in onChange callback', error)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})\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\tconst tombstones = Array.from(this.tombstones)\n\t\t\t\t// sort entries in ascending order by clock (oldest first)\n\t\t\t\ttombstones.sort((a, b) => a[1] - b[1])\n\t\t\t\t// determine how many to delete, avoiding partial history for a clock value\n\t\t\t\tlet cutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + this.tombstones.size - MAX_TOMBSTONES\n\t\t\t\twhile (cutoff < tombstones.length && tombstones[cutoff - 1][1] === tombstones[cutoff][1]) {\n\t\t\t\t\tcutoff++\n\t\t\t\t}\n\n\t\t\t\t// Set history start to the oldest remaining tombstone's clock\n\t\t\t\t// (or documentClock if we're deleting everything)\n\t\t\t\tconst oldestRemaining = tombstones[cutoff]\n\t\t\t\tthis.tombstoneHistoryStartsAtClock.set(oldestRemaining?.[1] ?? this.documentClock.get())\n\n\t\t\t\t// Delete the oldest tombstones (first cutoff entries)\n\t\t\t\tconst toDelete = tombstones.slice(0, cutoff)\n\t\t\t\tthis.tombstones.deleteMany(toDelete.map(([id]) => id))\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\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\n/**\n * Transaction implementation for InMemorySyncStorage.\n * Provides access to documents, tombstones, and metadata within a transaction.\n *\n * @internal\n */\nclass InMemorySyncStorageTransaction<R extends UnknownRecord>\n\timplements TLSyncStorageTransaction<R>\n{\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\tget(id: string): R | undefined {\n\t\tthis.assertNotClosed()\n\t\treturn this.storage.documents.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\tthis.storage.documents.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\tif (!this.storage.documents.has(id)) return\n\t\tconst clock = this.getNextClock()\n\t\tthis.storage.documents.delete(id)\n\t\tthis.storage.tombstones.set(id, clock)\n\t\tthis.storage.pruneTombstones()\n\t}\n\n\t*entries(): IterableIterator<[string, R]> {\n\t\tthis.assertNotClosed()\n\t\tfor (const [id, record] of this.storage.documents.entries()) {\n\t\t\tthis.assertNotClosed()\n\t\t\tyield [id, record.state]\n\t\t}\n\t}\n\n\t*keys(): IterableIterator<string> {\n\t\tthis.assertNotClosed()\n\t\tfor (const key of this.storage.documents.keys()) {\n\t\t\tthis.assertNotClosed()\n\t\t\tyield key\n\t\t}\n\t}\n\n\t*values(): IterableIterator<R> {\n\t\tthis.assertNotClosed()\n\t\tfor (const record of this.storage.documents.values()) {\n\t\t\tthis.assertNotClosed()\n\t\t\tyield record.state\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\tfor (const doc of this.storage.documents.values()) {\n\t\t\tif (wipeAll || doc.lastChangedClock > sinceClock) {\n\t\t\t\t// For historical changes, we don't have \"from\" state, so use added\n\t\t\t\tdiff.puts[doc.state.id] = doc.state as R\n\t\t\t}\n\t\t}\n\t\tfor (const [id, clock] of this.storage.tombstones.entries()) {\n\t\t\tif (clock > sinceClock) {\n\t\t\t\t// For tombstones, we don't have the removed record, use placeholder\n\t\t\t\tdiff.deletes.push(id)\n\t\t\t}\n\t\t}\n\t\treturn { diff, wipeAll }\n\t}\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["clock"]
|
|
7
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/RoomSession.ts"],
|
|
4
|
-
"sourcesContent": ["import { SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport { TLRoomSocket } from './TLSyncRoom'\nimport { TLSocketServerSentDataEvent } from './protocol'\n\n/**\n * Enumeration of possible states for a room session during its lifecycle.\n *\n * Room sessions progress through these states as clients connect, authenticate,\n * and disconnect from collaborative rooms.\n *\n * @internal\n */\nexport const RoomSessionState = {\n\t/** Session is waiting for the initial connect message from the client */\n\tAwaitingConnectMessage: 'awaiting-connect-message',\n\t/** Session is disconnected but waiting for final cleanup before removal */\n\tAwaitingRemoval: 'awaiting-removal',\n\t/** Session is fully connected and actively synchronizing */\n\tConnected: 'connected',\n} as const\n\n/**\n * Type representing the possible states a room session can be in.\n *\n * @example\n * ```ts\n * const sessionState: RoomSessionState = RoomSessionState.Connected\n * if (sessionState === RoomSessionState.AwaitingConnectMessage) {\n * console.log('Session waiting for connect message')\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSessionState = (typeof RoomSessionState)[keyof typeof RoomSessionState]\n\n/**\n * Maximum time in milliseconds to wait for a connect message after socket connection.\n *\n * If a client connects but doesn't send a connect message within this time,\n * the session will be terminated.\n *\n * @public\n */\nexport const SESSION_START_WAIT_TIME = 10000\n\n/**\n * Time in milliseconds to wait before completely removing a disconnected session.\n *\n * This grace period allows for quick reconnections without losing session state,\n * which is especially helpful for brief network interruptions.\n *\n * @public\n */\nexport const SESSION_REMOVAL_WAIT_TIME = 5000\n\n/**\n * Maximum time in milliseconds a connected session can remain idle before cleanup.\n *\n * Sessions that don't receive any messages or interactions for this duration\n * may be considered for cleanup to free server resources.\n *\n * @public\n */\nexport const SESSION_IDLE_TIMEOUT = 20000\n\n/**\n * Base properties shared by all room session states.\n *\n * @internal\n */\nexport interface RoomSessionBase<R extends UnknownRecord, Meta> {\n\t/** Unique identifier for this session */\n\tsessionId: string\n\t/** Presence identifier for live cursor/selection tracking, if available */\n\tpresenceId: string | null\n\t/** WebSocket connection wrapper for this session */\n\tsocket: TLRoomSocket<R>\n\t/** Custom metadata associated with this session */\n\tmeta: Meta\n\t/** Whether this session has read-only permissions */\n\tisReadonly: boolean\n\t/** Whether this session requires legacy protocol rejection handling */\n\trequiresLegacyRejection: boolean\n\t/** Whether this session supports string append operations */\n\tsupportsStringAppend: boolean\n}\n\n/**\n * Represents a client session within a collaborative room, tracking the connection\n * state, permissions, and synchronization details for a single user.\n *\n * Each session corresponds to one WebSocket connection and progresses through\n * different states during its lifecycle. The session type is a discriminated union\n * based on the current state, ensuring type safety when accessing state-specific properties.\n *\n * @example\n * ```ts\n * // Check session state and access appropriate properties\n * function handleSession(session: RoomSession<MyRecord, UserMeta>) {\n * switch (session.state) {\n * case RoomSessionState.AwaitingConnectMessage:\n * console.log(`Session ${session.sessionId} started at ${session.sessionStartTime}`)\n * break\n * case RoomSessionState.Connected:\n * console.log(`Connected session has ${session.outstandingDataMessages.length} pending messages`)\n * break\n * case RoomSessionState.AwaitingRemoval:\n * console.log(`Session will be removed at ${session.cancellationTime}`)\n * break\n * }\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSession<R extends UnknownRecord, Meta> =\n\t| (RoomSessionBase<R, Meta> & {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingConnectMessage\n\t\t\t/** Timestamp when the session was created */\n\t\t\tsessionStartTime: number\n\t })\n\t| (RoomSessionBase<R, Meta> & {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingRemoval\n\t\t\t/** Timestamp when the session was marked for removal */\n\t\t\tcancellationTime: number\n\t })\n\t| (RoomSessionBase<R, Meta> & {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.Connected\n\t\t\t/** Serialized schema information for this connected session */\n\t\t\tserializedSchema: SerializedSchema\n\t\t\t/** Timestamp of the last interaction or message from this session */\n\t\t\tlastInteractionTime: number\n\t\t\t/** Timer for debouncing operations, if active */\n\t\t\tdebounceTimer: ReturnType<typeof setTimeout> | null\n\t\t\t/** Queue of data messages waiting to be sent to this session */\n\t\t\toutstandingDataMessages: TLSocketServerSentDataEvent<R>[]\n\t })\n"],
|
|
4
|
+
"sourcesContent": ["import { SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport { TLRoomSocket } from './TLSyncRoom'\nimport { TLSocketServerSentDataEvent } from './protocol'\n\n/**\n * Enumeration of possible states for a room session during its lifecycle.\n *\n * Room sessions progress through these states as clients connect, authenticate,\n * and disconnect from collaborative rooms.\n *\n * @internal\n */\nexport const RoomSessionState = {\n\t/** Session is waiting for the initial connect message from the client */\n\tAwaitingConnectMessage: 'awaiting-connect-message',\n\t/** Session is disconnected but waiting for final cleanup before removal */\n\tAwaitingRemoval: 'awaiting-removal',\n\t/** Session is fully connected and actively synchronizing */\n\tConnected: 'connected',\n} as const\n\n/**\n * Type representing the possible states a room session can be in.\n *\n * @example\n * ```ts\n * const sessionState: RoomSessionState = RoomSessionState.Connected\n * if (sessionState === RoomSessionState.AwaitingConnectMessage) {\n * console.log('Session waiting for connect message')\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSessionState = (typeof RoomSessionState)[keyof typeof RoomSessionState]\n\n/**\n * Maximum time in milliseconds to wait for a connect message after socket connection.\n *\n * If a client connects but doesn't send a connect message within this time,\n * the session will be terminated.\n *\n * @public\n */\nexport const SESSION_START_WAIT_TIME = 10000\n\n/**\n * Time in milliseconds to wait before completely removing a disconnected session.\n *\n * This grace period allows for quick reconnections without losing session state,\n * which is especially helpful for brief network interruptions.\n *\n * @public\n */\nexport const SESSION_REMOVAL_WAIT_TIME = 5000\n\n/**\n * Maximum time in milliseconds a connected session can remain idle before cleanup.\n *\n * Sessions that don't receive any messages or interactions for this duration\n * may be considered for cleanup to free server resources.\n *\n * @public\n */\nexport const SESSION_IDLE_TIMEOUT = 20000\n\n/**\n * Base properties shared by all room session states.\n *\n * @internal\n */\nexport interface RoomSessionBase<R extends UnknownRecord, Meta> {\n\t/** Unique identifier for this session */\n\tsessionId: string\n\t/** Presence identifier for live cursor/selection tracking, if available */\n\tpresenceId: string | null\n\t/** WebSocket connection wrapper for this session */\n\tsocket: TLRoomSocket<R>\n\t/** Custom metadata associated with this session */\n\tmeta: Meta\n\t/** Whether this session has read-only permissions */\n\tisReadonly: boolean\n\t/** Whether this session requires legacy protocol rejection handling */\n\trequiresLegacyRejection: boolean\n\t/** Whether this session supports string append operations */\n\tsupportsStringAppend: boolean\n}\n\n/**\n * Represents a client session within a collaborative room, tracking the connection\n * state, permissions, and synchronization details for a single user.\n *\n * Each session corresponds to one WebSocket connection and progresses through\n * different states during its lifecycle. The session type is a discriminated union\n * based on the current state, ensuring type safety when accessing state-specific properties.\n *\n * @example\n * ```ts\n * // Check session state and access appropriate properties\n * function handleSession(session: RoomSession<MyRecord, UserMeta>) {\n * switch (session.state) {\n * case RoomSessionState.AwaitingConnectMessage:\n * console.log(`Session ${session.sessionId} started at ${session.sessionStartTime}`)\n * break\n * case RoomSessionState.Connected:\n * console.log(`Connected session has ${session.outstandingDataMessages.length} pending messages`)\n * break\n * case RoomSessionState.AwaitingRemoval:\n * console.log(`Session will be removed at ${session.cancellationTime}`)\n * break\n * }\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSession<R extends UnknownRecord, Meta> =\n\t| (RoomSessionBase<R, Meta> & {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingConnectMessage\n\t\t\t/** Timestamp when the session was created */\n\t\t\tsessionStartTime: number\n\t })\n\t| (RoomSessionBase<R, Meta> & {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingRemoval\n\t\t\t/** Timestamp when the session was marked for removal */\n\t\t\tcancellationTime: number\n\t })\n\t| (RoomSessionBase<R, Meta> & {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.Connected\n\t\t\t/** Serialized schema information for this connected session */\n\t\t\tserializedSchema: SerializedSchema\n\t\t\t/** Whether this session requires down migrations */\n\t\t\trequiresDownMigrations: boolean\n\t\t\t/** Timestamp of the last interaction or message from this session */\n\t\t\tlastInteractionTime: number\n\t\t\t/** Timer for debouncing operations, if active */\n\t\t\tdebounceTimer: ReturnType<typeof setTimeout> | null\n\t\t\t/** Queue of data messages waiting to be sent to this session */\n\t\t\toutstandingDataMessages: TLSocketServerSentDataEvent<R>[]\n\t })\n"],
|
|
5
5
|
"mappings": "AAYO,MAAM,mBAAmB;AAAA;AAAA,EAE/B,wBAAwB;AAAA;AAAA,EAExB,iBAAiB;AAAA;AAAA,EAEjB,WAAW;AACZ;AAyBO,MAAM,0BAA0B;AAUhC,MAAM,4BAA4B;AAUlC,MAAM,uBAAuB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { createTLSchema } from "@tldraw/tlschema";
|
|
2
|
-
import {
|
|
2
|
+
import { getOwnProperty, hasOwnProperty, isEqual, structuredClone } from "@tldraw/utils";
|
|
3
|
+
import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from "./InMemorySyncStorage.mjs";
|
|
3
4
|
import { RoomSessionState } from "./RoomSession.mjs";
|
|
4
5
|
import { ServerSocketAdapter } from "./ServerSocketAdapter.mjs";
|
|
5
6
|
import { TLSyncErrorCloseEventReason } from "./TLSyncClient.mjs";
|
|
6
7
|
import { TLSyncRoom } from "./TLSyncRoom.mjs";
|
|
8
|
+
import {
|
|
9
|
+
convertStoreSnapshotToRoomSnapshot,
|
|
10
|
+
loadSnapshotIntoStorage
|
|
11
|
+
} from "./TLSyncStorage.mjs";
|
|
7
12
|
import { JsonChunkAssembler } from "./chunk.mjs";
|
|
8
13
|
class TLSocketRoom {
|
|
9
14
|
/**
|
|
@@ -22,17 +27,29 @@ class TLSocketRoom {
|
|
|
22
27
|
*/
|
|
23
28
|
constructor(opts) {
|
|
24
29
|
this.opts = opts;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
if (opts.storage && opts.initialSnapshot) {
|
|
31
|
+
throw new Error("Cannot provide both storage and initialSnapshot options");
|
|
32
|
+
}
|
|
33
|
+
const storage = opts.storage ? opts.storage : new InMemorySyncStorage({
|
|
34
|
+
snapshot: convertStoreSnapshotToRoomSnapshot(
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
36
|
+
opts.initialSnapshot ?? DEFAULT_INITIAL_SNAPSHOT
|
|
37
|
+
)
|
|
38
|
+
});
|
|
39
|
+
if ("onDataChange" in opts && opts.onDataChange) {
|
|
40
|
+
this.disposables.add(
|
|
41
|
+
storage.onChange(() => {
|
|
42
|
+
opts.onDataChange?.();
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
}
|
|
30
46
|
this.room = new TLSyncRoom({
|
|
31
|
-
|
|
47
|
+
onPresenceChange: opts.onPresenceChange,
|
|
32
48
|
schema: opts.schema ?? createTLSchema(),
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
log: opts.log,
|
|
50
|
+
storage
|
|
35
51
|
});
|
|
52
|
+
this.storage = storage;
|
|
36
53
|
this.room.events.on("session_removed", (args) => {
|
|
37
54
|
this.sessions.delete(args.sessionId);
|
|
38
55
|
if (this.opts.onSessionRemoved) {
|
|
@@ -48,7 +65,8 @@ class TLSocketRoom {
|
|
|
48
65
|
room;
|
|
49
66
|
sessions = /* @__PURE__ */ new Map();
|
|
50
67
|
log;
|
|
51
|
-
|
|
68
|
+
storage;
|
|
69
|
+
disposables = /* @__PURE__ */ new Set();
|
|
52
70
|
/**
|
|
53
71
|
* Returns the number of active sessions.
|
|
54
72
|
* Note that this is not the same as the number of connected sockets!
|
|
@@ -232,7 +250,7 @@ class TLSocketRoom {
|
|
|
232
250
|
* ```
|
|
233
251
|
*/
|
|
234
252
|
getCurrentDocumentClock() {
|
|
235
|
-
return this.
|
|
253
|
+
return this.storage.getClock();
|
|
236
254
|
}
|
|
237
255
|
/**
|
|
238
256
|
* Retrieves a deeply cloned copy of a record from the document store.
|
|
@@ -253,7 +271,9 @@ class TLSocketRoom {
|
|
|
253
271
|
* ```
|
|
254
272
|
*/
|
|
255
273
|
getRecord(id) {
|
|
256
|
-
return
|
|
274
|
+
return this.storage.transaction((txn) => {
|
|
275
|
+
return structuredClone(txn.get(id));
|
|
276
|
+
}).result;
|
|
257
277
|
}
|
|
258
278
|
/**
|
|
259
279
|
* Returns information about all active sessions in the room. Each session
|
|
@@ -294,6 +314,7 @@ class TLSocketRoom {
|
|
|
294
314
|
* to restore the room state later or revert to a previous version.
|
|
295
315
|
*
|
|
296
316
|
* @returns Complete room snapshot including documents, clock values, and tombstones
|
|
317
|
+
* @deprecated if you need to do this use
|
|
297
318
|
*
|
|
298
319
|
* @example
|
|
299
320
|
* ```ts
|
|
@@ -307,7 +328,10 @@ class TLSocketRoom {
|
|
|
307
328
|
* ```
|
|
308
329
|
*/
|
|
309
330
|
getCurrentSnapshot() {
|
|
310
|
-
|
|
331
|
+
if (this.storage.getSnapshot) {
|
|
332
|
+
return this.storage.getSnapshot();
|
|
333
|
+
}
|
|
334
|
+
throw new Error("getCurrentSnapshot is not supported for this storage type");
|
|
311
335
|
}
|
|
312
336
|
/**
|
|
313
337
|
* Retrieves all presence records from the document store. Presence records
|
|
@@ -318,23 +342,11 @@ class TLSocketRoom {
|
|
|
318
342
|
*/
|
|
319
343
|
getPresenceRecords() {
|
|
320
344
|
const result = {};
|
|
321
|
-
for (const
|
|
322
|
-
|
|
323
|
-
result[document.state.id] = document.state;
|
|
324
|
-
}
|
|
345
|
+
for (const presence of this.room.presenceStore.values()) {
|
|
346
|
+
result[presence.id] = presence;
|
|
325
347
|
}
|
|
326
348
|
return result;
|
|
327
349
|
}
|
|
328
|
-
/**
|
|
329
|
-
* Returns a JSON-serialized snapshot of the current document state. This is
|
|
330
|
-
* equivalent to JSON.stringify(getCurrentSnapshot()) but provided as a convenience.
|
|
331
|
-
*
|
|
332
|
-
* @returns JSON string representation of the room snapshot
|
|
333
|
-
* @internal
|
|
334
|
-
*/
|
|
335
|
-
getCurrentSerializedSnapshot() {
|
|
336
|
-
return JSON.stringify(this.room.getSnapshot());
|
|
337
|
-
}
|
|
338
350
|
/**
|
|
339
351
|
* Loads a document snapshot, completely replacing the current room state.
|
|
340
352
|
* This will disconnect all current clients and update the document to match
|
|
@@ -354,39 +366,9 @@ class TLSocketRoom {
|
|
|
354
366
|
* ```
|
|
355
367
|
*/
|
|
356
368
|
loadSnapshot(snapshot) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
const oldRoom = this.room;
|
|
361
|
-
const oldRoomSnapshot = oldRoom.getSnapshot();
|
|
362
|
-
const oldIds = oldRoomSnapshot.documents.map((d) => d.state.id);
|
|
363
|
-
const newIds = new Set(snapshot.documents.map((d) => d.state.id));
|
|
364
|
-
const removedIds = oldIds.filter((id) => !newIds.has(id));
|
|
365
|
-
const tombstones = { ...oldRoomSnapshot.tombstones };
|
|
366
|
-
removedIds.forEach((id) => {
|
|
367
|
-
tombstones[id] = oldRoom.clock + 1;
|
|
368
|
-
});
|
|
369
|
-
newIds.forEach((id) => {
|
|
370
|
-
delete tombstones[id];
|
|
371
|
-
});
|
|
372
|
-
const newRoom = new TLSyncRoom({
|
|
373
|
-
...this.syncCallbacks,
|
|
374
|
-
schema: oldRoom.schema,
|
|
375
|
-
snapshot: {
|
|
376
|
-
clock: oldRoom.clock + 1,
|
|
377
|
-
documentClock: oldRoom.clock + 1,
|
|
378
|
-
documents: snapshot.documents.map((d) => ({
|
|
379
|
-
lastChangedClock: oldRoom.clock + 1,
|
|
380
|
-
state: d.state
|
|
381
|
-
})),
|
|
382
|
-
schema: snapshot.schema,
|
|
383
|
-
tombstones,
|
|
384
|
-
tombstoneHistoryStartsAtClock: oldRoomSnapshot.tombstoneHistoryStartsAtClock
|
|
385
|
-
},
|
|
386
|
-
log: this.log
|
|
369
|
+
this.storage.transaction((txn) => {
|
|
370
|
+
loadSnapshotIntoStorage(txn, this.room.schema, snapshot);
|
|
387
371
|
});
|
|
388
|
-
this.room = newRoom;
|
|
389
|
-
oldRoom.close();
|
|
390
372
|
}
|
|
391
373
|
/**
|
|
392
374
|
* Executes a transaction to modify the document store. Changes made within the
|
|
@@ -430,9 +412,31 @@ class TLSocketRoom {
|
|
|
430
412
|
* }
|
|
431
413
|
* })
|
|
432
414
|
* ```
|
|
415
|
+
* @deprecated use the storage.transaction method instead
|
|
433
416
|
*/
|
|
417
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
434
418
|
async updateStore(updater) {
|
|
435
|
-
|
|
419
|
+
if (this.isClosed()) {
|
|
420
|
+
throw new Error("Cannot update store on a closed room");
|
|
421
|
+
}
|
|
422
|
+
const ctx = new StoreUpdateContext(
|
|
423
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
424
|
+
Object.fromEntries(this.getCurrentSnapshot().documents.map((d) => [d.state.id, d.state])),
|
|
425
|
+
this.room.schema
|
|
426
|
+
);
|
|
427
|
+
try {
|
|
428
|
+
await updater(ctx);
|
|
429
|
+
} finally {
|
|
430
|
+
ctx.close();
|
|
431
|
+
}
|
|
432
|
+
this.storage.transaction((txn) => {
|
|
433
|
+
for (const [id, record] of Object.entries(ctx.updates.puts)) {
|
|
434
|
+
txn.set(id, record);
|
|
435
|
+
}
|
|
436
|
+
for (const id of ctx.updates.deletes) {
|
|
437
|
+
txn.delete(id);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
436
440
|
}
|
|
437
441
|
/**
|
|
438
442
|
* Sends a custom message to a specific client session. This allows sending
|
|
@@ -506,6 +510,8 @@ class TLSocketRoom {
|
|
|
506
510
|
*/
|
|
507
511
|
close() {
|
|
508
512
|
this.room.close();
|
|
513
|
+
this.disposables.forEach((d) => d());
|
|
514
|
+
this.disposables.clear();
|
|
509
515
|
}
|
|
510
516
|
/**
|
|
511
517
|
* Checks whether the room has been permanently closed. Closed rooms cannot
|
|
@@ -528,17 +534,62 @@ class TLSocketRoom {
|
|
|
528
534
|
return this.room.isClosed();
|
|
529
535
|
}
|
|
530
536
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
schema: snapshot.schema,
|
|
540
|
-
tombstones: {}
|
|
537
|
+
class StoreUpdateContext {
|
|
538
|
+
constructor(snapshot, schema) {
|
|
539
|
+
this.snapshot = snapshot;
|
|
540
|
+
this.schema = schema;
|
|
541
|
+
}
|
|
542
|
+
updates = {
|
|
543
|
+
puts: {},
|
|
544
|
+
deletes: /* @__PURE__ */ new Set()
|
|
541
545
|
};
|
|
546
|
+
put(record) {
|
|
547
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
548
|
+
const recordType = getOwnProperty(this.schema.types, record.typeName);
|
|
549
|
+
if (!recordType) {
|
|
550
|
+
throw new Error(`Missing definition for record type ${record.typeName}`);
|
|
551
|
+
}
|
|
552
|
+
const recordBefore = this.snapshot[record.id] ?? void 0;
|
|
553
|
+
recordType.validate(record, recordBefore);
|
|
554
|
+
if (record.id in this.snapshot && isEqual(this.snapshot[record.id], record)) {
|
|
555
|
+
delete this.updates.puts[record.id];
|
|
556
|
+
} else {
|
|
557
|
+
this.updates.puts[record.id] = structuredClone(record);
|
|
558
|
+
}
|
|
559
|
+
this.updates.deletes.delete(record.id);
|
|
560
|
+
}
|
|
561
|
+
delete(recordOrId) {
|
|
562
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
563
|
+
const id = typeof recordOrId === "string" ? recordOrId : recordOrId.id;
|
|
564
|
+
delete this.updates.puts[id];
|
|
565
|
+
if (this.snapshot[id]) {
|
|
566
|
+
this.updates.deletes.add(id);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
get(id) {
|
|
570
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
571
|
+
if (hasOwnProperty(this.updates.puts, id)) {
|
|
572
|
+
return structuredClone(this.updates.puts[id]);
|
|
573
|
+
}
|
|
574
|
+
if (this.updates.deletes.has(id)) {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
return structuredClone(this.snapshot[id] ?? null);
|
|
578
|
+
}
|
|
579
|
+
getAll() {
|
|
580
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
581
|
+
const result = Object.values(this.updates.puts);
|
|
582
|
+
for (const [id, record] of Object.entries(this.snapshot)) {
|
|
583
|
+
if (!this.updates.deletes.has(id) && !hasOwnProperty(this.updates.puts, id)) {
|
|
584
|
+
result.push(record);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return structuredClone(result);
|
|
588
|
+
}
|
|
589
|
+
_isClosed = false;
|
|
590
|
+
close() {
|
|
591
|
+
this._isClosed = true;
|
|
592
|
+
}
|
|
542
593
|
}
|
|
543
594
|
export {
|
|
544
595
|
TLSocketRoom
|