mindoodb 0.0.72 → 0.0.74

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.
Files changed (73) hide show
  1. package/dist/browser/index.mjs +2 -0
  2. package/dist/core/BackgroundTaskTracker.d.ts +18 -0
  3. package/dist/core/BackgroundTaskTracker.d.ts.map +1 -0
  4. package/dist/core/BackgroundTaskTracker.js +37 -0
  5. package/dist/core/BackgroundTaskTracker.js.map +1 -0
  6. package/dist/core/BaseMindooDB.d.ts +60 -7
  7. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  8. package/dist/core/BaseMindooDB.js +280 -66
  9. package/dist/core/BaseMindooDB.js.map +1 -1
  10. package/dist/core/BaseMindooTenant.d.ts +46 -12
  11. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  12. package/dist/core/BaseMindooTenant.js +133 -65
  13. package/dist/core/BaseMindooTenant.js.map +1 -1
  14. package/dist/core/BaseMindooTenantDirectory.d.ts +57 -14
  15. package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
  16. package/dist/core/BaseMindooTenantDirectory.js +319 -164
  17. package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
  18. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +11 -0
  19. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  20. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +44 -18
  21. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  22. package/dist/core/appendonlystores/purgeDocumentHistory.d.ts +52 -0
  23. package/dist/core/appendonlystores/purgeDocumentHistory.d.ts.map +1 -0
  24. package/dist/core/appendonlystores/purgeDocumentHistory.js +107 -0
  25. package/dist/core/appendonlystores/purgeDocumentHistory.js.map +1 -0
  26. package/dist/core/appendonlystores/purgedDocumentGuard.d.ts +33 -0
  27. package/dist/core/appendonlystores/purgedDocumentGuard.d.ts.map +1 -0
  28. package/dist/core/appendonlystores/purgedDocumentGuard.js +78 -0
  29. package/dist/core/appendonlystores/purgedDocumentGuard.js.map +1 -0
  30. package/dist/core/cache/CacheManager.d.ts +4 -1
  31. package/dist/core/cache/CacheManager.d.ts.map +1 -1
  32. package/dist/core/cache/CacheManager.js +47 -19
  33. package/dist/core/cache/CacheManager.js.map +1 -1
  34. package/dist/core/indexing/fulltext/DocumentFullTextIndex.d.ts +16 -1
  35. package/dist/core/indexing/fulltext/DocumentFullTextIndex.d.ts.map +1 -1
  36. package/dist/core/indexing/fulltext/DocumentFullTextIndex.js +86 -14
  37. package/dist/core/indexing/fulltext/DocumentFullTextIndex.js.map +1 -1
  38. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts +2 -0
  39. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts.map +1 -1
  40. package/dist/core/indexing/summary/DocumentSummaryStore.js +19 -5
  41. package/dist/core/indexing/summary/DocumentSummaryStore.js.map +1 -1
  42. package/dist/core/types.d.ts +57 -0
  43. package/dist/core/types.d.ts.map +1 -1
  44. package/dist/core/types.js +22 -0
  45. package/dist/core/types.js.map +1 -1
  46. package/dist/core/utils/singleFlight.d.ts +21 -0
  47. package/dist/core/utils/singleFlight.d.ts.map +1 -0
  48. package/dist/core/utils/singleFlight.js +47 -0
  49. package/dist/core/utils/singleFlight.js.map +1 -0
  50. package/dist/core/utils/timeSource.d.ts +3 -1
  51. package/dist/core/utils/timeSource.d.ts.map +1 -1
  52. package/dist/core/utils/timeSource.js +4 -0
  53. package/dist/core/utils/timeSource.js.map +1 -1
  54. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +13 -0
  55. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  56. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +26 -2
  57. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  58. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  59. package/dist/node/server/MindooDBServer.js +2 -0
  60. package/dist/node/server/MindooDBServer.js.map +1 -1
  61. package/dist/node/server/TenantManager.d.ts +11 -0
  62. package/dist/node/server/TenantManager.d.ts.map +1 -1
  63. package/dist/node/server/TenantManager.js +40 -9
  64. package/dist/node/server/TenantManager.js.map +1 -1
  65. package/dist/node/server/peer/ClusterManager.d.ts +4 -0
  66. package/dist/node/server/peer/ClusterManager.d.ts.map +1 -1
  67. package/dist/node/server/peer/ClusterManager.js +2 -0
  68. package/dist/node/server/peer/ClusterManager.js.map +1 -1
  69. package/dist/node/server/peer/PeerReplicator.d.ts +12 -0
  70. package/dist/node/server/peer/PeerReplicator.d.ts.map +1 -1
  71. package/dist/node/server/peer/PeerReplicator.js +19 -1
  72. package/dist/node/server/peer/PeerReplicator.js.map +1 -1
  73. package/package.json +1 -1
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BaseMindooTenantDirectory = void 0;
4
4
  const types_1 = require("./types");
5
5
  const timeSource_1 = require("./utils/timeSource");
6
+ const singleFlight_1 = require("./utils/singleFlight");
6
7
  const logging_1 = require("./logging");
7
8
  const grantKeys_1 = require("./accesscontrol/grantKeys");
8
9
  const types_2 = require("./accesscontrol/types");
@@ -14,10 +15,7 @@ const evaluate_1 = require("./accesscontrol/evaluate");
14
15
  const RSAEncryption_1 = require("./crypto/RSAEncryption");
15
16
  const encryptedFields_1 = require("./crypto/encryptedFields");
16
17
  const DIRECTORY_SYNC_INTERVAL_MS = 5 * 60 * 1000;
17
- // How long the resolved storage-format floor (`requireMetadataSignatureSince`)
18
- // is reused before re-reading the directory head. Short, because it is consulted
19
- // on the entry-verification hot path; the cutoff itself changes very rarely.
20
- const METADATA_SIGNATURE_CUTOFF_TTL_MS = 30 * 1000;
18
+ const MAX_UNKNOWN_KEYS_TRACKED = 1000;
21
19
  /** De-duplicated union of two string lists, preserving first-seen order. */
