loro-repo 0.3.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
@@ -6,16 +6,12 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
8
  var __copyProps = (to, from, except, desc) => {
9
- if (from && typeof from === "object" || typeof from === "function") {
10
- for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
- key = keys[i];
12
- if (!__hasOwnProp.call(to, key) && key !== except) {
13
- __defProp(to, key, {
14
- get: ((k) => from[k]).bind(null, key),
15
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
16
- });
17
- }
18
- }
9
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
+ key = keys[i];
11
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
+ get: ((k) => from[k]).bind(null, key),
13
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
+ });
19
15
  }
20
16
  return to;
21
17
  };
@@ -26,883 +22,467 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
22
 
27
23
  //#endregion
28
24
  let __loro_dev_flock = require("@loro-dev/flock");
29
- let loro_adaptors = require("loro-adaptors");
30
- let loro_protocol = require("loro-protocol");
31
- let loro_websocket = require("loro-websocket");
25
+ __loro_dev_flock = __toESM(__loro_dev_flock);
32
26
  let loro_crdt = require("loro-crdt");
27
+ loro_crdt = __toESM(loro_crdt);
33
28
  let node_fs = require("node:fs");
29
+ node_fs = __toESM(node_fs);
34
30
  let node_path = require("node:path");
35
31
  node_path = __toESM(node_path);
36
32
  let node_crypto = require("node:crypto");
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);
37
42
 
38
- //#region src/loro-adaptor.ts
39
- function createRepoFlockAdaptorFromDoc(flock, config = {}) {
40
- return new loro_adaptors.FlockAdaptor(flock, config);
41
- }
42
-
43
- //#endregion
44
- //#region src/internal/debug.ts
45
- const getEnv = () => {
46
- if (typeof globalThis !== "object" || globalThis === null) return;
47
- return globalThis.process?.env;
48
- };
49
- const rawNamespaceConfig = (getEnv()?.LORO_REPO_DEBUG ?? "").trim();
50
- const normalizedNamespaces = rawNamespaceConfig.length > 0 ? rawNamespaceConfig.split(/[\s,]+/).map((token) => token.toLowerCase()).filter(Boolean) : [];
51
- const wildcardTokens = new Set([
52
- "*",
53
- "1",
54
- "true",
55
- "all"
56
- ]);
57
- const namespaceSet = new Set(normalizedNamespaces);
58
- const hasWildcard = namespaceSet.size > 0 && normalizedNamespaces.some((token) => wildcardTokens.has(token));
59
- const isDebugEnabled = (namespace) => {
60
- if (!namespaceSet.size) return false;
61
- if (!namespace) return hasWildcard;
62
- const normalized = namespace.toLowerCase();
63
- if (hasWildcard) return true;
64
- if (namespaceSet.has(normalized)) return true;
65
- const [root] = normalized.split(":");
66
- return namespaceSet.has(root);
67
- };
68
- const createDebugLogger = (namespace) => {
69
- const normalized = namespace.toLowerCase();
70
- return (...args) => {
71
- if (!isDebugEnabled(normalized)) return;
72
- const prefix = `[loro-repo:${namespace}]`;
73
- if (args.length === 0) {
74
- console.info(prefix);
75
- return;
76
- }
77
- 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
78
51
  };
79
- };
80
-
81
- //#endregion
82
- //#region src/transport/websocket.ts
83
- const debug = createDebugLogger("transport:websocket");
84
- function withTimeout(promise, timeoutMs) {
85
- if (!timeoutMs || timeoutMs <= 0) return promise;
86
- return new Promise((resolve, reject) => {
87
- const timer = setTimeout(() => {
88
- reject(/* @__PURE__ */ new Error(`Operation timed out after ${timeoutMs}ms`));
89
- }, timeoutMs);
90
- promise.then((value) => {
91
- clearTimeout(timer);
92
- resolve(value);
93
- }).catch((error) => {
94
- clearTimeout(timer);
95
- reject(error);
96
- });
97
- });
98
52
  }
99
- function normalizeRoomId(roomId, fallback) {
100
- if (typeof roomId === "string" && roomId.length > 0) return roomId;
101
- if (roomId instanceof Uint8Array && roomId.length > 0) try {
102
- 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);
103
64
  } catch {
104
- return fallback;
65
+ return docId.replace(/[^a-z0-9_-]/gi, "_");
105
66
  }
106
- return fallback;
107
67
  }
