loro-repo 0.4.0 → 0.5.0

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/index.cjs CHANGED
@@ -23,891 +23,466 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  //#endregion
24
24
  let __loro_dev_flock = require("@loro-dev/flock");
25
25
  __loro_dev_flock = __toESM(__loro_dev_flock);
26
- let loro_adaptors_loro = require("loro-adaptors/loro");
27
- loro_adaptors_loro = __toESM(loro_adaptors_loro);
28
- let loro_protocol = require("loro-protocol");
29
- loro_protocol = __toESM(loro_protocol);
30
- let loro_websocket = require("loro-websocket");
31
- loro_websocket = __toESM(loro_websocket);
32
26
  let loro_crdt = require("loro-crdt");
33
27
  loro_crdt = __toESM(loro_crdt);
34
- let loro_adaptors_flock = require("loro-adaptors/flock");
35
- loro_adaptors_flock = __toESM(loro_adaptors_flock);
36
28
  let node_fs = require("node:fs");
37
29
  node_fs = __toESM(node_fs);
38
30
  let node_path = require("node:path");
39
31
  node_path = __toESM(node_path);
40
32
  let node_crypto = require("node:crypto");
41
33
  node_crypto = __toESM(node_crypto);
34
+ let loro_adaptors_loro = require("loro-adaptors/loro");
35
+ loro_adaptors_loro = __toESM(loro_adaptors_loro);
36
+ let loro_protocol = require("loro-protocol");
37
+ loro_protocol = __toESM(loro_protocol);
38
+ let loro_websocket = require("loro-websocket");
39
+ loro_websocket = __toESM(loro_websocket);
40
+ let loro_adaptors_flock = require("loro-adaptors/flock");
41
+ loro_adaptors_flock = __toESM(loro_adaptors_flock);
42
42
 
