@harperfast/harper 5.3.0-beta.1 → 5.3.0-beta.2

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 (126) hide show
  1. package/components/DESIGN.md +421 -0
  2. package/components/mcp/DESIGN.md +109 -0
  3. package/components/mcp/audit.ts +21 -17
  4. package/config/DESIGN.md +306 -0
  5. package/dataLayer/DESIGN.md +179 -0
  6. package/dataLayer/restoreMarker.ts +92 -25
  7. package/dist/components/mcp/audit.d.ts +2 -1
  8. package/dist/components/mcp/audit.js +21 -17
  9. package/dist/components/mcp/audit.js.map +1 -1
  10. package/dist/dataLayer/restoreMarker.d.ts +21 -8
  11. package/dist/dataLayer/restoreMarker.js +94 -27
  12. package/dist/dataLayer/restoreMarker.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +6 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/resources/RecordEncoder.js +25 -5
  17. package/dist/resources/RecordEncoder.js.map +1 -1
  18. package/dist/resources/Table.js +48 -2
  19. package/dist/resources/Table.js.map +1 -1
  20. package/dist/resources/analytics/write.d.ts +3 -0
  21. package/dist/resources/analytics/write.js +39 -13
  22. package/dist/resources/analytics/write.js.map +1 -1
  23. package/dist/resources/crdt.d.ts +10 -0
  24. package/dist/resources/crdt.js +22 -0
  25. package/dist/resources/crdt.js.map +1 -1
  26. package/dist/resources/databases.js +1 -1
  27. package/dist/resources/databases.js.map +1 -1
  28. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -2
  29. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +43 -24
  30. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  31. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
  32. package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
  33. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
  34. package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
  35. package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
  36. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
  37. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
  38. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
  39. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
  40. package/dist/resources/recordLockCoordinator.d.ts +5 -5
  41. package/dist/resources/recordLockCoordinator.js +48 -16
  42. package/dist/resources/recordLockCoordinator.js.map +1 -1
  43. package/dist/resources/transactionBroadcast.js +4 -6
  44. package/dist/resources/transactionBroadcast.js.map +1 -1
  45. package/dist/security/auth.js +59 -23
  46. package/dist/security/auth.js.map +1 -1
  47. package/dist/security/deferredAuthentication.d.ts +11 -0
  48. package/dist/security/deferredAuthentication.js +25 -3
  49. package/dist/security/deferredAuthentication.js.map +1 -1
  50. package/dist/security/jsLoader.js +3 -0
  51. package/dist/security/jsLoader.js.map +1 -1
  52. package/dist/server/REST.js +6 -3
  53. package/dist/server/REST.js.map +1 -1
  54. package/dist/server/mqtt.js +5 -1
  55. package/dist/server/mqtt.js.map +1 -1
  56. package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
  57. package/dist/server/serverHelpers/serverUtilities.js +14 -3
  58. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  59. package/dist/server/serverHelpers/uwsServer.js +4 -1
  60. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  61. package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
  62. package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
  63. package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
  64. package/dist/utility/logging/harper_logger.js +1 -0
  65. package/dist/utility/logging/harper_logger.js.map +1 -1
  66. package/index.ts +3 -0
  67. package/npm-shrinkwrap.json +104 -104
  68. package/package.json +6 -5
  69. package/resources/DESIGN.md +568 -3
  70. package/resources/RecordEncoder.ts +27 -5
  71. package/resources/Table.ts +49 -3
  72. package/resources/analytics/DESIGN.md +38 -0
  73. package/resources/analytics/write.ts +40 -14
  74. package/resources/crdt.ts +22 -0
  75. package/resources/databases.ts +1 -1
  76. package/resources/indexes/DESIGN.md +833 -0
  77. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +44 -25
  78. package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
  79. package/resources/indexes/fullTextNativeBinding.ts +146 -0
  80. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
  81. package/resources/record-locks.md +1407 -0
  82. package/resources/recordLockCoordinator.ts +61 -22
  83. package/resources/scheduler/DESIGN.md +40 -0
  84. package/resources/transactionBroadcast.ts +4 -4
  85. package/security/DESIGN.md +175 -0
  86. package/security/auth.ts +53 -24
  87. package/security/deferredAuthentication.ts +24 -2
  88. package/security/jsLoader.ts +3 -0
  89. package/server/DESIGN.md +264 -0
  90. package/server/REST.ts +6 -3
  91. package/server/mqtt.ts +6 -4
  92. package/server/serverHelpers/serverUtilities.ts +14 -3
  93. package/server/serverHelpers/uwsServer.ts +4 -1
  94. package/server/serverHelpers/webSocketCloseReason.ts +25 -0
  95. package/studio/web/assets/{Chat-D3j-1yY1.js → Chat-DADFFGe_.js} +1 -1
  96. package/studio/web/assets/{FloatingChat-BxJGYcfB.js → FloatingChat-D_mI-rZ7.js} +3 -3
  97. package/studio/web/assets/{apiToken-CT55oWOe.js → apiToken-c2NiSDHa.js} +1 -1
  98. package/studio/web/assets/{applications-D9Ct9_vm.js → applications-DktUqh7G.js} +1 -1
  99. package/studio/web/assets/{cssMode-DV8H7VwA.js → cssMode-Cs_75Xhw.js} +1 -1
  100. package/studio/web/assets/{editor-uatc0unt.js → editor-19b-Y1IN.js} +1 -1
  101. package/studio/web/assets/{html-Bm6D6paN.js → html-DiYEQMpB.js} +1 -1
  102. package/studio/web/assets/{htmlMode-CEn7tpLG.js → htmlMode-CmR0y7P_.js} +1 -1
  103. package/studio/web/assets/{index-BIXW6Pu4.js → index-Dm0rfkJ7.js} +5 -5
  104. package/studio/web/assets/{index.lazy-UI7L-Vrk.js → index.lazy-7vqt2CC3.js} +1 -1
  105. package/studio/web/assets/{javascript-CJ0G3AFZ.js → javascript-BWtCFuOt.js} +1 -1
  106. package/studio/web/assets/{jsonMode-DQADAYEa.js → jsonMode-Buzzbv9y.js} +1 -1
  107. package/studio/web/assets/{languageServices-CAQJXWcI.js → languageServices-SqsFWfTM.js} +1 -1
  108. package/studio/web/assets/{lspLanguageFeatures-CCQ8P5sY.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
  109. package/studio/web/assets/{notifications-Cvb3P1lB.js → notifications-CAB-LZWT.js} +1 -1
  110. package/studio/web/assets/{notifications-BbxTU6Aw.js → notifications-DRzmSRxM.js} +1 -1
  111. package/studio/web/assets/{profile-Yyb7gsvL.js → profile-BNKAl79n.js} +1 -1
  112. package/studio/web/assets/{regions-OgjGHlU5.js → regions-CUow_Zw2.js} +1 -1
  113. package/studio/web/assets/{register-6qwNEOY3.js → register-Dkt3WUMp.js} +2 -2
  114. package/studio/web/assets/{setComponentFile-BilDMtgB.js → setComponentFile-BZRfMD0N.js} +1 -1
  115. package/studio/web/assets/{setup-J6qJ7OIU.js → setup-D_yiEPO2.js} +2 -2
  116. package/studio/web/assets/{status-0RWGcfyD.js → status-DhHh1Ge-.js} +1 -1
  117. package/studio/web/assets/{toggleHighContrast-BIn-vErT.js → toggleHighContrast-D7L1PDtV.js} +1 -1
  118. package/studio/web/assets/{tsMode-DgUXku4d.js → tsMode-CCwLk1YS.js} +1 -1
  119. package/studio/web/assets/{typescript-C9orXcsM.js → typescript-BP1j1mjn.js} +1 -1
  120. package/studio/web/assets/{useEntityRestURL-BEoXXbUB.js → useEntityRestURL-D7bnYxLw.js} +1 -1
  121. package/studio/web/assets/{workers-JVzSDmgx.js → workers-tOuCNT17.js} +1 -1
  122. package/studio/web/assets/{xml-Cq-S8S4X.js → xml-BSG_3mQT.js} +1 -1
  123. package/studio/web/assets/{yaml-sfoRdh1M.js → yaml-DxiLprBB.js} +1 -1
  124. package/studio/web/index.html +1 -1
  125. package/utility/DESIGN.md +55 -0
  126. package/utility/logging/harper_logger.ts +1 -0
@@ -163,6 +163,10 @@ export class RecordObject {
163
163
  return entryMap.get(this)?.expiresAt;
164
164
  }
165
165
  }
