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.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { Flock } from "@loro-dev/flock";
2
- import { LoroDoc } from "loro-crdt";
3
2
  import { FlockAdaptor, LoroAdaptor } from "loro-adaptors";
4
3
  import { CrdtType, bytesToHex } from "loro-protocol";
5
4
  import { LoroWebsocketClient } from "loro-websocket";
5
+ import { LoroDoc } from "loro-crdt";
6
6
  import { promises } from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import { randomUUID } from "node:crypto";
@@ -12,8 +12,47 @@ function createRepoFlockAdaptorFromDoc(flock, config = {}) {
12
12
  return new FlockAdaptor(flock, config);
13
13
  }
14
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);
50
+ };
51
+ };
52
+
15
53
  //#endregion
16
54
  //#region src/transport/websocket.ts
55
+ const debug = createDebugLogger("transport:websocket");
17
56
  function withTimeout(promise, timeoutMs) {
18
57
  if (!timeoutMs || timeoutMs <= 0) return promise;
19
58
  return new Promise((resolve, reject) => {
@@ -58,30 +97,51 @@ var WebSocketTransportAdapter = class {
58
97
  }
59
98
  async connect(_options) {
60
99
  const client = this.ensureClient();
61
- await client.connect();
62
- await client.waitConnected();
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
+ }
63
110
  }
64
111
  async close() {
112
+ debug("close requested", {
113
+ docSessions: this.docSessions.size,
114
+ metadataSession: Boolean(this.metadataSession)
115
+ });
65
116
  for (const [docId] of this.docSessions) await this.leaveDocSession(docId).catch(() => {});
66
117
  this.docSessions.clear();
67
118
  await this.teardownMetadataSession().catch(() => {});
68
119
  if (this.client) {
69
- this.client.destroy();
120
+ const client = this.client;
70
121
  this.client = void 0;
122
+ client.destroy();
123
+ debug("websocket client destroyed");
71
124
  }
125
+ debug("close completed");
72
126
  }
73
127
  isConnected() {
74
128
  return this.client?.getStatus() === "connected";
75
129
  }
76
130
  async syncMeta(flock, options) {
77
- if (!this.options.metadataRoomId) return { ok: true };
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 });
78
136
  try {
79
137
  await withTimeout((await this.ensureMetadataSession(flock, {
80
138
  roomId: this.options.metadataRoomId,
81
139
  auth: this.options.metadataAuth
82
140
  })).firstSynced, options?.timeout);
141
+ debug("syncMeta completed", { roomId: this.options.metadataRoomId });
83
142
  return { ok: true };
84
- } catch {
143
+ } catch (error) {
144
+ debug("syncMeta failed", error);
85
145
  return { ok: false };
86
146
  }
87
147
  }
@@ -90,6 +150,10 @@ var WebSocketTransportAdapter = class {
90
150
  const roomId = normalizeRoomId(params?.roomId, fallback);
91
151
  if (!roomId) throw new Error("Metadata room id not configured");
92
152
  const auth = params?.auth ?? this.options.metadataAuth;
153
+ debug("joinMetaRoom requested", {
154
+ roomId,
155
+ hasAuth: Boolean(auth && auth.length)
156
+ });
93
157
  const ensure = this.ensureMetadataSession(flock, {
94
158
  roomId,
95
159
  auth
@@ -100,7 +164,14 @@ var WebSocketTransportAdapter = class {
100
164
  unsubscribe: () => {
101
165
  ensure.then((session) => {
102
166
  session.refCount = Math.max(0, session.refCount - 1);
103
- if (session.refCount === 0) this.teardownMetadataSession(session).catch(() => {});
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
+ }
104
175
  });
105
176
  },
106
177
  firstSyncedWithRemote: firstSynced,
@@ -110,18 +181,37 @@ var WebSocketTransportAdapter = class {
110
181
  };
111
182
  ensure.then((session) => {
112
183
  session.refCount += 1;
184
+ debug("metadata session refCount incremented", {
185
+ roomId: session.roomId,
186
+ refCount: session.refCount
187
+ });
113
188
  });
114
189
  return subscription;
115
190
  }
116
191
  async syncDoc(docId, doc, options) {
192
+ debug("syncDoc requested", { docId });
117
193
  try {
118
- await withTimeout((await this.ensureDocSession(docId, doc, {})).firstSynced, options?.timeout);
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
+ });
119
200
  return { ok: true };
120
- } catch {
201
+ } catch (error) {
202
+ debug("syncDoc failed", {
203
+ docId,
204
+ error
205
+ });
121
206
  return { ok: false };
122
207
  }
123
208
  }
124
209
  joinDocRoom(docId, doc, params) {
210
+ debug("joinDocRoom requested", {
211
+ docId,
212
+ roomParamType: params?.roomId ? typeof params.roomId === "string" ? "string" : "uint8array" : void 0,
213
+ hasAuthOverride: Boolean(params?.auth && params.auth.length)
214
+ });
125
215
  const ensure = this.ensureDocSession(docId, doc, params ?? {});
126
216
  const firstSynced = ensure.then((session) => session.firstSynced);
127
217
  const getConnected = () => this.isConnected();
@@ -129,6 +219,11 @@ var WebSocketTransportAdapter = class {
129
219
  unsubscribe: () => {
130
220
  ensure.then((session) => {
131
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
+ });
132
227
  if (session.refCount === 0) this.leaveDocSession(docId).catch(() => {});
133
228
  });
134
229
  },
