loro-repo 0.5.2 → 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;
@@ -68,7 +68,6 @@ var DocManager = class {
68
68
  docFrontierDebounceMs;
69
69
  getMetaFlock;
70
70
  eventBus;
71
- persistMeta;
72
71
  docs = /* @__PURE__ */ new Map();
73
72
  docSubscriptions = /* @__PURE__ */ new Map();
74
73
  docFrontierUpdates = /* @__PURE__ */ new Map();
@@ -78,7 +77,6 @@ var DocManager = class {
78
77
  this.docFrontierDebounceMs = options.docFrontierDebounceMs;
79
78
  this.getMetaFlock = options.getMetaFlock;
80
79
  this.eventBus = options.eventBus;
81
- this.persistMeta = options.persistMeta;
82
80
  }
83
81
  async openPersistedDoc(docId) {
84
82
  return await this.ensureDoc(docId);
@@ -139,16 +137,13 @@ var DocManager = class {
139
137
  ], f.counter);
140
138
  mutated = true;
141
139
  }
142
- if (mutated) {
143
- for (const [peer, counter] of existingFrontiers) {
144
- const docCounterEnd = vv.get(peer);
145
- if (docCounterEnd != null && docCounterEnd > counter) metaFlock.delete([
146
- "f",
147
- docId,
148
- peer
149
- ]);
150
- }
151
- await this.persistMeta();
140
+ if (mutated) for (const [peer, counter] of existingFrontiers) {
141
+ const docCounterEnd = vv.get(peer);
142
+ if (docCounterEnd != null && docCounterEnd > counter) metaFlock.delete([
143
+ "f",
144
+ docId,
145
+ peer
146
+ ]);
152
147
  }
153
148
  const by = this.eventBus.resolveEventBy(defaultBy);
154
149
  this.eventBus.emit({
@@ -182,6 +177,18 @@ var DocManager = class {
182
177
  this.docs.delete(docId);
183
178
  this.docPersistedVersions.delete(docId);
184
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
+ }
185
192
  async flush() {
186
193
  const promises = [];
187
194
  for (const [docId, doc] of this.docs) promises.push((async () => {
@@ -502,22 +509,51 @@ function matchesQuery(docId, _metadata, query) {
502
509
  var MetadataManager = class {
503
510
  getMetaFlock;
504
511
  eventBus;
505
- persistMeta;
506
512
  state;
507
513
  constructor(options) {
508
514
  this.getMetaFlock = options.getMetaFlock;
509
515
  this.eventBus = options.eventBus;
510
- this.persistMeta = options.persistMeta;
511
516
  this.state = options.state;
512
517
  }
513
518
  getDocIds() {
514
- 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);
515
527
  }
516
528
  entries() {
517
529
  return this.state.metadata.entries();
518
530
  }
519
531
  get(docId) {
520
- 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");
521
557
  }
522
558
  listDoc(query) {
523
559
  if (query?.limit !== void 0 && query.limit <= 0) return [];
@@ -532,25 +568,49 @@ var MetadataManager = class {
532
568
  kind: "exclusive",
533
569
  key: ["m", endKey]
534
570
  };
535
- const rows = this.metaFlock.scan(scanOptions);
536
571
  const seen = /* @__PURE__ */ new Set();
537
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);
538
587
  for (const row of rows) {
539
588
  if (query?.limit !== void 0 && entries.length >= query.limit) break;
540
589
  if (!Array.isArray(row.key) || row.key.length < 2) continue;
541
590
  const docId = row.key[1];
542
591
  if (typeof docId !== "string") continue;
543
- if (seen.has(docId)) continue;
544
- seen.add(docId);
545
- const metadata = this.state.metadata.get(docId);
546
- if (!metadata) continue;
547
- if (!matchesQuery(docId, metadata, query)) continue;
548
- entries.push({
549
- docId,
550
- meta: cloneJsonObject(metadata)
551
- });
592
+ pushDoc(docId);
552
593
  if (query?.limit !== void 0 && entries.length >= query.limit) break;
553
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
+ }
554
614
  return entries;
555
615
  }
556
616
  async upsert(docId, patch) {
@@ -563,12 +623,10 @@ var MetadataManager = class {
563
623
  const rawValue = patchObject[key];
564
624
  if (rawValue === void 0) continue;
565
625
  if (jsonEquals(base ? base[key] : void 0, rawValue)) continue;
566
- const storageKey = key === "tombstone" ? "$tombstone" : key;
567
- console.log("upserting", rawValue);
568
626
  this.metaFlock.put([
569
627
  "m",
570
628
  docId,
571
- storageKey
629
+ key
572
630
  ], rawValue);
573
631
  next[key] = rawValue;
574
632
  outPatch[key] = rawValue;
@@ -579,7 +637,6 @@ var MetadataManager = class {
579
637
  return;
580
638
  }
581
639
  this.state.metadata.set(docId, next);
582
- await this.persistMeta();
583
640
  this.eventBus.emit({
584
641
  kind: "doc-metadata",
585
642
  docId,
@@ -588,10 +645,10 @@ var MetadataManager = class {
588
645
  });
589
646
  }
590
647
  refreshFromFlock(docId, by) {
591
- const previous = this.state.metadata.get(docId);
592
- const next = this.readDocMetadataFromFlock(docId);
593
- if (!next) {
594
- if (previous) {
648
+ const previousMeta = this.state.metadata.get(docId);
649
+ const nextMeta = this.readDocMetadataFromFlock(docId);
650
+ if (!nextMeta) {
651
+ if (previousMeta) {
595
652
  this.state.metadata.delete(docId);
596
653
  this.eventBus.emit({
597
654
  kind: "doc-metadata",
@@ -602,9 +659,9 @@ var MetadataManager = class {
602
659
  }
603
660
  return;
604
661
  }
605
- this.state.metadata.set(docId, next);
606
- const patch = diffJsonObjects(previous, next);
607
- 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({
608
665
  kind: "doc-metadata",
609
666
  docId,
610
667
  patch,
@@ -620,12 +677,16 @@ var MetadataManager = class {
620
677
  const previous = prevMetadata.get(docId);
621
678
  const current = nextMetadata.get(docId);
622
679
  if (!current) {
623
- if (previous) this.eventBus.emit({
624
- kind: "doc-metadata",
625
- docId,
626
- patch: {},
627
- by
628
- });
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
+ }
629
690
  continue;
630
691
  }
631
692
  const patch = diffJsonObjects(previous, current);
@@ -640,6 +701,22 @@ var MetadataManager = class {
640
701
  clear() {
641
702
  this.state.metadata.clear();
642
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
+ }
643
720
  computeDocRangeKeys(query) {
644
721
  if (!query) return {};
645
722
  const prefix = query.prefix && query.prefix.length > 0 ? query.prefix : void 0;
@@ -665,7 +742,9 @@ var MetadataManager = class {
665
742
  if (!rows.length) return void 0;
666
743
  const docMeta = {};
667
744
  let populated = false;
745
+ let sawRow = false;
668
746
  for (const row of rows) {
747
+ sawRow = true;
669
748
  if (!Array.isArray(row.key) || row.key.length < 2) continue;
670
749
  if (row.key.length === 2) {
671
750
  const obj = asJsonObject(row.value);
@@ -681,17 +760,33 @@ var MetadataManager = class {
681
760
  }
682
761
  const fieldKey = row.key[2];
683
762
  if (typeof fieldKey !== "string") continue;
684
- if (fieldKey === "$tombstone") {
685
- docMeta.tombstone = Boolean(row.value);
686
- populated = true;
687
- continue;
688
- }
689
763
  const jsonValue = cloneJsonValue(row.value);
690
764
  if (jsonValue === void 0) continue;
691
765
  docMeta[fieldKey] = jsonValue;
692
766
  populated = true;
693
767
  }
694
- 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;
695
790
  }
696
791
  get metaFlock() {
697
792
  return this.getMetaFlock();
@@ -705,7 +800,6 @@ var AssetManager = class {
705
800
  assetTransport;
706
801
  getMetaFlock;
707
802
  eventBus;
708
- persistMeta;
709
803
  state;
710
804
  get docAssets() {
711
805
  return this.state.docAssets;
@@ -724,7 +818,6 @@ var AssetManager = class {
724
818
  this.assetTransport = options.assetTransport;
725
819
  this.getMetaFlock = options.getMetaFlock;
726
820
  this.eventBus = options.eventBus;
727
- this.persistMeta = options.persistMeta;
728
821
  this.state = options.state;
729
822
  }
730
823
  async uploadAsset(params) {
@@ -761,7 +854,6 @@ var AssetManager = class {
761
854
  if (metadataMutated) {
762
855
  existing.metadata = metadata$1;
763
856
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata$1));
764
- await this.persistMeta();
765
857
  this.eventBus.emit({
766
858
  kind: "asset-metadata",
767
859
  asset: this.createAssetDownload(assetId, metadata$1, bytes),
@@ -798,7 +890,6 @@ var AssetManager = class {
798
890
  this.markAssetAsOrphan(assetId, metadata);
799
891
  this.updateDocAssetMetadata(assetId, metadata);
800
892
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
801
- await this.persistMeta();
802
893
  this.eventBus.emit({
803
894
  kind: "asset-metadata",
804
895
  asset: this.createAssetDownload(assetId, metadata, storedBytes),
@@ -857,7 +948,6 @@ var AssetManager = class {
857
948
  existing.metadata = nextMetadata;
858
949
  metadata = nextMetadata;
859
950
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
860
- await this.persistMeta();
861
951
  this.eventBus.emit({
862
952
  kind: "asset-metadata",
863
953
  asset: this.createAssetDownload(assetId, metadata, bytes),
@@ -904,7 +994,6 @@ var AssetManager = class {
904
994
  docId,
905
995
  assetId
906
996
  ], true);
907
- await this.persistMeta();
908
997
  this.eventBus.emit({
909
998
  kind: "asset-link",
910
999
  docId,
@@ -929,7 +1018,6 @@ var AssetManager = class {
929
1018
  assetId
930
1019
  ]);
931
1020
  this.removeDocAssetReference(assetId, docId);
932
- await this.persistMeta();
933
1021
  this.eventBus.emit({
934
1022
  kind: "asset-unlink",
935
1023
  docId,
@@ -937,6 +1025,11 @@ var AssetManager = class {
937
1025
  by: "local"
938
1026
  });
939
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
+ }
940
1033
  async listAssets(docId) {
941
1034
  const mapping = this.docAssets.get(docId);
942
1035
  if (!mapping) return [];
@@ -1213,7 +1306,6 @@ var AssetManager = class {
1213
1306
  this.assets.set(assetId, { metadata });
1214
1307
  this.updateDocAssetMetadata(assetId, metadata);
1215
1308
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
1216
- await this.persistMeta();
1217
1309
  if (this.storage) await this.storage.save({
1218
1310
  type: "asset",
1219
1311
  assetId,
@@ -1286,14 +1378,35 @@ var FlockHydrator = class {
1286
1378
  this.docManager = options.docManager;
1287
1379
  }
1288
1380
  hydrateAll(by) {
1289
- const nextMetadata = this.readAllDocMetadata();
1290
- 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);
1291
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
+ })();
1292
1403
  }
1293
- applyEvents(events, by) {
1404
+ async applyEvents(events, by) {
1294
1405
  if (!events.length) return;
1295
1406
  const docMetadataIds = /* @__PURE__ */ new Set();
1407
+ const docTombstoneIds = /* @__PURE__ */ new Map();
1296
1408
  const docAssetIds = /* @__PURE__ */ new Set();
1409
+ const docFrontierIds = /* @__PURE__ */ new Set();
1297
1410
  const assetIds = /* @__PURE__ */ new Set();
1298
1411
  for (const event of events) {
1299
1412
  const key = event.key;
@@ -1302,6 +1415,12 @@ var FlockHydrator = class {
1302
1415
  if (root === "m") {
1303
1416
  const docId = key[1];
1304
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));
1305
1424
  } else if (root === "a") {
1306
1425
  const assetId = key[1];
1307
1426
  if (typeof assetId === "string") assetIds.add(assetId);
@@ -1314,7 +1433,38 @@ var FlockHydrator = class {
1314
1433
  }
1315
1434
  for (const assetId of assetIds) this.assetManager.refreshAssetMetadataEntry(assetId, by);
1316
1435
  for (const docId of docMetadataIds) this.metadataManager.refreshFromFlock(docId, by);
1436
+ for (const [docId, deletedAtMs] of docTombstoneIds) this.metadataManager.emitSoftDeleted(docId, deletedAtMs, by);
1317
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
+ }
1318
1468
  }
1319
1469
  readAllDocMetadata() {
1320
1470
  const nextMetadata = /* @__PURE__ */ new Map();
@@ -1323,35 +1473,86 @@ var FlockHydrator = class {
1323
1473
  if (!Array.isArray(row.key) || row.key.length < 2) continue;
1324
1474
  const docId = row.key[1];
1325
1475
  if (typeof docId !== "string") continue;
1326
- let docMeta = nextMetadata.get(docId);
1327
- if (!docMeta) {
1328
- docMeta = {};
1329
- nextMetadata.set(docId, docMeta);
1330
- }
1476
+ if (row.value === void 0 && !this.metadataManager.get(docId)) continue;
1331
1477
  if (row.key.length === 2) {
1332
1478
  const obj = asJsonObject(row.value);
1333
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
+ }
1334
1485
  for (const [field, value] of Object.entries(obj)) {
1335
1486
  const cloned = cloneJsonValue(value);
1336
- if (cloned !== void 0) docMeta[field] = cloned;
1487
+ if (cloned !== void 0) docMeta$1[field] = cloned;
1337
1488
  }
1338
1489
  continue;
1339
1490
  }
1340
1491
  const fieldKey = row.key[2];
1341
1492
  if (typeof fieldKey !== "string") continue;
1342
- if (fieldKey === "$tombstone") {
1343
- docMeta.tombstone = Boolean(row.value);
1344
- continue;
1345
- }
1346
1493
  const jsonValue = cloneJsonValue(row.value);
1347
1494
  if (jsonValue === void 0) continue;
1495
+ let docMeta = nextMetadata.get(docId);
1496
+ if (!docMeta) {
1497
+ docMeta = {};
1498
+ nextMetadata.set(docId, docMeta);
1499
+ }
1348
1500
  docMeta[fieldKey] = jsonValue;
1349
1501
  }
1350
- 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;
1351
1518
  }
1352
1519
  get metaFlock() {
1353
1520
  return this.getMetaFlock();
1354
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
+ }
1355
1556
  };
1356
1557
 
1357
1558
  //#endregion
@@ -1369,7 +1570,6 @@ var SyncRunner = class {
1369
1570
  flockHydrator;
1370
1571
  getMetaFlock;
1371
1572
  replaceMetaFlock;
1372
- persistMeta;
1373
1573
  readyPromise;
1374
1574
  metaRoomSubscription;
1375
1575
  unsubscribeMetaFlock;
@@ -1384,7 +1584,6 @@ var SyncRunner = class {
1384
1584
  this.flockHydrator = options.flockHydrator;
1385
1585
  this.getMetaFlock = options.getMetaFlock;
1386
1586
  this.replaceMetaFlock = options.mergeFlock;
1387
- this.persistMeta = options.persistMeta;
1388
1587
  }
1389
1588
  async ready() {
1390
1589
  if (!this.readyPromise) this.readyPromise = this.initialize();
@@ -1404,9 +1603,8 @@ var SyncRunner = class {
1404
1603
  });
1405
1604
  try {
1406
1605
  if (!(await this.transport.syncMeta(this.metaFlock)).ok) throw new Error("Metadata sync failed");
1407
- if (recordedEvents.length > 0) this.flockHydrator.applyEvents(recordedEvents, "sync");
1606
+ if (recordedEvents.length > 0) await this.flockHydrator.applyEvents(recordedEvents, "sync");
1408
1607
  else this.flockHydrator.hydrateAll("sync");
1409
- await this.persistMeta();
1410
1608
  } finally {
1411
1609
  unsubscribe();
1412
1610
  this.eventBus.popEventBy();
@@ -1431,63 +1629,105 @@ var SyncRunner = class {
1431
1629
  await this.ready();
1432
1630
  if (!this.transport) throw new Error("Transport adapter not configured");
1433
1631
  if (!this.transport.isConnected()) await this.transport.connect();
1434
- if (this.metaRoomSubscription) return this.metaRoomSubscription;
1632
+ const existing = this.metaRoomSubscription;
1633
+ if (existing) {
1634
+ existing.refCount += 1;
1635
+ return this.createMetaLease(existing);
1636
+ }
1435
1637
  this.ensureMetaLiveMonitor();
1436
- const subscription = this.transport.joinMetaRoom(this.metaFlock, params);
1437
- const wrapped = {
1438
- unsubscribe: () => {
1439
- subscription.unsubscribe();
1440
- if (this.metaRoomSubscription === wrapped) this.metaRoomSubscription = void 0;
1441
- if (this.unsubscribeMetaFlock) {
1442
- this.unsubscribeMetaFlock();
1443
- this.unsubscribeMetaFlock = void 0;
1444
- }
1445
- },
1446
- firstSyncedWithRemote: subscription.firstSyncedWithRemote,
1447
- get connected() {
1448
- return subscription.connected;
1449
- }
1638
+ const base = this.transport.joinMetaRoom(this.metaFlock, params);
1639
+ const record = {
1640
+ base,
1641
+ refCount: 1
1450
1642
  };
1451
- this.metaRoomSubscription = wrapped;
1452
- subscription.firstSyncedWithRemote.then(async () => {
1643
+ this.metaRoomSubscription = record;
1644
+ base.firstSyncedWithRemote.then(async () => {
1453
1645
  const by = this.eventBus.resolveEventBy("live");
1454
1646
  this.flockHydrator.hydrateAll(by);
1455
- await this.persistMeta();
1456
1647
  }).catch(logAsyncError("meta room first sync"));
1457
- return wrapped;
1648
+ return this.createMetaLease(record);
1458
1649
  }
1459
1650
  async joinDocRoom(docId, params) {
1460
1651
  await this.ready();
1461
1652
  if (!this.transport) throw new Error("Transport adapter not configured");
1462
1653
  if (!this.transport.isConnected()) await this.transport.connect();
1463
1654
  const existing = this.docSubscriptions.get(docId);
1464
- if (existing) return existing;
1655
+ if (existing) {
1656
+ existing.refCount += 1;
1657
+ return this.createDocLease(docId, existing);
1658
+ }
1465
1659
  const doc = await this.docManager.ensureDoc(docId);
1466
- const subscription = this.transport.joinDocRoom(docId, doc, params);
1467
- 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 {
1468
1672
  unsubscribe: () => {
1469
- subscription.unsubscribe();
1470
- 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
+ }
1471
1686
  },
1472
- firstSyncedWithRemote: subscription.firstSyncedWithRemote,
1687
+ firstSyncedWithRemote: record.base.firstSyncedWithRemote,
1473
1688
  get connected() {
1474
- return subscription.connected;
1475
- }
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
1476
1719
  };
1477
- this.docSubscriptions.set(docId, wrapped);
1478
- subscription.firstSyncedWithRemote.catch(logAsyncError(`doc ${docId} first sync`));
1479
- return wrapped;
1480
1720
  }
1481
1721
  async destroy() {
1482
1722
  await this.docManager.close();
1483
- this.metaRoomSubscription?.unsubscribe();
1484
- this.metaRoomSubscription = void 0;
1485
- for (const sub of this.docSubscriptions.values()) sub.unsubscribe();
1486
- this.docSubscriptions.clear();
1487
- if (this.unsubscribeMetaFlock) {
1488
- this.unsubscribeMetaFlock();
1489
- this.unsubscribeMetaFlock = void 0;
1723
+ if (this.metaRoomSubscription) {
1724
+ this.metaRoomSubscription.base.unsubscribe();
1725
+ this.metaRoomSubscription = void 0;
1490
1726
  }
1727
+ for (const record of this.docSubscriptions.values()) record.base.unsubscribe();
1728
+ this.docSubscriptions.clear();
1729
+ this.unsubscribeMetaFlock?.();
1730
+ this.unsubscribeMetaFlock = void 0;
1491
1731
  this.eventBus.clear();
1492
1732
  this.metadataManager.clear();
1493
1733
  this.assetManager.clear();
@@ -1507,8 +1747,7 @@ var SyncRunner = class {
1507
1747
  if (batch.source === "local") return;
1508
1748
  const by = this.eventBus.resolveEventBy("live");
1509
1749
  (async () => {
1510
- this.flockHydrator.applyEvents(batch.events, by);
1511
- await this.persistMeta();
1750
+ await this.flockHydrator.applyEvents(batch.events, by);
1512
1751
  })().catch(logAsyncError("meta live monitor sync"));
1513
1752
  });
1514
1753
  }
@@ -1530,9 +1769,152 @@ function createRepoState() {
1530
1769
  }
1531
1770
 
1532
1771
  //#endregion
1533
- //#region src/index.ts
1772
+ //#region src/internal/meta-persister.ts
1534
1773
  const textEncoder = new TextEncoder();
1774
+ const DEFAULT_META_PERSIST_DEBOUNCE_MS = 5e3;
1775
+ var MetaPersister = class {
1776
+ getMetaFlock;
1777
+ storage;
1778
+ debounceMs;
1779
+ lastPersistedVersion;
1780
+ unsubscribe;
1781
+ flushPromise = Promise.resolve();
1782
+ flushTimer;
1783
+ forceFullOnNextFlush = false;
1784
+ destroyed = false;
1785
+ constructor(options) {
1786
+ this.getMetaFlock = options.getMetaFlock;
1787
+ this.storage = options.storage;
1788
+ const configuredDebounce = options.debounceMs;
1789
+ this.debounceMs = typeof configuredDebounce === "number" && Number.isFinite(configuredDebounce) && configuredDebounce >= 0 ? configuredDebounce : DEFAULT_META_PERSIST_DEBOUNCE_MS;
1790
+ }
1791
+ start(initialVersion) {
1792
+ this.lastPersistedVersion = initialVersion;
1793
+ if (this.unsubscribe) return;
1794
+ this.unsubscribe = this.metaFlock.subscribe(() => {
1795
+ this.scheduleFlush();
1796
+ });
1797
+ }
1798
+ async destroy() {
1799
+ this.destroyed = true;
1800
+ if (this.flushTimer) {
1801
+ clearTimeout(this.flushTimer);
1802
+ this.flushTimer = void 0;
1803
+ }
1804
+ if (this.unsubscribe) {
1805
+ this.unsubscribe();
1806
+ this.unsubscribe = void 0;
1807
+ }
1808
+ await this.flushNow();
1809
+ }
1810
+ async flushNow(forceFull = false) {
1811
+ if (this.flushTimer) {
1812
+ clearTimeout(this.flushTimer);
1813
+ this.flushTimer = void 0;
1814
+ }
1815
+ await this.flush(forceFull);
1816
+ }
1817
+ scheduleFlush() {
1818
+ if (this.destroyed) return;
1819
+ if (this.debounceMs === 0) {
1820
+ this.flush();
1821
+ return;
1822
+ }
1823
+ if (this.flushTimer) clearTimeout(this.flushTimer);
1824
+ this.flushTimer = setTimeout(() => {
1825
+ this.flushTimer = void 0;
1826
+ this.flush();
1827
+ }, this.debounceMs);
1828
+ }
1829
+ async flush(forceFull = false) {
1830
+ if (forceFull) this.forceFullOnNextFlush = true;
1831
+ const run = this.flushPromise.catch(() => {}).then(() => this.flushInternal());
1832
+ this.flushPromise = run;
1833
+ await run;
1834
+ }
1835
+ async flushInternal() {
1836
+ const flock = this.metaFlock;
1837
+ const currentVersion = flock.version();
1838
+ if (this.lastPersistedVersion && this.versionsEqual(currentVersion, this.lastPersistedVersion)) {
1839
+ this.forceFullOnNextFlush = false;
1840
+ return;
1841
+ }
1842
+ const baseline = this.forceFullOnNextFlush ? void 0 : this.lastPersistedVersion;
1843
+ const rawBundle = baseline ? flock.exportJson(baseline) : flock.exportJson();
1844
+ const bundle = baseline ? this.stripUnchangedEntries(rawBundle, baseline) : rawBundle;
1845
+ if (Object.keys(bundle.entries).length === 0) {
1846
+ this.forceFullOnNextFlush = false;
1847
+ this.lastPersistedVersion = currentVersion;
1848
+ return;
1849
+ }
1850
+ const encoded = textEncoder.encode(JSON.stringify(bundle));
1851
+ if (!this.storage) {
1852
+ this.lastPersistedVersion = currentVersion;
1853
+ this.forceFullOnNextFlush = false;
1854
+ return;
1855
+ }
1856
+ await this.storage.save({
1857
+ type: "meta",
1858
+ update: encoded
1859
+ });
1860
+ this.lastPersistedVersion = currentVersion;
1861
+ this.forceFullOnNextFlush = false;
1862
+ }
1863
+ get metaFlock() {
1864
+ return this.getMetaFlock();
1865
+ }
1866
+ stripUnchangedEntries(bundle, baseline) {
1867
+ const entries = {};
1868
+ for (const [key, record] of Object.entries(bundle.entries)) {
1869
+ const clock = this.parseClock(record.c);
1870
+ if (!clock) {
1871
+ entries[key] = record;
1872
+ continue;
1873
+ }
1874
+ const baselineEntry = baseline[clock.peerIdHex];
1875
+ if (!baselineEntry) {
1876
+ entries[key] = record;
1877
+ continue;
1878
+ }
1879
+ if (clock.physicalTime > baselineEntry.physicalTime || clock.physicalTime === baselineEntry.physicalTime && clock.logicalCounter > baselineEntry.logicalCounter) entries[key] = record;
1880
+ }
1881
+ return {
1882
+ version: bundle.version,
1883
+ entries
1884
+ };
1885
+ }
1886
+ parseClock(raw) {
1887
+ if (typeof raw !== "string") return void 0;
1888
+ const [physicalTimeStr, logicalCounterStr, peerIdHex] = raw.split(",");
1889
+ if (!physicalTimeStr || !logicalCounterStr || !peerIdHex) return void 0;
1890
+ const physicalTime = Number(physicalTimeStr);
1891
+ const logicalCounter = Number(logicalCounterStr);
1892
+ if (!Number.isFinite(physicalTime) || !Number.isFinite(logicalCounter)) return void 0;
1893
+ return {
1894
+ physicalTime,
1895
+ logicalCounter,
1896
+ peerIdHex
1897
+ };
1898
+ }
1899
+ versionsEqual(a, b) {
1900
+ if (!a || !b) return false;
1901
+ const aKeys = Object.keys(a);
1902
+ const bKeys = Object.keys(b);
1903
+ if (aKeys.length !== bKeys.length) return false;
1904
+ for (const key of aKeys) {
1905
+ const aEntry = a[key];
1906
+ const bEntry = b[key];
1907
+ if (!aEntry || !bEntry) return false;
1908
+ if (aEntry.physicalTime !== bEntry.physicalTime || aEntry.logicalCounter !== bEntry.logicalCounter) return false;
1909
+ }
1910
+ return true;
1911
+ }
1912
+ };
1913
+
1914
+ //#endregion
1915
+ //#region src/index.ts
1535
1916
  const DEFAULT_DOC_FRONTIER_DEBOUNCE_MS = 1e3;
1917
+ const DEFAULT_DELETED_DOC_KEEP_MS = 720 * 60 * 60 * 1e3;
1536
1918
  var LoroRepo = class LoroRepo {
1537
1919
  options;
1538
1920
  _destroyed = false;
@@ -1547,6 +1929,9 @@ var LoroRepo = class LoroRepo {
1547
1929
  flockHydrator;
1548
1930
  state;
1549
1931
  syncRunner;
1932
+ metaPersister;
1933
+ deletedDocKeepMs;
1934
+ purgeWatchHandle;
1550
1935
  constructor(options) {
1551
1936
  this.options = options;
1552
1937
  this.transport = options.transportAdapter;
@@ -1556,17 +1941,17 @@ var LoroRepo = class LoroRepo {
1556
1941
  this.state = createRepoState();
1557
1942
  const configuredDebounce = options.docFrontierDebounceMs;
1558
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;
1559
1946
  this.docManager = new DocManager({
1560
1947
  storage: this.storage,
1561
1948
  docFrontierDebounceMs,
1562
1949
  getMetaFlock: () => this.metaFlock,
1563
- eventBus: this.eventBus,
1564
- persistMeta: () => this.persistMeta()
1950
+ eventBus: this.eventBus
1565
1951
  });
1566
1952
  this.metadataManager = new MetadataManager({
1567
1953
  getMetaFlock: () => this.metaFlock,
1568
1954
  eventBus: this.eventBus,
1569
- persistMeta: () => this.persistMeta(),
1570
1955
  state: this.state
1571
1956
  });
1572
1957
  this.assetManager = new AssetManager({
@@ -1574,9 +1959,13 @@ var LoroRepo = class LoroRepo {
1574
1959
  assetTransport: this.assetTransport,
1575
1960
  getMetaFlock: () => this.metaFlock,
1576
1961
  eventBus: this.eventBus,
1577
- persistMeta: () => this.persistMeta(),
1578
1962
  state: this.state
1579
1963
  });
1964
+ this.metaPersister = new MetaPersister({
1965
+ getMetaFlock: () => this.metaFlock,
1966
+ storage: this.storage,
1967
+ debounceMs: options.metaPersistDebounceMs
1968
+ });
1580
1969
  this.flockHydrator = new FlockHydrator({
1581
1970
  getMetaFlock: () => this.metaFlock,
1582
1971
  metadataManager: this.metadataManager,
@@ -1594,8 +1983,11 @@ var LoroRepo = class LoroRepo {
1594
1983
  getMetaFlock: () => this.metaFlock,
1595
1984
  mergeFlock: (snapshot) => {
1596
1985
  this.metaFlock.merge(snapshot);
1597
- },
1598
- persistMeta: () => this.persistMeta()
1986
+ }
1987
+ });
1988
+ this.purgeWatchHandle = this.eventBus.watch((event) => this.handlePurgeSignals(event), {
1989
+ kinds: ["doc-soft-deleted", "doc-metadata"],
1990
+ by: ["sync", "live"]
1599
1991
  });
1600
1992
  }
1601
1993
  static async create(options) {
@@ -1612,6 +2004,19 @@ var LoroRepo = class LoroRepo {
1612
2004
  */
1613
2005
  async ready() {
1614
2006
  await this.syncRunner.ready();
2007
+ this.metaPersister.start(this.metaFlock.version());
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]);
1615
2020
  }
1616
2021
  /**
1617
2022
  * Sync selected data via the transport adaptor
@@ -1623,23 +2028,64 @@ var LoroRepo = class LoroRepo {
1623
2028
  /**
1624
2029
  * Start syncing the metadata (Flock) room. It will establish a realtime connection to the transport adaptor.
1625
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`.
1626
2035
  * @param params
1627
2036
  * @returns
1628
2037
  */
1629
2038
  async joinMetaRoom(params) {
1630
- return this.syncRunner.joinMetaRoom(params);
2039
+ const subscription = await this.syncRunner.joinMetaRoom(params);
2040
+ return {
2041
+ unsubscribe: subscription.unsubscribe,
2042
+ get connected() {
2043
+ return subscription.connected;
2044
+ },
2045
+ get status() {
2046
+ return subscription.status;
2047
+ },
2048
+ onStatusChange: subscription.onStatusChange,
2049
+ firstSyncedWithRemote: subscription.firstSyncedWithRemote.then(async () => {
2050
+ await this.metaPersister.flushNow();
2051
+ })
2052
+ };
1631
2053
  }
1632
2054
  /**
1633
2055
  * Start syncing the given doc. It will establish a realtime connection to the transport adaptor.
1634
2056
  * All changes on the doc will be synced to the transport, and all changes on the transport will be synced to the doc.
1635
2057
  *
1636
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.
1637
2063
  * @param docId
1638
2064
  * @param params
1639
2065
  * @returns
1640
2066
  */
1641
2067
  async joinDocRoom(docId, params) {
1642
- 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
+ };
1643
2089
  }
1644
2090
  /**
1645
2091
  * Opens a document that is automatically persisted to the configured storage adapter.
@@ -1671,6 +2117,61 @@ var LoroRepo = class LoroRepo {
1671
2117
  async listDoc(query) {
1672
2118
  return this.metadataManager.listDoc(query);
1673
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
+ }
1674
2175
  getMeta() {
1675
2176
  return this.metaFlock;
1676
2177
  }
@@ -1699,6 +2200,7 @@ var LoroRepo = class LoroRepo {
1699
2200
  }
1700
2201
  async flush() {
1701
2202
  await this.docManager.flush();
2203
+ await this.metaPersister.flushNow();
1702
2204
  }
1703
2205
  async uploadAsset(params) {
1704
2206
  return this.assetManager.uploadAsset(params);
@@ -1721,26 +2223,35 @@ var LoroRepo = class LoroRepo {
1721
2223
  async gcAssets(options = {}) {
1722
2224
  return this.assetManager.gcAssets(options);
1723
2225
  }
1724
- async persistMeta() {
1725
- if (!this.storage) return;
1726
- const bundle = this.metaFlock.exportJson();
1727
- const encoded = textEncoder.encode(JSON.stringify(bundle));
1728
- await this.storage.save({
1729
- type: "meta",
1730
- update: encoded
1731
- });
1732
- }
1733
2226
  get destroyed() {
1734
2227
  return this._destroyed;
1735
2228
  }
1736
2229
  async destroy() {
1737
2230
  if (this._destroyed) return;
1738
2231
  this._destroyed = true;
2232
+ this.purgeWatchHandle.unsubscribe();
2233
+ await this.metaPersister.destroy();
1739
2234
  await this.syncRunner.destroy();
1740
2235
  this.assetTransport?.close?.();
1741
2236
  this.storage?.close?.();
1742
2237
  await this.transport?.close();
1743
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
+ }
1744
2255
  };
1745
2256
 
1746
2257
  //#endregion