43
- //#region src/loro-adaptor.ts
44
- function createRepoFlockAdaptorFromDoc(flock, config = {}) {
45
- return new loro_adaptors_flock.FlockAdaptor(flock, config);
46
- }
47
-
48
- //#endregion
49
- //#region src/internal/debug.ts
50
- const getEnv = () => {
51
- if (typeof globalThis !== "object" || globalThis === null) return;
52
- return globalThis.process?.env;
53
- };
54
- const rawNamespaceConfig = (getEnv()?.LORO_REPO_DEBUG ?? "").trim();
55
- const normalizedNamespaces = rawNamespaceConfig.length > 0 ? rawNamespaceConfig.split(/[\s,]+/).map((token) => token.toLowerCase()).filter(Boolean) : [];
56
- const wildcardTokens = new Set([
57
- "*",
58
- "1",
59
- "true",
60
- "all"
61
- ]);
62
- const namespaceSet = new Set(normalizedNamespaces);
63
- const hasWildcard = namespaceSet.size > 0 && normalizedNamespaces.some((token) => wildcardTokens.has(token));
64
- const isDebugEnabled = (namespace) => {
65
- if (!namespaceSet.size) return false;
66
- if (!namespace) return hasWildcard;
67
- const normalized = namespace.toLowerCase();
68
- if (hasWildcard) return true;
69
- if (namespaceSet.has(normalized)) return true;
70
- const [root] = normalized.split(":");
71
- return namespaceSet.has(root);
72
- };
73
- const createDebugLogger = (namespace) => {
74
- const normalized = namespace.toLowerCase();
75
- return (...args) => {
76
- if (!isDebugEnabled(normalized)) return;
77
- const prefix = `[loro-repo:${namespace}]`;
78
- if (args.length === 0) {
79
- console.info(prefix);
80
- return;
81
- }
82
- console.info(prefix, ...args);
43
+ //#region src/transport/broadcast-channel.ts
44
+ function deferred() {
45
+ let resolve;
46
+ return {
47
+ promise: new Promise((res) => {
48
+ resolve = res;
49
+ }),
50
+ resolve
83
51
  };
84
- };
85
-
86
- //#endregion
87
- //#region src/transport/websocket.ts
88
- const debug = createDebugLogger("transport:websocket");
89
- function withTimeout(promise, timeoutMs) {
90
- if (!timeoutMs || timeoutMs <= 0) return promise;
91
- return new Promise((resolve, reject) => {
92
- const timer = setTimeout(() => {
93
- reject(/* @__PURE__ */ new Error(`Operation timed out after ${timeoutMs}ms`));
94
- }, timeoutMs);
95
- promise.then((value) => {
96
- clearTimeout(timer);
97
- resolve(value);
98
- }).catch((error) => {
99
- clearTimeout(timer);
100
- reject(error);
101
- });
102
- });
103
52
  }
104
- function normalizeRoomId(roomId, fallback) {
105
- if (typeof roomId === "string" && roomId.length > 0) return roomId;
106
- if (roomId instanceof Uint8Array && roomId.length > 0) try {
107
- return (0, loro_protocol.bytesToHex)(roomId);
53
+ function randomInstanceId() {
54
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
55
+ return Math.random().toString(36).slice(2);
56
+ }
57
+ function ensureBroadcastChannel() {
58
+ if (typeof BroadcastChannel === "undefined") throw new Error("BroadcastChannel API is not available in this environment");
59
+ return BroadcastChannel;
60
+ }
61
+ function encodeDocChannelId(docId) {
62
+ try {
63
+ return encodeURIComponent(docId);
108
64
  } catch {
109
- return fallback;
65
+ return docId.replace(/[^a-z0-9_-]/gi, "_");
110
66
  }
111
- return fallback;
112
67
  }
113
- function bytesEqual(a, b) {
114
- if (a === b) return true;
115
- if (!a || !b) return false;
116
- if (a.length !== b.length) return false;
117
- for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
118
- return true;
68
+ function postChannelMessage(channel, message) {
69
+ channel.postMessage(message);
119
70
  }
120
71
  /**
121
- * loro-websocket backed {@link TransportAdapter} implementation for LoroRepo.
72
+ * TransportAdapter that relies on the BroadcastChannel API to fan out metadata
73
+ * and document updates between browser tabs within the same origin.
122
74
  */
123
- var WebSocketTransportAdapter = class {
124
- options;
125
- client;
126
- metadataSession;
127
- docSessions = /* @__PURE__ */ new Map();
128
- constructor(options) {
129
- this.options = options;
75
+ var BroadcastChannelTransportAdapter = class {
76
+ instanceId = randomInstanceId();
77
+ namespace;
78
+ metaChannelName;
79
+ connected = false;
80
+ metaState;
81
+ docStates = /* @__PURE__ */ new Map();
82
+ constructor(options = {}) {
83
+ ensureBroadcastChannel();
84
+ this.namespace = options.namespace ?? "loro-repo";
85
+ this.metaChannelName = options.metaChannelName ?? `${this.namespace}-meta`;
130
86
  }
131
- async connect(_options) {
132
- const client = this.ensureClient();
133
- debug("connect requested", { status: client.getStatus() });
134
- try {
135
- await client.connect();
136
- debug("client.connect resolved");
137
- await client.waitConnected();
138
- debug("client.waitConnected resolved", { status: client.getStatus() });
139
- } catch (error) {
140
- debug("connect failed", error);
141
- throw error;
142
- }
87
+ async connect() {
88
+ this.connected = true;
143
89
  }
144
90
  async close() {
145
- debug("close requested", {
146
- docSessions: this.docSessions.size,
147
- metadataSession: Boolean(this.metadataSession)
148
- });
149
- for (const [docId] of this.docSessions) await this.leaveDocSession(docId).catch(() => {});
150
- this.docSessions.clear();
151
- await this.teardownMetadataSession().catch(() => {});
152
- if (this.client) {
153
- const client = this.client;
154
- this.client = void 0;
155
- client.destroy();
156
- debug("websocket client destroyed");
91
+ this.connected = false;
92
+ if (this.metaState) {
93
+ for (const entry of this.metaState.listeners) entry.unsubscribe();
94
+ this.metaState.channel.close();
95
+ this.metaState = void 0;
157
96
  }
158
- debug("close completed");
97
+ for (const [docId] of this.docStates) this.teardownDocChannel(docId);
98
+ this.docStates.clear();
159
99
  }
160
100
  isConnected() {
161
- return this.client?.getStatus() === "connected";
101
+ return this.connected;
162
102
  }
163
- async syncMeta(flock, options) {
164
- if (!this.options.metadataRoomId) {
165
- debug("syncMeta skipped; metadata room not configured");
166
- return { ok: true };
167
- }
168
- debug("syncMeta requested", { roomId: this.options.metadataRoomId });
169
- try {
170
- await withTimeout((await this.ensureMetadataSession(flock, {
171
- roomId: this.options.metadataRoomId,
172
- auth: this.options.metadataAuth
173
- })).firstSynced, options?.timeout);
174
- debug("syncMeta completed", { roomId: this.options.metadataRoomId });
175
- return { ok: true };
176
- } catch (error) {
177
- debug("syncMeta failed", error);
178
- return { ok: false };
179
- }
103
+ async syncMeta(flock, _options) {
104
+ const subscription = this.joinMetaRoom(flock);
105
+ subscription.firstSyncedWithRemote.catch(() => void 0);
106
+ await subscription.firstSyncedWithRemote;
107
+ subscription.unsubscribe();
108
+ return { ok: true };
180
109
  }
181
- joinMetaRoom(flock, params) {
182
- const fallback = this.options.metadataRoomId ?? "";
183
- const roomId = normalizeRoomId(params?.roomId, fallback);
184
- if (!roomId) throw new Error("Metadata room id not configured");
185
- const auth = params?.auth ?? this.options.metadataAuth;
186
- debug("joinMetaRoom requested", {
187
- roomId,
188
- hasAuth: Boolean(auth && auth.length)
110
+ joinMetaRoom(flock, _params) {
111
+ const state = this.ensureMetaChannel();
112
+ const { promise, resolve } = deferred();
113
+ const listener = {
114
+ flock,
115
+ muted: false,
116
+ unsubscribe: flock.subscribe(() => {
117
+ if (listener.muted) return;
118
+ Promise.resolve(flock.exportJson()).then((bundle) => {
119
+ postChannelMessage(state.channel, {
120
+ kind: "meta-export",
121
+ from: this.instanceId,
122
+ bundle
123
+ });
124
+ });
125
+ }),
126
+ resolveFirst: resolve,
127
+ firstSynced: promise
128
+ };
129
+ state.listeners.add(listener);
130
+ postChannelMessage(state.channel, {
131
+ kind: "meta-request",
132
+ from: this.instanceId
189
133
  });
190
- const ensure = this.ensureMetadataSession(flock, {
191
- roomId,
192
- auth
134
+ Promise.resolve(flock.exportJson()).then((bundle) => {
135
+ postChannelMessage(state.channel, {
136
+ kind: "meta-export",
137
+ from: this.instanceId,
138
+ bundle
139
+ });
193
140
  });
194
- const firstSynced = ensure.then((session) => session.firstSynced);
195
- const getConnected = () => this.isConnected();
196
- const subscription = {
141
+ queueMicrotask(() => resolve());
142
+ return {
197
143
  unsubscribe: () => {
198
- ensure.then((session) => {
199
- session.refCount = Math.max(0, session.refCount - 1);
200
- debug("metadata session refCount decremented", {
201
- roomId: session.roomId,
202
- refCount: session.refCount
203
- });
204
- if (session.refCount === 0) {
205
- debug("tearing down metadata session due to refCount=0", { roomId: session.roomId });
206
- this.teardownMetadataSession(session).catch(() => {});
207
- }
208
- });
144
+ listener.unsubscribe();
145
+ state.listeners.delete(listener);
146
+ if (!state.listeners.size) {
147
+ state.channel.removeEventListener("message", state.onMessage);
148
+ state.channel.close();
149
+ this.metaState = void 0;
150
+ }
209
151
  },
210
- firstSyncedWithRemote: firstSynced,
152
+ firstSyncedWithRemote: listener.firstSynced,
211
153
  get connected() {
212
- return getConnected();
154
+ return true;
213
155
  }
214
156
  };
215
- ensure.then((session) => {
216
- session.refCount += 1;
217
- debug("metadata session refCount incremented", {
218
- roomId: session.roomId,
219
- refCount: session.refCount
220
- });
221
- });
222
- return subscription;
223
157
  }
224
- async syncDoc(docId, doc, options) {
225
- debug("syncDoc requested", { docId });
226
- try {
227
- const session = await this.ensureDocSession(docId, doc, {});
228
- await withTimeout(session.firstSynced, options?.timeout);
229
- debug("syncDoc completed", {
230
- docId,
231
- roomId: session.roomId
232
- });
233
- return { ok: true };
234
- } catch (error) {
235
- debug("syncDoc failed", {
236
- docId,
237
- error
238
- });
239
- return { ok: false };
240
- }
158
+ async syncDoc(docId, doc, _options) {
159
+ const subscription = this.joinDocRoom(docId, doc);
160
+ subscription.firstSyncedWithRemote.catch(() => void 0);
161
+ await subscription.firstSyncedWithRemote;
162
+ subscription.unsubscribe();
163
+ return { ok: true };
241
164
  }
242
- joinDocRoom(docId, doc, params) {
243
- debug("joinDocRoom requested", {
165
+ joinDocRoom(docId, doc, _params) {
166
+ const state = this.ensureDocChannel(docId);
167
+ const { promise, resolve } = deferred();
168
+ const listener = {
169
+ doc,
170
+ muted: false,
171
+ unsubscribe: doc.subscribe(() => {
172
+ if (listener.muted) return;
173
+ const payload = doc.export({ mode: "update" });
174
+ postChannelMessage(state.channel, {
175
+ kind: "doc-update",
176
+ docId,
177
+ from: this.instanceId,
178
+ mode: "update",
179
+ payload
180
+ });
181
+ }),
182
+ resolveFirst: resolve,
183
+ firstSynced: promise
184
+ };
185
+ state.listeners.add(listener);
186
+ postChannelMessage(state.channel, {
187
+ kind: "doc-request",
244
188
  docId,
245
- roomParamType: params?.roomId ? typeof params.roomId === "string" ? "string" : "uint8array" : void 0,
246
- hasAuthOverride: Boolean(params?.auth && params.auth.length)
189
+ from: this.instanceId
247
190
  });
248
- const ensure = this.ensureDocSession(docId, doc, params ?? {});
249
- const firstSynced = ensure.then((session) => session.firstSynced);
250
- const getConnected = () => this.isConnected();
251
- const subscription = {
191
+ postChannelMessage(state.channel, {
192
+ kind: "doc-update",
193
+ docId,
194
+ from: this.instanceId,
195
+ mode: "snapshot",
196
+ payload: doc.export({ mode: "snapshot" })
197
+ });
198
+ queueMicrotask(() => resolve());
199
+ return {
252
200
  unsubscribe: () => {
253
- ensure.then((session) => {
254
- session.refCount = Math.max(0, session.refCount - 1);
255
- debug("doc session refCount decremented", {
256
- docId,
257
- roomId: session.roomId,
258
- refCount: session.refCount
259
- });
260
- if (session.refCount === 0) this.leaveDocSession(docId).catch(() => {});
261
- });
201
+ listener.unsubscribe();
202
+ state.listeners.delete(listener);
203
+ if (!state.listeners.size) this.teardownDocChannel(docId);
262
204
  },
263
- firstSyncedWithRemote: firstSynced,
205
+ firstSyncedWithRemote: listener.firstSynced,
264
206
  get connected() {
265
- return getConnected();
207
+ return true;
266
208
  }
267
209
  };
268
- ensure.then((session) => {
269
- session.refCount += 1;
270
- debug("doc session refCount incremented", {
271
- docId,
272
- roomId: session.roomId,
273
- refCount: session.refCount
274
- });
275
- });
276
- return subscription;
277
210
  }
278
- ensureClient() {
279
- if (this.client) {
280
- debug("reusing websocket client", { status: this.client.getStatus() });
281
- return this.client;
282
- }
283
- const { url, client: clientOptions } = this.options;
284
- debug("creating websocket client", {
285
- url,
286
- clientOptionsKeys: clientOptions ? Object.keys(clientOptions) : []
287
- });
288
- const client = new loro_websocket.LoroWebsocketClient({
289
- url,
290
- ...clientOptions
291
- });
292
- this.client = client;
293
- return client;
211
+ ensureMetaChannel() {
212
+ if (this.metaState) return this.metaState;
213
+ const channel = new (ensureBroadcastChannel())(this.metaChannelName);
214
+ const listeners = /* @__PURE__ */ new Set();
215
+ const onMessage = (event) => {
216
+ const message = event.data;
217
+ if (!message || message.from === this.instanceId) return;
218
+ if (message.kind === "meta-export") for (const entry of listeners) {
219
+ entry.muted = true;
220
+ entry.flock.importJson(message.bundle);
221
+ entry.muted = false;
222
+ entry.resolveFirst();
223
+ }
224
+ else if (message.kind === "meta-request") {
225
+ const first = listeners.values().next().value;
226
+ if (!first) return;
227
+ Promise.resolve(first.flock.exportJson()).then((bundle) => {
228
+ postChannelMessage(channel, {
229
+ kind: "meta-export",
230
+ from: this.instanceId,
231
+ bundle
232
+ });
233
+ });
234
+ }
235
+ };
236
+ channel.addEventListener("message", onMessage);
237
+ this.metaState = {
238
+ channel,
239
+ listeners,
240
+ onMessage
241
+ };
242
+ return this.metaState;
294
243
  }
295
- async ensureMetadataSession(flock, params) {
296
- debug("ensureMetadataSession invoked", {
297
- roomId: params.roomId,
298
- hasAuth: Boolean(params.auth && params.auth.length)
299
- });
300
- const client = this.ensureClient();
301
- await client.waitConnected();
302
- debug("websocket client ready for metadata session", { status: client.getStatus() });
303
- if (this.metadataSession && this.metadataSession.flock === flock && this.metadataSession.roomId === params.roomId && bytesEqual(this.metadataSession.auth, params.auth)) {
304
- debug("reusing metadata session", {
305
- roomId: this.metadataSession.roomId,
306
- refCount: this.metadataSession.refCount
307
- });
308
- return this.metadataSession;
309
- }
310
- if (this.metadataSession) {
311
- debug("tearing down previous metadata session", { roomId: this.metadataSession.roomId });
312
- await this.teardownMetadataSession(this.metadataSession).catch(() => {});
313
- }
314
- const configuredType = this.options.metadataCrdtType;
315
- if (configuredType && configuredType !== loro_protocol.CrdtType.Flock) throw new Error(`metadataCrdtType must be ${loro_protocol.CrdtType.Flock} when syncing Flock metadata`);
316
- const adaptor = createRepoFlockAdaptorFromDoc(flock, this.options.metadataAdaptorConfig ?? {});
317
- debug("joining metadata room", {
318
- roomId: params.roomId,
319
- hasAuth: Boolean(params.auth && params.auth.length)
320
- });
321
- const room = await client.join({
322
- roomId: params.roomId,
323
- crdtAdaptor: adaptor,
324
- auth: params.auth
325
- });
326
- const firstSynced = room.waitForReachingServerVersion();
327
- firstSynced.then(() => {
328
- debug("metadata session firstSynced resolved", { roomId: params.roomId });
329
- }, (error) => {
330
- debug("metadata session firstSynced rejected", {
331
- roomId: params.roomId,
332
- error
333
- });
334
- });
335
- const session = {
336
- adaptor,
337
- room,
338
- firstSynced,
339
- flock,
340
- roomId: params.roomId,
341
- auth: params.auth,
342
- refCount: 0
244
+ ensureDocChannel(docId) {
245
+ const existing = this.docStates.get(docId);
246
+ if (existing) return existing;
247
+ const channel = new (ensureBroadcastChannel())(`${this.namespace}-doc-${encodeDocChannelId(docId)}`);
248
+ const listeners = /* @__PURE__ */ new Set();
249
+ const onMessage = (event) => {
250
+ const message = event.data;
251
+ if (!message || message.from === this.instanceId) return;
252
+ if (message.kind === "doc-update") for (const entry of listeners) {
253
+ entry.muted = true;
254
+ entry.doc.import(message.payload);
255
+ entry.muted = false;
256
+ entry.resolveFirst();
257
+ }
258
+ else if (message.kind === "doc-request") {
259
+ const first = listeners.values().next().value;
260
+ if (!first) return;
261
+ const payload = message.docId === docId ? first.doc.export({ mode: "snapshot" }) : void 0;
262
+ if (!payload) return;
263
+ postChannelMessage(channel, {
264
+ kind: "doc-update",
265
+ docId,
266
+ from: this.instanceId,
267
+ mode: "snapshot",
268
+ payload
269
+ });
270
+ }
343
271
  };
344
- this.metadataSession = session;
345
- return session;
272
+ channel.addEventListener("message", onMessage);
273
+ const state = {
274
+ channel,
275
+ listeners,
276
+ onMessage
277
+ };
278
+ this.docStates.set(docId, state);
279
+ return state;
346
280
  }
347
- async teardownMetadataSession(session) {
348
- const target = session ?? this.metadataSession;
349
- if (!target) return;
350
- debug("teardownMetadataSession invoked", { roomId: target.roomId });
351
- if (this.metadataSession === target) this.metadataSession = void 0;
352
- const { adaptor, room } = target;
353
- try {
354
- await room.leave();
355
- debug("metadata room left", { roomId: target.roomId });
356
- } catch (error) {
357
- debug("metadata room leave failed; destroying", {
358
- roomId: target.roomId,
359
- error
360
- });
361
- await room.destroy().catch(() => {});
362
- }
363
- adaptor.destroy();
364
- debug("metadata session destroyed", { roomId: target.roomId });
365
- }
366
- async ensureDocSession(docId, doc, params) {
367
- debug("ensureDocSession invoked", { docId });
368
- const client = this.ensureClient();
369
- await client.waitConnected();
370
- debug("websocket client ready for doc session", {
371
- docId,
372
- status: client.getStatus()
373
- });
374
- const existing = this.docSessions.get(docId);
375
- const derivedRoomId = this.options.docRoomId?.(docId) ?? docId;
376
- const roomId = normalizeRoomId(params.roomId, derivedRoomId);
377
- const auth = params.auth ?? this.options.docAuth?.(docId);
378
- debug("doc session params resolved", {
379
- docId,
380
- roomId,
381
- hasAuth: Boolean(auth && auth.length)
382
- });
383
- if (existing && existing.doc === doc && existing.roomId === roomId) {
384
- debug("reusing doc session", {
385
- docId,
386
- roomId,
387
- refCount: existing.refCount
388
- });
389
- return existing;
390
- }
391
- if (existing) {
392
- debug("doc session mismatch; leaving existing session", {
393
- docId,
394
- previousRoomId: existing.roomId,
395
- nextRoomId: roomId
396
- });
397
- await this.leaveDocSession(docId).catch(() => {});
398
- }
399
- const adaptor = new loro_adaptors_loro.LoroAdaptor(doc);
400
- debug("joining doc room", {
401
- docId,
402
- roomId,
403
- hasAuth: Boolean(auth && auth.length)
404
- });
405
- const room = await client.join({
406
- roomId,
407
- crdtAdaptor: adaptor,
408
- auth
409
- });
410
- const firstSynced = room.waitForReachingServerVersion();
411
- firstSynced.then(() => {
412
- debug("doc session firstSynced resolved", {
413
- docId,
414
- roomId
415
- });
416
- }, (error) => {
417
- debug("doc session firstSynced rejected", {
418
- docId,
419
- roomId,
420
- error
421
- });
422
- });
423
- const session = {
424
- adaptor,
425
- room,
426
- firstSynced,
427
- doc,
428
- roomId,
429
- refCount: 0
430
- };
431
- this.docSessions.set(docId, session);
432
- return session;
433
- }
434
- async leaveDocSession(docId) {
435
- const session = this.docSessions.get(docId);
436
- if (!session) {
437
- debug("leaveDocSession invoked but no session found", { docId });
438
- return;
439
- }
440
- this.docSessions.delete(docId);
441
- debug("leaving doc session", {
442
- docId,
443
- roomId: session.roomId
444
- });
445
- try {
446
- await session.room.leave();
447
- debug("doc room left", {
448
- docId,
449
- roomId: session.roomId
450
- });
451
- } catch (error) {
452
- debug("doc room leave failed; destroying", {
453
- docId,
454
- roomId: session.roomId,
455
- error
456
- });
457
- await session.room.destroy().catch(() => {});
458
- }
459
- session.adaptor.destroy();
460
- debug("doc session destroyed", {
461
- docId,
462
- roomId: session.roomId
463
- });
281
+ teardownDocChannel(docId) {
282
+ const state = this.docStates.get(docId);
283
+ if (!state) return;
284
+ for (const entry of state.listeners) entry.unsubscribe();
285
+ state.channel.removeEventListener("message", state.onMessage);
286
+ state.channel.close();
287
+ this.docStates.delete(docId);
464
288
  }
465
289
  };
466
290
 
467
291
  //#endregion
468
- //#region src/transport/broadcast-channel.ts
469
- function deferred() {
470
- let resolve;
471
- return {
472
- promise: new Promise((res) => {
473
- resolve = res;
474
- }),
475
- resolve
476
- };
477
- }
478
- function randomInstanceId() {
479
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
480
- return Math.random().toString(36).slice(2);
481
- }
482
- function ensureBroadcastChannel() {
483
- if (typeof BroadcastChannel === "undefined") throw new Error("BroadcastChannel API is not available in this environment");
484
- return BroadcastChannel;
485
- }
486
- function encodeDocChannelId(docId) {
487
- try {
488
- return encodeURIComponent(docId);
292
+ //#region src/storage/indexeddb.ts
293
+ const DEFAULT_DB_NAME = "loro-repo";
294
+ const DEFAULT_DB_VERSION = 1;
295
+ const DEFAULT_DOC_STORE = "docs";
296
+ const DEFAULT_META_STORE = "meta";
297
+ const DEFAULT_ASSET_STORE = "assets";
298
+ const DEFAULT_DOC_UPDATE_STORE = "doc-updates";
299
+ const DEFAULT_META_KEY = "snapshot";
300
+ const textDecoder$1 = new TextDecoder();
301
+ function describeUnknown(cause) {
302
+ if (typeof cause === "string") return cause;
303
+ if (typeof cause === "number" || typeof cause === "boolean") return String(cause);
304
+ if (typeof cause === "bigint") return cause.toString();
305
+ if (typeof cause === "symbol") return cause.description ?? cause.toString();
306
+ if (typeof cause === "function") return `[function ${cause.name ?? "anonymous"}]`;
307
+ if (cause && typeof cause === "object") try {
308
+ return JSON.stringify(cause);
489
309
  } catch {
490
- return docId.replace(/[^a-z0-9_-]/gi, "_");
310
+ return "[object]";
491
311
  }
312
+ return String(cause);
492
313
  }
493
- function postChannelMessage(channel, message) {
494
- channel.postMessage(message);
495
- }
496
- /**
497
- * TransportAdapter that relies on the BroadcastChannel API to fan out metadata
498
- * and document updates between browser tabs within the same origin.
499
- */
500
- var BroadcastChannelTransportAdapter = class {
501
- instanceId = randomInstanceId();
502
- namespace;
503
- metaChannelName;
504
- connected = false;
505
- metaState;
506
- docStates = /* @__PURE__ */ new Map();
314
+ var IndexedDBStorageAdaptor = class {
315
+ idb;
316
+ dbName;
317
+ version;
318
+ docStore;
319
+ docUpdateStore;
320
+ metaStore;
321
+ assetStore;
322
+ metaKey;
323
+ dbPromise;
324
+ closed = false;
507
325
  constructor(options = {}) {
508
- ensureBroadcastChannel();
509
- this.namespace = options.namespace ?? "loro-repo";
510
- this.metaChannelName = options.metaChannelName ?? `${this.namespace}-meta`;
326
+ const idbFactory = globalThis.indexedDB;
327
+ if (!idbFactory) throw new Error("IndexedDB is not available in this environment");
328
+ this.idb = idbFactory;
329
+ this.dbName = options.dbName ?? DEFAULT_DB_NAME;
330
+ this.version = options.version ?? DEFAULT_DB_VERSION;
331
+ this.docStore = options.docStoreName ?? DEFAULT_DOC_STORE;
332
+ this.docUpdateStore = options.docUpdateStoreName ?? DEFAULT_DOC_UPDATE_STORE;
333
+ this.metaStore = options.metaStoreName ?? DEFAULT_META_STORE;
334
+ this.assetStore = options.assetStoreName ?? DEFAULT_ASSET_STORE;
335
+ this.metaKey = options.metaKey ?? DEFAULT_META_KEY;
511
336
  }
512
- async connect() {
513
- this.connected = true;
337
+ async save(payload) {
338
+ const db = await this.ensureDb();
339
+ switch (payload.type) {
340
+ case "doc-snapshot": {
341
+ const snapshot = payload.snapshot.slice();
342
+ await this.storeMergedSnapshot(db, payload.docId, snapshot);
343
+ break;
344
+ }
345
+ case "doc-update": {
346
+ const update = payload.update.slice();
347
+ await this.appendDocUpdate(db, payload.docId, update);
348
+ break;
349
+ }
350
+ case "asset": {
351
+ const bytes = payload.data.slice();
352
+ await this.putBinary(db, this.assetStore, payload.assetId, bytes);
353
+ break;
354
+ }
355
+ case "meta": {
356
+ const bytes = payload.update.slice();
357
+ await this.putBinary(db, this.metaStore, this.metaKey, bytes);
358
+ break;
359
+ }
360
+ default: throw new Error("Unsupported storage payload type");
361
+ }
514
362
  }
515
- async close() {
516
- this.connected = false;
517
- if (this.metaState) {
518
- for (const entry of this.metaState.listeners) entry.unsubscribe();
519
- this.metaState.channel.close();
520
- this.metaState = void 0;
363
+ async deleteAsset(assetId) {
364
+ const db = await this.ensureDb();
365
+ await this.deleteKey(db, this.assetStore, assetId);
366
+ }
367
+ async loadDoc(docId) {
368
+ const db = await this.ensureDb();
369
+ const snapshot = await this.getBinaryFromDb(db, this.docStore, docId);
370
+ const pendingUpdates = await this.getDocUpdates(db, docId);
371
+ if (!snapshot && pendingUpdates.length === 0) return;
372
+ let doc;
373
+ try {
374
+ doc = snapshot ? loro_crdt.LoroDoc.fromSnapshot(snapshot) : new loro_crdt.LoroDoc();
375
+ } catch (error) {
376
+ throw this.createError(`Failed to hydrate document snapshot for "${docId}"`, error);
521
377
  }
522
- for (const [docId] of this.docStates) this.teardownDocChannel(docId);
523
- this.docStates.clear();
378
+ let appliedUpdates = false;
379
+ for (const update of pendingUpdates) try {
380
+ doc.import(update);
381
+ appliedUpdates = true;
382
+ } catch (error) {
383
+ throw this.createError(`Failed to apply queued document update for "${docId}"`, error);
384
+ }
385
+ if (appliedUpdates) {
386
+ let consolidated;
387
+ try {
388
+ consolidated = doc.export({ mode: "snapshot" });
389
+ } catch (error) {
390
+ throw this.createError(`Failed to export consolidated snapshot for "${docId}"`, error);
391
+ }
392
+ await this.writeSnapshot(db, docId, consolidated);
393
+ await this.clearDocUpdates(db, docId);
394
+ }
395
+ return doc;
524
396
  }
525
- isConnected() {
526
- return this.connected;
397
+ async loadMeta() {
398
+ const bytes = await this.getBinary(this.metaStore, this.metaKey);
399
+ if (!bytes) return void 0;
400
+ try {
401
+ const json = textDecoder$1.decode(bytes);
402
+ const bundle = JSON.parse(json);
403
+ const flock = new __loro_dev_flock.Flock();
404
+ flock.importJson(bundle);
405
+ return flock;
406
+ } catch (error) {
407
+ throw this.createError("Failed to hydrate metadata snapshot", error);
408
+ }
527
409
  }
528
- async syncMeta(flock, _options) {
529
- const subscription = this.joinMetaRoom(flock);
530
- subscription.firstSyncedWithRemote.catch(() => void 0);
531
- await subscription.firstSyncedWithRemote;
532
- subscription.unsubscribe();
533
- return { ok: true };
410
+ async loadAsset(assetId) {
411
+ return await this.getBinary(this.assetStore, assetId) ?? void 0;
534
412
  }
535
- joinMetaRoom(flock, _params) {
536
- const state = this.ensureMetaChannel();
537
- const { promise, resolve } = deferred();
538
- const listener = {
539
- flock,
540
- muted: false,
541
- unsubscribe: flock.subscribe(() => {
542
- if (listener.muted) return;
543
- Promise.resolve(flock.exportJson()).then((bundle) => {
544
- postChannelMessage(state.channel, {
545
- kind: "meta-export",
546
- from: this.instanceId,
547
- bundle
548
- });
549
- });
550
- }),
551
- resolveFirst: resolve,
552
- firstSynced: promise
553
- };
554
- state.listeners.add(listener);
555
- postChannelMessage(state.channel, {
556
- kind: "meta-request",
557
- from: this.instanceId
558
- });
559
- Promise.resolve(flock.exportJson()).then((bundle) => {
560
- postChannelMessage(state.channel, {
561
- kind: "meta-export",
562
- from: this.instanceId,
563
- bundle
413
+ async close() {
414
+ this.closed = true;
415
+ const db = await this.dbPromise;
416
+ if (db) db.close();
417
+ this.dbPromise = void 0;
418
+ }
419
+ async ensureDb() {
420
+ if (this.closed) throw new Error("IndexedDBStorageAdaptor has been closed");
421
+ if (!this.dbPromise) this.dbPromise = new Promise((resolve, reject) => {
422
+ const request = this.idb.open(this.dbName, this.version);
423
+ request.addEventListener("upgradeneeded", () => {
424
+ const db = request.result;
425
+ this.ensureStore(db, this.docStore);
426
+ this.ensureStore(db, this.docUpdateStore);
427
+ this.ensureStore(db, this.metaStore);
428
+ this.ensureStore(db, this.assetStore);
564
429
  });
430
+ request.addEventListener("success", () => resolve(request.result), { once: true });
431
+ request.addEventListener("error", () => {
432
+ reject(this.createError(`Failed to open IndexedDB database "${this.dbName}"`, request.error));
433
+ }, { once: true });
565
434
  });
566
- queueMicrotask(() => resolve());
567
- return {
568
- unsubscribe: () => {
569
- listener.unsubscribe();
570
- state.listeners.delete(listener);
571
- if (!state.listeners.size) {
572
- state.channel.removeEventListener("message", state.onMessage);
573
- state.channel.close();
574
- this.metaState = void 0;
575
- }
576
- },
577
- firstSyncedWithRemote: listener.firstSynced,
578
- get connected() {
579
- return true;
580
- }
581
- };
435
+ return this.dbPromise;
582
436
  }
583
- async syncDoc(docId, doc, _options) {
584
- const subscription = this.joinDocRoom(docId, doc);
585
- subscription.firstSyncedWithRemote.catch(() => void 0);
586
- await subscription.firstSyncedWithRemote;
587
- subscription.unsubscribe();
588
- return { ok: true };
437
+ ensureStore(db, storeName) {
438
+ const names = db.objectStoreNames;
439
+ if (this.storeExists(names, storeName)) return;
440
+ db.createObjectStore(storeName);
589
441
  }
590
- joinDocRoom(docId, doc, _params) {
591
- const state = this.ensureDocChannel(docId);
592
- const { promise, resolve } = deferred();
593
- const listener = {
594
- doc,
595
- muted: false,
596
- unsubscribe: doc.subscribe(() => {
597
- if (listener.muted) return;
598
- const payload = doc.export({ mode: "update" });
599
- postChannelMessage(state.channel, {
600
- kind: "doc-update",
601
- docId,
602
- from: this.instanceId,
603
- mode: "update",
604
- payload
605
- });
606
- }),
607
- resolveFirst: resolve,
608
- firstSynced: promise
609
- };
610
- state.listeners.add(listener);
611
- postChannelMessage(state.channel, {
612
- kind: "doc-request",
613
- docId,
614
- from: this.instanceId
442
+ storeExists(names, storeName) {
443
+ if (typeof names.contains === "function") return names.contains(storeName);
444
+ const length = names.length ?? 0;
445
+ for (let index = 0; index < length; index += 1) if (names.item?.(index) === storeName) return true;
446
+ return false;
447
+ }
448
+ async storeMergedSnapshot(db, docId, incoming) {
449
+ await this.runInTransaction(db, this.docStore, "readwrite", async (store) => {
450
+ const existingRaw = await this.wrapRequest(store.get(docId), "read");
451
+ const existing = await this.normalizeBinary(existingRaw);
452
+ const merged = this.mergeSnapshots(docId, existing, incoming);
453
+ await this.wrapRequest(store.put(merged, docId), "write");
615
454
  });
616
- postChannelMessage(state.channel, {
617
- kind: "doc-update",
618
- docId,
619
- from: this.instanceId,
620
- mode: "snapshot",
621
- payload: doc.export({ mode: "snapshot" })
455
+ }
456
+ mergeSnapshots(docId, existing, incoming) {
457
+ try {
458
+ const doc = existing ? loro_crdt.LoroDoc.fromSnapshot(existing) : new loro_crdt.LoroDoc();
459
+ doc.import(incoming);
460
+ return doc.export({ mode: "snapshot" });
461
+ } catch (error) {
462
+ throw this.createError(`Failed to merge snapshot for "${docId}"`, error);
463
+ }
464
+ }
465
+ async appendDocUpdate(db, docId, update) {
466
+ await this.runInTransaction(db, this.docUpdateStore, "readwrite", async (store) => {
467
+ const raw = await this.wrapRequest(store.get(docId), "read");
468
+ const queue = await this.normalizeUpdateQueue(raw);
469
+ queue.push(update.slice());
470
+ await this.wrapRequest(store.put({ updates: queue }, docId), "write");
622
471
  });
623
- queueMicrotask(() => resolve());
624
- return {
625
- unsubscribe: () => {
626
- listener.unsubscribe();
627
- state.listeners.delete(listener);
628
- if (!state.listeners.size) this.teardownDocChannel(docId);
629
- },
630
- firstSyncedWithRemote: listener.firstSynced,
631
- get connected() {
632
- return true;
633
- }
634
- };
635
472
  }
636
- ensureMetaChannel() {
637
- if (this.metaState) return this.metaState;
638
- const channel = new (ensureBroadcastChannel())(this.metaChannelName);
639
- const listeners = /* @__PURE__ */ new Set();
640
- const onMessage = (event) => {
641
- const message = event.data;
642
- if (!message || message.from === this.instanceId) return;
643
- if (message.kind === "meta-export") for (const entry of listeners) {
644
- entry.muted = true;
645
- entry.flock.importJson(message.bundle);
646
- entry.muted = false;
647
- entry.resolveFirst();
648
- }
649
- else if (message.kind === "meta-request") {
650
- const first = listeners.values().next().value;
651
- if (!first) return;
652
- Promise.resolve(first.flock.exportJson()).then((bundle) => {
653
- postChannelMessage(channel, {
654
- kind: "meta-export",
655
- from: this.instanceId,
656
- bundle
657
- });
658
- });
659
- }
660
- };
661
- channel.addEventListener("message", onMessage);
662
- this.metaState = {
663
- channel,
664
- listeners,
665
- onMessage
666
- };
667
- return this.metaState;
473
+ async getDocUpdates(db, docId) {
474
+ const raw = await this.runInTransaction(db, this.docUpdateStore, "readonly", (store) => this.wrapRequest(store.get(docId), "read"));
475
+ return this.normalizeUpdateQueue(raw);
668
476
  }
669
- ensureDocChannel(docId) {
670
- const existing = this.docStates.get(docId);
671
- if (existing) return existing;
672
- const channel = new (ensureBroadcastChannel())(`${this.namespace}-doc-${encodeDocChannelId(docId)}`);
673
- const listeners = /* @__PURE__ */ new Set();
674
- const onMessage = (event) => {
675
- const message = event.data;
676
- if (!message || message.from === this.instanceId) return;
677
- if (message.kind === "doc-update") for (const entry of listeners) {
678
- entry.muted = true;
679
- entry.doc.import(message.payload);
680
- entry.muted = false;
681
- entry.resolveFirst();
682
- }
683
- else if (message.kind === "doc-request") {
684
- const first = listeners.values().next().value;
685
- if (!first) return;
686
- const payload = message.docId === docId ? first.doc.export({ mode: "snapshot" }) : void 0;
687
- if (!payload) return;
688
- postChannelMessage(channel, {
689
- kind: "doc-update",
690
- docId,
691
- from: this.instanceId,
692
- mode: "snapshot",
693
- payload
694
- });
695
- }
696
- };
697
- channel.addEventListener("message", onMessage);
698
- const state = {
699
- channel,
700
- listeners,
701
- onMessage
702
- };
703
- this.docStates.set(docId, state);
704
- return state;
477
+ async clearDocUpdates(db, docId) {
478
+ await this.runInTransaction(db, this.docUpdateStore, "readwrite", (store) => this.wrapRequest(store.delete(docId), "delete"));
705
479
  }
706
- teardownDocChannel(docId) {
707
- const state = this.docStates.get(docId);
708
- if (!state) return;
709
- for (const entry of state.listeners) entry.unsubscribe();
710
- state.channel.removeEventListener("message", state.onMessage);
711
- state.channel.close();
712
- this.docStates.delete(docId);
480
+ async writeSnapshot(db, docId, snapshot) {
481
+ await this.putBinary(db, this.docStore, docId, snapshot.slice());
713
482
  }
714
- };
715
-
716
- //#endregion
717
- //#region src/storage/indexeddb.ts
718
- const DEFAULT_DB_NAME = "loro-repo";
719
- const DEFAULT_DB_VERSION = 1;
720
- const DEFAULT_DOC_STORE = "docs";
721
- const DEFAULT_META_STORE = "meta";
722
- const DEFAULT_ASSET_STORE = "assets";
723
- const DEFAULT_DOC_UPDATE_STORE = "doc-updates";
724
- const DEFAULT_META_KEY = "snapshot";
725
- const textDecoder$1 = new TextDecoder();
726
- function describeUnknown(cause) {
727
- if (typeof cause === "string") return cause;
728
- if (typeof cause === "number" || typeof cause === "boolean") return String(cause);
729
- if (typeof cause === "bigint") return cause.toString();
730
- if (typeof cause === "symbol") return cause.description ?? cause.toString();
731
- if (typeof cause === "function") return `[function ${cause.name ?? "anonymous"}]`;
732
- if (cause && typeof cause === "object") try {
733
- return JSON.stringify(cause);
734
- } catch {
735
- return "[object]";
736
- }
737
- return String(cause);
738
- }
739
- var IndexedDBStorageAdaptor = class {
740
- idb;
741
- dbName;
742
- version;
743
- docStore;
744
- docUpdateStore;
745
- metaStore;
746
- assetStore;
747
- metaKey;
748
- dbPromise;
749
- closed = false;
750
- constructor(options = {}) {
751
- const idbFactory = globalThis.indexedDB;
752
- if (!idbFactory) throw new Error("IndexedDB is not available in this environment");
753
- this.idb = idbFactory;
754
- this.dbName = options.dbName ?? DEFAULT_DB_NAME;
755
- this.version = options.version ?? DEFAULT_DB_VERSION;
756
- this.docStore = options.docStoreName ?? DEFAULT_DOC_STORE;
757
- this.docUpdateStore = options.docUpdateStoreName ?? DEFAULT_DOC_UPDATE_STORE;
758
- this.metaStore = options.metaStoreName ?? DEFAULT_META_STORE;
759
- this.assetStore = options.assetStoreName ?? DEFAULT_ASSET_STORE;
760
- this.metaKey = options.metaKey ?? DEFAULT_META_KEY;
761
- }
762
- async save(payload) {
763
- const db = await this.ensureDb();
764
- switch (payload.type) {
765
- case "doc-snapshot": {
766
- const snapshot = payload.snapshot.slice();
767
- await this.storeMergedSnapshot(db, payload.docId, snapshot);
768
- break;
769
- }
770
- case "doc-update": {
771
- const update = payload.update.slice();
772
- await this.appendDocUpdate(db, payload.docId, update);
773
- break;
774
- }
775
- case "asset": {
776
- const bytes = payload.data.slice();
777
- await this.putBinary(db, this.assetStore, payload.assetId, bytes);
778
- break;
779
- }
780
- case "meta": {
781
- const bytes = payload.update.slice();
782
- await this.putBinary(db, this.metaStore, this.metaKey, bytes);
783
- break;
784
- }
785
- default: throw new Error("Unsupported storage payload type");
786
- }
787
- }
788
- async deleteAsset(assetId) {
789
- const db = await this.ensureDb();
790
- await this.deleteKey(db, this.assetStore, assetId);
791
- }
792
- async loadDoc(docId) {
793
- const db = await this.ensureDb();
794
- const snapshot = await this.getBinaryFromDb(db, this.docStore, docId);
795
- const pendingUpdates = await this.getDocUpdates(db, docId);
796
- if (!snapshot && pendingUpdates.length === 0) return;
797
- let doc;
798
- try {
799
- doc = snapshot ? loro_crdt.LoroDoc.fromSnapshot(snapshot) : new loro_crdt.LoroDoc();
800
- } catch (error) {
801
- throw this.createError(`Failed to hydrate document snapshot for "${docId}"`, error);
802
- }
803
- let appliedUpdates = false;
804
- for (const update of pendingUpdates) try {
805
- doc.import(update);
806
- appliedUpdates = true;
807
- } catch (error) {
808
- throw this.createError(`Failed to apply queued document update for "${docId}"`, error);
809
- }
810
- if (appliedUpdates) {
811
- let consolidated;
812
- try {
813
- consolidated = doc.export({ mode: "snapshot" });
814
- } catch (error) {
815
- throw this.createError(`Failed to export consolidated snapshot for "${docId}"`, error);
816
- }
817
- await this.writeSnapshot(db, docId, consolidated);
818
- await this.clearDocUpdates(db, docId);
819
- }
820
- return doc;
821
- }
822
- async loadMeta() {
823
- const bytes = await this.getBinary(this.metaStore, this.metaKey);
824
- if (!bytes) return void 0;
825
- try {
826
- const json = textDecoder$1.decode(bytes);
827
- const bundle = JSON.parse(json);
828
- const flock = new __loro_dev_flock.Flock();
829
- flock.importJson(bundle);
830
- return flock;
831
- } catch (error) {
832
- throw this.createError("Failed to hydrate metadata snapshot", error);
833
- }
834
- }
835
- async loadAsset(assetId) {
836
- return await this.getBinary(this.assetStore, assetId) ?? void 0;
837
- }
838
- async close() {
839
- this.closed = true;
840
- const db = await this.dbPromise;
841
- if (db) db.close();
842
- this.dbPromise = void 0;
843
- }
844
- async ensureDb() {
845
- if (this.closed) throw new Error("IndexedDBStorageAdaptor has been closed");
846
- if (!this.dbPromise) this.dbPromise = new Promise((resolve, reject) => {
847
- const request = this.idb.open(this.dbName, this.version);
848
- request.addEventListener("upgradeneeded", () => {
849
- const db = request.result;
850
- this.ensureStore(db, this.docStore);
851
- this.ensureStore(db, this.docUpdateStore);
852
- this.ensureStore(db, this.metaStore);
853
- this.ensureStore(db, this.assetStore);
854
- });
855
- request.addEventListener("success", () => resolve(request.result), { once: true });
856
- request.addEventListener("error", () => {
857
- reject(this.createError(`Failed to open IndexedDB database "${this.dbName}"`, request.error));
858
- }, { once: true });
859
- });
860
- return this.dbPromise;
861
- }
862
- ensureStore(db, storeName) {
863
- const names = db.objectStoreNames;
864
- if (this.storeExists(names, storeName)) return;
865
- db.createObjectStore(storeName);
866
- }
867
- storeExists(names, storeName) {
868
- if (typeof names.contains === "function") return names.contains(storeName);
869
- const length = names.length ?? 0;
870
- for (let index = 0; index < length; index += 1) if (names.item?.(index) === storeName) return true;
871
- return false;
872
- }
873
- async storeMergedSnapshot(db, docId, incoming) {
874
- await this.runInTransaction(db, this.docStore, "readwrite", async (store) => {
875
- const existingRaw = await this.wrapRequest(store.get(docId), "read");
876
- const existing = await this.normalizeBinary(existingRaw);
877
- const merged = this.mergeSnapshots(docId, existing, incoming);
878
- await this.wrapRequest(store.put(merged, docId), "write");
879
- });
880
- }
881
- mergeSnapshots(docId, existing, incoming) {
882
- try {
883
- const doc = existing ? loro_crdt.LoroDoc.fromSnapshot(existing) : new loro_crdt.LoroDoc();
884
- doc.import(incoming);
885
- return doc.export({ mode: "snapshot" });
886
- } catch (error) {
887
- throw this.createError(`Failed to merge snapshot for "${docId}"`, error);
888
- }
889
- }
890
- async appendDocUpdate(db, docId, update) {
891
- await this.runInTransaction(db, this.docUpdateStore, "readwrite", async (store) => {
892
- const raw = await this.wrapRequest(store.get(docId), "read");
893
- const queue = await this.normalizeUpdateQueue(raw);
894
- queue.push(update.slice());
895
- await this.wrapRequest(store.put({ updates: queue }, docId), "write");
896
- });
897
- }
898
- async getDocUpdates(db, docId) {
899
- const raw = await this.runInTransaction(db, this.docUpdateStore, "readonly", (store) => this.wrapRequest(store.get(docId), "read"));
900
- return this.normalizeUpdateQueue(raw);
901
- }
902
- async clearDocUpdates(db, docId) {
903
- await this.runInTransaction(db, this.docUpdateStore, "readwrite", (store) => this.wrapRequest(store.delete(docId), "delete"));
904
- }
905
- async writeSnapshot(db, docId, snapshot) {
906
- await this.putBinary(db, this.docStore, docId, snapshot.slice());
907
- }
908
- async getBinaryFromDb(db, storeName, key) {
909
- const value = await this.runInTransaction(db, storeName, "readonly", (store) => this.wrapRequest(store.get(key), "read"));
910
- return this.normalizeBinary(value);
483
+ async getBinaryFromDb(db, storeName, key) {
484
+ const value = await this.runInTransaction(db, storeName, "readonly", (store) => this.wrapRequest(store.get(key), "read"));
485
+ return this.normalizeBinary(value);
911
486
  }
912
487
  async normalizeUpdateQueue(value) {
913
488
  if (value == null) return [];
@@ -1117,241 +692,476 @@ async function writeFileAtomic(targetPath, data) {
1117
692
  }
1118
693
 
1119
694
  //#endregion
1120
- //#region src/internal/event-bus.ts
1121
- var RepoEventBus = class {
1122
- watchers = /* @__PURE__ */ new Set();
1123
- eventByStack = [];
1124
- watch(listener, filter = {}) {
1125
- const entry = {
1126
- listener,
1127
- filter
1128
- };
1129
- this.watchers.add(entry);
1130
- return { unsubscribe: () => {
1131
- this.watchers.delete(entry);
1132
- } };
1133
- }
1134
- emit(event) {
1135
- for (const entry of this.watchers) if (this.shouldNotify(entry.filter, event)) entry.listener(event);
1136
- }
1137
- clear() {
1138
- this.watchers.clear();
1139
- this.eventByStack.length = 0;
1140
- }
1141
- pushEventBy(by) {
1142
- this.eventByStack.push(by);
1143
- }
1144
- popEventBy() {
1145
- this.eventByStack.pop();
1146
- }
1147
- resolveEventBy(defaultBy) {
1148
- const index = this.eventByStack.length - 1;
1149
- return index >= 0 ? this.eventByStack[index] : defaultBy;
1150
- }
1151
- shouldNotify(filter, event) {
1152
- if (!filter.docIds && !filter.kinds && !filter.metadataFields && !filter.by) return true;
1153
- if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
1154
- if (filter.by && !filter.by.includes(event.by)) return false;
1155
- const docId = (() => {
1156
- if (event.kind === "doc-metadata" || event.kind === "doc-frontiers") return event.docId;
1157
- if (event.kind === "asset-link" || event.kind === "asset-unlink") return event.docId;
1158
- })();
1159
- if (filter.docIds && docId && !filter.docIds.includes(docId)) return false;
1160
- if (filter.docIds && !docId) return false;
1161
- if (filter.metadataFields && event.kind === "doc-metadata") {
1162
- if (!Object.keys(event.patch).some((key) => filter.metadataFields?.includes(key))) return false;
695
+ //#region src/internal/debug.ts
696
+ const getEnv = () => {
697
+ if (typeof globalThis !== "object" || globalThis === null) return;
698
+ return globalThis.process?.env;
699
+ };
700
+ const rawNamespaceConfig = (getEnv()?.LORO_REPO_DEBUG ?? "").trim();
701
+ const normalizedNamespaces = rawNamespaceConfig.length > 0 ? rawNamespaceConfig.split(/[\s,]+/).map((token) => token.toLowerCase()).filter(Boolean) : [];
702
+ const wildcardTokens = new Set([
703
+ "*",
704
+ "1",
705
+ "true",
706
+ "all"
707
+ ]);
708
+ const namespaceSet = new Set(normalizedNamespaces);
709
+ const hasWildcard = namespaceSet.size > 0 && normalizedNamespaces.some((token) => wildcardTokens.has(token));
710
+ const isDebugEnabled = (namespace) => {
711
+ if (!namespaceSet.size) return false;
712
+ if (!namespace) return hasWildcard;
713
+ const normalized = namespace.toLowerCase();
714
+ if (hasWildcard) return true;
715
+ if (namespaceSet.has(normalized)) return true;
716
+ const [root] = normalized.split(":");
717
+ return namespaceSet.has(root);
718
+ };
719
+ const createDebugLogger = (namespace) => {
720
+ const normalized = namespace.toLowerCase();
721
+ return (...args) => {
722
+ if (!isDebugEnabled(normalized)) return;
723
+ const prefix = `[loro-repo:${namespace}]`;
724
+ if (args.length === 0) {
725
+ console.info(prefix);
726
+ return;
1163
727
  }
1164
- return true;
1165
- }
728
+ console.info(prefix, ...args);
729
+ };
1166
730
  };
1167
731
 
1168
732
  //#endregion
1169
- //#region src/utils.ts
1170
- async function streamToUint8Array(stream) {
1171
- const reader = stream.getReader();
1172
- const chunks = [];
1173
- let total = 0;
1174
- while (true) {
1175
- const { done, value } = await reader.read();
1176
- if (done) break;
1177
- if (value) {
1178
- chunks.push(value);
1179
- total += value.byteLength;
1180
- }
1181
- }
1182
- const buffer = new Uint8Array(total);
1183
- let offset = 0;
1184
- for (const chunk of chunks) {
1185
- buffer.set(chunk, offset);
1186
- offset += chunk.byteLength;
1187
- }
1188
- return buffer;
733
+ //#region src/transport/websocket.ts
734
+ const debug = createDebugLogger("transport:websocket");
735
+ function withTimeout(promise, timeoutMs) {
736
+ if (!timeoutMs || timeoutMs <= 0) return promise;
737
+ return new Promise((resolve, reject) => {
738
+ const timer = setTimeout(() => {
739
+ reject(/* @__PURE__ */ new Error(`Operation timed out after ${timeoutMs}ms`));
740
+ }, timeoutMs);
741
+ promise.then((value) => {
742
+ clearTimeout(timer);
743
+ resolve(value);
744
+ }).catch((error) => {
745
+ clearTimeout(timer);
746
+ reject(error);
747
+ });
748
+ });
1189
749
  }
1190
- async function assetContentToUint8Array(content) {
1191
- if (content instanceof Uint8Array) return content;
1192
- if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength));
1193
- if (typeof Blob !== "undefined" && content instanceof Blob) return new Uint8Array(await content.arrayBuffer());
1194
- if (typeof ReadableStream !== "undefined" && content instanceof ReadableStream) return streamToUint8Array(content);
1195
- throw new TypeError("Unsupported asset content type");
750
+ function normalizeRoomId(roomId, fallback) {
751
+ if (typeof roomId === "string" && roomId.length > 0) return roomId;
752
+ if (roomId instanceof Uint8Array && roomId.length > 0) try {
753
+ return (0, loro_protocol.bytesToHex)(roomId);
754
+ } catch {
755
+ return fallback;
756
+ }
757
+ return fallback;
1196
758
  }
1197
- function bytesToHex(bytes) {
1198
- return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
759
+ function bytesEqual(a, b) {
760
+ if (a === b) return true;
761
+ if (!a || !b) return false;
762
+ if (a.length !== b.length) return false;
763
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
764
+ return true;
1199
765
  }
1200
- async function computeSha256(bytes) {
1201
- const globalCrypto = globalThis.crypto;
1202
- if (globalCrypto?.subtle && typeof globalCrypto.subtle.digest === "function") {
1203
- const digest = await globalCrypto.subtle.digest("SHA-256", bytes);
1204
- return bytesToHex(new Uint8Array(digest));
766
+ /**
767
+ * loro-websocket backed {@link TransportAdapter} implementation for LoroRepo.
768
+ * It uses loro-protocol as the underlying protocol for the transport.
769
+ */
770
+ var WebSocketTransportAdapter = class {
771
+ options;
772
+ client;
773
+ metadataSession;
774
+ docSessions = /* @__PURE__ */ new Map();
775
+ constructor(options) {
776
+ this.options = options;
1205
777
  }
1206
- try {
1207
- const { createHash } = await import("node:crypto");
1208
- const hash = createHash("sha256");
1209
- hash.update(bytes);
1210
- return hash.digest("hex");
1211
- } catch {
1212
- throw new Error("SHA-256 digest is not available in this environment");
778
+ async connect(_options) {
779
+ const client = this.ensureClient();
780
+ debug("connect requested", { status: client.getStatus() });
781
+ try {
782
+ await client.connect();
783
+ debug("client.connect resolved");
784
+ await client.waitConnected();
785
+ debug("client.waitConnected resolved", { status: client.getStatus() });
786
+ } catch (error) {
787
+ debug("connect failed", error);
788
+ throw error;
789
+ }
1213
790
  }
1214
- }
1215
- function cloneJsonValue(value) {
1216
- if (value === null) return null;
1217
- if (typeof value === "string" || typeof value === "boolean") return value;
1218
- if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
1219
- if (Array.isArray(value)) {
1220
- const arr = [];
1221
- for (const entry of value) {
1222
- const cloned = cloneJsonValue(entry);
1223
- if (cloned !== void 0) arr.push(cloned);
791
+ async close() {
792
+ debug("close requested", {
793
+ docSessions: this.docSessions.size,
794
+ metadataSession: Boolean(this.metadataSession)
795
+ });
796
+ for (const [docId] of this.docSessions) await this.leaveDocSession(docId).catch(() => {});
797
+ this.docSessions.clear();
798
+ await this.teardownMetadataSession().catch(() => {});
799
+ if (this.client) {
800
+ const client = this.client;
801
+ this.client = void 0;
802
+ client.destroy();
803
+ debug("websocket client destroyed");
1224
804
  }
1225
- return arr;
805
+ debug("close completed");
1226
806
  }
1227
- if (value && typeof value === "object") {
1228
- const input = value;
1229
- const obj = {};
1230
- for (const [key, entry] of Object.entries(input)) {
1231
- const cloned = cloneJsonValue(entry);
1232
- if (cloned !== void 0) obj[key] = cloned;
807
+ isConnected() {
808
+ return this.client?.getStatus() === "connected";
809
+ }
810
+ async syncMeta(flock, options) {
811
+ debug("syncMeta requested", { roomId: this.options.metadataRoomId });
812
+ try {
813
+ let auth;
814
+ if (this.options.metadataAuth) if (typeof this.options.metadataAuth === "function") auth = await this.options.metadataAuth();
815
+ else auth = this.options.metadataAuth;
816
+ await withTimeout((await this.ensureMetadataSession(flock, {
817
+ roomId: this.options.metadataRoomId ?? "repo:meta",
818
+ auth
819
+ })).firstSynced, options?.timeout);
820
+ debug("syncMeta completed", { roomId: this.options.metadataRoomId });
821
+ return { ok: true };
822
+ } catch (error) {
823
+ debug("syncMeta failed", error);
824
+ return { ok: false };
1233
825
  }
1234
- return obj;
1235
826
  }
1236
- }
1237
- function cloneJsonObject(value) {
1238
- const cloned = cloneJsonValue(value);
1239
- if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1240
- return {};
1241
- }
1242
- function asJsonObject(value) {
1243
- const cloned = cloneJsonValue(value);
1244
- if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1245
- }
1246
- function isJsonObjectValue(value) {
1247
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
1248
- }
1249
- function stableStringify(value) {
1250
- if (value === null) return "null";
1251
- if (typeof value === "string") return JSON.stringify(value);
1252
- if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
1253
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1254
- if (!isJsonObjectValue(value)) return "null";
1255
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1256
- }
1257
- function jsonEquals(a, b) {
1258
- if (a === void 0 && b === void 0) return true;
1259
- if (a === void 0 || b === void 0) return false;
1260
- return stableStringify(a) === stableStringify(b);
1261
- }
1262
- function diffJsonObjects(previous, next) {
1263
- const patch = {};
1264
- const keys = /* @__PURE__ */ new Set();
1265
- if (previous) for (const key of Object.keys(previous)) keys.add(key);
1266
- for (const key of Object.keys(next)) keys.add(key);
1267
- for (const key of keys) {
1268
- const prevValue = previous ? previous[key] : void 0;
1269
- const nextValue = next[key];
1270
- if (!jsonEquals(prevValue, nextValue)) {
1271
- if (nextValue === void 0 && previous && key in previous) {
1272
- patch[key] = null;
1273
- continue;
827
+ joinMetaRoom(flock, params) {
828
+ const fallback = this.options.metadataRoomId ?? "";
829
+ const roomId = normalizeRoomId(params?.roomId, fallback);
830
+ if (!roomId) throw new Error("Metadata room id not configured");
831
+ const session = (async () => {
832
+ let auth;
833
+ const authWay = params?.auth ?? this.options.metadataAuth;
834
+ if (typeof authWay === "function") auth = await authWay();
835
+ else auth = authWay;
836
+ debug("joinMetaRoom requested", {
837
+ roomId,
838
+ hasAuth: Boolean(auth && auth.length)
839
+ });
840
+ return this.ensureMetadataSession(flock, {
841
+ roomId,
842
+ auth
843
+ });
844
+ })();
845
+ const firstSynced = session.then((session$1) => session$1.firstSynced);
846
+ const getConnected = () => this.isConnected();
847
+ const subscription = {
848
+ unsubscribe: () => {
849
+ session.then((session$1) => {
850
+ session$1.refCount = Math.max(0, session$1.refCount - 1);
851
+ debug("metadata session refCount decremented", {
852
+ roomId: session$1.roomId,
853
+ refCount: session$1.refCount
854
+ });
855
+ if (session$1.refCount === 0) {
856
+ debug("tearing down metadata session due to refCount=0", { roomId: session$1.roomId });
857
+ this.teardownMetadataSession(session$1).catch(() => {});
858
+ }
859
+ });
860
+ },
861
+ firstSyncedWithRemote: firstSynced,
862
+ get connected() {
863
+ return getConnected();
1274
864
  }
1275
- const cloned = cloneJsonValue(nextValue);
1276
- if (cloned !== void 0) patch[key] = cloned;
865
+ };
866
+ session.then((session$1) => {
867
+ session$1.refCount += 1;
868
+ debug("metadata session refCount incremented", {
869
+ roomId: session$1.roomId,
870
+ refCount: session$1.refCount
871
+ });
872
+ });
873
+ return subscription;
874
+ }
875
+ async syncDoc(docId, doc, options) {
876
+ debug("syncDoc requested", { docId });
877
+ try {
878
+ const session = await this.ensureDocSession(docId, doc, {});
879
+ await withTimeout(session.firstSynced, options?.timeout);
880
+ debug("syncDoc completed", {
881
+ docId,
882
+ roomId: session.roomId
883
+ });
884
+ return { ok: true };
885
+ } catch (error) {
886
+ debug("syncDoc failed", {
887
+ docId,
888
+ error
889
+ });
890
+ return { ok: false };
1277
891
  }
1278
892
  }
1279
- return patch;
1280
- }
1281
- function assetMetaToJson(meta) {
1282
- const json = {
1283
- assetId: meta.assetId,
1284
- size: meta.size,
1285
- createdAt: meta.createdAt
1286
- };
1287
- if (meta.mime !== void 0) json.mime = meta.mime;
1288
- if (meta.policy !== void 0) json.policy = meta.policy;
1289
- if (meta.tag !== void 0) json.tag = meta.tag;
1290
- return json;
1291
- }
1292
- function assetMetaFromJson(value) {
1293
- const obj = asJsonObject(value);
1294
- if (!obj) return void 0;
1295
- const assetId = typeof obj.assetId === "string" ? obj.assetId : void 0;
1296
- if (!assetId) return void 0;
1297
- const size = typeof obj.size === "number" ? obj.size : void 0;
1298
- const createdAt = typeof obj.createdAt === "number" ? obj.createdAt : void 0;
1299
- if (size === void 0 || createdAt === void 0) return void 0;
1300
- return {
1301
- assetId,
1302
- size,
1303
- createdAt,
1304
- ...typeof obj.mime === "string" ? { mime: obj.mime } : {},
1305
- ...typeof obj.policy === "string" ? { policy: obj.policy } : {},
1306
- ...typeof obj.tag === "string" ? { tag: obj.tag } : {}
1307
- };
1308
- }
1309
- function assetMetadataEqual(a, b) {
1310
- if (!a && !b) return true;
1311
- if (!a || !b) return false;
1312
- return stableStringify(assetMetaToJson(a)) === stableStringify(assetMetaToJson(b));
1313
- }
1314
- function cloneRepoAssetMetadata(meta) {
1315
- return {
1316
- assetId: meta.assetId,
1317
- size: meta.size,
1318
- createdAt: meta.createdAt,
1319
- ...meta.mime !== void 0 ? { mime: meta.mime } : {},
1320
- ...meta.policy !== void 0 ? { policy: meta.policy } : {},
1321
- ...meta.tag !== void 0 ? { tag: meta.tag } : {}
1322
- };
1323
- }
1324
- function toReadableStream(bytes) {
1325
- return new ReadableStream({ start(controller) {
1326
- controller.enqueue(bytes);
1327
- controller.close();
1328
- } });
1329
- }
1330
- function canonicalizeFrontiers(frontiers) {
1331
- const json = [...frontiers].sort((a, b) => {
1332
- if (a.peer < b.peer) return -1;
1333
- if (a.peer > b.peer) return 1;
1334
- return a.counter - b.counter;
1335
- }).map((f) => ({
1336
- peer: f.peer,
1337
- counter: f.counter
1338
- }));
1339
- return {
1340
- json,
1341
- key: stableStringify(json)
1342
- };
1343
- }
1344
- function includesFrontiers(vv, frontiers) {
1345
- for (const { peer, counter } of frontiers) if ((vv.get(peer) ?? 0) <= counter) return false;
1346
- return true;
1347
- }
1348
- function matchesQuery(docId, _metadata, query) {
1349
- if (!query) return true;
1350
- if (query.prefix && !docId.startsWith(query.prefix)) return false;
1351
- if (query.start && docId < query.start) return false;
1352
- if (query.end && docId > query.end) return false;
1353
- return true;
1354
- }
893
+ joinDocRoom(docId, doc, params) {
894
+ debug("joinDocRoom requested", {
895
+ docId,
896
+ roomParamType: params?.roomId ? typeof params.roomId === "string" ? "string" : "uint8array" : void 0,
897
+ hasAuthOverride: Boolean(params?.auth && params.auth.length)
898
+ });
899
+ const ensure = this.ensureDocSession(docId, doc, params ?? {});
900
+ const firstSynced = ensure.then((session) => session.firstSynced);
901
+ const getConnected = () => this.isConnected();
902
+ const subscription = {
903
+ unsubscribe: () => {
904
+ ensure.then((session) => {
905
+ session.refCount = Math.max(0, session.refCount - 1);
906
+ debug("doc session refCount decremented", {
907
+ docId,
908
+ roomId: session.roomId,
909
+ refCount: session.refCount
910
+ });
911
+ if (session.refCount === 0) this.leaveDocSession(docId).catch(() => {});
912
+ });
913
+ },
914
+ firstSyncedWithRemote: firstSynced,
915
+ get connected() {
916
+ return getConnected();
917
+ }
918
+ };
919
+ ensure.then((session) => {
920
+ session.refCount += 1;
921
+ debug("doc session refCount incremented", {
922
+ docId,
923
+ roomId: session.roomId,
924
+ refCount: session.refCount
925
+ });
926
+ });
927
+ return subscription;
928
+ }
929
+ ensureClient() {
930
+ if (this.client) {
931
+ debug("reusing websocket client", { status: this.client.getStatus() });
932
+ return this.client;
933
+ }
934
+ const { url, client: clientOptions } = this.options;
935
+ debug("creating websocket client", {
936
+ url,
937
+ clientOptionsKeys: clientOptions ? Object.keys(clientOptions) : []
938
+ });
939
+ const client = new loro_websocket.LoroWebsocketClient({
940
+ url,
941
+ ...clientOptions
942
+ });
943
+ this.client = client;
944
+ return client;
945
+ }
946
+ async ensureMetadataSession(flock, params) {
947
+ debug("ensureMetadataSession invoked", {
948
+ roomId: params.roomId,
949
+ hasAuth: Boolean(params.auth && params.auth.length)
950
+ });
951
+ const client = this.ensureClient();
952
+ await client.waitConnected();
953
+ debug("websocket client ready for metadata session", { status: client.getStatus() });
954
+ if (this.metadataSession && this.metadataSession.flock === flock && this.metadataSession.roomId === params.roomId && bytesEqual(this.metadataSession.auth, params.auth)) {
955
+ debug("reusing metadata session", {
956
+ roomId: this.metadataSession.roomId,
957
+ refCount: this.metadataSession.refCount
958
+ });
959
+ return this.metadataSession;
960
+ }
961
+ if (this.metadataSession) {
962
+ debug("tearing down previous metadata session", { roomId: this.metadataSession.roomId });
963
+ await this.teardownMetadataSession(this.metadataSession).catch(() => {});
964
+ }
965
+ const adaptor = new loro_adaptors_flock.FlockAdaptor(flock, this.options.metadataAdaptorConfig);
966
+ debug("joining metadata room", {
967
+ roomId: params.roomId,
968
+ hasAuth: Boolean(params.auth && params.auth.length)
969
+ });
970
+ const room = await client.join({
971
+ roomId: params.roomId,
972
+ crdtAdaptor: adaptor,
973
+ auth: params.auth
974
+ });
975
+ const firstSynced = room.waitForReachingServerVersion();
976
+ firstSynced.then(() => {
977
+ debug("metadata session firstSynced resolved", { roomId: params.roomId });
978
+ }, (error) => {
979
+ debug("metadata session firstSynced rejected", {
980
+ roomId: params.roomId,
981
+ error
982
+ });
983
+ });
984
+ const session = {
985
+ adaptor,
986
+ room,
987
+ firstSynced,
988
+ flock,
989
+ roomId: params.roomId,
990
+ auth: params.auth,
991
+ refCount: 0
992
+ };
993
+ this.metadataSession = session;
994
+ return session;
995
+ }
996
+ async teardownMetadataSession(session) {
997
+ const target = session ?? this.metadataSession;
998
+ if (!target) return;
999
+ debug("teardownMetadataSession invoked", { roomId: target.roomId });
1000
+ if (this.metadataSession === target) this.metadataSession = void 0;
1001
+ const { adaptor, room } = target;
1002
+ try {
1003
+ await room.leave();
1004
+ debug("metadata room left", { roomId: target.roomId });
1005
+ } catch (error) {
1006
+ debug("metadata room leave failed; destroying", {
1007
+ roomId: target.roomId,
1008
+ error
1009
+ });
1010
+ await room.destroy().catch(() => {});
1011
+ }
1012
+ adaptor.destroy();
1013
+ debug("metadata session destroyed", { roomId: target.roomId });
1014
+ }
1015
+ async ensureDocSession(docId, doc, params) {
1016
+ debug("ensureDocSession invoked", { docId });
1017
+ const client = this.ensureClient();
1018
+ await client.waitConnected();
1019
+ debug("websocket client ready for doc session", {
1020
+ docId,
1021
+ status: client.getStatus()
1022
+ });
1023
+ const existing = this.docSessions.get(docId);
1024
+ const derivedRoomId = this.options.docRoomId?.(docId) ?? docId;
1025
+ const roomId = normalizeRoomId(params.roomId, derivedRoomId);
1026
+ let auth;
1027
+ auth = await (params.auth ?? this.options.docAuth?.(docId));
1028
+ debug("doc session params resolved", {
1029
+ docId,
1030
+ roomId,
1031
+ hasAuth: Boolean(auth && auth.length)
1032
+ });
1033
+ if (existing && existing.doc === doc && existing.roomId === roomId) {
1034
+ debug("reusing doc session", {
1035
+ docId,
1036
+ roomId,
1037
+ refCount: existing.refCount
1038
+ });
1039
+ return existing;
1040
+ }
1041
+ if (existing) {
1042
+ debug("doc session mismatch; leaving existing session", {
1043
+ docId,
1044
+ previousRoomId: existing.roomId,
1045
+ nextRoomId: roomId
1046
+ });
1047
+ await this.leaveDocSession(docId).catch(() => {});
1048
+ }
1049
+ const adaptor = new loro_adaptors_loro.LoroAdaptor(doc);
1050
+ debug("joining doc room", {
1051
+ docId,
1052
+ roomId,
1053
+ hasAuth: Boolean(auth && auth.length)
1054
+ });
1055
+ const room = await client.join({
1056
+ roomId,
1057
+ crdtAdaptor: adaptor,
1058
+ auth
1059
+ });
1060
+ const firstSynced = room.waitForReachingServerVersion();
1061
+ firstSynced.then(() => {
1062
+ debug("doc session firstSynced resolved", {
1063
+ docId,
1064
+ roomId
1065
+ });
1066
+ }, (error) => {
1067
+ debug("doc session firstSynced rejected", {
1068
+ docId,
1069
+ roomId,
1070
+ error
1071
+ });
1072
+ });
1073
+ const session = {
1074
+ adaptor,
1075
+ room,
1076
+ firstSynced,
1077
+ doc,
1078
+ roomId,
1079
+ refCount: 0
1080
+ };
1081
+ this.docSessions.set(docId, session);
1082
+ return session;
1083
+ }
1084
+ async leaveDocSession(docId) {
1085
+ const session = this.docSessions.get(docId);
1086
+ if (!session) {
1087
+ debug("leaveDocSession invoked but no session found", { docId });
1088
+ return;
1089
+ }
1090
+ this.docSessions.delete(docId);
1091
+ debug("leaving doc session", {
1092
+ docId,
1093
+ roomId: session.roomId
1094
+ });
1095
+ try {
1096
+ await session.room.leave();
1097
+ debug("doc room left", {
1098
+ docId,
1099
+ roomId: session.roomId
1100
+ });
1101
+ } catch (error) {
1102
+ debug("doc room leave failed; destroying", {
1103
+ docId,
1104
+ roomId: session.roomId,
1105
+ error
1106
+ });
1107
+ await session.room.destroy().catch(() => {});
1108
+ }
1109
+ session.adaptor.destroy();
1110
+ debug("doc session destroyed", {
1111
+ docId,
1112
+ roomId: session.roomId
1113
+ });
1114
+ }
1115
+ };
1116
+
1117
+ //#endregion
1118
+ //#region src/internal/event-bus.ts
1119
+ var RepoEventBus = class {
1120
+ watchers = /* @__PURE__ */ new Set();
1121
+ eventByStack = [];
1122
+ watch(listener, filter = {}) {
1123
+ const entry = {
1124
+ listener,
1125
+ filter
1126
+ };
1127
+ this.watchers.add(entry);
1128
+ return { unsubscribe: () => {
1129
+ this.watchers.delete(entry);
1130
+ } };
1131
+ }
1132
+ emit(event) {
1133
+ for (const entry of this.watchers) if (this.shouldNotify(entry.filter, event)) entry.listener(event);
1134
+ }
1135
+ clear() {
1136
+ this.watchers.clear();
1137
+ this.eventByStack.length = 0;
1138
+ }
1139
+ pushEventBy(by) {
1140
+ this.eventByStack.push(by);
1141
+ }
1142
+ popEventBy() {
1143
+ this.eventByStack.pop();
1144
+ }
1145
+ resolveEventBy(defaultBy) {
1146
+ const index = this.eventByStack.length - 1;
1147
+ return index >= 0 ? this.eventByStack[index] : defaultBy;
1148
+ }
1149
+ shouldNotify(filter, event) {
1150
+ if (!filter.docIds && !filter.kinds && !filter.metadataFields && !filter.by) return true;
1151
+ if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
1152
+ if (filter.by && !filter.by.includes(event.by)) return false;
1153
+ const docId = (() => {
1154
+ if (event.kind === "doc-metadata" || event.kind === "doc-frontiers") return event.docId;
1155
+ if (event.kind === "asset-link" || event.kind === "asset-unlink") return event.docId;
1156
+ })();
1157
+ if (filter.docIds && docId && !filter.docIds.includes(docId)) return false;
1158
+ if (filter.docIds && !docId) return false;
1159
+ if (filter.metadataFields && event.kind === "doc-metadata") {
1160
+ if (!Object.keys(event.patch).some((key) => filter.metadataFields?.includes(key))) return false;
1161
+ }
1162
+ return true;
1163
+ }
1164
+ };
1355
1165
 
1356
1166
  //#endregion
1357
1167
  //#region src/internal/logging.ts
@@ -1370,23 +1180,18 @@ var DocManager = class {
1370
1180
  getMetaFlock;
1371
1181
  eventBus;
1372
1182
  persistMeta;
1373
- state;
1374
1183
  docs = /* @__PURE__ */ new Map();
1375
1184
  docSubscriptions = /* @__PURE__ */ new Map();
1376
1185
  docFrontierUpdates = /* @__PURE__ */ new Map();
1377
1186
  docPersistedVersions = /* @__PURE__ */ new Map();
1378
- get docFrontierKeys() {
1379
- return this.state.docFrontierKeys;
1380
- }
1381
1187
  constructor(options) {
1382
1188
  this.storage = options.storage;
1383
1189
  this.docFrontierDebounceMs = options.docFrontierDebounceMs;
1384
1190
  this.getMetaFlock = options.getMetaFlock;
1385
1191
  this.eventBus = options.eventBus;
1386
1192
  this.persistMeta = options.persistMeta;
1387
- this.state = options.state;
1388
1193
  }
1389
- async openCollaborativeDoc(docId) {
1194
+ async openPersistedDoc(docId) {
1390
1195
  return await this.ensureDoc(docId);
1391
1196
  }
1392
1197
  async openDetachedDoc(docId) {
@@ -1433,38 +1238,27 @@ var DocManager = class {
1433
1238
  }
1434
1239
  async updateDocFrontiers(docId, doc, defaultBy) {
1435
1240
  const frontiers = doc.oplogFrontiers();
1436
- const { json, key } = canonicalizeFrontiers(frontiers);
1437
- const existingKeys = this.docFrontierKeys.get(docId) ?? /* @__PURE__ */ new Set();
1241
+ const vv = doc.version();
1242
+ const existingFrontiers = this.readFrontiersFromFlock(docId);
1438
1243
  let mutated = false;
1439
1244
  const metaFlock = this.metaFlock;
1440
- const vv = doc.version();
1441
- for (const entry of existingKeys) {
1442
- if (entry === key) continue;
1443
- let oldFrontiers;
1444
- try {
1445
- oldFrontiers = JSON.parse(entry);
1446
- } catch {
1447
- continue;
1448
- }
1449
- if (includesFrontiers(vv, oldFrontiers)) {
1450
- metaFlock.delete([
1451
- "f",
1452
- docId,
1453
- entry
1454
- ]);
1455
- mutated = true;
1456
- }
1457
- }
1458
- if (!existingKeys.has(key)) {
1245
+ for (const f of frontiers) if (existingFrontiers.get(f.peer) !== f.counter) {
1459
1246
  metaFlock.put([
1460
1247
  "f",
1461
1248
  docId,
1462
- key
1463
- ], json);
1249
+ f.peer
1250
+ ], f.counter);
1464
1251
  mutated = true;
1465
1252
  }
1466
1253
  if (mutated) {
1467
- this.refreshDocFrontierKeys(docId);
1254
+ for (const [peer, counter] of existingFrontiers) {
1255
+ const docCounterEnd = vv.get(peer);
1256
+ if (docCounterEnd != null && docCounterEnd > counter) metaFlock.delete([
1257
+ "f",
1258
+ docId,
1259
+ peer
1260
+ ]);
1261
+ }
1468
1262
  await this.persistMeta();
1469
1263
  }
1470
1264
  const by = this.eventBus.resolveEventBy(defaultBy);
@@ -1516,37 +1310,22 @@ var DocManager = class {
1516
1310
  this.docFrontierUpdates.clear();
1517
1311
  this.docs.clear();
1518
1312
  this.docPersistedVersions.clear();
1519
- this.docFrontierKeys.clear();
1520
1313
  }
1521
- hydrateFrontierKeys() {
1522
- const nextFrontierKeys = /* @__PURE__ */ new Map();
1523
- const frontierRows = this.metaFlock.scan({ prefix: ["f"] });
1524
- for (const row of frontierRows) {
1525
- if (!Array.isArray(row.key) || row.key.length < 3) continue;
1526
- const docId = row.key[1];
1527
- const frontierKey = row.key[2];
1528
- if (typeof docId !== "string" || typeof frontierKey !== "string") continue;
1529
- const set = nextFrontierKeys.get(docId) ?? /* @__PURE__ */ new Set();
1530
- set.add(frontierKey);
1531
- nextFrontierKeys.set(docId, set);
1532
- }
1533
- this.docFrontierKeys.clear();
1534
- for (const [docId, keys] of nextFrontierKeys) this.docFrontierKeys.set(docId, keys);
1314
+ get metaFlock() {
1315
+ return this.getMetaFlock();
1535
1316
  }
1536
- refreshDocFrontierKeys(docId) {
1317
+ readFrontiersFromFlock(docId) {
1537
1318
  const rows = this.metaFlock.scan({ prefix: ["f", docId] });
1538
- const keys = /* @__PURE__ */ new Set();
1319
+ const frontiers = /* @__PURE__ */ new Map();
1539
1320
  for (const row of rows) {
1540
1321
  if (!Array.isArray(row.key) || row.key.length < 3) continue;
1541
- if (row.value === void 0 || row.value === null) continue;
1542
- const frontierKey = row.key[2];
1543
- if (typeof frontierKey === "string") keys.add(frontierKey);
1322
+ const peer = row.key[2];
1323
+ const counter = row.value;
1324
+ if (typeof peer !== "string") continue;
1325
+ if (typeof counter !== "number" || !Number.isFinite(counter)) continue;
1326
+ frontiers.set(peer, counter);
1544
1327
  }
1545
- if (keys.size > 0) this.docFrontierKeys.set(docId, keys);
1546
- else this.docFrontierKeys.delete(docId);
1547
- }
1548
- get metaFlock() {
1549
- return this.getMetaFlock();
1328
+ return frontiers;
1550
1329
  }
1551
1330
  registerDoc(docId, doc) {
1552
1331
  this.docs.set(docId, doc);
@@ -1659,6 +1438,176 @@ var DocManager = class {
1659
1438
  }
1660
1439
  };
1661
1440
 
1441
+ //#endregion
1442
+ //#region src/utils.ts
1443
+ async function streamToUint8Array(stream) {
1444
+ const reader = stream.getReader();
1445
+ const chunks = [];
1446
+ let total = 0;
1447
+ while (true) {
1448
+ const { done, value } = await reader.read();
1449
+ if (done) break;
1450
+ if (value) {
1451
+ chunks.push(value);
1452
+ total += value.byteLength;
1453
+ }
1454
+ }
1455
+ const buffer = new Uint8Array(total);
1456
+ let offset = 0;
1457
+ for (const chunk of chunks) {
1458
+ buffer.set(chunk, offset);
1459
+ offset += chunk.byteLength;
1460
+ }
1461
+ return buffer;
1462
+ }
1463
+ async function assetContentToUint8Array(content) {
1464
+ if (content instanceof Uint8Array) return content;
1465
+ if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength));
1466
+ if (typeof Blob !== "undefined" && content instanceof Blob) return new Uint8Array(await content.arrayBuffer());
1467
+ if (typeof ReadableStream !== "undefined" && content instanceof ReadableStream) return streamToUint8Array(content);
1468
+ throw new TypeError("Unsupported asset content type");
1469
+ }
1470
+ function bytesToHex(bytes) {
1471
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1472
+ }
1473
+ async function computeSha256(bytes) {
1474
+ const globalCrypto = globalThis.crypto;
1475
+ if (globalCrypto?.subtle && typeof globalCrypto.subtle.digest === "function") {
1476
+ const digest = await globalCrypto.subtle.digest("SHA-256", bytes);
1477
+ return bytesToHex(new Uint8Array(digest));
1478
+ }
1479
+ try {
1480
+ const { createHash } = await import("node:crypto");
1481
+ const hash = createHash("sha256");
1482
+ hash.update(bytes);
1483
+ return hash.digest("hex");
1484
+ } catch {
1485
+ throw new Error("SHA-256 digest is not available in this environment");
1486
+ }
1487
+ }
1488
+ function cloneJsonValue(value) {
1489
+ if (value === null) return null;
1490
+ if (typeof value === "string" || typeof value === "boolean") return value;
1491
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
1492
+ if (Array.isArray(value)) {
1493
+ const arr = [];
1494
+ for (const entry of value) {
1495
+ const cloned = cloneJsonValue(entry);
1496
+ if (cloned !== void 0) arr.push(cloned);
1497
+ }
1498
+ return arr;
1499
+ }
1500
+ if (value && typeof value === "object") {
1501
+ const input = value;
1502
+ const obj = {};
1503
+ for (const [key, entry] of Object.entries(input)) {
1504
+ const cloned = cloneJsonValue(entry);
1505
+ if (cloned !== void 0) obj[key] = cloned;
1506
+ }
1507
+ return obj;
1508
+ }
1509
+ }
1510
+ function cloneJsonObject(value) {
1511
+ const cloned = cloneJsonValue(value);
1512
+ if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1513
+ return {};
1514
+ }
1515
+ function asJsonObject(value) {
1516
+ const cloned = cloneJsonValue(value);
1517
+ if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1518
+ }
1519
+ function isJsonObjectValue(value) {
1520
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
1521
+ }
1522
+ function stableStringify(value) {
1523
+ if (value === null) return "null";
1524
+ if (typeof value === "string") return JSON.stringify(value);
1525
+ if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
1526
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1527
+ if (!isJsonObjectValue(value)) return "null";
1528
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1529
+ }
1530
+ function jsonEquals(a, b) {
1531
+ if (a === void 0 && b === void 0) return true;
1532
+ if (a === void 0 || b === void 0) return false;
1533
+ return stableStringify(a) === stableStringify(b);
1534
+ }
1535
+ function diffJsonObjects(previous, next) {
1536
+ const patch = {};
1537
+ const keys = /* @__PURE__ */ new Set();
1538
+ if (previous) for (const key of Object.keys(previous)) keys.add(key);
1539
+ for (const key of Object.keys(next)) keys.add(key);
1540
+ for (const key of keys) {
1541
+ const prevValue = previous ? previous[key] : void 0;
1542
+ const nextValue = next[key];
1543
+ if (!jsonEquals(prevValue, nextValue)) {
1544
+ if (nextValue === void 0 && previous && key in previous) {
1545
+ patch[key] = null;
1546
+ continue;
1547
+ }
1548
+ const cloned = cloneJsonValue(nextValue);
1549
+ if (cloned !== void 0) patch[key] = cloned;
1550
+ }
1551
+ }
1552
+ return patch;
1553
+ }
1554
+ function assetMetaToJson(meta) {
1555
+ const json = {
1556
+ assetId: meta.assetId,
1557
+ size: meta.size,
1558
+ createdAt: meta.createdAt
1559
+ };
1560
+ if (meta.mime !== void 0) json.mime = meta.mime;
1561
+ if (meta.policy !== void 0) json.policy = meta.policy;
1562
+ if (meta.tag !== void 0) json.tag = meta.tag;
1563
+ return json;
1564
+ }
1565
+ function assetMetaFromJson(value) {
1566
+ const obj = asJsonObject(value);
1567
+ if (!obj) return void 0;
1568
+ const assetId = typeof obj.assetId === "string" ? obj.assetId : void 0;
1569
+ if (!assetId) return void 0;
1570
+ const size = typeof obj.size === "number" ? obj.size : void 0;
1571
+ const createdAt = typeof obj.createdAt === "number" ? obj.createdAt : void 0;
1572
+ if (size === void 0 || createdAt === void 0) return void 0;
1573
+ return {
1574
+ assetId,
1575
+ size,
1576
+ createdAt,
1577
+ ...typeof obj.mime === "string" ? { mime: obj.mime } : {},
1578
+ ...typeof obj.policy === "string" ? { policy: obj.policy } : {},
1579
+ ...typeof obj.tag === "string" ? { tag: obj.tag } : {}
1580
+ };
1581
+ }
1582
+ function assetMetadataEqual(a, b) {
1583
+ if (!a && !b) return true;
1584
+ if (!a || !b) return false;
1585
+ return stableStringify(assetMetaToJson(a)) === stableStringify(assetMetaToJson(b));
1586
+ }
1587
+ function cloneRepoAssetMetadata(meta) {
1588
+ return {
1589
+ assetId: meta.assetId,
1590
+ size: meta.size,
1591
+ createdAt: meta.createdAt,
1592
+ ...meta.mime !== void 0 ? { mime: meta.mime } : {},
1593
+ ...meta.policy !== void 0 ? { policy: meta.policy } : {},
1594
+ ...meta.tag !== void 0 ? { tag: meta.tag } : {}
1595
+ };
1596
+ }
1597
+ function toReadableStream(bytes) {
1598
+ return new ReadableStream({ start(controller) {
1599
+ controller.enqueue(bytes);
1600
+ controller.close();
1601
+ } });
1602
+ }
1603
+ function matchesQuery(docId, _metadata, query) {
1604
+ if (!query) return true;
1605
+ if (query.prefix && !docId.startsWith(query.prefix)) return false;
1606
+ if (query.start && docId < query.start) return false;
1607
+ if (query.end && docId > query.end) return false;
1608
+ return true;
1609
+ }
1610
+
1662
1611
  //#endregion
1663
1612
  //#region src/internal/metadata-manager.ts
1664
1613
  var MetadataManager = class {
@@ -2456,13 +2405,11 @@ var FlockHydrator = class {
2456
2405
  const nextMetadata = this.readAllDocMetadata();
2457
2406
  this.metadataManager.replaceAll(nextMetadata, by);
2458
2407
  this.assetManager.hydrateFromFlock(by);
2459
- this.docManager.hydrateFrontierKeys();
2460
2408
  }
2461
2409
  applyEvents(events, by) {
2462
2410
  if (!events.length) return;
2463
2411
  const docMetadataIds = /* @__PURE__ */ new Set();
2464
2412
  const docAssetIds = /* @__PURE__ */ new Set();
2465
- const docFrontiersIds = /* @__PURE__ */ new Set();
2466
2413
  const assetIds = /* @__PURE__ */ new Set();
2467
2414
  for (const event of events) {
2468
2415
  const key = event.key;
@@ -2479,15 +2426,11 @@ var FlockHydrator = class {
2479
2426
  const assetId = key[2];
2480
2427
  if (typeof docId === "string") docAssetIds.add(docId);
2481
2428
  if (typeof assetId === "string") assetIds.add(assetId);
2482
- } else if (root === "f") {
2483
- const docId = key[1];
2484
- if (typeof docId === "string") docFrontiersIds.add(docId);
2485
2429
  }
2486
2430
  }
2487
2431
  for (const assetId of assetIds) this.assetManager.refreshAssetMetadataEntry(assetId, by);
2488
2432
  for (const docId of docMetadataIds) this.metadataManager.refreshFromFlock(docId, by);
2489
2433
  for (const docId of docAssetIds) this.assetManager.refreshDocAssetsEntry(docId, by);
2490
- for (const docId of docFrontiersIds) this.docManager.refreshDocFrontierKeys(docId);
2491
2434
  }
2492
2435
  readAllDocMetadata() {
2493
2436
  const nextMetadata = /* @__PURE__ */ new Map();
@@ -2698,8 +2641,7 @@ function createRepoState() {
2698
2641
  docAssets: /* @__PURE__ */ new Map(),
2699
2642
  assets: /* @__PURE__ */ new Map(),
2700
2643
  orphanedAssets: /* @__PURE__ */ new Map(),
2701
- assetToDocRefs: /* @__PURE__ */ new Map(),
2702
- docFrontierKeys: /* @__PURE__ */ new Map()
2644
+ assetToDocRefs: /* @__PURE__ */ new Map()
2703
2645
  };
2704
2646
  }
2705
2647
 
@@ -2735,8 +2677,7 @@ var LoroRepo = class LoroRepo {
2735
2677
  docFrontierDebounceMs,
2736
2678
  getMetaFlock: () => this.metaFlock,
2737
2679
  eventBus: this.eventBus,
2738
- persistMeta: () => this.persistMeta(),
2739
- state: this.state
2680
+ persistMeta: () => this.persistMeta()
2740
2681
  });
2741
2682
  this.metadataManager = new MetadataManager({
2742
2683
  getMetaFlock: () => this.metaFlock,
@@ -2825,7 +2766,7 @@ var LoroRepo = class LoroRepo {
2825
2766
  */
2826
2767
  async openPersistedDoc(docId) {
2827
2768
  return {
2828
- doc: await this.docManager.openCollaborativeDoc(docId),
2769
+ doc: await this.docManager.openPersistedDoc(docId),
2829
2770
  syncOnce: () => {
2830
2771
  return this.sync({
2831
2772
  scope: "doc",