mindoodb 0.0.65 → 0.0.67

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 (78) hide show
  1. package/README-server.md +77 -16
  2. package/README.md +41 -58
  3. package/dist/appendonlystores/network/HttpTransport.d.ts +2 -0
  4. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  5. package/dist/appendonlystores/network/HttpTransport.js +31 -6
  6. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  7. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +18 -8
  8. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  9. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +30 -16
  10. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  11. package/dist/browser/index.mjs +29 -0
  12. package/dist/core/BaseMindooDB.d.ts +129 -8
  13. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  14. package/dist/core/BaseMindooDB.js +631 -60
  15. package/dist/core/BaseMindooDB.js.map +1 -1
  16. package/dist/core/appendonlystores/network/IrohNetworkTransport.d.ts.map +1 -1
  17. package/dist/core/appendonlystores/network/IrohNetworkTransport.js +5 -1
  18. package/dist/core/appendonlystores/network/IrohNetworkTransport.js.map +1 -1
  19. package/dist/core/appendonlystores/network/IrohStreamIO.d.ts +10 -0
  20. package/dist/core/appendonlystores/network/IrohStreamIO.d.ts.map +1 -1
  21. package/dist/core/appendonlystores/network/IrohStreamIO.js.map +1 -1
  22. package/dist/core/appendonlystores/network/types.d.ts +20 -0
  23. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  24. package/dist/core/appendonlystores/network/types.js +33 -0
  25. package/dist/core/appendonlystores/network/types.js.map +1 -1
  26. package/dist/core/appendonlystores/syncStores.d.ts +80 -2
  27. package/dist/core/appendonlystores/syncStores.d.ts.map +1 -1
  28. package/dist/core/appendonlystores/syncStores.js +82 -9
  29. package/dist/core/appendonlystores/syncStores.js.map +1 -1
  30. package/dist/core/appendonlystores/types.d.ts +88 -6
  31. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  32. package/dist/core/appendonlystores/types.js +61 -1
  33. package/dist/core/appendonlystores/types.js.map +1 -1
  34. package/dist/core/builtinDbInvariants.d.ts +8 -5
  35. package/dist/core/builtinDbInvariants.d.ts.map +1 -1
  36. package/dist/core/builtinDbInvariants.js +8 -5
  37. package/dist/core/builtinDbInvariants.js.map +1 -1
  38. package/dist/core/index.d.ts +8 -0
  39. package/dist/core/index.d.ts.map +1 -1
  40. package/dist/core/index.js +45 -11
  41. package/dist/core/index.js.map +1 -1
  42. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts +26 -3
  43. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts.map +1 -1
  44. package/dist/core/indexing/summary/DocumentSummaryStore.js +100 -15
  45. package/dist/core/indexing/summary/DocumentSummaryStore.js.map +1 -1
  46. package/dist/core/quarantine/QuarantineDocument.d.ts +211 -0
  47. package/dist/core/quarantine/QuarantineDocument.d.ts.map +1 -0
  48. package/dist/core/quarantine/QuarantineDocument.js +314 -0
  49. package/dist/core/quarantine/QuarantineDocument.js.map +1 -0
  50. package/dist/core/quarantine/QuarantineView.d.ts +90 -0
  51. package/dist/core/quarantine/QuarantineView.d.ts.map +1 -0
  52. package/dist/core/quarantine/QuarantineView.js +106 -0
  53. package/dist/core/quarantine/QuarantineView.js.map +1 -0
  54. package/dist/core/quarantine/recordPushRejections.d.ts +73 -0
  55. package/dist/core/quarantine/recordPushRejections.d.ts.map +1 -0
  56. package/dist/core/quarantine/recordPushRejections.js +112 -0
  57. package/dist/core/quarantine/recordPushRejections.js.map +1 -0
  58. package/dist/core/quarantine/resubmitQuarantined.d.ts +81 -0
  59. package/dist/core/quarantine/resubmitQuarantined.d.ts.map +1 -0
  60. package/dist/core/quarantine/resubmitQuarantined.js +120 -0
  61. package/dist/core/quarantine/resubmitQuarantined.js.map +1 -0
  62. package/dist/core/types.d.ts +132 -4
  63. package/dist/core/types.d.ts.map +1 -1
  64. package/dist/core/types.js.map +1 -1
  65. package/dist/core/userkeys/UserKeyManager.d.ts.map +1 -1
  66. package/dist/core/userkeys/UserKeyManager.js +11 -1
  67. package/dist/core/userkeys/UserKeyManager.js.map +1 -1
  68. package/dist/core/values.d.ts +72 -0
  69. package/dist/core/values.d.ts.map +1 -0
  70. package/dist/core/values.js +107 -0
  71. package/dist/core/values.js.map +1 -0
  72. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  73. package/dist/node/server/MindooDBServer.js +6 -1
  74. package/dist/node/server/MindooDBServer.js.map +1 -1
  75. package/dist/node/server/peer/PeerReplicator.d.ts.map +1 -1
  76. package/dist/node/server/peer/PeerReplicator.js +35 -15
  77. package/dist/node/server/peer/PeerReplicator.js.map +1 -1
  78. package/package.json +1 -1
@@ -5,6 +5,7 @@ exports.BaseMindooDB = exports.CUSTOM_DOC_ID_INITIAL_CHANGE_BYTES = void 0;
5
5
  // React Native: native Rust (react-native-automerge-generated)
6
6
  // Browser/Node.js: WASM (@automerge/automerge/slim)
7
7
  const automerge_adapter_1 = require("./automerge-adapter");
8
+ const values_1 = require("./values");
8
9
  const storeEntryTime_1 = require("./storeEntryTime");
9
10
  const uuid_1 = require("uuid");
10
11
  const types_1 = require("./types");