108
- function bytesEqual(a, b) {
109
- if (a === b) return true;
110
- if (!a || !b) return false;
111
- if (a.length !== b.length) return false;
112
- for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
113
- return true;
68
+ function postChannelMessage(channel, message) {
69
+ channel.postMessage(message);
114
70
  }
115
71
  /**
116
- * 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.
117
74
  */
118
- var WebSocketTransportAdapter = class {
119
- options;
120
- client;
121
- metadataSession;
122
- docSessions = /* @__PURE__ */ new Map();
123
- constructor(options) {
124
- 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`;
125
86
  }
126
- async connect(_options) {
127
- const client = this.ensureClient();
128
- debug("connect requested", { status: client.getStatus() });
129
- try {
130
- await client.connect();
131
- debug("client.connect resolved");
132
- await client.waitConnected();
133
- debug("client.waitConnected resolved", { status: client.getStatus() });
134
- } catch (error) {
135
- debug("connect failed", error);
136
- throw error;
137
- }
87
+ async connect() {
88
+ this.connected = true;
138
89
  }
139
90
  async close() {
140
- debug("close requested", {
141
- docSessions: this.docSessions.size,
142
- metadataSession: Boolean(this.metadataSession)
143
- });
144
- for (const [docId] of this.docSessions) await this.leaveDocSession(docId).catch(() => {});
145
- this.docSessions.clear();
146
- await this.teardownMetadataSession().catch(() => {});
147
- if (this.client) {
148
- const client = this.client;
149
- this.client = void 0;
150
- client.destroy();
151
- 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;
152
96
  }
153
- debug("close completed");
97
+ for (const [docId] of this.docStates) this.teardownDocChannel(docId);
98
+ this.docStates.clear();
154
99
  }
155
100
  isConnected() {
156
- return this.client?.getStatus() === "connected";
101
+ return this.connected;
157
102
  }
158
- async syncMeta(flock, options) {
159
- if (!this.options.metadataRoomId) {
160
- debug("syncMeta skipped; metadata room not configured");
161
- return { ok: true };
162
- }
163
- debug("syncMeta requested", { roomId: this.options.metadataRoomId });
164
- try {
165
- await withTimeout((await this.ensureMetadataSession(flock, {
166
- roomId: this.options.metadataRoomId,
167
- auth: this.options.metadataAuth
168
- })).firstSynced, options?.timeout);
169
- debug("syncMeta completed", { roomId: this.options.metadataRoomId });
170
- return { ok: true };
171
- } catch (error) {
172
- debug("syncMeta failed", error);
173
- return { ok: false };
174
- }
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 };
175
109
  }
176
- joinMetaRoom(flock, params) {
177
- const fallback = this.options.metadataRoomId ?? "";
178
- const roomId = normalizeRoomId(params?.roomId, fallback);
179
- if (!roomId) throw new Error("Metadata room id not configured");
180
- const auth = params?.auth ?? this.options.metadataAuth;
181
- debug("joinMetaRoom requested", {
182
- roomId,
183
- 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
184
133
  });
185
- const ensure = this.ensureMetadataSession(flock, {
186
- roomId,
187
- auth
134
+ Promise.resolve(flock.exportJson()).then((bundle) => {
135
+ postChannelMessage(state.channel, {
136
+ kind: "meta-export",
137
+ from: this.instanceId,
138
+ bundle
139
+ });
188
140
  });
189
- const firstSynced = ensure.then((session) => session.firstSynced);
190
- const getConnected = () => this.isConnected();
191
- const subscription = {
141
+ queueMicrotask(() => resolve());
142
+ return {
192
143
  unsubscribe: () => {
193
- ensure.then((session) => {
194
- session.refCount = Math.max(0, session.refCount - 1);
195
- debug("metadata session refCount decremented", {
196
- roomId: session.roomId,
197
- refCount: session.refCount
198
- });
199
- if (session.refCount === 0) {
200
- debug("tearing down metadata session due to refCount=0", { roomId: session.roomId });
201
- this.teardownMetadataSession(session).catch(() => {});
202
- }
203
- });
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
+ }
204
151
  },
205
- firstSyncedWithRemote: firstSynced,
152
+ firstSyncedWithRemote: listener.firstSynced,
206
153
  get connected() {
207
- return getConnected();
154
+ return true;
208
155
  }
209
156
  };
210
- ensure.then((session) => {
211
- session.refCount += 1;
212
- debug("metadata session refCount incremented", {
213
- roomId: session.roomId,
214
- refCount: session.refCount
215
- });
216
- });
217
- return subscription;
218
157
  }
219
- async syncDoc(docId, doc, options) {
220
- debug("syncDoc requested", { docId });
221
- try {
222
- const session = await this.ensureDocSession(docId, doc, {});
223
- await withTimeout(session.firstSynced, options?.timeout);
224
- debug("syncDoc completed", {
225
- docId,
226
- roomId: session.roomId
227
- });
228
- return { ok: true };
229
- } catch (error) {
230
- debug("syncDoc failed", {
231
- docId,
232
- error
233
- });
234
- return { ok: false };
235
- }
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 };
236
164
  }
237
- joinDocRoom(docId, doc, params) {
238
- 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",
239
188
  docId,
240
- roomParamType: params?.roomId ? typeof params.roomId === "string" ? "string" : "uint8array" : void 0,
241
- hasAuthOverride: Boolean(params?.auth && params.auth.length)
189
+ from: this.instanceId
242
190
  });
243
- const ensure = this.ensureDocSession(docId, doc, params ?? {});
244
- const firstSynced = ensure.then((session) => session.firstSynced);
245
- const getConnected = () => this.isConnected();
246
- 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 {
247
200
  unsubscribe: () => {
248
- ensure.then((session) => {
249
- session.refCount = Math.max(0, session.refCount - 1);
250
- debug("doc session refCount decremented", {
251
- docId,
252
- roomId: session.roomId,
253
- refCount: session.refCount
254
- });
255
- if (session.refCount === 0) this.leaveDocSession(docId).catch(() => {});
256
- });
201
+ listener.unsubscribe();
202
+ state.listeners.delete(listener);
203
+ if (!state.listeners.size) this.teardownDocChannel(docId);
257
204
  },
258
- firstSyncedWithRemote: firstSynced,
205
+ firstSyncedWithRemote: listener.firstSynced,
259
206
  get connected() {
260
- return getConnected();
207
+ return true;
261
208
  }
262
209
  };
263
- ensure.then((session) => {
264
- session.refCount += 1;
265
- debug("doc session refCount incremented", {
266
- docId,
267
- roomId: session.roomId,
268
- refCount: session.refCount
269
- });
270
- });
271
- return subscription;
272
210
  }
273
- ensureClient() {
274
- if (this.client) {
275
- debug("reusing websocket client", { status: this.client.getStatus() });
276
- return this.client;
277
- }
278
- const { url, client: clientOptions } = this.options;
279
- debug("creating websocket client", {
280
- url,
281
- clientOptionsKeys: clientOptions ? Object.keys(clientOptions) : []
282
- });
283
- const client = new loro_websocket.LoroWebsocketClient({
284
- url,
285
- ...clientOptions
286
- });
287
- this.client = client;
288
- 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;
289
243
  }
290
- async ensureMetadataSession(flock, params) {
291
- debug("ensureMetadataSession invoked", {
292
- roomId: params.roomId,
293
- hasAuth: Boolean(params.auth && params.auth.length)
294
- });
295
- const client = this.ensureClient();
296
- await client.waitConnected();
297
- debug("websocket client ready for metadata session", { status: client.getStatus() });
298
- if (this.metadataSession && this.metadataSession.flock === flock && this.metadataSession.roomId === params.roomId && bytesEqual(this.metadataSession.auth, params.auth)) {
299
- debug("reusing metadata session", {
300
- roomId: this.metadataSession.roomId,
301
- refCount: this.metadataSession.refCount
302
- });
303
- return this.metadataSession;
304
- }
305
- if (this.metadataSession) {
306
- debug("tearing down previous metadata session", { roomId: this.metadataSession.roomId });
307
- await this.teardownMetadataSession(this.metadataSession).catch(() => {});
308
- }
309
- const configuredType = this.options.metadataCrdtType;
310
- if (configuredType && configuredType !== loro_protocol.CrdtType.Flock) throw new Error(`metadataCrdtType must be ${loro_protocol.CrdtType.Flock} when syncing Flock metadata`);
311
- const adaptor = createRepoFlockAdaptorFromDoc(flock, this.options.metadataAdaptorConfig ?? {});
312
- debug("joining metadata room", {
313
- roomId: params.roomId,
314
- hasAuth: Boolean(params.auth && params.auth.length)
315
- });
316
- const room = await client.join({
317
- roomId: params.roomId,
318
- crdtAdaptor: adaptor,
319
- auth: params.auth
320
- });
321
- const firstSynced = room.waitForReachingServerVersion();
322
- firstSynced.then(() => {
323
- debug("metadata session firstSynced resolved", { roomId: params.roomId });
324
- }, (error) => {
325
- debug("metadata session firstSynced rejected", {
326
- roomId: params.roomId,
327
- error
328
- });
329
- });
330
- const session = {
331
- adaptor,
332
- room,
333
- firstSynced,
334
- flock,
335
- roomId: params.roomId,
336
- auth: params.auth,
337
- 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
+ }
338
271
  };
339
- this.metadataSession = session;
340
- 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;
341
280
  }
342
- async teardownMetadataSession(session) {
343
- const target = session ?? this.metadataSession;
344
- if (!target) return;
345
- debug("teardownMetadataSession invoked", { roomId: target.roomId });
346
- if (this.metadataSession === target) this.metadataSession = void 0;
347
- const { adaptor, room } = target;
348
- try {
349
- await room.leave();
350
- debug("metadata room left", { roomId: target.roomId });
351
- } catch (error) {
352
- debug("metadata room leave failed; destroying", {
353
- roomId: target.roomId,
354
- error
355
- });
356
- await room.destroy().catch(() => {});
357
- }
358
- adaptor.destroy();
359
- debug("metadata session destroyed", { roomId: target.roomId });
360
- }
361
- async ensureDocSession(docId, doc, params) {
362
- debug("ensureDocSession invoked", { docId });
363
- const client = this.ensureClient();
364
- await client.waitConnected();
365
- debug("websocket client ready for doc session", {
366
- docId,
367
- status: client.getStatus()
368
- });
369
- const existing = this.docSessions.get(docId);
370
- const derivedRoomId = this.options.docRoomId?.(docId) ?? docId;
371
- const roomId = normalizeRoomId(params.roomId, derivedRoomId);
372
- const auth = params.auth ?? this.options.docAuth?.(docId);
373
- debug("doc session params resolved", {
374
- docId,
375
- roomId,
376
- hasAuth: Boolean(auth && auth.length)
377
- });
378
- if (existing && existing.doc === doc && existing.roomId === roomId) {
379
- debug("reusing doc session", {
380
- docId,
381
- roomId,
382
- refCount: existing.refCount
383
- });
384
- return existing;
385
- }
386
- if (existing) {
387
- debug("doc session mismatch; leaving existing session", {
388
- docId,
389
- previousRoomId: existing.roomId,
390
- nextRoomId: roomId
391
- });
392
- await this.leaveDocSession(docId).catch(() => {});
393
- }
394
- const adaptor = new loro_adaptors.LoroAdaptor(doc);
395
- debug("joining doc room", {
396
- docId,
397
- roomId,
398
- hasAuth: Boolean(auth && auth.length)
399
- });
400
- const room = await client.join({
401
- roomId,
402
- crdtAdaptor: adaptor,
403
- auth
404
- });
405
- const firstSynced = room.waitForReachingServerVersion();
406
- firstSynced.then(() => {
407
- debug("doc session firstSynced resolved", {
408
- docId,
409
- roomId
410
- });
411
- }, (error) => {
412
- debug("doc session firstSynced rejected", {
413
- docId,
414
- roomId,
415
- error
416
- });
417
- });
418
- const session = {
419
- adaptor,
420
- room,
421
- firstSynced,
422
- doc,
423
- roomId,
424
- refCount: 0
425
- };
426
- this.docSessions.set(docId, session);
427
- return session;
428
- }
429
- async leaveDocSession(docId) {
430
- const session = this.docSessions.get(docId);
431
- if (!session) {
432
- debug("leaveDocSession invoked but no session found", { docId });
433
- return;
434
- }
435
- this.docSessions.delete(docId);
436
- debug("leaving doc session", {
437
- docId,
438
- roomId: session.roomId
439
- });
440
- try {
441
- await session.room.leave();
442
- debug("doc room left", {
443
- docId,
444
- roomId: session.roomId
445
- });
446
- } catch (error) {
447
- debug("doc room leave failed; destroying", {
448
- docId,
449
- roomId: session.roomId,
450
- error
451
- });
452
- await session.room.destroy().catch(() => {});
453
- }
454
- session.adaptor.destroy();
455
- debug("doc session destroyed", {
456
- docId,
457
- roomId: session.roomId
458
- });
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);
459
288
  }
460
289
  };
461
290
 
462
291
  //#endregion
463
- //#region src/transport/broadcast-channel.ts
464
- function deferred() {
465
- let resolve;
466
- return {
467
- promise: new Promise((res) => {
468
- resolve = res;
469
- }),
470
- resolve
471
- };
472
- }
473
- function randomInstanceId() {
474
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
475
- return Math.random().toString(36).slice(2);
476
- }
477
- function ensureBroadcastChannel() {
478
- if (typeof BroadcastChannel === "undefined") throw new Error("BroadcastChannel API is not available in this environment");
479
- return BroadcastChannel;
480
- }
481
- function encodeDocChannelId(docId) {
482
- try {
483
- 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);
484
309
  } catch {
485
- return docId.replace(/[^a-z0-9_-]/gi, "_");
310
+ return "[object]";
486
311
  }
312
+ return String(cause);
487
313
  }
488
- function postChannelMessage(channel, message) {
489
- channel.postMessage(message);
490
- }
491
- /**
492
- * TransportAdapter that relies on the BroadcastChannel API to fan out metadata
493
- * and document updates between browser tabs within the same origin.
494
- */
495
- var BroadcastChannelTransportAdapter = class {
496
- instanceId = randomInstanceId();
497
- namespace;
498
- metaChannelName;
499
- connected = false;
500
- metaState;
501
- 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;
502
325
  constructor(options = {}) {
503
- ensureBroadcastChannel();
504
- this.namespace = options.namespace ?? "loro-repo";
505
- 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;
506
336
  }
507
- async connect() {
508
- 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
+ }
509
362
  }
510
- async close() {
511
- this.connected = false;
512
- if (this.metaState) {
513
- for (const entry of this.metaState.listeners) entry.unsubscribe();
514
- this.metaState.channel.close();
515
- 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);
516
377
  }
517
- for (const [docId] of this.docStates) this.teardownDocChannel(docId);
518
- 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;
519
396
  }
520
- isConnected() {
521
- 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
+ }
522
409
  }
523
- async syncMeta(flock, _options) {
524
- const subscription = this.joinMetaRoom(flock);
525
- subscription.firstSyncedWithRemote.catch(() => void 0);
526
- await subscription.firstSyncedWithRemote;
527
- subscription.unsubscribe();
528
- return { ok: true };
410
+ async loadAsset(assetId) {
411
+ return await this.getBinary(this.assetStore, assetId) ?? void 0;
529
412
  }
530
- joinMetaRoom(flock, _params) {
531
- const state = this.ensureMetaChannel();
532
- const { promise, resolve } = deferred();
533
- const listener = {
534
- flock,
535
- muted: false,
536
- unsubscribe: flock.subscribe(() => {
537
- if (listener.muted) return;
538
- Promise.resolve(flock.exportJson()).then((bundle) => {
539
- postChannelMessage(state.channel, {
540
- kind: "meta-export",
541
- from: this.instanceId,
542
- bundle
543
- });
544
- });
545
- }),
546
- resolveFirst: resolve,
547
- firstSynced: promise
548
- };
549
- state.listeners.add(listener);
550
- postChannelMessage(state.channel, {
551
- kind: "meta-request",
552
- from: this.instanceId
553
- });
554
- Promise.resolve(flock.exportJson()).then((bundle) => {
555
- postChannelMessage(state.channel, {
556
- kind: "meta-export",
557
- from: this.instanceId,
558
- 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);
559
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 });
560
434
  });
561
- queueMicrotask(() => resolve());
562
- return {
563
- unsubscribe: () => {
564
- listener.unsubscribe();
565
- state.listeners.delete(listener);
566
- if (!state.listeners.size) {
567
- state.channel.removeEventListener("message", state.onMessage);
568
- state.channel.close();
569
- this.metaState = void 0;
570
- }
571
- },
572
- firstSyncedWithRemote: listener.firstSynced,
573
- get connected() {
574
- return true;
575
- }
576
- };
435
+ return this.dbPromise;
577
436
  }
578
- async syncDoc(docId, doc, _options) {
579
- const subscription = this.joinDocRoom(docId, doc);
580
- subscription.firstSyncedWithRemote.catch(() => void 0);
581
- await subscription.firstSyncedWithRemote;
582
- subscription.unsubscribe();
583
- return { ok: true };
437
+ ensureStore(db, storeName) {
438
+ const names = db.objectStoreNames;
439
+ if (this.storeExists(names, storeName)) return;
440
+ db.createObjectStore(storeName);
584
441
  }
585
- joinDocRoom(docId, doc, _params) {
586
- const state = this.ensureDocChannel(docId);
587
- const { promise, resolve } = deferred();
588
- const listener = {
589
- doc,
590
- muted: false,
591
- unsubscribe: doc.subscribe(() => {
592
- if (listener.muted) return;
593
- const payload = doc.export({ mode: "update" });
594
- postChannelMessage(state.channel, {
595
- kind: "doc-update",
596
- docId,
597
- from: this.instanceId,
598
- mode: "update",
599
- payload
600
- });
601
- }),
602
- resolveFirst: resolve,
603
- firstSynced: promise
604
- };
605
- state.listeners.add(listener);
606
- postChannelMessage(state.channel, {
607
- kind: "doc-request",
608
- docId,
609
- 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");
610
454
  });
611
- postChannelMessage(state.channel, {
612
- kind: "doc-update",
613
- docId,
614
- from: this.instanceId,
615
- mode: "snapshot",
616
- 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");
617
471
  });
618
- queueMicrotask(() => resolve());
619
- return {
620
- unsubscribe: () => {
621
- listener.unsubscribe();
622
- state.listeners.delete(listener);
623
- if (!state.listeners.size) this.teardownDocChannel(docId);
624
- },
625
- firstSyncedWithRemote: listener.firstSynced,
626
- get connected() {
627
- return true;
628
- }
629
- };
630
472
  }
631
- ensureMetaChannel() {
632
- if (this.metaState) return this.metaState;
633
- const channel = new (ensureBroadcastChannel())(this.metaChannelName);
634
- const listeners = /* @__PURE__ */ new Set();
635
- const onMessage = (event) => {
636
- const message = event.data;
637
- if (!message || message.from === this.instanceId) return;
638
- if (message.kind === "meta-export") for (const entry of listeners) {
639
- entry.muted = true;
640
- entry.flock.importJson(message.bundle);
641
- entry.muted = false;
642
- entry.resolveFirst();
643
- }
644
- else if (message.kind === "meta-request") {
645
- const first = listeners.values().next().value;
646
- if (!first) return;
647
- Promise.resolve(first.flock.exportJson()).then((bundle) => {
648
- postChannelMessage(channel, {
649
- kind: "meta-export",
650
- from: this.instanceId,
651
- bundle
652
- });
653
- });
654
- }
655
- };
656
- channel.addEventListener("message", onMessage);
657
- this.metaState = {
658
- channel,
659
- listeners,
660
- onMessage
661
- };
662
- 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);
663
476
  }
664
- ensureDocChannel(docId) {
665
- const existing = this.docStates.get(docId);
666
- if (existing) return existing;
667
- const channel = new (ensureBroadcastChannel())(`${this.namespace}-doc-${encodeDocChannelId(docId)}`);
668
- const listeners = /* @__PURE__ */ new Set();
669
- const onMessage = (event) => {
670
- const message = event.data;
671
- if (!message || message.from === this.instanceId) return;
672
- if (message.kind === "doc-update") for (const entry of listeners) {
673
- entry.muted = true;
674
- entry.doc.import(message.payload);
675
- entry.muted = false;
676
- entry.resolveFirst();
677
- }
678
- else if (message.kind === "doc-request") {
679
- const first = listeners.values().next().value;
680
- if (!first) return;
681
- const payload = message.docId === docId ? first.doc.export({ mode: "snapshot" }) : void 0;
682
- if (!payload) return;
683
- postChannelMessage(channel, {
684
- kind: "doc-update",
685
- docId,
686
- from: this.instanceId,
687
- mode: "snapshot",
688
- payload
689
- });
690
- }
691
- };
692
- channel.addEventListener("message", onMessage);
693
- const state = {
694
- channel,
695
- listeners,
696
- onMessage
697
- };
698
- this.docStates.set(docId, state);
699
- return state;
477
+ async clearDocUpdates(db, docId) {
478
+ await this.runInTransaction(db, this.docUpdateStore, "readwrite", (store) => this.wrapRequest(store.delete(docId), "delete"));
700
479
  }
701
- teardownDocChannel(docId) {
702
- const state = this.docStates.get(docId);
703
- if (!state) return;
704
- for (const entry of state.listeners) entry.unsubscribe();
705
- state.channel.removeEventListener("message", state.onMessage);
706
- state.channel.close();
707
- this.docStates.delete(docId);
480
+ async writeSnapshot(db, docId, snapshot) {
481
+ await this.putBinary(db, this.docStore, docId, snapshot.slice());
708
482
  }
709
- };
710
-
711
- //#endregion
712
- //#region src/storage/indexeddb.ts
713
- const DEFAULT_DB_NAME = "loro-repo";
714
- const DEFAULT_DB_VERSION = 1;
715
- const DEFAULT_DOC_STORE = "docs";
716
- const DEFAULT_META_STORE = "meta";
717
- const DEFAULT_ASSET_STORE = "assets";
718
- const DEFAULT_DOC_UPDATE_STORE = "doc-updates";
719
- const DEFAULT_META_KEY = "snapshot";
720
- const textDecoder$1 = new TextDecoder();
721
- function describeUnknown(cause) {
722
- if (typeof cause === "string") return cause;
723
- if (typeof cause === "number" || typeof cause === "boolean") return String(cause);
724
- if (typeof cause === "bigint") return cause.toString();
725
- if (typeof cause === "symbol") return cause.description ?? cause.toString();
726
- if (typeof cause === "function") return `[function ${cause.name ?? "anonymous"}]`;
727
- if (cause && typeof cause === "object") try {
728
- return JSON.stringify(cause);
729
- } catch {
730
- return "[object]";
731
- }
732
- return String(cause);
733
- }
734
- var IndexedDBStorageAdaptor = class {
735
- idb;
736
- dbName;
737
- version;
738
- docStore;
739
- docUpdateStore;
740
- metaStore;
741
- assetStore;
742
- metaKey;
743
- dbPromise;
744
- closed = false;
745
- constructor(options = {}) {
746
- const idbFactory = globalThis.indexedDB;
747
- if (!idbFactory) throw new Error("IndexedDB is not available in this environment");
748
- this.idb = idbFactory;
749
- this.dbName = options.dbName ?? DEFAULT_DB_NAME;
750
- this.version = options.version ?? DEFAULT_DB_VERSION;
751
- this.docStore = options.docStoreName ?? DEFAULT_DOC_STORE;
752
- this.docUpdateStore = options.docUpdateStoreName ?? DEFAULT_DOC_UPDATE_STORE;
753
- this.metaStore = options.metaStoreName ?? DEFAULT_META_STORE;
754
- this.assetStore = options.assetStoreName ?? DEFAULT_ASSET_STORE;
755
- this.metaKey = options.metaKey ?? DEFAULT_META_KEY;
756
- }
757
- async save(payload) {
758
- const db = await this.ensureDb();
759
- switch (payload.type) {
760
- case "doc-snapshot": {
761
- const snapshot = payload.snapshot.slice();
762
- await this.storeMergedSnapshot(db, payload.docId, snapshot);
763
- break;
764
- }
765
- case "doc-update": {
766
- const update = payload.update.slice();
767
- await this.appendDocUpdate(db, payload.docId, update);
768
- break;
769
- }
770
- case "asset": {
771
- const bytes = payload.data.slice();
772
- await this.putBinary(db, this.assetStore, payload.assetId, bytes);
773
- break;
774
- }
775
- case "meta": {
776
- const bytes = payload.update.slice();
777
- await this.putBinary(db, this.metaStore, this.metaKey, bytes);
778
- break;
779
- }
780
- default: throw new Error("Unsupported storage payload type");
781
- }
782
- }
783
- async deleteAsset(assetId) {
784
- const db = await this.ensureDb();
785
- await this.deleteKey(db, this.assetStore, assetId);
786
- }
787
- async loadDoc(docId) {
788
- const db = await this.ensureDb();
789
- const snapshot = await this.getBinaryFromDb(db, this.docStore, docId);
790
- const pendingUpdates = await this.getDocUpdates(db, docId);
791
- if (!snapshot && pendingUpdates.length === 0) return;
792
- let doc;
793
- try {
794
- doc = snapshot ? loro_crdt.LoroDoc.fromSnapshot(snapshot) : new loro_crdt.LoroDoc();
795
- } catch (error) {
796
- throw this.createError(`Failed to hydrate document snapshot for "${docId}"`, error);
797
- }
798
- let appliedUpdates = false;
799
- for (const update of pendingUpdates) try {
800
- doc.import(update);
801
- appliedUpdates = true;
802
- } catch (error) {
803
- throw this.createError(`Failed to apply queued document update for "${docId}"`, error);
804
- }
805
- if (appliedUpdates) {
806
- let consolidated;
807
- try {
808
- consolidated = doc.export({ mode: "snapshot" });
809
- } catch (error) {
810
- throw this.createError(`Failed to export consolidated snapshot for "${docId}"`, error);
811
- }
812
- await this.writeSnapshot(db, docId, consolidated);
813
- await this.clearDocUpdates(db, docId);
814
- }
815
- return doc;
816
- }
817
- async loadMeta() {
818
- const bytes = await this.getBinary(this.metaStore, this.metaKey);
819
- if (!bytes) return void 0;
820
- try {
821
- const json = textDecoder$1.decode(bytes);
822
- const bundle = JSON.parse(json);
823
- const flock = new __loro_dev_flock.Flock();
824
- flock.importJson(bundle);
825
- return flock;
826
- } catch (error) {
827
- throw this.createError("Failed to hydrate metadata snapshot", error);
828
- }
829
- }
830
- async loadAsset(assetId) {
831
- return await this.getBinary(this.assetStore, assetId) ?? void 0;
832
- }
833
- async close() {
834
- this.closed = true;
835
- const db = await this.dbPromise;
836
- if (db) db.close();
837
- this.dbPromise = void 0;
838
- }
839
- async ensureDb() {
840
- if (this.closed) throw new Error("IndexedDBStorageAdaptor has been closed");
841
- if (!this.dbPromise) this.dbPromise = new Promise((resolve, reject) => {
842
- const request = this.idb.open(this.dbName, this.version);
843
- request.addEventListener("upgradeneeded", () => {
844
- const db = request.result;
845
- this.ensureStore(db, this.docStore);
846
- this.ensureStore(db, this.docUpdateStore);
847
- this.ensureStore(db, this.metaStore);
848
- this.ensureStore(db, this.assetStore);
849
- });
850
- request.addEventListener("success", () => resolve(request.result), { once: true });
851
- request.addEventListener("error", () => {
852
- reject(this.createError(`Failed to open IndexedDB database "${this.dbName}"`, request.error));
853
- }, { once: true });
854
- });
855
- return this.dbPromise;
856
- }
857
- ensureStore(db, storeName) {
858
- const names = db.objectStoreNames;
859
- if (this.storeExists(names, storeName)) return;
860
- db.createObjectStore(storeName);
861
- }
862
- storeExists(names, storeName) {
863
- if (typeof names.contains === "function") return names.contains(storeName);
864
- const length = names.length ?? 0;
865
- for (let index = 0; index < length; index += 1) if (names.item?.(index) === storeName) return true;
866
- return false;
867
- }
868
- async storeMergedSnapshot(db, docId, incoming) {
869
- await this.runInTransaction(db, this.docStore, "readwrite", async (store) => {
870
- const existingRaw = await this.wrapRequest(store.get(docId), "read");
871
- const existing = await this.normalizeBinary(existingRaw);
872
- const merged = this.mergeSnapshots(docId, existing, incoming);
873
- await this.wrapRequest(store.put(merged, docId), "write");
874
- });
875
- }
876
- mergeSnapshots(docId, existing, incoming) {
877
- try {
878
- const doc = existing ? loro_crdt.LoroDoc.fromSnapshot(existing) : new loro_crdt.LoroDoc();
879
- doc.import(incoming);
880
- return doc.export({ mode: "snapshot" });
881
- } catch (error) {
882
- throw this.createError(`Failed to merge snapshot for "${docId}"`, error);
883
- }
884
- }
885
- async appendDocUpdate(db, docId, update) {
886
- await this.runInTransaction(db, this.docUpdateStore, "readwrite", async (store) => {
887
- const raw = await this.wrapRequest(store.get(docId), "read");
888
- const queue = await this.normalizeUpdateQueue(raw);
889
- queue.push(update.slice());
890
- await this.wrapRequest(store.put({ updates: queue }, docId), "write");
891
- });
892
- }
893
- async getDocUpdates(db, docId) {
894
- const raw = await this.runInTransaction(db, this.docUpdateStore, "readonly", (store) => this.wrapRequest(store.get(docId), "read"));
895
- return this.normalizeUpdateQueue(raw);
896
- }
897
- async clearDocUpdates(db, docId) {
898
- await this.runInTransaction(db, this.docUpdateStore, "readwrite", (store) => this.wrapRequest(store.delete(docId), "delete"));
899
- }
900
- async writeSnapshot(db, docId, snapshot) {
901
- await this.putBinary(db, this.docStore, docId, snapshot.slice());
902
- }
903
- async getBinaryFromDb(db, storeName, key) {
904
- const value = await this.runInTransaction(db, storeName, "readonly", (store) => this.wrapRequest(store.get(key), "read"));
905
- 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);
906
486
  }
907
487
  async normalizeUpdateQueue(value) {
908
488
  if (value == null) return [];
@@ -1094,22 +674,445 @@ async function removeIfExists(filePath) {
1094
674
  if (error.code === "ENOENT") return;
1095
675
  throw error;
1096
676
  }
1097
- }
1098
- async function listFiles(dir) {
1099
- try {
1100
- return (await node_fs.promises.readdir(dir)).sort();
1101
- } catch (error) {
1102
- if (error.code === "ENOENT") return [];
1103
- throw error;
677
+ }
678
+ async function listFiles(dir) {
679
+ try {
680
+ return (await node_fs.promises.readdir(dir)).sort();
681
+ } catch (error) {
682
+ if (error.code === "ENOENT") return [];
683
+ throw error;
684
+ }
685
+ }
686
+ async function writeFileAtomic(targetPath, data) {
687
+ const dir = node_path.dirname(targetPath);
688
+ await ensureDir(dir);
689
+ const tempPath = node_path.join(dir, `.tmp-${(0, node_crypto.randomUUID)()}`);
690
+ await node_fs.promises.writeFile(tempPath, data);
691
+ await node_fs.promises.rename(tempPath, targetPath);
692
+ }
693
+
694
+ //#endregion
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;
727
+ }
728
+ console.info(prefix, ...args);
729
+ };
730
+ };
731
+
732
+ //#endregion
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
+ });
749
+ }
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;
758
+ }
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;
765
+ }
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;
777
+ }
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
+ }
790
+ }
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");
804
+ }
805
+ debug("close completed");
806
+ }
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 };
825
+ }
826
+ }
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();
864
+ }
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 };
891
+ }
892
+ }
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
+ });
1104
1114
  }
1105
- }
1106
- async function writeFileAtomic(targetPath, data) {
1107
- const dir = node_path.dirname(targetPath);
1108
- await ensureDir(dir);
1109
- const tempPath = node_path.join(dir, `.tmp-${(0, node_crypto.randomUUID)()}`);
1110
- await node_fs.promises.writeFile(tempPath, data);
1111
- await node_fs.promises.rename(tempPath, targetPath);
1112
- }
1115
+ };
1113
1116
 
1114
1117
  //#endregion
1115
1118
  //#region src/internal/event-bus.ts
@@ -1130,223 +1133,35 @@ var RepoEventBus = class {
1130
1133
  for (const entry of this.watchers) if (this.shouldNotify(entry.filter, event)) entry.listener(event);
1131
1134
  }
1132
1135
  clear() {
1133
- this.watchers.clear();
1134
- this.eventByStack.length = 0;
1135
- }
1136
- pushEventBy(by) {
1137
- this.eventByStack.push(by);
1138
- }
1139
- popEventBy() {
1140
- this.eventByStack.pop();
1141
- }
1142
- resolveEventBy(defaultBy) {
1143
- const index = this.eventByStack.length - 1;
1144
- return index >= 0 ? this.eventByStack[index] : defaultBy;
1145
- }
1146
- shouldNotify(filter, event) {
1147
- if (!filter.docIds && !filter.kinds && !filter.metadataFields && !filter.by) return true;
1148
- if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
1149
- if (filter.by && !filter.by.includes(event.by)) return false;
1150
- const docId = (() => {
1151
- if (event.kind === "doc-metadata" || event.kind === "doc-frontiers") return event.docId;
1152
- if (event.kind === "asset-link" || event.kind === "asset-unlink") return event.docId;
1153
- })();
1154
- if (filter.docIds && docId && !filter.docIds.includes(docId)) return false;
1155
- if (filter.docIds && !docId) return false;
1156
- if (filter.metadataFields && event.kind === "doc-metadata") {
1157
- if (!Object.keys(event.patch).some((key) => filter.metadataFields?.includes(key))) return false;
1158
- }
1159
- return true;
1160
- }
1161
- };
1162
-
1163
- //#endregion
1164
- //#region src/utils.ts
1165
- async function streamToUint8Array(stream) {
1166
- const reader = stream.getReader();
1167
- const chunks = [];
1168
- let total = 0;
1169
- while (true) {
1170
- const { done, value } = await reader.read();
1171
- if (done) break;
1172
- if (value) {
1173
- chunks.push(value);
1174
- total += value.byteLength;
1175
- }
1176
- }
1177
- const buffer = new Uint8Array(total);
1178
- let offset = 0;
1179
- for (const chunk of chunks) {
1180
- buffer.set(chunk, offset);
1181
- offset += chunk.byteLength;
1182
- }
1183
- return buffer;
1184
- }
1185
- async function assetContentToUint8Array(content) {
1186
- if (content instanceof Uint8Array) return content;
1187
- if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength));
1188
- if (typeof Blob !== "undefined" && content instanceof Blob) return new Uint8Array(await content.arrayBuffer());
1189
- if (typeof ReadableStream !== "undefined" && content instanceof ReadableStream) return streamToUint8Array(content);
1190
- throw new TypeError("Unsupported asset content type");
1191
- }
1192
- function bytesToHex(bytes) {
1193
- return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1194
- }
1195
- async function computeSha256(bytes) {
1196
- const globalCrypto = globalThis.crypto;
1197
- if (globalCrypto?.subtle && typeof globalCrypto.subtle.digest === "function") {
1198
- const digest = await globalCrypto.subtle.digest("SHA-256", bytes);
1199
- return bytesToHex(new Uint8Array(digest));
1200
- }
1201
- try {
1202
- const { createHash } = await import("node:crypto");
1203
- const hash = createHash("sha256");
1204
- hash.update(bytes);
1205
- return hash.digest("hex");
1206
- } catch {
1207
- throw new Error("SHA-256 digest is not available in this environment");
1208
- }
1209
- }
1210
- function cloneJsonValue(value) {
1211
- if (value === null) return null;
1212
- if (typeof value === "string" || typeof value === "boolean") return value;
1213
- if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
1214
- if (Array.isArray(value)) {
1215
- const arr = [];
1216
- for (const entry of value) {
1217
- const cloned = cloneJsonValue(entry);
1218
- if (cloned !== void 0) arr.push(cloned);
1219
- }
1220
- return arr;
1221
- }
1222
- if (value && typeof value === "object") {
1223
- const input = value;
1224
- const obj = {};
1225
- for (const [key, entry] of Object.entries(input)) {
1226
- const cloned = cloneJsonValue(entry);
1227
- if (cloned !== void 0) obj[key] = cloned;
1228
- }
1229
- return obj;
1230
- }
1231
- }
1232
- function cloneJsonObject(value) {
1233
- const cloned = cloneJsonValue(value);
1234
- if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1235
- return {};
1236
- }
1237
- function asJsonObject(value) {
1238
- const cloned = cloneJsonValue(value);
1239
- if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1240
- }
1241
- function isJsonObjectValue(value) {
1242
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
1243
- }
1244
- function stableStringify(value) {
1245
- if (value === null) return "null";
1246
- if (typeof value === "string") return JSON.stringify(value);
1247
- if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
1248
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1249
- if (!isJsonObjectValue(value)) return "null";
1250
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1251
- }
1252
- function jsonEquals(a, b) {
1253
- if (a === void 0 && b === void 0) return true;
1254
- if (a === void 0 || b === void 0) return false;
1255
- return stableStringify(a) === stableStringify(b);
1256
- }
1257
- function diffJsonObjects(previous, next) {
1258
- const patch = {};
1259
- const keys = /* @__PURE__ */ new Set();
1260
- if (previous) for (const key of Object.keys(previous)) keys.add(key);
1261
- for (const key of Object.keys(next)) keys.add(key);
1262
- for (const key of keys) {
1263
- const prevValue = previous ? previous[key] : void 0;
1264
- const nextValue = next[key];
1265
- if (!jsonEquals(prevValue, nextValue)) {
1266
- if (nextValue === void 0 && previous && key in previous) {
1267
- patch[key] = null;
1268
- continue;
1269
- }
1270
- const cloned = cloneJsonValue(nextValue);
1271
- if (cloned !== void 0) patch[key] = cloned;
1272
- }
1273
- }
1274
- return patch;
1275
- }
1276
- function assetMetaToJson(meta) {
1277
- const json = {
1278
- assetId: meta.assetId,
1279
- size: meta.size,
1280
- createdAt: meta.createdAt
1281
- };
1282
- if (meta.mime !== void 0) json.mime = meta.mime;
1283
- if (meta.policy !== void 0) json.policy = meta.policy;
1284
- if (meta.tag !== void 0) json.tag = meta.tag;
1285
- return json;
1286
- }
1287
- function assetMetaFromJson(value) {
1288
- const obj = asJsonObject(value);
1289
- if (!obj) return void 0;
1290
- const assetId = typeof obj.assetId === "string" ? obj.assetId : void 0;
1291
- if (!assetId) return void 0;
1292
- const size = typeof obj.size === "number" ? obj.size : void 0;
1293
- const createdAt = typeof obj.createdAt === "number" ? obj.createdAt : void 0;
1294
- if (size === void 0 || createdAt === void 0) return void 0;
1295
- return {
1296
- assetId,
1297
- size,
1298
- createdAt,
1299
- ...typeof obj.mime === "string" ? { mime: obj.mime } : {},
1300
- ...typeof obj.policy === "string" ? { policy: obj.policy } : {},
1301
- ...typeof obj.tag === "string" ? { tag: obj.tag } : {}
1302
- };
1303
- }
1304
- function assetMetadataEqual(a, b) {
1305
- if (!a && !b) return true;
1306
- if (!a || !b) return false;
1307
- return stableStringify(assetMetaToJson(a)) === stableStringify(assetMetaToJson(b));
1308
- }
1309
- function cloneRepoAssetMetadata(meta) {
1310
- return {
1311
- assetId: meta.assetId,
1312
- size: meta.size,
1313
- createdAt: meta.createdAt,
1314
- ...meta.mime !== void 0 ? { mime: meta.mime } : {},
1315
- ...meta.policy !== void 0 ? { policy: meta.policy } : {},
1316
- ...meta.tag !== void 0 ? { tag: meta.tag } : {}
1317
- };
1318
- }
1319
- function toReadableStream(bytes) {
1320
- return new ReadableStream({ start(controller) {
1321
- controller.enqueue(bytes);
1322
- controller.close();
1323
- } });
1324
- }
1325
- function canonicalizeFrontiers(frontiers) {
1326
- const json = [...frontiers].sort((a, b) => {
1327
- if (a.peer < b.peer) return -1;
1328
- if (a.peer > b.peer) return 1;
1329
- return a.counter - b.counter;
1330
- }).map((f) => ({
1331
- peer: f.peer,
1332
- counter: f.counter
1333
- }));
1334
- return {
1335
- json,
1336
- key: stableStringify(json)
1337
- };
1338
- }
1339
- function includesFrontiers(vv, frontiers) {
1340
- for (const { peer, counter } of frontiers) if ((vv.get(peer) ?? 0) <= counter) return false;
1341
- return true;
1342
- }
1343
- function matchesQuery(docId, _metadata, query) {
1344
- if (!query) return true;
1345
- if (query.prefix && !docId.startsWith(query.prefix)) return false;
1346
- if (query.start && docId < query.start) return false;
1347
- if (query.end && docId > query.end) return false;
1348
- return true;
1349
- }
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
+ };
1350
1165
 
1351
1166
  //#endregion
1352
1167
  //#region src/internal/logging.ts
@@ -1365,23 +1180,18 @@ var DocManager = class {
1365
1180
  getMetaFlock;
1366
1181
  eventBus;
1367
1182
  persistMeta;
1368
- state;
1369
1183
  docs = /* @__PURE__ */ new Map();
1370
1184
  docSubscriptions = /* @__PURE__ */ new Map();
1371
1185
  docFrontierUpdates = /* @__PURE__ */ new Map();
1372
1186
  docPersistedVersions = /* @__PURE__ */ new Map();
1373
- get docFrontierKeys() {
1374
- return this.state.docFrontierKeys;
1375
- }
1376
1187
  constructor(options) {
1377
1188
  this.storage = options.storage;
1378
1189
  this.docFrontierDebounceMs = options.docFrontierDebounceMs;
1379
1190
  this.getMetaFlock = options.getMetaFlock;
1380
1191
  this.eventBus = options.eventBus;
1381
1192
  this.persistMeta = options.persistMeta;
1382
- this.state = options.state;
1383
1193
  }
1384
- async openCollaborativeDoc(docId) {
1194
+ async openPersistedDoc(docId) {
1385
1195
  return await this.ensureDoc(docId);
1386
1196
  }
1387
1197
  async openDetachedDoc(docId) {
@@ -1428,38 +1238,27 @@ var DocManager = class {
1428
1238
  }
1429
1239
  async updateDocFrontiers(docId, doc, defaultBy) {
1430
1240
  const frontiers = doc.oplogFrontiers();
1431
- const { json, key } = canonicalizeFrontiers(frontiers);
1432
- const existingKeys = this.docFrontierKeys.get(docId) ?? /* @__PURE__ */ new Set();
1241
+ const vv = doc.version();
1242
+ const existingFrontiers = this.readFrontiersFromFlock(docId);
1433
1243
  let mutated = false;
1434
1244
  const metaFlock = this.metaFlock;
1435
- const vv = doc.version();
1436
- for (const entry of existingKeys) {
1437
- if (entry === key) continue;
1438
- let oldFrontiers;
1439
- try {
1440
- oldFrontiers = JSON.parse(entry);
1441
- } catch {
1442
- continue;
1443
- }
1444
- if (includesFrontiers(vv, oldFrontiers)) {
1445
- metaFlock.delete([
1446
- "f",
1447
- docId,
1448
- entry
1449
- ]);
1450
- mutated = true;
1451
- }
1452
- }
1453
- if (!existingKeys.has(key)) {
1245
+ for (const f of frontiers) if (existingFrontiers.get(f.peer) !== f.counter) {
1454
1246
  metaFlock.put([
1455
1247
  "f",
1456
1248
  docId,
1457
- key
1458
- ], json);
1249
+ f.peer
1250
+ ], f.counter);
1459
1251
  mutated = true;
1460
1252
  }
1461
1253
  if (mutated) {
1462
- 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
+ }
1463
1262
  await this.persistMeta();
1464
1263
  }
1465
1264
  const by = this.eventBus.resolveEventBy(defaultBy);
@@ -1511,37 +1310,22 @@ var DocManager = class {
1511
1310
  this.docFrontierUpdates.clear();
1512
1311
  this.docs.clear();
1513
1312
  this.docPersistedVersions.clear();
1514
- this.docFrontierKeys.clear();
1515
1313
  }
1516
- hydrateFrontierKeys() {
1517
- const nextFrontierKeys = /* @__PURE__ */ new Map();
1518
- const frontierRows = this.metaFlock.scan({ prefix: ["f"] });
1519
- for (const row of frontierRows) {
1520
- if (!Array.isArray(row.key) || row.key.length < 3) continue;
1521
- const docId = row.key[1];
1522
- const frontierKey = row.key[2];
1523
- if (typeof docId !== "string" || typeof frontierKey !== "string") continue;
1524
- const set = nextFrontierKeys.get(docId) ?? /* @__PURE__ */ new Set();
1525
- set.add(frontierKey);
1526
- nextFrontierKeys.set(docId, set);
1527
- }
1528
- this.docFrontierKeys.clear();
1529
- for (const [docId, keys] of nextFrontierKeys) this.docFrontierKeys.set(docId, keys);
1314
+ get metaFlock() {
1315
+ return this.getMetaFlock();
1530
1316
  }
1531
- refreshDocFrontierKeys(docId) {
1317
+ readFrontiersFromFlock(docId) {
1532
1318
  const rows = this.metaFlock.scan({ prefix: ["f", docId] });
1533
- const keys = /* @__PURE__ */ new Set();
1319
+ const frontiers = /* @__PURE__ */ new Map();
1534
1320
  for (const row of rows) {
1535
1321
  if (!Array.isArray(row.key) || row.key.length < 3) continue;
1536
- if (row.value === void 0 || row.value === null) continue;
1537
- const frontierKey = row.key[2];
1538
- 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);
1539
1327
  }
1540
- if (keys.size > 0) this.docFrontierKeys.set(docId, keys);
1541
- else this.docFrontierKeys.delete(docId);
1542
- }
1543
- get metaFlock() {
1544
- return this.getMetaFlock();
1328
+ return frontiers;
1545
1329
  }
1546
1330
  registerDoc(docId, doc) {
1547
1331
  this.docs.set(docId, doc);
@@ -1654,6 +1438,176 @@ var DocManager = class {
1654
1438
  }
1655
1439
  };
1656
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
+
1657
1611
  //#endregion
1658
1612
  //#region src/internal/metadata-manager.ts
1659
1613
  var MetadataManager = class {
@@ -1895,13 +1849,11 @@ var AssetManager = class {
1895
1849
  if (params.assetId && params.assetId !== assetId) throw new Error("Provided assetId does not match content digest");
1896
1850
  const existing = this.assets.get(assetId);
1897
1851
  if (existing) {
1898
- if (!existing.data) {
1899
- const clone = bytes.slice();
1900
- existing.data = clone;
1901
- if (this.storage) await this.storage.save({
1852
+ if (this.storage) {
1853
+ if (!await this.storage.loadAsset(assetId)) await this.storage.save({
1902
1854
  type: "asset",
1903
1855
  assetId,
1904
- data: clone.slice()
1856
+ data: bytes.slice()
1905
1857
  });
1906
1858
  }
1907
1859
  let metadataMutated = false;
@@ -1928,7 +1880,7 @@ var AssetManager = class {
1928
1880
  await this.persistMeta();
1929
1881
  this.eventBus.emit({
1930
1882
  kind: "asset-metadata",
1931
- asset: this.createAssetDownload(assetId, metadata$1, existing.data),
1883
+ asset: this.createAssetDownload(assetId, metadata$1, bytes),
1932
1884
  by: "local"
1933
1885
  });
1934
1886
  }
@@ -1958,7 +1910,8 @@ var AssetManager = class {
1958
1910
  assetId,
1959
1911
  data: storedBytes.slice()
1960
1912
  });
1961
- this.rememberAsset(metadata, storedBytes);
1913
+ this.rememberAsset(metadata);
1914
+ this.markAssetAsOrphan(assetId, metadata);
1962
1915
  this.updateDocAssetMetadata(assetId, metadata);
1963
1916
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
1964
1917
  await this.persistMeta();
@@ -1979,13 +1932,11 @@ var AssetManager = class {
1979
1932
  const existing = this.assets.get(assetId);
1980
1933
  if (existing) {
1981
1934
  metadata = existing.metadata;
1982
- if (!existing.data) {
1983
- const clone = bytes.slice();
1984
- existing.data = clone;
1985
- if (this.storage) await this.storage.save({
1935
+ if (this.storage) {
1936
+ if (!await this.storage.loadAsset(assetId)) await this.storage.save({
1986
1937
  type: "asset",
1987
1938
  assetId,
1988
- data: clone.slice()
1939
+ data: bytes.slice()
1989
1940
  });
1990
1941
  }
1991
1942
  let nextMetadata = metadata;
@@ -2025,11 +1976,11 @@ var AssetManager = class {
2025
1976
  await this.persistMeta();
2026
1977
  this.eventBus.emit({
2027
1978
  kind: "asset-metadata",
2028
- asset: this.createAssetDownload(assetId, metadata, existing.data),
1979
+ asset: this.createAssetDownload(assetId, metadata, bytes),
2029
1980
  by: "local"
2030
1981
  });
2031
1982
  } else metadata = existing.metadata;
2032
- storedBytes = existing.data;
1983
+ storedBytes = bytes;
2033
1984
  this.rememberAsset(metadata);
2034
1985
  } else {
2035
1986
  metadata = {
@@ -2055,7 +2006,7 @@ var AssetManager = class {
2055
2006
  assetId,
2056
2007
  data: storedBytes.slice()
2057
2008
  });
2058
- this.rememberAsset(metadata, storedBytes);
2009
+ this.rememberAsset(metadata);
2059
2010
  this.updateDocAssetMetadata(assetId, metadata);
2060
2011
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
2061
2012
  created = true;
@@ -2063,9 +2014,7 @@ var AssetManager = class {
2063
2014
  const mapping = this.docAssets.get(docId) ?? /* @__PURE__ */ new Map();
2064
2015
  mapping.set(assetId, metadata);
2065
2016
  this.docAssets.set(docId, mapping);
2066
- const refs = this.assetToDocRefs.get(assetId) ?? /* @__PURE__ */ new Set();
2067
- refs.add(docId);
2068
- this.assetToDocRefs.set(assetId, refs);
2017
+ this.addDocReference(assetId, docId);
2069
2018
  this.metaFlock.put([
2070
2019
  "ld",
2071
2020
  docId,
@@ -2095,20 +2044,7 @@ var AssetManager = class {
2095
2044
  docId,
2096
2045
  assetId
2097
2046
  ]);
2098
- const refs = this.assetToDocRefs.get(assetId);
2099
- if (refs) {
2100
- refs.delete(docId);
2101
- if (refs.size === 0) {
2102
- this.assetToDocRefs.delete(assetId);
2103
- const record = this.assets.get(assetId);
2104
- if (record) this.orphanedAssets.set(assetId, {
2105
- metadata: record.metadata,
2106
- deletedAt: Date.now()
2107
- });
2108
- this.metaFlock.delete(["a", assetId]);
2109
- this.assets.delete(assetId);
2110
- }
2111
- }
2047
+ this.removeDocAssetReference(assetId, docId);
2112
2048
  await this.persistMeta();
2113
2049
  this.eventBus.emit({
2114
2050
  kind: "asset-unlink",
@@ -2195,12 +2131,11 @@ var AssetManager = class {
2195
2131
  this.handleAssetRemoval(assetId, by);
2196
2132
  return;
2197
2133
  }
2198
- const existingData = previous?.data;
2199
- this.rememberAsset(metadata, existingData);
2134
+ this.rememberAsset(metadata);
2200
2135
  this.updateDocAssetMetadata(assetId, cloneRepoAssetMetadata(metadata));
2201
2136
  if (!previous || !assetMetadataEqual(previous.metadata, metadata)) this.eventBus.emit({
2202
2137
  kind: "asset-metadata",
2203
- asset: this.createAssetDownload(assetId, metadata, existingData),
2138
+ asset: this.createAssetDownload(assetId, metadata),
2204
2139
  by
2205
2140
  });
2206
2141
  }
@@ -2215,11 +2150,7 @@ var AssetManager = class {
2215
2150
  if (typeof assetId !== "string") continue;
2216
2151
  const metadata = assetMetaFromJson(row.value);
2217
2152
  if (!metadata) continue;
2218
- const existing = this.assets.get(assetId);
2219
- nextAssets.set(assetId, {
2220
- metadata,
2221
- data: existing?.data
2222
- });
2153
+ nextAssets.set(assetId, { metadata });
2223
2154
  }
2224
2155
  const nextDocAssets = /* @__PURE__ */ new Map();
2225
2156
  const linkRows = this.metaFlock.scan({ prefix: ["ld"] });
@@ -2255,12 +2186,18 @@ var AssetManager = class {
2255
2186
  this.assetToDocRefs.set(assetId, refs);
2256
2187
  }
2257
2188
  this.assets.clear();
2258
- for (const record of nextAssets.values()) this.rememberAsset(record.metadata, record.data);
2189
+ for (const record of nextAssets.values()) this.rememberAsset(record.metadata);
2190
+ for (const assetId of nextAssets.keys()) {
2191
+ const refs = this.assetToDocRefs.get(assetId);
2192
+ if (!refs || refs.size === 0) {
2193
+ if (!this.orphanedAssets.has(assetId)) this.markAssetAsOrphan(assetId, nextAssets.get(assetId).metadata);
2194
+ } else this.orphanedAssets.delete(assetId);
2195
+ }
2259
2196
  for (const [assetId, record] of nextAssets) {
2260
2197
  const previous = prevAssets.get(assetId)?.metadata;
2261
2198
  if (!assetMetadataEqual(previous, record.metadata)) this.eventBus.emit({
2262
2199
  kind: "asset-metadata",
2263
- asset: this.createAssetDownload(assetId, record.metadata, record.data),
2200
+ asset: this.createAssetDownload(assetId, record.metadata),
2264
2201
  by
2265
2202
  });
2266
2203
  }
@@ -2356,36 +2293,24 @@ var AssetManager = class {
2356
2293
  };
2357
2294
  }
2358
2295
  async materializeAsset(assetId) {
2359
- let record = this.assets.get(assetId);
2360
- if (record?.data) return {
2361
- metadata: record.metadata,
2362
- bytes: record.data.slice()
2363
- };
2296
+ const record = this.assets.get(assetId);
2364
2297
  if (record && this.storage) {
2365
2298
  const stored = await this.storage.loadAsset(assetId);
2366
- if (stored) {
2367
- const clone = stored.slice();
2368
- record.data = clone.slice();
2369
- return {
2370
- metadata: record.metadata,
2371
- bytes: clone
2372
- };
2373
- }
2299
+ if (stored) return {
2300
+ metadata: record.metadata,
2301
+ bytes: stored
2302
+ };
2374
2303
  }
2375
2304
  if (!record && this.storage) {
2376
2305
  const stored = await this.storage.loadAsset(assetId);
2377
2306
  if (stored) {
2378
2307
  const metadata$1 = this.getAssetMetadata(assetId);
2379
2308
  if (!metadata$1) throw new Error(`Missing metadata for asset ${assetId}`);
2380
- const clone = stored.slice();
2381
- this.assets.set(assetId, {
2382
- metadata: metadata$1,
2383
- data: clone.slice()
2384
- });
2309
+ this.assets.set(assetId, { metadata: metadata$1 });
2385
2310
  this.updateDocAssetMetadata(assetId, metadata$1);
2386
2311
  return {
2387
2312
  metadata: metadata$1,
2388
- bytes: clone
2313
+ bytes: stored
2389
2314
  };
2390
2315
  }
2391
2316
  }
@@ -2401,10 +2326,7 @@ var AssetManager = class {
2401
2326
  ...remote.policy ? { policy: remote.policy } : {},
2402
2327
  ...remote.tag ? { tag: remote.tag } : {}
2403
2328
  };
2404
- this.assets.set(assetId, {
2405
- metadata,
2406
- data: remoteBytes.slice()
2407
- });
2329
+ this.assets.set(assetId, { metadata });
2408
2330
  this.updateDocAssetMetadata(assetId, metadata);
2409
2331
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
2410
2332
  await this.persistMeta();
@@ -2426,18 +2348,14 @@ var AssetManager = class {
2426
2348
  if (assets) assets.set(assetId, metadata);
2427
2349
  }
2428
2350
  }
2429
- rememberAsset(metadata, bytes) {
2430
- const data = bytes ? bytes.slice() : this.assets.get(metadata.assetId)?.data;
2431
- this.assets.set(metadata.assetId, {
2432
- metadata,
2433
- data
2434
- });
2435
- this.orphanedAssets.delete(metadata.assetId);
2351
+ rememberAsset(metadata) {
2352
+ this.assets.set(metadata.assetId, { metadata });
2436
2353
  }
2437
2354
  addDocReference(assetId, docId) {
2438
2355
  const refs = this.assetToDocRefs.get(assetId) ?? /* @__PURE__ */ new Set();
2439
2356
  refs.add(docId);
2440
2357
  this.assetToDocRefs.set(assetId, refs);
2358
+ this.orphanedAssets.delete(assetId);
2441
2359
  }
2442
2360
  removeDocAssetReference(assetId, docId) {
2443
2361
  const refs = this.assetToDocRefs.get(assetId);
@@ -2487,13 +2405,11 @@ var FlockHydrator = class {
2487
2405
  const nextMetadata = this.readAllDocMetadata();
2488
2406
  this.metadataManager.replaceAll(nextMetadata, by);
2489
2407
  this.assetManager.hydrateFromFlock(by);
2490
- this.docManager.hydrateFrontierKeys();
2491
2408
  }
2492
2409
  applyEvents(events, by) {
2493
2410
  if (!events.length) return;
2494
2411
  const docMetadataIds = /* @__PURE__ */ new Set();
2495
2412
  const docAssetIds = /* @__PURE__ */ new Set();
2496
- const docFrontiersIds = /* @__PURE__ */ new Set();
2497
2413
  const assetIds = /* @__PURE__ */ new Set();
2498
2414
  for (const event of events) {
2499
2415
  const key = event.key;
@@ -2510,15 +2426,11 @@ var FlockHydrator = class {
2510
2426
  const assetId = key[2];
2511
2427
  if (typeof docId === "string") docAssetIds.add(docId);
2512
2428
  if (typeof assetId === "string") assetIds.add(assetId);
2513
- } else if (root === "f") {
2514
- const docId = key[1];
2515
- if (typeof docId === "string") docFrontiersIds.add(docId);
2516
2429
  }
2517
2430
  }
2518
2431
  for (const assetId of assetIds) this.assetManager.refreshAssetMetadataEntry(assetId, by);
2519
2432
  for (const docId of docMetadataIds) this.metadataManager.refreshFromFlock(docId, by);
2520
2433
  for (const docId of docAssetIds) this.assetManager.refreshDocAssetsEntry(docId, by);
2521
- for (const docId of docFrontiersIds) this.docManager.refreshDocFrontierKeys(docId);
2522
2434
  }
2523
2435
  readAllDocMetadata() {
2524
2436
  const nextMetadata = /* @__PURE__ */ new Map();
@@ -2729,8 +2641,7 @@ function createRepoState() {
2729
2641
  docAssets: /* @__PURE__ */ new Map(),
2730
2642
  assets: /* @__PURE__ */ new Map(),
2731
2643
  orphanedAssets: /* @__PURE__ */ new Map(),
2732
- assetToDocRefs: /* @__PURE__ */ new Map(),
2733
- docFrontierKeys: /* @__PURE__ */ new Map()
2644
+ assetToDocRefs: /* @__PURE__ */ new Map()
2734
2645
  };
2735
2646
  }
2736
2647
 
@@ -2766,8 +2677,7 @@ var LoroRepo = class LoroRepo {
2766
2677
  docFrontierDebounceMs,
2767
2678
  getMetaFlock: () => this.metaFlock,
2768
2679
  eventBus: this.eventBus,
2769
- persistMeta: () => this.persistMeta(),
2770
- state: this.state
2680
+ persistMeta: () => this.persistMeta()
2771
2681
  });
2772
2682
  this.metadataManager = new MetadataManager({
2773
2683
  getMetaFlock: () => this.metaFlock,
@@ -2856,7 +2766,7 @@ var LoroRepo = class LoroRepo {
2856
2766
  */
2857
2767
  async openPersistedDoc(docId) {
2858
2768
  return {
2859
- doc: await this.docManager.openCollaborativeDoc(docId),
2769
+ doc: await this.docManager.openPersistedDoc(docId),
2860
2770
  syncOnce: () => {
2861
2771
  return this.sync({
2862
2772
  scope: "doc",