@@ -139,12 +234,24 @@ var WebSocketTransportAdapter = class {
139
234
  };
140
235
  ensure.then((session) => {
141
236
  session.refCount += 1;
237
+ debug("doc session refCount incremented", {
238
+ docId,
239
+ roomId: session.roomId,
240
+ refCount: session.refCount
241
+ });
142
242
  });
143
243
  return subscription;
144
244
  }
145
245
  ensureClient() {
146
- if (this.client) return this.client;
246
+ if (this.client) {
247
+ debug("reusing websocket client", { status: this.client.getStatus() });
248
+ return this.client;
249
+ }
147
250
  const { url, client: clientOptions } = this.options;
251
+ debug("creating websocket client", {
252
+ url,
253
+ clientOptionsKeys: clientOptions ? Object.keys(clientOptions) : []
254
+ });
148
255
  const client = new LoroWebsocketClient({
149
256
  url,
150
257
  ...clientOptions
@@ -153,22 +260,49 @@ var WebSocketTransportAdapter = class {
153
260
  return client;
154
261
  }
155
262
  async ensureMetadataSession(flock, params) {
263
+ debug("ensureMetadataSession invoked", {
264
+ roomId: params.roomId,
265
+ hasAuth: Boolean(params.auth && params.auth.length)
266
+ });
156
267
  const client = this.ensureClient();
157
268
  await client.waitConnected();
158
- if (this.metadataSession && this.metadataSession.flock === flock && this.metadataSession.roomId === params.roomId && bytesEqual(this.metadataSession.auth, params.auth)) return this.metadataSession;
159
- if (this.metadataSession) await this.teardownMetadataSession(this.metadataSession).catch(() => {});
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
+ }
160
281
  const configuredType = this.options.metadataCrdtType;
161
282
  if (configuredType && configuredType !== CrdtType.Flock) throw new Error(`metadataCrdtType must be ${CrdtType.Flock} when syncing Flock metadata`);
162
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
+ });
163
288
  const room = await client.join({
164
289
  roomId: params.roomId,
165
290
  crdtAdaptor: adaptor,
166
291
  auth: params.auth
167
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
+ });
168
302
  const session = {
169
303
  adaptor,
170
304
  room,
171
- firstSynced: room.waitForReachingServerVersion(),
305
+ firstSynced,
172
306
  flock,
173
307
  roomId: params.roomId,
174
308
  auth: params.auth,
@@ -180,34 +314,83 @@ var WebSocketTransportAdapter = class {
180
314
  async teardownMetadataSession(session) {
181
315
  const target = session ?? this.metadataSession;
182
316
  if (!target) return;
317
+ debug("teardownMetadataSession invoked", { roomId: target.roomId });
183
318
  if (this.metadataSession === target) this.metadataSession = void 0;
184
319
  const { adaptor, room } = target;
185
320
  try {
186
321
  await room.leave();
187
- } catch {
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
+ });
188
328
  await room.destroy().catch(() => {});
189
329
  }
190
330
  adaptor.destroy();
331
+ debug("metadata session destroyed", { roomId: target.roomId });
191
332
  }
192
333
  async ensureDocSession(docId, doc, params) {
334
+ debug("ensureDocSession invoked", { docId });
193
335
  const client = this.ensureClient();
194
336
  await client.waitConnected();
337
+ debug("websocket client ready for doc session", {
338
+ docId,
339
+ status: client.getStatus()
340
+ });
195
341
  const existing = this.docSessions.get(docId);
196
342
  const derivedRoomId = this.options.docRoomId?.(docId) ?? docId;
197
343
  const roomId = normalizeRoomId(params.roomId, derivedRoomId);
198
344
  const auth = params.auth ?? this.options.docAuth?.(docId);
199
- if (existing && existing.doc === doc && existing.roomId === roomId) return existing;
200
- if (existing) await this.leaveDocSession(docId).catch(() => {});
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
+ }
201
366
  const adaptor = new LoroAdaptor(doc);
367
+ debug("joining doc room", {
368
+ docId,
369
+ roomId,
370
+ hasAuth: Boolean(auth && auth.length)
371
+ });
202
372
  const room = await client.join({
203
373
  roomId,
204
374
  crdtAdaptor: adaptor,
205
375
  auth
206
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
+ });
207
390
  const session = {
208
391
  adaptor,
209
392
  room,
210
- firstSynced: room.waitForReachingServerVersion(),
393
+ firstSynced,
211
394
  doc,
212
395
  roomId,
213
396
  refCount: 0
@@ -217,14 +400,34 @@ var WebSocketTransportAdapter = class {
217
400
  }
218
401
  async leaveDocSession(docId) {
219
402
  const session = this.docSessions.get(docId);
220
- if (!session) return;
403
+ if (!session) {
404
+ debug("leaveDocSession invoked but no session found", { docId });
405
+ return;
406
+ }
221
407
  this.docSessions.delete(docId);
408
+ debug("leaving doc session", {
409
+ docId,
410
+ roomId: session.roomId
411
+ });
222
412
  try {
223
413
  await session.room.leave();
224
- } catch {
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
+ });
225
424
  await session.room.destroy().catch(() => {});
226
425
  }
227
426
  session.adaptor.destroy();
427
+ debug("doc session destroyed", {
428
+ docId,
429
+ roomId: session.roomId
430
+ });
228
431
  }