166
+ // msgpackr's RESERVE_START_SPACE option carries the reserved byte count in the LOW BYTE of the same
167
+ // word as its flags (`encodeOptions & 0xff`, msgpackr `pack`), so the metadata prefix this encoder
168
+ // reserves cannot exceed it.
169
+ const MAX_RESERVED_START_SPACE = 255;
166
170
  export let lastValueEncoding: Buffer | undefined;
167
171
  let timestampNextEncoding = 0,
168
172
  metadataInNextEncoding = -1,
@@ -355,6 +359,7 @@ export class RecordEncoder extends StructonEncoder {
355
359
  const residencyId = residencyIdAtNextEncoding;
356
360
  const nodeId = nodeIdAtNextEncoding;
357
361
  const additionalAuditRefs = additionalAuditRefsNextEncoding;
362
+ let auditRefsToEncode = 0;
358
363
  if (metadata >= 0) {
359
364
  valueStart += 4; // make room for metadata bytes
360
365
  metadataInNextEncoding = -1; // reset indicator to mean no metadata
@@ -380,7 +385,18 @@ export class RecordEncoder extends StructonEncoder {
380
385
  }
381
386
  }
382
387
  if (additionalAuditRefs && additionalAuditRefs.length > 0) {
383
- valueStart += 1 + additionalAuditRefs.length * 12; // 1 byte for count + 8 bytes version + 4 bytes nodeId per ref
388
+ // `valueStart` is OR'd into msgpackr's encode options below, where the reserved-space field
389
+ // is the LOW BYTE (RESERVE_START_SPACE): a valueStart over 255 wraps, so the record is
390
+ // written with a prefix neither side agrees on and decodes as garbage. The persisted ref
391
+ // count is one byte too. Refs are best-effort — a later in-order write drops them outright —
392
+ // so over budget the MIDDLE is dropped: the first entry is the addressable audit head when
393
+ // the record and log clocks diverge, and the last is the identity this write is matched on
394
+ // by the re-delivery guard, so neither end may go. (harper#2642)
395
+ auditRefsToEncode = Math.min(
396
+ additionalAuditRefs.length,
397
+ Math.floor((MAX_RESERVED_START_SPACE - valueStart - 1) / 12)
398
+ );
399
+ valueStart += 1 + auditRefsToEncode * 12; // 1 byte for count + 8 bytes version + 4 bytes nodeId per ref
384
400
  additionalAuditRefsNextEncoding = undefined;
385
401
  }
386
402
  }
