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.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;
@@ -65,7 +65,6 @@ var DocManager = class {
65
65
  docFrontierDebounceMs;
66
66
  getMetaFlock;
67
67
  eventBus;
68
- persistMeta;
69
68
  docs = /* @__PURE__ */ new Map();
70
69
  docSubscriptions = /* @__PURE__ */ new Map();
71
70
  docFrontierUpdates = /* @__PURE__ */ new Map();
@@ -75,7 +74,6 @@ var DocManager = class {
75
74
  this.docFrontierDebounceMs = options.docFrontierDebounceMs;
76
75
  this.getMetaFlock = options.getMetaFlock;
77
76
  this.eventBus = options.eventBus;
78
- this.persistMeta = options.persistMeta;
79
77
  }
80
78
  async openPersistedDoc(docId) {
81
79
  return await this.ensureDoc(docId);
@@ -136,16 +134,13 @@ var DocManager = class {
136
134
  ], f.counter);
137
135
  mutated = true;
138
136
  }
139
- if (mutated) {
140
- 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
- ]);
147
- }
148
- await this.persistMeta();
137
+ if (mutated) for (const [peer, counter] of existingFrontiers) {
138
+ const docCounterEnd = vv.get(peer);
139
+ if (docCounterEnd != null && docCounterEnd > counter) metaFlock.delete([
140
+ "f",
141
+ docId,
142
+ peer
143
+ ]);
149
144
  }
150
145
  const by = this.eventBus.resolveEventBy(defaultBy);
