loro-repo 0.5.3 → 0.6.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
@@ -1385,7 +1603,7 @@ var SyncRunner = class {
1385
1603
  });
1386
1604
  try {
1387
1605
  if (!(await this.transport.syncMeta(this.metaFlock)).ok) throw new Error("Metadata sync failed");
1388
- if (recordedEvents.length > 0) this.flockHydrator.applyEvents(recordedEvents, "sync");
1606
+ if (recordedEvents.length > 0) await this.flockHydrator.applyEvents(recordedEvents, "sync");
1389
1607
  else this.flockHydrator.hydrateAll("sync");
1390
1608
  } finally {
1391
1609
  unsubscribe();
@@ -1411,62 +1629,105 @@ var SyncRunner = class {
1411
1629
  await this.ready();
1412
1630
  if (!this.transport) throw new Error("Transport adapter not configured");
1413
1631
  if (!this.transport.isConnected()) await this.transport.connect();
1414
- if (this.metaRoomSubscription) return this.metaRoomSubscription;
1632
+ const existing = this.metaRoomSubscription;
1633
+ if (existing) {
1634
+ existing.refCount += 1;
1635
+ return this.createMetaLease(existing);
1636
+ }
1415
1637
  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
- }
1638
+ const base = this.transport.joinMetaRoom(this.metaFlock, params);
1639
+ const record = {
1640
+ base,
1641
+ refCount: 1
1430
1642
  };
1431
- this.metaRoomSubscription = wrapped;
1432
- subscription.firstSyncedWithRemote.then(async () => {
1643
+ this.metaRoomSubscription = record;
1644
+ base.firstSyncedWithRemote.then(async () => {
1433
1645
  const by = this.eventBus.resolveEventBy("live");
1434
1646
  this.flockHydrator.hydrateAll(by);
1435
1647
  }).catch(logAsyncError("meta room first sync"));
1436
- return wrapped;
1648
+ return this.createMetaLease(record);
1437
1649
  }
1438
1650
  async joinDocRoom(docId, params) {
1439
1651
  await this.ready();
1440
1652
  if (!this.transport) throw new Error("Transport adapter not configured");
1441
1653
  if (!this.transport.isConnected()) await this.transport.connect();
1442
1654
  const existing = this.docSubscriptions.get(docId);
1443
- if (existing) return existing;
1655
+ if (existing) {
1656
+ existing.refCount += 1;
1657
+ return this.createDocLease(docId, existing);
1658
+ }
1444
1659
  const doc = await this.docManager.ensureDoc(docId);
1445
- const subscription = this.transport.joinDocRoom(docId, doc, params);
1446
- const wrapped = {
1660
+ const base = this.transport.joinDocRoom(docId, doc, params);
1661
+ const record = {
1662
+ base,
1663
+ refCount: 1
1664
+ };
1665
+ this.docSubscriptions.set(docId, record);
1666
+ base.firstSyncedWithRemote.catch(logAsyncError(`doc ${docId} first sync`));
1667
+ return this.createDocLease(docId, record);
1668
+ }
1669
+ createMetaLease(record) {
1670
+ let released = false;
1671
+ return {
1447
1672
  unsubscribe: () => {
1448
- subscription.unsubscribe();
1449
- if (this.docSubscriptions.get(docId) === wrapped) this.docSubscriptions.delete(docId);
1673
+ if (released) return;
1674
+ released = true;
1675
+ const current = this.metaRoomSubscription;
1676
+ if (!current || current !== record) return;
1677
+ current.refCount = Math.max(0, current.refCount - 1);
1678
+ if (current.refCount === 0) {
1679
+ current.base.unsubscribe();
1680
+ this.metaRoomSubscription = void 0;
1681
+ if (this.unsubscribeMetaFlock) {
1682
+ this.unsubscribeMetaFlock();
1683
+ this.unsubscribeMetaFlock = void 0;
1684
+ }
1685
+ }
1450
1686
  },
1451
- firstSyncedWithRemote: subscription.firstSyncedWithRemote,
1687
+ firstSyncedWithRemote: record.base.firstSyncedWithRemote,
1452
1688
  get connected() {
1453
- return subscription.connected;
1454
- }
1689
+ return record.base.connected;
1690
+ },
1691
+ get status() {
1692
+ return record.base.status;
1693
+ },
1694
+ onStatusChange: record.base.onStatusChange
1695
+ };
1696
+ }
1697
+ createDocLease(docId, record) {
1698
+ let released = false;
1699
+ return {
1700
+ unsubscribe: () => {
1701
+ if (released) return;
1702
+ released = true;
1703
+ const current = this.docSubscriptions.get(docId);
1704
+ if (!current || current !== record) return;
1705
+ current.refCount = Math.max(0, current.refCount - 1);
1706
+ if (current.refCount === 0) {
1707
+ current.base.unsubscribe();
1708
+ if (this.docSubscriptions.get(docId) === current) this.docSubscriptions.delete(docId);
1709
+ }
1710
+ },
1711
+ firstSyncedWithRemote: record.base.firstSyncedWithRemote,
1712
+ get connected() {
1713
+ return record.base.connected;
1714
+ },
1715
+ get status() {
1716
+ return record.base.status;
1717
+ },
1718
+ onStatusChange: record.base.onStatusChange
1455
1719
  };
1456
- this.docSubscriptions.set(docId, wrapped);
1457
- subscription.firstSyncedWithRemote.catch(logAsyncError(`doc ${docId} first sync`));
1458
- return wrapped;
1459
1720
  }
1460
1721
  async destroy() {
1461
1722
  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;
1723
+ if (this.metaRoomSubscription) {
1724
+ this.metaRoomSubscription.base.unsubscribe();
1725
+ this.metaRoomSubscription = void 0;
1469
1726
  }
1727
+ for (const record of this.docSubscriptions.values()) record.base.unsubscribe();
1728
+ this.docSubscriptions.clear();
1729
+ this.unsubscribeMetaFlock?.();
1730
+ this.unsubscribeMetaFlock = void 0;
1470
1731
  this.eventBus.clear();
1471
1732
  this.metadataManager.clear();
1472
1733
  this.assetManager.clear();
@@ -1486,7 +1747,7 @@ var SyncRunner = class {
1486
1747
  if (batch.source === "local") return;
1487
1748
  const by = this.eventBus.resolveEventBy("live");
1488
1749
  (async () => {
1489
- this.flockHydrator.applyEvents(batch.events, by);
1750
+ await this.flockHydrator.applyEvents(batch.events, by);
1490
1751
  })().catch(logAsyncError("meta live monitor sync"));
1491
1752
  });
1492
1753
  }
@@ -1592,14 +1853,10 @@ var MetaPersister = class {
1592
1853
  this.forceFullOnNextFlush = false;
1593
1854
  return;
1594
1855
  }
1595
- try {
1596
- await this.storage.save({
1597
- type: "meta",
1598
- update: encoded
1599
- });
1600
- } catch (error) {
1601
- throw error;
1602
- }
1856
+ await this.storage.save({
1857
+ type: "meta",
1858
+ update: encoded
1859
+ });
1603
1860
  this.lastPersistedVersion = currentVersion;
1604
1861
  this.forceFullOnNextFlush = false;
1605
1862
  }