@@ -150,6 +151,23 @@ const DEFAULT_WARMER_SCHEDULER = {
150
151
  * with a v2 header.
151
152
  */
152
153
  const DOC_CACHE_HEADER_VERSION = 2;
154
+ /**
155
+ * How many quarantine records one document keeps in the audit log.
156
+ *
157
+ * One refused entry usually taints its causal dependents too (the
158
+ * `cascade_dependent` reason), so a single bad entry near the start of a long
159
+ * history can quarantine everything after it. The log exists to say which
160
+ * document is affected and why; a bounded sample of entry ids answers that as
161
+ * well as an unbounded one, and keeps one broken document from crowding every
162
+ * other out of the log — and out of the checkpoint it is written to.
163
+ */
164
+ const MAX_QUARANTINE_RECORDS_PER_DOC = 200;
165
+ /**
166
+ * How many documents the audit log covers at once. Oldest-first eviction: a
167
+ * document that started failing long ago has had its chance to be noticed,
168
+ * whereas dropping the newest would hide the problem the user is looking at.
169
+ */
170
+ const MAX_QUARANTINE_DOCS = 500;
153
171
  /**
154
172
  * Hard-coded initial Automerge change bytes that seed every document created
155
173
  * with a caller-provided id (`createDocument({ id })`).
@@ -216,10 +234,19 @@ class BaseMindooDB {
216
234
  constructor(tenant, store, attachmentStore, attachmentConfig, documentCacheConfig, snapshotConfig, adminOnlyDb = false, logger, performanceCallback, timeTravelDate = null) {
217
235
  /**
218
236
  * Per-tenant quarantine/audit log of entries rejected during materialization
219
- * (docs/accesscontrol.md §10). In-memory; surfaced via {@link getQuarantineLog}
220
- * for Haven's audit view.
237
+ * (docs/accesscontrol.md §10), grouped by document and persisted in the
238
+ * metadata checkpoint. Surfaced via {@link getQuarantineLog} for Haven's
239
+ * audit view.
240
+ *
241
+ * Grouped by document because that is the unit a verdict is recomputed in: a
242
+ * materialization pass re-evaluates every entry of one document and knows the
243
+ * complete set of quarantined ones when it ends. Replacing that document's
244
+ * group with the pass result is what makes recording idempotent — the log
245
+ * would otherwise gain a duplicate of every record on every re-materialization
246
+ * — and what lets it heal once trust is restored, since an entry that is
247
+ * accepted this time is simply absent from the new group.
221
248
  */
222
- this.quarantineLog = [];
249
+ this.quarantineLog = new Map();
223
250
  /**
224
251
  * Short-TTL cache of "is access control active for this tenant?" so the
225
252
  * materialization fast-path does not re-query the directory on every load.
@@ -446,6 +473,16 @@ class BaseMindooDB {
446
473
  /** Whether the setup document was already probed for FTS auto-activation. */
447
474
  this.fulltextSetupProbed = false;
448
475
  this.dirtyDocIds = new Set();
476
+ /**
477
+ * Per-document counter bumped by {@link updateIndex}, i.e. whenever new
478
+ * entries of a document are indexed. A materialization compares it before
479
+ * caching its result: if entries arrived while it was reading (a sync
480
+ * ingested the next page while a changefeed consumer was materializing),
481
+ * the result reflects an older entry set and must not land in L1 — the
482
+ * index already points past it, so a cached copy would be served (and
483
+ * flushed to L2 under the new changeSeq) as if it were current.
484
+ */
485
+ this.docEntryGeneration = new Map();
449
486
  this.cacheMetaDirty = false;
450
487
  /**
451
488
  * Per-document fingerprint (`changeSeq:automergeHeads`) of the state last
@@ -586,7 +623,7 @@ class BaseMindooDB {
586
623
  usernameHashFromRecord(payload) {
587
624
  if (!payload || typeof payload !== "object")
588
625
  return null;
589
- const value = payload.username_hash;
626
+ const value = this.readAutomergeScalar(payload.username_hash);
590
627
  return typeof value === "string" && value.length > 0 ? value : null;
591
628
  }
592
629
  usernameHashFromInternalDoc(internalDoc) {
@@ -837,7 +874,7 @@ class BaseMindooDB {
837
874
  // flush-before-remove step.
838
875
  this.dirtyDocIds.clear();
839
876
  this.cacheMetaDirty = false;
840
- this.summaryStore?.clearDirty();
877
+ this.summaryStore?.discardDirtyState();
841
878
  this.fulltextIndex?.clearDirty();
842
879
  void cacheManager.deregister(this);
843
880
  if (this.summaryStore) {
@@ -1594,6 +1631,14 @@ class BaseMindooDB {
1594
1631
  // the checkpoint as tally-aware, so restore can tell "nothing hidden"
1595
1632
  // apart from "written before this existed, count unknown".
1596
1633
  checkpoint.inaccessibleDocIds = Array.from(this.inaccessibleDocIds);
1634
+ // Persist the materialization quarantine log (additive field, no version
1635
+ // bump: a missing log restores as empty, which is exactly what the log was
1636
+ // before it was persisted at all — and bumping the version would force a
1637
+ // full metadata rebuild on every existing installation to gain nothing but
1638
+ // an audit trail).
1639
+ if (this.quarantineLog.size > 0) {
1640
+ checkpoint.quarantineLog = Object.fromEntries(this.quarantineLog);
1641
+ }
1597
1642
  return new TextEncoder().encode(JSON.stringify(checkpoint));
1598
1643
  }
1599
1644
  /**
@@ -1659,6 +1704,31 @@ class BaseMindooDB {
1659
1704
  else {
1660
1705
  this.legacyCheckpointNeedsVisibilityTally = true;
1661
1706
  }
1707
+ // Materialization quarantine log. Absent in checkpoints written before it
1708
+ // was persisted, and absent whenever nothing is quarantined — both mean
1709
+ // an empty log, and no heal flag is needed: the next materialization of
1710
+ // an affected document re-derives its verdict anyway.
1711
+ this.quarantineLog.clear();
1712
+ if (checkpoint.quarantineLog &&
1713
+ typeof checkpoint.quarantineLog === "object") {
1714
+ for (const [docId, records] of Object.entries(checkpoint.quarantineLog)) {
1715
+ if (!Array.isArray(records))
1716
+ continue;
1717
+ const valid = records
1718
+ .filter((rec) => Boolean(rec &&
1719
+ typeof rec === "object" &&
1720
+ typeof rec.entryId === "string" &&
1721
+ typeof rec.reason === "string"))
1722
+ // Re-apply the caps on read: this file is on disk and may have been
1723
+ // written by a build with different limits.
1724
+ .slice(0, MAX_QUARANTINE_RECORDS_PER_DOC);
1725
+ if (valid.length === 0)
1726
+ continue;
1727
+ if (this.quarantineLog.size >= MAX_QUARANTINE_DOCS)
1728
+ break;
1729
+ this.quarantineLog.set(docId, valid);
1730
+ }
1731
+ }
1662
1732
  this.syncScanCursors.clear();
1663
1733
  if (checkpoint.syncScanCursors &&
1664
1734
  typeof checkpoint.syncScanCursors === "object") {
@@ -1799,12 +1869,18 @@ class BaseMindooDB {
1799
1869
  * fall back to the normal materialization path when they encounter
1800
1870
  * one. Throws on truly malformed records (caught by callers).
1801
1871
  */
1802
- deserializeDoc(value) {
1872
+ /** Split an L2 record into its JSON header and the Automerge binary. */
1873
+ parseDocCacheRecord(value) {
1803
1874
  const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
1804
1875
  const headerLen = view.getUint32(0, false);
1805
1876
  const headerBytes = value.slice(4, 4 + headerLen);
1806
- const amBinary = value.slice(4 + headerLen);
1807
- const header = JSON.parse(new TextDecoder().decode(headerBytes));
1877
+ return {
1878
+ header: JSON.parse(new TextDecoder().decode(headerBytes)),
1879
+ amBinary: value.slice(4 + headerLen),
1880
+ };
1881
+ }
1882
+ deserializeDoc(value) {
1883
+ const { header, amBinary } = this.parseDocCacheRecord(value);
1808
1884
  if (header.version !== DOC_CACHE_HEADER_VERSION) {
1809
1885
  // v1 records (no `version`) and unknown future versions: skip
1810
1886
  // gracefully so they get re-flushed with the current header on
@@ -2027,6 +2103,7 @@ class BaseMindooDB {
2027
2103
  // Every path that writes entries for a document lands here, so this is
2028
2104
  // where retained metadata for it goes stale (see scannedEntryMetadata).
2029
2105
  this.forgetScannedEntryMetadata(docId);
2106
+ this.docEntryGeneration.set(docId, (this.docEntryGeneration.get(docId) ?? 0) + 1);
2030
2107
  // Same reasoning for the unreadable-document tally: every reveal and every
2031
2108
  // inaccessibility tombstone passes through here, so keeping it in step
2032
2109
  // needs no bookkeeping at the individual call sites. The one case this
@@ -3464,7 +3541,7 @@ class BaseMindooDB {
3464
3541
  },
3465
3542
  };
3466
3543
  }
3467
- async syncEntriesFromStore(sourceStore, targetStore, options) {
3544
+ async syncEntriesFromStore(sourceStore, targetStore, options, onPageTransferred) {
3468
3545
  const signal = options?.signal;
3469
3546
  (0, syncStores_1.setSyncAbortSignalOnStore)(sourceStore, signal);
3470
3547
  (0, syncStores_1.setSyncAbortSignalOnStore)(targetStore, signal);
@@ -3482,6 +3559,7 @@ class BaseMindooDB {
3482
3559
  logger: this.logger,
3483
3560
  cursors: this.syncCursorStore(),
3484
3561
  rejectionPolicy: "advance",
3562
+ onPageTransferred,
3485
3563
  });
3486
3564
  }
3487
3565
  catch (error) {
@@ -3917,8 +3995,12 @@ class BaseMindooDB {
3917
3995
  // starting with "_", e.g. "_attachments") are managed by MindooDB and must
3918
3996
  // not be seeded by callers. `undefined` values are treated as "not set"
3919
3997
  // (Automerge cannot represent undefined).
3998
+ // `MindooValue` typed values become CRDT values here (this also validates
3999
+ // them before anything is written).
3920
4000
  const initialValueEntries = options.initialValues
3921
- ? Object.entries(options.initialValues).filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4001
+ ? Object.entries(options.initialValues)
4002
+ .filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4003
+ .map(([k, v]) => [k, this.hydrateTypedValues(v)])
3922
4004
  : [];
3923
4005
  const hasInitialValues = initialValueEntries.length > 0;
3924
4006
  if (useCustomDocId) {
@@ -4181,8 +4263,12 @@ class BaseMindooDB {
4181
4263
  // Sanitize caller-provided initial values: internal/reserved fields are
4182
4264
  // managed by MindooDB and must not be seeded by callers. `undefined`
4183
4265
  // values are treated as "not set" (Automerge cannot represent undefined).
4266
+ // `MindooValue` typed values become CRDT values here (this also validates
4267
+ // them before anything is written).
4184
4268
  const initialValueEntries = options.initialValues
4185
- ? Object.entries(options.initialValues).filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4269
+ ? Object.entries(options.initialValues)
4270
+ .filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4271
+ .map(([k, v]) => [k, this.hydrateTypedValues(v)])
4186
4272
  : [];
4187
4273
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
4188
4274
  const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
@@ -6836,13 +6922,11 @@ class BaseMindooDB {
6836
6922
  ...entryMetadata,
6837
6923
  encryptedData: encryptedPayload,
6838
6924
  };
6839
- // Client-side write-policy precheck (docs/accesscontrol.md §9). `internalDoc.doc`
6840
- // is still the pre-change state here (reassigned in
6841
- // applyLifecycleEntryLocally, after the store write). For deletes the rules
6842
- // default to the "before" state; both states are supplied so either `when`
6843
- // resolves. Throws AccessDeniedError if denied.
6925
+ // Client-side write-policy precheck (docs/accesscontrol.md §9). For deletes
6926
+ // the rules default to the "before" state; both states are supplied so either
6927
+ // `when` resolves. Throws AccessDeniedError if denied.
6844
6928
  {
6845
- const beforeState = internalDoc.doc;
6929
+ const beforeState = this.docAtHeads(internalDoc, newDoc, automergeDepHashes);
6846
6930
  await this.assertWriteAllowed({
6847
6931
  op: entryType,
6848
6932
  signerKey: createdByPublicKey,
@@ -7174,6 +7258,87 @@ class BaseMindooDB {
7174
7258
  spans: this.dehydrateRichTextSpans(spans),
7175
7259
  };
7176
7260
  }
7261
+ async getTextCursors(doc, path, positions, options) {
7262
+ this.validateJsonPath(path, "Text cursor");
7263
+ if (!Array.isArray(positions)) {
7264
+ throw new Error("Text cursor positions must be an array");
7265
+ }
7266
+ const { automergeDoc, heads } = await this.resolveTextCursorTarget(doc, options);
7267
+ if (options?.move !== undefined &&
7268
+ options.move !== "before" &&
7269
+ options.move !== "after") {
7270
+ throw new Error('Text cursor move must be "before" or "after"');
7271
+ }
7272
+ const source = this.textCursorSource(automergeDoc, heads);
7273
+ const text = this.readTextForCursor(source, path);
7274
+ const cursors = positions.map((position) => {
7275
+ let target;
7276
+ if (position === "start" || position === "end") {
7277
+ target = position;
7278
+ }
7279
+ else if (typeof position === "number" && Number.isFinite(position)) {
7280
+ target = this.clampIndex(position, text.length);
7281
+ }
7282
+ else {
7283
+ throw new Error("Text cursor positions must be numbers, \"start\" or \"end\"");
7284
+ }
7285
+ return automerge_adapter_1.Automerge.getCursor(source, path, target, options?.move);
7286
+ });
7287
+ return {
7288
+ path: [...path],
7289
+ heads: heads ?? automerge_adapter_1.Automerge.getHeads(automergeDoc),
7290
+ cursors,
7291
+ };
7292
+ }
7293
+ async resolveTextCursors(doc, path, cursors, options) {
7294
+ this.validateJsonPath(path, "Text cursor");
7295
+ if (!Array.isArray(cursors) ||
7296
+ cursors.some((cursor) => typeof cursor !== "string" || cursor === "")) {
7297
+ throw new Error("Text cursors must be non-empty strings");
7298
+ }
7299
+ const { automergeDoc, heads } = await this.resolveTextCursorTarget(doc, options);
7300
+ const source = this.textCursorSource(automergeDoc, heads);
7301
+ this.readTextForCursor(source, path);
7302
+ const positions = cursors.map((cursor) => automerge_adapter_1.Automerge.getCursorPosition(source, path, cursor));
7303
+ return {
7304
+ path: [...path],
7305
+ heads: heads ?? automerge_adapter_1.Automerge.getHeads(automergeDoc),
7306
+ positions,
7307
+ };
7308
+ }
7309
+ async resolveTextCursorTarget(doc, options) {
7310
+ const docId = doc.getId();
7311
+ let internalDoc = this.wrappedInternalDocs.get(doc) ?? null;
7312
+ if (!internalDoc) {
7313
+ internalDoc = this.getCachedDocument(docId);
7314
+ }
7315
+ if (!internalDoc) {
7316
+ const loadedDoc = await this.loadDocumentInternal(docId);
7317
+ if (!loadedDoc) {
7318
+ throw new errors_1.DocumentNotFoundError(docId);
7319
+ }
7320
+ internalDoc = loadedDoc;
7321
+ }
7322
+ if (internalDoc.isDeleted) {
7323
+ throw new errors_1.DocumentDeletedError(docId);
7324
+ }
7325
+ const heads = options?.heads && options.heads.length > 0
7326
+ ? options.heads
7327
+ : undefined;
7328
+ return { automergeDoc: internalDoc.doc, heads };
7329
+ }
7330
+ /** The document itself, or a read-only view of it at `heads`. */
7331
+ textCursorSource(automergeDoc, heads) {
7332
+ return heads ? automerge_adapter_1.Automerge.view(automergeDoc, heads) : automergeDoc;
7333
+ }
7334
+ /** Reads the text a cursor operates on, rejecting non-text fields early. */
7335
+ readTextForCursor(source, path) {
7336
+ const value = this.readValueAtPath(source, path);
7337
+ if (typeof value !== "string") {
7338
+ throw new Error(`Text cursors require an Automerge text field at '${path.map(String).join(".")}'`);
7339
+ }
7340
+ return value;
7341
+ }
7177
7342
  async exportAutomergeSnapshot(doc) {
7178
7343
  const internalDoc = await this.resolveReadableInternalDoc(doc);
7179
7344
  const binary = automerge_adapter_1.Automerge.save(internalDoc.doc);
@@ -7385,6 +7550,9 @@ class BaseMindooDB {
7385
7550
  const pendingAttachmentAppends = new Map();
7386
7551
  // Map of attachmentId -> extraction result for setAttachmentExtractedText
7387
7552
  const pendingAttachmentTextUpdates = new Map();
7553
+ // incrementCounter() calls, applied in call order after the field
7554
+ // assignments so they can target counters assigned in the same callback.
7555
+ const pendingCounterIncrements = [];
7388
7556
  // Reference to db for closures
7389
7557
  const db = this;
7390
7558
  // Guard flag to prevent operations after callback completes
@@ -7449,7 +7617,9 @@ class BaseMindooDB {
7449
7617
  if (typeof prop === "string" && pendingChanges.has(prop)) {
7450
7618
  return pendingChanges.get(prop);
7451
7619
  }
7452
- return target[prop];
7620
+ // Counters read as numbers and immutable strings as strings here
7621
+ // too; change counters with incrementCounter().
7622
+ return db.readAutomergeScalar(target[prop]);
7453
7623
  },
7454
7624
  has: (target, prop) => {
7455
7625
  // If marked for deletion, it doesn't exist
@@ -7577,6 +7747,14 @@ class BaseMindooDB {
7577
7747
  });
7578
7748
  }
7579
7749
  },
7750
+ // ========== Counter Write Methods ==========
7751
+ incrementCounter: (path, delta) => {
7752
+ throwIfCallbackInactive("incrementCounter");
7753
+ const segments = typeof path === "string" ? [path] : [...path];
7754
+ db.validateJsonPath(segments, "incrementCounter");
7755
+ (0, values_1.assertCounterAmount)(delta, "incrementCounter delta");
7756
+ pendingCounterIncrements.push({ path: segments, delta });
7757
+ },
7580
7758
  // ========== Attachment Read Methods (also work in changeDoc context) ==========
7581
7759
  getAttachments: () => {
7582
7760
  const payload = internalDoc.doc;
@@ -7612,12 +7790,15 @@ class BaseMindooDB {
7612
7790
  newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, (automergeDoc) => {
7613
7791
  // Apply all pending changes (sets/updates)
7614
7792
  for (const [key, value] of pendingChanges) {
7615
- automergeDoc[key] = value;
7793
+ automergeDoc[key] = this.hydrateTypedValues(value);
7616
7794
  }
7617
7795
  // Apply all pending deletions
7618
7796
  for (const key of pendingDeletions) {
7619
7797
  delete automergeDoc[key];
7620
7798
  }
7799
+ for (const { path, delta } of pendingCounterIncrements) {
7800
+ this.incrementCounterAtPath(automergeDoc, path, delta);
7801
+ }
7621
7802
  // Apply pending attachment changes
7622
7803
  if (pendingAttachmentAdditions.length > 0 ||
7623
7804
  pendingAttachmentRemovals.size > 0 ||
@@ -7741,12 +7922,14 @@ class BaseMindooDB {
7741
7922
  (patch.listInsert?.length ?? 0) +
7742
7923
  (patch.textSplice?.length ?? 0) +
7743
7924
  (patch.textMark?.length ?? 0) +
7744
- (patch.textUnmark?.length ?? 0);
7925
+ (patch.textUnmark?.length ?? 0) +
7926
+ (patch.counterIncrement?.length ?? 0);
7745
7927
  if (operationCount === 0) {
7746
7928
  throw new Error("JSON patch must include at least one operation");
7747
7929
  }
7748
7930
  for (const operation of patch.set ?? []) {
7749
7931
  this.validateJsonPath(operation.path, "JSON set");
7932
+ this.hydrateTypedValues(operation.value);
7750
7933
  }
7751
7934
  for (const operation of patch.unset ?? []) {
7752
7935
  this.validateJsonPath(operation.path, "JSON unset");
@@ -7769,6 +7952,7 @@ class BaseMindooDB {
7769
7952
  if (!Array.isArray(operation.values)) {
7770
7953
  throw new Error("JSON listInsert values must be an array");
7771
7954
  }
7955
+ this.hydrateTypedValues(operation.values);
7772
7956
  }
7773
7957
  for (const operation of patch.textSplice ?? []) {
7774
7958
  this.validateJsonPath(operation.path, "JSON textSplice");
@@ -7811,6 +7995,134 @@ class BaseMindooDB {
7811
7995
  throw new Error("JSON textUnmark names must be non-empty strings");
7812
7996
  }
7813
7997
  }
7998
+ for (const operation of patch.counterIncrement ?? []) {
7999
+ this.validateJsonPath(operation.path, "JSON counterIncrement");
8000
+ (0, values_1.assertCounterAmount)(operation.delta, "JSON counterIncrement delta");
8001
+ }
8002
+ }
8003
+ isAutomergeCounter(value) {
8004
+ if (value === null || typeof value !== "object")
8005
+ return false;
8006
+ const automerge = automerge_adapter_1.Automerge;
8007
+ if (typeof automerge.isCounter === "function") {
8008
+ return automerge.isCounter(value);
8009
+ }
8010
+ return (typeof automerge.Counter === "function" &&
8011
+ value instanceof automerge.Counter);
8012
+ }
8013
+ isAutomergeImmutableString(value) {
8014
+ if (value === null || typeof value !== "object")
8015
+ return false;
8016
+ const check = automerge_adapter_1.Automerge
8017
+ .isImmutableString;
8018
+ return typeof check === "function" && check(value);
8019
+ }
8020
+ /**
8021
+ * Plain-JS read view of an Automerge scalar wrapper: counters become numbers
8022
+ * and immutable strings become strings. Everything else is returned as is.
8023
+ */
8024
+ readAutomergeScalar(value) {
8025
+ if (this.isAutomergeCounter(value))
8026
+ return value.value;
8027
+ if (this.isAutomergeImmutableString(value))
8028
+ return String(value);
8029
+ return value;
8030
+ }
8031
+ /**
8032
+ * Turns `MindooValue` typed values (tagged plain objects, see values.ts)
8033
+ * anywhere inside `value` into their CRDT representation, and copies plain
8034
+ * objects and arrays into fresh literals on the way. Values that already are
8035
+ * CRDT scalars, dates or bytes pass through. Throws for malformed tags, so
8036
+ * it doubles as validation.
8037
+ */
8038
+ hydrateTypedValues(value, depth = 0) {
8039
+ if (value === null || typeof value !== "object")
8040
+ return value;
8041
+ if (depth > 100) {
8042
+ throw new Error("Document value is nested too deeply");
8043
+ }
8044
+ if (value instanceof Uint8Array ||
8045
+ value instanceof Date ||
8046
+ this.isAutomergeCounter(value) ||
8047
+ this.isAutomergeImmutableString(value)) {
8048
+ return value;
8049
+ }
8050
+ if (Array.isArray(value)) {
8051
+ return value.map((entry) => this.hydrateTypedValues(entry, depth + 1));
8052
+ }
8053
+ const record = value;
8054
+ if (Object.prototype.hasOwnProperty.call(record, values_1.MINDOO_VALUE_TAG)) {
8055
+ const typed = (0, values_1.parseTypedValue)(record);
8056
+ switch (typed.$mindoo) {
8057
+ case "atomic":
8058
+ return new automerge_adapter_1.Automerge.ImmutableString(typed.value);
8059
+ case "counter":
8060
+ return new automerge_adapter_1.Automerge.Counter(typed.value);
8061
+ case "timestamp":
8062
+ return (0, values_1.toTimestampDate)(typed.value, 'Typed value "timestamp"');
8063
+ }
8064
+ }
8065
+ const result = {};
8066
+ for (const [key, entry] of Object.entries(record)) {
8067
+ if (entry !== undefined) {
8068
+ result[key] = this.hydrateTypedValues(entry, depth + 1);
8069
+ }
8070
+ }
8071
+ return result;
8072
+ }
8073
+ /**
8074
+ * The plain read view of a value that may contain `MindooValue` tags, as
8075
+ * `getData()` would return it once written (used where a precheck needs the
8076
+ * "after" state before the value is in a document).
8077
+ */
8078
+ readTypedValues(value) {
8079
+ return this.normalizeAutomergeScalars(this.hydrateTypedValues(value));
8080
+ }
8081
+ /**
8082
+ * Replaces counters and atomic strings inside a materialized value with
8083
+ * plain numbers and strings, in place where possible. Reads (getData, the
8084
+ * query and summary indexes, write prechecks) then compare them like any
8085
+ * other JSON value.
8086
+ */
8087
+ normalizeAutomergeScalars(value, depth = 0) {
8088
+ if (value === null || typeof value !== "object")
8089
+ return value;
8090
+ if (this.isAutomergeCounter(value) || this.isAutomergeImmutableString(value)) {
8091
+ return this.readAutomergeScalar(value);
8092
+ }
8093
+ if (value instanceof Uint8Array || value instanceof Date || depth > 100) {
8094
+ return value;
8095
+ }
8096
+ if (Array.isArray(value)) {
8097
+ for (let index = 0; index < value.length; index += 1) {
8098
+ value[index] = this.normalizeAutomergeScalars(value[index], depth + 1);
8099
+ }
8100
+ return value;
8101
+ }
8102
+ const record = value;
8103
+ for (const key of Object.keys(record)) {
8104
+ record[key] = this.normalizeAutomergeScalars(record[key], depth + 1);
8105
+ }
8106
+ return record;
8107
+ }
8108
+ /**
8109
+ * Must run inside an Automerge change callback. Initializes a missing field
8110
+ * as a counter holding `delta`; rejects existing non-counter values rather
8111
+ * than silently converting them (a plain number would lose its history and
8112
+ * any concurrent plain-number write would still win).
8113
+ */
8114
+ incrementCounterAtPath(automergeDoc, path, delta) {
8115
+ const parent = this.ensureJsonParentAtPath(automergeDoc, path);
8116
+ const leaf = path[path.length - 1];
8117
+ const existing = parent[leaf];
8118
+ if (existing === undefined || existing === null) {
8119
+ parent[leaf] = new automerge_adapter_1.Automerge.Counter(delta);
8120
+ return;
8121
+ }
8122
+ if (!this.isAutomergeCounter(existing)) {
8123
+ throw new Error(`Cannot increment non-counter value at '${path.map(String).join(".")}'; create it with MindooValue.counter() first`);
8124
+ }
8125
+ existing.increment(delta);
7814
8126
  }
7815
8127
  validateRichTextPatch(patch) {
7816
8128
  this.validateJsonPath(patch.path, "Rich-text patch");
@@ -7975,7 +8287,7 @@ class BaseMindooDB {
7975
8287
  }
7976
8288
  applyJsonPatchOperations(automergeDoc, patch) {
7977
8289
  for (const operation of patch.set ?? []) {
7978
- this.setJsonValueAtPath(automergeDoc, operation.path, structuredClone(operation.value));
8290
+ this.setJsonValueAtPath(automergeDoc, operation.path, this.hydrateTypedValues(structuredClone(operation.value)));
7979
8291
  }
7980
8292
  for (const operation of patch.unset ?? []) {
7981
8293
  this.unsetJsonValueAtPath(automergeDoc, operation.path);
@@ -7986,7 +8298,7 @@ class BaseMindooDB {
7986
8298
  }
7987
8299
  for (const operation of patch.listInsert ?? []) {
7988
8300
  const list = this.ensureJsonListAtPath(automergeDoc, operation.path);
7989
- list.splice(operation.index, 0, ...structuredClone(operation.values));
8301
+ list.splice(operation.index, 0, ...this.hydrateTypedValues(structuredClone(operation.values)));
7990
8302
  }
7991
8303
  for (const operation of patch.textSplice ?? []) {
7992
8304
  this.spliceJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.deleteCount, operation.insert ?? "");
@@ -8005,6 +8317,9 @@ class BaseMindooDB {
8005
8317
  this.unmarkJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.length, name);
8006
8318
  }
8007
8319
  }
8320
+ for (const operation of patch.counterIncrement ?? []) {
8321
+ this.incrementCounterAtPath(automergeDoc, operation.path, operation.delta);
8322
+ }
8008
8323
  }
8009
8324
  spliceJsonTextAtPath(automergeDoc, path, index, deleteCount, insert) {
8010
8325
  const value = this.readValueAtPath(automergeDoc, path);
@@ -8183,6 +8498,38 @@ class BaseMindooDB {
8183
8498
  return apply(internalDoc.doc);
8184
8499
  }
8185
8500
  }
8501
+ /**
8502
+ * The document as it stood at `heads`, for the "before" side of a Tier 2
8503
+ * (`withfields`) write precheck.
8504
+ *
8505
+ * `internalDoc.doc` is normally the pre-change document (it is reassigned to
8506
+ * the new document after the store write), but it is shared mutable state and
8507
+ * is not guaranteed to still sit on the heads a change was computed from: an
8508
+ * out-of-band Automerge operation, the clone recovery in
8509
+ * {@link runChangeWithOutdatedDocRecovery}, or a concurrent op on the same
8510
+ * cached document can move it. A "before" that is really the "after" silently
8511
+ * flips every `when: "before"` verdict, so the caller passes the change's own
8512
+ * dependency hashes, which are fixed at the moment the change was produced,
8513
+ * and only falls back to the cache slot when it has none.
8514
+ *
8515
+ * `Automerge.clone` is load-bearing: a bare `Automerge.view` shares the source
8516
+ * document's WASM handle, and {@link convertAutomergeToJS} materializes through
8517
+ * that handle at ITS heads, not the view's — so materializing a view returns
8518
+ * the post-change state. Cloning gives the snapshot its own handle.
8519
+ */
8520
+ docAtHeads(internalDoc, doc, heads) {
8521
+ if (!heads || heads.length === 0)
8522
+ return internalDoc.doc;
8523
+ try {
8524
+ return automerge_adapter_1.Automerge.clone(automerge_adapter_1.Automerge.view(doc, heads));
8525
+ }
8526
+ catch (error) {
8527
+ // A snapshot we cannot build must not fail the write outright; the cache
8528
+ // slot is the same value this code used before the snapshot existed.
8529
+ this.logger.warn(`Could not snapshot document ${internalDoc.id} at its pre-change heads, using the cached state: ${error}`);
8530
+ return internalDoc.doc;
8531
+ }
8532
+ }
8186
8533
  isOutdatedDocumentError(error) {
8187
8534
  if (!(error instanceof Error))
8188
8535
  return false;
@@ -8261,6 +8608,9 @@ class BaseMindooDB {
8261
8608
  // text patches, merges, ...) carries it consistently on each produced entry.
8262
8609
  const attachmentRefs = this.collectAttachmentRefs(newDoc);
8263
8610
  const entries = [];
8611
+ // The dependencies of the first change ARE the heads the change was computed
8612
+ // from, i.e. the document's "before" state for the write precheck below.
8613
+ let headsBeforeFirstChange;
8264
8614
  for (let index = 0; index < resolvedChangeBytesList.length; index += 1) {
8265
8615
  const changeBytes = resolvedChangeBytesList[index];
8266
8616
  const entry = await this.buildDocChangeStoreEntry({
@@ -8278,23 +8628,23 @@ class BaseMindooDB {
8278
8628
  });
8279
8629
  entries.push(entry);
8280
8630
  const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
8631
+ if (index === 0) {
8632
+ headsBeforeFirstChange = decodedChange.deps;
8633
+ }
8281
8634
  this.registerAutomergeHashMapping(docId, decodedChange.hash, entry.id);
8282
8635
  }
8283
- // Client-side write-policy precheck (docs/accesscontrol.md §9). `internalDoc.doc`
8284
- // is still the pre-change state here (it is reassigned to `newDoc` below,
8285
- // after the store write), so before/after are both available for Tier 2
8286
- // (`withfields`) evaluation. Throws AccessDeniedError when denied.
8636
+ // Client-side write-policy precheck (docs/accesscontrol.md §9). Throws
8637
+ // AccessDeniedError when denied.
8287
8638
  {
8288
8639
  const signerKey = useCustomKey
8289
8640
  ? signingKeyPair.publicKey
8290
8641
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
8291
- const beforeState = internalDoc.doc;
8292
8642
  await this.assertWriteAllowed({
8293
8643
  op: "doc_change",
8294
8644
  signerKey,
8295
8645
  isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
8296
8646
  bypass: bypassPrecheck,
8297
- getBeforeDoc: () => this.convertAutomergeToJS(beforeState),
8647
+ getBeforeDoc: () => this.convertAutomergeToJS(this.docAtHeads(internalDoc, newDoc, headsBeforeFirstChange)),
8298
8648
  getAfterDoc: () => this.convertAutomergeToJS(newDoc),
8299
8649
  });
8300
8650
  }
@@ -9474,8 +9824,12 @@ class BaseMindooDB {
9474
9824
  return !persistedHashes.has(parsed.automergeHash);
9475
9825
  });
9476
9826
  if (missingMetadata.length === 0) {
9477
- // No real deltas (changeSeq pointer drift only). Promote and return.
9827
+ // No real deltas (changeSeq pointer drift only). Promote and return,
9828
+ // marked dirty so the record is rewritten under the current changeSeq:
9829
+ // otherwise every later read (and every warmer pass) would repeat
9830
+ // this metadata scan + hash comparison for a record that is current.
9478
9831
  await this.storeCachedDocument(internal);
9832
+ this.markDocDirty(docId);
9479
9833
  return internal;
9480
9834
  }
9481
9835
  let updated;
@@ -9490,8 +9844,9 @@ class BaseMindooDB {
9490
9844
  // applyNewEntriesToCachedDocument returns null when no signed entries
9491
9845
  // produced a head change. Treat the persisted doc as authoritative
9492
9846
  // and put it in L1 ourselves (the helper only caches when it
9493
- // actually applied work).
9847
+ // actually applied work). Dirty for the same reason as above.
9494
9848
  await this.storeCachedDocument(internal);
9849
+ this.markDocDirty(docId);
9495
9850
  return internal;
9496
9851
  }
9497
9852
  return updated;
@@ -9511,9 +9866,13 @@ class BaseMindooDB {
9511
9866
  /**
9512
9867
  * Start the L2 background warmer.
9513
9868
  *
9514
- * Walks a snapshot of the in-memory document index and, for every
9515
- * doc not already in L1, calls {@link tryLoadFromL2}. That path
9516
- * either:
9869
+ * First waits until the changefeed consumers (summary buffer, an
9870
+ * enabled full-text index) have caught up: they materialize every
9871
+ * changed document anyway, so running next to them would do the same
9872
+ * work twice. Then walks a snapshot of the in-memory document index and
9873
+ * skips every doc that is in L1 or whose L2 record is already at the
9874
+ * current changeSeq (checked from the record header, no deserialization).
9875
+ * For the rest it calls {@link tryLoadFromL2}. That path either:
9517
9876
  *
9518
9877
  * - hits L2 fresh and just promotes the doc to L1, or
9519
9878
  * - hits L2 stale and applies missing deltas (re-flushed via the
@@ -9614,16 +9973,13 @@ class BaseMindooDB {
9614
9973
  this.logger.debug("Warmer skipped: no cacheManager attached, no L2 to warm.");
9615
9974
  return;
9616
9975
  }
9617
- // Snapshot the docId list so concurrent index updates don't shift
9618
- // our iteration mid-pass. Iterating the index directly would also
9619
- // work but a snapshot makes the loop's behavior easier to reason
9620
- // about under sync activity.
9621
- const docIds = this.index.map((entry) => entry.docId);
9622
- const total = docIds.length;
9623
- // Progress snapshot is replaced (not mutated in place) so polled
9624
- // readers see consistent values. We initialize even when total=0
9625
- // so the UI can display a "done" terminal state.
9626
- this.warmerProgress = { processed: 0, total, phase: "warming" };
9976
+ // Changefeed consumers (summary buffer, full-text index) materialize
9977
+ // every changed document anyway, and those loads persist to L2 through
9978
+ // flush-before-evict / the periodic flush. Running the warmer next to
9979
+ // them (it is typically started right after a sync, while their
9980
+ // catch-up is still going) materialized the same documents twice. Let
9981
+ // them finish first; the pass below then only has to pick up what they
9982
+ // did not cover.
9627
9983
  const emitProgress = (progress) => {
9628
9984
  this.warmerProgress = progress;
9629
9985
  if (!onProgress)
@@ -9636,6 +9992,26 @@ class BaseMindooDB {
9636
9992
  this.logger.warn(`Warmer onProgress callback threw: ${e}`);
9637
9993
  }
9638
9994
  };
9995
+ this.warmerProgress = {
9996
+ processed: 0,
9997
+ total: this.index.length,
9998
+ phase: "warming",
9999
+ };
10000
+ await this.awaitChangefeedConsumersForWarmer(signal);
10001
+ if (signal.aborted) {
10002
+ emitProgress({ processed: 0, total: this.index.length, phase: "cancelled" });
10003
+ return;
10004
+ }
10005
+ // Snapshot the docId list so concurrent index updates don't shift
10006
+ // our iteration mid-pass. Iterating the index directly would also
10007
+ // work but a snapshot makes the loop's behavior easier to reason
10008
+ // about under sync activity.
10009
+ const docIds = this.index.map((entry) => entry.docId);
10010
+ const total = docIds.length;
10011
+ // Progress snapshot is replaced (not mutated in place) so polled
10012
+ // readers see consistent values. We initialize even when total=0
10013
+ // so the UI can display a "done" terminal state.
10014
+ this.warmerProgress = { processed: 0, total, phase: "warming" };
9639
10015
  if (total === 0) {
9640
10016
  this.logger.debug("Warmer: nothing to warm, index is empty.");
9641
10017
  emitProgress({ processed: 0, total: 0, phase: "done" });
@@ -9645,6 +10021,7 @@ class BaseMindooDB {
9645
10021
  let processed = 0;
9646
10022
  let warmed = 0;
9647
10023
  let skippedAlreadyHot = 0;
10024
+ let skippedFreshInL2 = 0;
9648
10025
  let errored = 0;
9649
10026
  try {
9650
10027
  for (const docId of docIds) {
@@ -9657,6 +10034,11 @@ class BaseMindooDB {
9657
10034
  if (this.docCache.has(docId)) {
9658
10035
  skippedAlreadyHot++;
9659
10036
  }
10037
+ else if (await this.isL2RecordCurrent(docId)) {
10038
+ // Nothing to do: loading it would only deserialize the doc into
10039
+ // L1 and evict it again (L1 is far smaller than a large DB).
10040
+ skippedFreshInL2++;
10041
+ }
9660
10042
  else {
9661
10043
  try {
9662
10044
  const fromL2 = await this.tryLoadFromL2(docId);
@@ -9682,7 +10064,7 @@ class BaseMindooDB {
9682
10064
  }
9683
10065
  }
9684
10066
  this.logger.info(`Warmer finished in ${Date.now() - startedAt}ms: processed=${processed}, ` +
9685
- `warmed=${warmed}, alreadyHot=${skippedAlreadyHot}, errored=${errored}`);
10067
+ `warmed=${warmed}, alreadyHot=${skippedAlreadyHot}, freshInL2=${skippedFreshInL2}, errored=${errored}`);
9686
10068
  emitProgress({ processed, total, phase: "done" });
9687
10069
  }
9688
10070
  catch (e) {
@@ -9693,6 +10075,82 @@ class BaseMindooDB {
9693
10075
  emitProgress({ processed, total, phase: "cancelled" });
9694
10076
  }
9695
10077
  }
10078
+ /**
10079
+ * Wait until the summary buffer and an enabled full-text index have
10080
+ * consumed the changefeed (joining a running auto-follow run or starting
10081
+ * a catch-up). Failures are logged: the warmer then simply does the
10082
+ * remaining work itself.
10083
+ */
10084
+ async awaitChangefeedConsumersForWarmer(signal) {
10085
+ const startedAt = Date.now();
10086
+ const catchUp = (async () => {
10087
+ await this.summaryStore?.update({ signal });
10088
+ if (!signal.aborted && this.fulltextIndex?.isEnabled()) {
10089
+ await this.fulltextIndex.update({ signal });
10090
+ }
10091
+ })();
10092
+ // Joining an already running auto-follow run does not honor our signal,
10093
+ // so a stop must not wait for it: the run just continues on its own.
10094
+ let onAbort = () => { };
10095
+ const aborted = new Promise((resolve) => {
10096
+ onAbort = () => resolve();
10097
+ if (signal.aborted)
10098
+ resolve();
10099
+ else
10100
+ signal.addEventListener("abort", onAbort, { once: true });
10101
+ });
10102
+ try {
10103
+ await Promise.race([catchUp, aborted]);
10104
+ }
10105
+ catch (e) {
10106
+ this.logger.warn(`Warmer: changefeed consumer catch-up failed: ${e}`);
10107
+ }
10108
+ finally {
10109
+ signal.removeEventListener("abort", onAbort);
10110
+ }
10111
+ catchUp.catch(() => { });
10112
+ this.logger.debug(`Warmer: changefeed consumers caught up in ${Date.now() - startedAt}ms`);
10113
+ }
10114
+ /**
10115
+ * Whether L2 already holds this document at its current changeSeq, so
10116
+ * the warmer can skip it without deserializing. Answered from
10117
+ * {@link lastFlushedDocState} for records written in this session,
10118
+ * otherwise from the record header (no Automerge load).
10119
+ */
10120
+ async isL2RecordCurrent(docId) {
10121
+ const indexEntryIdx = this.getDocIndexPosition(docId);
10122
+ const store = this.cacheManager?.getStore() ?? null;
10123
+ if (indexEntryIdx === undefined || !store) {
10124
+ return false;
10125
+ }
10126
+ const entry = this.index[indexEntryIdx];
10127
+ if (entry.accessState !== "visible") {
10128
+ return false;
10129
+ }
10130
+ const currentChangeSeq = entry.changeSeq;
10131
+ const flushed = this.lastFlushedDocState.get(docId);
10132
+ if (flushed !== undefined) {
10133
+ return flushed.startsWith(`${currentChangeSeq}:`);
10134
+ }
10135
+ try {
10136
+ const bytes = await store.get("doc", `${this.getCachePrefix()}/${docId}`);
10137
+ if (!bytes) {
10138
+ return false;
10139
+ }
10140
+ const { header } = this.parseDocCacheRecord(bytes);
10141
+ if (header.version !== DOC_CACHE_HEADER_VERSION ||
10142
+ header.changeSeq !== currentChangeSeq ||
10143
+ !Array.isArray(header.automergeHeads)) {
10144
+ return false;
10145
+ }
10146
+ this.lastFlushedDocState.set(docId, `${currentChangeSeq}:${header.automergeHeads.join(",")}`);
10147
+ return true;
10148
+ }
10149
+ catch {
10150
+ // Unreadable record: let the regular L2 path evict/rebuild it.
10151
+ return false;
10152
+ }
10153
+ }
9696
10154
  /**
9697
10155
  * The per-tenant quarantine/audit log: entries rejected during
9698
10156
  * materialization by the access-control layer (docs/accesscontrol.md §10).
@@ -9700,7 +10158,11 @@ class BaseMindooDB {
9700
10158
  * store but are excluded from materialized state and queries.
9701
10159
  */
9702
10160
  getQuarantineLog() {
9703
- return this.quarantineLog;
10161
+ const flat = [];
10162
+ for (const records of this.quarantineLog.values()) {
10163
+ flat.push(...records);
10164
+ }
10165
+ return flat;
9704
10166
  }
9705
10167
  /**
9706
10168
  * Whether access control is active for this tenant and should gate
@@ -9900,8 +10362,9 @@ class BaseMindooDB {
9900
10362
  const afterValues = { _attachments: [] };
9901
10363
  if (options.initialValues) {
9902
10364
  for (const [k, v] of Object.entries(options.initialValues)) {
9903
- if (!k.startsWith("_"))
9904
- afterValues[k] = v;
10365
+ if (!k.startsWith("_") && v !== undefined) {
10366
+ afterValues[k] = this.readTypedValues(v);
10367
+ }
9905
10368
  }
9906
10369
  }
9907
10370
  const decision = await this.evaluateClientWriteAccess({
@@ -10224,10 +10687,52 @@ class BaseMindooDB {
10224
10687
  const info = await this.resolveCreatorInfo(docId);
10225
10688
  return info?.signingKey ?? null;
10226
10689
  }
10227
- /** Append a record to the quarantine/audit log (and log it). */
10228
- recordQuarantine(rec) {
10229
- this.quarantineLog.push(rec);
10230
- console.log(`[MindooDB][ACL] Quarantined entry ${rec.entryId} (doc ${rec.docId}): ${rec.reason} — ${rec.detail}`);
10690
+ /**
10691
+ * Replace one document's quarantine records with the verdict of the pass that
10692
+ * just ended.
10693
+ *
10694
+ * Whole-group replacement rather than appending, because a pass re-derives the
10695
+ * complete verdict for the document: appending would duplicate every record
10696
+ * on every re-materialization, and would never drop the record of an entry
10697
+ * that has since become acceptable.
10698
+ *
10699
+ * Writes nothing when the verdict is unchanged. That is not just thrift — the
10700
+ * checkpoint is rewritten whenever this marks it dirty, and a document is
10701
+ * re-materialized often, so marking dirty on an unchanged verdict would turn
10702
+ * every read of a quarantined document into a checkpoint write.
10703
+ */
10704
+ commitQuarantineForDoc(docId, records) {
10705
+ const previous = this.quarantineLog.get(docId);
10706
+ if (records.length === 0) {
10707
+ if (previous) {
10708
+ this.quarantineLog.delete(docId);
10709
+ this.cacheMetaDirty = true;
10710
+ }
10711
+ return;
10712
+ }
10713
+ const kept = records.length > MAX_QUARANTINE_RECORDS_PER_DOC
10714
+ ? records.slice(0, MAX_QUARANTINE_RECORDS_PER_DOC)
10715
+ : records;
10716
+ // Compare the verdict, not the record objects: `recordedAt` is a wall-clock
10717
+ // stamp that differs on every pass and would make every verdict look new.
10718
+ const verdictOf = (list) => list.map((rec) => `${rec.entryId}\u0000${rec.reason}`).join("\u0001");
10719
+ if (previous && verdictOf(previous) === verdictOf(kept)) {
10720
+ return;
10721
+ }
10722
+ this.quarantineLog.set(docId, kept);
10723
+ if (!previous) {
10724
+ while (this.quarantineLog.size > MAX_QUARANTINE_DOCS) {
10725
+ // Map iteration is insertion-ordered, so this is the oldest document.
10726
+ const oldest = this.quarantineLog.keys().next();
10727
+ if (oldest.done || oldest.value === docId)
10728
+ break;
10729
+ this.quarantineLog.delete(oldest.value);
10730
+ }
10731
+ }
10732
+ this.cacheMetaDirty = true;
10733
+ for (const rec of kept) {
10734
+ console.log(`[MindooDB][ACL] Quarantined entry ${rec.entryId} (doc ${rec.docId}): ${rec.reason} — ${rec.detail}`);
10735
+ }
10231
10736
  }
10232
10737
  /**
10233
10738
  * Materialize a document through the access-control (Tier 2) path
@@ -10284,8 +10789,12 @@ class BaseMindooDB {
10284
10789
  .sort((a, b) => (a.receivedAt ?? a.createdAt) === (b.receivedAt ?? b.createdAt)
10285
10790
  ? a.id.localeCompare(b.id)
10286
10791
  : (a.receivedAt ?? a.createdAt) - (b.receivedAt ?? b.createdAt));
10287
- if (dataEntries.length === 0)
10792
+ if (dataEntries.length === 0) {
10793
+ // Nothing left to judge (e.g. the history was purged), so no verdict can
10794
+ // stand either.
10795
+ this.commitQuarantineForDoc(docId, []);
10288
10796
  return { doc: null, cacheable };
10797
+ }
10289
10798
  const createMeta = dataEntries.find((m) => m.entryType === "doc_create");
10290
10799
  const creatorKey = createMeta?.createdByPublicKey ?? null;
10291
10800
  const creatorTrustedTime = createMeta
@@ -10299,9 +10808,13 @@ class BaseMindooDB {
10299
10808
  let decryptionKeyId = "default";
10300
10809
  const acceptedMeta = [];
10301
10810
  const quarantined = new Set();
10811
+ // Collected for the whole pass and committed once at the end, so the log
10812
+ // holds this document's current verdict rather than the union of every
10813
+ // verdict ever reached for it. See commitQuarantineForDoc.
10814
+ const quarantineRecords = [];
10302
10815
  const quarantine = (meta, reason, detail) => {
10303
10816
  quarantined.add(meta.id);
10304
- this.recordQuarantine({
10817
+ quarantineRecords.push({
10305
10818
  entryId: meta.id,
10306
10819
  docId,
10307
10820
  dbid,
@@ -10458,6 +10971,9 @@ class BaseMindooDB {
10458
10971
  this.registerAutomergeHashMapping(docId, parsed.automergeHash, entry.id);
10459
10972
  }
10460
10973
  }
10974
+ // The pass is over and `quarantineRecords` is this document's full verdict,
10975
+ // including the empty verdict that retires an earlier one.
10976
+ this.commitQuarantineForDoc(docId, quarantineRecords);
10461
10977
  if (currentDoc === null) {
10462
10978
  // The create was quarantined or absent: the document is logically absent.
10463
10979
  return { doc: null, cacheable };
@@ -10485,6 +11001,22 @@ class BaseMindooDB {
10485
11001
  /**
10486
11002
  * Internal method to load a document from the content-addressed store
10487
11003
  */
11004
+ /** Whether no new entries of `docId` were indexed since `generation` was read. */
11005
+ isEntryGenerationCurrent(docId, generation) {
11006
+ return (this.docEntryGeneration.get(docId) ?? 0) === generation;
11007
+ }
11008
+ /**
11009
+ * The L2 path caches inside {@link tryLoadFromL2}; when entries were
11010
+ * indexed while it ran, take the (outdated) result back out of L1.
11011
+ */
11012
+ dropCachedLoadIfSuperseded(docId, generation, loaded) {
11013
+ if (this.isEntryGenerationCurrent(docId, generation) ||
11014
+ this.docCache.get(docId) !== loaded) {
11015
+ return;
11016
+ }
11017
+ this.docCache.delete(docId);
11018
+ this.dirtyDocIds.delete(docId);
11019
+ }
10488
11020
  async loadDocumentInternal(docId) {
10489
11021
  const startedAt = Date.now();
10490
11022
  const cacheCheckStartedAt = Date.now();
@@ -10528,8 +11060,10 @@ class BaseMindooDB {
10528
11060
  // the full signature-verify + decrypt + Automerge-replay pipeline.
10529
11061
  // The L2 path either returns a ready-to-use InternalDoc (already
10530
11062
  // promoted into L1) or null - in which case we proceed below.
11063
+ const entryGeneration = this.docEntryGeneration.get(docId) ?? 0;
10531
11064
  const l2Doc = await this.tryLoadFromL2(docId);
10532
11065
  if (l2Doc !== null) {
11066
+ this.dropCachedLoadIfSuperseded(docId, entryGeneration, l2Doc);
10533
11067
  this.performanceCallback?.onDocumentLoad?.({
10534
11068
  docId,
10535
11069
  cacheHit: true,
@@ -10613,7 +11147,9 @@ class BaseMindooDB {
10613
11147
  const aclDoc = aclResult.doc;
10614
11148
  // Only cache a fully-validated result. A transient validation failure
10615
11149
  // (audit finding #2) leaves `cacheable` false so the next load retries.
10616
- if (aclDoc && aclResult.cacheable) {
11150
+ if (aclDoc &&
11151
+ aclResult.cacheable &&
11152
+ this.isEntryGenerationCurrent(docId, entryGeneration)) {
10617
11153
  await this.storeCachedDocument(aclDoc);
10618
11154
  this.markDocDirty(docId);
10619
11155
  }
@@ -10886,10 +11422,16 @@ class BaseMindooDB {
10886
11422
  witnessed: witnessState.witnessed,
10887
11423
  sealedRecipients: (0, recipients_1.newestRecipientBlock)(allEntryMetadata),
10888
11424
  };
10889
- // Update cache
10890
- await this.storeCachedDocument(internalDoc);
10891
- this.markDocDirty(docId);
10892
- this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
11425
+ // Update cache (unless newer entries were indexed meanwhile: then this
11426
+ // result is already outdated and the next read materializes again)
11427
+ if (this.isEntryGenerationCurrent(docId, entryGeneration)) {
11428
+ await this.storeCachedDocument(internalDoc);
11429
+ this.markDocDirty(docId);
11430
+ this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
11431
+ }
11432
+ else {
11433
+ this.logger.debug(`===== Loaded document ${docId}; not cached, newer entries were indexed during the load =====`);
11434
+ }
10893
11435
  this.performanceCallback?.onDocumentLoad?.({
10894
11436
  docId,
10895
11437
  cacheHit: false,
@@ -10920,8 +11462,10 @@ class BaseMindooDB {
10920
11462
  if (meta && meta.handle && typeof meta.handle.materialize === "function") {
10921
11463
  // Use native materialize() which properly converts Text objects to strings
10922
11464
  try {
11465
+ // materialize() returns fresh objects, so the scalar wrappers can be
11466
+ // replaced in place.
10923
11467
  const materialized = meta.handle.materialize("/");
10924
- return materialized;
11468
+ return this.normalizeAutomergeScalars(materialized);
10925
11469
  }
10926
11470
  catch (error) {
10927
11471
  console.error("[MindooDB] Failed to materialize document:", error);
@@ -10942,6 +11486,11 @@ class BaseMindooDB {
10942
11486
  return value;
10943
11487
  if (value instanceof Uint8Array)
10944
11488
  return value;
11489
+ if (value instanceof Date)
11490
+ return new Date(value.getTime());
11491
+ if (this.isAutomergeCounter(value) || this.isAutomergeImmutableString(value)) {
11492
+ return this.readAutomergeScalar(value);
11493
+ }
10945
11494
  if (depth > 40)
10946
11495
  return null;
10947
11496
  const automerge = automerge_adapter_1.Automerge;
@@ -10987,6 +11536,18 @@ class BaseMindooDB {
10987
11536
  },
10988
11537
  get: (target, prop) => {
10989
11538
  const value = target[prop];
11539
+ // Counters read as plain numbers and immutable strings as strings;
11540
+ // their wrappers would otherwise surface as `{ value }` / `{ val }`
11541
+ // after structured cloning.
11542
+ if (db.isAutomergeCounter(value) ||
11543
+ db.isAutomergeImmutableString(value)) {
11544
+ return db.readAutomergeScalar(value);
11545
+ }
11546
+ // Hand out a copy: a Proxy around a Date breaks every Date method
11547
+ // ("this is not a Date object").
11548
+ if (value instanceof Date) {
11549
+ return new Date(value.getTime());
11550
+ }
10990
11551
  // Recursively wrap nested objects (but not arrays or special types)
10991
11552
  if (value !== null &&
10992
11553
  typeof value === "object" &&
@@ -11044,6 +11605,9 @@ class BaseMindooDB {
11044
11605
  setAttachmentExtractedText: () => {
11045
11606
  throw new Error("setAttachmentExtractedText() can only be called within changeDoc() callback");
11046
11607
  },
11608
+ incrementCounter: () => {
11609
+ throw new Error("incrementCounter() can only be called within changeDoc() callback; use applyJsonPatch() with counterIncrement outside of it");
11610
+ },
11047
11611
  // ========== Attachment Read Methods ==========
11048
11612
  // These work in the read-only wrapper
11049
11613
  getAttachments: () => {
@@ -11851,7 +12415,14 @@ class BaseMindooDB {
11851
12415
  this.logger.info(`Pulling entries from remote store ${remoteStore.getId()}/${remoteStore.getStoreKind()}`);
11852
12416
  const restoreAuthOverride = await this.applyNetworkAuthOverrideForSync(remoteStore, options);
11853
12417
  try {
11854
- const syncResult = await this.syncEntriesFromStore(remoteStore, localStore, options);
12418
+ // Docs pulls process each transferred scan page right away: the local
12419
+ // index advances chunk by chunk, and the change event it emits lets the
12420
+ // summary buffer / full-text index materialize and extract documents
12421
+ // while the next page is still on the wire, instead of all at once
12422
+ // after the transfer (see docs/adhoc-queries.md, "Cold start").
12423
+ const syncResult = await this.syncEntriesFromStore(remoteStore, localStore, options, storeKind === types_2.StoreKind.docs
12424
+ ? () => this.syncStoreChanges()
12425
+ : undefined);
11855
12426
  const transferredBytes = syncResult.transferredBytes ?? 0;
11856
12427
  this.logger.debug(`Transferred ${syncResult.transferred} entries from remote store`);
11857
12428
  if (syncResult.cancelled) {