229
432
  };
230
433
 
@@ -929,24 +1132,6 @@ var RepoEventBus = class {
929
1132
  }
930
1133
  };
931
1134
 
932
- //#endregion
933
- //#region src/internal/doc-handle.ts
934
- var RepoDocHandleImpl = class {
935
- doc;
936
- whenSyncedWithRemote;
937
- docId;
938
- onClose;
939
- constructor(docId, doc, whenSyncedWithRemote, onClose) {
940
- this.docId = docId;
941
- this.doc = doc;
942
- this.whenSyncedWithRemote = whenSyncedWithRemote;
943
- this.onClose = onClose;
944
- }
945
- async close() {
946
- await this.onClose(this.docId, this.doc);
947
- }
948
- };
949
-
950
1135
  //#endregion
951
1136
  //#region src/utils.ts
952
1137
  async function streamToUint8Array(stream) {
@@ -1109,37 +1294,24 @@ function toReadableStream(bytes) {
1109
1294
  controller.close();
1110
1295
  } });
1111
1296
  }
1112
- function emptyFrontiers() {
1113
- return [];
1114
- }
1115
- function getDocFrontiers(doc) {
1116
- const candidate = doc;
1117
- if (typeof candidate.frontiers === "function") {
1118
- const result = candidate.frontiers();
1119
- if (result) return result;
1120
- }
1121
- return emptyFrontiers();
1122
- }
1123
- function versionVectorToJson(vv) {
1124
- const map = vv.toJSON();
1125
- const record = {};
1126
- if (map instanceof Map) {
1127
- const entries = Array.from(map.entries()).sort(([a], [b]) => String(a).localeCompare(String(b)));
1128
- for (const [peer, counter] of entries) {
1129
- if (typeof counter !== "number" || !Number.isFinite(counter)) continue;
1130
- const key = typeof peer === "string" ? peer : JSON.stringify(peer);
1131
- record[key] = counter;
1132
- }
1133
- }
1134
- return record;
1135
- }
1136
- function canonicalizeVersionVector(vv) {
1137
- const json = versionVectorToJson(vv);
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
+ }));
1138
1306
  return {
1139
1307
  json,
1140
1308
  key: stableStringify(json)
1141
1309
  };