151
146
  this.eventBus.emit({
@@ -179,6 +174,18 @@ var DocManager = class {
179
174
  this.docs.delete(docId);
180
175
  this.docPersistedVersions.delete(docId);
181
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
+ }
182
189
  async flush() {
183
190
  const promises = [];
184
191
  for (const [docId, doc] of this.docs) promises.push((async () => {
@@ -499,22 +506,51 @@ function matchesQuery(docId, _metadata, query) {
499
506
  var MetadataManager = class {
500
507
  getMetaFlock;
501
508
  eventBus;
502
- persistMeta;
503
509
  state;
504
510
  constructor(options) {
505
511
  this.getMetaFlock = options.getMetaFlock;
506
512
  this.eventBus = options.eventBus;
507
- this.persistMeta = options.persistMeta;
508
513
  this.state = options.state;
509
514
  }
510
515
  getDocIds() {
511
- 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);
512
524
  }
513
525
  entries() {
514
526
  return this.state.metadata.entries();
515
527
  }
516
528
  get(docId) {
517
- 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");
518
554
  }
519
555
  listDoc(query) {
520
556
  if (query?.limit !== void 0 && query.limit <= 0) return [];
@@ -529,25 +565,49 @@ var MetadataManager = class {
529
565
  kind: "exclusive",
530
566
  key: ["m", endKey]
531
567
  };
532
- const rows = this.metaFlock.scan(scanOptions);
533
568
  const seen = /* @__PURE__ */ new Set();
534
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);
535
584
  for (const row of rows) {
536
585
  if (query?.limit !== void 0 && entries.length >= query.limit) break;
537
586
  if (!Array.isArray(row.key) || row.key.length < 2) continue;
538
587
  const docId = row.key[1];
539
588
  if (typeof docId !== "string") continue;
540
- if (seen.has(docId)) continue;
541
- seen.add(docId);
542
- const metadata = this.state.metadata.get(docId);
543
- if (!metadata) continue;
544
- if (!matchesQuery(docId, metadata, query)) continue;
545
- entries.push({
546
- docId,
547
- meta: cloneJsonObject(metadata)
548
- });
589
+ pushDoc(docId);
549
590
  if (query?.limit !== void 0 && entries.length >= query.limit) break;
550
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
+ }
551
611
  return entries;
552
612
  }
553
613
  async upsert(docId, patch) {
@@ -560,12 +620,10 @@ var MetadataManager = class {
560
620
  const rawValue = patchObject[key];
561
621
  if (rawValue === void 0) continue;
562
622
  if (jsonEquals(base ? base[key] : void 0, rawValue)) continue;
563
- const storageKey = key === "tombstone" ? "$tombstone" : key;
564
- console.log("upserting", rawValue);
565
623
  this.metaFlock.put([
566
624
  "m",
567
625
  docId,
568
- storageKey
626
+ key
569
627
  ], rawValue);
570
628
  next[key] = rawValue;
571
629
  outPatch[key] = rawValue;
@@ -576,7 +634,6 @@ var MetadataManager = class {
576
634
  return;
577
635
  }
578
636
  this.state.metadata.set(docId, next);
579
- await this.persistMeta();
580
637
  this.eventBus.emit({
581
638
  kind: "doc-metadata",
582
639
  docId,
@@ -585,10 +642,10 @@ var MetadataManager = class {
585
642
  });
586
643
  }
587
644
  refreshFromFlock(docId, by) {
588
- const previous = this.state.metadata.get(docId);
589
- const next = this.readDocMetadataFromFlock(docId);
590
- if (!next) {
591
- if (previous) {
645
+ const previousMeta = this.state.metadata.get(docId);
646
+ const nextMeta = this.readDocMetadataFromFlock(docId);
647
+ if (!nextMeta) {
648
+ if (previousMeta) {
592
649
  this.state.metadata.delete(docId);
593
650
  this.eventBus.emit({
594
651
  kind: "doc-metadata",
@@ -599,9 +656,9 @@ var MetadataManager = class {
599
656
  }
600
657
  return;
601
658
  }
602
- this.state.metadata.set(docId, next);
603
- const patch = diffJsonObjects(previous, next);
604
- 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({
605
662
  kind: "doc-metadata",
606
663
  docId,
607
664
  patch,
@@ -617,12 +674,16 @@ var MetadataManager = class {
617
674
  const previous = prevMetadata.get(docId);
618
675
  const current = nextMetadata.get(docId);
619
676
  if (!current) {
620
- if (previous) this.eventBus.emit({
621
- kind: "doc-metadata",
622
- docId,
623
- patch: {},
624
- by
625
- });
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
+ }
626
687
  continue;
627
688
  }
628
689
  const patch = diffJsonObjects(previous, current);
@@ -637,6 +698,22 @@ var MetadataManager = class {
637
698
  clear() {
638
699
  this.state.metadata.clear();
639
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
+ }
640
717
  computeDocRangeKeys(query) {
641
718
  if (!query) return {};
642
719
  const prefix = query.prefix && query.prefix.length > 0 ? query.prefix : void 0;
@@ -662,7 +739,9 @@ var MetadataManager = class {
662
739
  if (!rows.length) return void 0;
663
740
  const docMeta = {};
664
741
  let populated = false;
742
+ let sawRow = false;
665
743
  for (const row of rows) {
744
+ sawRow = true;
666
745
  if (!Array.isArray(row.key) || row.key.length < 2) continue;
667
746
  if (row.key.length === 2) {
668
747
  const obj = asJsonObject(row.value);
@@ -678,17 +757,33 @@ var MetadataManager = class {
678
757
  }
679
758
  const fieldKey = row.key[2];
680
759
  if (typeof fieldKey !== "string") continue;
681
- if (fieldKey === "$tombstone") {
682
- docMeta.tombstone = Boolean(row.value);
683
- populated = true;
684
- continue;
685
- }
686
760
  const jsonValue = cloneJsonValue(row.value);
687
761
  if (jsonValue === void 0) continue;
688
762
  docMeta[fieldKey] = jsonValue;
689
763
  populated = true;
690
764
  }
691
- 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;
692
787
  }
693
788
  get metaFlock() {
694
789
  return this.getMetaFlock();
@@ -702,7 +797,6 @@ var AssetManager = class {
702
797
  assetTransport;
703
798
  getMetaFlock;
704
799
  eventBus;
705
- persistMeta;
706
800
  state;
707
801
  get docAssets() {
708
802
  return this.state.docAssets;
@@ -721,7 +815,6 @@ var AssetManager = class {
721
815
  this.assetTransport = options.assetTransport;
722
816
  this.getMetaFlock = options.getMetaFlock;
723
817
  this.eventBus = options.eventBus;
724
- this.persistMeta = options.persistMeta;
725
818
  this.state = options.state;
726
819
  }
727
820
  async uploadAsset(params) {
@@ -758,7 +851,6 @@ var AssetManager = class {
758
851
  if (metadataMutated) {
759
852
  existing.metadata = metadata$1;
760
853
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata$1));
761
- await this.persistMeta();
762
854
  this.eventBus.emit({
763
855
  kind: "asset-metadata",
764
856
  asset: this.createAssetDownload(assetId, metadata$1, bytes),
@@ -795,7 +887,6 @@ var AssetManager = class {
795
887
  this.markAssetAsOrphan(assetId, metadata);
796
888
  this.updateDocAssetMetadata(assetId, metadata);
797
889
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
798
- await this.persistMeta();
799
890
  this.eventBus.emit({
800
891
  kind: "asset-metadata",
801
892
  asset: this.createAssetDownload(assetId, metadata, storedBytes),
@@ -854,7 +945,6 @@ var AssetManager = class {
854
945
  existing.metadata = nextMetadata;
855
946
  metadata = nextMetadata;
856
947
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
857
- await this.persistMeta();
858
948
  this.eventBus.emit({
859
949
  kind: "asset-metadata",
860
950
  asset: this.createAssetDownload(assetId, metadata, bytes),
@@ -901,7 +991,6 @@ var AssetManager = class {
901
991
  docId,
902
992
  assetId
903
993
  ], true);
904
- await this.persistMeta();
905
994
  this.eventBus.emit({
906
995
  kind: "asset-link",
907
996
  docId,
@@ -926,7 +1015,6 @@ var AssetManager = class {
926
1015
  assetId
927
1016
  ]);
928
1017
  this.removeDocAssetReference(assetId, docId);
929
- await this.persistMeta();
930
1018
  this.eventBus.emit({
931
1019
  kind: "asset-unlink",
932
1020
  docId,
@@ -934,6 +1022,11 @@ var AssetManager = class {
934
1022
  by: "local"
935
1023
  });
936
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
+ }
937
1030
  async listAssets(docId) {
938
1031
  const mapping = this.docAssets.get(docId);
939
1032
  if (!mapping) return [];
@@ -1210,7 +1303,6 @@ var AssetManager = class {
1210
1303
  this.assets.set(assetId, { metadata });
1211
1304
  this.updateDocAssetMetadata(assetId, metadata);
1212
1305
  this.metaFlock.put(["a", assetId], assetMetaToJson(metadata));
1213
- await this.persistMeta();
1214
1306
  if (this.storage) await this.storage.save({
1215
1307
  type: "asset",
1216
1308
  assetId,
@@ -1283,14 +1375,35 @@ var FlockHydrator = class {
1283
1375
  this.docManager = options.docManager;
1284
1376
  }
1285
1377
  hydrateAll(by) {
1286
- const nextMetadata = this.readAllDocMetadata();
1287
- 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);
1288
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
+ })();
1289
1400
  }
1290
- applyEvents(events, by) {
1401
+ async applyEvents(events, by) {
1291
1402
  if (!events.length) return;
1292
1403
  const docMetadataIds = /* @__PURE__ */ new Set();
1404
+ const docTombstoneIds = /* @__PURE__ */ new Map();
1293
1405
  const docAssetIds = /* @__PURE__ */ new Set();
1406
+ const docFrontierIds = /* @__PURE__ */ new Set();
1294
1407
  const assetIds = /* @__PURE__ */ new Set();
1295
1408
  for (const event of events) {
1296
1409
  const key = event.key;
@@ -1299,6 +1412,12 @@ var FlockHydrator = class {
1299
1412
  if (root === "m") {
1300
1413
  const docId = key[1];
1301
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));
1302
1421
  } else if (root === "a") {
1303
1422
  const assetId = key[1];
1304
1423
  if (typeof assetId === "string") assetIds.add(assetId);
@@ -1311,7 +1430,38 @@ var FlockHydrator = class {
1311
1430
  }
1312
1431
  for (const assetId of assetIds) this.assetManager.refreshAssetMetadataEntry(assetId, by);
1313
1432
  for (const docId of docMetadataIds) this.metadataManager.refreshFromFlock(docId, by);
1433
+ for (const [docId, deletedAtMs] of docTombstoneIds) this.metadataManager.emitSoftDeleted(docId, deletedAtMs, by);
1314
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
+ }
1315
1465
  }
1316
1466
  readAllDocMetadata() {
1317
1467
  const nextMetadata = /* @__PURE__ */ new Map();
@@ -1320,35 +1470,86 @@ var FlockHydrator = class {
1320
1470
  if (!Array.isArray(row.key) || row.key.length < 2) continue;
1321
1471
  const docId = row.key[1];
1322
1472
  if (typeof docId !== "string") continue;
1323
- let docMeta = nextMetadata.get(docId);
1324
- if (!docMeta) {
1325
- docMeta = {};
1326
- nextMetadata.set(docId, docMeta);
1327
- }
1473
+ if (row.value === void 0 && !this.metadataManager.get(docId)) continue;
1328
1474
  if (row.key.length === 2) {
1329
1475
  const obj = asJsonObject(row.value);
1330
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
+ }
1331
1482
  for (const [field, value] of Object.entries(obj)) {
1332
1483
  const cloned = cloneJsonValue(value);
1333
- if (cloned !== void 0) docMeta[field] = cloned;
1484
+ if (cloned !== void 0) docMeta$1[field] = cloned;
1334
1485
  }
1335
1486
  continue;
1336
1487
  }
1337
1488
  const fieldKey = row.key[2];
1338
1489
  if (typeof fieldKey !== "string") continue;
1339
- if (fieldKey === "$tombstone") {
1340
- docMeta.tombstone = Boolean(row.value);
1341
- continue;
1342
- }
1343
1490
  const jsonValue = cloneJsonValue(row.value);
1344
1491
  if (jsonValue === void 0) continue;
1492
+ let docMeta = nextMetadata.get(docId);
1493
+ if (!docMeta) {
1494
+ docMeta = {};
1495
+ nextMetadata.set(docId, docMeta);
1496
+ }
1345
1497
  docMeta[fieldKey] = jsonValue;
1346
1498
  }
1347
- 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;
1348
1515
  }
1349
1516
  get metaFlock() {
1350
1517
  return this.getMetaFlock();
1351
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
+ }
1352
1553
  };
1353
1554
 
1354
1555
  //#endregion
@@ -1366,7 +1567,6 @@ var SyncRunner = class {
1366
1567
  flockHydrator;
1367
1568
  getMetaFlock;
1368
1569
  replaceMetaFlock;
1369
- persistMeta;
1370
1570
  readyPromise;
1371
1571
  metaRoomSubscription;
1372
1572
  unsubscribeMetaFlock;
@@ -1381,7 +1581,6 @@ var SyncRunner = class {
1381
1581
  this.flockHydrator = options.flockHydrator;
1382
1582
  this.getMetaFlock = options.getMetaFlock;
1383
1583
  this.replaceMetaFlock = options.mergeFlock;
1384
- this.persistMeta = options.persistMeta;
1385
1584
  }
1386
1585
  async ready() {
1387
1586
  if (!this.readyPromise) this.readyPromise = this.initialize();
@@ -1401,9 +1600,8 @@ var SyncRunner = class {
1401
1600
  });
1402
1601
  try {
1403
1602
  if (!(await this.transport.syncMeta(this.metaFlock)).ok) throw new Error("Metadata sync failed");
1404
- if (recordedEvents.length > 0) this.flockHydrator.applyEvents(recordedEvents, "sync");
1603
+ if (recordedEvents.length > 0) await this.flockHydrator.applyEvents(recordedEvents, "sync");
1405
1604
  else this.flockHydrator.hydrateAll("sync");
1406
- await this.persistMeta();
1407
1605
  } finally {
1408
1606
  unsubscribe();
1409
1607
  this.eventBus.popEventBy();
@@ -1428,63 +1626,105 @@ var SyncRunner = class {
1428
1626
  await this.ready();
1429
1627
  if (!this.transport) throw new Error("Transport adapter not configured");
1430
1628
  if (!this.transport.isConnected()) await this.transport.connect();
1431
- if (this.metaRoomSubscription) return this.metaRoomSubscription;
1629
+ const existing = this.metaRoomSubscription;
1630
+ if (existing) {
1631
+ existing.refCount += 1;
1632
+ return this.createMetaLease(existing);
1633
+ }
1432
1634
  this.ensureMetaLiveMonitor();
1433
- const subscription = this.transport.joinMetaRoom(this.metaFlock, params);
1434
- const wrapped = {
1435
- unsubscribe: () => {
1436
- subscription.unsubscribe();
1437
- if (this.metaRoomSubscription === wrapped) this.metaRoomSubscription = void 0;
1438
- if (this.unsubscribeMetaFlock) {
1439
- this.unsubscribeMetaFlock();
1440
- this.unsubscribeMetaFlock = void 0;
1441
- }
1442
- },
1443
- firstSyncedWithRemote: subscription.firstSyncedWithRemote,
1444
- get connected() {
1445
- return subscription.connected;
1446
- }
1635
+ const base = this.transport.joinMetaRoom(this.metaFlock, params);
1636
+ const record = {
1637
+ base,
1638
+ refCount: 1
1447
1639
  };
1448
- this.metaRoomSubscription = wrapped;
1449
- subscription.firstSyncedWithRemote.then(async () => {
1640
+ this.metaRoomSubscription = record;
1641
+ base.firstSyncedWithRemote.then(async () => {
1450
1642
  const by = this.eventBus.resolveEventBy("live");
1451
1643
  this.flockHydrator.hydrateAll(by);
1452
- await this.persistMeta();
1453
1644
  }).catch(logAsyncError("meta room first sync"));
1454
- return wrapped;
1645
+ return this.createMetaLease(record);
1455
1646
  }
1456
1647
  async joinDocRoom(docId, params) {
1457
1648
  await this.ready();
1458
1649
  if (!this.transport) throw new Error("Transport adapter not configured");
1459
1650
  if (!this.transport.isConnected()) await this.transport.connect();
1460
1651
  const existing = this.docSubscriptions.get(docId);
1461
- if (existing) return existing;
1652
+ if (existing) {
1653
+ existing.refCount += 1;
1654
+ return this.createDocLease(docId, existing);
1655
+ }
1462
1656
  const doc = await this.docManager.ensureDoc(docId);
1463
- const subscription = this.transport.joinDocRoom(docId, doc, params);
1464
- const wrapped = {
1657
+ const base = this.transport.joinDocRoom(docId, doc, params);
1658
+ const record = {
1659
+ base,
1660
+ refCount: 1
1661
+ };
1662
+ this.docSubscriptions.set(docId, record);
1663
+ base.firstSyncedWithRemote.catch(logAsyncError(`doc ${docId} first sync`));
1664
+ return this.createDocLease(docId, record);
1665
+ }
1666
+ createMetaLease(record) {
1667
+ let released = false;
1668
+ return {
1465
1669
  unsubscribe: () => {
1466
- subscription.unsubscribe();
1467
- if (this.docSubscriptions.get(docId) === wrapped) this.docSubscriptions.delete(docId);
1670
+ if (released) return;
1671
+ released = true;
1672
+ const current = this.metaRoomSubscription;
1673
+ if (!current || current !== record) return;
1674
+ current.refCount = Math.max(0, current.refCount - 1);
1675
+ if (current.refCount === 0) {
1676
+ current.base.unsubscribe();
1677
+ this.metaRoomSubscription = void 0;
1678
+ if (this.unsubscribeMetaFlock) {
1679
+ this.unsubscribeMetaFlock();
1680
+ this.unsubscribeMetaFlock = void 0;
1681
+ }
1682
+ }
1468
1683
  },
1469
- firstSyncedWithRemote: subscription.firstSyncedWithRemote,
1684
+ firstSyncedWithRemote: record.base.firstSyncedWithRemote,
1470
1685
  get connected() {
1471
- return subscription.connected;
1472
- }
1686
+ return record.base.connected;
1687
+ },
1688
+ get status() {
1689
+ return record.base.status;
1690
+ },
1691
+ onStatusChange: record.base.onStatusChange
1692
+ };
1693
+ }
1694
+ createDocLease(docId, record) {
1695
+ let released = false;
1696
+ return {
1697
+ unsubscribe: () => {
1698
+ if (released) return;
1699
+ released = true;
1700
+ const current = this.docSubscriptions.get(docId);
1701
+ if (!current || current !== record) return;
1702
+ current.refCount = Math.max(0, current.refCount - 1);
1703
+ if (current.refCount === 0) {
1704
+ current.base.unsubscribe();
1705
+ if (this.docSubscriptions.get(docId) === current) this.docSubscriptions.delete(docId);
1706
+ }
1707
+ },
1708
+ firstSyncedWithRemote: record.base.firstSyncedWithRemote,
1709
+ get connected() {
1710
+ return record.base.connected;
1711
+ },
1712
+ get status() {
1713
+ return record.base.status;
1714
+ },
1715
+ onStatusChange: record.base.onStatusChange
1473
1716
  };
1474
- this.docSubscriptions.set(docId, wrapped);
1475
- subscription.firstSyncedWithRemote.catch(logAsyncError(`doc ${docId} first sync`));
1476
- return wrapped;
1477
1717
  }
1478
1718
  async destroy() {
1479
1719
  await this.docManager.close();
1480
- this.metaRoomSubscription?.unsubscribe();
1481
- this.metaRoomSubscription = void 0;
1482
- for (const sub of this.docSubscriptions.values()) sub.unsubscribe();
1483
- this.docSubscriptions.clear();
1484
- if (this.unsubscribeMetaFlock) {
1485
- this.unsubscribeMetaFlock();
1486
- this.unsubscribeMetaFlock = void 0;
1720
+ if (this.metaRoomSubscription) {
1721
+ this.metaRoomSubscription.base.unsubscribe();
1722
+ this.metaRoomSubscription = void 0;
1487
1723
  }
1724
+ for (const record of this.docSubscriptions.values()) record.base.unsubscribe();
1725
+ this.docSubscriptions.clear();
1726
+ this.unsubscribeMetaFlock?.();
1727
+ this.unsubscribeMetaFlock = void 0;
1488
1728
  this.eventBus.clear();
1489
1729
  this.metadataManager.clear();
1490
1730
  this.assetManager.clear();
@@ -1504,8 +1744,7 @@ var SyncRunner = class {
1504
1744
  if (batch.source === "local") return;
1505
1745
  const by = this.eventBus.resolveEventBy("live");
1506
1746
  (async () => {
1507
- this.flockHydrator.applyEvents(batch.events, by);
1508
- await this.persistMeta();
1747
+ await this.flockHydrator.applyEvents(batch.events, by);
1509
1748
  })().catch(logAsyncError("meta live monitor sync"));
1510
1749
  });
1511
1750
  }
@@ -1527,9 +1766,152 @@ function createRepoState() {
1527
1766
  }
1528
1767
 
1529
1768
  //#endregion
1530
- //#region src/index.ts
1769
+ //#region src/internal/meta-persister.ts
1531
1770
  const textEncoder = new TextEncoder();
1771
+ const DEFAULT_META_PERSIST_DEBOUNCE_MS = 5e3;
1772
+ var MetaPersister = class {
1773
+ getMetaFlock;
1774
+ storage;
1775
+ debounceMs;
1776
+ lastPersistedVersion;
1777
+ unsubscribe;
1778
+ flushPromise = Promise.resolve();
1779
+ flushTimer;
1780
+ forceFullOnNextFlush = false;
1781
+ destroyed = false;
1782
+ constructor(options) {
1783
+ this.getMetaFlock = options.getMetaFlock;
1784
+ this.storage = options.storage;
1785
+ const configuredDebounce = options.debounceMs;
1786
+ this.debounceMs = typeof configuredDebounce === "number" && Number.isFinite(configuredDebounce) && configuredDebounce >= 0 ? configuredDebounce : DEFAULT_META_PERSIST_DEBOUNCE_MS;
1787
+ }
1788
+ start(initialVersion) {
1789
+ this.lastPersistedVersion = initialVersion;
1790
+ if (this.unsubscribe) return;
1791
+ this.unsubscribe = this.metaFlock.subscribe(() => {
1792
+ this.scheduleFlush();
1793
+ });
1794
+ }
1795
+ async destroy() {
1796
+ this.destroyed = true;
1797
+ if (this.flushTimer) {
1798
+ clearTimeout(this.flushTimer);
1799
+ this.flushTimer = void 0;
1800
+ }
1801
+ if (this.unsubscribe) {
1802
+ this.unsubscribe();
1803
+ this.unsubscribe = void 0;
1804
+ }
1805
+ await this.flushNow();
1806
+ }
1807
+ async flushNow(forceFull = false) {
1808
+ if (this.flushTimer) {
1809
+ clearTimeout(this.flushTimer);
1810
+ this.flushTimer = void 0;
1811
+ }
1812
+ await this.flush(forceFull);
1813
+ }
1814
+ scheduleFlush() {
1815
+ if (this.destroyed) return;
1816
+ if (this.debounceMs === 0) {
1817
+ this.flush();
1818
+ return;
1819
+ }
1820
+ if (this.flushTimer) clearTimeout(this.flushTimer);
1821
+ this.flushTimer = setTimeout(() => {
1822
+ this.flushTimer = void 0;
1823
+ this.flush();
1824
+ }, this.debounceMs);
1825
+ }
1826
+ async flush(forceFull = false) {
1827
+ if (forceFull) this.forceFullOnNextFlush = true;
1828
+ const run = this.flushPromise.catch(() => {}).then(() => this.flushInternal());
1829
+ this.flushPromise = run;
1830
+ await run;
1831
+ }
1832
+ async flushInternal() {
1833
+ const flock = this.metaFlock;
1834
+ const currentVersion = flock.version();
1835
+ if (this.lastPersistedVersion && this.versionsEqual(currentVersion, this.lastPersistedVersion)) {
1836
+ this.forceFullOnNextFlush = false;
1837
+ return;
1838
+ }
1839
+ const baseline = this.forceFullOnNextFlush ? void 0 : this.lastPersistedVersion;
1840
+ const rawBundle = baseline ? flock.exportJson(baseline) : flock.exportJson();
1841
+ const bundle = baseline ? this.stripUnchangedEntries(rawBundle, baseline) : rawBundle;
1842
+ if (Object.keys(bundle.entries).length === 0) {
1843
+ this.forceFullOnNextFlush = false;
1844
+ this.lastPersistedVersion = currentVersion;
1845
+ return;
1846
+ }
1847
+ const encoded = textEncoder.encode(JSON.stringify(bundle));
1848
+ if (!this.storage) {
1849
+ this.lastPersistedVersion = currentVersion;
1850
+ this.forceFullOnNextFlush = false;
1851
+ return;
1852
+ }
1853
+ await this.storage.save({
1854
+ type: "meta",
1855
+ update: encoded
1856
+ });
1857
+ this.lastPersistedVersion = currentVersion;
1858
+ this.forceFullOnNextFlush = false;
1859
+ }
1860
+ get metaFlock() {
1861
+ return this.getMetaFlock();
1862
+ }
1863
+ stripUnchangedEntries(bundle, baseline) {
1864
+ const entries = {};
1865
+ for (const [key, record] of Object.entries(bundle.entries)) {
1866
+ const clock = this.parseClock(record.c);
1867
+ if (!clock) {
1868
+ entries[key] = record;
1869
+ continue;
1870
+ }
1871
+ const baselineEntry = baseline[clock.peerIdHex];
1872
+ if (!baselineEntry) {
1873
+ entries[key] = record;
1874
+ continue;
1875
+ }
1876
+ if (clock.physicalTime > baselineEntry.physicalTime || clock.physicalTime === baselineEntry.physicalTime && clock.logicalCounter > baselineEntry.logicalCounter) entries[key] = record;
1877
+ }
1878
+ return {
1879
+ version: bundle.version,
1880
+ entries
1881
+ };
1882
+ }
1883
+ parseClock(raw) {
1884
+ if (typeof raw !== "string") return void 0;
1885
+ const [physicalTimeStr, logicalCounterStr, peerIdHex] = raw.split(",");
1886
+ if (!physicalTimeStr || !logicalCounterStr || !peerIdHex) return void 0;
1887
+ const physicalTime = Number(physicalTimeStr);
1888
+ const logicalCounter = Number(logicalCounterStr);
1889
+ if (!Number.isFinite(physicalTime) || !Number.isFinite(logicalCounter)) return void 0;
1890
+ return {
1891
+ physicalTime,
1892
+ logicalCounter,
1893
+ peerIdHex
1894
+ };
1895
+ }
1896
+ versionsEqual(a, b) {
1897
+ if (!a || !b) return false;
1898
+ const aKeys = Object.keys(a);
1899
+ const bKeys = Object.keys(b);
1900
+ if (aKeys.length !== bKeys.length) return false;
1901
+ for (const key of aKeys) {
1902
+ const aEntry = a[key];
1903
+ const bEntry = b[key];
1904
+ if (!aEntry || !bEntry) return false;
1905
+ if (aEntry.physicalTime !== bEntry.physicalTime || aEntry.logicalCounter !== bEntry.logicalCounter) return false;
1906
+ }
1907
+ return true;
1908
+ }
1909
+ };
1910
+
1911
+ //#endregion
1912
+ //#region src/index.ts
1532
1913
  const DEFAULT_DOC_FRONTIER_DEBOUNCE_MS = 1e3;
1914
+ const DEFAULT_DELETED_DOC_KEEP_MS = 720 * 60 * 60 * 1e3;
1533
1915
  var LoroRepo = class LoroRepo {
1534
1916
  options;
1535
1917
  _destroyed = false;
@@ -1544,6 +1926,9 @@ var LoroRepo = class LoroRepo {
1544
1926
  flockHydrator;
1545
1927
  state;
1546
1928
  syncRunner;
1929
+ metaPersister;
1930
+ deletedDocKeepMs;
1931
+ purgeWatchHandle;
1547
1932
  constructor(options) {
1548
1933
  this.options = options;
1549
1934
  this.transport = options.transportAdapter;
@@ -1553,17 +1938,17 @@ var LoroRepo = class LoroRepo {
1553
1938
  this.state = createRepoState();
1554
1939
  const configuredDebounce = options.docFrontierDebounceMs;
1555
1940
  const docFrontierDebounceMs = typeof configuredDebounce === "number" && Number.isFinite(configuredDebounce) && configuredDebounce >= 0 ? configuredDebounce : DEFAULT_DOC_FRONTIER_DEBOUNCE_MS;
1941
+ const configuredDeletedKeepMs = options.deletedDocKeepMs;
1942
+ this.deletedDocKeepMs = typeof configuredDeletedKeepMs === "number" && Number.isFinite(configuredDeletedKeepMs) && configuredDeletedKeepMs >= 0 ? configuredDeletedKeepMs : DEFAULT_DELETED_DOC_KEEP_MS;
1556
1943
  this.docManager = new DocManager({
1557
1944
  storage: this.storage,
1558
1945
  docFrontierDebounceMs,
1559
1946
  getMetaFlock: () => this.metaFlock,
1560
- eventBus: this.eventBus,
1561
- persistMeta: () => this.persistMeta()
1947
+ eventBus: this.eventBus
1562
1948
  });
1563
1949
  this.metadataManager = new MetadataManager({
1564
1950
  getMetaFlock: () => this.metaFlock,
1565
1951
  eventBus: this.eventBus,
1566
- persistMeta: () => this.persistMeta(),
1567
1952
  state: this.state
1568
1953
  });
1569
1954
  this.assetManager = new AssetManager({
@@ -1571,9 +1956,13 @@ var LoroRepo = class LoroRepo {
1571
1956
  assetTransport: this.assetTransport,
1572
1957
  getMetaFlock: () => this.metaFlock,
1573
1958
  eventBus: this.eventBus,
1574
- persistMeta: () => this.persistMeta(),
1575
1959
  state: this.state
1576
1960
  });
1961
+ this.metaPersister = new MetaPersister({
1962
+ getMetaFlock: () => this.metaFlock,
1963
+ storage: this.storage,
1964
+ debounceMs: options.metaPersistDebounceMs
1965
+ });
1577
1966
  this.flockHydrator = new FlockHydrator({
1578
1967
  getMetaFlock: () => this.metaFlock,
1579
1968
  metadataManager: this.metadataManager,
@@ -1591,8 +1980,11 @@ var LoroRepo = class LoroRepo {
1591
1980
  getMetaFlock: () => this.metaFlock,
1592
1981
  mergeFlock: (snapshot) => {
1593
1982
  this.metaFlock.merge(snapshot);
1594
- },
1595
- persistMeta: () => this.persistMeta()
1983
+ }
1984
+ });
1985
+ this.purgeWatchHandle = this.eventBus.watch((event) => this.handlePurgeSignals(event), {
1986
+ kinds: ["doc-soft-deleted", "doc-metadata"],
1987
+ by: ["sync", "live"]
1596
1988
  });
1597
1989
  }
1598
1990
  static async create(options) {
@@ -1609,6 +2001,19 @@ var LoroRepo = class LoroRepo {
1609
2001
  */
1610
2002
  async ready() {
1611
2003
  await this.syncRunner.ready();
2004
+ this.metaPersister.start(this.metaFlock.version());
2005
+ }
2006
+ computeDocPurgeAfter(docId, minKeepMs) {
2007
+ const deletedAt = this.metadataManager.getDeletedAtMs(docId);
2008
+ if (deletedAt === void 0) return void 0;
2009
+ return deletedAt + minKeepMs;
2010
+ }
2011
+ purgeDocKeyspace(docId) {
2012
+ const metadataKeys = Array.from(this.metaFlock.scan({ prefix: ["m", docId] }), (row) => row.key);
2013
+ for (const key of metadataKeys) this.metaFlock.delete(key);
2014
+ const frontierKeys = Array.from(this.metaFlock.scan({ prefix: ["f", docId] }), (row) => row.key);
2015
+ for (const key of frontierKeys) this.metaFlock.delete(key);
2016
+ this.metaFlock.delete(["ts", docId]);
1612
2017
  }
1613
2018
  /**
1614
2019
  * Sync selected data via the transport adaptor
@@ -1620,23 +2025,64 @@ var LoroRepo = class LoroRepo {
1620
2025
  /**
1621
2026
  * Start syncing the metadata (Flock) room. It will establish a realtime connection to the transport adaptor.
1622
2027
  * All changes on the room will be synced to the Flock, and all changes on the Flock will be synced to the room.
2028
+ *
2029
+ * - Idempotent: repeated calls reuse the same underlying room session; no extra join request is sent for the same repo.
2030
+ * - Reference-counted leave: every call to `joinMetaRoom` returns a subscription that increments an internal counter. The room is
2031
+ * actually left only after all returned subscriptions have called `unsubscribe`.
1623
2032
  * @param params
1624
2033
  * @returns
1625
2034
  */
1626
2035
  async joinMetaRoom(params) {
1627
- return this.syncRunner.joinMetaRoom(params);
2036
+ const subscription = await this.syncRunner.joinMetaRoom(params);
2037
+ return {
2038
+ unsubscribe: subscription.unsubscribe,
2039
+ get connected() {
2040
+ return subscription.connected;
2041
+ },
2042
+ get status() {
2043
+ return subscription.status;
2044
+ },
2045
+ onStatusChange: subscription.onStatusChange,
2046
+ firstSyncedWithRemote: subscription.firstSyncedWithRemote.then(async () => {
2047
+ await this.metaPersister.flushNow();
2048
+ })
2049
+ };
1628
2050
  }
1629
2051
  /**
1630
2052
  * Start syncing the given doc. It will establish a realtime connection to the transport adaptor.
1631
2053
  * All changes on the doc will be synced to the transport, and all changes on the transport will be synced to the doc.
1632
2054
  *
1633
2055
  * All the changes on the room will be reflected on the same doc you get from `repo.openCollaborativeDoc(docId)`
2056
+ *
2057
+ * - Idempotent: multiple joins for the same `docId` reuse the existing session; no duplicate transport joins are issued.
2058
+ * - Reference-counted leave: each returned subscription bumps an internal counter and only the final `unsubscribe()` will
2059
+ * actually leave the room. Earlier unsubscribes simply decrement the counter.
1634
2060
  * @param docId
1635
2061
  * @param params
1636
2062
  * @returns
1637
2063
  */
1638
2064
  async joinDocRoom(docId, params) {
1639
- return this.syncRunner.joinDocRoom(docId, params);
2065
+ const subscription = await this.syncRunner.joinDocRoom(docId, params);
2066
+ return {
2067
+ ...subscription,
2068
+ onStatusChange: subscription.onStatusChange,
2069
+ status: subscription.status
2070
+ };
2071
+ }
2072
+ /**
2073
+ * Joins an ephemeral CRDT room. This is useful for presence-like state that should not be persisted.
2074
+ * The returned store can be used immediately; the first sync promise resolves once the initial handshake completes.
2075
+ */
2076
+ async joinEphemeralRoom(roomId) {
2077
+ if (!this.transport) throw new Error("Transport adapter not configured");
2078
+ await this.syncRunner.ready();
2079
+ if (!this.transport.isConnected()) await this.transport.connect();
2080
+ const subscription = this.transport.joinEphemeralRoom(roomId);
2081
+ return {
2082
+ ...subscription,
2083
+ onStatusChange: subscription.onStatusChange,
2084
+ status: subscription.status
2085
+ };
1640
2086
  }
1641
2087
  /**
1642
2088
  * Opens a document that is automatically persisted to the configured storage adapter.
@@ -1668,6 +2114,61 @@ var LoroRepo = class LoroRepo {
1668
2114
  async listDoc(query) {
1669
2115
  return this.metadataManager.listDoc(query);
1670
2116
  }
2117
+ /**
2118
+ * Mark a document deleted by writing a `ts/*` tombstone entry (timestamp).
2119
+ * The body and metadata remain until purged; callers use the tombstone to
2120
+ * render deleted state or trigger retention workflows. For immediate removal,
2121
+ * call `purgeDoc` instead.
2122
+ */
2123
+ async deleteDoc(docId, options = {}) {
2124
+ if (this.metadataManager.getDeletedAtMs(docId) !== void 0 && !options.force) return;
2125
+ const deletedAt = options.deletedAt ?? Date.now();
2126
+ this.metadataManager.markDeleted(docId, deletedAt);
2127
+ }
2128
+ /**
2129
+ * Undo a soft delete by removing the tombstone entry. Metadata and document
2130
+ * state remain untouched.
2131
+ */
2132
+ async restoreDoc(docId) {
2133
+ if (this.metadataManager.getDeletedAtMs(docId) === void 0) return;
2134
+ this.metadataManager.clearDeleted(docId);
2135
+ }
2136
+ /**
2137
+ * Hard-delete a document immediately. Removes doc snapshots/updates via the
2138
+ * storage adapter (if supported), clears metadata/frontiers/link keys from
2139
+ * Flock, and unlinks assets (they become orphaned for asset GC).
2140
+ */
2141
+ async purgeDoc(docId) {
2142
+ const deletedAtMs = this.metadataManager.getDeletedAtMs(docId);
2143
+ this.eventBus.emit({
2144
+ kind: "doc-purging",
2145
+ docId,
2146
+ deletedAtMs,
2147
+ by: "local"
2148
+ });
2149
+ await this.docManager.dropDoc(docId);
2150
+ this.assetManager.purgeDocLinks(docId, "local");
2151
+ this.purgeDocKeyspace(docId);
2152
+ this.metadataManager.emitSoftDeleted(docId, void 0, "local");
2153
+ this.metadataManager.refreshFromFlock(docId, "local");
2154
+ }
2155
+ /**
2156
+ * Sweep tombstoned documents whose retention window expired. Uses
2157
+ * `deletedDocKeepMs` by default; pass `minKeepMs`/`now` for overrides.
2158
+ */
2159
+ async gcDeletedDocs(options = {}) {
2160
+ const now = options.now ?? Date.now();
2161
+ const minKeepMs = options.minKeepMs ?? this.deletedDocKeepMs;
2162
+ const docIds = this.metadataManager.getDocIds();
2163
+ let purged = 0;
2164
+ for (const docId of docIds) {
2165
+ const purgeAfter = this.computeDocPurgeAfter(docId, minKeepMs);
2166
+ if (purgeAfter === void 0 || now < purgeAfter) continue;
2167
+ await this.purgeDoc(docId);
2168
+ purged += 1;
2169
+ }
2170
+ return purged;
2171
+ }
1671
2172
  getMeta() {
1672
2173
  return this.metaFlock;
1673
2174
  }
@@ -1696,6 +2197,7 @@ var LoroRepo = class LoroRepo {
1696
2197
  }
1697
2198
  async flush() {
1698
2199
  await this.docManager.flush();
2200
+ await this.metaPersister.flushNow();
1699
2201
  }
1700
2202
  async uploadAsset(params) {
1701
2203
  return this.assetManager.uploadAsset(params);
@@ -1718,26 +2220,35 @@ var LoroRepo = class LoroRepo {
1718
2220
  async gcAssets(options = {}) {
1719
2221
  return this.assetManager.gcAssets(options);
1720
2222
  }
1721
- async persistMeta() {
1722
- if (!this.storage) return;
1723
- const bundle = this.metaFlock.exportJson();
1724
- const encoded = textEncoder.encode(JSON.stringify(bundle));
1725
- await this.storage.save({
1726
- type: "meta",
1727
- update: encoded
1728
- });
1729
- }
1730
2223
  get destroyed() {
1731
2224
  return this._destroyed;
1732
2225
  }
1733
2226
  async destroy() {
1734
2227
  if (this._destroyed) return;
1735
2228
  this._destroyed = true;
2229
+ this.purgeWatchHandle.unsubscribe();
2230
+ await this.metaPersister.destroy();
1736
2231
  await this.syncRunner.destroy();
1737
2232
  this.assetTransport?.close?.();
1738
2233
  this.storage?.close?.();
1739
2234
  await this.transport?.close();
1740
2235
  }
2236
+ handlePurgeSignals(event) {
2237
+ const docId = (() => {
2238
+ if (event.kind === "doc-soft-deleted") return event.docId;
2239
+ if (event.kind === "doc-metadata") return event.docId;
2240
+ })();
2241
+ if (!docId) return;
2242
+ const metadataCleared = event.kind === "doc-metadata" && Object.keys(event.patch).length === 0;
2243
+ const tombstoneCleared = event.kind === "doc-soft-deleted" && event.deletedAtMs === void 0;
2244
+ if (!(metadataCleared || tombstoneCleared && this.metadataManager.get(docId) === void 0)) return;
2245
+ this.docManager.dropDoc(docId).catch((error) => {
2246
+ console.error("Failed to drop purged doc", {
2247
+ docId,
2248
+ error
2249
+ });
2250
+ });
2251
+ }
1741
2252
  };
1742
2253
 
1743
2254
  //#endregion