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