1142
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
+ }
1143
1315
  function matchesQuery(docId, _metadata, query) {
1144
1316
  if (!query) return true;
1145
1317
  if (query.prefix && !docId.startsWith(query.prefix)) return false;
@@ -1161,14 +1333,12 @@ function logAsyncError(context) {
1161
1333
  //#region src/internal/doc-manager.ts
1162
1334
  var DocManager = class {
1163
1335
  storage;
1164
- docFactory;
1165
1336
  docFrontierDebounceMs;
1166
1337
  getMetaFlock;
1167
1338
  eventBus;
1168
1339
  persistMeta;
1169
1340
  state;
1170
1341
  docs = /* @__PURE__ */ new Map();
1171
- docRefs = /* @__PURE__ */ new Map();
1172
1342
  docSubscriptions = /* @__PURE__ */ new Map();
1173
1343
  docFrontierUpdates = /* @__PURE__ */ new Map();
1174
1344
  docPersistedVersions = /* @__PURE__ */ new Map();
@@ -1177,21 +1347,17 @@ var DocManager = class {
1177
1347
  }
1178
1348
  constructor(options) {
1179
1349
  this.storage = options.storage;
1180
- this.docFactory = options.docFactory;
1181
1350
  this.docFrontierDebounceMs = options.docFrontierDebounceMs;
1182
1351
  this.getMetaFlock = options.getMetaFlock;
1183
1352
  this.eventBus = options.eventBus;
1184
1353
  this.persistMeta = options.persistMeta;
1185
1354
  this.state = options.state;
1186
1355
  }
1187
- async openCollaborativeDoc(docId, whenSyncedWithRemote) {
1188
- const doc = await this.ensureDoc(docId);
1189
- const refs = this.docRefs.get(docId) ?? 0;
1190
- this.docRefs.set(docId, refs + 1);
1191
- return new RepoDocHandleImpl(docId, doc, whenSyncedWithRemote, async () => this.onDocHandleClose(docId, doc));
1356
+ async openCollaborativeDoc(docId) {
1357
+ return await this.ensureDoc(docId);
1192
1358
  }
1193
1359
  async openDetachedDoc(docId) {
1194
- return new RepoDocHandleImpl(docId, await this.materializeDetachedDoc(docId), Promise.resolve(), async () => {});
1360
+ return await this.materializeDetachedDoc(docId);
1195
1361
  }
1196
1362
  async ensureDoc(docId) {
1197
1363
  const cached = this.docs.get(docId);
@@ -1207,7 +1373,7 @@ var DocManager = class {
1207
1373
  return stored;
1208
1374
  }
1209
1375
  }
1210
- const created = await this.docFactory(docId);
1376
+ const created = new LoroDoc();
1211
1377
  this.registerDoc(docId, created);
1212
1378
  return created;
1213
1379
  }
@@ -1233,27 +1399,42 @@ var DocManager = class {
1233
1399
  }
1234
1400
  }
1235
1401
  async updateDocFrontiers(docId, doc, defaultBy) {
1236
- const { json, key } = canonicalizeVersionVector(doc.version());
1402
+ const frontiers = doc.oplogFrontiers();
1403
+ const { json, key } = canonicalizeFrontiers(frontiers);
1237
1404
  const existingKeys = this.docFrontierKeys.get(docId) ?? /* @__PURE__ */ new Set();
1238
1405
  let mutated = false;
1239
- if (existingKeys.size !== 1 || !existingKeys.has(key)) {
1240
- const metaFlock = this.metaFlock;
1241
- for (const entry of existingKeys) metaFlock.delete([
1242
- "f",
1243
- docId,
1244
- entry
1245
- ]);
1406
+ 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)) {
1246
1426
  metaFlock.put([
1247
1427
  "f",
1248
1428
  docId,
1249
1429
  key
1250
1430
  ], json);
1251
- this.docFrontierKeys.set(docId, new Set([key]));
1252
1431
  mutated = true;
1253
1432
  }
1254
- if (mutated) await this.persistMeta();
1433
+ if (mutated) {
1434
+ this.refreshDocFrontierKeys(docId);
1435
+ await this.persistMeta();
1436
+ }
1255
1437
  const by = this.eventBus.resolveEventBy(defaultBy);
1256
- const frontiers = getDocFrontiers(doc);
1257
1438
  this.eventBus.emit({
1258
1439
  kind: "doc-frontiers",
1259
1440
  docId,
@@ -1274,20 +1455,33 @@ var DocManager = class {
1274
1455
  }
1275
1456
  return true;
1276
1457
  }
1458
+ async unloadDoc(docId) {
1459
+ const doc = this.docs.get(docId);
1460
+ if (!doc) return;
1461
+ await this.flushScheduledDocFrontierUpdate(docId);
1462
+ await this.persistDocUpdate(docId, doc);
1463
+ await this.updateDocFrontiers(docId, doc, "local");
1464
+ this.docSubscriptions.get(docId)?.();
1465
+ this.docSubscriptions.delete(docId);
1466
+ this.docs.delete(docId);
1467
+ this.docPersistedVersions.delete(docId);
1468
+ }
1469
+ async flush() {
1470
+ const promises$1 = [];
1471
+ for (const [docId, doc] of this.docs) promises$1.push((async () => {
1472
+ await this.persistDocUpdate(docId, doc);
1473
+ await this.flushScheduledDocFrontierUpdate(docId);
1474
+ })());
1475
+ await Promise.all(promises$1);
1476
+ }
1277
1477
  async close() {
1478
+ await this.flush();
1278
1479
  for (const unsubscribe of this.docSubscriptions.values()) try {
1279
1480
  unsubscribe();
1280
1481
  } catch {}
1281
1482
  this.docSubscriptions.clear();
1282
- const pendingDocIds = Array.from(this.docFrontierUpdates.keys());
1283
- for (const docId of pendingDocIds) try {
1284
- await this.flushScheduledDocFrontierUpdate(docId);
1285
- } catch (error) {
1286
- logAsyncError(`doc ${docId} frontier flush on close`)(error);
1287
- }
1288
1483
  this.docFrontierUpdates.clear();
1289
1484
  this.docs.clear();
1290
- this.docRefs.clear();
1291
1485
  this.docPersistedVersions.clear();
1292
1486
  this.docFrontierKeys.clear();
1293
1487
  }
@@ -1311,6 +1505,7 @@ var DocManager = class {
1311
1505
  const keys = /* @__PURE__ */ new Set();
1312
1506
  for (const row of rows) {
1313
1507
  if (!Array.isArray(row.key) || row.key.length < 3) continue;
1508
+ if (row.value === void 0 || row.value === null) continue;
1314
1509
  const frontierKey = row.key[2];
1315
1510
  if (typeof frontierKey === "string") keys.add(frontierKey);
1316
1511
  }
@@ -1365,22 +1560,10 @@ var DocManager = class {
1365
1560
  }
1366
1561
  })().catch(logAsyncError(`doc ${docId} frontier debounce`));
1367
1562
  }
1368
- async onDocHandleClose(docId, doc) {
1369
- const refs = this.docRefs.get(docId) ?? 0;
1370
- if (refs <= 1) {
1371
- this.docRefs.delete(docId);
1372
- this.docSubscriptions.get(docId)?.();
1373
- this.docSubscriptions.delete(docId);
1374
- this.docs.delete(docId);
1375
- this.docPersistedVersions.delete(docId);
1376
- } else this.docRefs.set(docId, refs - 1);
1377
- await this.persistDocUpdate(docId, doc);
1378
- if (!await this.flushScheduledDocFrontierUpdate(docId)) await this.updateDocFrontiers(docId, doc, "local");
1379
- }
1380
1563
  async materializeDetachedDoc(docId) {
1381
1564
  const snapshot = await this.exportDocSnapshot(docId);
1382
1565
  if (snapshot) return LoroDoc.fromSnapshot(snapshot);
1383
- return this.docFactory(docId);
1566
+ return new LoroDoc();
1384
1567
  }
1385
1568
  async exportDocSnapshot(docId) {
1386
1569
  const cached = this.docs.get(docId);
@@ -1390,7 +1573,7 @@ var DocManager = class {
1390
1573
  }
1391
1574
  async persistDocUpdate(docId, doc) {
1392
1575
  const previousVersion = this.docPersistedVersions.get(docId);
1393
- const nextVersion = doc.version();
1576
+ const nextVersion = doc.oplogVersion();
1394
1577
  if (!this.storage) {
1395
1578
  this.docPersistedVersions.set(docId, nextVersion);
1396
1579
  return;
@@ -1400,14 +1583,11 @@ var DocManager = class {
1400
1583
  this.docPersistedVersions.set(docId, nextVersion);
1401
1584
  return;
1402
1585
  }
1586
+ if (previousVersion.compare(nextVersion) === 0) return;
1403
1587
  const update = doc.export({
1404
1588
  mode: "update",
1405
1589
  from: previousVersion
1406
1590
  });
1407
- if (!update.length) {
1408
- this.docPersistedVersions.set(docId, nextVersion);
1409
- return;
1410
- }
1411
1591
  this.docPersistedVersions.set(docId, nextVersion);
1412
1592
  try {
1413
1593
  await this.storage.save({
@@ -1429,7 +1609,14 @@ var DocManager = class {
1429
1609
  return;
1430
1610
  }
1431
1611
  const flushed = this.flushScheduledDocFrontierUpdate(docId);
1432
- const updated = this.updateDocFrontiers(docId, doc, by);
1612
+ const updated = (async () => {
1613
+ this.eventBus.pushEventBy(by);
1614
+ try {
1615
+ await this.updateDocFrontiers(docId, doc, by);
1616
+ } finally {
1617
+ this.eventBus.popEventBy();
1618
+ }
1619
+ })();
1433
1620
  await Promise.all([
1434
1621
  persist,
1435
1622
  flushed,
@@ -1462,17 +1649,38 @@ var MetadataManager = class {
1462
1649
  const metadata = this.state.metadata.get(docId);
1463
1650
  return metadata ? cloneJsonObject(metadata) : void 0;
1464
1651
  }
1465
- list(query) {
1652
+ listDoc(query) {
1653
+ if (query?.limit !== void 0 && query.limit <= 0) return [];
1654
+ const { startKey, endKey } = this.computeDocRangeKeys(query);
1655
+ if (startKey && endKey && startKey >= endKey) return [];
1656
+ const scanOptions = { prefix: ["m"] };
1657
+ if (startKey) scanOptions.start = {
1658
+ kind: "inclusive",
1659
+ key: ["m", startKey]
1660
+ };
1661
+ if (endKey) scanOptions.end = {
1662
+ kind: "exclusive",
1663
+ key: ["m", endKey]
1664
+ };
1665
+ const rows = this.metaFlock.scan(scanOptions);
1666
+ const seen = /* @__PURE__ */ new Set();
1466
1667
  const entries = [];
1467
- for (const [docId, metadata] of this.state.metadata.entries()) {
1668
+ for (const row of rows) {
1669
+ if (query?.limit !== void 0 && entries.length >= query.limit) break;
1670
+ if (!Array.isArray(row.key) || row.key.length < 2) continue;
1671
+ const docId = row.key[1];
1672
+ if (typeof docId !== "string") continue;
1673
+ if (seen.has(docId)) continue;
1674
+ seen.add(docId);
1675
+ const metadata = this.state.metadata.get(docId);
1676
+ if (!metadata) continue;
1468
1677
  if (!matchesQuery(docId, metadata, query)) continue;
1469
1678
  entries.push({
1470
1679
  docId,
1471
1680
  meta: cloneJsonObject(metadata)
1472
1681
  });
1682
+ if (query?.limit !== void 0 && entries.length >= query.limit) break;
1473
1683
  }
1474
- entries.sort((a, b) => a.docId < b.docId ? -1 : a.docId > b.docId ? 1 : 0);
1475
- if (query?.limit !== void 0) return entries.slice(0, query.limit);
1476
1684
  return entries;
1477
1685
  }
1478
1686
  async upsert(docId, patch) {
@@ -1566,6 +1774,26 @@ var MetadataManager = class {
1566
1774
  clear() {
1567
1775
  this.state.metadata.clear();
1568
1776
  }
1777
+ computeDocRangeKeys(query) {
1778
+ if (!query) return {};
1779
+ const prefix = query.prefix && query.prefix.length > 0 ? query.prefix : void 0;
1780
+ let startKey = query.start;
1781
+ if (prefix) startKey = !startKey || prefix > startKey ? prefix : startKey;
1782
+ let endKey = query.end;
1783
+ const prefixEnd = this.nextLexicographicString(prefix);
1784
+ if (prefixEnd) endKey = !endKey || prefixEnd < endKey ? prefixEnd : endKey;
1785
+ return {
1786
+ startKey,
1787
+ endKey
1788
+ };
1789
+ }
1790
+ nextLexicographicString(value) {
1791
+ if (!value) return void 0;
1792
+ for (let i = value.length - 1; i >= 0; i -= 1) {
1793
+ const code = value.charCodeAt(i);
1794
+ if (code < 65535) return `${value.slice(0, i)}${String.fromCharCode(code + 1)}`;
1795
+ }
1796
+ }
1569
1797
  readDocMetadataFromFlock(docId) {
1570
1798
  const rows = this.metaFlock.scan({ prefix: ["m", docId] });
1571
1799
  if (!rows.length) return void 0;
@@ -2304,6 +2532,9 @@ var FlockHydrator = class {
2304
2532
 
2305
2533
  //#endregion
2306
2534
  //#region src/internal/sync-runner.ts
2535
+ /**
2536
+ * Sync data between storage and transport layer
2537
+ */
2307
2538
  var SyncRunner = class {
2308
2539
  storage;
2309
2540
  transport;
@@ -2318,6 +2549,7 @@ var SyncRunner = class {
2318
2549
  readyPromise;
2319
2550
  metaRoomSubscription;
2320
2551
  unsubscribeMetaFlock;
2552
+ docSubscriptions = /* @__PURE__ */ new Map();
2321
2553
  constructor(options) {
2322
2554
  this.storage = options.storage;
2323
2555
  this.transport = options.transport;
@@ -2327,7 +2559,7 @@ var SyncRunner = class {
2327
2559
  this.assetManager = options.assetManager;
2328
2560
  this.flockHydrator = options.flockHydrator;
2329
2561
  this.getMetaFlock = options.getMetaFlock;
2330
- this.replaceMetaFlock = options.replaceMetaFlock;
2562
+ this.replaceMetaFlock = options.mergeFlock;
2331
2563
  this.persistMeta = options.persistMeta;
2332
2564
  }
2333
2565
  async ready() {
@@ -2404,15 +2636,30 @@ var SyncRunner = class {
2404
2636
  await this.ready();
2405
2637
  if (!this.transport) throw new Error("Transport adapter not configured");
2406
2638
  if (!this.transport.isConnected()) await this.transport.connect();
2639
+ const existing = this.docSubscriptions.get(docId);
2640
+ if (existing) return existing;
2407
2641
  const doc = await this.docManager.ensureDoc(docId);
2408
2642
  const subscription = this.transport.joinDocRoom(docId, doc, params);
2643
+ const wrapped = {
2644
+ unsubscribe: () => {
2645
+ subscription.unsubscribe();
2646
+ if (this.docSubscriptions.get(docId) === wrapped) this.docSubscriptions.delete(docId);
2647
+ },
2648
+ firstSyncedWithRemote: subscription.firstSyncedWithRemote,
2649
+ get connected() {
2650
+ return subscription.connected;
2651
+ }
2652
+ };
2653
+ this.docSubscriptions.set(docId, wrapped);
2409
2654
  subscription.firstSyncedWithRemote.catch(logAsyncError(`doc ${docId} first sync`));
2410
- return subscription;
2655
+ return wrapped;
2411
2656
  }
2412
- async close() {
2657
+ async destroy() {
2413
2658
  await this.docManager.close();
2414
2659
  this.metaRoomSubscription?.unsubscribe();
2415
2660
  this.metaRoomSubscription = void 0;
2661
+ for (const sub of this.docSubscriptions.values()) sub.unsubscribe();
2662
+ this.docSubscriptions.clear();
2416
2663
  if (this.unsubscribeMetaFlock) {
2417
2664
  this.unsubscribeMetaFlock();
2418
2665
  this.unsubscribeMetaFlock = void 0;
@@ -2463,16 +2710,17 @@ function createRepoState() {
2463
2710
  //#region src/index.ts
2464
2711
  const textEncoder = new TextEncoder();
2465
2712
  const DEFAULT_DOC_FRONTIER_DEBOUNCE_MS = 1e3;
2466
- var LoroRepo = class {
2713
+ var LoroRepo = class LoroRepo {
2467
2714
  options;
2715
+ _destroyed = false;
2468
2716
  transport;
2469
2717
  storage;
2470
- docFactory;
2471
2718
  metaFlock = new Flock();
2472
2719
  eventBus;
2473
2720
  docManager;
2474
2721
  metadataManager;
2475
2722
  assetManager;
2723
+ assetTransport;
2476
2724
  flockHydrator;
2477
2725
  state;
2478
2726
  syncRunner;
@@ -2480,14 +2728,13 @@ var LoroRepo = class {
2480
2728
  this.options = options;
2481
2729
  this.transport = options.transportAdapter;
2482
2730
  this.storage = options.storageAdapter;
2483
- this.docFactory = options.docFactory ?? (async () => new LoroDoc());
2731
+ this.assetTransport = options.assetTransportAdapter;
2484
2732
  this.eventBus = new RepoEventBus();
2485
2733
  this.state = createRepoState();
2486
2734
  const configuredDebounce = options.docFrontierDebounceMs;
2487
2735
  const docFrontierDebounceMs = typeof configuredDebounce === "number" && Number.isFinite(configuredDebounce) && configuredDebounce >= 0 ? configuredDebounce : DEFAULT_DOC_FRONTIER_DEBOUNCE_MS;
2488
2736
  this.docManager = new DocManager({
2489
2737
  storage: this.storage,
2490
- docFactory: this.docFactory,
2491
2738
  docFrontierDebounceMs,
2492
2739
  getMetaFlock: () => this.metaFlock,
2493
2740
  eventBus: this.eventBus,
@@ -2502,7 +2749,7 @@ var LoroRepo = class {
2502
2749
  });
2503
2750
  this.assetManager = new AssetManager({
2504
2751
  storage: this.storage,
2505
- assetTransport: options.assetTransportAdapter,
2752
+ assetTransport: this.assetTransport,
2506
2753
  getMetaFlock: () => this.metaFlock,
2507
2754
  eventBus: this.eventBus,
2508
2755
  persistMeta: () => this.persistMeta(),
@@ -2523,96 +2770,133 @@ var LoroRepo = class {
2523
2770
  assetManager: this.assetManager,
2524
2771
  flockHydrator: this.flockHydrator,
2525
2772
  getMetaFlock: () => this.metaFlock,
2526
- replaceMetaFlock: (snapshot) => {
2527
- this.metaFlock = snapshot;
2773
+ mergeFlock: (snapshot) => {
2774
+ this.metaFlock.merge(snapshot);
2528
2775
  },
2529
2776
  persistMeta: () => this.persistMeta()
2530
2777
  });
2531
2778
  }
2779
+ static async create(options) {
2780
+ const repo = new LoroRepo(options);
2781
+ await repo.storage?.init?.();
2782
+ await repo.ready();
2783
+ return repo;
2784
+ }
2785
+ /**
2786
+ * Load meta from storage.
2787
+ *
2788
+ * You need to call this before all other operations to make the app functioning correctly.
2789
+ * Though we do that implicitly already
2790
+ */
2532
2791
  async ready() {
2533
2792
  await this.syncRunner.ready();
2534
2793
  }
2794
+ /**
2795
+ * Sync selected data via the transport adaptor
2796
+ * @param options
2797
+ */
2535
2798
  async sync(options = {}) {
2536
2799
  await this.syncRunner.sync(options);
2537
2800
  }
2801
+ /**
2802
+ * Start syncing the metadata (Flock) room. It will establish a realtime connection to the transport adaptor.
2803
+ * All changes on the room will be synced to the Flock, and all changes on the Flock will be synced to the room.
2804
+ * @param params
2805
+ * @returns
2806
+ */
2538
2807
  async joinMetaRoom(params) {
2539
2808
  return this.syncRunner.joinMetaRoom(params);
2540
2809
  }
2810
+ /**
2811
+ * Start syncing the given doc. It will establish a realtime connection to the transport adaptor.
2812
+ * All changes on the doc will be synced to the transport, and all changes on the transport will be synced to the doc.
2813
+ *
2814
+ * All the changes on the room will be reflected on the same doc you get from `repo.openCollaborativeDoc(docId)`
2815
+ * @param docId
2816
+ * @param params
2817
+ * @returns
2818
+ */
2541
2819
  async joinDocRoom(docId, params) {
2542
2820
  return this.syncRunner.joinDocRoom(docId, params);
2543
2821
  }
2544
- async close() {
2545
- await this.syncRunner.close();
2822
+ /**
2823
+ * Opens a document that is automatically persisted to the configured storage adapter.
2824
+ *
2825
+ * - Edits are saved to storage (debounced).
2826
+ * - Frontiers are synced to the metadata (Flock).
2827
+ * - Realtime collaboration is NOT enabled by default; use `joinDocRoom` to connect.
2828
+ */
2829
+ async openPersistedDoc(docId) {
2830
+ return {
2831
+ doc: await this.docManager.openCollaborativeDoc(docId),
2832
+ syncOnce: () => {
2833
+ return this.sync({
2834
+ scope: "doc",
2835
+ docIds: [docId]
2836
+ });
2837
+ },
2838
+ joinRoom: (auth) => {
2839
+ return this.syncRunner.joinDocRoom(docId, { auth });
2840
+ }
2841
+ };
2546
2842
  }
2547
- async upsertDocMeta(docId, patch, _options = {}) {
2548
- await this.ready();
2843
+ async upsertDocMeta(docId, patch) {
2549
2844
  await this.metadataManager.upsert(docId, patch);
2550
2845
  }
2551
2846
  async getDocMeta(docId) {
2552
- await this.ready();
2553
2847
  return this.metadataManager.get(docId);
2554
2848
  }
2555
2849
  async listDoc(query) {
2556
- await this.ready();
2557
- return this.metadataManager.list(query);
2850
+ return this.metadataManager.listDoc(query);
2558
2851
  }
2559
- getMetaReplica() {
2852
+ getMeta() {
2560
2853
  return this.metaFlock;
2561
2854
  }
2562
2855
  watch(listener, filter = {}) {
2563
2856
  return this.eventBus.watch(listener, filter);
2564
2857
  }
2565
2858
  /**
2566
- * Opens the repo-managed collaborative document, registers it for persistence,
2567
- * and schedules a doc-level sync so `whenSyncedWithRemote` resolves after remote backfills.
2859
+ * Opens a detached `LoroDoc` snapshot.
2860
+ *
2861
+ * - **No Persistence**: Edits to this document are NOT saved to storage.
2862
+ * - **No Sync**: This document does not participate in realtime updates.
2863
+ * - **Use Case**: Ideal for read-only history inspection, temporary drafts, or conflict resolution without affecting the main state.
2568
2864
  */
2569
- async openCollaborativeDoc(docId) {
2570
- await this.ready();
2571
- const whenSyncedWithRemote = this.whenDocInSyncWithRemote(docId);
2572
- return this.docManager.openCollaborativeDoc(docId, whenSyncedWithRemote);
2865
+ async openDetachedDoc(docId) {
2866
+ return this.docManager.openDetachedDoc(docId);
2573
2867
  }
2574
2868
  /**
2575
- * Opens a detached `LoroDoc` snapshot that never registers with the repo, meaning
2576
- * it neither participates in remote subscriptions nor persists edits back to storage.
2869
+ * Explicitly unloads a document from memory.
2870
+ *
2871
+ * - **Persists Immediately**: Forces a save of the document's current state to storage.
2872
+ * - **Frees Memory**: Removes the document from the internal cache.
2873
+ * - **Note**: If the document is currently being synced (via `joinDocRoom`), you should also unsubscribe from the room to fully release resources.
2577
2874
  */
2578
- async openDetachedDoc(docId) {
2579
- await this.ready();
2580
- return this.docManager.openDetachedDoc(docId);
2875
+ async unloadDoc(docId) {
2876
+ await this.docManager.unloadDoc(docId);
2877
+ }
2878
+ async flush() {
2879
+ await this.docManager.flush();
2581
2880
  }
2582
2881
  async uploadAsset(params) {
2583
- await this.ready();
2584
2882
  return this.assetManager.uploadAsset(params);
2585
2883
  }
2586
- async whenDocInSyncWithRemote(docId) {
2587
- await this.ready();
2588
- await this.docManager.ensureDoc(docId);
2589
- await this.sync({
2590
- scope: "doc",
2591
- docIds: [docId]
2592
- });
2593
- }
2594
2884
  async linkAsset(docId, params) {
2595
- await this.ready();
2596
2885
  return this.assetManager.linkAsset(docId, params);
2597
2886
  }
2598
2887
  async fetchAsset(assetId) {
2599
- await this.ready();
2600
2888
  return this.assetManager.fetchAsset(assetId);
2601
2889
  }
2602
2890
  async unlinkAsset(docId, assetId) {
2603
- await this.ready();
2604
2891
  await this.assetManager.unlinkAsset(docId, assetId);
2605
2892
  }
2606
2893
  async listAssets(docId) {
2607
- await this.ready();
2608
2894
  return this.assetManager.listAssets(docId);
2609
2895
  }
2610
2896
  async ensureAsset(assetId) {
2611
- await this.ready();
2612
2897
  return this.assetManager.ensureAsset(assetId);
2613
2898
  }
2614
2899
  async gcAssets(options = {}) {
2615
- await this.ready();
2616
2900
  return this.assetManager.gcAssets(options);
2617
2901
  }
2618
2902
  async persistMeta() {
@@ -2624,6 +2908,17 @@ var LoroRepo = class {
2624
2908
  update: encoded
2625
2909
  });
2626
2910
  }
2911
+ get destroyed() {
2912
+ return this._destroyed;
2913
+ }
2914
+ async destroy() {
2915
+ if (this._destroyed) return;
2916
+ this._destroyed = true;
2917
+ await this.syncRunner.destroy();
2918
+ this.assetTransport?.close?.();
2919
+ this.storage?.close?.();
2920
+ await this.transport?.close();
2921
+ }
2627
2922
  };
2628
2923
 
2629
2924
  //#endregion