@@ -1657,6 +1914,7 @@ var MetaPersister = class {
1657
1914
  //#endregion
1658
1915
  //#region src/index.ts
1659
1916
  const DEFAULT_DOC_FRONTIER_DEBOUNCE_MS = 1e3;
1917
+ const DEFAULT_DELETED_DOC_KEEP_MS = 720 * 60 * 60 * 1e3;
1660
1918
  var LoroRepo = class LoroRepo {
1661
1919
  options;
1662
1920
  _destroyed = false;
@@ -1672,6 +1930,8 @@ var LoroRepo = class LoroRepo {
1672
1930
  state;
1673
1931
  syncRunner;
1674
1932
  metaPersister;
1933
+ deletedDocKeepMs;
1934
+ purgeWatchHandle;
1675
1935
  constructor(options) {
1676
1936
  this.options = options;
1677
1937
  this.transport = options.transportAdapter;
@@ -1681,6 +1941,8 @@ var LoroRepo = class LoroRepo {
1681
1941
  this.state = createRepoState();
1682
1942
  const configuredDebounce = options.docFrontierDebounceMs;
1683
1943
  const docFrontierDebounceMs = typeof configuredDebounce === "number" && Number.isFinite(configuredDebounce) && configuredDebounce >= 0 ? configuredDebounce : DEFAULT_DOC_FRONTIER_DEBOUNCE_MS;
1944
+ const configuredDeletedKeepMs = options.deletedDocKeepMs;
1945
+ this.deletedDocKeepMs = typeof configuredDeletedKeepMs === "number" && Number.isFinite(configuredDeletedKeepMs) && configuredDeletedKeepMs >= 0 ? configuredDeletedKeepMs : DEFAULT_DELETED_DOC_KEEP_MS;
1684
1946
  this.docManager = new DocManager({
1685
1947
  storage: this.storage,
1686
1948
  docFrontierDebounceMs,
@@ -1723,6 +1985,10 @@ var LoroRepo = class LoroRepo {
1723
1985
  this.metaFlock.merge(snapshot);
1724
1986
  }
1725
1987
  });
1988
+ this.purgeWatchHandle = this.eventBus.watch((event) => this.handlePurgeSignals(event), {
1989
+ kinds: ["doc-soft-deleted", "doc-metadata"],
1990
+ by: ["sync", "live"]
1991
+ });
1726
1992
  }
1727
1993
  static async create(options) {
1728
1994
  const repo = new LoroRepo(options);
@@ -1740,6 +2006,18 @@ var LoroRepo = class LoroRepo {
1740
2006
  await this.syncRunner.ready();
1741
2007
  this.metaPersister.start(this.metaFlock.version());
1742
2008
  }
2009
+ computeDocPurgeAfter(docId, minKeepMs) {
2010
+ const deletedAt = this.metadataManager.getDeletedAtMs(docId);
2011
+ if (deletedAt === void 0) return void 0;
2012
+ return deletedAt + minKeepMs;
2013
+ }
2014
+ purgeDocKeyspace(docId) {
2015
+ const metadataKeys = Array.from(this.metaFlock.scan({ prefix: ["m", docId] }), (row) => row.key);
2016
+ for (const key of metadataKeys) this.metaFlock.delete(key);
2017
+ const frontierKeys = Array.from(this.metaFlock.scan({ prefix: ["f", docId] }), (row) => row.key);
2018
+ for (const key of frontierKeys) this.metaFlock.delete(key);
2019
+ this.metaFlock.delete(["ts", docId]);
2020
+ }
1743
2021
  /**
1744
2022
  * Sync selected data via the transport adaptor
1745
2023
  * @param options
@@ -1750,6 +2028,10 @@ var LoroRepo = class LoroRepo {
1750
2028
  /**
1751
2029
  * Start syncing the metadata (Flock) room. It will establish a realtime connection to the transport adaptor.
1752
2030
  * All changes on the room will be synced to the Flock, and all changes on the Flock will be synced to the room.
2031
+ *
2032
+ * - Idempotent: repeated calls reuse the same underlying room session; no extra join request is sent for the same repo.
2033
+ * - Reference-counted leave: every call to `joinMetaRoom` returns a subscription that increments an internal counter. The room is
2034
+ * actually left only after all returned subscriptions have called `unsubscribe`.
1753
2035
  * @param params
1754
2036
  * @returns
1755
2037
  */
@@ -1760,6 +2042,10 @@ var LoroRepo = class LoroRepo {
1760
2042
  get connected() {
1761
2043
  return subscription.connected;
1762
2044
  },
2045
+ get status() {
2046
+ return subscription.status;
2047
+ },
2048
+ onStatusChange: subscription.onStatusChange,
1763
2049
  firstSyncedWithRemote: subscription.firstSyncedWithRemote.then(async () => {
1764
2050
  await this.metaPersister.flushNow();
1765
2051
  })
@@ -1770,12 +2056,36 @@ var LoroRepo = class LoroRepo {
1770
2056
  * All changes on the doc will be synced to the transport, and all changes on the transport will be synced to the doc.
1771
2057
  *
1772
2058
  * All the changes on the room will be reflected on the same doc you get from `repo.openCollaborativeDoc(docId)`
2059
+ *
2060
+ * - Idempotent: multiple joins for the same `docId` reuse the existing session; no duplicate transport joins are issued.
2061
+ * - Reference-counted leave: each returned subscription bumps an internal counter and only the final `unsubscribe()` will
2062
+ * actually leave the room. Earlier unsubscribes simply decrement the counter.
1773
2063
  * @param docId
1774
2064
  * @param params
1775
2065
  * @returns
1776
2066
  */
1777
2067
  async joinDocRoom(docId, params) {
1778
- return this.syncRunner.joinDocRoom(docId, params);
2068
+ const subscription = await this.syncRunner.joinDocRoom(docId, params);
2069
+ return {
2070
+ ...subscription,
2071
+ onStatusChange: subscription.onStatusChange,
2072
+ status: subscription.status
2073
+ };
2074
+ }
2075
+ /**
2076
+ * Joins an ephemeral CRDT room. This is useful for presence-like state that should not be persisted.
2077
+ * The returned store can be used immediately; the first sync promise resolves once the initial handshake completes.
2078
+ */
2079
+ async joinEphemeralRoom(roomId) {
2080
+ if (!this.transport) throw new Error("Transport adapter not configured");
2081
+ await this.syncRunner.ready();
2082
+ if (!this.transport.isConnected()) await this.transport.connect();
2083
+ const subscription = this.transport.joinEphemeralRoom(roomId);
2084
+ return {
2085
+ ...subscription,
2086
+ onStatusChange: subscription.onStatusChange,
2087
+ status: subscription.status
2088
+ };
1779
2089
  }
1780
2090
  /**
1781
2091
  * Opens a document that is automatically persisted to the configured storage adapter.
@@ -1807,6 +2117,61 @@ var LoroRepo = class LoroRepo {
1807
2117
  async listDoc(query) {
1808
2118
  return this.metadataManager.listDoc(query);
1809
2119
  }
2120
+ /**
2121
+ * Mark a document deleted by writing a `ts/*` tombstone entry (timestamp).
2122
+ * The body and metadata remain until purged; callers use the tombstone to
2123
+ * render deleted state or trigger retention workflows. For immediate removal,
2124
+ * call `purgeDoc` instead.
2125
+ */
2126
+ async deleteDoc(docId, options = {}) {
2127
+ if (this.metadataManager.getDeletedAtMs(docId) !== void 0 && !options.force) return;
2128
+ const deletedAt = options.deletedAt ?? Date.now();
2129
+ this.metadataManager.markDeleted(docId, deletedAt);
2130
+ }
2131
+ /**
2132
+ * Undo a soft delete by removing the tombstone entry. Metadata and document
2133
+ * state remain untouched.
2134
+ */
2135
+ async restoreDoc(docId) {
2136
+ if (this.metadataManager.getDeletedAtMs(docId) === void 0) return;
2137
+ this.metadataManager.clearDeleted(docId);
2138
+ }
2139
+ /**
2140
+ * Hard-delete a document immediately. Removes doc snapshots/updates via the
2141
+ * storage adapter (if supported), clears metadata/frontiers/link keys from
2142
+ * Flock, and unlinks assets (they become orphaned for asset GC).
2143
+ */
2144
+ async purgeDoc(docId) {
2145
+ const deletedAtMs = this.metadataManager.getDeletedAtMs(docId);
2146
+ this.eventBus.emit({
2147
+ kind: "doc-purging",
2148
+ docId,
2149
+ deletedAtMs,
2150
+ by: "local"
2151
+ });
2152
+ await this.docManager.dropDoc(docId);
2153
+ this.assetManager.purgeDocLinks(docId, "local");
2154
+ this.purgeDocKeyspace(docId);
2155
+ this.metadataManager.emitSoftDeleted(docId, void 0, "local");
2156
+ this.metadataManager.refreshFromFlock(docId, "local");
2157
+ }
2158
+ /**
2159
+ * Sweep tombstoned documents whose retention window expired. Uses
2160
+ * `deletedDocKeepMs` by default; pass `minKeepMs`/`now` for overrides.
2161
+ */
2162
+ async gcDeletedDocs(options = {}) {
2163
+ const now = options.now ?? Date.now();
2164
+ const minKeepMs = options.minKeepMs ?? this.deletedDocKeepMs;
2165
+ const docIds = this.metadataManager.getDocIds();
2166
+ let purged = 0;
2167
+ for (const docId of docIds) {
2168
+ const purgeAfter = this.computeDocPurgeAfter(docId, minKeepMs);
2169
+ if (purgeAfter === void 0 || now < purgeAfter) continue;
2170
+ await this.purgeDoc(docId);
2171
+ purged += 1;
2172
+ }
2173
+ return purged;
2174
+ }
1810
2175
  getMeta() {
1811
2176
  return this.metaFlock;
1812
2177
  }
@@ -1864,12 +2229,29 @@ var LoroRepo = class LoroRepo {
1864
2229
  async destroy() {
1865
2230
  if (this._destroyed) return;
1866
2231
  this._destroyed = true;
2232
+ this.purgeWatchHandle.unsubscribe();
1867
2233
  await this.metaPersister.destroy();
1868
2234
  await this.syncRunner.destroy();
1869
2235
  this.assetTransport?.close?.();
1870
2236
  this.storage?.close?.();
1871
2237
  await this.transport?.close();
1872
2238
  }
2239
+ handlePurgeSignals(event) {
2240
+ const docId = (() => {
2241
+ if (event.kind === "doc-soft-deleted") return event.docId;
2242
+ if (event.kind === "doc-metadata") return event.docId;
2243
+ })();
2244
+ if (!docId) return;
2245
+ const metadataCleared = event.kind === "doc-metadata" && Object.keys(event.patch).length === 0;
2246
+ const tombstoneCleared = event.kind === "doc-soft-deleted" && event.deletedAtMs === void 0;
2247
+ if (!(metadataCleared || tombstoneCleared && this.metadataManager.get(docId) === void 0)) return;
2248
+ this.docManager.dropDoc(docId).catch((error) => {
2249
+ console.error("Failed to drop purged doc", {
2250
+ docId,
2251
+ error
2252
+ });
2253
+ });
2254
+ }
1873
2255
  };
1874
2256
 
1875
2257
  //#endregion