22
20
  function unionStrings(existing, added) {
23
21
  return Array.from(new Set([...existing, ...added]));
@@ -49,6 +47,13 @@ class BaseMindooTenantDirectory {
49
47
  // Cache for groups: key -> merged group data (key: lowercase groupName)
50
48
  // We store merged data separately to avoid mutating MindooDoc objects.
51
49
  this.groupsCache = new Map();
50
+ // Every group document seen, by doc id. Several documents can carry the same
51
+ // group name (offline creation on two devices); groupsCache is their merge,
52
+ // recomputed per name from this map so an incremental pass that sees only
53
+ // one of them does not drop the others' members.
54
+ this.groupDocsById = new Map();
55
+ // Monotonic counter giving group documents their "latest seen" order.
56
+ this.groupDocSeenCounter = 0;
52
57
  // Cache for key-distribution documents (acl_keydistribution_<keyId>), keyed by
53
58
  // keyId. Folded incrementally in updateUnifiedCache (singleton per keyId,
54
59
  // last-seen-wins; a deleted doc removes its entry). Powers the revoked-key
@@ -81,11 +86,33 @@ class BaseMindooTenantDirectory {
81
86
  this.lastTimeTravelChangeSeq = null;
82
87
  // Unified cache cursor for all document types
83
88
  this.unifiedCacheLastCursor = null;
89
+ // Directory index `changeSeq` the last completed updateUnifiedCache pass
90
+ // covered. Compared against the live index instead of the cursor, because
91
+ // the cursor can lag behind entries that iterateChangesSince skips.
92
+ this.unifiedCacheIndexChangeSeq = null;
93
+ this.unifiedCachePass = new singleFlight_1.SingleFlight(() => this.runUnifiedCachePass());
94
+ this.timeTravelPass = new singleFlight_1.SingleFlight(() => this.runTimeTravelPass());
95
+ // Set when the feed changed stored revisions; cleared only once the chain
96
+ // was rebuilt from them, so a pass that fails in between is redone.
97
+ this.timeTravelNeedsRebuild = false;
98
+ // Set by invalidateUnifiedCache; the next pass starts from scratch. A flag
99
+ // rather than resetting the cursor directly, which a running pass would
100
+ // overwrite with its own progress.
101
+ this.unifiedCacheResetRequested = false;
102
+ // Last store poll (syncStoreChanges) for trust validation. Polling only
103
+ // discovers entries this process has not seen yet; entries already in the
104
+ // directory index are applied via the changeSeq check, independent of time.
84
105
  this.lastDirectorySyncTimestamp = 0;
85
- // Cached storage-format floor (`requireMetadataSignatureSince`) with re-entrancy
86
- // guard; see getRequireMetadataSignatureSince.
106
+ // Unknown signing keys -> time their cache miss last polled the store. Keeps
107
+ // repeated lookups of a key that is not granted from polling the store (and,
108
+ // for remote stores, the server) on every call.
109
+ this.unknownKeyPolledAt = new Map();
110
+ // Cached storage-format floor (`requireMetadataSignatureSince`), keyed by the
111
+ // directory changefeed `changeSeq` it was resolved at, with re-entrancy guard;
112
+ // see getRequireMetadataSignatureSince.
87
113
  this.metadataSignatureCutoffCache = undefined;
88
- this.metadataSignatureCutoffCachedAt = 0;
114
+ this.metadataSignatureCutoffResolved = false;
115
+ this.metadataSignatureCutoffChangeSeq = null;
89
116
  this.resolvingMetadataSignatureCutoff = false;
90
117
  this.tenant = tenant;
91
118
  // Create logger if not provided (for backward compatibility)
@@ -110,7 +137,7 @@ class BaseMindooTenantDirectory {
110
137
  // would otherwise go un-reconciled until the next network sync. Runs only
111
138
  // once per directory object (the creation branch); the driver's own
112
139
  // getDirectoryDB calls reuse the now-set instance and do not re-trigger.
113
- void this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
140
+ this.tenant.trackBackgroundTask(this.tenant.reconcileKeyDistributionsForCurrentUserSafe());
114
141
  }
115
142
  return this.directoryDB;
116
143
  }
@@ -436,31 +463,35 @@ class BaseMindooTenantDirectory {
436
463
  return additionalResult;
437
464
  }
438
465
  }
439
- // Refresh directory state at most once per interval — unless the caller
440
- // forces a refresh (audit #4): the server push path must observe a freshly
441
- // pushed revocation immediately rather than lagging by up to
442
- // DIRECTORY_SYNC_INTERVAL_MS, which would let a just-revoked key keep
443
- // pushing entries.
466
+ // Store poll: discovers entries this process has not seen yet, so it is
467
+ // throttled to once per interval — unless the caller forces it (audit #4):
468
+ // the server push path must observe a freshly pushed revocation
469
+ // immediately, which would otherwise let a just-revoked key keep pushing.
444
470
  const now = Date.now();
445
- let didSync = false;
471
+ let didPoll = false;
446
472
  if (opts?.forceRefresh ||
447
473
  now - this.lastDirectorySyncTimestamp >= DIRECTORY_SYNC_INTERVAL_MS) {
448
- const directoryDB = await this.getDirectoryDB();
449
- await directoryDB.syncStoreChanges();
450
- await this.updateUnifiedCache();
451
- this.lastDirectorySyncTimestamp = now;
452
- didSync = true;
474
+ await this.pollDirectoryStore();
475
+ didPoll = true;
453
476
  }
477
+ // Apply whatever the directory index already holds (local writes, pulls,
478
+ // the poll above). Cheap and time-independent: a grant or revocation this
479
+ // process knows about takes effect on the next validation.
480
+ await this.applyDirectoryIndexChanges();
454
481
  let cachedResult = this.trustedKeysCache.get(publicKey);
455
- if (cachedResult === undefined && !didSync) {
456
- // Cache miss and not synced yet - sync once and re-check immediately.
457
- const directoryDB = await this.getDirectoryDB();
458
- await directoryDB.syncStoreChanges();
459
- await this.updateUnifiedCache();
460
- this.lastDirectorySyncTimestamp = Date.now();
482
+ if (cachedResult === undefined && !didPoll && this.mayPollForUnknownKey(publicKey, now)) {
483
+ // Cache miss: the grant may sit in the store without this process
484
+ // having seen it yet, so poll once and re-check.
485
+ await this.pollDirectoryStore();
486
+ await this.applyDirectoryIndexChanges();
461
487
  cachedResult = this.trustedKeysCache.get(publicKey);
488
+ didPoll = true;
489
+ }
490
+ if (cachedResult === undefined && didPoll) {
491
+ this.recordUnknownKeyPoll(publicKey, now);
462
492
  }
463
493
  if (cachedResult !== undefined) {
494
+ this.unknownKeyPolledAt.delete(publicKey);
464
495
  this.logger.debug(`Public key validation result (from cache): ${cachedResult}`);
465
496
  return cachedResult;
466
497
  }
@@ -469,26 +500,84 @@ class BaseMindooTenantDirectory {
469
500
  return false;
470
501
  }
471
502
  /**
472
- * Force-refresh the directory trust caches from the (already synced local)
473
- * directory store, bypassing the TTL used by
474
- * {@link validatePublicSigningKey}. Called right after a directory pull so
475
- * newly arrived grantaccess documents are diffed immediately and the
476
- * author-trust reconcile of open databases fires without waiting up to
477
- * DIRECTORY_SYNC_INTERVAL_MS for the next cache-miss validation.
503
+ * Whether a cache miss for `publicKey` may poll the store. A key that missed
504
+ * within the last DIRECTORY_SYNC_INTERVAL_MS does not poll again; it still
505
+ * becomes trusted as soon as its grant reaches the directory index.
478
506
  */
479
- async refreshTrustCaches() {
507
+ mayPollForUnknownKey(publicKey, now) {
508
+ const polledAt = this.unknownKeyPolledAt.get(publicKey);
509
+ return polledAt === undefined || now - polledAt >= DIRECTORY_SYNC_INTERVAL_MS;
510
+ }
511
+ /** Remember that the store was polled and `publicKey` was still unknown. */
512
+ recordUnknownKeyPoll(publicKey, now) {
513
+ this.unknownKeyPolledAt.delete(publicKey);
514
+ if (this.unknownKeyPolledAt.size >= MAX_UNKNOWN_KEYS_TRACKED) {
515
+ const oldest = this.unknownKeyPolledAt.keys().next().value;
516
+ if (oldest !== undefined)
517
+ this.unknownKeyPolledAt.delete(oldest);
518
+ }
519
+ this.unknownKeyPolledAt.set(publicKey, now);
520
+ }
521
+ /** Read new directory entries from the store into the directory index. */
522
+ async pollDirectoryStore() {
480
523
  const directoryDB = await this.getDirectoryDB();
481
524
  await directoryDB.syncStoreChanges();
482
- await this.updateUnifiedCache();
483
525
  this.lastDirectorySyncTimestamp = Date.now();
484
526
  }
485
527
  /**
486
- * Update all caches (trusted keys, groups, settings) by processing new changes since the last cursor.
487
- * This unified method processes all document types in a single loop for efficiency.
488
- * No signature verification is needed since the DB already enforces admin-only access.
528
+ * Bring the unified cache up to the directory index when the index has
529
+ * advanced since the last pass. No store access.
530
+ */
531
+ async applyDirectoryIndexChanges() {
532
+ const directoryDB = await this.getDirectoryDB();
533
+ const latestSeq = directoryDB.getLatestChangeCursor?.()?.changeSeq ?? null;
534
+ if (this.unifiedCacheLastCursor === null ||
535
+ this.unifiedCacheResetRequested ||
536
+ latestSeq !== this.unifiedCacheIndexChangeSeq) {
537
+ await this.updateUnifiedCache();
538
+ }
539
+ }
540
+ /**
541
+ * Refresh the directory trust caches from the directory index right after a
542
+ * directory pull (which already read the pulled entries into the index), so
543
+ * newly arrived grantaccess documents are diffed immediately and the
544
+ * author-trust reconcile of open databases fires.
545
+ */
546
+ async refreshTrustCaches() {
547
+ await this.applyDirectoryIndexChanges();
548
+ }
549
+ /**
550
+ * Bring all caches (trusted keys, groups, settings) up to date with the
551
+ * directory index.
552
+ *
553
+ * Single-flight: passes mutate shared caches across awaits, so overlapping
554
+ * passes would read each other's half-built state and multiply the work
555
+ * under load. A caller arriving while a pass runs waits for one follow-up
556
+ * pass that starts after it arrived (so it observes its own preceding
557
+ * writes); all such callers share that follow-up.
558
+ */
559
+ updateUnifiedCache() {
560
+ return this.unifiedCachePass.run();
561
+ }
562
+ /** Make the next unified-cache pass rebuild every cache from scratch. */
563
+ invalidateUnifiedCache() {
564
+ this.unifiedCacheResetRequested = true;
565
+ }
566
+ /**
567
+ * One pass over the directory changes since the last cursor. Processes all
568
+ * document types in a single loop. No signature verification is needed
569
+ * since the DB already enforces admin-only access. Only called through
570
+ * {@link updateUnifiedCache}.
489
571
  */
490
- async updateUnifiedCache() {
572
+ async runUnifiedCachePass() {
491
573
  const directoryDB = await this.getDirectoryDB();
574
+ // Captured before iterating: entries added during the pass carry a higher
575
+ // changeSeq, so the next applyDirectoryIndexChanges picks them up.
576
+ const indexChangeSeqAtStart = directoryDB.getLatestChangeCursor?.()?.changeSeq ?? null;
577
+ if (this.unifiedCacheResetRequested) {
578
+ this.unifiedCacheResetRequested = false;
579
+ this.unifiedCacheLastCursor = null;
580
+ }
492
581
  // Determine starting cursor (null = process all, otherwise incremental)
493
582
  const startCursor = this.unifiedCacheLastCursor;
494
583
  // Snapshot the trusted signing keys BEFORE any rebuild so newly granted
@@ -496,19 +585,33 @@ class BaseMindooTenantDirectory {
496
585
  // author-trust reconcile of open databases (entries previously skipped
497
586
  // because their author was unknown get re-materialized).
498
587
  const previouslyTrustedKeys = new Set(this.trustedKeysCache.keys());
499
- // If processing from the beginning, clear all caches first
500
- if (startCursor === null) {
501
- this.trustedKeysCache.clear();
502
- this.grantDocIdToSigningKeys.clear();
503
- this.userLookupCache.clear();
504
- this.wipeKeyToGrantDocId.clear();
505
- this.tenantSettingsCache = null;
506
- this.dbSettingsCache.clear();
507
- this.groupsCache.clear();
508
- this.keyDistributionCache.clear();
509
- }
510
- // Track group documents by name for merging (handles offline sync scenarios)
511
- const groupDocsByName = new Map();
588
+ // Build and swap. The pass awaits between documents, and readers (also
589
+ // ones iterating a cache across their own awaits) must never see a cache
590
+ // that a full rebuild has cleared but not refilled. A full rebuild fills
591
+ // fresh maps and installs them at the end, synchronously; an incremental
592
+ // pass only adds/overwrites entries in place. The cursor is committed at
593
+ // the end too, so a pass that throws midway is simply redone (all updates
594
+ // are idempotent) instead of leaving its documents half-applied.
595
+ const fullRebuild = startCursor === null;
596
+ const grantDocIdToSigningKeys = fullRebuild ? new Map() : this.grantDocIdToSigningKeys;
597
+ const userLookupCache = fullRebuild ? new Map() : this.userLookupCache;
598
+ const wipeKeyToGrantDocId = fullRebuild ? new Map() : this.wipeKeyToGrantDocId;
599
+ let tenantSettingsCache = fullRebuild ? null : this.tenantSettingsCache;
600
+ const dbSettingsCache = fullRebuild ? new Map() : this.dbSettingsCache;
601
+ const groupDocsById = fullRebuild ? new Map() : this.groupDocsById;
602
+ const groupsCache = fullRebuild ? new Map() : this.groupsCache;
603
+ const keyDistributionCache = fullRebuild
604
+ ? new Map()
605
+ : this.keyDistributionCache;
606
+ const appDistributionCache = fullRebuild
607
+ ? new Map()
608
+ : this.appDistributionCache;
609
+ const syncSetupPolicyCache = fullRebuild
610
+ ? new Map()
611
+ : this.syncSetupPolicyCache;
612
+ let lastCursor = startCursor;
613
+ // Group names whose merged entry must be recomputed at the end of the pass.
614
+ const dirtyGroupNames = new Set();
512
615
  let sawGrantAccessChange = false;
513
616
  // Process changes (documents are returned in order by lastModified, oldest to newest)
514
617
  for await (const { doc, cursor } of directoryDB.iterateChangesSince(startCursor)) {
@@ -525,7 +628,7 @@ class BaseMindooTenantDirectory {
525
628
  // to wipe. Maps each wipe-targeted signing key to this grant doc's id.
526
629
  if (data.type === "grantaccess") {
527
630
  for (const wipeKey of (0, grantKeys_1.extractWipeRequestedSigningKeys)(data)) {
528
- this.wipeKeyToGrantDocId.set(wipeKey, doc.getId());
631
+ wipeKeyToGrantDocId.set(wipeKey, doc.getId());
529
632
  }
530
633
  }
531
634
  if (data.type === "grantaccess") {
@@ -537,7 +640,7 @@ class BaseMindooTenantDirectory {
537
640
  // all grant documents once the change loop completes, so a key
538
641
  // dropped from this document is no longer trusted unless another
539
642
  // grant still lists it.
540
- this.grantDocIdToSigningKeys.set(doc.getId(), grantedSigningKeys);
643
+ grantDocIdToSigningKeys.set(doc.getId(), grantedSigningKeys);
541
644
  if (grantedSigningKeys.length > 0) {
542
645
  // One cache entry per device key pair (§6.5). Reusing a single
543
646
  // lookup built from keys[0] for every signing key would make the
@@ -546,32 +649,45 @@ class BaseMindooTenantDirectory {
546
649
  // then fails with a WebCrypto OperationError on the iPad.
547
650
  const lookups = await this.buildUserLookups(data);
548
651
  for (const lookup of lookups) {
549
- this.userLookupCache.set(lookup.signingPublicKey, lookup);
652
+ userLookupCache.set(lookup.signingPublicKey, lookup);
550
653
  }
551
654
  }
552
655
  }
553
656
  }
554
- // Process group documents
657
+ // Process group documents. The previous name of a re-seen doc is marked
658
+ // dirty too, so a renamed or no-longer-group doc leaves its old merge.
659
+ const previousGroupEntry = groupDocsById.get(doc.getId());
660
+ if (previousGroupEntry) {
661
+ dirtyGroupNames.add(previousGroupEntry.normalizedName);
662
+ }
555
663
  if (data.form === "group" &&
556
664
  data.type === "group" &&
557
665
  data.groupName &&
558
666
  typeof data.groupName === "string") {
559
667
  const normalizedGroupName = this.normalizeGroupName(data.groupName);
560
- if (!groupDocsByName.has(normalizedGroupName)) {
561
- groupDocsByName.set(normalizedGroupName, []);
562
- }
563
- groupDocsByName.get(normalizedGroupName).push(doc);
668
+ const stringArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
669
+ groupDocsById.set(doc.getId(), {
670
+ normalizedName: normalizedGroupName,
671
+ membersHashes: stringArray(data.members_hashes),
672
+ membersEncrypted: stringArray(data.members_encrypted),
673
+ deleted: doc.isDeleted(),
674
+ seenOrder: ++this.groupDocSeenCounter,
675
+ });
676
+ dirtyGroupNames.add(normalizedGroupName);
677
+ }
678
+ else if (previousGroupEntry) {
679
+ groupDocsById.delete(doc.getId());
564
680
  }
565
681
  // Process tenant settings (no signature verification needed - Automerge handles merging)
566
682
  if (data.form === "tenantsettings") {
567
683
  // Just overwrite - last one seen will be the latest
568
- this.tenantSettingsCache = doc;
684
+ tenantSettingsCache = doc;
569
685
  }
570
686
  // Process DB settings (no signature verification needed - Automerge handles merging)
571
687
  if (data.form === "dbsettings" && data.dbid && typeof data.dbid === "string") {
572
688
  const dbId = data.dbid;
573
689
  // Just overwrite - last one seen will be the latest
574
- this.dbSettingsCache.set(dbId, doc);
690
+ dbSettingsCache.set(dbId, doc);
575
691
  }
576
692
  // Process key-distribution documents (acl_keydistribution_<keyId>); a
577
693
  // singleton per keyId. Cache head state here so the revoked-key resolver
@@ -580,10 +696,10 @@ class BaseMindooTenantDirectory {
580
696
  if (data.type === types_3.KEY_DISTRIBUTION_TYPE && typeof data.keyId === "string") {
581
697
  const keyId = data.keyId;
582
698
  if (doc.isDeleted()) {
583
- this.keyDistributionCache.delete(keyId);
699
+ keyDistributionCache.delete(keyId);
584
700
  }
585
701
  else {
586
- this.keyDistributionCache.set(keyId, {
702
+ keyDistributionCache.set(keyId, {
587
703
  keyVersions: Array.isArray(data.keyVersions)
588
704
  ? data.keyVersions
589
705
  .filter((v) => !!v && typeof v.fingerprint === "string")
@@ -608,11 +724,11 @@ class BaseMindooTenantDirectory {
608
724
  if (data.type === types_3.APP_DISTRIBUTION_TYPE && typeof data.appId === "string") {
609
725
  const appId = data.appId;
610
726
  if (doc.isDeleted()) {
611
- this.appDistributionCache.delete(appId);
727
+ appDistributionCache.delete(appId);
612
728
  }
613
729
  else {
614
730
  const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
615
- this.appDistributionCache.set(appId, {
731
+ appDistributionCache.set(appId, {
616
732
  pushto_users_hashes: stringArray(data.pushto_users_hashes),
617
733
  pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
618
734
  pullfrom_users_hashes: stringArray(data.pullfrom_users_hashes),
@@ -627,11 +743,11 @@ class BaseMindooTenantDirectory {
627
743
  if (data.type === types_3.SYNC_SETUP_POLICY_TYPE && typeof data.policyId === "string") {
628
744
  const policyId = data.policyId;
629
745
  if (doc.isDeleted()) {
630
- this.syncSetupPolicyCache.delete(policyId);
746
+ syncSetupPolicyCache.delete(policyId);
631
747
  }
632
748
  else {
633
749
  const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
634
- this.syncSetupPolicyCache.set(policyId, {
750
+ syncSetupPolicyCache.set(policyId, {
635
751
  mode: data.mode === "permanent" ? "permanent" : "initial",
636
752
  pushto_users_hashes: stringArray(data.pushto_users_hashes),
637
753
  pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
@@ -640,21 +756,74 @@ class BaseMindooTenantDirectory {
640
756
  });
641
757
  }
642
758
  }
643
- // Update cursor after each document
644
- this.unifiedCacheLastCursor = cursor;
759
+ lastCursor = cursor;
645
760
  }
761
+ // From here to the end of the method there is no await: readers see
762
+ // either the previous state or this pass's complete state.
646
763
  // Rebuild the trusted signing-key set as the union of every grant
647
764
  // document's current keys (docs/accesscontrol.md §6.5). Revocation removes
648
765
  // keys from a grant, so a key absent from this union is no longer trusted;
649
766
  // validatePublicSigningKey treats "not present" as untrusted. This is
650
767
  // recomputed on every pass (full or incremental) because the per-grant map
651
768
  // is the source of truth and persists across incremental updates.
652
- this.trustedKeysCache.clear();
653
- for (const signingKeys of this.grantDocIdToSigningKeys.values()) {
769
+ const trustedKeysCache = new Map();
770
+ for (const signingKeys of grantDocIdToSigningKeys.values()) {
654
771
  for (const key of signingKeys) {
655
- this.trustedKeysCache.set(key, true);
772
+ trustedKeysCache.set(key, true);
656
773
  }
657
774
  }
775
+ // Merge the group documents of every touched name (handles the same group
776
+ // created offline on several devices). A name is deleted as soon as any of
777
+ // its documents is deleted; docId points at the latest-seen document.
778
+ if (dirtyGroupNames.size > 0) {
779
+ const entriesByName = new Map();
780
+ for (const [docId, entry] of groupDocsById) {
781
+ if (!dirtyGroupNames.has(entry.normalizedName))
782
+ continue;
783
+ let list = entriesByName.get(entry.normalizedName);
784
+ if (!list) {
785
+ list = [];
786
+ entriesByName.set(entry.normalizedName, list);
787
+ }
788
+ list.push([docId, entry]);
789
+ }
790
+ for (const normalizedGroupName of dirtyGroupNames) {
791
+ const entries = entriesByName.get(normalizedGroupName);
792
+ if (!entries || entries.some(([, entry]) => entry.deleted)) {
793
+ groupsCache.delete(normalizedGroupName);
794
+ continue;
795
+ }
796
+ let latest = entries[0];
797
+ const allMembersHashes = new Set();
798
+ const allMembersEncrypted = new Set();
799
+ for (const item of entries) {
800
+ if (item[1].seenOrder > latest[1].seenOrder)
801
+ latest = item;
802
+ for (const h of item[1].membersHashes)
803
+ allMembersHashes.add(h);
804
+ for (const e of item[1].membersEncrypted)
805
+ allMembersEncrypted.add(e);
806
+ }
807
+ groupsCache.set(normalizedGroupName, {
808
+ docId: latest[0],
809
+ members_hashes: Array.from(allMembersHashes),
810
+ members_encrypted: Array.from(allMembersEncrypted),
811
+ });
812
+ }
813
+ }
814
+ this.trustedKeysCache = trustedKeysCache;
815
+ this.grantDocIdToSigningKeys = grantDocIdToSigningKeys;
816
+ this.userLookupCache = userLookupCache;
817
+ this.wipeKeyToGrantDocId = wipeKeyToGrantDocId;
818
+ this.tenantSettingsCache = tenantSettingsCache;
819
+ this.dbSettingsCache = dbSettingsCache;
820
+ this.groupDocsById = groupDocsById;
821
+ this.groupsCache = groupsCache;
822
+ this.keyDistributionCache = keyDistributionCache;
823
+ this.appDistributionCache = appDistributionCache;
824
+ this.syncSetupPolicyCache = syncSetupPolicyCache;
825
+ this.unifiedCacheLastCursor = lastCursor;
826
+ this.unifiedCacheIndexChangeSeq = indexChangeSeqAtStart;
658
827
  // Author-trust hook: keys that just became trusted (new grantaccess docs)
659
828
  // may unblock entries that open databases skipped earlier because the
660
829
  // author was unknown. Notify the tenant so those documents get purged and
@@ -668,56 +837,11 @@ class BaseMindooTenantDirectory {
668
837
  }
669
838
  }
670
839
  if (newlyTrustedKeys.size > 0) {
671
- void this.tenant.reconcileAuthorTrustChangesSafe(newlyTrustedKeys);
840
+ this.tenant.trackBackgroundTask(this.tenant.reconcileAuthorTrustChangesSafe(newlyTrustedKeys));
672
841
  }
673
842
  if (sawGrantAccessChange) {
674
843
  this.tenant.scheduleUserKeyReconcile?.();
675
844
  }
676
- // Merge group documents with the same name
677
- for (const [normalizedGroupName, docs] of groupDocsByName.entries()) {
678
- // Collect all member hashes and encrypted values from all documents with this group name
679
- const allMembersHashes = new Set();
680
- const allMembersEncrypted = new Set();
681
- let latestDoc = null;
682
- let isDeleted = false;
683
- for (const doc of docs) {
684
- const data = doc.getData();
685
- // Track the latest document (by iteration order, which is by lastModified)
686
- latestDoc = doc;
687
- // Check if document is deleted via lifecycle metadata.
688
- if (doc.isDeleted()) {
689
- isDeleted = true;
690
- }
691
- // Use members_hashes for membership lookups.
692
- if (data.members_hashes && Array.isArray(data.members_hashes)) {
693
- for (const memberHash of data.members_hashes) {
694
- if (typeof memberHash === "string") {
695
- allMembersHashes.add(memberHash);
696
- }
697
- }
698
- }
699
- if (data.members_encrypted && Array.isArray(data.members_encrypted)) {
700
- for (const encryptedMember of data.members_encrypted) {
701
- if (typeof encryptedMember === "string") {
702
- allMembersEncrypted.add(encryptedMember);
703
- }
704
- }
705
- }
706
- }
707
- // If latest version is deleted, remove from cache
708
- if (isDeleted) {
709
- this.groupsCache.delete(normalizedGroupName);
710
- continue;
711
- }
712
- // Store merged group data in cache (without mutating the MindooDoc)
713
- if (latestDoc) {
714
- this.groupsCache.set(normalizedGroupName, {
715
- docId: latestDoc.getId(),
716
- members_hashes: Array.from(allMembersHashes),
717
- members_encrypted: Array.from(allMembersEncrypted),
718
- });
719
- }
720
- }
721
845
  }
722
846
  /**
723
847
  * The head time-travel directory-state node ("now"), after ensuring the chain
@@ -745,8 +869,15 @@ class BaseMindooTenantDirectory {
745
869
  * The (potentially expensive) feed rebuild is skipped when the directory's
746
870
  * changefeed cursor has not advanced since the previous build and no
747
871
  * un-witnessed revisions are pending a possible trusted-time re-stamp.
872
+ *
873
+ * Single-flight: the pass creates the index across an await and advances
874
+ * the shared index cursor per revision, so overlapping passes would create
875
+ * and register two indexes or interleave their feed reads.
748
876
  */
749
- async ensureTimeTravelCurrent() {
877
+ ensureTimeTravelCurrent() {
878
+ return this.timeTravelPass.run();
879
+ }
880
+ async runTimeTravelPass() {
750
881
  const directoryDB = await this.getDirectoryDB();
751
882
  // Projection used to (re)build the chain from stored revisions in
752
883
  // trusted-time order. Captures this directory's group-name normalization.
@@ -789,13 +920,14 @@ class BaseMindooTenantDirectory {
789
920
  // (notably when a re-stamped entry is re-discovered with a `receivedAt`), so
790
921
  // we do not need to re-run the feed on every call just to refresh the
791
922
  // provisional `now` of un-witnessed entries.
792
- if (this.lastTimeTravelChangeSeq !== null && latestSeq === this.lastTimeTravelChangeSeq) {
923
+ if (!this.timeTravelNeedsRebuild &&
924
+ this.lastTimeTravelChangeSeq !== null &&
925
+ latestSeq === this.lastTimeTravelChangeSeq) {
793
926
  return index;
794
927
  }
795
928
  // Incrementally advance the feed from the persisted cursor; the feed parks
796
929
  // the cursor before the earliest un-witnessed entry so those revisions are
797
930
  // re-discovered (and re-stamped/superseded as needed) on this resume.
798
- let changed = false;
799
931
  for await (const rev of directoryDB.iterateChangeRevisionsSince(index.cursor)) {
800
932
  const revChanged = index.upsertRevision({
801
933
  entryId: rev.entryId,
@@ -805,13 +937,16 @@ class BaseMindooTenantDirectory {
805
937
  trustedTime: rev.trustedTime,
806
938
  witnessed: rev.witnessed,
807
939
  }, rev.cursor);
808
- changed = changed || revChanged;
940
+ if (revChanged) {
941
+ this.timeTravelNeedsRebuild = true;
942
+ }
809
943
  }
810
944
  // Replay all revisions into the chain in trusted-time order. This absorbs
811
945
  // out-of-order arrivals and supersessions (a re-emitted revision replaced its
812
946
  // prior record above) without any special-casing in the chain builder.
813
- if (changed) {
947
+ if (this.timeTravelNeedsRebuild) {
814
948
  index.rebuild(project);
949
+ this.timeTravelNeedsRebuild = false;
815
950
  }
816
951
  this.lastTimeTravelChangeSeq = latestSeq;
817
952
  // Persist the observed changeSeq with the chain so the gate survives restart.
@@ -1002,28 +1137,34 @@ class BaseMindooTenantDirectory {
1002
1137
  * signature, or `undefined` when no floor is configured (fully backward
1003
1138
  * compatible). Tenant-level only, read from the `acl_defaultpolicy` head.
1004
1139
  *
1005
- * Cached with a short TTL and guarded against re-entrancy: resolving the
1006
- * cutoff loads the directory head, and directory materialization itself runs
1007
- * signature verification — without the guard a verify→resolve→materialize→
1008
- * verify cycle could recurse. During such re-entry we return the last known
1009
- * value (the directory store is anyway exempt from the floor by its callers).
1140
+ * Cached until the directory changefeed `changeSeq` advances, so a newly set
1141
+ * floor applies to the very next verification, and guarded against
1142
+ * re-entrancy: resolving the cutoff loads the directory head, and directory
1143
+ * materialization itself runs signature verification — without the guard a
1144
+ * verify→resolve→materialize→verify cycle could recurse. During such re-entry
1145
+ * we return the last known value (the directory store is anyway exempt from
1146
+ * the floor by its callers).
1010
1147
  */
1011
1148
  async getRequireMetadataSignatureSince() {
1012
- const now = Date.now();
1013
1149
  if (this.resolvingMetadataSignatureCutoff) {
1014
1150
  return this.metadataSignatureCutoffCache;
1015
1151
  }
1016
- if (this.metadataSignatureCutoffCachedAt !== 0 &&
1017
- now - this.metadataSignatureCutoffCachedAt < METADATA_SIGNATURE_CUTOFF_TTL_MS) {
1152
+ if (this.metadataSignatureCutoffResolved &&
1153
+ this.directoryDB &&
1154
+ (this.directoryDB.getLatestChangeCursor?.()?.changeSeq ?? null) ===
1155
+ this.metadataSignatureCutoffChangeSeq) {
1018
1156
  return this.metadataSignatureCutoffCache;
1019
1157
  }
1020
1158
  this.resolvingMetadataSignatureCutoff = true;
1021
1159
  try {
1160
+ const directoryDB = await this.getDirectoryDB();
1161
+ const changeSeq = directoryDB.getLatestChangeCursor?.()?.changeSeq ?? null;
1022
1162
  const head = await this.getDirectoryStateHead();
1023
1163
  const since = head.defaultPolicy?.requireMetadataSignatureSince;
1024
1164
  this.metadataSignatureCutoffCache =
1025
1165
  typeof since === "number" && Number.isFinite(since) ? since : undefined;
1026
- this.metadataSignatureCutoffCachedAt = now;
1166
+ this.metadataSignatureCutoffChangeSeq = changeSeq;
1167
+ this.metadataSignatureCutoffResolved = true;
1027
1168
  }
1028
1169
  finally {
1029
1170
  this.resolvingMetadataSignatureCutoff = false;
@@ -3237,7 +3378,7 @@ class BaseMindooTenantDirectory {
3237
3378
  });
3238
3379
  // Invalidate cache
3239
3380
  this.tenantSettingsCache = null;
3240
- this.unifiedCacheLastCursor = null;
3381
+ this.invalidateUnifiedCache();
3241
3382
  }
3242
3383
  async changeDBSettings(dbId, changeFunc, administrationPrivateKey, administrationPrivateKeyPassword) {
3243
3384
  const baseTenant = this.tenant;
@@ -3266,7 +3407,7 @@ class BaseMindooTenantDirectory {
3266
3407
  });
3267
3408
  // Invalidate cache for this dbId
3268
3409
  this.dbSettingsCache.delete(dbId);
3269
- this.unifiedCacheLastCursor = null;
3410
+ this.invalidateUnifiedCache();
3270
3411
  }
3271
3412
  async getGroups() {
3272
3413
  const directoryDB = await this.getDirectoryDB();
@@ -3308,6 +3449,11 @@ class BaseMindooTenantDirectory {
3308
3449
  // new usernames. Reads match both v1 and v2 forms (docs/accesscontrol.md §6.5).
3309
3450
  const newMembersHashes = await Promise.all(username.map(u => this.hashUsernameForWrite(u)));
3310
3451
  const newMembersEncrypted = await Promise.all(username.map(u => this.encryptGroupMemberForTenant(u)));
3452
+ // Bring the cache up to date first: a stale miss (e.g. right after the
3453
+ // previous call invalidated it) would create a second document for an
3454
+ // existing group name.
3455
+ await directoryDB.syncStoreChanges();
3456
+ await this.updateUnifiedCache();
3311
3457
  // Get the actual document from the database (using cached docId if available)
3312
3458
  const cachedGroup = this.groupsCache.get(normalizedGroupName);
3313
3459
  let groupDoc;
@@ -3353,7 +3499,7 @@ class BaseMindooTenantDirectory {
3353
3499
  });
3354
3500
  // Invalidate cache for this group
3355
3501
  this.groupsCache.delete(normalizedGroupName);
3356
- this.unifiedCacheLastCursor = null;
3502
+ this.invalidateUnifiedCache();
3357
3503
  }
3358
3504
  async removeUsersFromGroup(groupName, username, administrationPrivateKey, administrationPrivateKeyPassword) {
3359
3505
  const normalizedGroupName = this.normalizeGroupName(groupName);
@@ -3368,8 +3514,6 @@ class BaseMindooTenantDirectory {
3368
3514
  // Group doesn't exist, nothing to do
3369
3515
  return;
3370
3516
  }
3371
- // Load the actual document from the database
3372
- const groupDoc = await directoryDB.getDocument(cachedGroup.docId);
3373
3517
  const adminSigningKeyPair = {
3374
3518
  publicKey: baseTenant.getAdministrationPublicKey(),
3375
3519
  privateKey: administrationPrivateKey,
@@ -3377,36 +3521,47 @@ class BaseMindooTenantDirectory {
3377
3521
  // Compute hashes of usernames to remove. Include both legacy (v1) and
3378
3522
  // salted (v2) forms so members stored under either scheme are removed.
3379
3523
  const hashesToRemove = new Set((await Promise.all(username.map(u => this.usernameHashCandidates(u)))).flat());
3380
- // Remove users from members arrays
3381
- await directoryDB.changeDoc(groupDoc, async (d) => {
3382
- const data = d.getData();
3383
- if (!data.members_hashes || !Array.isArray(data.members_hashes) ||
3384
- !data.members_encrypted || !Array.isArray(data.members_encrypted)) {
3385
- return;
3386
- }
3387
- // Filter out members whose hashes match the ones to remove
3388
- // Keep arrays in sync by filtering indices
3389
- const indicesToKeep = [];
3390
- const hashesArray = data.members_hashes;
3391
- for (let i = 0; i < hashesArray.length; i++) {
3392
- if (!hashesToRemove.has(hashesArray[i])) {
3393
- indicesToKeep.push(i);
3524
+ // Membership is the union over every document carrying this group name,
3525
+ // so the users must be removed from all of them, not only the latest.
3526
+ const groupDocIds = new Set([cachedGroup.docId]);
3527
+ for (const [docId, entry] of this.groupDocsById) {
3528
+ if (entry.normalizedName === normalizedGroupName && !entry.deleted) {
3529
+ groupDocIds.add(docId);
3530
+ }
3531
+ }
3532
+ for (const groupDocId of groupDocIds) {
3533
+ const groupDoc = await directoryDB.getDocument(groupDocId);
3534
+ // Remove users from members arrays
3535
+ await directoryDB.changeDoc(groupDoc, async (d) => {
3536
+ const data = d.getData();
3537
+ if (!data.members_hashes || !Array.isArray(data.members_hashes) ||
3538
+ !data.members_encrypted || !Array.isArray(data.members_encrypted)) {
3539
+ return;
3394
3540
  }
3395
- }
3396
- const encryptedArray = data.members_encrypted;
3397
- data.members_hashes = indicesToKeep.map(i => hashesArray[i]);
3398
- data.members_encrypted = indicesToKeep.map(i => encryptedArray[i]);
3399
- // Ensure form, type, and groupName fields are always correct
3400
- data.form = "group";
3401
- data.type = "group";
3402
- data.groupName = normalizedGroupName;
3403
- }, {
3404
- signingKeyPair: adminSigningKeyPair,
3405
- signingKeyPassword: administrationPrivateKeyPassword,
3406
- });
3541
+ // Filter out members whose hashes match the ones to remove
3542
+ // Keep arrays in sync by filtering indices
3543
+ const indicesToKeep = [];
3544
+ const hashesArray = data.members_hashes;
3545
+ for (let i = 0; i < hashesArray.length; i++) {
3546
+ if (!hashesToRemove.has(hashesArray[i])) {
3547
+ indicesToKeep.push(i);
3548
+ }
3549
+ }
3550
+ const encryptedArray = data.members_encrypted;
3551
+ data.members_hashes = indicesToKeep.map(i => hashesArray[i]);
3552
+ data.members_encrypted = indicesToKeep.map(i => encryptedArray[i]);
3553
+ // Ensure form, type, and groupName fields are always correct
3554
+ data.form = "group";
3555
+ data.type = "group";
3556
+ data.groupName = normalizedGroupName;
3557
+ }, {
3558
+ signingKeyPair: adminSigningKeyPair,
3559
+ signingKeyPassword: administrationPrivateKeyPassword,
3560
+ });
3561
+ }
3407
3562
  // Invalidate cache for this group
3408
3563
  this.groupsCache.delete(normalizedGroupName);
3409
- this.unifiedCacheLastCursor = null;
3564
+ this.invalidateUnifiedCache();
3410
3565
  }
3411
3566
  async deleteGroup(groupName, administrationPrivateKey, administrationPrivateKeyPassword) {
3412
3567
  const normalizedGroupName = this.normalizeGroupName(groupName);
@@ -3432,7 +3587,7 @@ class BaseMindooTenantDirectory {
3432
3587
  });
3433
3588
  // Remove from cache
3434
3589
  this.groupsCache.delete(normalizedGroupName);
3435
- this.unifiedCacheLastCursor = null;
3590
+ this.invalidateUnifiedCache();
3436
3591
  }
3437
3592
  async getUserNamesList(username) {
3438
3593
  // Generate username variants (wildcards)