loro-repo 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -6,12 +6,16 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
8
  var __copyProps = (to, from, except, desc) => {
9
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
- key = keys[i];
11
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
- get: ((k) => from[k]).bind(null, key),
13
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
- });
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) {
13
+ __defProp(to, key, {
14
+ get: ((k) => from[k]).bind(null, key),
15
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
16
+ });
17
+ }
18
+ }
15
19
  }
16
20
  return to;
17
21
  };
@@ -22,10 +26,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
26
 
23
27
  //#endregion
24
28
  let __loro_dev_flock = require("@loro-dev/flock");
25
- let loro_crdt = require("loro-crdt");
26
29
  let loro_adaptors = require("loro-adaptors");
27
30
  let loro_protocol = require("loro-protocol");
28
31
  let loro_websocket = require("loro-websocket");
32
+ let loro_crdt = require("loro-crdt");
29
33
  let node_fs = require("node:fs");
30
34
  let node_path = require("node:path");
31
35
  node_path = __toESM(node_path);
@@ -36,8 +40,47 @@ function createRepoFlockAdaptorFromDoc(flock, config = {}) {
36
40
  return new loro_adaptors.FlockAdaptor(flock, config);
37
41
  }
38
42
 
43
+ //#endregion
44
+ //#region src/internal/debug.ts
45
+ const getEnv = () => {
46
+ if (typeof globalThis !== "object" || globalThis === null) return;
47
+ return globalThis.process?.env;
48
+ };
49
+ const rawNamespaceConfig = (getEnv()?.LORO_REPO_DEBUG ?? "").trim();
50
+ const normalizedNamespaces = rawNamespaceConfig.length > 0 ? rawNamespaceConfig.split(/[\s,]+/).map((token) => token.toLowerCase()).filter(Boolean) : [];
51
+ const wildcardTokens = new Set([
52
+ "*",
53
+ "1",
54
+ "true",
55
+ "all"
56
+ ]);
57
+ const namespaceSet = new Set(normalizedNamespaces);
58
+ const hasWildcard = namespaceSet.size > 0 && normalizedNamespaces.some((token) => wildcardTokens.has(token));
59
+ const isDebugEnabled = (namespace) => {
60
+ if (!namespaceSet.size) return false;
61
+ if (!namespace) return hasWildcard;
62
+ const normalized = namespace.toLowerCase();
63
+ if (hasWildcard) return true;
64
+ if (namespaceSet.has(normalized)) return true;
65
+ const [root] = normalized.split(":");
66
+ return namespaceSet.has(root);
67
+ };
68
+ const createDebugLogger = (namespace) => {
69
+ const normalized = namespace.toLowerCase();
70
+ return (...args) => {
71
+ if (!isDebugEnabled(normalized)) return;
72
+ const prefix = `[loro-repo:${namespace}]`;
73
+ if (args.length === 0) {
74
+ console.info(prefix);
75
+ return;
76
+ }
77
+ console.info(prefix, ...args);
78
+ };
79
+ };
80
+
39
81
  //#endregion
40
82
  //#region src/transport/websocket.ts