@@ -402,7 +418,7 @@ export class RecordEncoder extends StructonEncoder {
402
418
  position += 8;
403
419
  }
404
420
  if (blobsWereEncoded) metadata |= HAS_BLOBS;
405
- if (additionalAuditRefs && additionalAuditRefs.length > 0) metadata |= HAS_ADDITIONAL_AUDIT_REFS;
421
+ if (auditRefsToEncode > 0) metadata |= HAS_ADDITIONAL_AUDIT_REFS;
406
422
  if (metadata >= 0) {
407
423
  dataView.setUint32(position, metadata | (ACTION_32_BIT << 24)); // use the extended action byte
408
424
  position += 4;
@@ -418,9 +434,15 @@ export class RecordEncoder extends StructonEncoder {
418
434
  dataView.setUint32(position, nodeId);
419
435
  position += 4;
420
436
  }
421
- if (additionalAuditRefs && additionalAuditRefs.length > 0) {
422
- encoded[position++] = additionalAuditRefs.length;
423
- for (const ref of additionalAuditRefs) {
437
+ if (auditRefsToEncode > 0) {
438
+ encoded[position++] = auditRefsToEncode;
439
+ // Written as [0, …tail]: the dropped window is the middle, and it is empty when nothing
440
+ // had to be dropped.
441
+ const dropFrom = 1;
442
+ const dropTo = additionalAuditRefs.length - auditRefsToEncode + 1;
443
+ for (let index = 0; index < additionalAuditRefs.length; index++) {
444
+ if (index >= dropFrom && index < dropTo) continue;
445
+ const ref = additionalAuditRefs[index];
424
446
  dataView.setFloat64(position, ref.version);
425
447
  position += 8;
426
448
  dataView.setUint32(position, ref.nodeId);
@@ -108,6 +108,7 @@ import {
108
108
  LOCAL_ONLY,
109
109
  auditRetention,
110
110
  removeAuditEntry,
111
+ getAuditFloor,
111
112
  raiseAuditFloor,
112
113
  boundedAuditPruneEnd,
113
114
  isLockControlType,
@@ -135,7 +136,7 @@ import {
135
136
  storedFieldsOnly,
136
137
  } from './RecordEncoder.ts';
137
138
  import { recordAction, recordActionBinary } from './analytics/write.ts';
138
- import { rebuildUpdateBefore } from './crdt.ts';
139
+ import { commutativeOpsOf, rebuildUpdateBefore } from './crdt.ts';
139
140
  import { appendHeader } from '../server/serverHelpers/Headers.ts';
140
141
  import fs from 'node:fs';
141
142
  import { Blob, deleteBlobsInObject, findBlobsInObject, startPreCommitBlobsForRecord } from './blob.ts';
@@ -3642,6 +3643,8 @@ export function makeTable(options) {
3642
3643
  // existing timestamp, which means that we received updates out of order, and must resequence the application
3643
3644
  // of the updates to the record to ensure consistency across the cluster
3644
3645
  // TODO: can the previous version be older, but even more previous version be newer?
3646
+ let belowAuditFloor = false;
3647
+ let dedupVersionCouldBeRetained: (version: number) => boolean;
3645
3648
  if (audit) {
3646
3649
  // A re-delivered out-of-order write (full-copy audit-replay re-delivers writes) must not have
3647
3650
  // its commutative ops re-folded. additionalAuditRefs is the record's own list of folded
@@ -3687,7 +3690,7 @@ export function makeTable(options) {
3687
3690
  // Resolve the oldest retained entry once, for the same log the dedup reads.
3688
3691
  let oldestRetainedAuditTime: number | undefined;
3689
3692
  let oldestRetainedAuditTimeResolved = false;
3690
- const dedupVersionCouldBeRetained = (version: number): boolean => {
3693
+ dedupVersionCouldBeRetained = (version: number): boolean => {
3691
3694
  if (!isRocksDB) return true; // LMDB keeps its exact, unbounded lookup (keyed by local audit time)
3692
3695
  if (!oldestRetainedAuditTimeResolved) {
3693
3696
  oldestRetainedAuditTimeResolved = true;
@@ -3734,6 +3737,19 @@ export function makeTable(options) {
3734
3737
  return; // duplicate already applied; avoid the resequencing walk
3735
3738
  }
3736
3739
  }
3740
+ // The walk terminates at this write only by reaching an audit entry whose log key is at or below
3741
+ // txnTime (the loop condition below), so below the floor it cannot: it runs the whole retained
3742
+ // chain to an outcome the floor already determines (harper#2642). txnTime is the coordinate
3743
+ // because it is the one that loop compares; txnLogKey addresses this write's own entry, a
3744
+ // different question that dedupVersionCouldBeRetained already answers. An unknown floor is
3745
+ // Infinity, which fails closed for a cursor check but has to fail OPEN here — walk rather than
3746
+ // discard. RocksDB only: LMDB keeps its exact, unbounded reconciliation.
3747
+ if (isRocksDB && precedesExisting < 0) {
3748
+ const auditFloor = getAuditFloor(auditStore);
3749
+ belowAuditFloor = Number.isFinite(auditFloor) && txnTime < auditFloor;
3750
+ }
3751
+ }
3752
+ if (audit && !belowAuditFloor) {
3737
3753
  // incremental CRDT updates are only available with audit logging on
3738
3754
  const initialAuditHead = isRocksDB
3739
3755
  ? resolveAuditHead(id, existingEntry.version, existingEntry.nodeId, existingEntry.additionalAuditRefs)
@@ -3751,7 +3767,12 @@ export function makeTable(options) {
3751
3767
  new Date(localTime)
3752
3768
  );
3753
3769
 
3754
- let nodeId = initialAuditHead.nodeId;
3770
+ // Normalized here and not at the lookup, because the two sources of an undefined nodeId do not
3771
+ // mean the same thing: a record's own nodeId is resolved by the same expression as the id its
3772
+ // audit entry is logged under, which applies `?? 0` (RecordEncoder), so absent means log 0;
3773
+ // `previousNodeId` is never encoded, so absent there means the log is unknown and only the
3774
+ // aggregate lookup can resolve a cross-origin predecessor.
3775
+ let nodeId = initialAuditHead.nodeId ?? 0;
3755
3776
  const succeedingUpdates = []; // record the "future" updates, as we need to apply the updates in reverse order
3756
3777
  const auditRefsToVisit: Array<{ localTime: number; nodeId: number }> = existingEntry.additionalAuditRefs
3757
3778
  ? existingEntry.additionalAuditRefs.map((ref) => ({ localTime: ref.version, nodeId: ref.nodeId }))
@@ -4015,6 +4036,31 @@ export function makeTable(options) {
4015
4036
  write.skipped = true;
4016
4037
  return;
4017
4038
  }
4039
+ } else if (belowAuditFloor) {
4040
+ // The walk cannot reach this write, so it may contribute only what is order-independent: its
4041
+ // commutative ops. Whether a plain field survives depends on what newer writes did to that key,
4042
+ // which is what the purged history no longer answers, so applying one would resurrect state a
4043
+ // newer put may have erased. Everything else loses to the strictly newer head: a full update, a
4044
+ // head carrying no record (a delete or a residency-omitted record, which an op must not
4045
+ // resurrect), and an op-less patch. Bare return, no writeCommit, so no audit record references
4046
+ // the losing update's pre-saved blobs.
4047
+ incrementalUpdateToApply = fullUpdate || existingRecord == null ? null : commutativeOpsOf(recordUpdate);
4048
+ if (!incrementalUpdateToApply) {
4049
+ write.skipped = true;
4050
+ return;
4051
+ }
4052
+ // The surviving head's addressable log-key pointer lives in these, wherever the record and log
4053
+ // clocks differ.
4054
+ if (existingEntry.additionalAuditRefs) {
4055
+ for (const ref of existingEntry.additionalAuditRefs) {
4056
+ additionalAuditRefs.push(ref);
4057
+ }
4058
+ }
4059
+ // Once the walk no longer runs, this ref is what the read-your-writes check above matches a
4060
+ // re-delivery of these ops on. Best-effort, like every other guard here: the encoder bounds
4061
+ // the persisted list, so an identity can age out of it (harper#1148's full-copy convergence
4062
+ // is the backstop).
4063
+ additionalAuditRefs.push({ version: txnLogKey, nodeId: options?.nodeId });
4018
4064
  } else if (fullUpdate) {
4019
4065
  // if no audit, we can't accurately do incremental updates, so we just assume the last update
4020
4066
  // was the same type. Assuming a full update this record update loses and there are no changes —
@@ -0,0 +1,38 @@
1
+ # resources/analytics/ — Design notes
2
+
3
+ Analytics recording and aggregation.
4
+
5
+ **Read this when:** touching `write.ts` aggregation or the raw-analytics cursor.
6
+
7
+ Index of every design note: [DESIGN.md](../../DESIGN.md).
8
+
9
+ ---
10
+
11
+ ## Analytics aggregation resumes from a raw cursor, which is not a clock (`resources/analytics/write.ts`)
12
+
13
+ `aggregation()` rolls up the raw reports in `hdb_raw_analytics` one `toPeriod` window at a time —
14
+ it stops at `firstForPeriod + toPeriod` so one cycle's main-thread msgpackr decode stays bounded
15
+ (#1538) — and resumes, exclusive, from `rawCursor`. Two rules hold that cursor:
16
+
17
+ - **It only ever moves to a record a cycle actually read.** It used to be one variable with the
18
+ cadence marker, stamped with the cycle's end-of-run `Date.now()`, which discarded the remainder of
19
+ a backlog longer than one window and every report written while the cycle ran, because nothing
20
+ reads below the cursor again.
21
+ - **A cycle that reads nothing leaves it where it is.** An empty scan is not proof that nothing is
22
+ there: `recordAnalytics` does not await its `primaryStore.put`, so a report can be uncommitted and
23
+ invisible to the scan while carrying a key older than anything the cycle could compare against. No
24
+ wall-clock reading is safe to put in the cursor for the same reason.
25
+
26
+ `lastAggregationTime` stays a pure cadence marker, stamped `now` at the end of every completed
27
+ cycle, so an idle node still aggregates once per period (#1538). A cycle that stopped at its window
28
+ edge sets `aggregationBehind`, which lets the next tick skip that guard: a backlog then drains one
29
+ window per half-period tick instead of waiting out the cadence it is already behind on.
30
+
31
+ Cycles must not overlap. `setInterval` does not await its async callback, so `runAggregationCycle`
32
+ holds a single-flight flag — two cycles reading the same cursor roll the same window up twice and
33
+ double every count in it.
34
+
35
+ Still open: `findLastAggregationTime` seeds both markers after a restart from the newest stored
36
+ analytics record, whose `time` is an end-of-cycle stamp, so a restart mid-drain can skip the rest of
37
+ the backlog; and `storeMetric` discards `table.put()`'s result, so the cursor advances past raw
38
+ records whose aggregate rows failed to commit. Both need a cursor persisted with the rows.
@@ -877,6 +877,12 @@ async function storeNodeStorageMetric(analyticsTable: Table) {
877
877
  const MAX_LAST_AGGREGATION_SCAN = 1000;
878
878
  // Time of this node's last completed aggregation, tracked in O(1) across cycles within a process.
879
879
  let lastAggregationTime: number | undefined;
880
+ // Resume point into the raw analytics table: it moves only over records a cycle read, so it is not
881
+ // a clock and never passes an unread report.
882
+ let rawCursor: number | undefined;
883
+ // A cycle stopped at its window edge with raw records still behind it, so the next one skips the
884
+ // cadence guard and drains the next window.
885
+ let aggregationBehind = false;
880
886
 
881
887
  /**
882
888
  * Find the time of the local node's most recent aggregation record by scanning back from the
@@ -901,6 +907,20 @@ export function findLastAggregationTime(
901
907
  return undefined;
902
908
  }
903
909
 
910
+ let aggregationRunning = false;
911
+
912
+ /** Two cycles reading the same cursor roll the same window up twice, and the scheduler does not
913
+ * await its callback. */
914
+ export async function runAggregationCycle(fromPeriod, toPeriod = 60000) {
915
+ if (aggregationRunning) return;
916
+ aggregationRunning = true;
917
+ try {
918
+ await aggregation(fromPeriod, toPeriod);
919
+ } finally {
920
+ aggregationRunning = false;
921
+ }
922
+ }
923
+
904
924
  async function aggregation(fromPeriod, toPeriod = 60000) {
905
925
  const rawAnalyticsTable = getRawAnalyticsTable();
906
926
  const analyticsTable = getAnalyticsTable();
@@ -924,28 +944,35 @@ async function aggregation(fromPeriod, toPeriod = 60000) {
924
944
  // first never touch the table. On the first cycle after boot we seed it once from the table
925
945
  // via a bounded reverse scan (see findLastAggregationTime). Caching the seeded value keeps
926
946
  // the next cycle O(1) even when this run early-returns below as "too recent"; a bound-hit
927
- // (no match) leaves it undefined so we don't early-return, proceed to aggregate, and set the
928
- // marker to `now` at the end of the cycle instead (#1538).
947
+ // (no match) leaves it undefined so we don't early-return, proceed to aggregate, and stamp the
948
+ // cadence marker at the end of the cycle instead (#1538).
929
949
  let lastForPeriod = lastAggregationTime;
930
950
  if (lastForPeriod === undefined) {
931
951
  lastForPeriod = findLastAggregationTime(analyticsTable.primaryStore, localNodeId);
932
- if (lastForPeriod !== undefined) lastAggregationTime = lastForPeriod;
952
+ if (lastForPeriod !== undefined) {
953
+ lastAggregationTime = lastForPeriod;
954
+ rawCursor ??= lastForPeriod;
955
+ }
933
956
  }
934
957
  // was the last aggregation too recent to calculate a whole period?
935
- if (lastForPeriod !== undefined && Date.now() - toPeriod < lastForPeriod) return;
958
+ if (!aggregationBehind && lastForPeriod !== undefined && Date.now() - toPeriod < lastForPeriod) return;
936
959
  let firstForPeriod;
937
960
  const aggregateActions = new Map();
938
961
  const distributions = new Map();
939
962
  const threadsToAverage = [];
940
- let lastTime: number;
963
+ let lastTime: number | undefined;
964
+ let stoppedAtWindowEdge = false;
941
965
  for (const { key, value } of rawAnalyticsTable.primaryStore.getRange({
942
- start: lastForPeriod || false,
966
+ start: rawCursor || false,
943
967
  exclusiveStart: true,
944
968
  end: Infinity,
945
969
  })) {
946
970
  if (!value) continue;
947
971
  if (firstForPeriod) {
948
- if (key > firstForPeriod + toPeriod) break; // outside the period of interest
972
+ if (key > firstForPeriod + toPeriod) {
973
+ stoppedAtWindowEdge = true;
974
+ break; // outside the period of interest
975
+ }
949
976
  } else firstForPeriod = key;
950
977
  lastTime = key;
951
978
  const { metrics, threadId } = value;
@@ -1091,12 +1118,11 @@ async function aggregation(fromPeriod, toPeriod = 60000) {
1091
1118
  };
1092
1119
  storeMetric(analyticsTable, cruMetric);
1093
1120
  lastResourceUsage = resourceUsage;
1094
- // Advance the O(1) last-aggregation marker to this cycle's time. The resource-usage metric
1095
- // just written is a composite-id record stamped `time: now`, so it is exactly what the
1096
- // reverse-scan seed would find as the newest matching record. Updating it every completed
1097
- // cycle — not only when there were raw updates — keeps the `Date.now() - toPeriod` period
1098
- // guard enforcing the configured cadence during idle stretches, matching the prior
1099
- // scan-based behavior rather than letting a stale marker run aggregation on every tick (#1538).
1121
+ // The cursor may only pass records this cycle read, and nothing reads below it again: the scan
1122
+ // covers one `toPeriod` window, and a raw report whose `put` has not committed is invisible to
1123
+ // it however recent its key is. An empty scan therefore leaves the cursor where it was.
1124
+ if (lastTime !== undefined) rawCursor = lastTime;
1125
+ aggregationBehind = stoppedAtWindowEdge;
1100
1126
  lastAggregationTime = now;
1101
1127
 
1102
1128
  // `system` is set as non-enumerable on the object returned by getDatabases() so most
@@ -1208,7 +1234,7 @@ function startScheduledTasks() {
1208
1234
  const aggregateRetentionMs = envGet(CONFIG_PARAMS.ANALYTICS_AGGREGATERETENTIONMS) ?? AGGREGATE_EXPIRATION;
1209
1235
  setInterval(
1210
1236
  async () => {
1211
- await aggregation(analyticsDelay, AGGREGATE_PERIOD);
1237
+ await runAggregationCycle(analyticsDelay, AGGREGATE_PERIOD);
1212
1238
  await cleanup(getRawAnalyticsTable(), rawRetentionMs);
1213
1239
  // 0 means "keep forever" — skip aggregate cleanup, matching storageInterval: 0 convention
1214
1240
  if (aggregateRetentionMs) await cleanup(getAnalyticsTable(), aggregateRetentionMs);
package/resources/crdt.ts CHANGED
@@ -74,6 +74,28 @@ export function rebuildUpdateBefore(update: any, newerUpdate: any, fullUpdate?:
74
74
  }
75
75
  return newUpdate;
76
76
  }
77
+
78
+ /**
79
+ * The commutative operations in an out-of-order `update` — the only part of it whose outcome does not
80
+ * depend on where it lands in the write order. Used when the audit history the update would have been
81
+ * resequenced against is no longer retained.
82
+ *
83
+ * Not `rebuildUpdateBefore`, which folds an update against another UPDATE: against a materialized
84
+ * record its plain-newer-value rule drops the incoming `add` on any key the record already has a
85
+ * value for.
86
+ */
87
+ export function commutativeOpsOf(update: any) {
88
+ let ops = null;
89
+ for (const key in update) {
90
+ const value = update[key];
91
+ if (value?.__op__) {
92
+ if (!ops) ops = {};
93
+ ops[key] = value;
94
+ }
95
+ }
96
+ return ops;
97
+ }
98
+
77
99
  export function applyReverse(record, update, unknowns: Set<string>) {
78
100
  for (const key in update) {
79
101
  const value = update[key];
@@ -1014,7 +1014,7 @@ function initStores(
1014
1014
  { defaultTable, auditPath, isLegacy, destination, storeName, openedStores }: InitStoresOptions = {}
1015
1015
  ) {
1016
1016
  // a store with no tables never reaches the per-table loop below, and blob roots resolve from this
1017
- rootStore.databaseName = storeName ?? databaseName;
1017
+ rootStore.databaseName ??= storeName ?? databaseName;
1018
1018
  const envInit = new OpenEnvironmentObject(path, isReadOnlyMode());
1019
1019
  const internalDbiInit = createOpenDBIObject(false);
1020
1020
  let attributesDbi = rootStore.dbisDb;