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.js CHANGED
@@ -1,876 +1,452 @@
1
1
  import { Flock } from "@loro-dev/flock";
2
- import { FlockAdaptor, LoroAdaptor } from "loro-adaptors";
3
- import { CrdtType, bytesToHex } from "loro-protocol";
4
- import { LoroWebsocketClient } from "loro-websocket";
5
2
  import { LoroDoc } from "loro-crdt";
6
3
  import { promises } from "node:fs";
7
4
  import * as path from "node:path";
8
5
  import { randomUUID } from "node:crypto";
6
+ import { LoroAdaptor } from "loro-adaptors/loro";
7
+ import { bytesToHex } from "loro-protocol";
8
+ import { LoroWebsocketClient } from "loro-websocket";
9
+ import { FlockAdaptor } from "loro-adaptors/flock";
9
10
 
10
- //#region src/loro-adaptor.ts
11
- function createRepoFlockAdaptorFromDoc(flock, config = {}) {
12
- return new FlockAdaptor(flock, config);
13
- }
14
-
15
- //#endregion
16
- //#region src/internal/debug.ts
17
- const getEnv = () => {
18
- if (typeof globalThis !== "object" || globalThis === null) return;
19
- return globalThis.process?.env;
20
- };
21
- const rawNamespaceConfig = (getEnv()?.LORO_REPO_DEBUG ?? "").trim();
22
- const normalizedNamespaces = rawNamespaceConfig.length > 0 ? rawNamespaceConfig.split(/[\s,]+/).map((token) => token.toLowerCase()).filter(Boolean) : [];
23
- const wildcardTokens = new Set([
24
- "*",
25
- "1",
26
- "true",
27
- "all"
28
- ]);
29
- const namespaceSet = new Set(normalizedNamespaces);
30
- const hasWildcard = namespaceSet.size > 0 && normalizedNamespaces.some((token) => wildcardTokens.has(token));
31
- const isDebugEnabled = (namespace) => {
32
- if (!namespaceSet.size) return false;
33
- if (!namespace) return hasWildcard;
34
- const normalized = namespace.toLowerCase();
35
- if (hasWildcard) return true;
36
- if (namespaceSet.has(normalized)) return true;
37
- const [root] = normalized.split(":");
38
- return namespaceSet.has(root);
39
- };
40
- const createDebugLogger = (namespace) => {
41
- const normalized = namespace.toLowerCase();
42
- return (...args) => {
43
- if (!isDebugEnabled(normalized)) return;
44
- const prefix = `[loro-repo:${namespace}]`;
45
- if (args.length === 0) {
46
- console.info(prefix);
47
- return;
48
- }
49
- console.info(prefix, ...args);
11
+ //#region src/transport/broadcast-channel.ts
12
+ function deferred() {
13
+ let resolve;
14
+ return {
15
+ promise: new Promise((res) => {
16
+ resolve = res;
17
+ }),
18
+ resolve
50
19
  };
51
- };
52
-
53
- //#endregion
54
- //#region src/transport/websocket.ts
55
- const debug = createDebugLogger("transport:websocket");
56
- function withTimeout(promise, timeoutMs) {
57
- if (!timeoutMs || timeoutMs <= 0) return promise;
58
- return new Promise((resolve, reject) => {
59
- const timer = setTimeout(() => {
60
- reject(/* @__PURE__ */ new Error(`Operation timed out after ${timeoutMs}ms`));
61
- }, timeoutMs);
62
- promise.then((value) => {
63
- clearTimeout(timer);
64
- resolve(value);
65
- }).catch((error) => {
66
- clearTimeout(timer);
67
- reject(error);
68
- });
69
- });
70
20
  }
71
- function normalizeRoomId(roomId, fallback) {
72
- if (typeof roomId === "string" && roomId.length > 0) return roomId;
73
- if (roomId instanceof Uint8Array && roomId.length > 0) try {
74
- return bytesToHex(roomId);
21
+ function randomInstanceId() {
22
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
23
+ return Math.random().toString(36).slice(2);
24
+ }
25
+ function ensureBroadcastChannel() {
26
+ if (typeof BroadcastChannel === "undefined") throw new Error("BroadcastChannel API is not available in this environment");
27
+ return BroadcastChannel;
28
+ }
29
+ function encodeDocChannelId(docId) {
30
+ try {
31
+ return encodeURIComponent(docId);
75
32
  } catch {
76
- return fallback;
33
+ return docId.replace(/[^a-z0-9_-]/gi, "_");
77
34
  }
78
- return fallback;
79
35
  }
80
- function bytesEqual(a, b) {
81
- if (a === b) return true;
82
- if (!a || !b) return false;
83
- if (a.length !== b.length) return false;
84
- for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
85
- return true;
36
+ function postChannelMessage(channel, message) {
37
+ channel.postMessage(message);
86
38
  }
87
39
  /**
88
- * loro-websocket backed {@link TransportAdapter} implementation for LoroRepo.
40
+ * TransportAdapter that relies on the BroadcastChannel API to fan out metadata
41
+ * and document updates between browser tabs within the same origin.
89
42
  */
90
- var WebSocketTransportAdapter = class {
91
- options;
92
- client;
93
- metadataSession;
94
- docSessions = /* @__PURE__ */ new Map();
95
- constructor(options) {
96
- this.options = options;
43
+ var BroadcastChannelTransportAdapter = class {
44
+ instanceId = randomInstanceId();
45
+ namespace;
46
+ metaChannelName;
47
+ connected = false;
48
+ metaState;
49
+ docStates = /* @__PURE__ */ new Map();
50
+ constructor(options = {}) {
51
+ ensureBroadcastChannel();
52
+ this.namespace = options.namespace ?? "loro-repo";
53
+ this.metaChannelName = options.metaChannelName ?? `${this.namespace}-meta`;
97
54
  }
98
- async connect(_options) {
99
- const client = this.ensureClient();
100
- debug("connect requested", { status: client.getStatus() });
101
- try {
102
- await client.connect();
103
- debug("client.connect resolved");
104
- await client.waitConnected();
105
- debug("client.waitConnected resolved", { status: client.getStatus() });
106
- } catch (error) {
107
- debug("connect failed", error);
108
- throw error;
109
- }
55
+ async connect() {
56
+ this.connected = true;
110
57
  }
111
58
  async close() {
112
- debug("close requested", {
113
- docSessions: this.docSessions.size,
114
- metadataSession: Boolean(this.metadataSession)
115
- });
116
- for (const [docId] of this.docSessions) await this.leaveDocSession(docId).catch(() => {});
117
- this.docSessions.clear();
118
- await this.teardownMetadataSession().catch(() => {});
119
- if (this.client) {
120
- const client = this.client;
121
- this.client = void 0;
122
- client.destroy();
123
- debug("websocket client destroyed");
59
+ this.connected = false;
60
+ if (this.metaState) {
61
+ for (const entry of this.metaState.listeners) entry.unsubscribe();
62
+ this.metaState.channel.close();
63
+ this.metaState = void 0;
124
64
  }
125
- debug("close completed");
65
+ for (const [docId] of this.docStates) this.teardownDocChannel(docId);
66
+ this.docStates.clear();
126
67
  }
127
68
  isConnected() {
128
- return this.client?.getStatus() === "connected";
69
+ return this.connected;
129
70
  }
130
- async syncMeta(flock, options) {
131
- if (!this.options.metadataRoomId) {
132
- debug("syncMeta skipped; metadata room not configured");
133
- return { ok: true };
134
- }
135
- debug("syncMeta requested", { roomId: this.options.metadataRoomId });
136
- try {
137
- await withTimeout((await this.ensureMetadataSession(flock, {
138
- roomId: this.options.metadataRoomId,
139
- auth: this.options.metadataAuth
140
- })).firstSynced, options?.timeout);
141
- debug("syncMeta completed", { roomId: this.options.metadataRoomId });
142
- return { ok: true };
143
- } catch (error) {
144
- debug("syncMeta failed", error);
145
- return { ok: false };
146
- }
71
+ async syncMeta(flock, _options) {
72
+ const subscription = this.joinMetaRoom(flock);
73
+ subscription.firstSyncedWithRemote.catch(() => void 0);
74
+ await subscription.firstSyncedWithRemote;
75
+ subscription.unsubscribe();
76
+ return { ok: true };
147
77
  }
148
- joinMetaRoom(flock, params) {
149
- const fallback = this.options.metadataRoomId ?? "";
150
- const roomId = normalizeRoomId(params?.roomId, fallback);
151
- if (!roomId) throw new Error("Metadata room id not configured");
152
- const auth = params?.auth ?? this.options.metadataAuth;
153
- debug("joinMetaRoom requested", {
154
- roomId,
155
- hasAuth: Boolean(auth && auth.length)
78
+ joinMetaRoom(flock, _params) {
79
+ const state = this.ensureMetaChannel();
80
+ const { promise, resolve } = deferred();
81
+ const listener = {
82
+ flock,
83
+ muted: false,
84
+ unsubscribe: flock.subscribe(() => {
85
+ if (listener.muted) return;
86
+ Promise.resolve(flock.exportJson()).then((bundle) => {
87
+ postChannelMessage(state.channel, {
88
+ kind: "meta-export",
89
+ from: this.instanceId,
90
+ bundle
91
+ });
92
+ });
93
+ }),
94
+ resolveFirst: resolve,
95
+ firstSynced: promise
96
+ };
97
+ state.listeners.add(listener);
98
+ postChannelMessage(state.channel, {
99
+ kind: "meta-request",
100
+ from: this.instanceId
156
101
  });
157
- const ensure = this.ensureMetadataSession(flock, {
158
- roomId,
159
- auth
102
+ Promise.resolve(flock.exportJson()).then((bundle) => {
103
+ postChannelMessage(state.channel, {
104
+ kind: "meta-export",
105
+ from: this.instanceId,
106
+ bundle
107
+ });
160
108
  });
161
- const firstSynced = ensure.then((session) => session.firstSynced);
162
- const getConnected = () => this.isConnected();
163
- const subscription = {
109
+ queueMicrotask(() => resolve());
110
+ return {
164
111
  unsubscribe: () => {
165
- ensure.then((session) => {
166
- session.refCount = Math.max(0, session.refCount - 1);
167
- debug("metadata session refCount decremented", {
168
- roomId: session.roomId,
169
- refCount: session.refCount
170
- });
171
- if (session.refCount === 0) {
172
- debug("tearing down metadata session due to refCount=0", { roomId: session.roomId });
173
- this.teardownMetadataSession(session).catch(() => {});
174
- }
175
- });
112
+ listener.unsubscribe();
113
+ state.listeners.delete(listener);
114
+ if (!state.listeners.size) {
115
+ state.channel.removeEventListener("message", state.onMessage);
116
+ state.channel.close();
117
+ this.metaState = void 0;
118
+ }
176
119
  },
177
- firstSyncedWithRemote: firstSynced,
120
+ firstSyncedWithRemote: listener.firstSynced,
178
121
  get connected() {
179
- return getConnected();
122
+ return true;
180
123
  }
181
124
  };
182
- ensure.then((session) => {
183
- session.refCount += 1;
184
- debug("metadata session refCount incremented", {
185
- roomId: session.roomId,
186
- refCount: session.refCount
187
- });
188
- });
189
- return subscription;
190
- }
191
- async syncDoc(docId, doc, options) {
192
- debug("syncDoc requested", { docId });
193
- try {
194
- const session = await this.ensureDocSession(docId, doc, {});
195
- await withTimeout(session.firstSynced, options?.timeout);
196
- debug("syncDoc completed", {
197
- docId,
198
- roomId: session.roomId
199
- });
200
- return { ok: true };
201
- } catch (error) {
202
- debug("syncDoc failed", {
203
- docId,
204
- error
205
- });
206
- return { ok: false };
207
- }
208
125
  }
209
- joinDocRoom(docId, doc, params) {
210
- debug("joinDocRoom requested", {
126
+ async syncDoc(docId, doc, _options) {
127
+ const subscription = this.joinDocRoom(docId, doc);
128
+ subscription.firstSyncedWithRemote.catch(() => void 0);
129
+ await subscription.firstSyncedWithRemote;
130
+ subscription.unsubscribe();
131
+ return { ok: true };
132
+ }
133
+ joinDocRoom(docId, doc, _params) {
134
+ const state = this.ensureDocChannel(docId);
135
+ const { promise, resolve } = deferred();
136
+ const listener = {
137
+ doc,
138
+ muted: false,
139
+ unsubscribe: doc.subscribe(() => {
140
+ if (listener.muted) return;
141
+ const payload = doc.export({ mode: "update" });
142
+ postChannelMessage(state.channel, {
143
+ kind: "doc-update",
144
+ docId,
145
+ from: this.instanceId,
146
+ mode: "update",
147
+ payload
148
+ });
149
+ }),
150
+ resolveFirst: resolve,
151
+ firstSynced: promise
152
+ };
153
+ state.listeners.add(listener);
154
+ postChannelMessage(state.channel, {
155
+ kind: "doc-request",
211
156
  docId,
212
- roomParamType: params?.roomId ? typeof params.roomId === "string" ? "string" : "uint8array" : void 0,
213
- hasAuthOverride: Boolean(params?.auth && params.auth.length)
157
+ from: this.instanceId
214
158
  });
215
- const ensure = this.ensureDocSession(docId, doc, params ?? {});
216
- const firstSynced = ensure.then((session) => session.firstSynced);
217
- const getConnected = () => this.isConnected();
218
- const subscription = {
159
+ postChannelMessage(state.channel, {
160
+ kind: "doc-update",
161
+ docId,
162
+ from: this.instanceId,
163
+ mode: "snapshot",
164
+ payload: doc.export({ mode: "snapshot" })
165
+ });
166
+ queueMicrotask(() => resolve());
167
+ return {
219
168
  unsubscribe: () => {
220
- ensure.then((session) => {
221
- session.refCount = Math.max(0, session.refCount - 1);
222
- debug("doc session refCount decremented", {
223
- docId,
224
- roomId: session.roomId,
225
- refCount: session.refCount
226
- });
227
- if (session.refCount === 0) this.leaveDocSession(docId).catch(() => {});
228
- });
169
+ listener.unsubscribe();
170
+ state.listeners.delete(listener);
171
+ if (!state.listeners.size) this.teardownDocChannel(docId);
229
172
  },
230
- firstSyncedWithRemote: firstSynced,
173
+ firstSyncedWithRemote: listener.firstSynced,
231
174
  get connected() {
232
- return getConnected();
175
+ return true;
233
176
  }
234
177
  };
235
- ensure.then((session) => {
236
- session.refCount += 1;
237
- debug("doc session refCount incremented", {
238
- docId,
239
- roomId: session.roomId,
240
- refCount: session.refCount
241
- });
242
- });
243
- return subscription;
244
178
  }
245
- ensureClient() {
246
- if (this.client) {
247
- debug("reusing websocket client", { status: this.client.getStatus() });
248
- return this.client;
249
- }
250
- const { url, client: clientOptions } = this.options;
251
- debug("creating websocket client", {
252
- url,
253
- clientOptionsKeys: clientOptions ? Object.keys(clientOptions) : []
254
- });
255
- const client = new LoroWebsocketClient({
256
- url,
257
- ...clientOptions
258
- });
259
- this.client = client;
260
- return client;
179
+ ensureMetaChannel() {
180
+ if (this.metaState) return this.metaState;
181
+ const channel = new (ensureBroadcastChannel())(this.metaChannelName);
182
+ const listeners = /* @__PURE__ */ new Set();
183
+ const onMessage = (event) => {
184
+ const message = event.data;
185
+ if (!message || message.from === this.instanceId) return;
186
+ if (message.kind === "meta-export") for (const entry of listeners) {
187
+ entry.muted = true;
188
+ entry.flock.importJson(message.bundle);
189
+ entry.muted = false;
190
+ entry.resolveFirst();
191
+ }
192
+ else if (message.kind === "meta-request") {
193
+ const first = listeners.values().next().value;
194
+ if (!first) return;
195
+ Promise.resolve(first.flock.exportJson()).then((bundle) => {
196
+ postChannelMessage(channel, {
197
+ kind: "meta-export",
198
+ from: this.instanceId,
199
+ bundle
200
+ });
201
+ });
202
+ }
203
+ };
204
+ channel.addEventListener("message", onMessage);
205
+ this.metaState = {
206
+ channel,
207
+ listeners,
208
+ onMessage
209
+ };
210
+ return this.metaState;
261
211
  }
262
- async ensureMetadataSession(flock, params) {
263
- debug("ensureMetadataSession invoked", {
264
- roomId: params.roomId,
265
- hasAuth: Boolean(params.auth && params.auth.length)
266
- });
267
- const client = this.ensureClient();
268
- await client.waitConnected();
269
- debug("websocket client ready for metadata session", { status: client.getStatus() });
270
- if (this.metadataSession && this.metadataSession.flock === flock && this.metadataSession.roomId === params.roomId && bytesEqual(this.metadataSession.auth, params.auth)) {
271
- debug("reusing metadata session", {
272
- roomId: this.metadataSession.roomId,
273
- refCount: this.metadataSession.refCount
274
- });
275
- return this.metadataSession;
276
- }
277
- if (this.metadataSession) {
278
- debug("tearing down previous metadata session", { roomId: this.metadataSession.roomId });
279
- await this.teardownMetadataSession(this.metadataSession).catch(() => {});
280
- }
281
- const configuredType = this.options.metadataCrdtType;
282
- if (configuredType && configuredType !== CrdtType.Flock) throw new Error(`metadataCrdtType must be ${CrdtType.Flock} when syncing Flock metadata`);
283
- const adaptor = createRepoFlockAdaptorFromDoc(flock, this.options.metadataAdaptorConfig ?? {});
284
- debug("joining metadata room", {
285
- roomId: params.roomId,
286
- hasAuth: Boolean(params.auth && params.auth.length)
287
- });
288
- const room = await client.join({
289
- roomId: params.roomId,
290
- crdtAdaptor: adaptor,
291
- auth: params.auth
292
- });
293
- const firstSynced = room.waitForReachingServerVersion();
294
- firstSynced.then(() => {
295
- debug("metadata session firstSynced resolved", { roomId: params.roomId });
296
- }, (error) => {
297
- debug("metadata session firstSynced rejected", {
298
- roomId: params.roomId,
299
- error
300
- });
301
- });
302
- const session = {
303
- adaptor,
304
- room,
305
- firstSynced,
306
- flock,
307
- roomId: params.roomId,
308
- auth: params.auth,
309
- refCount: 0
212
+ ensureDocChannel(docId) {
213
+ const existing = this.docStates.get(docId);
214
+ if (existing) return existing;
215
+ const channel = new (ensureBroadcastChannel())(`${this.namespace}-doc-${encodeDocChannelId(docId)}`);
216
+ const listeners = /* @__PURE__ */ new Set();
217
+ const onMessage = (event) => {
218
+ const message = event.data;
219
+ if (!message || message.from === this.instanceId) return;
220
+ if (message.kind === "doc-update") for (const entry of listeners) {
221
+ entry.muted = true;
222
+ entry.doc.import(message.payload);
223
+ entry.muted = false;
224
+ entry.resolveFirst();
225
+ }
226
+ else if (message.kind === "doc-request") {
227
+ const first = listeners.values().next().value;
228
+ if (!first) return;
229
+ const payload = message.docId === docId ? first.doc.export({ mode: "snapshot" }) : void 0;
230
+ if (!payload) return;
231
+ postChannelMessage(channel, {
232
+ kind: "doc-update",
233
+ docId,
234
+ from: this.instanceId,
235
+ mode: "snapshot",
236
+ payload
237
+ });
238
+ }
310
239
  };
311
- this.metadataSession = session;
312
- return session;
240
+ channel.addEventListener("message", onMessage);
241
+ const state = {
242
+ channel,
243
+ listeners,
244
+ onMessage
245
+ };
246
+ this.docStates.set(docId, state);
247
+ return state;
313
248
  }
314
- async teardownMetadataSession(session) {
315
- const target = session ?? this.metadataSession;
316
- if (!target) return;
317
- debug("teardownMetadataSession invoked", { roomId: target.roomId });
318
- if (this.metadataSession === target) this.metadataSession = void 0;
319
- const { adaptor, room } = target;
320
- try {
321
- await room.leave();
322
- debug("metadata room left", { roomId: target.roomId });
323
- } catch (error) {
324
- debug("metadata room leave failed; destroying", {
325
- roomId: target.roomId,
326
- error
327
- });
328
- await room.destroy().catch(() => {});
329
- }
330
- adaptor.destroy();
331
- debug("metadata session destroyed", { roomId: target.roomId });
332
- }
333
- async ensureDocSession(docId, doc, params) {
334
- debug("ensureDocSession invoked", { docId });
335
- const client = this.ensureClient();
336
- await client.waitConnected();
337
- debug("websocket client ready for doc session", {
338
- docId,
339
- status: client.getStatus()
340
- });
341
- const existing = this.docSessions.get(docId);
342
- const derivedRoomId = this.options.docRoomId?.(docId) ?? docId;
343
- const roomId = normalizeRoomId(params.roomId, derivedRoomId);
344
- const auth = params.auth ?? this.options.docAuth?.(docId);
345
- debug("doc session params resolved", {
346
- docId,
347
- roomId,
348
- hasAuth: Boolean(auth && auth.length)
349
- });
350
- if (existing && existing.doc === doc && existing.roomId === roomId) {
351
- debug("reusing doc session", {
352
- docId,
353
- roomId,
354
- refCount: existing.refCount
355
- });
356
- return existing;
357
- }
358
- if (existing) {
359
- debug("doc session mismatch; leaving existing session", {
360
- docId,
361
- previousRoomId: existing.roomId,
362
- nextRoomId: roomId
363
- });
364
- await this.leaveDocSession(docId).catch(() => {});
365
- }
366
- const adaptor = new LoroAdaptor(doc);
367
- debug("joining doc room", {
368
- docId,
369
- roomId,
370
- hasAuth: Boolean(auth && auth.length)
371
- });
372
- const room = await client.join({
373
- roomId,
374
- crdtAdaptor: adaptor,
375
- auth
376
- });
377
- const firstSynced = room.waitForReachingServerVersion();
378
- firstSynced.then(() => {
379
- debug("doc session firstSynced resolved", {
380
- docId,
381
- roomId
382
- });
383
- }, (error) => {
384
- debug("doc session firstSynced rejected", {
385
- docId,
386
- roomId,
387
- error
388
- });
389
- });
390
- const session = {
391
- adaptor,
392
- room,
393
- firstSynced,
394
- doc,
395
- roomId,
396
- refCount: 0
397
- };
398
- this.docSessions.set(docId, session);
399
- return session;
400
- }
401
- async leaveDocSession(docId) {
402
- const session = this.docSessions.get(docId);
403
- if (!session) {
404
- debug("leaveDocSession invoked but no session found", { docId });
405
- return;
406
- }
407
- this.docSessions.delete(docId);
408
- debug("leaving doc session", {
409
- docId,
410
- roomId: session.roomId
411
- });
412
- try {
413
- await session.room.leave();
414
- debug("doc room left", {
415
- docId,
416
- roomId: session.roomId
417
- });
418
- } catch (error) {
419
- debug("doc room leave failed; destroying", {
420
- docId,
421
- roomId: session.roomId,
422
- error
423
- });
424
- await session.room.destroy().catch(() => {});
425
- }
426
- session.adaptor.destroy();
427
- debug("doc session destroyed", {
428
- docId,
429
- roomId: session.roomId
430
- });
249
+ teardownDocChannel(docId) {
250
+ const state = this.docStates.get(docId);
251
+ if (!state) return;
252
+ for (const entry of state.listeners) entry.unsubscribe();
253
+ state.channel.removeEventListener("message", state.onMessage);
254
+ state.channel.close();
255
+ this.docStates.delete(docId);
431
256
  }
432
257
  };
433
258
 
434
259
  //#endregion
435
- //#region src/transport/broadcast-channel.ts
436
- function deferred() {
437
- let resolve;
438
- return {
439
- promise: new Promise((res) => {
440
- resolve = res;
441
- }),
442
- resolve
443
- };
444
- }
445
- function randomInstanceId() {
446
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
447
- return Math.random().toString(36).slice(2);
448
- }
449
- function ensureBroadcastChannel() {
450
- if (typeof BroadcastChannel === "undefined") throw new Error("BroadcastChannel API is not available in this environment");
451
- return BroadcastChannel;
452
- }
453
- function encodeDocChannelId(docId) {
454
- try {
455
- return encodeURIComponent(docId);
260
+ //#region src/storage/indexeddb.ts
261
+ const DEFAULT_DB_NAME = "loro-repo";
262
+ const DEFAULT_DB_VERSION = 1;
263
+ const DEFAULT_DOC_STORE = "docs";
264
+ const DEFAULT_META_STORE = "meta";
265
+ const DEFAULT_ASSET_STORE = "assets";
266
+ const DEFAULT_DOC_UPDATE_STORE = "doc-updates";
267
+ const DEFAULT_META_KEY = "snapshot";
268
+ const textDecoder$1 = new TextDecoder();
269
+ function describeUnknown(cause) {
270
+ if (typeof cause === "string") return cause;
271
+ if (typeof cause === "number" || typeof cause === "boolean") return String(cause);
272
+ if (typeof cause === "bigint") return cause.toString();
273
+ if (typeof cause === "symbol") return cause.description ?? cause.toString();
274
+ if (typeof cause === "function") return `[function ${cause.name ?? "anonymous"}]`;
275
+ if (cause && typeof cause === "object") try {
276
+ return JSON.stringify(cause);
456
277
  } catch {
457
- return docId.replace(/[^a-z0-9_-]/gi, "_");
278
+ return "[object]";
458
279
  }
280
+ return String(cause);
459
281
  }
460
- function postChannelMessage(channel, message) {
461
- channel.postMessage(message);
462
- }
463
- /**
464
- * TransportAdapter that relies on the BroadcastChannel API to fan out metadata
465
- * and document updates between browser tabs within the same origin.
466
- */
467
- var BroadcastChannelTransportAdapter = class {
468
- instanceId = randomInstanceId();
469
- namespace;
470
- metaChannelName;
471
- connected = false;
472
- metaState;
473
- docStates = /* @__PURE__ */ new Map();
282
+ var IndexedDBStorageAdaptor = class {
283
+ idb;
284
+ dbName;
285
+ version;
286
+ docStore;
287
+ docUpdateStore;
288
+ metaStore;
289
+ assetStore;
290
+ metaKey;
291
+ dbPromise;
292
+ closed = false;
474
293
  constructor(options = {}) {
475
- ensureBroadcastChannel();
476
- this.namespace = options.namespace ?? "loro-repo";
477
- this.metaChannelName = options.metaChannelName ?? `${this.namespace}-meta`;
294
+ const idbFactory = globalThis.indexedDB;
295
+ if (!idbFactory) throw new Error("IndexedDB is not available in this environment");
296
+ this.idb = idbFactory;
297
+ this.dbName = options.dbName ?? DEFAULT_DB_NAME;
298
+ this.version = options.version ?? DEFAULT_DB_VERSION;
299
+ this.docStore = options.docStoreName ?? DEFAULT_DOC_STORE;
300
+ this.docUpdateStore = options.docUpdateStoreName ?? DEFAULT_DOC_UPDATE_STORE;
301
+ this.metaStore = options.metaStoreName ?? DEFAULT_META_STORE;
302
+ this.assetStore = options.assetStoreName ?? DEFAULT_ASSET_STORE;
303
+ this.metaKey = options.metaKey ?? DEFAULT_META_KEY;
478
304
  }
479
- async connect() {
480
- this.connected = true;
305
+ async save(payload) {
306
+ const db = await this.ensureDb();
307
+ switch (payload.type) {
308
+ case "doc-snapshot": {
309
+ const snapshot = payload.snapshot.slice();
310
+ await this.storeMergedSnapshot(db, payload.docId, snapshot);
311
+ break;
312
+ }
313
+ case "doc-update": {
314
+ const update = payload.update.slice();
315
+ await this.appendDocUpdate(db, payload.docId, update);
316
+ break;
317
+ }
318
+ case "asset": {
319
+ const bytes = payload.data.slice();
320
+ await this.putBinary(db, this.assetStore, payload.assetId, bytes);
321
+ break;
322
+ }
323
+ case "meta": {
324
+ const bytes = payload.update.slice();
325
+ await this.putBinary(db, this.metaStore, this.metaKey, bytes);
326
+ break;
327
+ }
328
+ default: throw new Error("Unsupported storage payload type");
329
+ }
481
330
  }
482
- async close() {
483
- this.connected = false;
484
- if (this.metaState) {
485
- for (const entry of this.metaState.listeners) entry.unsubscribe();
486
- this.metaState.channel.close();
487
- this.metaState = void 0;
331
+ async deleteAsset(assetId) {
332
+ const db = await this.ensureDb();
333
+ await this.deleteKey(db, this.assetStore, assetId);
334
+ }
335
+ async loadDoc(docId) {
336
+ const db = await this.ensureDb();
337
+ const snapshot = await this.getBinaryFromDb(db, this.docStore, docId);
338
+ const pendingUpdates = await this.getDocUpdates(db, docId);
339
+ if (!snapshot && pendingUpdates.length === 0) return;
340
+ let doc;
341
+ try {
342
+ doc = snapshot ? LoroDoc.fromSnapshot(snapshot) : new LoroDoc();
343
+ } catch (error) {
344
+ throw this.createError(`Failed to hydrate document snapshot for "${docId}"`, error);
488
345
  }
489
- for (const [docId] of this.docStates) this.teardownDocChannel(docId);
490
- this.docStates.clear();
346
+ let appliedUpdates = false;
347
+ for (const update of pendingUpdates) try {
348
+ doc.import(update);
349
+ appliedUpdates = true;
350
+ } catch (error) {
351
+ throw this.createError(`Failed to apply queued document update for "${docId}"`, error);
352
+ }
353
+ if (appliedUpdates) {
354
+ let consolidated;
355
+ try {
356
+ consolidated = doc.export({ mode: "snapshot" });
357
+ } catch (error) {
358
+ throw this.createError(`Failed to export consolidated snapshot for "${docId}"`, error);
359
+ }
360
+ await this.writeSnapshot(db, docId, consolidated);
361
+ await this.clearDocUpdates(db, docId);
362
+ }
363
+ return doc;
491
364
  }
492
- isConnected() {
493
- return this.connected;
365
+ async loadMeta() {
366
+ const bytes = await this.getBinary(this.metaStore, this.metaKey);
367
+ if (!bytes) return void 0;
368
+ try {
369
+ const json = textDecoder$1.decode(bytes);
370
+ const bundle = JSON.parse(json);
371
+ const flock = new Flock();
372
+ flock.importJson(bundle);
373
+ return flock;
374
+ } catch (error) {
375
+ throw this.createError("Failed to hydrate metadata snapshot", error);
376
+ }
494
377
  }
495
- async syncMeta(flock, _options) {
496
- const subscription = this.joinMetaRoom(flock);
497
- subscription.firstSyncedWithRemote.catch(() => void 0);
498
- await subscription.firstSyncedWithRemote;
499
- subscription.unsubscribe();
500
- return { ok: true };
378
+ async loadAsset(assetId) {
379
+ return await this.getBinary(this.assetStore, assetId) ?? void 0;
501
380
  }
502
- joinMetaRoom(flock, _params) {
503
- const state = this.ensureMetaChannel();
504
- const { promise, resolve } = deferred();
505
- const listener = {
506
- flock,
507
- muted: false,
508
- unsubscribe: flock.subscribe(() => {
509
- if (listener.muted) return;
510
- Promise.resolve(flock.exportJson()).then((bundle) => {
511
- postChannelMessage(state.channel, {
512
- kind: "meta-export",
513
- from: this.instanceId,
514
- bundle
515
- });
516
- });
517
- }),
518
- resolveFirst: resolve,
519
- firstSynced: promise
520
- };
521
- state.listeners.add(listener);
522
- postChannelMessage(state.channel, {
523
- kind: "meta-request",
524
- from: this.instanceId
525
- });
526
- Promise.resolve(flock.exportJson()).then((bundle) => {
527
- postChannelMessage(state.channel, {
528
- kind: "meta-export",
529
- from: this.instanceId,
530
- bundle
381
+ async close() {
382
+ this.closed = true;
383
+ const db = await this.dbPromise;
384
+ if (db) db.close();
385
+ this.dbPromise = void 0;
386
+ }
387
+ async ensureDb() {
388
+ if (this.closed) throw new Error("IndexedDBStorageAdaptor has been closed");
389
+ if (!this.dbPromise) this.dbPromise = new Promise((resolve, reject) => {
390
+ const request = this.idb.open(this.dbName, this.version);
391
+ request.addEventListener("upgradeneeded", () => {
392
+ const db = request.result;
393
+ this.ensureStore(db, this.docStore);
394
+ this.ensureStore(db, this.docUpdateStore);
395
+ this.ensureStore(db, this.metaStore);
396
+ this.ensureStore(db, this.assetStore);
531
397
  });
398
+ request.addEventListener("success", () => resolve(request.result), { once: true });
399
+ request.addEventListener("error", () => {
400
+ reject(this.createError(`Failed to open IndexedDB database "${this.dbName}"`, request.error));
401
+ }, { once: true });
532
402
  });
533
- queueMicrotask(() => resolve());
534
- return {
535
- unsubscribe: () => {
536
- listener.unsubscribe();
537
- state.listeners.delete(listener);
538
- if (!state.listeners.size) {
539
- state.channel.removeEventListener("message", state.onMessage);
540
- state.channel.close();
541
- this.metaState = void 0;
542
- }
543
- },
544
- firstSyncedWithRemote: listener.firstSynced,
545
- get connected() {
546
- return true;
547
- }
548
- };
403
+ return this.dbPromise;
549
404
  }
550
- async syncDoc(docId, doc, _options) {
551
- const subscription = this.joinDocRoom(docId, doc);
552
- subscription.firstSyncedWithRemote.catch(() => void 0);
553
- await subscription.firstSyncedWithRemote;
554
- subscription.unsubscribe();
555
- return { ok: true };
405
+ ensureStore(db, storeName) {
406
+ const names = db.objectStoreNames;
407
+ if (this.storeExists(names, storeName)) return;
408
+ db.createObjectStore(storeName);
556
409
  }
557
- joinDocRoom(docId, doc, _params) {
558
- const state = this.ensureDocChannel(docId);
559
- const { promise, resolve } = deferred();
560
- const listener = {
561
- doc,
562
- muted: false,
563
- unsubscribe: doc.subscribe(() => {
564
- if (listener.muted) return;
565
- const payload = doc.export({ mode: "update" });
566
- postChannelMessage(state.channel, {
567
- kind: "doc-update",
568
- docId,
569
- from: this.instanceId,
570
- mode: "update",
571
- payload
572
- });
573
- }),
574
- resolveFirst: resolve,
575
- firstSynced: promise
576
- };
577
- state.listeners.add(listener);
578
- postChannelMessage(state.channel, {
579
- kind: "doc-request",
580
- docId,
581
- from: this.instanceId
582
- });
583
- postChannelMessage(state.channel, {
584
- kind: "doc-update",
585
- docId,
586
- from: this.instanceId,
587
- mode: "snapshot",
588
- payload: doc.export({ mode: "snapshot" })
410
+ storeExists(names, storeName) {
411
+ if (typeof names.contains === "function") return names.contains(storeName);
412
+ const length = names.length ?? 0;
413
+ for (let index = 0; index < length; index += 1) if (names.item?.(index) === storeName) return true;
414
+ return false;
415
+ }
416
+ async storeMergedSnapshot(db, docId, incoming) {
417
+ await this.runInTransaction(db, this.docStore, "readwrite", async (store) => {
418
+ const existingRaw = await this.wrapRequest(store.get(docId), "read");
419
+ const existing = await this.normalizeBinary(existingRaw);
420
+ const merged = this.mergeSnapshots(docId, existing, incoming);
421
+ await this.wrapRequest(store.put(merged, docId), "write");
589
422
  });
590
- queueMicrotask(() => resolve());
591
- return {
592
- unsubscribe: () => {
593
- listener.unsubscribe();
594
- state.listeners.delete(listener);
595
- if (!state.listeners.size) this.teardownDocChannel(docId);
596
- },
597
- firstSyncedWithRemote: listener.firstSynced,
598
- get connected() {
599
- return true;
600
- }
601
- };
602
423
  }
603
- ensureMetaChannel() {
604
- if (this.metaState) return this.metaState;
605
- const channel = new (ensureBroadcastChannel())(this.metaChannelName);
606
- const listeners = /* @__PURE__ */ new Set();
607
- const onMessage = (event) => {
608
- const message = event.data;
609
- if (!message || message.from === this.instanceId) return;
610
- if (message.kind === "meta-export") for (const entry of listeners) {
611
- entry.muted = true;
612
- entry.flock.importJson(message.bundle);
613
- entry.muted = false;
614
- entry.resolveFirst();
615
- }
616
- else if (message.kind === "meta-request") {
617
- const first = listeners.values().next().value;
618
- if (!first) return;
619
- Promise.resolve(first.flock.exportJson()).then((bundle) => {
620
- postChannelMessage(channel, {
621
- kind: "meta-export",
622
- from: this.instanceId,
623
- bundle
624
- });
625
- });
626
- }
627
- };
628
- channel.addEventListener("message", onMessage);
629
- this.metaState = {
630
- channel,
631
- listeners,
632
- onMessage
633
- };
634
- return this.metaState;
424
+ mergeSnapshots(docId, existing, incoming) {
425
+ try {
426
+ const doc = existing ? LoroDoc.fromSnapshot(existing) : new LoroDoc();
427
+ doc.import(incoming);
428
+ return doc.export({ mode: "snapshot" });
429
+ } catch (error) {
430
+ throw this.createError(`Failed to merge snapshot for "${docId}"`, error);
431
+ }
635
432
  }
636
- ensureDocChannel(docId) {
637
- const existing = this.docStates.get(docId);
638
- if (existing) return existing;
639
- const channel = new (ensureBroadcastChannel())(`${this.namespace}-doc-${encodeDocChannelId(docId)}`);
640
- const listeners = /* @__PURE__ */ new Set();
641
- const onMessage = (event) => {
642
- const message = event.data;
643
- if (!message || message.from === this.instanceId) return;
644
- if (message.kind === "doc-update") for (const entry of listeners) {
645
- entry.muted = true;
646
- entry.doc.import(message.payload);
647
- entry.muted = false;
648
- entry.resolveFirst();
649
- }
650
- else if (message.kind === "doc-request") {
651
- const first = listeners.values().next().value;
652
- if (!first) return;
653
- const payload = message.docId === docId ? first.doc.export({ mode: "snapshot" }) : void 0;
654
- if (!payload) return;
655
- postChannelMessage(channel, {
656
- kind: "doc-update",
657
- docId,
658
- from: this.instanceId,
659
- mode: "snapshot",
660
- payload
661
- });
662
- }
663
- };
664
- channel.addEventListener("message", onMessage);
665
- const state = {
666
- channel,
667
- listeners,
668
- onMessage
669
- };
670
- this.docStates.set(docId, state);
671
- return state;
433
+ async appendDocUpdate(db, docId, update) {
434
+ await this.runInTransaction(db, this.docUpdateStore, "readwrite", async (store) => {
435
+ const raw = await this.wrapRequest(store.get(docId), "read");
436
+ const queue = await this.normalizeUpdateQueue(raw);
437
+ queue.push(update.slice());
438
+ await this.wrapRequest(store.put({ updates: queue }, docId), "write");
439
+ });
672
440
  }
673
- teardownDocChannel(docId) {
674
- const state = this.docStates.get(docId);
675
- if (!state) return;
676
- for (const entry of state.listeners) entry.unsubscribe();
677
- state.channel.removeEventListener("message", state.onMessage);
678
- state.channel.close();
679
- this.docStates.delete(docId);
441
+ async getDocUpdates(db, docId) {
442
+ const raw = await this.runInTransaction(db, this.docUpdateStore, "readonly", (store) => this.wrapRequest(store.get(docId), "read"));
443
+ return this.normalizeUpdateQueue(raw);
680
444
  }
681
- };
682
-
683
- //#endregion
684
- //#region src/storage/indexeddb.ts
685
- const DEFAULT_DB_NAME = "loro-repo";
686
- const DEFAULT_DB_VERSION = 1;
687
- const DEFAULT_DOC_STORE = "docs";
688
- const DEFAULT_META_STORE = "meta";
689
- const DEFAULT_ASSET_STORE = "assets";
690
- const DEFAULT_DOC_UPDATE_STORE = "doc-updates";
691
- const DEFAULT_META_KEY = "snapshot";
692
- const textDecoder$1 = new TextDecoder();
693
- function describeUnknown(cause) {
694
- if (typeof cause === "string") return cause;
695
- if (typeof cause === "number" || typeof cause === "boolean") return String(cause);
696
- if (typeof cause === "bigint") return cause.toString();
697
- if (typeof cause === "symbol") return cause.description ?? cause.toString();
698
- if (typeof cause === "function") return `[function ${cause.name ?? "anonymous"}]`;
699
- if (cause && typeof cause === "object") try {
700
- return JSON.stringify(cause);
701
- } catch {
702
- return "[object]";
445
+ async clearDocUpdates(db, docId) {
446
+ await this.runInTransaction(db, this.docUpdateStore, "readwrite", (store) => this.wrapRequest(store.delete(docId), "delete"));
703
447
  }
704
- return String(cause);
705
- }
706
- var IndexedDBStorageAdaptor = class {
707
- idb;
708
- dbName;
709
- version;
710
- docStore;
711
- docUpdateStore;
712
- metaStore;
713
- assetStore;
714
- metaKey;
715
- dbPromise;
716
- closed = false;
717
- constructor(options = {}) {
718
- const idbFactory = globalThis.indexedDB;
719
- if (!idbFactory) throw new Error("IndexedDB is not available in this environment");
720
- this.idb = idbFactory;
721
- this.dbName = options.dbName ?? DEFAULT_DB_NAME;
722
- this.version = options.version ?? DEFAULT_DB_VERSION;
723
- this.docStore = options.docStoreName ?? DEFAULT_DOC_STORE;
724
- this.docUpdateStore = options.docUpdateStoreName ?? DEFAULT_DOC_UPDATE_STORE;
725
- this.metaStore = options.metaStoreName ?? DEFAULT_META_STORE;
726
- this.assetStore = options.assetStoreName ?? DEFAULT_ASSET_STORE;
727
- this.metaKey = options.metaKey ?? DEFAULT_META_KEY;
728
- }
729
- async save(payload) {
730
- const db = await this.ensureDb();
731
- switch (payload.type) {
732
- case "doc-snapshot": {
733
- const snapshot = payload.snapshot.slice();
734
- await this.storeMergedSnapshot(db, payload.docId, snapshot);
735
- break;
736
- }
737
- case "doc-update": {
738
- const update = payload.update.slice();
739
- await this.appendDocUpdate(db, payload.docId, update);
740
- break;
741
- }
742
- case "asset": {
743
- const bytes = payload.data.slice();
744
- await this.putBinary(db, this.assetStore, payload.assetId, bytes);
745
- break;
746
- }
747
- case "meta": {
748
- const bytes = payload.update.slice();
749
- await this.putBinary(db, this.metaStore, this.metaKey, bytes);
750
- break;
751
- }
752
- default: throw new Error("Unsupported storage payload type");
753
- }
754
- }
755
- async deleteAsset(assetId) {
756
- const db = await this.ensureDb();
757
- await this.deleteKey(db, this.assetStore, assetId);
758
- }
759
- async loadDoc(docId) {
760
- const db = await this.ensureDb();
761
- const snapshot = await this.getBinaryFromDb(db, this.docStore, docId);
762
- const pendingUpdates = await this.getDocUpdates(db, docId);
763
- if (!snapshot && pendingUpdates.length === 0) return;
764
- let doc;
765
- try {
766
- doc = snapshot ? LoroDoc.fromSnapshot(snapshot) : new LoroDoc();
767
- } catch (error) {
768
- throw this.createError(`Failed to hydrate document snapshot for "${docId}"`, error);
769
- }
770
- let appliedUpdates = false;
771
- for (const update of pendingUpdates) try {
772
- doc.import(update);
773
- appliedUpdates = true;
774
- } catch (error) {
775
- throw this.createError(`Failed to apply queued document update for "${docId}"`, error);
776
- }
777
- if (appliedUpdates) {
778
- let consolidated;
779
- try {
780
- consolidated = doc.export({ mode: "snapshot" });
781
- } catch (error) {
782
- throw this.createError(`Failed to export consolidated snapshot for "${docId}"`, error);
783
- }
784
- await this.writeSnapshot(db, docId, consolidated);
785
- await this.clearDocUpdates(db, docId);
786
- }
787
- return doc;
788
- }
789
- async loadMeta() {
790
- const bytes = await this.getBinary(this.metaStore, this.metaKey);
791
- if (!bytes) return void 0;
792
- try {
793
- const json = textDecoder$1.decode(bytes);
794
- const bundle = JSON.parse(json);
795
- const flock = new Flock();
796
- flock.importJson(bundle);
797
- return flock;
798
- } catch (error) {
799
- throw this.createError("Failed to hydrate metadata snapshot", error);
800
- }
801
- }
802
- async loadAsset(assetId) {
803
- return await this.getBinary(this.assetStore, assetId) ?? void 0;
804
- }
805
- async close() {
806
- this.closed = true;
807
- const db = await this.dbPromise;
808
- if (db) db.close();
809
- this.dbPromise = void 0;
810
- }
811
- async ensureDb() {
812
- if (this.closed) throw new Error("IndexedDBStorageAdaptor has been closed");
813
- if (!this.dbPromise) this.dbPromise = new Promise((resolve, reject) => {
814
- const request = this.idb.open(this.dbName, this.version);
815
- request.addEventListener("upgradeneeded", () => {
816
- const db = request.result;
817
- this.ensureStore(db, this.docStore);
818
- this.ensureStore(db, this.docUpdateStore);
819
- this.ensureStore(db, this.metaStore);
820
- this.ensureStore(db, this.assetStore);
821
- });
822
- request.addEventListener("success", () => resolve(request.result), { once: true });
823
- request.addEventListener("error", () => {
824
- reject(this.createError(`Failed to open IndexedDB database "${this.dbName}"`, request.error));
825
- }, { once: true });
826
- });
827
- return this.dbPromise;
828
- }
829
- ensureStore(db, storeName) {
830
- const names = db.objectStoreNames;
831
- if (this.storeExists(names, storeName)) return;
832
- db.createObjectStore(storeName);
833
- }
834
- storeExists(names, storeName) {
835
- if (typeof names.contains === "function") return names.contains(storeName);
836
- const length = names.length ?? 0;
837
- for (let index = 0; index < length; index += 1) if (names.item?.(index) === storeName) return true;
838
- return false;
839
- }
840
- async storeMergedSnapshot(db, docId, incoming) {
841
- await this.runInTransaction(db, this.docStore, "readwrite", async (store) => {
842
- const existingRaw = await this.wrapRequest(store.get(docId), "read");
843
- const existing = await this.normalizeBinary(existingRaw);
844
- const merged = this.mergeSnapshots(docId, existing, incoming);
845
- await this.wrapRequest(store.put(merged, docId), "write");
846
- });
847
- }
848
- mergeSnapshots(docId, existing, incoming) {
849
- try {
850
- const doc = existing ? LoroDoc.fromSnapshot(existing) : new LoroDoc();
851
- doc.import(incoming);
852
- return doc.export({ mode: "snapshot" });
853
- } catch (error) {
854
- throw this.createError(`Failed to merge snapshot for "${docId}"`, error);
855
- }
856
- }
857
- async appendDocUpdate(db, docId, update) {
858
- await this.runInTransaction(db, this.docUpdateStore, "readwrite", async (store) => {
859
- const raw = await this.wrapRequest(store.get(docId), "read");
860
- const queue = await this.normalizeUpdateQueue(raw);
861
- queue.push(update.slice());
862
- await this.wrapRequest(store.put({ updates: queue }, docId), "write");
863
- });
864
- }
865
- async getDocUpdates(db, docId) {
866
- const raw = await this.runInTransaction(db, this.docUpdateStore, "readonly", (store) => this.wrapRequest(store.get(docId), "read"));
867
- return this.normalizeUpdateQueue(raw);
868
- }
869
- async clearDocUpdates(db, docId) {
870
- await this.runInTransaction(db, this.docUpdateStore, "readwrite", (store) => this.wrapRequest(store.delete(docId), "delete"));
871
- }
872
- async writeSnapshot(db, docId, snapshot) {
873
- await this.putBinary(db, this.docStore, docId, snapshot.slice());
448
+ async writeSnapshot(db, docId, snapshot) {
449
+ await this.putBinary(db, this.docStore, docId, snapshot.slice());
874
450
  }
875
451
  async getBinaryFromDb(db, storeName, key) {
876
452
  const value = await this.runInTransaction(db, storeName, "readonly", (store) => this.wrapRequest(store.get(key), "read"));
@@ -1066,22 +642,445 @@ async function removeIfExists(filePath) {
1066
642
  if (error.code === "ENOENT") return;
1067
643
  throw error;
1068
644
  }
1069
- }
1070
- async function listFiles(dir) {
1071
- try {
1072
- return (await promises.readdir(dir)).sort();
1073
- } catch (error) {
1074
- if (error.code === "ENOENT") return [];
1075
- throw error;
645
+ }
646
+ async function listFiles(dir) {
647
+ try {
648
+ return (await promises.readdir(dir)).sort();
649
+ } catch (error) {
650
+ if (error.code === "ENOENT") return [];
651
+ throw error;
652
+ }
653
+ }
654
+ async function writeFileAtomic(targetPath, data) {
655
+ const dir = path.dirname(targetPath);
656
+ await ensureDir(dir);
657
+ const tempPath = path.join(dir, `.tmp-${randomUUID()}`);
658
+ await promises.writeFile(tempPath, data);
659
+ await promises.rename(tempPath, targetPath);
660
+ }
661
+
662
+ //#endregion
663
+ //#region src/internal/debug.ts
664
+ const getEnv = () => {
665
+ if (typeof globalThis !== "object" || globalThis === null) return;
666
+ return globalThis.process?.env;
667
+ };
668
+ const rawNamespaceConfig = (getEnv()?.LORO_REPO_DEBUG ?? "").trim();
669
+ const normalizedNamespaces = rawNamespaceConfig.length > 0 ? rawNamespaceConfig.split(/[\s,]+/).map((token) => token.toLowerCase()).filter(Boolean) : [];
670
+ const wildcardTokens = new Set([
671
+ "*",
672
+ "1",
673
+ "true",
674
+ "all"
675
+ ]);
676
+ const namespaceSet = new Set(normalizedNamespaces);
677
+ const hasWildcard = namespaceSet.size > 0 && normalizedNamespaces.some((token) => wildcardTokens.has(token));
678
+ const isDebugEnabled = (namespace) => {
679
+ if (!namespaceSet.size) return false;
680
+ if (!namespace) return hasWildcard;
681
+ const normalized = namespace.toLowerCase();
682
+ if (hasWildcard) return true;
683
+ if (namespaceSet.has(normalized)) return true;
684
+ const [root] = normalized.split(":");
685
+ return namespaceSet.has(root);
686
+ };
687
+ const createDebugLogger = (namespace) => {
688
+ const normalized = namespace.toLowerCase();
689
+ return (...args) => {
690
+ if (!isDebugEnabled(normalized)) return;
691
+ const prefix = `[loro-repo:${namespace}]`;
692
+ if (args.length === 0) {
693
+ console.info(prefix);
694
+ return;
695
+ }
696
+ console.info(prefix, ...args);
697
+ };
698
+ };
699
+
700
+ //#endregion
701
+ //#region src/transport/websocket.ts
702
+ const debug = createDebugLogger("transport:websocket");
703
+ function withTimeout(promise, timeoutMs) {
704
+ if (!timeoutMs || timeoutMs <= 0) return promise;
705
+ return new Promise((resolve, reject) => {
706
+ const timer = setTimeout(() => {
707
+ reject(/* @__PURE__ */ new Error(`Operation timed out after ${timeoutMs}ms`));
708
+ }, timeoutMs);
709
+ promise.then((value) => {
710
+ clearTimeout(timer);
711
+ resolve(value);
712
+ }).catch((error) => {
713
+ clearTimeout(timer);
714
+ reject(error);
715
+ });
716
+ });
717
+ }
718
+ function normalizeRoomId(roomId, fallback) {
719
+ if (typeof roomId === "string" && roomId.length > 0) return roomId;
720
+ if (roomId instanceof Uint8Array && roomId.length > 0) try {
721
+ return bytesToHex(roomId);
722
+ } catch {
723
+ return fallback;
724
+ }
725
+ return fallback;
726
+ }
727
+ function bytesEqual(a, b) {
728
+ if (a === b) return true;
729
+ if (!a || !b) return false;
730
+ if (a.length !== b.length) return false;
731
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
732
+ return true;
733
+ }
734
+ /**
735
+ * loro-websocket backed {@link TransportAdapter} implementation for LoroRepo.
736
+ * It uses loro-protocol as the underlying protocol for the transport.
737
+ */
738
+ var WebSocketTransportAdapter = class {
739
+ options;
740
+ client;
741
+ metadataSession;
742
+ docSessions = /* @__PURE__ */ new Map();
743
+ constructor(options) {
744
+ this.options = options;
745
+ }
746
+ async connect(_options) {
747
+ const client = this.ensureClient();
748
+ debug("connect requested", { status: client.getStatus() });
749
+ try {
750
+ await client.connect();
751
+ debug("client.connect resolved");
752
+ await client.waitConnected();
753
+ debug("client.waitConnected resolved", { status: client.getStatus() });
754
+ } catch (error) {
755
+ debug("connect failed", error);
756
+ throw error;
757
+ }
758
+ }
759
+ async close() {
760
+ debug("close requested", {
761
+ docSessions: this.docSessions.size,
762
+ metadataSession: Boolean(this.metadataSession)
763
+ });
764
+ for (const [docId] of this.docSessions) await this.leaveDocSession(docId).catch(() => {});
765
+ this.docSessions.clear();
766
+ await this.teardownMetadataSession().catch(() => {});
767
+ if (this.client) {
768
+ const client = this.client;
769
+ this.client = void 0;
770
+ client.destroy();
771
+ debug("websocket client destroyed");
772
+ }
773
+ debug("close completed");
774
+ }
775
+ isConnected() {
776
+ return this.client?.getStatus() === "connected";
777
+ }
778
+ async syncMeta(flock, options) {
779
+ debug("syncMeta requested", { roomId: this.options.metadataRoomId });
780
+ try {
781
+ let auth;
782
+ if (this.options.metadataAuth) if (typeof this.options.metadataAuth === "function") auth = await this.options.metadataAuth();
783
+ else auth = this.options.metadataAuth;
784
+ await withTimeout((await this.ensureMetadataSession(flock, {
785
+ roomId: this.options.metadataRoomId ?? "repo:meta",
786
+ auth
787
+ })).firstSynced, options?.timeout);
788
+ debug("syncMeta completed", { roomId: this.options.metadataRoomId });
789
+ return { ok: true };
790
+ } catch (error) {
791
+ debug("syncMeta failed", error);
792
+ return { ok: false };
793
+ }
794
+ }
795
+ joinMetaRoom(flock, params) {
796
+ const fallback = this.options.metadataRoomId ?? "";
797
+ const roomId = normalizeRoomId(params?.roomId, fallback);
798
+ if (!roomId) throw new Error("Metadata room id not configured");
799
+ const session = (async () => {
800
+ let auth;
801
+ const authWay = params?.auth ?? this.options.metadataAuth;
802
+ if (typeof authWay === "function") auth = await authWay();
803
+ else auth = authWay;
804
+ debug("joinMetaRoom requested", {
805
+ roomId,
806
+ hasAuth: Boolean(auth && auth.length)
807
+ });
808
+ return this.ensureMetadataSession(flock, {
809
+ roomId,
810
+ auth
811
+ });
812
+ })();
813
+ const firstSynced = session.then((session$1) => session$1.firstSynced);
814
+ const getConnected = () => this.isConnected();
815
+ const subscription = {
816
+ unsubscribe: () => {
817
+ session.then((session$1) => {
818
+ session$1.refCount = Math.max(0, session$1.refCount - 1);
819
+ debug("metadata session refCount decremented", {
820
+ roomId: session$1.roomId,
821
+ refCount: session$1.refCount
822
+ });
823
+ if (session$1.refCount === 0) {
824
+ debug("tearing down metadata session due to refCount=0", { roomId: session$1.roomId });
825
+ this.teardownMetadataSession(session$1).catch(() => {});
826
+ }
827
+ });
828
+ },
829
+ firstSyncedWithRemote: firstSynced,
830
+ get connected() {
831
+ return getConnected();
832
+ }
833
+ };
834
+ session.then((session$1) => {
835
+ session$1.refCount += 1;
836
+ debug("metadata session refCount incremented", {
837
+ roomId: session$1.roomId,
838
+ refCount: session$1.refCount
839
+ });
840
+ });
841
+ return subscription;
842
+ }
843
+ async syncDoc(docId, doc, options) {
844
+ debug("syncDoc requested", { docId });
845
+ try {
846
+ const session = await this.ensureDocSession(docId, doc, {});
847
+ await withTimeout(session.firstSynced, options?.timeout);
848
+ debug("syncDoc completed", {
849
+ docId,
850
+ roomId: session.roomId
851
+ });
852
+ return { ok: true };
853
+ } catch (error) {
854
+ debug("syncDoc failed", {
855
+ docId,
856
+ error
857
+ });
858
+ return { ok: false };
859
+ }
860
+ }
861
+ joinDocRoom(docId, doc, params) {
862
+ debug("joinDocRoom requested", {
863
+ docId,
864
+ roomParamType: params?.roomId ? typeof params.roomId === "string" ? "string" : "uint8array" : void 0,
865
+ hasAuthOverride: Boolean(params?.auth && params.auth.length)
866
+ });
867
+ const ensure = this.ensureDocSession(docId, doc, params ?? {});
868
+ const firstSynced = ensure.then((session) => session.firstSynced);
869
+ const getConnected = () => this.isConnected();
870
+ const subscription = {
871
+ unsubscribe: () => {
872
+ ensure.then((session) => {
873
+ session.refCount = Math.max(0, session.refCount - 1);
874
+ debug("doc session refCount decremented", {
875
+ docId,
876
+ roomId: session.roomId,
877
+ refCount: session.refCount
878
+ });
879
+ if (session.refCount === 0) this.leaveDocSession(docId).catch(() => {});
880
+ });
881
+ },
882
+ firstSyncedWithRemote: firstSynced,
883
+ get connected() {
884
+ return getConnected();
885
+ }
886
+ };
887
+ ensure.then((session) => {
888
+ session.refCount += 1;
889
+ debug("doc session refCount incremented", {
890
+ docId,
891
+ roomId: session.roomId,
892
+ refCount: session.refCount
893
+ });
894
+ });
895
+ return subscription;
896
+ }
897
+ ensureClient() {
898
+ if (this.client) {
899
+ debug("reusing websocket client", { status: this.client.getStatus() });
900
+ return this.client;
901
+ }
902
+ const { url, client: clientOptions } = this.options;
903
+ debug("creating websocket client", {
904
+ url,
905
+ clientOptionsKeys: clientOptions ? Object.keys(clientOptions) : []
906
+ });
907
+ const client = new LoroWebsocketClient({
908
+ url,
909
+ ...clientOptions
910
+ });
911
+ this.client = client;
912
+ return client;
913
+ }
914
+ async ensureMetadataSession(flock, params) {
915
+ debug("ensureMetadataSession invoked", {
916
+ roomId: params.roomId,
917
+ hasAuth: Boolean(params.auth && params.auth.length)
918
+ });
919
+ const client = this.ensureClient();
920
+ await client.waitConnected();
921
+ debug("websocket client ready for metadata session", { status: client.getStatus() });
922
+ if (this.metadataSession && this.metadataSession.flock === flock && this.metadataSession.roomId === params.roomId && bytesEqual(this.metadataSession.auth, params.auth)) {
923
+ debug("reusing metadata session", {
924
+ roomId: this.metadataSession.roomId,
925
+ refCount: this.metadataSession.refCount
926
+ });
927
+ return this.metadataSession;
928
+ }
929
+ if (this.metadataSession) {
930
+ debug("tearing down previous metadata session", { roomId: this.metadataSession.roomId });
931
+ await this.teardownMetadataSession(this.metadataSession).catch(() => {});
932
+ }
933
+ const adaptor = new FlockAdaptor(flock, this.options.metadataAdaptorConfig);
934
+ debug("joining metadata room", {
935
+ roomId: params.roomId,
936
+ hasAuth: Boolean(params.auth && params.auth.length)
937
+ });
938
+ const room = await client.join({
939
+ roomId: params.roomId,
940
+ crdtAdaptor: adaptor,
941
+ auth: params.auth
942
+ });
943
+ const firstSynced = room.waitForReachingServerVersion();
944
+ firstSynced.then(() => {
945
+ debug("metadata session firstSynced resolved", { roomId: params.roomId });
946
+ }, (error) => {
947
+ debug("metadata session firstSynced rejected", {
948
+ roomId: params.roomId,
949
+ error
950
+ });
951
+ });
952
+ const session = {
953
+ adaptor,
954
+ room,
955
+ firstSynced,
956
+ flock,
957
+ roomId: params.roomId,
958
+ auth: params.auth,
959
+ refCount: 0
960
+ };
961
+ this.metadataSession = session;
962
+ return session;
963
+ }
964
+ async teardownMetadataSession(session) {
965
+ const target = session ?? this.metadataSession;
966
+ if (!target) return;
967
+ debug("teardownMetadataSession invoked", { roomId: target.roomId });
968
+ if (this.metadataSession === target) this.metadataSession = void 0;
969
+ const { adaptor, room } = target;
970
+ try {
971
+ await room.leave();
972
+ debug("metadata room left", { roomId: target.roomId });
973
+ } catch (error) {
974
+ debug("metadata room leave failed; destroying", {
975
+ roomId: target.roomId,
976
+ error
977
+ });
978
+ await room.destroy().catch(() => {});
979
+ }
980
+ adaptor.destroy();
981
+ debug("metadata session destroyed", { roomId: target.roomId });
982
+ }
983
+ async ensureDocSession(docId, doc, params) {
984
+ debug("ensureDocSession invoked", { docId });
985
+ const client = this.ensureClient();
986
+ await client.waitConnected();
987
+ debug("websocket client ready for doc session", {
988
+ docId,
989
+ status: client.getStatus()
990
+ });
991
+ const existing = this.docSessions.get(docId);
992
+ const derivedRoomId = this.options.docRoomId?.(docId) ?? docId;
993
+ const roomId = normalizeRoomId(params.roomId, derivedRoomId);
994
+ let auth;
995
+ auth = await (params.auth ?? this.options.docAuth?.(docId));
996
+ debug("doc session params resolved", {
997
+ docId,
998
+ roomId,
999
+ hasAuth: Boolean(auth && auth.length)
1000
+ });
1001
+ if (existing && existing.doc === doc && existing.roomId === roomId) {
1002
+ debug("reusing doc session", {
1003
+ docId,
1004
+ roomId,
1005
+ refCount: existing.refCount
1006
+ });
1007
+ return existing;
1008
+ }
1009
+ if (existing) {
1010
+ debug("doc session mismatch; leaving existing session", {
1011
+ docId,
1012
+ previousRoomId: existing.roomId,
1013
+ nextRoomId: roomId
1014
+ });
1015
+ await this.leaveDocSession(docId).catch(() => {});
1016
+ }
1017
+ const adaptor = new LoroAdaptor(doc);
1018
+ debug("joining doc room", {
1019
+ docId,
1020
+ roomId,
1021
+ hasAuth: Boolean(auth && auth.length)
1022
+ });
1023
+ const room = await client.join({
1024
+ roomId,
1025
+ crdtAdaptor: adaptor,
1026
+ auth
1027
+ });
1028
+ const firstSynced = room.waitForReachingServerVersion();
1029
+ firstSynced.then(() => {
1030
+ debug("doc session firstSynced resolved", {
1031
+ docId,
1032
+ roomId
1033
+ });
1034
+ }, (error) => {
1035
+ debug("doc session firstSynced rejected", {
1036
+ docId,
1037
+ roomId,
1038
+ error
1039
+ });
1040
+ });
1041
+ const session = {
1042
+ adaptor,
1043
+ room,
1044
+ firstSynced,
1045
+ doc,
1046
+ roomId,
1047
+ refCount: 0
1048
+ };
1049
+ this.docSessions.set(docId, session);
1050
+ return session;
1051
+ }
1052
+ async leaveDocSession(docId) {
1053
+ const session = this.docSessions.get(docId);
1054
+ if (!session) {
1055
+ debug("leaveDocSession invoked but no session found", { docId });
1056
+ return;
1057
+ }
1058
+ this.docSessions.delete(docId);
1059
+ debug("leaving doc session", {
1060
+ docId,
1061
+ roomId: session.roomId
1062
+ });
1063
+ try {
1064
+ await session.room.leave();
1065
+ debug("doc room left", {
1066
+ docId,
1067
+ roomId: session.roomId
1068
+ });
1069
+ } catch (error) {
1070
+ debug("doc room leave failed; destroying", {
1071
+ docId,
1072
+ roomId: session.roomId,
1073
+ error
1074
+ });
1075
+ await session.room.destroy().catch(() => {});
1076
+ }
1077
+ session.adaptor.destroy();
1078
+ debug("doc session destroyed", {
1079
+ docId,
1080
+ roomId: session.roomId
1081
+ });
1076
1082
  }
1077
- }
1078
- async function writeFileAtomic(targetPath, data) {
1079
- const dir = path.dirname(targetPath);
1080
- await ensureDir(dir);
1081
- const tempPath = path.join(dir, `.tmp-${randomUUID()}`);
1082
- await promises.writeFile(tempPath, data);
1083
- await promises.rename(tempPath, targetPath);
1084
- }
1083
+ };
1085
1084
 
1086
1085
  //#endregion
1087
1086
  //#region src/internal/event-bus.ts
@@ -1102,223 +1101,35 @@ var RepoEventBus = class {
1102
1101
  for (const entry of this.watchers) if (this.shouldNotify(entry.filter, event)) entry.listener(event);
1103
1102
  }
1104
1103
  clear() {
1105
- this.watchers.clear();
1106
- this.eventByStack.length = 0;
1107
- }
1108
- pushEventBy(by) {
1109
- this.eventByStack.push(by);
1110
- }
1111
- popEventBy() {
1112
- this.eventByStack.pop();
1113
- }
1114
- resolveEventBy(defaultBy) {
1115
- const index = this.eventByStack.length - 1;
1116
- return index >= 0 ? this.eventByStack[index] : defaultBy;
1117
- }
1118
- shouldNotify(filter, event) {
1119
- if (!filter.docIds && !filter.kinds && !filter.metadataFields && !filter.by) return true;
1120
- if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
1121
- if (filter.by && !filter.by.includes(event.by)) return false;
1122
- const docId = (() => {
1123
- if (event.kind === "doc-metadata" || event.kind === "doc-frontiers") return event.docId;
1124
- if (event.kind === "asset-link" || event.kind === "asset-unlink") return event.docId;
1125
- })();
1126
- if (filter.docIds && docId && !filter.docIds.includes(docId)) return false;
1127
- if (filter.docIds && !docId) return false;
1128
- if (filter.metadataFields && event.kind === "doc-metadata") {
1129
- if (!Object.keys(event.patch).some((key) => filter.metadataFields?.includes(key))) return false;
1130
- }
1131
- return true;
1132
- }
1133
- };
1134
-
1135
- //#endregion
1136
- //#region src/utils.ts
1137
- async function streamToUint8Array(stream) {
1138
- const reader = stream.getReader();
1139
- const chunks = [];
1140
- let total = 0;
1141
- while (true) {
1142
- const { done, value } = await reader.read();
1143
- if (done) break;
1144
- if (value) {
1145
- chunks.push(value);
1146
- total += value.byteLength;
1147
- }
1148
- }
1149
- const buffer = new Uint8Array(total);
1150
- let offset = 0;
1151
- for (const chunk of chunks) {
1152
- buffer.set(chunk, offset);
1153
- offset += chunk.byteLength;
1154
- }
1155
- return buffer;
1156
- }
1157
- async function assetContentToUint8Array(content) {
1158
- if (content instanceof Uint8Array) return content;
1159
- if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength));
1160
- if (typeof Blob !== "undefined" && content instanceof Blob) return new Uint8Array(await content.arrayBuffer());
1161
- if (typeof ReadableStream !== "undefined" && content instanceof ReadableStream) return streamToUint8Array(content);
1162
- throw new TypeError("Unsupported asset content type");
1163
- }
1164
- function bytesToHex$1(bytes) {
1165
- return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1166
- }
1167
- async function computeSha256(bytes) {
1168
- const globalCrypto = globalThis.crypto;
1169
- if (globalCrypto?.subtle && typeof globalCrypto.subtle.digest === "function") {
1170
- const digest = await globalCrypto.subtle.digest("SHA-256", bytes);
1171
- return bytesToHex$1(new Uint8Array(digest));
1172
- }
1173
- try {
1174
- const { createHash } = await import("node:crypto");
1175
- const hash = createHash("sha256");
1176
- hash.update(bytes);
1177
- return hash.digest("hex");
1178
- } catch {
1179
- throw new Error("SHA-256 digest is not available in this environment");
1180
- }
1181
- }
1182
- function cloneJsonValue(value) {
1183
- if (value === null) return null;
1184
- if (typeof value === "string" || typeof value === "boolean") return value;
1185
- if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
1186
- if (Array.isArray(value)) {
1187
- const arr = [];
1188
- for (const entry of value) {
1189
- const cloned = cloneJsonValue(entry);
1190
- if (cloned !== void 0) arr.push(cloned);
1191
- }
1192
- return arr;
1193
- }
1194
- if (value && typeof value === "object") {
1195
- const input = value;
1196
- const obj = {};
1197
- for (const [key, entry] of Object.entries(input)) {
1198
- const cloned = cloneJsonValue(entry);
1199
- if (cloned !== void 0) obj[key] = cloned;
1200
- }
1201
- return obj;
1202
- }
1203
- }
1204
- function cloneJsonObject(value) {
1205
- const cloned = cloneJsonValue(value);
1206
- if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1207
- return {};
1208
- }
1209
- function asJsonObject(value) {
1210
- const cloned = cloneJsonValue(value);
1211
- if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1212
- }
1213
- function isJsonObjectValue(value) {
1214
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
1215
- }
1216
- function stableStringify(value) {
1217
- if (value === null) return "null";
1218
- if (typeof value === "string") return JSON.stringify(value);
1219
- if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
1220
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1221
- if (!isJsonObjectValue(value)) return "null";
1222
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1223
- }
1224
- function jsonEquals(a, b) {
1225
- if (a === void 0 && b === void 0) return true;
1226
- if (a === void 0 || b === void 0) return false;
1227
- return stableStringify(a) === stableStringify(b);
1228
- }
1229
- function diffJsonObjects(previous, next) {
1230
- const patch = {};
1231
- const keys = /* @__PURE__ */ new Set();
1232
- if (previous) for (const key of Object.keys(previous)) keys.add(key);
1233
- for (const key of Object.keys(next)) keys.add(key);
1234
- for (const key of keys) {
1235
- const prevValue = previous ? previous[key] : void 0;
1236
- const nextValue = next[key];
1237
- if (!jsonEquals(prevValue, nextValue)) {
1238
- if (nextValue === void 0 && previous && key in previous) {
1239
- patch[key] = null;
1240
- continue;
1241
- }
1242
- const cloned = cloneJsonValue(nextValue);
1243
- if (cloned !== void 0) patch[key] = cloned;
1244
- }
1245
- }
1246
- return patch;
1247
- }
1248
- function assetMetaToJson(meta) {
1249
- const json = {
1250
- assetId: meta.assetId,
1251
- size: meta.size,
1252
- createdAt: meta.createdAt
1253
- };
1254
- if (meta.mime !== void 0) json.mime = meta.mime;
1255
- if (meta.policy !== void 0) json.policy = meta.policy;
1256
- if (meta.tag !== void 0) json.tag = meta.tag;
1257
- return json;
1258
- }
1259
- function assetMetaFromJson(value) {
1260
- const obj = asJsonObject(value);
1261
- if (!obj) return void 0;
1262
- const assetId = typeof obj.assetId === "string" ? obj.assetId : void 0;
1263
- if (!assetId) return void 0;
1264
- const size = typeof obj.size === "number" ? obj.size : void 0;
1265
- const createdAt = typeof obj.createdAt === "number" ? obj.createdAt : void 0;
1266
- if (size === void 0 || createdAt === void 0) return void 0;
1267
- return {
1268
- assetId,
1269
- size,
1270
- createdAt,
1271
- ...typeof obj.mime === "string" ? { mime: obj.mime } : {},
1272
- ...typeof obj.policy === "string" ? { policy: obj.policy } : {},
1273
- ...typeof obj.tag === "string" ? { tag: obj.tag } : {}
1274
- };
1275
- }
1276
- function assetMetadataEqual(a, b) {
1277
- if (!a && !b) return true;
1278
- if (!a || !b) return false;
1279
- return stableStringify(assetMetaToJson(a)) === stableStringify(assetMetaToJson(b));
1280
- }
1281
- function cloneRepoAssetMetadata(meta) {
1282
- return {
1283
- assetId: meta.assetId,
1284
- size: meta.size,
1285
- createdAt: meta.createdAt,
1286
- ...meta.mime !== void 0 ? { mime: meta.mime } : {},
1287
- ...meta.policy !== void 0 ? { policy: meta.policy } : {},
1288
- ...meta.tag !== void 0 ? { tag: meta.tag } : {}
1289
- };
1290
- }
1291
- function toReadableStream(bytes) {
1292
- return new ReadableStream({ start(controller) {
1293
- controller.enqueue(bytes);
1294
- controller.close();
1295
- } });
1296
- }
1297
- function canonicalizeFrontiers(frontiers) {
1298
- const json = [...frontiers].sort((a, b) => {
1299
- if (a.peer < b.peer) return -1;
1300
- if (a.peer > b.peer) return 1;
1301
- return a.counter - b.counter;
1302
- }).map((f) => ({
1303
- peer: f.peer,
1304
- counter: f.counter
1305
- }));
1306
- return {
1307
- json,
1308
- key: stableStringify(json)
1309
- };
1310
- }
1311
- function includesFrontiers(vv, frontiers) {
1312
- for (const { peer, counter } of frontiers) if ((vv.get(peer) ?? 0) <= counter) return false;
1313
- return true;
1314
- }
1315
- function matchesQuery(docId, _metadata, query) {
1316
- if (!query) return true;
1317
- if (query.prefix && !docId.startsWith(query.prefix)) return false;
1318
- if (query.start && docId < query.start) return false;
1319
- if (query.end && docId > query.end) return false;
1320
- return true;
1321
- }
1104
+ this.watchers.clear();
1105
+ this.eventByStack.length = 0;
1106
+ }
1107
+ pushEventBy(by) {
1108
+ this.eventByStack.push(by);
1109
+ }
1110
+ popEventBy() {
1111
+ this.eventByStack.pop();
1112
+ }
1113
+ resolveEventBy(defaultBy) {
1114
+ const index = this.eventByStack.length - 1;
1115
+ return index >= 0 ? this.eventByStack[index] : defaultBy;
1116
+ }
1117
+ shouldNotify(filter, event) {
1118
+ if (!filter.docIds && !filter.kinds && !filter.metadataFields && !filter.by) return true;
1119
+ if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
1120
+ if (filter.by && !filter.by.includes(event.by)) return false;
1121
+ const docId = (() => {
1122
+ if (event.kind === "doc-metadata" || event.kind === "doc-frontiers") return event.docId;
1123
+ if (event.kind === "asset-link" || event.kind === "asset-unlink") return event.docId;
1124
+ })();
1125
+ if (filter.docIds && docId && !filter.docIds.includes(docId)) return false;
1126
+ if (filter.docIds && !docId) return false;
1127
+ if (filter.metadataFields && event.kind === "doc-metadata") {
1128
+ if (!Object.keys(event.patch).some((key) => filter.metadataFields?.includes(key))) return false;
1129
+ }
1130
+ return true;
1131
+ }
1132
+ };
1322
1133
 
1323
1134
  //#endregion
1324
1135
  //#region src/internal/logging.ts
@@ -1337,23 +1148,18 @@ var DocManager = class {
1337
1148
  getMetaFlock;
1338
1149
  eventBus;
1339
1150
  persistMeta;
1340
- state;
1341
1151
  docs = /* @__PURE__ */ new Map();
1342
1152
  docSubscriptions = /* @__PURE__ */ new Map();
1343
1153
  docFrontierUpdates = /* @__PURE__ */ new Map();
1344
1154
  docPersistedVersions = /* @__PURE__ */ new Map();
1345
- get docFrontierKeys() {
1346
- return this.state.docFrontierKeys;
1347
- }
1348
1155
  constructor(options) {
1349
1156
  this.storage = options.storage;
1350
1157
  this.docFrontierDebounceMs = options.docFrontierDebounceMs;
1351
1158
  this.getMetaFlock = options.getMetaFlock;
1352
1159
  this.eventBus = options.eventBus;
1353
1160
  this.persistMeta = options.persistMeta;
1354
- this.state = options.state;
1355
1161
  }
1356
- async openCollaborativeDoc(docId) {
1162
+ async openPersistedDoc(docId) {
1357
1163
  return await this.ensureDoc(docId);
1358
1164
  }
1359
1165
  async openDetachedDoc(docId) {
@@ -1400,38 +1206,27 @@ var DocManager = class {
1400
1206
  }
1401
1207
  async updateDocFrontiers(docId, doc, defaultBy) {
1402
1208
  const frontiers = doc.oplogFrontiers();
1403
- const { json, key } = canonicalizeFrontiers(frontiers);
1404
- const existingKeys = this.docFrontierKeys.get(docId) ?? /* @__PURE__ */ new Set();
1209
+ const vv = doc.version();
1210
+ const existingFrontiers = this.readFrontiersFromFlock(docId);
1405
1211
  let mutated = false;
1406
1212
  const metaFlock = this.metaFlock;
1407
- const vv = doc.version();
1408
- for (const entry of existingKeys) {
1409
- if (entry === key) continue;
1410
- let oldFrontiers;
1411
- try {
1412
- oldFrontiers = JSON.parse(entry);
1413
- } catch {
1414
- continue;
1415
- }
1416
- if (includesFrontiers(vv, oldFrontiers)) {
1417
- metaFlock.delete([
1418
- "f",
1419
- docId,
1420
- entry
1421
- ]);
1422
- mutated = true;
1423
- }
1424
- }
1425
- if (!existingKeys.has(key)) {
1213
+ for (const f of frontiers) if (existingFrontiers.get(f.peer) !== f.counter) {
1426
1214
  metaFlock.put([
1427
1215
  "f",
1428
1216
  docId,
1429
- key
1430
- ], json);
1217
+ f.peer
1218
+ ], f.counter);
1431
1219
  mutated = true;
1432
1220
  }
1433
1221
  if (mutated) {
1434
- this.refreshDocFrontierKeys(docId);
1222
+ for (const [peer, counter] of existingFrontiers) {
1223
+ const docCounterEnd = vv.get(peer);
1224
+ if (docCounterEnd != null && docCounterEnd > counter) metaFlock.delete([
1225
+ "f",
1226
+ docId,
1227
+ peer
1228
+ ]);
1229
+ }
1435
1230
  await this.persistMeta();
1436
1231
  }
1437
1232
  const by = this.eventBus.resolveEventBy(defaultBy);
@@ -1483,37 +1278,22 @@ var DocManager = class {
1483
1278
  this.docFrontierUpdates.clear();
1484
1279
  this.docs.clear();
1485
1280
  this.docPersistedVersions.clear();
1486
- this.docFrontierKeys.clear();
1487
1281
  }
1488
- hydrateFrontierKeys() {
1489
- const nextFrontierKeys = /* @__PURE__ */ new Map();
1490
- const frontierRows = this.metaFlock.scan({ prefix: ["f"] });
1491
- for (const row of frontierRows) {
1492
- if (!Array.isArray(row.key) || row.key.length < 3) continue;
1493
- const docId = row.key[1];
1494
- const frontierKey = row.key[2];
1495
- if (typeof docId !== "string" || typeof frontierKey !== "string") continue;
1496
- const set = nextFrontierKeys.get(docId) ?? /* @__PURE__ */ new Set();
1497
- set.add(frontierKey);
1498
- nextFrontierKeys.set(docId, set);
1499
- }
1500
- this.docFrontierKeys.clear();
1501
- for (const [docId, keys] of nextFrontierKeys) this.docFrontierKeys.set(docId, keys);
1282
+ get metaFlock() {
1283
+ return this.getMetaFlock();
1502
1284
  }
1503
- refreshDocFrontierKeys(docId) {
1285
+ readFrontiersFromFlock(docId) {
1504
1286
  const rows = this.metaFlock.scan({ prefix: ["f", docId] });
1505
- const keys = /* @__PURE__ */ new Set();
1287
+ const frontiers = /* @__PURE__ */ new Map();
1506
1288
  for (const row of rows) {
1507
1289
  if (!Array.isArray(row.key) || row.key.length < 3) continue;
1508
- if (row.value === void 0 || row.value === null) continue;
1509
- const frontierKey = row.key[2];
1510
- if (typeof frontierKey === "string") keys.add(frontierKey);
1290
+ const peer = row.key[2];
1291
+ const counter = row.value;
1292
+ if (typeof peer !== "string") continue;
1293
+ if (typeof counter !== "number" || !Number.isFinite(counter)) continue;
1294
+ frontiers.set(peer, counter);
1511
1295
  }
1512
- if (keys.size > 0) this.docFrontierKeys.set(docId, keys);
1513
- else this.docFrontierKeys.delete(docId);
1514
- }
1515
- get metaFlock() {
1516
- return this.getMetaFlock();
1296
+ return frontiers;
1517
1297
  }
1518
1298
  registerDoc(docId, doc) {
1519
1299
  this.docs.set(docId, doc);
@@ -1626,6 +1406,176 @@ var DocManager = class {
1626
1406
  }
1627
1407
  };
1628
1408
 
1409
+ //#endregion
1410
+ //#region src/utils.ts
1411
+ async function streamToUint8Array(stream) {
1412
+ const reader = stream.getReader();
1413
+ const chunks = [];
1414
+ let total = 0;
1415
+ while (true) {
1416
+ const { done, value } = await reader.read();
1417
+ if (done) break;
1418
+ if (value) {
1419
+ chunks.push(value);
1420
+ total += value.byteLength;
1421
+ }
1422
+ }
1423
+ const buffer = new Uint8Array(total);
1424
+ let offset = 0;
1425
+ for (const chunk of chunks) {
1426
+ buffer.set(chunk, offset);
1427
+ offset += chunk.byteLength;
1428
+ }
1429
+ return buffer;
1430
+ }
1431
+ async function assetContentToUint8Array(content) {
1432
+ if (content instanceof Uint8Array) return content;
1433
+ if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength));
1434
+ if (typeof Blob !== "undefined" && content instanceof Blob) return new Uint8Array(await content.arrayBuffer());
1435
+ if (typeof ReadableStream !== "undefined" && content instanceof ReadableStream) return streamToUint8Array(content);
1436
+ throw new TypeError("Unsupported asset content type");
1437
+ }
1438
+ function bytesToHex$1(bytes) {
1439
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1440
+ }
1441
+ async function computeSha256(bytes) {
1442
+ const globalCrypto = globalThis.crypto;
1443
+ if (globalCrypto?.subtle && typeof globalCrypto.subtle.digest === "function") {
1444
+ const digest = await globalCrypto.subtle.digest("SHA-256", bytes);
1445
+ return bytesToHex$1(new Uint8Array(digest));
1446
+ }
1447
+ try {
1448
+ const { createHash } = await import("node:crypto");
1449
+ const hash = createHash("sha256");
1450
+ hash.update(bytes);
1451
+ return hash.digest("hex");
1452
+ } catch {
1453
+ throw new Error("SHA-256 digest is not available in this environment");
1454
+ }
1455
+ }
1456
+ function cloneJsonValue(value) {
1457
+ if (value === null) return null;
1458
+ if (typeof value === "string" || typeof value === "boolean") return value;
1459
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
1460
+ if (Array.isArray(value)) {
1461
+ const arr = [];
1462
+ for (const entry of value) {
1463
+ const cloned = cloneJsonValue(entry);
1464
+ if (cloned !== void 0) arr.push(cloned);
1465
+ }
1466
+ return arr;
1467
+ }
1468
+ if (value && typeof value === "object") {
1469
+ const input = value;
1470
+ const obj = {};
1471
+ for (const [key, entry] of Object.entries(input)) {
1472
+ const cloned = cloneJsonValue(entry);
1473
+ if (cloned !== void 0) obj[key] = cloned;
1474
+ }
1475
+ return obj;
1476
+ }
1477
+ }
1478
+ function cloneJsonObject(value) {
1479
+ const cloned = cloneJsonValue(value);
1480
+ if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1481
+ return {};
1482
+ }
1483
+ function asJsonObject(value) {
1484
+ const cloned = cloneJsonValue(value);
1485
+ if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) return cloned;
1486
+ }
1487
+ function isJsonObjectValue(value) {
1488
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
1489
+ }
1490
+ function stableStringify(value) {
1491
+ if (value === null) return "null";
1492
+ if (typeof value === "string") return JSON.stringify(value);
1493
+ if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
1494
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1495
+ if (!isJsonObjectValue(value)) return "null";
1496
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1497
+ }
1498
+ function jsonEquals(a, b) {
1499
+ if (a === void 0 && b === void 0) return true;
1500
+ if (a === void 0 || b === void 0) return false;
1501
+ return stableStringify(a) === stableStringify(b);
1502
+ }
1503
+ function diffJsonObjects(previous, next) {
1504
+ const patch = {};
1505
+ const keys = /* @__PURE__ */ new Set();
1506
+ if (previous) for (const key of Object.keys(previous)) keys.add(key);
1507
+ for (const key of Object.keys(next)) keys.add(key);
1508
+ for (const key of keys) {
1509
+ const prevValue = previous ? previous[key] : void 0;
1510
+ const nextValue = next[key];
1511
+ if (!jsonEquals(prevValue, nextValue)) {
1512
+ if (nextValue === void 0 && previous && key in previous) {
1513
+ patch[key] = null;
1514
+ continue;
1515
+ }
1516
+ const cloned = cloneJsonValue(nextValue);
1517
+ if (cloned !== void 0) patch[key] = cloned;
1518
+ }
1519
+ }
1520
+ return patch;
1521
+ }
1522
+ function assetMetaToJson(meta) {
1523
+ const json = {
1524
+ assetId: meta.assetId,
1525
+ size: meta.size,
1526
+ createdAt: meta.createdAt
1527
+ };
1528
+ if (meta.mime !== void 0) json.mime = meta.mime;
1529
+ if (meta.policy !== void 0) json.policy = meta.policy;
1530
+ if (meta.tag !== void 0) json.tag = meta.tag;
1531
+ return json;
1532
+ }
1533
+ function assetMetaFromJson(value) {
1534
+ const obj = asJsonObject(value);
1535
+ if (!obj) return void 0;
1536
+ const assetId = typeof obj.assetId === "string" ? obj.assetId : void 0;
1537
+ if (!assetId) return void 0;
1538
+ const size = typeof obj.size === "number" ? obj.size : void 0;
1539
+ const createdAt = typeof obj.createdAt === "number" ? obj.createdAt : void 0;
1540
+ if (size === void 0 || createdAt === void 0) return void 0;
1541
+ return {
1542
+ assetId,
1543
+ size,
1544
+ createdAt,
1545
+ ...typeof obj.mime === "string" ? { mime: obj.mime } : {},
1546
+ ...typeof obj.policy === "string" ? { policy: obj.policy } : {},
1547
+ ...typeof obj.tag === "string" ? { tag: obj.tag } : {}
1548
+ };
1549
+ }
1550
+ function assetMetadataEqual(a, b) {
1551
+ if (!a && !b) return true;
1552
+ if (!a || !b) return false;
1553
+ return stableStringify(assetMetaToJson(a)) === stableStringify(assetMetaToJson(b));
1554
+ }
1555
+ function cloneRepoAssetMetadata(meta) {
1556
+ return {
1557
+ assetId: meta.assetId,
1558
+ size: meta.size,
1559
+ createdAt: meta.createdAt,
1560
+ ...meta.mime !== void 0 ? { mime: meta.mime } : {},
1561
+ ...meta.policy !== void 0 ? { policy: meta.policy } : {},
1562
+ ...meta.tag !== void 0 ? { tag: meta.tag } : {}
1563
+ };
1564
+ }
1565
+ function toReadableStream(bytes) {
1566
+ return new ReadableStream({ start(controller) {
1567
+ controller.enqueue(bytes);
1568
+ controller.close();
1569
+ } });
1570
+ }
1571
+ function matchesQuery(docId, _metadata, query) {
1572
+ if (!query) return true;
1573
+ if (query.prefix && !docId.startsWith(query.prefix)) return false;
1574
+ if (query.start && docId < query.start) return false;
1575
+ if (query.end && docId > query.end) return false;
1576
+ return true;
1577
+ }
1578
+
1629
1579
  //#endregion
1630
1580
  //#region src/internal/metadata-manager.ts
1631
1581
  var MetadataManager = class {
@@ -1867,13 +1817,11 @@ var AssetManager = class {
1867
1817
  if (params.assetId && params.assetId !== assetId) throw new Error("Provided assetId does not match content digest");
1868
1818
  const existing = this.assets.get(assetId);
1869
1819
  if (existing) {
1870
- if (!existing.data) {
1871
- const clone = bytes.slice();
1872
- existing.data = clone;
1873
- if (this.storage) await this.storage.save({
1820
+ if (this.storage) {
1821
+ if (!await this.storage.loadAsset(assetId)) await this.storage.save({
1874
1822
  type: "asset",
1875
1823
  assetId,
1876
- data: clone.slice()
1824
+ data: bytes.slice()
1877
1825
  });
1878
1826
  }
1879
1827
  let metadataMutated = false;
@@ -1900,7 +1848,7 @@ var AssetManager = class {
1900
1848
  await this.persistMeta();
1901
1849
  this.eventBus.emit({
1902
1850
  kind: "asset-metadata",
1903
- asset: this.createAssetDownload(assetId, metadata$1, existing.data),
1851
+ asset: this.createAssetDownload(assetId, metadata$1, bytes),
1904
1852
  by: "local"
1905
1853
  });
1906
1854
  }
@@ -1930,7 +1878,8 @@ var AssetManager = class {
1930
1878
  assetId,
1931
1879
  data: storedBytes.slice()
1932
1880
  });
1933
- this.rememberAsset(metadata, storedBytes);
1881
+ this.rememberAsset(metadata);
1882
+ this.markAssetAsOrphan(assetId, metadata);
1934
1883
  this.updateDocAssetMetadata(assetId, metadata);
1935
1884
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
1936
1885
  await this.persistMeta();
@@ -1951,13 +1900,11 @@ var AssetManager = class {
1951
1900
  const existing = this.assets.get(assetId);
1952
1901
  if (existing) {
1953
1902
  metadata = existing.metadata;
1954
- if (!existing.data) {
1955
- const clone = bytes.slice();
1956
- existing.data = clone;
1957
- if (this.storage) await this.storage.save({
1903
+ if (this.storage) {
1904
+ if (!await this.storage.loadAsset(assetId)) await this.storage.save({
1958
1905
  type: "asset",
1959
1906
  assetId,
1960
- data: clone.slice()
1907
+ data: bytes.slice()
1961
1908
  });
1962
1909
  }
1963
1910
  let nextMetadata = metadata;
@@ -1997,11 +1944,11 @@ var AssetManager = class {
1997
1944
  await this.persistMeta();
1998
1945
  this.eventBus.emit({
1999
1946
  kind: "asset-metadata",
2000
- asset: this.createAssetDownload(assetId, metadata, existing.data),
1947
+ asset: this.createAssetDownload(assetId, metadata, bytes),
2001
1948
  by: "local"
2002
1949
  });
2003
1950
  } else metadata = existing.metadata;
2004
- storedBytes = existing.data;
1951
+ storedBytes = bytes;
2005
1952
  this.rememberAsset(metadata);
2006
1953
  } else {
2007
1954
  metadata = {
@@ -2027,7 +1974,7 @@ var AssetManager = class {
2027
1974
  assetId,
2028
1975
  data: storedBytes.slice()
2029
1976
  });
2030
- this.rememberAsset(metadata, storedBytes);
1977
+ this.rememberAsset(metadata);
2031
1978
  this.updateDocAssetMetadata(assetId, metadata);
2032
1979
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
2033
1980
  created = true;
@@ -2035,9 +1982,7 @@ var AssetManager = class {
2035
1982
  const mapping = this.docAssets.get(docId) ?? /* @__PURE__ */ new Map();
2036
1983
  mapping.set(assetId, metadata);
2037
1984
  this.docAssets.set(docId, mapping);
2038
- const refs = this.assetToDocRefs.get(assetId) ?? /* @__PURE__ */ new Set();
2039
- refs.add(docId);
2040
- this.assetToDocRefs.set(assetId, refs);
1985
+ this.addDocReference(assetId, docId);
2041
1986
  this.metaFlock.put([
2042
1987
  "ld",
2043
1988
  docId,
@@ -2067,20 +2012,7 @@ var AssetManager = class {
2067
2012
  docId,
2068
2013
  assetId
2069
2014
  ]);
2070
- const refs = this.assetToDocRefs.get(assetId);
2071
- if (refs) {
2072
- refs.delete(docId);
2073
- if (refs.size === 0) {
2074
- this.assetToDocRefs.delete(assetId);
2075
- const record = this.assets.get(assetId);
2076
- if (record) this.orphanedAssets.set(assetId, {
2077
- metadata: record.metadata,
2078
- deletedAt: Date.now()
2079
- });
2080
- this.metaFlock.delete(["a", assetId]);
2081
- this.assets.delete(assetId);
2082
- }
2083
- }
2015
+ this.removeDocAssetReference(assetId, docId);
2084
2016
  await this.persistMeta();
2085
2017
  this.eventBus.emit({
2086
2018
  kind: "asset-unlink",
@@ -2167,12 +2099,11 @@ var AssetManager = class {
2167
2099
  this.handleAssetRemoval(assetId, by);
2168
2100
  return;
2169
2101
  }
2170
- const existingData = previous?.data;
2171
- this.rememberAsset(metadata, existingData);
2102
+ this.rememberAsset(metadata);
2172
2103
  this.updateDocAssetMetadata(assetId, cloneRepoAssetMetadata(metadata));
2173
2104
  if (!previous || !assetMetadataEqual(previous.metadata, metadata)) this.eventBus.emit({
2174
2105
  kind: "asset-metadata",
2175
- asset: this.createAssetDownload(assetId, metadata, existingData),
2106
+ asset: this.createAssetDownload(assetId, metadata),
2176
2107
  by
2177
2108
  });
2178
2109
  }
@@ -2187,11 +2118,7 @@ var AssetManager = class {
2187
2118
  if (typeof assetId !== "string") continue;
2188
2119
  const metadata = assetMetaFromJson(row.value);
2189
2120
  if (!metadata) continue;
2190
- const existing = this.assets.get(assetId);
2191
- nextAssets.set(assetId, {
2192
- metadata,
2193
- data: existing?.data
2194
- });
2121
+ nextAssets.set(assetId, { metadata });
2195
2122
  }
2196
2123
  const nextDocAssets = /* @__PURE__ */ new Map();
2197
2124
  const linkRows = this.metaFlock.scan({ prefix: ["ld"] });
@@ -2227,12 +2154,18 @@ var AssetManager = class {
2227
2154
  this.assetToDocRefs.set(assetId, refs);
2228
2155
  }
2229
2156
  this.assets.clear();
2230
- for (const record of nextAssets.values()) this.rememberAsset(record.metadata, record.data);
2157
+ for (const record of nextAssets.values()) this.rememberAsset(record.metadata);
2158
+ for (const assetId of nextAssets.keys()) {
2159
+ const refs = this.assetToDocRefs.get(assetId);
2160
+ if (!refs || refs.size === 0) {
2161
+ if (!this.orphanedAssets.has(assetId)) this.markAssetAsOrphan(assetId, nextAssets.get(assetId).metadata);
2162
+ } else this.orphanedAssets.delete(assetId);
2163
+ }
2231
2164
  for (const [assetId, record] of nextAssets) {
2232
2165
  const previous = prevAssets.get(assetId)?.metadata;
2233
2166
  if (!assetMetadataEqual(previous, record.metadata)) this.eventBus.emit({
2234
2167
  kind: "asset-metadata",
2235
- asset: this.createAssetDownload(assetId, record.metadata, record.data),
2168
+ asset: this.createAssetDownload(assetId, record.metadata),
2236
2169
  by
2237
2170
  });
2238
2171
  }
@@ -2328,36 +2261,24 @@ var AssetManager = class {
2328
2261
  };
2329
2262
  }
2330
2263
  async materializeAsset(assetId) {
2331
- let record = this.assets.get(assetId);
2332
- if (record?.data) return {
2333
- metadata: record.metadata,
2334
- bytes: record.data.slice()
2335
- };
2264
+ const record = this.assets.get(assetId);
2336
2265
  if (record && this.storage) {
2337
2266
  const stored = await this.storage.loadAsset(assetId);
2338
- if (stored) {
2339
- const clone = stored.slice();
2340
- record.data = clone.slice();
2341
- return {
2342
- metadata: record.metadata,
2343
- bytes: clone
2344
- };
2345
- }
2267
+ if (stored) return {
2268
+ metadata: record.metadata,
2269
+ bytes: stored
2270
+ };
2346
2271
  }
2347
2272
  if (!record && this.storage) {
2348
2273
  const stored = await this.storage.loadAsset(assetId);
2349
2274
  if (stored) {
2350
2275
  const metadata$1 = this.getAssetMetadata(assetId);
2351
2276
  if (!metadata$1) throw new Error(`Missing metadata for asset ${assetId}`);
2352
- const clone = stored.slice();
2353
- this.assets.set(assetId, {
2354
- metadata: metadata$1,
2355
- data: clone.slice()
2356
- });
2277
+ this.assets.set(assetId, { metadata: metadata$1 });
2357
2278
  this.updateDocAssetMetadata(assetId, metadata$1);
2358
2279
  return {
2359
2280
  metadata: metadata$1,
2360
- bytes: clone
2281
+ bytes: stored
2361
2282
  };
2362
2283
  }
2363
2284
  }
@@ -2373,10 +2294,7 @@ var AssetManager = class {
2373
2294
  ...remote.policy ? { policy: remote.policy } : {},
2374
2295
  ...remote.tag ? { tag: remote.tag } : {}
2375
2296
  };
2376
- this.assets.set(assetId, {
2377
- metadata,
2378
- data: remoteBytes.slice()
2379
- });
2297
+ this.assets.set(assetId, { metadata });
2380
2298
  this.updateDocAssetMetadata(assetId, metadata);
2381
2299
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
2382
2300
  await this.persistMeta();
@@ -2398,18 +2316,14 @@ var AssetManager = class {
2398
2316
  if (assets) assets.set(assetId, metadata);
2399
2317
  }
2400
2318
  }
2401
- rememberAsset(metadata, bytes) {
2402
- const data = bytes ? bytes.slice() : this.assets.get(metadata.assetId)?.data;
2403
- this.assets.set(metadata.assetId, {
2404
- metadata,
2405
- data
2406
- });
2407
- this.orphanedAssets.delete(metadata.assetId);
2319
+ rememberAsset(metadata) {
2320
+ this.assets.set(metadata.assetId, { metadata });
2408
2321
  }
2409
2322
  addDocReference(assetId, docId) {
2410
2323
  const refs = this.assetToDocRefs.get(assetId) ?? /* @__PURE__ */ new Set();
2411
2324
  refs.add(docId);
2412
2325
  this.assetToDocRefs.set(assetId, refs);
2326
+ this.orphanedAssets.delete(assetId);
2413
2327
  }
2414
2328
  removeDocAssetReference(assetId, docId) {
2415
2329
  const refs = this.assetToDocRefs.get(assetId);
@@ -2459,13 +2373,11 @@ var FlockHydrator = class {
2459
2373
  const nextMetadata = this.readAllDocMetadata();
2460
2374
  this.metadataManager.replaceAll(nextMetadata, by);
2461
2375
  this.assetManager.hydrateFromFlock(by);
2462
- this.docManager.hydrateFrontierKeys();
2463
2376
  }
2464
2377
  applyEvents(events, by) {
2465
2378
  if (!events.length) return;
2466
2379
  const docMetadataIds = /* @__PURE__ */ new Set();
2467
2380
  const docAssetIds = /* @__PURE__ */ new Set();
2468
- const docFrontiersIds = /* @__PURE__ */ new Set();
2469
2381
  const assetIds = /* @__PURE__ */ new Set();
2470
2382
  for (const event of events) {
2471
2383
  const key = event.key;
@@ -2482,15 +2394,11 @@ var FlockHydrator = class {
2482
2394
  const assetId = key[2];
2483
2395
  if (typeof docId === "string") docAssetIds.add(docId);
2484
2396
  if (typeof assetId === "string") assetIds.add(assetId);
2485
- } else if (root === "f") {
2486
- const docId = key[1];
2487
- if (typeof docId === "string") docFrontiersIds.add(docId);
2488
2397
  }
2489
2398
  }
2490
2399
  for (const assetId of assetIds) this.assetManager.refreshAssetMetadataEntry(assetId, by);
2491
2400
  for (const docId of docMetadataIds) this.metadataManager.refreshFromFlock(docId, by);
2492
2401
  for (const docId of docAssetIds) this.assetManager.refreshDocAssetsEntry(docId, by);
2493
- for (const docId of docFrontiersIds) this.docManager.refreshDocFrontierKeys(docId);
2494
2402
  }
2495
2403
  readAllDocMetadata() {
2496
2404
  const nextMetadata = /* @__PURE__ */ new Map();
@@ -2701,8 +2609,7 @@ function createRepoState() {
2701
2609
  docAssets: /* @__PURE__ */ new Map(),
2702
2610
  assets: /* @__PURE__ */ new Map(),
2703
2611
  orphanedAssets: /* @__PURE__ */ new Map(),
2704
- assetToDocRefs: /* @__PURE__ */ new Map(),
2705
- docFrontierKeys: /* @__PURE__ */ new Map()
2612
+ assetToDocRefs: /* @__PURE__ */ new Map()
2706
2613
  };
2707
2614
  }
2708
2615
 
@@ -2738,8 +2645,7 @@ var LoroRepo = class LoroRepo {
2738
2645
  docFrontierDebounceMs,
2739
2646
  getMetaFlock: () => this.metaFlock,
2740
2647
  eventBus: this.eventBus,
2741
- persistMeta: () => this.persistMeta(),
2742
- state: this.state
2648
+ persistMeta: () => this.persistMeta()
2743
2649
  });
2744
2650
  this.metadataManager = new MetadataManager({
2745
2651
  getMetaFlock: () => this.metaFlock,
@@ -2828,7 +2734,7 @@ var LoroRepo = class LoroRepo {
2828
2734
  */
2829
2735
  async openPersistedDoc(docId) {
2830
2736
  return {
2831
- doc: await this.docManager.openCollaborativeDoc(docId),
2737
+ doc: await this.docManager.openPersistedDoc(docId),
2832
2738
  syncOnce: () => {
2833
2739
  return this.sync({
2834
2740
  scope: "doc",