mindoodb 0.0.37 → 0.0.39

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.
@@ -229,6 +229,27 @@ class BaseMindooDB {
229
229
  // Cache for imported public keys (CryptoKey objects) to avoid re-importing the same key
230
230
  // Map<publicKeyPEM, CryptoKey>
231
231
  this.publicKeyCache = new Map();
232
+ /**
233
+ * Author signing keys whose entries were skipped during materialization
234
+ * because the key was not (yet) trusted by the tenant directory, mapped to
235
+ * the ids of the affected documents.
236
+ *
237
+ * This is the record that makes skipped entries recoverable: when the
238
+ * directory later learns about the author (a `grantaccess` document arrives
239
+ * on sync), {@link reconcileAuthorTrust} purges and force-re-emits exactly
240
+ * these documents so they re-materialize with the now-trusted entries and
241
+ * every `iterateChangesSince` consumer (views, summary, full-text) catches
242
+ * up. Persisted in the metadata checkpoint so the healing also works when
243
+ * the directory is synced in a later session while this DB is closed.
244
+ */
245
+ this.pendingUntrustedAuthors = new Map();
246
+ /**
247
+ * Set while restoring a metadata checkpoint that predates the
248
+ * `pendingUntrustedAuthors` field. Triggers a one-time heal scan at open
249
+ * ({@link reconcileAuthorTrustAtOpen}) that detects store entries missing
250
+ * from the cached materialization and seeds the pending map from them.
251
+ */
252
+ this.legacyCheckpointNeedsAuthorTrustScan = false;
232
253
  // Local cache support
233
254
  this.cacheManager = null;
234
255
  this.cachePrefix = null;
@@ -326,6 +347,12 @@ class BaseMindooDB {
326
347
  * the warmer settled can still display its outcome.
327
348
  */
328
349
  this.warmerProgress = null;
350
+ // ---------------------------------------------------------------------------
351
+ // Author-trust reconciliation: heal documents whose entries were skipped
352
+ // because their author was not yet known to the tenant directory.
353
+ // ---------------------------------------------------------------------------
354
+ /** Serializes concurrent author-trust reconciles (per database). */
355
+ this.authorTrustReconcileChain = Promise.resolve();
329
356
  (0, databaseIdValidation_1.validateDatabaseId)(store.getId(), "dbId");
330
357
  (0, databaseIdValidation_1.validateDatabaseId)(attachmentStore.getId(), "dbId");
331
358
  if (store.getId() !== attachmentStore.getId()) {
@@ -903,8 +930,19 @@ class BaseMindooDB {
903
930
  /**
904
931
  * Suppress timer-based change emission for the duration of a batch
905
932
  * operation (sync ingest); the release emits one coalesced event.
933
+ *
934
+ * Anything already pending when the outermost hold begins can only be
935
+ * coalesced local writes (ingest additions happen strictly inside a hold),
936
+ * so it is flushed as a `"local"` event first. Without this, a write helper
937
+ * that follows its write with an immediate `syncStoreChanges()` — before
938
+ * the coalescing setTimeout(0) timer gets a macrotask to fire — would see
939
+ * its local write re-labeled `"ingest"` at hold release, and origin-
940
+ * filtering consumers (e.g. auto-push) would miss the user's edit.
906
941
  */
907
942
  beginChangeNotificationHold() {
943
+ if (this.changeNotificationHolds === 0) {
944
+ this.emitPendingChangeEvent("local");
945
+ }
908
946
  this.changeNotificationHolds++;
909
947
  }
910
948
  endChangeNotificationHold() {
@@ -1180,6 +1218,18 @@ class BaseMindooDB {
1180
1218
  if (this.lastReconciledKeyBagFingerprint !== null) {
1181
1219
  checkpoint.keyBagFingerprint = this.lastReconciledKeyBagFingerprint;
1182
1220
  }
1221
+ // Persist the pending untrusted-author map (additive field). Written even
1222
+ // when empty: the field's PRESENCE marks the checkpoint as
1223
+ // author-trust-aware, so restore can distinguish it from a legacy
1224
+ // checkpoint that needs the one-time heal scan (see
1225
+ // reconcileAuthorTrustAtOpen).
1226
+ const pendingUntrusted = {};
1227
+ for (const [publicKey, docIds] of this.pendingUntrustedAuthors) {
1228
+ if (docIds.size > 0) {
1229
+ pendingUntrusted[publicKey] = Array.from(docIds);
1230
+ }
1231
+ }
1232
+ checkpoint.pendingUntrustedAuthors = pendingUntrusted;
1183
1233
  return new TextEncoder().encode(JSON.stringify(checkpoint));
1184
1234
  }
1185
1235
  /**
@@ -1208,6 +1258,24 @@ class BaseMindooDB {
1208
1258
  }
1209
1259
  this.lastReconciledKeyBagFingerprint =
1210
1260
  typeof checkpoint.keyBagFingerprint === "string" ? checkpoint.keyBagFingerprint : null;
1261
+ // Pending untrusted-author map. A checkpoint written before this field
1262
+ // existed may hide entries that were silently skipped because their
1263
+ // author was unknown at the time - flag it for the one-time heal scan.
1264
+ this.pendingUntrustedAuthors.clear();
1265
+ if (checkpoint.pendingUntrustedAuthors && typeof checkpoint.pendingUntrustedAuthors === "object") {
1266
+ this.legacyCheckpointNeedsAuthorTrustScan = false;
1267
+ for (const [publicKey, docIds] of Object.entries(checkpoint.pendingUntrustedAuthors)) {
1268
+ if (!Array.isArray(docIds))
1269
+ continue;
1270
+ const validIds = docIds.filter((id) => typeof id === "string");
1271
+ if (validIds.length > 0) {
1272
+ this.pendingUntrustedAuthors.set(publicKey, new Set(validIds));
1273
+ }
1274
+ }
1275
+ }
1276
+ else {
1277
+ this.legacyCheckpointNeedsAuthorTrustScan = true;
1278
+ }
1211
1279
  this.syncScanCursors.clear();
1212
1280
  if (checkpoint.syncScanCursors && typeof checkpoint.syncScanCursors === "object") {
1213
1281
  for (const [key, value] of Object.entries(checkpoint.syncScanCursors)) {
@@ -1399,6 +1467,28 @@ class BaseMindooDB {
1399
1467
  this.publicKeyCache.set(publicKey, cryptoKey);
1400
1468
  return cryptoKey;
1401
1469
  }
1470
+ /**
1471
+ * Remember that entries of `publicKey` were skipped for `docId` because the
1472
+ * key is not (yet) trusted by the tenant directory. The record is persisted
1473
+ * in the metadata checkpoint and consumed by {@link reconcileAuthorTrust}
1474
+ * once the directory later trusts the key (e.g. a grantaccess document
1475
+ * arrived on a delayed directory sync), so the skipped entries get
1476
+ * re-materialized instead of staying invisible forever.
1477
+ */
1478
+ recordPendingUntrustedAuthor(publicKey, docId) {
1479
+ let docIds = this.pendingUntrustedAuthors.get(publicKey);
1480
+ if (!docIds) {
1481
+ docIds = new Set();
1482
+ this.pendingUntrustedAuthors.set(publicKey, docIds);
1483
+ }
1484
+ if (docIds.has(docId)) {
1485
+ return;
1486
+ }
1487
+ docIds.add(docId);
1488
+ this.logger.info(`Recorded pending untrusted author for doc ${docId}: entries skipped until the directory trusts the key`);
1489
+ this.cacheMetaDirty = true;
1490
+ this.cacheManager?.markDirty();
1491
+ }
1402
1492
  /**
1403
1493
  * Compute the author's metadata-binding signature for a new entry (audit
1404
1494
  * finding #5). The signature covers the canonical, versioned, length-prefixed
@@ -1807,6 +1897,12 @@ class BaseMindooDB {
1807
1897
  if (this.reconcileRestoredIndexOnInit) {
1808
1898
  await this.reconcileRestoredIndexWithStore();
1809
1899
  }
1900
+ // Author-trust healing: entries skipped in an earlier session because
1901
+ // their author was unknown to the directory become materializable once
1902
+ // a grantaccess arrived while this DB was closed. Also runs the
1903
+ // one-time legacy heal scan for checkpoints that predate the
1904
+ // pending-untrusted-author tracking.
1905
+ await this.reconcileAuthorTrustAtOpen();
1810
1906
  this.probeFulltextSetupAtOpen();
1811
1907
  this.probeSummarySetupAtOpen();
1812
1908
  return;
@@ -2080,6 +2176,190 @@ class BaseMindooDB {
2080
2176
  // can skip the scan.
2081
2177
  this.markKeyBagFingerprintReconciled(await this.computeCurrentKeyBagFingerprint());
2082
2178
  }
2179
+ /**
2180
+ * Re-materialize documents whose entries were previously skipped because the
2181
+ * author's signing key was not (yet) trusted by the tenant directory.
2182
+ *
2183
+ * This is the counterpart to {@link reconcileKeyVisibility} for AUTHOR trust
2184
+ * instead of decryption keys: when a `grantaccess` document arrives on a
2185
+ * delayed directory sync, entries that were quarantined/skipped earlier
2186
+ * (recorded in {@link pendingUntrustedAuthors}) become valid. For each
2187
+ * affected document we purge every cached materialization (L1/L2/summary/
2188
+ * full-text) and force a changeSeq bump on the index so all
2189
+ * `iterateChangesSince` consumers (virtual views, summary buffer, full-text
2190
+ * index) re-process the doc; the body itself re-materializes lazily on the
2191
+ * next load and now includes the previously skipped entries.
2192
+ *
2193
+ * Safe to call repeatedly; concurrent calls are serialized and a call with
2194
+ * no pending or no newly-trusted keys is a fast no-op. Errors are logged,
2195
+ * never thrown (best-effort hook semantics).
2196
+ *
2197
+ * @param newlyTrustedKeys Signing keys that just became trusted (directory
2198
+ * cache diff). When omitted, every pending key is re-checked against the
2199
+ * directory - used at DB open to catch trust changes that happened while
2200
+ * this database was closed.
2201
+ */
2202
+ reconcileAuthorTrust(newlyTrustedKeys) {
2203
+ const run = () => this.reconcileAuthorTrustNow(newlyTrustedKeys).catch((error) => {
2204
+ this.logger.warn(`reconcileAuthorTrust failed: ${error}`);
2205
+ });
2206
+ const chained = this.authorTrustReconcileChain.then(run, run);
2207
+ this.authorTrustReconcileChain = chained;
2208
+ return chained;
2209
+ }
2210
+ async reconcileAuthorTrustNow(newlyTrustedKeys) {
2211
+ if (this.isTimeTravelMode()) {
2212
+ return;
2213
+ }
2214
+ if (this.pendingUntrustedAuthors.size === 0) {
2215
+ return;
2216
+ }
2217
+ const candidateKeys = Array.from(this.pendingUntrustedAuthors.keys()).filter((key) => !newlyTrustedKeys || newlyTrustedKeys.has(key));
2218
+ if (candidateKeys.length === 0) {
2219
+ return;
2220
+ }
2221
+ // Confirm against the directory even when the caller supplied a diff:
2222
+ // validatePublicSigningKey is the same trust source the materialization
2223
+ // paths use, so both stay consistent.
2224
+ const directory = await this.tenant.openDirectory();
2225
+ const trustedKeys = [];
2226
+ for (const key of candidateKeys) {
2227
+ try {
2228
+ if (await directory.validatePublicSigningKey(key)) {
2229
+ trustedKeys.push(key);
2230
+ }
2231
+ }
2232
+ catch (error) {
2233
+ // Transient directory failure: keep the pending record so a later
2234
+ // reconcile retries (fail open towards retry, never towards loss).
2235
+ this.logger.warn(`reconcileAuthorTrust: directory validation failed for a pending key: ${error}`);
2236
+ }
2237
+ }
2238
+ if (trustedKeys.length === 0) {
2239
+ return;
2240
+ }
2241
+ const affectedDocIds = new Set();
2242
+ for (const key of trustedKeys) {
2243
+ for (const docId of this.pendingUntrustedAuthors.get(key) ?? []) {
2244
+ affectedDocIds.add(docId);
2245
+ }
2246
+ this.pendingUntrustedAuthors.delete(key);
2247
+ }
2248
+ this.logger.info(`reconcileAuthorTrust: ${trustedKeys.length} author key(s) became trusted, re-materializing ${affectedDocIds.size} document(s)`);
2249
+ // Deterministic ordering keeps the resulting changefeed stable between
2250
+ // runs (mirrors reconcileKeyVisibility).
2251
+ const docIds = Array.from(affectedDocIds).sort((left, right) => left.localeCompare(right));
2252
+ for (const docId of docIds) {
2253
+ // Drop every cached form of the stale materialization first so the
2254
+ // next load rebuilds from the store with the now-trusted entries.
2255
+ await this.purgeMaterializedDocument(docId);
2256
+ const metadata = await this.scanAllMetadata(this.store, { docId });
2257
+ const lifecycleEntries = metadata.filter((entry) => entry.entryType === "doc_create"
2258
+ || entry.entryType === "doc_change"
2259
+ || entry.entryType === "doc_delete"
2260
+ || entry.entryType === "doc_undelete"
2261
+ || entry.entryType === "doc_snapshot");
2262
+ const visibility = this.deriveDocumentVisibilityMetadata(metadata);
2263
+ if (!visibility) {
2264
+ continue;
2265
+ }
2266
+ // Author trust and key visibility are independent layers: without the
2267
+ // decryption key the doc stays governed by reconcileKeyVisibility.
2268
+ if (!(await this.tenant.hasDecryptionKey(visibility.decryptionKeyId))) {
2269
+ continue;
2270
+ }
2271
+ const { awaitingWitness, witnessed } = (0, storeEntryTime_1.metadataWitnessState)(lifecycleEntries);
2272
+ // Force the changeSeq bump: the tracked index fields may be identical to
2273
+ // the stale entry, but the materialized content WILL differ once the
2274
+ // previously skipped entries are applied - consumers must re-read.
2275
+ this.updateIndex(docId, visibility.lastModified, visibility.isDeleted, visibility.decryptionKeyId, "visible", awaitingWitness, witnessed, true);
2276
+ }
2277
+ this.cacheMetaDirty = true;
2278
+ this.cacheManager?.markDirty();
2279
+ }
2280
+ /**
2281
+ * Open-time author-trust healing, called from {@link initialize} after a
2282
+ * cache restore. Two jobs:
2283
+ *
2284
+ * 1. Legacy checkpoints (written before {@link pendingUntrustedAuthors}
2285
+ * existed) may hide entries that were silently skipped - detect them via
2286
+ * the persisted automerge-hash lookup and seed the pending map.
2287
+ * 2. Re-check all pending authors against the directory, healing documents
2288
+ * whose authors became trusted while this database was closed (e.g. the
2289
+ * directory was synced in a session that never opened this DB).
2290
+ */
2291
+ async reconcileAuthorTrustAtOpen() {
2292
+ if (this.isTimeTravelMode() || this._isAdminOnlyDb || this.store.getId() === "directory") {
2293
+ return;
2294
+ }
2295
+ if (this.legacyCheckpointNeedsAuthorTrustScan) {
2296
+ this.legacyCheckpointNeedsAuthorTrustScan = false;
2297
+ try {
2298
+ await this.seedPendingAuthorsFromLegacyCheckpoint();
2299
+ }
2300
+ catch (error) {
2301
+ this.logger.warn(`Legacy author-trust heal scan failed: ${error}`);
2302
+ }
2303
+ // Whether or not entries were found, the checkpoint is upgraded on the
2304
+ // next flush (the pendingUntrustedAuthors field is always written), so
2305
+ // this scan runs at most once per database.
2306
+ this.cacheMetaDirty = true;
2307
+ this.cacheManager?.markDirty();
2308
+ }
2309
+ if (this.pendingUntrustedAuthors.size === 0) {
2310
+ return;
2311
+ }
2312
+ await this.reconcileAuthorTrust();
2313
+ }
2314
+ /**
2315
+ * One-time heal scan for checkpoints written before the pending
2316
+ * untrusted-author tracking existed: entries skipped back then left no
2317
+ * record, but the persisted automerge-hash lookup
2318
+ * ({@link automergeHashToEntryId}) only contains changes that were actually
2319
+ * APPLIED to a materialization. A store head entry whose hash is missing
2320
+ * from that lookup therefore marks a document whose cached state is missing
2321
+ * entries - seed the pending map with the authors of all unapplied entries
2322
+ * so the regular reconcile can heal (or keep waiting for) them.
2323
+ */
2324
+ async seedPendingAuthorsFromLegacyCheckpoint() {
2325
+ const allMetadata = await this.scanAllMetadata(this.store);
2326
+ const replayByDoc = new Map();
2327
+ for (const entry of allMetadata) {
2328
+ if (!this.isDocumentReplayEntry(entry)) {
2329
+ continue;
2330
+ }
2331
+ const entries = replayByDoc.get(entry.docId) ?? [];
2332
+ entries.push(entry);
2333
+ replayByDoc.set(entry.docId, entries);
2334
+ }
2335
+ let seededDocs = 0;
2336
+ for (const [docId, replayEntries] of replayByDoc) {
2337
+ const applied = this.automergeHashToEntryId.get(docId);
2338
+ // Heads applied => every entry reachable from them was applied too
2339
+ // (Automerge buffers dependents of missing changes), so checking the
2340
+ // active DAG heads is sufficient and avoids false positives for entries
2341
+ // that are only covered by a snapshot.
2342
+ const headEntryIds = this.findActiveReplayHeadEntryIds(replayEntries);
2343
+ const hasUnappliedHead = headEntryIds.some((entryId) => {
2344
+ const parsed = (0, idGeneration_1.parseDocEntryId)(entryId);
2345
+ return parsed !== null && !(applied?.has(parsed.automergeHash));
2346
+ });
2347
+ if (!hasUnappliedHead) {
2348
+ continue;
2349
+ }
2350
+ seededDocs++;
2351
+ for (const entry of replayEntries) {
2352
+ const parsed = (0, idGeneration_1.parseDocEntryId)(entry.id);
2353
+ if (!parsed || applied?.has(parsed.automergeHash)) {
2354
+ continue;
2355
+ }
2356
+ this.recordPendingUntrustedAuthor(entry.createdByPublicKey, docId);
2357
+ }
2358
+ }
2359
+ if (seededDocs > 0) {
2360
+ this.logger.info(`Legacy author-trust heal scan found ${seededDocs} document(s) with unapplied store entries`);
2361
+ }
2362
+ }
2083
2363
  async reconcileRestoredIndexWithStore() {
2084
2364
  const lifecycleMetadata = await this.scanAllMetadata(this.store);
2085
2365
  const lifecycleDocIds = Array.from(new Set(lifecycleMetadata
@@ -8149,6 +8429,12 @@ class BaseMindooDB {
8149
8429
  if (cryptoKey) {
8150
8430
  keyMap.set(publicKey, cryptoKey);
8151
8431
  }
8432
+ else {
8433
+ // Author key not (yet) trusted by the directory: the entries below
8434
+ // will be skipped. Record the author so a later grantaccess sync
8435
+ // can re-materialize this document (reconcileAuthorTrust).
8436
+ this.recordPendingUntrustedAuthor(publicKey, docId);
8437
+ }
8152
8438
  }
8153
8439
  // Verify all signatures in parallel using cached keys
8154
8440
  const signatureVerificationResults = await Promise.all(validEntries.map(async (entryData) => {
@@ -8948,7 +9234,14 @@ class BaseMindooDB {
8948
9234
  }
8949
9235
  // Verify the author signature over the encrypted payload.
8950
9236
  const cryptoKey = await this.getOrImportPublicKey(entry.createdByPublicKey);
8951
- if (!cryptoKey || !(await this.verifyEntrySignatureWithKey(cryptoKey, entry))) {
9237
+ if (!cryptoKey) {
9238
+ // Author key not (yet) trusted by the directory. Remember it so a
9239
+ // later grantaccess sync can re-materialize the doc (reconcileAuthorTrust).
9240
+ this.recordPendingUntrustedAuthor(entry.createdByPublicKey, docId);
9241
+ quarantine(meta, "invalid_signature", "author key not trusted by directory");
9242
+ continue;
9243
+ }
9244
+ if (!(await this.verifyEntrySignatureWithKey(cryptoKey, entry))) {
8952
9245
  quarantine(meta, "invalid_signature", "signature verification failed");
8953
9246
  continue;
8954
9247
  }
@@ -9352,6 +9645,12 @@ class BaseMindooDB {
9352
9645
  if (cryptoKey) {
9353
9646
  keyMap.set(publicKey, cryptoKey);
9354
9647
  }
9648
+ else {
9649
+ // Author key not (yet) trusted by the directory: the entries below
9650
+ // will be skipped. Record the author so a later grantaccess sync
9651
+ // can re-materialize this document (reconcileAuthorTrust).
9652
+ this.recordPendingUntrustedAuthor(publicKey, docId);
9653
+ }
9355
9654
  }
9356
9655
  // Verify all signatures in parallel using cached keys
9357
9656
  const signatureStartedAt = Date.now();
@@ -10240,6 +10539,13 @@ class BaseMindooDB {
10240
10539
  // single-flight; never blocks or fails the sync.
10241
10540
  if (this.store.getId() === "directory") {
10242
10541
  await this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
10542
+ // Author-trust reconcile: refresh the directory trust caches NOW
10543
+ // (instead of waiting up to DIRECTORY_SYNC_INTERVAL_MS for the next
10544
+ // cache-miss validation) so grantaccess documents that just arrived
10545
+ // immediately unblock entries other open databases skipped while
10546
+ // the author was still unknown. Awaited so a subsequent content-DB
10547
+ // sync in the same flow observes the healed state.
10548
+ await this.tenant.reconcileAuthorTrustAfterDirectorySyncSafe();
10243
10549
  }
10244
10550
  }
10245
10551
  if (options?.signal?.aborted) {