83
+ const debug = createDebugLogger("transport:websocket");
41
84
  function withTimeout(promise, timeoutMs) {
42
85
  if (!timeoutMs || timeoutMs <= 0) return promise;
43
86
  return new Promise((resolve, reject) => {
@@ -82,30 +125,51 @@ var WebSocketTransportAdapter = class {
82
125
  }
83
126
  async connect(_options) {
84
127
  const client = this.ensureClient();
85
- await client.connect();
86
- await client.waitConnected();
128
+ debug("connect requested", { status: client.getStatus() });
129
+ try {
130
+ await client.connect();
131
+ debug("client.connect resolved");
132
+ await client.waitConnected();
133
+ debug("client.waitConnected resolved", { status: client.getStatus() });
134
+ } catch (error) {
135
+ debug("connect failed", error);
136
+ throw error;
137
+ }
87
138
  }
88
139
  async close() {
140
+ debug("close requested", {
141
+ docSessions: this.docSessions.size,
142
+ metadataSession: Boolean(this.metadataSession)
143
+ });
89
144
  for (const [docId] of this.docSessions) await this.leaveDocSession(docId).catch(() => {});
90
145
  this.docSessions.clear();
91
146
  await this.teardownMetadataSession().catch(() => {});
92
147
  if (this.client) {
93
- this.client.destroy();
148
+ const client = this.client;
94
149
  this.client = void 0;
150
+ client.destroy();
151
+ debug("websocket client destroyed");
95
152
  }
153
+ debug("close completed");
96
154
  }
97
155
  isConnected() {
98
156
  return this.client?.getStatus() === "connected";
99
157
  }
100
158
  async syncMeta(flock, options) {
101
- if (!this.options.metadataRoomId) return { ok: true };
159
+ if (!this.options.metadataRoomId) {
160
+ debug("syncMeta skipped; metadata room not configured");
161
+ return { ok: true };
162
+ }
163
+ debug("syncMeta requested", { roomId: this.options.metadataRoomId });
102
164
  try {
103
165
  await withTimeout((await this.ensureMetadataSession(flock, {
104
166
  roomId: this.options.metadataRoomId,
105
167
  auth: this.options.metadataAuth
106
168
  })).firstSynced, options?.timeout);
169
+ debug("syncMeta completed", { roomId: this.options.metadataRoomId });
107
170
  return { ok: true };
108
- } catch {
171
+ } catch (error) {
172
+ debug("syncMeta failed", error);
109
173
  return { ok: false };
110
174
  }
111
175
  }
@@ -114,6 +178,10 @@ var WebSocketTransportAdapter = class {
114
178
  const roomId = normalizeRoomId(params?.roomId, fallback);
115
179
  if (!roomId) throw new Error("Metadata room id not configured");
116
180
  const auth = params?.auth ?? this.options.metadataAuth;
181
+ debug("joinMetaRoom requested", {
182
+ roomId,
183
+ hasAuth: Boolean(auth && auth.length)
184
+ });
117
185
  const ensure = this.ensureMetadataSession(flock, {
118
186
  roomId,
119
187
  auth
@@ -124,7 +192,14 @@ var WebSocketTransportAdapter = class {
124
192
  unsubscribe: () => {
125
193
  ensure.then((session) => {
126
194
  session.refCount = Math.max(0, session.refCount - 1);
127
- if (session.refCount === 0) this.teardownMetadataSession(session).catch(() => {});
195
+ debug("metadata session refCount decremented", {
196
+ roomId: session.roomId,
197
+ refCount: session.refCount
198
+ });
199
+ if (session.refCount === 0) {
200
+ debug("tearing down metadata session due to refCount=0", { roomId: session.roomId });
201
+ this.teardownMetadataSession(session).catch(() => {});
202
+ }
128
203
  });
129
204
  },
130
205
  firstSyncedWithRemote: firstSynced,
@@ -134,18 +209,37 @@ var WebSocketTransportAdapter = class {
134
209
  };
135
210
  ensure.then((session) => {
136
211
  session.refCount += 1;
212
+ debug("metadata session refCount incremented", {
213
+ roomId: session.roomId,
214
+ refCount: session.refCount
215
+ });
137
216
  });
138
217
  return subscription;
139
218
  }
140
219
  async syncDoc(docId, doc, options) {
220
+ debug("syncDoc requested", { docId });
141
221
  try {
142
- await withTimeout((await this.ensureDocSession(docId, doc, {})).firstSynced, options?.timeout);
222
+ const session = await this.ensureDocSession(docId, doc, {});
223
+ await withTimeout(session.firstSynced, options?.timeout);
224
+ debug("syncDoc completed", {
225
+ docId,
226
+ roomId: session.roomId
227
+ });
143
228
  return { ok: true };
144
- } catch {
229
+ } catch (error) {
230
+ debug("syncDoc failed", {
231
+ docId,
232
+ error
233
+ });
145
234
  return { ok: false };
146
235
  }
147
236
  }
148
237
  joinDocRoom(docId, doc, params) {
238
+ debug("joinDocRoom requested", {
239
+ docId,
240
+ roomParamType: params?.roomId ? typeof params.roomId === "string" ? "string" : "uint8array" : void 0,
241
+ hasAuthOverride: Boolean(params?.auth && params.auth.length)
242
+ });
149
243
  const ensure = this.ensureDocSession(docId, doc, params ?? {});
150
244
  const firstSynced = ensure.then((session) => session.firstSynced);
151
245
  const getConnected = () => this.isConnected();
@@ -153,6 +247,11 @@ var WebSocketTransportAdapter = class {
153
247
  unsubscribe: () => {
154
248
  ensure.then((session) => {
155
249
  session.refCount = Math.max(0, session.refCount - 1);
250
+ debug("doc session refCount decremented", {
251
+ docId,
252
+ roomId: session.roomId,
253
+ refCount: session.refCount
254
+ });
156
255
  if (session.refCount === 0) this.leaveDocSession(docId).catch(() => {});
157
256
  });
158
257
  },
@@ -163,12 +262,24 @@ var WebSocketTransportAdapter = class {
163
262
  };
164
263
  ensure.then((session) => {
165
264
  session.refCount += 1;
265
+ debug("doc session refCount incremented", {
266
+ docId,
267
+ roomId: session.roomId,
268
+ refCount: session.refCount
269
+ });
166
270
  });
167
271
  return subscription;
168
272
  }
169
273
  ensureClient() {
170
- if (this.client) return this.client;
274
+ if (this.client) {
275
+ debug("reusing websocket client", { status: this.client.getStatus() });
276
+ return this.client;
277
+ }
171
278
  const { url, client: clientOptions } = this.options;
279
+ debug("creating websocket client", {
280
+ url,
281
+ clientOptionsKeys: clientOptions ? Object.keys(clientOptions) : []
282
+ });
172
283
  const client = new loro_websocket.LoroWebsocketClient({
173
284
  url,
174
285
  ...clientOptions
@@ -177,22 +288,49 @@ var WebSocketTransportAdapter = class {
177
288
  return client;
178
289
  }
179
290
  async ensureMetadataSession(flock, params) {
291
+ debug("ensureMetadataSession invoked", {
292
+ roomId: params.roomId,
293
+ hasAuth: Boolean(params.auth && params.auth.length)
294
+ });
180
295
  const client = this.ensureClient();
181
296
  await client.waitConnected();
182
- if (this.metadataSession && this.metadataSession.flock === flock && this.metadataSession.roomId === params.roomId && bytesEqual(this.metadataSession.auth, params.auth)) return this.metadataSession;
183
- if (this.metadataSession) await this.teardownMetadataSession(this.metadataSession).catch(() => {});
297
+ debug("websocket client ready for metadata session", { status: client.getStatus() });
298
+ if (this.metadataSession && this.metadataSession.flock === flock && this.metadataSession.roomId === params.roomId && bytesEqual(this.metadataSession.auth, params.auth)) {
299
+ debug("reusing metadata session", {
300
+ roomId: this.metadataSession.roomId,
301
+ refCount: this.metadataSession.refCount
302
+ });
303
+ return this.metadataSession;
304
+ }
305
+ if (this.metadataSession) {
306
+ debug("tearing down previous metadata session", { roomId: this.metadataSession.roomId });
307
+ await this.teardownMetadataSession(this.metadataSession).catch(() => {});
308
+ }
184
309
  const configuredType = this.options.metadataCrdtType;
185
310
  if (configuredType && configuredType !== loro_protocol.CrdtType.Flock) throw new Error(`metadataCrdtType must be ${loro_protocol.CrdtType.Flock} when syncing Flock metadata`);
186
311
  const adaptor = createRepoFlockAdaptorFromDoc(flock, this.options.metadataAdaptorConfig ?? {});
312
+ debug("joining metadata room", {
313
+ roomId: params.roomId,
314
+ hasAuth: Boolean(params.auth && params.auth.length)
315
+ });
187
316
  const room = await client.join({
188
317
  roomId: params.roomId,
189
318
  crdtAdaptor: adaptor,
190
319
  auth: params.auth
191
320
  });
321
+ const firstSynced = room.waitForReachingServerVersion();
322
+ firstSynced.then(() => {
323
+ debug("metadata session firstSynced resolved", { roomId: params.roomId });
324
+ }, (error) => {
325
+ debug("metadata session firstSynced rejected", {
326
+ roomId: params.roomId,
327
+ error
328
+ });
329
+ });
192
330
  const session = {
193
331
  adaptor,
194
332
  room,
195
- firstSynced: room.waitForReachingServerVersion(),
333
+ firstSynced,
196
334
  flock,
197
335
  roomId: params.roomId,
198
336
  auth: params.auth,
@@ -204,34 +342,83 @@ var WebSocketTransportAdapter = class {
204
342
  async teardownMetadataSession(session) {
205
343
  const target = session ?? this.metadataSession;
206
344
  if (!target) return;
345
+ debug("teardownMetadataSession invoked", { roomId: target.roomId });
207
346
  if (this.metadataSession === target) this.metadataSession = void 0;
208
347
  const { adaptor, room } = target;
209
348
  try {
210
349
  await room.leave();
211
- } catch {
350
+ debug("metadata room left", { roomId: target.roomId });
351
+ } catch (error) {
352
+ debug("metadata room leave failed; destroying", {
353
+ roomId: target.roomId,
354
+ error
355
+ });
212
356
  await room.destroy().catch(() => {});
213
357
  }
214
358
  adaptor.destroy();
359
+ debug("metadata session destroyed", { roomId: target.roomId });
215
360
  }
216
361
  async ensureDocSession(docId, doc, params) {
362
+ debug("ensureDocSession invoked", { docId });
217
363
  const client = this.ensureClient();
218
364
  await client.waitConnected();
365
+ debug("websocket client ready for doc session", {
366
+ docId,
367
+ status: client.getStatus()
368
+ });
219
369
  const existing = this.docSessions.get(docId);
220
370
  const derivedRoomId = this.options.docRoomId?.(docId) ?? docId;
221
371
  const roomId = normalizeRoomId(params.roomId, derivedRoomId);
222
372
  const auth = params.auth ?? this.options.docAuth?.(docId);
223
- if (existing && existing.doc === doc && existing.roomId === roomId) return existing;
224
- if (existing) await this.leaveDocSession(docId).catch(() => {});
373
+ debug("doc session params resolved", {
374
+ docId,
375
+ roomId,
376
+ hasAuth: Boolean(auth && auth.length)
377
+ });
378
+ if (existing && existing.doc === doc && existing.roomId === roomId) {
379
+ debug("reusing doc session", {
380
+ docId,
381
+ roomId,
382
+ refCount: existing.refCount
383
+ });
384
+ return existing;
385
+ }
386
+ if (existing) {
387
+ debug("doc session mismatch; leaving existing session", {
388
+ docId,
389
+ previousRoomId: existing.roomId,
390
+ nextRoomId: roomId
391
+ });
392
+ await this.leaveDocSession(docId).catch(() => {});
393
+ }
225
394
  const adaptor = new loro_adaptors.LoroAdaptor(doc);
395
+ debug("joining doc room", {
396
+ docId,
397
+ roomId,
398
+ hasAuth: Boolean(auth && auth.length)
399
+ });
226
400
  const room = await client.join({
227
401
  roomId,
228
402
  crdtAdaptor: adaptor,
229
403
  auth
230
404
  });
405
+ const firstSynced = room.waitForReachingServerVersion();
406
+ firstSynced.then(() => {
407
+ debug("doc session firstSynced resolved", {
408
+ docId,
409
+ roomId
410
+ });
411
+ }, (error) => {
412
+ debug("doc session firstSynced rejected", {
413
+ docId,
414
+ roomId,
415
+ error
416
+ });
417
+ });
231
418
  const session = {
232
419
  adaptor,
233
420
  room,
234
- firstSynced: room.waitForReachingServerVersion(),
421
+ firstSynced,
235
422
  doc,
236
423
  roomId,
237
424
  refCount: 0
@@ -241,14 +428,34 @@ var WebSocketTransportAdapter = class {
241
428
  }
242
429
  async leaveDocSession(docId) {
243
430
  const session = this.docSessions.get(docId);
244
- if (!session) return;
431
+ if (!session) {
432
+ debug("leaveDocSession invoked but no session found", { docId });
433
+ return;
434
+ }
245
435
  this.docSessions.delete(docId);
436
+ debug("leaving doc session", {
437
+ docId,
438
+ roomId: session.roomId
439
+ });
246
440
  try {
247
441
  await session.room.leave();
248
- } catch {
442
+ debug("doc room left", {
443
+ docId,
444
+ roomId: session.roomId
445
+ });
446
+ } catch (error) {
447
+ debug("doc room leave failed; destroying", {
448
+ docId,
449
+ roomId: session.roomId,
450
+ error
451
+ });
249
452
  await session.room.destroy().catch(() => {});
250
453
  }
251
454
  session.adaptor.destroy();
455
+ debug("doc session destroyed", {
456
+ docId,
457
+ roomId: session.roomId
458
+ });
252
459
  }
253
460
  };
254
461
 
@@ -953,24 +1160,6 @@ var RepoEventBus = class {
953
1160
  }
954
1161
  };
955
1162
 
956
- //#endregion
957
- //#region src/internal/doc-handle.ts
958
- var RepoDocHandleImpl = class {
959
- doc;
960
- whenSyncedWithRemote;
961
- docId;
962
- onClose;
963
- constructor(docId, doc, whenSyncedWithRemote, onClose) {
964
- this.docId = docId;
965
- this.doc = doc;
966
- this.whenSyncedWithRemote = whenSyncedWithRemote;
967
- this.onClose = onClose;
968
- }
969
- async close() {
970
- await this.onClose(this.docId, this.doc);
971
- }
972
- };
973
-
974
1163
  //#endregion
975
1164
  //#region src/utils.ts
976
1165
  async function streamToUint8Array(stream) {
@@ -1133,37 +1322,24 @@ function toReadableStream(bytes) {
1133
1322
  controller.close();
1134
1323
  } });
1135
1324
  }
1136
- function emptyFrontiers() {
1137
- return [];
1138
- }
1139
- function getDocFrontiers(doc) {
1140
- const candidate = doc;
1141
- if (typeof candidate.frontiers === "function") {
1142
- const result = candidate.frontiers();
1143
- if (result) return result;
1144
- }
1145
- return emptyFrontiers();
1146
- }
1147
- function versionVectorToJson(vv) {
1148
- const map = vv.toJSON();
1149
- const record = {};
1150
- if (map instanceof Map) {
1151
- const entries = Array.from(map.entries()).sort(([a], [b]) => String(a).localeCompare(String(b)));
1152
- for (const [peer, counter] of entries) {
1153
- if (typeof counter !== "number" || !Number.isFinite(counter)) continue;
1154
- const key = typeof peer === "string" ? peer : JSON.stringify(peer);
1155
- record[key] = counter;
1156
- }
1157
- }
1158
- return record;
1159
- }
1160
- function canonicalizeVersionVector(vv) {
1161
- const json = versionVectorToJson(vv);
1325
+ function canonicalizeFrontiers(frontiers) {
1326
+ const json = [...frontiers].sort((a, b) => {
1327
+ if (a.peer < b.peer) return -1;
1328
+ if (a.peer > b.peer) return 1;
1329
+ return a.counter - b.counter;
1330
+ }).map((f) => ({
1331
+ peer: f.peer,
1332
+ counter: f.counter
1333
+ }));
1162
1334
  return {
1163
1335
  json,
1164
1336
  key: stableStringify(json)
1165
1337
  };
1166
1338
  }
1339
+ function includesFrontiers(vv, frontiers) {
1340
+ for (const { peer, counter } of frontiers) if ((vv.get(peer) ?? 0) <= counter) return false;
1341
+ return true;
1342
+ }
1167
1343
  function matchesQuery(docId, _metadata, query) {
1168
1344
  if (!query) return true;
1169
1345
  if (query.prefix && !docId.startsWith(query.prefix)) return false;
@@ -1185,14 +1361,12 @@ function logAsyncError(context) {
1185
1361
  //#region src/internal/doc-manager.ts
1186
1362
  var DocManager = class {
1187
1363
  storage;
1188
- docFactory;
1189
1364
  docFrontierDebounceMs;
1190
1365
  getMetaFlock;
1191
1366
  eventBus;
1192
1367
  persistMeta;
1193
1368
  state;
1194
1369
  docs = /* @__PURE__ */ new Map();
1195
- docRefs = /* @__PURE__ */ new Map();
1196
1370
  docSubscriptions = /* @__PURE__ */ new Map();
1197
1371
  docFrontierUpdates = /* @__PURE__ */ new Map();
1198
1372
  docPersistedVersions = /* @__PURE__ */ new Map();
@@ -1201,21 +1375,17 @@ var DocManager = class {
1201
1375
  }
1202
1376
  constructor(options) {
1203
1377
  this.storage = options.storage;
1204
- this.docFactory = options.docFactory;
1205
1378
  this.docFrontierDebounceMs = options.docFrontierDebounceMs;
1206
1379
  this.getMetaFlock = options.getMetaFlock;
1207
1380
  this.eventBus = options.eventBus;
1208
1381
  this.persistMeta = options.persistMeta;
1209
1382
  this.state = options.state;
1210
1383
  }
1211
- async openCollaborativeDoc(docId, whenSyncedWithRemote) {
1212
- const doc = await this.ensureDoc(docId);
1213
- const refs = this.docRefs.get(docId) ?? 0;
1214
- this.docRefs.set(docId, refs + 1);
1215
- return new RepoDocHandleImpl(docId, doc, whenSyncedWithRemote, async () => this.onDocHandleClose(docId, doc));
1384
+ async openCollaborativeDoc(docId) {
1385
+ return await this.ensureDoc(docId);
1216
1386
  }
1217
1387
  async openDetachedDoc(docId) {
1218
- return new RepoDocHandleImpl(docId, await this.materializeDetachedDoc(docId), Promise.resolve(), async () => {});
1388
+ return await this.materializeDetachedDoc(docId);
1219
1389
  }
1220
1390
  async ensureDoc(docId) {
1221
1391
  const cached = this.docs.get(docId);
@@ -1231,7 +1401,7 @@ var DocManager = class {
1231
1401
  return stored;
1232
1402
  }
1233
1403
  }
1234
- const created = await this.docFactory(docId);
1404
+ const created = new loro_crdt.LoroDoc();
1235
1405
  this.registerDoc(docId, created);
1236
1406
  return created;
1237
1407
  }
@@ -1257,27 +1427,42 @@ var DocManager = class {
1257
1427
  }
1258
1428
  }
1259
1429
  async updateDocFrontiers(docId, doc, defaultBy) {
1260
- const { json, key } = canonicalizeVersionVector(doc.version());
1430
+ const frontiers = doc.oplogFrontiers();
1431
+ const { json, key } = canonicalizeFrontiers(frontiers);
1261
1432
  const existingKeys = this.docFrontierKeys.get(docId) ?? /* @__PURE__ */ new Set();
1262
1433
  let mutated = false;
1263
- if (existingKeys.size !== 1 || !existingKeys.has(key)) {
1264
- const metaFlock = this.metaFlock;
1265
- for (const entry of existingKeys) metaFlock.delete([
1266
- "f",
1267
- docId,
1268
- entry
1269
- ]);
1434
+ const metaFlock = this.metaFlock;
1435
+ const vv = doc.version();
1436
+ for (const entry of existingKeys) {
1437
+ if (entry === key) continue;
1438
+ let oldFrontiers;
1439
+ try {
1440
+ oldFrontiers = JSON.parse(entry);
1441
+ } catch {
1442
+ continue;
1443
+ }
1444
+ if (includesFrontiers(vv, oldFrontiers)) {
1445
+ metaFlock.delete([
1446
+ "f",
1447
+ docId,
1448
+ entry
1449
+ ]);
1450
+ mutated = true;
1451
+ }
1452
+ }
1453
+ if (!existingKeys.has(key)) {
1270
1454
  metaFlock.put([
1271
1455
  "f",
1272
1456
  docId,
1273
1457
  key
1274
1458
  ], json);
1275
- this.docFrontierKeys.set(docId, new Set([key]));
1276
1459
  mutated = true;
1277
1460
  }
1278
- if (mutated) await this.persistMeta();
1461
+ if (mutated) {
1462
+ this.refreshDocFrontierKeys(docId);
1463
+ await this.persistMeta();
1464
+ }
1279
1465
  const by = this.eventBus.resolveEventBy(defaultBy);
1280
- const frontiers = getDocFrontiers(doc);
1281
1466
  this.eventBus.emit({
1282
1467
  kind: "doc-frontiers",
1283
1468
  docId,
@@ -1298,20 +1483,33 @@ var DocManager = class {
1298
1483
  }
1299
1484
  return true;
1300
1485
  }
1486
+ async unloadDoc(docId) {
1487
+ const doc = this.docs.get(docId);
1488
+ if (!doc) return;
1489
+ await this.flushScheduledDocFrontierUpdate(docId);
1490
+ await this.persistDocUpdate(docId, doc);
1491
+ await this.updateDocFrontiers(docId, doc, "local");
1492
+ this.docSubscriptions.get(docId)?.();
1493
+ this.docSubscriptions.delete(docId);
1494
+ this.docs.delete(docId);
1495
+ this.docPersistedVersions.delete(docId);
1496
+ }
1497
+ async flush() {
1498
+ const promises = [];
1499
+ for (const [docId, doc] of this.docs) promises.push((async () => {
1500
+ await this.persistDocUpdate(docId, doc);
1501
+ await this.flushScheduledDocFrontierUpdate(docId);
1502
+ })());
1503
+ await Promise.all(promises);
1504
+ }
1301
1505
  async close() {
1506
+ await this.flush();
1302
1507
  for (const unsubscribe of this.docSubscriptions.values()) try {
1303
1508
  unsubscribe();
1304
1509
  } catch {}
1305
1510
  this.docSubscriptions.clear();
1306
- const pendingDocIds = Array.from(this.docFrontierUpdates.keys());
1307
- for (const docId of pendingDocIds) try {
1308
- await this.flushScheduledDocFrontierUpdate(docId);
1309
- } catch (error) {
1310
- logAsyncError(`doc ${docId} frontier flush on close`)(error);
1311
- }
1312
1511
  this.docFrontierUpdates.clear();
1313
1512
  this.docs.clear();
1314
- this.docRefs.clear();
1315
1513
  this.docPersistedVersions.clear();
1316
1514
  this.docFrontierKeys.clear();
1317
1515
  }
@@ -1335,6 +1533,7 @@ var DocManager = class {
1335
1533
  const keys = /* @__PURE__ */ new Set();
1336
1534
  for (const row of rows) {
1337
1535
  if (!Array.isArray(row.key) || row.key.length < 3) continue;
1536
+ if (row.value === void 0 || row.value === null) continue;
1338
1537
  const frontierKey = row.key[2];
1339
1538
  if (typeof frontierKey === "string") keys.add(frontierKey);
1340
1539
  }
@@ -1389,22 +1588,10 @@ var DocManager = class {
1389
1588
  }
1390
1589
  })().catch(logAsyncError(`doc ${docId} frontier debounce`));
1391
1590
  }
1392
- async onDocHandleClose(docId, doc) {
1393
- const refs = this.docRefs.get(docId) ?? 0;
1394
- if (refs <= 1) {
1395
- this.docRefs.delete(docId);
1396
- this.docSubscriptions.get(docId)?.();
1397
- this.docSubscriptions.delete(docId);
1398
- this.docs.delete(docId);
1399
- this.docPersistedVersions.delete(docId);
1400
- } else this.docRefs.set(docId, refs - 1);
1401
- await this.persistDocUpdate(docId, doc);
1402
- if (!await this.flushScheduledDocFrontierUpdate(docId)) await this.updateDocFrontiers(docId, doc, "local");
1403
- }
1404
1591
  async materializeDetachedDoc(docId) {
1405
1592
  const snapshot = await this.exportDocSnapshot(docId);
1406
1593
  if (snapshot) return loro_crdt.LoroDoc.fromSnapshot(snapshot);
1407
- return this.docFactory(docId);
1594
+ return new loro_crdt.LoroDoc();
1408
1595
  }
1409
1596
  async exportDocSnapshot(docId) {
1410
1597
  const cached = this.docs.get(docId);
@@ -1414,7 +1601,7 @@ var DocManager = class {
1414
1601
  }
1415
1602
  async persistDocUpdate(docId, doc) {
1416
1603
  const previousVersion = this.docPersistedVersions.get(docId);
1417
- const nextVersion = doc.version();
1604
+ const nextVersion = doc.oplogVersion();
1418
1605
  if (!this.storage) {
1419
1606
  this.docPersistedVersions.set(docId, nextVersion);
1420
1607
  return;
@@ -1424,14 +1611,11 @@ var DocManager = class {
1424
1611
  this.docPersistedVersions.set(docId, nextVersion);
1425
1612
  return;
1426
1613
  }
1614
+ if (previousVersion.compare(nextVersion) === 0) return;
1427
1615
  const update = doc.export({
1428
1616
  mode: "update",
1429
1617
  from: previousVersion
1430
1618
  });
1431
- if (!update.length) {
1432
- this.docPersistedVersions.set(docId, nextVersion);
1433
- return;
1434
- }
1435
1619
  this.docPersistedVersions.set(docId, nextVersion);
1436
1620
  try {
1437
1621
  await this.storage.save({
@@ -1453,7 +1637,14 @@ var DocManager = class {
1453
1637
  return;
1454
1638
  }
1455
1639
  const flushed = this.flushScheduledDocFrontierUpdate(docId);
1456
- const updated = this.updateDocFrontiers(docId, doc, by);
1640
+ const updated = (async () => {
1641
+ this.eventBus.pushEventBy(by);
1642
+ try {
1643
+ await this.updateDocFrontiers(docId, doc, by);
1644
+ } finally {
1645
+ this.eventBus.popEventBy();
1646
+ }
1647
+ })();
1457
1648
  await Promise.all([
1458
1649
  persist,
1459
1650
  flushed,
@@ -1486,17 +1677,38 @@ var MetadataManager = class {
1486
1677
  const metadata = this.state.metadata.get(docId);
1487
1678
  return metadata ? cloneJsonObject(metadata) : void 0;
1488
1679
  }
1489
- list(query) {
1680
+ listDoc(query) {
1681
+ if (query?.limit !== void 0 && query.limit <= 0) return [];
1682
+ const { startKey, endKey } = this.computeDocRangeKeys(query);
1683
+ if (startKey && endKey && startKey >= endKey) return [];
1684
+ const scanOptions = { prefix: ["m"] };
1685
+ if (startKey) scanOptions.start = {
1686
+ kind: "inclusive",
1687
+ key: ["m", startKey]
1688
+ };
1689
+ if (endKey) scanOptions.end = {
1690
+ kind: "exclusive",
1691
+ key: ["m", endKey]
1692
+ };
1693
+ const rows = this.metaFlock.scan(scanOptions);
1694
+ const seen = /* @__PURE__ */ new Set();
1490
1695
  const entries = [];
1491
- for (const [docId, metadata] of this.state.metadata.entries()) {
1696
+ for (const row of rows) {
1697
+ if (query?.limit !== void 0 && entries.length >= query.limit) break;
1698
+ if (!Array.isArray(row.key) || row.key.length < 2) continue;
1699
+ const docId = row.key[1];
1700
+ if (typeof docId !== "string") continue;
1701
+ if (seen.has(docId)) continue;
1702
+ seen.add(docId);
1703
+ const metadata = this.state.metadata.get(docId);
1704
+ if (!metadata) continue;
1492
1705
  if (!matchesQuery(docId, metadata, query)) continue;
1493
1706
  entries.push({
1494
1707
  docId,
1495
1708
  meta: cloneJsonObject(metadata)
1496
1709
  });
1710
+ if (query?.limit !== void 0 && entries.length >= query.limit) break;
1497
1711
  }
1498
- entries.sort((a, b) => a.docId < b.docId ? -1 : a.docId > b.docId ? 1 : 0);
1499
- if (query?.limit !== void 0) return entries.slice(0, query.limit);
1500
1712
  return entries;
1501
1713
  }
1502
1714
  async upsert(docId, patch) {
@@ -1590,6 +1802,26 @@ var MetadataManager = class {
1590
1802
  clear() {
1591
1803
  this.state.metadata.clear();
1592
1804
  }
1805
+ computeDocRangeKeys(query) {
1806
+ if (!query) return {};
1807
+ const prefix = query.prefix && query.prefix.length > 0 ? query.prefix : void 0;
1808
+ let startKey = query.start;
1809
+ if (prefix) startKey = !startKey || prefix > startKey ? prefix : startKey;
1810
+ let endKey = query.end;
1811
+ const prefixEnd = this.nextLexicographicString(prefix);
1812
+ if (prefixEnd) endKey = !endKey || prefixEnd < endKey ? prefixEnd : endKey;
1813
+ return {
1814
+ startKey,
1815
+ endKey
1816
+ };
1817
+ }
1818
+ nextLexicographicString(value) {
1819
+ if (!value) return void 0;
1820
+ for (let i = value.length - 1; i >= 0; i -= 1) {
1821
+ const code = value.charCodeAt(i);
1822
+ if (code < 65535) return `${value.slice(0, i)}${String.fromCharCode(code + 1)}`;
1823
+ }
1824
+ }
1593
1825
  readDocMetadataFromFlock(docId) {
1594
1826
  const rows = this.metaFlock.scan({ prefix: ["m", docId] });
1595
1827
  if (!rows.length) return void 0;
@@ -2328,6 +2560,9 @@ var FlockHydrator = class {
2328
2560
 
2329
2561
  //#endregion
2330
2562
  //#region src/internal/sync-runner.ts
2563
+ /**
2564
+ * Sync data between storage and transport layer
2565
+ */
2331
2566
  var SyncRunner = class {
2332
2567
  storage;
2333
2568
  transport;
@@ -2342,6 +2577,7 @@ var SyncRunner = class {
2342
2577
  readyPromise;
2343
2578
  metaRoomSubscription;
2344
2579
  unsubscribeMetaFlock;
2580
+ docSubscriptions = /* @__PURE__ */ new Map();
2345
2581
  constructor(options) {
2346
2582
  this.storage = options.storage;
2347
2583
  this.transport = options.transport;
@@ -2351,7 +2587,7 @@ var SyncRunner = class {
2351
2587
  this.assetManager = options.assetManager;
2352
2588
  this.flockHydrator = options.flockHydrator;
2353
2589
  this.getMetaFlock = options.getMetaFlock;
2354
- this.replaceMetaFlock = options.replaceMetaFlock;
2590
+ this.replaceMetaFlock = options.mergeFlock;
2355
2591
  this.persistMeta = options.persistMeta;
2356
2592
  }
2357
2593
  async ready() {
@@ -2428,15 +2664,30 @@ var SyncRunner = class {
2428
2664
  await this.ready();
2429
2665
  if (!this.transport) throw new Error("Transport adapter not configured");
2430
2666
  if (!this.transport.isConnected()) await this.transport.connect();
2667
+ const existing = this.docSubscriptions.get(docId);
2668
+ if (existing) return existing;
2431
2669
  const doc = await this.docManager.ensureDoc(docId);
2432
2670
  const subscription = this.transport.joinDocRoom(docId, doc, params);
2671
+ const wrapped = {
2672
+ unsubscribe: () => {
2673
+ subscription.unsubscribe();
2674
+ if (this.docSubscriptions.get(docId) === wrapped) this.docSubscriptions.delete(docId);
2675
+ },
2676
+ firstSyncedWithRemote: subscription.firstSyncedWithRemote,
2677
+ get connected() {
2678
+ return subscription.connected;
2679
+ }
2680
+ };
2681
+ this.docSubscriptions.set(docId, wrapped);
2433
2682
  subscription.firstSyncedWithRemote.catch(logAsyncError(`doc ${docId} first sync`));
2434
- return subscription;
2683
+ return wrapped;
2435
2684
  }
2436
- async close() {
2685
+ async destroy() {
2437
2686
  await this.docManager.close();
2438
2687
  this.metaRoomSubscription?.unsubscribe();
2439
2688
  this.metaRoomSubscription = void 0;
2689
+ for (const sub of this.docSubscriptions.values()) sub.unsubscribe();
2690
+ this.docSubscriptions.clear();
2440
2691
  if (this.unsubscribeMetaFlock) {
2441
2692
  this.unsubscribeMetaFlock();
2442
2693
  this.unsubscribeMetaFlock = void 0;
@@ -2487,16 +2738,17 @@ function createRepoState() {
2487
2738
  //#region src/index.ts
2488
2739
  const textEncoder = new TextEncoder();
2489
2740
  const DEFAULT_DOC_FRONTIER_DEBOUNCE_MS = 1e3;
2490
- var LoroRepo = class {
2741
+ var LoroRepo = class LoroRepo {
2491
2742
  options;
2743
+ _destroyed = false;
2492
2744
  transport;
2493
2745
  storage;
2494
- docFactory;
2495
2746
  metaFlock = new __loro_dev_flock.Flock();
2496
2747
  eventBus;
2497
2748
  docManager;
2498
2749
  metadataManager;
2499
2750
  assetManager;
2751
+ assetTransport;
2500
2752
  flockHydrator;
2501
2753
  state;
2502
2754
  syncRunner;
@@ -2504,14 +2756,13 @@ var LoroRepo = class {
2504
2756
  this.options = options;
2505
2757
  this.transport = options.transportAdapter;
2506
2758
  this.storage = options.storageAdapter;
2507
- this.docFactory = options.docFactory ?? (async () => new loro_crdt.LoroDoc());
2759
+ this.assetTransport = options.assetTransportAdapter;
2508
2760
  this.eventBus = new RepoEventBus();
2509
2761
  this.state = createRepoState();
2510
2762
  const configuredDebounce = options.docFrontierDebounceMs;
2511
2763
  const docFrontierDebounceMs = typeof configuredDebounce === "number" && Number.isFinite(configuredDebounce) && configuredDebounce >= 0 ? configuredDebounce : DEFAULT_DOC_FRONTIER_DEBOUNCE_MS;
2512
2764
  this.docManager = new DocManager({
2513
2765
  storage: this.storage,
2514
- docFactory: this.docFactory,
2515
2766
  docFrontierDebounceMs,
2516
2767
  getMetaFlock: () => this.metaFlock,
2517
2768
  eventBus: this.eventBus,
@@ -2526,7 +2777,7 @@ var LoroRepo = class {
2526
2777
  });
2527
2778
  this.assetManager = new AssetManager({
2528
2779
  storage: this.storage,
2529
- assetTransport: options.assetTransportAdapter,
2780
+ assetTransport: this.assetTransport,
2530
2781
  getMetaFlock: () => this.metaFlock,
2531
2782
  eventBus: this.eventBus,
2532
2783
  persistMeta: () => this.persistMeta(),
@@ -2547,96 +2798,133 @@ var LoroRepo = class {
2547
2798
  assetManager: this.assetManager,
2548
2799
  flockHydrator: this.flockHydrator,
2549
2800
  getMetaFlock: () => this.metaFlock,
2550
- replaceMetaFlock: (snapshot) => {
2551
- this.metaFlock = snapshot;
2801
+ mergeFlock: (snapshot) => {
2802
+ this.metaFlock.merge(snapshot);
2552
2803
  },
2553
2804
  persistMeta: () => this.persistMeta()
2554
2805
  });
2555
2806
  }
2807
+ static async create(options) {
2808
+ const repo = new LoroRepo(options);
2809
+ await repo.storage?.init?.();
2810
+ await repo.ready();
2811
+ return repo;
2812
+ }
2813
+ /**
2814
+ * Load meta from storage.
2815
+ *
2816
+ * You need to call this before all other operations to make the app functioning correctly.
2817
+ * Though we do that implicitly already
2818
+ */
2556
2819
  async ready() {
2557
2820
  await this.syncRunner.ready();
2558
2821
  }
2822
+ /**
2823
+ * Sync selected data via the transport adaptor
2824
+ * @param options
2825
+ */
2559
2826
  async sync(options = {}) {
2560
2827
  await this.syncRunner.sync(options);
2561
2828
  }
2829
+ /**
2830
+ * Start syncing the metadata (Flock) room. It will establish a realtime connection to the transport adaptor.
2831
+ * All changes on the room will be synced to the Flock, and all changes on the Flock will be synced to the room.
2832
+ * @param params
2833
+ * @returns
2834
+ */
2562
2835
  async joinMetaRoom(params) {
2563
2836
  return this.syncRunner.joinMetaRoom(params);
2564
2837
  }
2838
+ /**
2839
+ * Start syncing the given doc. It will establish a realtime connection to the transport adaptor.
2840
+ * All changes on the doc will be synced to the transport, and all changes on the transport will be synced to the doc.
2841
+ *
2842
+ * All the changes on the room will be reflected on the same doc you get from `repo.openCollaborativeDoc(docId)`
2843
+ * @param docId
2844
+ * @param params
2845
+ * @returns
2846
+ */
2565
2847
  async joinDocRoom(docId, params) {
2566
2848
  return this.syncRunner.joinDocRoom(docId, params);
2567
2849
  }
2568
- async close() {
2569
- await this.syncRunner.close();
2850
+ /**
2851
+ * Opens a document that is automatically persisted to the configured storage adapter.
2852
+ *
2853
+ * - Edits are saved to storage (debounced).
2854
+ * - Frontiers are synced to the metadata (Flock).
2855
+ * - Realtime collaboration is NOT enabled by default; use `joinDocRoom` to connect.
2856
+ */
2857
+ async openPersistedDoc(docId) {
2858
+ return {
2859
+ doc: await this.docManager.openCollaborativeDoc(docId),
2860
+ syncOnce: () => {
2861
+ return this.sync({
2862
+ scope: "doc",
2863
+ docIds: [docId]
2864
+ });
2865
+ },
2866
+ joinRoom: (auth) => {
2867
+ return this.syncRunner.joinDocRoom(docId, { auth });
2868
+ }
2869
+ };
2570
2870
  }
2571
- async upsertDocMeta(docId, patch, _options = {}) {
2572
- await this.ready();
2871
+ async upsertDocMeta(docId, patch) {
2573
2872
  await this.metadataManager.upsert(docId, patch);
2574
2873
  }
2575
2874
  async getDocMeta(docId) {
2576
- await this.ready();
2577
2875
  return this.metadataManager.get(docId);
2578
2876
  }
2579
2877
  async listDoc(query) {
2580
- await this.ready();
2581
- return this.metadataManager.list(query);
2878
+ return this.metadataManager.listDoc(query);
2582
2879
  }
2583
- getMetaReplica() {
2880
+ getMeta() {
2584
2881
  return this.metaFlock;
2585
2882
  }
2586
2883
  watch(listener, filter = {}) {
2587
2884
  return this.eventBus.watch(listener, filter);
2588
2885
  }
2589
2886
  /**
2590
- * Opens the repo-managed collaborative document, registers it for persistence,
2591
- * and schedules a doc-level sync so `whenSyncedWithRemote` resolves after remote backfills.
2887
+ * Opens a detached `LoroDoc` snapshot.
2888
+ *
2889
+ * - **No Persistence**: Edits to this document are NOT saved to storage.
2890
+ * - **No Sync**: This document does not participate in realtime updates.
2891
+ * - **Use Case**: Ideal for read-only history inspection, temporary drafts, or conflict resolution without affecting the main state.
2592
2892
  */
2593
- async openCollaborativeDoc(docId) {
2594
- await this.ready();
2595
- const whenSyncedWithRemote = this.whenDocInSyncWithRemote(docId);
2596
- return this.docManager.openCollaborativeDoc(docId, whenSyncedWithRemote);
2893
+ async openDetachedDoc(docId) {
2894
+ return this.docManager.openDetachedDoc(docId);
2597
2895
  }
2598
2896
  /**
2599
- * Opens a detached `LoroDoc` snapshot that never registers with the repo, meaning
2600
- * it neither participates in remote subscriptions nor persists edits back to storage.
2897
+ * Explicitly unloads a document from memory.
2898
+ *
2899
+ * - **Persists Immediately**: Forces a save of the document's current state to storage.
2900
+ * - **Frees Memory**: Removes the document from the internal cache.
2901
+ * - **Note**: If the document is currently being synced (via `joinDocRoom`), you should also unsubscribe from the room to fully release resources.
2601
2902
  */
2602
- async openDetachedDoc(docId) {
2603
- await this.ready();
2604
- return this.docManager.openDetachedDoc(docId);
2903
+ async unloadDoc(docId) {
2904
+ await this.docManager.unloadDoc(docId);
2905
+ }
2906
+ async flush() {
2907
+ await this.docManager.flush();
2605
2908
  }
2606
2909
  async uploadAsset(params) {
2607
- await this.ready();
2608
2910
  return this.assetManager.uploadAsset(params);
2609
2911
  }
2610
- async whenDocInSyncWithRemote(docId) {
2611
- await this.ready();
2612
- await this.docManager.ensureDoc(docId);
2613
- await this.sync({
2614
- scope: "doc",
2615
- docIds: [docId]
2616
- });
2617
- }
2618
2912
  async linkAsset(docId, params) {
2619
- await this.ready();
2620
2913
  return this.assetManager.linkAsset(docId, params);
2621
2914
  }
2622
2915
  async fetchAsset(assetId) {
2623
- await this.ready();
2624
2916
  return this.assetManager.fetchAsset(assetId);
2625
2917
  }
2626
2918
  async unlinkAsset(docId, assetId) {
2627
- await this.ready();
2628
2919
  await this.assetManager.unlinkAsset(docId, assetId);
2629
2920
  }
2630
2921
  async listAssets(docId) {
2631
- await this.ready();
2632
2922
  return this.assetManager.listAssets(docId);
2633
2923
  }
2634
2924
  async ensureAsset(assetId) {
2635
- await this.ready();
2636
2925
  return this.assetManager.ensureAsset(assetId);
2637
2926
  }
2638
2927
  async gcAssets(options = {}) {
2639
- await this.ready();
2640
2928
  return this.assetManager.gcAssets(options);
2641
2929
  }
2642
2930
  async persistMeta() {
@@ -2648,6 +2936,17 @@ var LoroRepo = class {
2648
2936
  update: encoded
2649
2937
  });
2650
2938
  }
2939
+ get destroyed() {
2940
+ return this._destroyed;
2941
+ }
2942
+ async destroy() {
2943
+ if (this._destroyed) return;
2944
+ this._destroyed = true;
2945
+ await this.syncRunner.destroy();
2946
+ this.assetTransport?.close?.();
2947
+ this.storage?.close?.();
2948
+ await this.transport?.close();
2949
+ }
2651
2950
  };
2652
2951
 
2653
2952
  //#endregion