loro-repo 0.5.3 → 0.7.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
@@ -40,7 +40,7 @@ var RepoEventBus = class {
40
40
  if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
41
41
  if (filter.by && !filter.by.includes(event.by)) return false;
42
42
  const docId = (() => {
43
- if (event.kind === "doc-metadata" || event.kind === "doc-frontiers") return event.docId;
43
+ if (event.kind === "doc-metadata" || event.kind === "doc-frontiers" || event.kind === "doc-soft-deleted" || event.kind === "doc-purging") return event.docId;
44
44
  if (event.kind === "asset-link" || event.kind === "asset-unlink") return event.docId;
45
45
  })();
46
46
  if (filter.docIds && docId && !filter.docIds.includes(docId)) return false;
@@ -177,6 +177,18 @@ var DocManager = class {
177
177
  this.docs.delete(docId);
178
178
  this.docPersistedVersions.delete(docId);
179
179
  }
180
+ async dropDoc(docId) {
181
+ const pending = this.docFrontierUpdates.get(docId);
182
+ if (pending) {
183
+ clearTimeout(pending.timeout);
184
+ this.docFrontierUpdates.delete(docId);
185
+ }
186
+ this.docSubscriptions.get(docId)?.();
187
+ this.docSubscriptions.delete(docId);
188
+ this.docs.delete(docId);
189
+ this.docPersistedVersions.delete(docId);
190
+ if (this.storage?.deleteDoc) await this.storage.deleteDoc(docId);
191
+ }
180
192
  async flush() {
181
193
  const promises = [];
182
194
  for (const [docId, doc] of this.docs) promises.push((async () => {
@@ -504,13 +516,44 @@ var MetadataManager = class {
504
516
  this.state = options.state;
505
517
  }
506
518
  getDocIds() {
507
- return Array.from(this.state.metadata.keys());
519
+ const ids = new Set(this.state.metadata.keys());
520
+ const tombstoneRows = this.metaFlock.scan({ prefix: ["ts"] });
521
+ for (const row of tombstoneRows) {
522
+ if (!Array.isArray(row.key) || row.key.length < 2) continue;
523
+ const docId = row.key[1];
524
+ if (typeof docId === "string") ids.add(docId);
525
+ }
526
+ return Array.from(ids);
508
527
  }
509
528
  entries() {
510
529
  return this.state.metadata.entries();
511
530
  }
512
531
  get(docId) {
513
- return this.state.metadata.get(docId);
532
+ const metadata = this.state.metadata.get(docId);
533
+ const deletedAtMs = this.readDeletedAtFromFlock(docId);
534
+ if (deletedAtMs === void 0 && this.isDocKeyspaceEmpty(docId) || !metadata || deletedAtMs === void 0 && this.isEmpty(metadata)) {
535
+ if (deletedAtMs === void 0) return void 0;
536
+ return {
537
+ meta: {},
538
+ deletedAtMs
539
+ };
540
+ }
541
+ return {
542
+ meta: cloneJsonObject(metadata),
543
+ deletedAtMs
544
+ };
545
+ }
546
+ getDeletedAtMs(docId) {
547
+ return this.readDeletedAtFromFlock(docId);
548
+ }
549
+ markDeleted(docId, deletedAtMs) {
550
+ this.metaFlock.put(["ts", docId], deletedAtMs);
551
+ this.emitSoftDeleted(docId, deletedAtMs, "local");
552
+ }
553
+ clearDeleted(docId) {
554
+ const existing = this.readDeletedAtFromFlock(docId);
555
+ this.metaFlock.delete(["ts", docId]);
556
+ if (existing !== void 0) this.emitSoftDeleted(docId, void 0, "local");
514
557
  }
515
558
  listDoc(query) {
516
559
  if (query?.limit !== void 0 && query.limit <= 0) return [];
@@ -525,25 +568,49 @@ var MetadataManager = class {
525
568
  kind: "exclusive",
526
569
  key: ["m", endKey]
527
570
  };
528
- const rows = this.metaFlock.scan(scanOptions);
529
571
  const seen = /* @__PURE__ */ new Set();
530
572
  const entries = [];
573
+ const pushDoc = (docId) => {
574
+ if (seen.has(docId)) return;
575
+ const metadata = this.state.metadata.get(docId);
576
+ const deletedAtMs = this.readDeletedAtFromFlock(docId);
577
+ if (deletedAtMs === void 0 && this.isDocKeyspaceEmpty(docId) || this.isEmpty(metadata) && deletedAtMs === void 0) return;
578
+ if (!matchesQuery(docId, metadata ?? {}, query)) return;
579
+ seen.add(docId);
580
+ entries.push({
581
+ docId,
582
+ deletedAtMs,
583
+ meta: cloneJsonObject(metadata ?? {})
584
+ });
585
+ };
586
+ const rows = this.metaFlock.scan(scanOptions);
531
587
  for (const row of rows) {
532
588
  if (query?.limit !== void 0 && entries.length >= query.limit) break;
533
589
  if (!Array.isArray(row.key) || row.key.length < 2) continue;
534
590
  const docId = row.key[1];
535
591
  if (typeof docId !== "string") continue;
536
- if (seen.has(docId)) continue;
537
- seen.add(docId);
538
- const metadata = this.state.metadata.get(docId);
539
- if (!metadata) continue;
540
- if (!matchesQuery(docId, metadata, query)) continue;
541
- entries.push({
542
- docId,
543
- meta: cloneJsonObject(metadata)
544
- });
592
+ pushDoc(docId);
545
593
  if (query?.limit !== void 0 && entries.length >= query.limit) break;
546
594
  }
595
+ if (query?.limit === void 0 || entries.length < query.limit) {
596
+ const tsScan = { prefix: ["ts"] };
597
+ if (startKey) tsScan.start = {
598
+ kind: "inclusive",
599
+ key: ["ts", startKey]
600
+ };
601
+ if (endKey) tsScan.end = {
602
+ kind: "exclusive",
603
+ key: ["ts", endKey]
604
+ };
605
+ const tsRows = this.metaFlock.scan(tsScan);
606
+ for (const row of tsRows) {
607
+ if (query?.limit !== void 0 && entries.length >= query.limit) break;
608
+ if (!Array.isArray(row.key) || row.key.length < 2) continue;
609
+ const docId = row.key[1];
610
+ if (typeof docId !== "string") continue;
611
+ pushDoc(docId);
612
+ }
613
+ }
547
614
  return entries;
548
615
  }
549
616
  async upsert(docId, patch) {
@@ -556,11 +623,10 @@ var MetadataManager = class {
556
623
  const rawValue = patchObject[key];
557
624
  if (rawValue === void 0) continue;
558
625
  if (jsonEquals(base ? base[key] : void 0, rawValue)) continue;
559
- const storageKey = key === "tombstone" ? "$tombstone" : key;
560
626
  this.metaFlock.put([
561
627
  "m",
562
628
  docId,
563
- storageKey
629
+ key
564
630
  ], rawValue);
565
631
  next[key] = rawValue;
566
632
  outPatch[key] = rawValue;
@@ -579,10 +645,10 @@ var MetadataManager = class {
579
645
  });
580
646
  }
581
647
  refreshFromFlock(docId, by) {
582
- const previous = this.state.metadata.get(docId);
583
- const next = this.readDocMetadataFromFlock(docId);
584
- if (!next) {
585
- if (previous) {
648
+ const previousMeta = this.state.metadata.get(docId);
649
+ const nextMeta = this.readDocMetadataFromFlock(docId);
650
+ if (!nextMeta) {
651
+ if (previousMeta) {
586
652
  this.state.metadata.delete(docId);
587
653
  this.eventBus.emit({
588
654
  kind: "doc-metadata",
@@ -593,9 +659,9 @@ var MetadataManager = class {
593
659
  }
594
660
  return;
595
661
  }
596
- this.state.metadata.set(docId, next);
597
- const patch = diffJsonObjects(previous, next);
598
- if (!previous || Object.keys(patch).length > 0) this.eventBus.emit({
662
+ this.state.metadata.set(docId, nextMeta);
663
+ const patch = diffJsonObjects(previousMeta, nextMeta);
664
+ if (!previousMeta || Object.keys(patch).length > 0) this.eventBus.emit({
599
665
  kind: "doc-metadata",
600
666
  docId,
601
667
  patch,
@@ -611,12 +677,16 @@ var MetadataManager = class {
611
677
  const previous = prevMetadata.get(docId);
612
678
  const current = nextMetadata.get(docId);
613
679
  if (!current) {
614
- if (previous) this.eventBus.emit({
615
- kind: "doc-metadata",
616
- docId,
617
- patch: {},
618
- by
619
- });
680
+ if (previous) {
681
+ const patch$1 = {};
682
+ for (const key of Object.keys(previous)) patch$1[key] = null;
683
+ this.eventBus.emit({
684
+ kind: "doc-metadata",
685
+ docId,
686
+ patch: patch$1,
687
+ by
688
+ });
689
+ }
620
690
  continue;
621
691
  }
622
692
  const patch = diffJsonObjects(previous, current);
@@ -631,6 +701,22 @@ var MetadataManager = class {
631
701
  clear() {
632
702
  this.state.metadata.clear();
633
703
  }
704
+ purgeMetadata(docId, by) {
705
+ if (this.state.metadata.delete(docId)) this.eventBus.emit({
706
+ kind: "doc-metadata",
707
+ docId,
708
+ patch: {},
709
+ by
710
+ });
711
+ }
712
+ emitSoftDeleted(docId, deletedAtMs, by) {
713
+ this.eventBus.emit({
714
+ kind: "doc-soft-deleted",
715
+ docId,
716
+ deletedAtMs,
717
+ by
718
+ });
719
+ }
634
720
  computeDocRangeKeys(query) {
635
721
  if (!query) return {};
636
722
  const prefix = query.prefix && query.prefix.length > 0 ? query.prefix : void 0;
@@ -656,7 +742,9 @@ var MetadataManager = class {
656
742
  if (!rows.length) return void 0;
657
743
  const docMeta = {};
658
744
  let populated = false;
745
+ let sawRow = false;
659
746
  for (const row of rows) {
747
+ sawRow = true;
660
748
  if (!Array.isArray(row.key) || row.key.length < 2) continue;
661
749
  if (row.key.length === 2) {
662
750
  const obj = asJsonObject(row.value);
@@ -672,17 +760,33 @@ var MetadataManager = class {
672
760
  }
673
761
  const fieldKey = row.key[2];
674
762
  if (typeof fieldKey !== "string") continue;
675
- if (fieldKey === "$tombstone") {
676
- docMeta.tombstone = Boolean(row.value);
677
- populated = true;
678
- continue;
679
- }
680
763
  const jsonValue = cloneJsonValue(row.value);
681
764
  if (jsonValue === void 0) continue;
682
765
  docMeta[fieldKey] = jsonValue;
683
766
  populated = true;
684
767
  }
685
- return populated ? docMeta : void 0;
768
+ if (populated) return docMeta;
769
+ if (sawRow) return {};
770
+ }
771
+ readDeletedAtFromFlock(docId) {
772
+ const raw = this.metaFlock.get(["ts", docId]);
773
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
774
+ }
775
+ isEmpty(metadata) {
776
+ return !metadata || Object.keys(metadata).length === 0;
777
+ }
778
+ isDocKeyspaceEmpty(docId) {
779
+ if (this.state.metadata.has(docId)) return false;
780
+ const hasTruthyKey = (prefix) => {
781
+ const rows = this.metaFlock.scan({ prefix });
782
+ for (const row of rows) if (row.value !== void 0) return true;
783
+ return false;
784
+ };
785
+ if (this.metaFlock.get(["ts", docId]) !== void 0) return false;
786
+ if (hasTruthyKey(["m", docId])) return false;
787
+ if (hasTruthyKey(["f", docId])) return false;
788
+ if (hasTruthyKey(["ld", docId])) return false;
789
+ return true;
686
790
  }
687
791
  get metaFlock() {
688
792
  return this.getMetaFlock();
@@ -921,6 +1025,11 @@ var AssetManager = class {
921
1025
  by: "local"
922
1026
  });
923
1027
  }
1028
+ purgeDocLinks(docId, by) {
1029
+ const keys = Array.from(this.metaFlock.scan({ prefix: ["ld", docId] }), (row) => row.key);
1030
+ for (const key of keys) this.metaFlock.delete(key);
1031
+ this.refreshDocAssetsEntry(docId, by);
1032
+ }
924
1033
  async listAssets(docId) {
925
1034
  const mapping = this.docAssets.get(docId);
926
1035
  if (!mapping) return [];
@@ -1269,14 +1378,35 @@ var FlockHydrator = class {
1269
1378
  this.docManager = options.docManager;
1270
1379
  }
1271
1380
  hydrateAll(by) {
1272
- const nextMetadata = this.readAllDocMetadata();
1273
- this.metadataManager.replaceAll(nextMetadata, by);
1381
+ const { metadata, tombstones } = this.readAllDocMetadata();
1382
+ this.metadataManager.replaceAll(metadata, by);
1383
+ for (const [docId, deletedAtMs] of tombstones) this.metadataManager.emitSoftDeleted(docId, deletedAtMs, by);
1274
1384
  this.assetManager.hydrateFromFlock(by);
1385
+ const docIds = new Set([
1386
+ ...metadata.keys(),
1387
+ ...tombstones.keys(),
1388
+ ...this.collectDocIds(["f"]),
1389
+ ...this.collectDocIds(["ld"])
1390
+ ]);
1391
+ for (const docId of docIds) if (this.isDocKeyspaceEmpty(docId)) (async () => {
1392
+ try {
1393
+ this.clearDocKeyspace(docId);
1394
+ this.metadataManager.purgeMetadata(docId, by);
1395
+ await this.docManager.dropDoc(docId);
1396
+ } catch (error) {
1397
+ console.error("Failed to drop purged doc during hydrateAll", {
1398
+ docId,
1399
+ error
1400
+ });
1401
+ }
1402
+ })();
1275
1403
  }
1276
- applyEvents(events, by) {
1404
+ async applyEvents(events, by) {
1277
1405
  if (!events.length) return;
1278
1406
  const docMetadataIds = /* @__PURE__ */ new Set();
1407
+ const docTombstoneIds = /* @__PURE__ */ new Map();
1279
1408
  const docAssetIds = /* @__PURE__ */ new Set();
1409
+ const docFrontierIds = /* @__PURE__ */ new Set();
1280
1410
  const assetIds = /* @__PURE__ */ new Set();
1281
1411
  for (const event of events) {
1282
1412
  const key = event.key;
@@ -1285,6 +1415,12 @@ var FlockHydrator = class {
1285
1415
  if (root === "m") {
1286
1416
  const docId = key[1];
1287
1417
  if (typeof docId === "string") docMetadataIds.add(docId);
1418
+ } else if (root === "f") {
1419
+ const docId = key[1];
1420
+ if (typeof docId === "string") docFrontierIds.add(docId);
1421
+ } else if (root === "ts") {
1422
+ const docId = key[1];
1423
+ if (typeof docId === "string") docTombstoneIds.set(docId, this.toDeletedAt(event.value));
1288
1424
  } else if (root === "a") {
1289
1425
  const assetId = key[1];
1290
1426
  if (typeof assetId === "string") assetIds.add(assetId);
@@ -1297,7 +1433,38 @@ var FlockHydrator = class {
1297
1433
  }
1298
1434
  for (const assetId of assetIds) this.assetManager.refreshAssetMetadataEntry(assetId, by);
1299
1435
  for (const docId of docMetadataIds) this.metadataManager.refreshFromFlock(docId, by);
1436
+ for (const [docId, deletedAtMs] of docTombstoneIds) this.metadataManager.emitSoftDeleted(docId, deletedAtMs, by);
1300
1437
  for (const docId of docAssetIds) this.assetManager.refreshDocAssetsEntry(docId, by);
1438
+ const docIdsToCheck = new Set([
1439
+ ...docMetadataIds,
1440
+ ...docTombstoneIds.keys(),
1441
+ ...docAssetIds,
1442
+ ...docFrontierIds
1443
+ ]);
1444
+ for (const docId of docIdsToCheck) if (this.isDocKeyspaceEmpty(docId)) try {
1445
+ this.clearDocKeyspace(docId);
1446
+ this.metadataManager.purgeMetadata(docId, by);
1447
+ await this.docManager.dropDoc(docId);
1448
+ } catch (error) {
1449
+ console.error("Failed to drop purged doc during hydration", {
1450
+ docId,
1451
+ error
1452
+ });
1453
+ }
1454
+ for (const docId of docIdsToCheck) {
1455
+ const snapshot = this.metadataManager.get(docId);
1456
+ const tombstone = this.metaFlock.get(["ts", docId]);
1457
+ if (!snapshot && tombstone === void 0) try {
1458
+ this.clearDocKeyspace(docId);
1459
+ this.metadataManager.purgeMetadata(docId, by);
1460
+ await this.docManager.dropDoc(docId);
1461
+ } catch (error) {
1462
+ console.error("Failed to drop purged doc after refresh", {
1463
+ docId,
1464
+ error
1465
+ });
1466
+ }
1467
+ }
1301
1468
  }
1302
1469
  readAllDocMetadata() {
1303
1470
  const nextMetadata = /* @__PURE__ */ new Map();
@@ -1306,35 +1473,86 @@ var FlockHydrator = class {
1306
1473
  if (!Array.isArray(row.key) || row.key.length < 2) continue;
1307
1474
  const docId = row.key[1];
1308
1475
  if (typeof docId !== "string") continue;
1309
- let docMeta = nextMetadata.get(docId);
1310
- if (!docMeta) {
1311
- docMeta = {};
1312
- nextMetadata.set(docId, docMeta);
1313
- }
1476
+ if (row.value === void 0 && !this.metadataManager.get(docId)) continue;
1314
1477
  if (row.key.length === 2) {
1315
1478
  const obj = asJsonObject(row.value);
1316
1479
  if (!obj) continue;
1480
+ let docMeta$1 = nextMetadata.get(docId);
1481
+ if (!docMeta$1) {
1482
+ docMeta$1 = {};
1483
+ nextMetadata.set(docId, docMeta$1);
1484
+ }
1317
1485
  for (const [field, value] of Object.entries(obj)) {
1318
1486
  const cloned = cloneJsonValue(value);
1319
- if (cloned !== void 0) docMeta[field] = cloned;
1487
+ if (cloned !== void 0) docMeta$1[field] = cloned;
1320
1488
  }
1321
1489
  continue;
1322
1490
  }
1323
1491
  const fieldKey = row.key[2];
1324
1492
  if (typeof fieldKey !== "string") continue;
1325
- if (fieldKey === "$tombstone") {
1326
- docMeta.tombstone = Boolean(row.value);
1327
- continue;
1328
- }
1329
1493
  const jsonValue = cloneJsonValue(row.value);
1330
1494
  if (jsonValue === void 0) continue;
1495
+ let docMeta = nextMetadata.get(docId);
1496
+ if (!docMeta) {
1497
+ docMeta = {};
1498
+ nextMetadata.set(docId, docMeta);
1499
+ }
1331
1500
  docMeta[fieldKey] = jsonValue;
1332
1501
  }
1333
- return nextMetadata;
1502
+ const tombstones = /* @__PURE__ */ new Map();
1503
+ const tombstoneRows = this.metaFlock.scan({ prefix: ["ts"] });
1504
+ for (const row of tombstoneRows) {
1505
+ if (!Array.isArray(row.key) || row.key.length < 2) continue;
1506
+ const docId = row.key[1];
1507
+ if (typeof docId !== "string") continue;
1508
+ const tombstone = cloneJsonValue(row.value);
1509
+ if (typeof tombstone === "number" && Number.isFinite(tombstone)) tombstones.set(docId, tombstone);
1510
+ }
1511
+ return {
1512
+ metadata: nextMetadata,
1513
+ tombstones
1514
+ };
1515
+ }
1516
+ toDeletedAt(value) {
1517
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1334
1518
  }
1335
1519
  get metaFlock() {
1336
1520
  return this.getMetaFlock();
1337
1521
  }
1522
+ isDocKeyspaceEmpty(docId) {
1523
+ if (this.metaFlock.get(["ts", docId]) !== void 0) return false;
1524
+ if (this.hasTruthyKey(["m", docId])) return false;
1525
+ if (this.hasTruthyKey(["f", docId])) return false;
1526
+ if (this.hasTruthyKey(["ld", docId])) return false;
1527
+ return true;
1528
+ }
1529
+ hasTruthyKey(prefix) {
1530
+ const rows = this.metaFlock.scan({ prefix });
1531
+ for (const row of rows) if (row.value !== void 0) return true;
1532
+ return false;
1533
+ }
1534
+ collectDocIds(prefix) {
1535
+ const ids = /* @__PURE__ */ new Set();
1536
+ const rows = this.metaFlock.scan({ prefix });
1537
+ for (const row of rows) {
1538
+ if (!Array.isArray(row.key) || row.key.length < 2) continue;
1539
+ const docId = row.key[1];
1540
+ if (typeof docId === "string") ids.add(docId);
1541
+ }
1542
+ return ids;
1543
+ }
1544
+ clearDocKeyspace(docId) {
1545
+ const prefixes = [
1546
+ ["m", docId],
1547
+ ["f", docId],
1548
+ ["ld", docId]
1549
+ ];
1550
+ for (const prefix of prefixes) {
1551
+ const rows = this.metaFlock.scan({ prefix });
1552
+ for (const row of rows) this.metaFlock.delete(row.key);
1553
+ }
1554
+ this.metaFlock.delete(["ts", docId]);
1555
+ }
1338
1556
  };
1339
1557
 
1340
1558
  //#endregion
@@ -1367,6 +1585,11 @@ var SyncRunner = class {
1367
1585
  this.getMetaFlock = options.getMetaFlock;
1368
1586
  this.replaceMetaFlock = options.mergeFlock;
1369
1587
  }
1588
+ setTransport(transport) {
1589
+ if (this.transport === transport) return;
1590
+ this.leaveRooms();
1591
+ this.transport = transport;
1592
+ }
1370
1593
  async ready() {
1371
1594
  if (!this.readyPromise) this.readyPromise = this.initialize();
1372
1595
  await this.readyPromise;
@@ -1385,7 +1608,7 @@ var SyncRunner = class {
1385
1608
  });
1386
1609
  try {
1387
1610
  if (!(await this.transport.syncMeta(this.metaFlock)).ok) throw new Error("Metadata sync failed");
1388
- if (recordedEvents.length > 0) this.flockHydrator.applyEvents(recordedEvents, "sync");
1611
+ if (recordedEvents.length > 0) await this.flockHydrator.applyEvents(recordedEvents, "sync");
1389
1612
  else this.flockHydrator.hydrateAll("sync");
1390
1613
  } finally {
1391
1614
  unsubscribe();
@@ -1411,62 +1634,105 @@ var SyncRunner = class {
1411
1634
  await this.ready();
1412
1635
  if (!this.transport) throw new Error("Transport adapter not configured");
1413
1636
  if (!this.transport.isConnected()) await this.transport.connect();
1414
- if (this.metaRoomSubscription) return this.metaRoomSubscription;
1637
+ const existing = this.metaRoomSubscription;
1638
+ if (existing) {
1639
+ existing.refCount += 1;
1640
+ return this.createMetaLease(existing);
1641
+ }
1415
1642
  this.ensureMetaLiveMonitor();
1416
- const subscription = this.transport.joinMetaRoom(this.metaFlock, params);
1417
- const wrapped = {
1418
- unsubscribe: () => {
1419
- subscription.unsubscribe();
1420
- if (this.metaRoomSubscription === wrapped) this.metaRoomSubscription = void 0;
1421
- if (this.unsubscribeMetaFlock) {
1422
- this.unsubscribeMetaFlock();
1423
- this.unsubscribeMetaFlock = void 0;
1424
- }
1425
- },
1426
- firstSyncedWithRemote: subscription.firstSyncedWithRemote,
1427
- get connected() {
1428
- return subscription.connected;
1429
- }
1643
+ const base = this.transport.joinMetaRoom(this.metaFlock, params);
1644
+ const record = {
1645
+ base,
1646
+ refCount: 1
1430
1647
  };
1431
- this.metaRoomSubscription = wrapped;
1432
- subscription.firstSyncedWithRemote.then(async () => {
1648
+ this.metaRoomSubscription = record;
1649
+ base.firstSyncedWithRemote.then(async () => {
1433
1650
  const by = this.eventBus.resolveEventBy("live");
1434
1651
  this.flockHydrator.hydrateAll(by);
1435
1652
  }).catch(logAsyncError("meta room first sync"));
1436
- return wrapped;
1653
+ return this.createMetaLease(record);
1437
1654
  }
1438
1655
  async joinDocRoom(docId, params) {
1439
1656
  await this.ready();
1440
1657
  if (!this.transport) throw new Error("Transport adapter not configured");
1441
1658
  if (!this.transport.isConnected()) await this.transport.connect();
1442
1659
  const existing = this.docSubscriptions.get(docId);
1443
- if (existing) return existing;
1660
+ if (existing) {
1661
+ existing.refCount += 1;
1662
+ return this.createDocLease(docId, existing);
1663
+ }
1444
1664
  const doc = await this.docManager.ensureDoc(docId);
1445
- const subscription = this.transport.joinDocRoom(docId, doc, params);
1446
- const wrapped = {
1665
+ const base = this.transport.joinDocRoom(docId, doc, params);
1666
+ const record = {
1667
+ base,
1668
+ refCount: 1
1669
+ };
1670
+ this.docSubscriptions.set(docId, record);
1671
+ base.firstSyncedWithRemote.catch(logAsyncError(`doc ${docId} first sync`));
1672
+ return this.createDocLease(docId, record);
1673
+ }
1674
+ createMetaLease(record) {
1675
+ let released = false;
1676
+ return {
1447
1677
  unsubscribe: () => {
1448
- subscription.unsubscribe();
1449
- if (this.docSubscriptions.get(docId) === wrapped) this.docSubscriptions.delete(docId);
1678
+ if (released) return;
1679
+ released = true;
1680
+ const current = this.metaRoomSubscription;
1681
+ if (!current || current !== record) return;
1682
+ current.refCount = Math.max(0, current.refCount - 1);
1683
+ if (current.refCount === 0) {
1684
+ current.base.unsubscribe();
1685
+ this.metaRoomSubscription = void 0;
1686
+ if (this.unsubscribeMetaFlock) {
1687
+ this.unsubscribeMetaFlock();
1688
+ this.unsubscribeMetaFlock = void 0;
1689
+ }
1690
+ }
1450
1691
  },
1451
- firstSyncedWithRemote: subscription.firstSyncedWithRemote,
1692
+ firstSyncedWithRemote: record.base.firstSyncedWithRemote,
1452
1693
  get connected() {
1453
- return subscription.connected;
1454
- }
1694
+ return record.base.connected;
1695
+ },
1696
+ get status() {
1697
+ return record.base.status;
1698
+ },
1699
+ onStatusChange: record.base.onStatusChange
1700
+ };
1701
+ }
1702
+ createDocLease(docId, record) {
1703
+ let released = false;
1704
+ return {
1705
+ unsubscribe: () => {
1706
+ if (released) return;
1707
+ released = true;
1708
+ const current = this.docSubscriptions.get(docId);
1709
+ if (!current || current !== record) return;
1710
+ current.refCount = Math.max(0, current.refCount - 1);
1711
+ if (current.refCount === 0) {
1712
+ current.base.unsubscribe();
1713
+ if (this.docSubscriptions.get(docId) === current) this.docSubscriptions.delete(docId);
1714
+ }
1715
+ },
1716
+ firstSyncedWithRemote: record.base.firstSyncedWithRemote,
1717
+ get connected() {
1718
+ return record.base.connected;
1719
+ },
1720
+ get status() {
1721
+ return record.base.status;
1722
+ },
1723
+ onStatusChange: record.base.onStatusChange
1455
1724
  };
1456
- this.docSubscriptions.set(docId, wrapped);
1457
- subscription.firstSyncedWithRemote.catch(logAsyncError(`doc ${docId} first sync`));
1458
- return wrapped;
1459
1725
  }
1460
1726
  async destroy() {
1461
1727
  await this.docManager.close();
1462
- this.metaRoomSubscription?.unsubscribe();
1463
- this.metaRoomSubscription = void 0;
1464
- for (const sub of this.docSubscriptions.values()) sub.unsubscribe();
1465
- this.docSubscriptions.clear();
1466
- if (this.unsubscribeMetaFlock) {
1467
- this.unsubscribeMetaFlock();
1468
- this.unsubscribeMetaFlock = void 0;
1728
+ if (this.metaRoomSubscription) {
1729
+ this.metaRoomSubscription.base.unsubscribe();
1730
+ this.metaRoomSubscription = void 0;
1469
1731
  }
1732
+ for (const record of this.docSubscriptions.values()) record.base.unsubscribe();
1733
+ this.docSubscriptions.clear();
1734
+ this.unsubscribeMetaFlock?.();
1735
+ this.unsubscribeMetaFlock = void 0;
1470
1736
  this.eventBus.clear();
1471
1737
  this.metadataManager.clear();
1472
1738
  this.assetManager.clear();
@@ -1486,13 +1752,23 @@ var SyncRunner = class {
1486
1752
  if (batch.source === "local") return;
1487
1753
  const by = this.eventBus.resolveEventBy("live");
1488
1754
  (async () => {
1489
- this.flockHydrator.applyEvents(batch.events, by);
1755
+ await this.flockHydrator.applyEvents(batch.events, by);
1490
1756
  })().catch(logAsyncError("meta live monitor sync"));
1491
1757
  });
1492
1758
  }
1493
1759
  get metaFlock() {
1494
1760
  return this.getMetaFlock();
1495
1761
  }
1762
+ leaveRooms() {
1763
+ if (this.metaRoomSubscription) {
1764
+ this.metaRoomSubscription.base.unsubscribe();
1765
+ this.metaRoomSubscription = void 0;
1766
+ }
1767
+ for (const record of this.docSubscriptions.values()) record.base.unsubscribe();
1768
+ this.docSubscriptions.clear();
1769
+ this.unsubscribeMetaFlock?.();
1770
+ this.unsubscribeMetaFlock = void 0;
1771
+ }
1496
1772
  };
1497
1773
 
1498
1774
  //#endregion
@@ -1592,14 +1868,10 @@ var MetaPersister = class {
1592
1868
  this.forceFullOnNextFlush = false;
1593
1869
  return;
1594
1870
  }
1595
- try {
1596
- await this.storage.save({
1597
- type: "meta",
1598
- update: encoded
1599
- });
1600
- } catch (error) {
1601
- throw error;
1602
- }
1871
+ await this.storage.save({
1872
+ type: "meta",
1873
+ update: encoded
1874
+ });
1603
1875
  this.lastPersistedVersion = currentVersion;
1604
1876
  this.forceFullOnNextFlush = false;
1605
1877
  }
@@ -1657,8 +1929,8 @@ var MetaPersister = class {
1657
1929
  //#endregion
1658
1930
  //#region src/index.ts
1659
1931
  const DEFAULT_DOC_FRONTIER_DEBOUNCE_MS = 1e3;
1932
+ const DEFAULT_DELETED_DOC_KEEP_MS = 720 * 60 * 60 * 1e3;
1660
1933
  var LoroRepo = class LoroRepo {
1661
- options;
1662
1934
  _destroyed = false;
1663
1935
  transport;
1664
1936
  storage;
@@ -1672,8 +1944,9 @@ var LoroRepo = class LoroRepo {
1672
1944
  state;
1673
1945
  syncRunner;
1674
1946
  metaPersister;
1947
+ deletedDocKeepMs;
1948
+ purgeWatchHandle;
1675
1949
  constructor(options) {
1676
- this.options = options;
1677
1950
  this.transport = options.transportAdapter;
1678
1951
  this.storage = options.storageAdapter;
1679
1952
  this.assetTransport = options.assetTransportAdapter;
@@ -1681,6 +1954,8 @@ var LoroRepo = class LoroRepo {
1681
1954
  this.state = createRepoState();
1682
1955
  const configuredDebounce = options.docFrontierDebounceMs;
1683
1956
  const docFrontierDebounceMs = typeof configuredDebounce === "number" && Number.isFinite(configuredDebounce) && configuredDebounce >= 0 ? configuredDebounce : DEFAULT_DOC_FRONTIER_DEBOUNCE_MS;
1957
+ const configuredDeletedKeepMs = options.deletedDocKeepMs;
1958
+ this.deletedDocKeepMs = typeof configuredDeletedKeepMs === "number" && Number.isFinite(configuredDeletedKeepMs) && configuredDeletedKeepMs >= 0 ? configuredDeletedKeepMs : DEFAULT_DELETED_DOC_KEEP_MS;
1684
1959
  this.docManager = new DocManager({
1685
1960
  storage: this.storage,
1686
1961
  docFrontierDebounceMs,
@@ -1723,6 +1998,10 @@ var LoroRepo = class LoroRepo {
1723
1998
  this.metaFlock.merge(snapshot);
1724
1999
  }
1725
2000
  });
2001
+ this.purgeWatchHandle = this.eventBus.watch((event) => this.handlePurgeSignals(event), {
2002
+ kinds: ["doc-soft-deleted", "doc-metadata"],
2003
+ by: ["sync", "live"]
2004
+ });
1726
2005
  }
1727
2006
  static async create(options) {
1728
2007
  const repo = new LoroRepo(options);
@@ -1740,6 +2019,18 @@ var LoroRepo = class LoroRepo {
1740
2019
  await this.syncRunner.ready();
1741
2020
  this.metaPersister.start(this.metaFlock.version());
1742
2021
  }
2022
+ computeDocPurgeAfter(docId, minKeepMs) {
2023
+ const deletedAt = this.metadataManager.getDeletedAtMs(docId);
2024
+ if (deletedAt === void 0) return void 0;
2025
+ return deletedAt + minKeepMs;
2026
+ }
2027
+ purgeDocKeyspace(docId) {
2028
+ const metadataKeys = Array.from(this.metaFlock.scan({ prefix: ["m", docId] }), (row) => row.key);
2029
+ for (const key of metadataKeys) this.metaFlock.delete(key);
2030
+ const frontierKeys = Array.from(this.metaFlock.scan({ prefix: ["f", docId] }), (row) => row.key);
2031
+ for (const key of frontierKeys) this.metaFlock.delete(key);
2032
+ this.metaFlock.delete(["ts", docId]);
2033
+ }
1743
2034
  /**
1744
2035
  * Sync selected data via the transport adaptor
1745
2036
  * @param options
@@ -1748,8 +2039,31 @@ var LoroRepo = class LoroRepo {
1748
2039
  await this.syncRunner.sync(options);
1749
2040
  }
1750
2041
  /**
2042
+ * Sets (or replaces) the transport adapter used for syncing and realtime rooms.
2043
+ *
2044
+ * Swapping transports will leave any joined meta/doc rooms managed by the repo.
2045
+ */
2046
+ async setTransportAdapter(transport) {
2047
+ if (this._destroyed) throw new Error("Repo has been destroyed");
2048
+ if (this.transport === transport) return;
2049
+ const previous = this.transport;
2050
+ this.transport = transport;
2051
+ this.syncRunner.setTransport(transport);
2052
+ await previous?.close();
2053
+ }
2054
+ hasTransport() {
2055
+ return Boolean(this.transport);
2056
+ }
2057
+ hasStorage() {
2058
+ return Boolean(this.storage);
2059
+ }
2060
+ /**
1751
2061
  * Start syncing the metadata (Flock) room. It will establish a realtime connection to the transport adaptor.
1752
2062
  * All changes on the room will be synced to the Flock, and all changes on the Flock will be synced to the room.
2063
+ *
2064
+ * - Idempotent: repeated calls reuse the same underlying room session; no extra join request is sent for the same repo.
2065
+ * - Reference-counted leave: every call to `joinMetaRoom` returns a subscription that increments an internal counter. The room is
2066
+ * actually left only after all returned subscriptions have called `unsubscribe`.
1753
2067
  * @param params
1754
2068
  * @returns
1755
2069
  */
@@ -1760,6 +2074,10 @@ var LoroRepo = class LoroRepo {
1760
2074
  get connected() {
1761
2075
  return subscription.connected;
1762
2076
  },
2077
+ get status() {
2078
+ return subscription.status;
2079
+ },
2080
+ onStatusChange: subscription.onStatusChange,
1763
2081
  firstSyncedWithRemote: subscription.firstSyncedWithRemote.then(async () => {
1764
2082
  await this.metaPersister.flushNow();
1765
2083
  })
@@ -1770,12 +2088,36 @@ var LoroRepo = class LoroRepo {
1770
2088
  * All changes on the doc will be synced to the transport, and all changes on the transport will be synced to the doc.
1771
2089
  *
1772
2090
  * All the changes on the room will be reflected on the same doc you get from `repo.openCollaborativeDoc(docId)`
2091
+ *
2092
+ * - Idempotent: multiple joins for the same `docId` reuse the existing session; no duplicate transport joins are issued.
2093
+ * - Reference-counted leave: each returned subscription bumps an internal counter and only the final `unsubscribe()` will
2094
+ * actually leave the room. Earlier unsubscribes simply decrement the counter.
1773
2095
  * @param docId
1774
2096
  * @param params
1775
2097
  * @returns
1776
2098
  */
1777
2099
  async joinDocRoom(docId, params) {
1778
- return this.syncRunner.joinDocRoom(docId, params);
2100
+ const subscription = await this.syncRunner.joinDocRoom(docId, params);
2101
+ return {
2102
+ ...subscription,
2103
+ onStatusChange: subscription.onStatusChange,
2104
+ status: subscription.status
2105
+ };
2106
+ }
2107
+ /**
2108
+ * Joins an ephemeral CRDT room. This is useful for presence-like state that should not be persisted.
2109
+ * The returned store can be used immediately; the first sync promise resolves once the initial handshake completes.
2110
+ */
2111
+ async joinEphemeralRoom(roomId) {
2112
+ if (!this.transport) throw new Error("Transport adapter not configured");
2113
+ await this.syncRunner.ready();
2114
+ if (!this.transport.isConnected()) await this.transport.connect();
2115
+ const subscription = this.transport.joinEphemeralRoom(roomId);
2116
+ return {
2117
+ ...subscription,
2118
+ onStatusChange: subscription.onStatusChange,
2119
+ status: subscription.status
2120
+ };
1779
2121
  }
1780
2122
  /**
1781
2123
  * Opens a document that is automatically persisted to the configured storage adapter.
@@ -1807,6 +2149,61 @@ var LoroRepo = class LoroRepo {
1807
2149
  async listDoc(query) {
1808
2150
  return this.metadataManager.listDoc(query);
1809
2151
  }
2152
+ /**
2153
+ * Mark a document deleted by writing a `ts/*` tombstone entry (timestamp).
2154
+ * The body and metadata remain until purged; callers use the tombstone to
2155
+ * render deleted state or trigger retention workflows. For immediate removal,
2156
+ * call `purgeDoc` instead.
2157
+ */
2158
+ async deleteDoc(docId, options = {}) {
2159
+ if (this.metadataManager.getDeletedAtMs(docId) !== void 0 && !options.force) return;
2160
+ const deletedAt = options.deletedAt ?? Date.now();
2161
+ this.metadataManager.markDeleted(docId, deletedAt);
2162
+ }
2163
+ /**
2164
+ * Undo a soft delete by removing the tombstone entry. Metadata and document
2165
+ * state remain untouched.
2166
+ */
2167
+ async restoreDoc(docId) {
2168
+ if (this.metadataManager.getDeletedAtMs(docId) === void 0) return;
2169
+ this.metadataManager.clearDeleted(docId);
2170
+ }
2171
+ /**
2172
+ * Hard-delete a document immediately. Removes doc snapshots/updates via the
2173
+ * storage adapter (if supported), clears metadata/frontiers/link keys from
2174
+ * Flock, and unlinks assets (they become orphaned for asset GC).
2175
+ */
2176
+ async purgeDoc(docId) {
2177
+ const deletedAtMs = this.metadataManager.getDeletedAtMs(docId);
2178
+ this.eventBus.emit({
2179
+ kind: "doc-purging",
2180
+ docId,
2181
+ deletedAtMs,
2182
+ by: "local"
2183
+ });
2184
+ await this.docManager.dropDoc(docId);
2185
+ this.assetManager.purgeDocLinks(docId, "local");
2186
+ this.purgeDocKeyspace(docId);
2187
+ this.metadataManager.emitSoftDeleted(docId, void 0, "local");
2188
+ this.metadataManager.refreshFromFlock(docId, "local");
2189
+ }
2190
+ /**
2191
+ * Sweep tombstoned documents whose retention window expired. Uses
2192
+ * `deletedDocKeepMs` by default; pass `minKeepMs`/`now` for overrides.
2193
+ */
2194
+ async gcDeletedDocs(options = {}) {
2195
+ const now = options.now ?? Date.now();
2196
+ const minKeepMs = options.minKeepMs ?? this.deletedDocKeepMs;
2197
+ const docIds = this.metadataManager.getDocIds();
2198
+ let purged = 0;
2199
+ for (const docId of docIds) {
2200
+ const purgeAfter = this.computeDocPurgeAfter(docId, minKeepMs);
2201
+ if (purgeAfter === void 0 || now < purgeAfter) continue;
2202
+ await this.purgeDoc(docId);
2203
+ purged += 1;
2204
+ }
2205
+ return purged;
2206
+ }
1810
2207
  getMeta() {
1811
2208
  return this.metaFlock;
1812
2209
  }
@@ -1864,12 +2261,29 @@ var LoroRepo = class LoroRepo {
1864
2261
  async destroy() {
1865
2262
  if (this._destroyed) return;
1866
2263
  this._destroyed = true;
2264
+ this.purgeWatchHandle.unsubscribe();
1867
2265
  await this.metaPersister.destroy();
1868
2266
  await this.syncRunner.destroy();
1869
2267
  this.assetTransport?.close?.();
1870
2268
  this.storage?.close?.();
1871
2269
  await this.transport?.close();
1872
2270
  }
2271
+ handlePurgeSignals(event) {
2272
+ const docId = (() => {
2273
+ if (event.kind === "doc-soft-deleted") return event.docId;
2274
+ if (event.kind === "doc-metadata") return event.docId;
2275
+ })();
2276
+ if (!docId) return;
2277
+ const metadataCleared = event.kind === "doc-metadata" && Object.keys(event.patch).length === 0;
2278
+ const tombstoneCleared = event.kind === "doc-soft-deleted" && event.deletedAtMs === void 0;
2279
+ if (!(metadataCleared || tombstoneCleared && this.metadataManager.get(docId) === void 0)) return;
2280
+ this.docManager.dropDoc(docId).catch((error) => {
2281
+ console.error("Failed to drop purged doc", {
2282
+ docId,
2283
+ error
2284
+ });
2285
+ });
2286
+ }
1873
2287
  };
1874
2288
 
1875
2289
  //#endregion