@blamejs/core 0.18.43 → 0.18.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/time.js CHANGED
@@ -34,8 +34,15 @@
34
34
  */
35
35
  var C = require("./constants");
36
36
  var codepointClass = require("./codepoint-class");
37
+ var lazyRequire = require("./lazy-require");
38
+ var validateOpts = require("./validate-opts");
37
39
  var { defineClass } = require("./framework-error");
38
40
 
41
+ // Lazy: the drift report is the only thing that needs it, and loading the
42
+ // observability stack at require-time would pull it into every consumer of a
43
+ // date formatter.
44
+ var observability = lazyRequire(function () { return require("./observability"); });
45
+
39
46
  var TimeError = defineClass("TimeError", { alwaysPermanent: true });
40
47
 
41
48
  var DEFAULT_TIMEZONE = "UTC";
@@ -821,6 +828,279 @@ function stripIsoMilliseconds(text) {
821
828
  return text.slice(0, at) + "Z";
822
829
  }
823
830
 
831
+ // ---------------------------------------------------------------------------
832
+ // Monotonic wall clock.
833
+ // ---------------------------------------------------------------------------
834
+
835
+ // How far ahead of the underlying source a clock may run before it says so.
836
+ // One second buys a thousand stamps inside a single millisecond — well past
837
+ // any realistic write burst — while keeping a recorded timestamp close enough
838
+ // to true time that an auditor reading it is not misled.
839
+ var DEFAULT_MAX_DRIFT_MS = C.TIME.seconds(1);
840
+
841
+ /**
842
+ * @primitive b.time.monotonicClock
843
+ * @signature b.time.monotonicClock(opts?)
844
+ * @since 0.18.44
845
+ * @status stable
846
+ * @related b.time.monotonicNow, b.chainWriter.create, b.ntpCheck.checkDrift
847
+ *
848
+ * An isolated clock whose `now()` never returns a value less than or equal to
849
+ * the one before it.
850
+ *
851
+ * `Date.now()` gives neither guarantee. It repeats when two writes land in the
852
+ * same millisecond, and it moves BACKWARDS when NTP steps the clock — the
853
+ * correction `b.ntpCheck` exists to detect. Anything that orders records by a
854
+ * timestamp inherits both problems: an append-only chain gets a row that
855
+ * appears to precede its predecessor, and keyset pagination over a timestamp
856
+ * column drops or repeats rows that share a millisecond.
857
+ *
858
+ * Three things the four-line hand-rolled version leaves out, and this settles:
859
+ *
860
+ * - **An injectable source.** `opts.source` defaults to `Date.now` but a test
861
+ * can hand over a scripted one, which a closure over `Date.now` cannot. It
862
+ * must return a SAFE-INTEGER count of milliseconds. A fractional clock such
863
+ * as `performance.timeOrigin + performance.now()` is refused rather than
864
+ * rounded: this primitive's whole promise is that the next value exceeds the
865
+ * last, and it keeps that promise by handing out `last + 1` when the source
866
+ * has not moved — arithmetic that needs an integer to stay exact.
867
+ * - **A floor that survives a restart.** Process memory resets to zero on
868
+ * restart or failover — precisely when a fresh node is syncing NTP and a
869
+ * backwards step is most likely. `observeFloor(ms)` seeds the guarantee from
870
+ * a value read back out of storage, and ignores anything below where the
871
+ * clock already is. It takes a NUMBER and refuses anything else, including
872
+ * the decimal string a Postgres `BIGINT` arrives as through most drivers:
873
+ * coercing a floor silently would accept `"abc"` as `NaN` and every later
874
+ * comparison against it as false, so the conversion is the caller's to make
875
+ * and to get wrong loudly.
876
+ * - **A ceiling on how far ahead of the source it may run.** A burst inside
877
+ * one millisecond walks the returned value into the future. Unbounded, that
878
+ * is a silently wrong timestamp; `maxDriftMs` is what makes it not silent.
879
+ *
880
+ * Passing the cap REPORTS — through `onDrift` when given, otherwise as the
881
+ * `time.monotonic.drift_exceeded` observability event — and keeps returning
882
+ * monotonic values. For the framework's own consumer, an append-only audit
883
+ * chain, a timestamp a few milliseconds optimistic is a smaller harm than a
884
+ * dropped row, and a dropped row is what an attacker who can step the clock
885
+ * would be aiming for. A caller whose property is timestamp ACCURACY rather
886
+ * than completeness asks for `strict: true` and gets a
887
+ * `time/monotonic-drift-cap` throw instead.
888
+ *
889
+ * The report never goes through `b.audit`: a clock that stamps audit rows and
890
+ * audits its own drift would call itself.
891
+ *
892
+ * @opts
893
+ * source: function, // default: Date.now — must return a SAFE-INTEGER number of ms
894
+ * maxDriftMs: number, // default: 1000 — lead over the source before it reports
895
+ * onDrift: function, // ({ driftMs, maxDriftMs, value, sourceMs, label }) — replaces the observability event
896
+ * strict: boolean, // default: false — true throws instead of reporting
897
+ * label: string, // default: "default" — names this clock in drift reports
898
+ *
899
+ * @example
900
+ * var clock = b.time.monotonicClock({ label: "device_event_log" });
901
+ * // Number(...) is not decoration: a Postgres BIGINT arrives as a decimal
902
+ * // STRING through most drivers, and observeFloor refuses one rather than
903
+ * // coercing it — a floor is too important to guess at.
904
+ * clock.observeFloor(Number(tipRow.recordedAt)); // durable, read from storage
905
+ * var recordedAt = clock.now();
906
+ * // → strictly greater than tipRow.recordedAt, whatever the wall clock did
907
+ */
908
+ function monotonicClock(opts) {
909
+ opts = opts || {};
910
+ validateOpts(opts, ["source", "maxDriftMs", "onDrift", "strict", "label"],
911
+ "b.time.monotonicClock");
912
+
913
+ validateOpts.shape(opts, {
914
+ source: { rule: "optional-function", code: "time/bad-source" },
915
+ maxDriftMs: { rule: "optional-non-negative", code: "time/bad-max-drift" },
916
+ onDrift: { rule: "optional-function", code: "time/bad-on-drift" },
917
+ strict: { rule: "optional-boolean", code: "time/bad-strict" },
918
+ label: { rule: "optional-string", code: "time/bad-label" },
919
+ }, "b.time.monotonicClock", TimeError, "time/bad-opts");
920
+
921
+ var source = opts.source == null ? Date.now : opts.source;
922
+ var maxDriftMs = opts.maxDriftMs == null ? DEFAULT_MAX_DRIFT_MS : opts.maxDriftMs;
923
+ var onDrift = opts.onDrift == null ? null : opts.onDrift;
924
+ var strict = opts.strict === true;
925
+ var label = opts.label == null ? "default" : opts.label;
926
+
927
+ var last = 0;
928
+ var lastSourceMs = 0;
929
+ var reporting = false;
930
+
931
+ function _report(info) {
932
+ // A reporter that calls now() re-enters here, and the re-entrant call drifts
933
+ // by construction - it is one millisecond further ahead than the call that
934
+ // triggered the report. Reporting that would recurse until the stack ran
935
+ // out, so the outermost report is the one that speaks for the burst.
936
+ if (reporting) return;
937
+ reporting = true;
938
+ try { _reportOnce(info); } finally { reporting = false; }
939
+ }
940
+
941
+ function _reportOnce(info) {
942
+ if (onDrift) {
943
+ // The caller's reporter is theirs to get wrong; a throw from it must not
944
+ // decide whether the timestamp was issued.
945
+ try { onDrift(info); } catch (_e) { /* drop-silent — see the block comment above */ }
946
+ return;
947
+ }
948
+ // Never b.audit: an audit row needs a timestamp, which needs this clock.
949
+ try {
950
+ observability().safeEvent("time.monotonic.drift_exceeded", info.driftMs, {
951
+ clock: info.label,
952
+ });
953
+ } catch (_e) { /* drop-silent — an observability failure must not stop the clock */ }
954
+ }
955
+
956
+ function now() {
957
+ // A drift report runs caller code in the middle of issuing a value. A
958
+ // reporter that asks this same clock for another one gets a HIGHER value
959
+ // and returns it BEFORE the call that triggered the report returns its
960
+ // lower one - so a callback that stamps its own log line records the drift
961
+ // at a later instant than the event it describes, which is time running
962
+ // backwards in the record this clock exists to keep straight.
963
+ //
964
+ // Refusing is the honest answer, and it costs the reporter nothing: the
965
+ // value being reported on is already in `info.value`. The throw is caught
966
+ // by _report's drop-silent wrapper, so it cannot fail the append that
967
+ // triggered the report. A DIFFERENT clock is unaffected - this is per
968
+ // instance, not a global lock.
969
+ if (reporting) {
970
+ throw new TimeError("time/monotonic-reentrant",
971
+ "b.time.monotonicClock(" + label + "): now() was called from this " +
972
+ "clock's own drift report. The value being reported on is in " +
973
+ "info.value; asking for a new one here would return a later " +
974
+ "timestamp than the event it describes.");
975
+ }
976
+ var t = source();
977
+ // Number.isSafeInteger, not isFinite: past 2^53 the value has ALREADY been
978
+ // rounded before it arrives, and `last + 1` stops increasing there - the
979
+ // addition saturates, two calls return the same number, and the one
980
+ // guarantee this primitive makes fails silently. Refuse instead.
981
+ if (typeof t !== "number" || !Number.isSafeInteger(t)) {
982
+ throw new TimeError("time/monotonic-bad-source",
983
+ "b.time.monotonicClock: the clock source returned " +
984
+ (typeof t === "number" ? String(t) : typeof t) +
985
+ " rather than a safe integer number of epoch milliseconds");
986
+ }
987
+ lastSourceMs = t;
988
+ var value = t > last ? t : last + 1;
989
+ // The invariant, checked rather than assumed. Reachable only at the
990
+ // safe-integer ceiling, which the input screens above already refuse - so
991
+ // this is the backstop that cannot be bypassed by a future caller finding
992
+ // another way to raise the floor.
993
+ if (!(value > last) || !Number.isSafeInteger(value)) {
994
+ throw new TimeError("time/monotonic-exhausted",
995
+ "b.time.monotonicClock(" + label + "): the next monotonic value would " +
996
+ "not exceed the last (" + last + "), so the guarantee cannot be kept. " +
997
+ "The clock has reached the safe-integer ceiling; a floor read from " +
998
+ "storage is the only way to get here.");
999
+ }
1000
+ var driftMs = value - t;
1001
+ if (driftMs > maxDriftMs) {
1002
+ var info = {
1003
+ driftMs: driftMs, maxDriftMs: maxDriftMs,
1004
+ value: value, sourceMs: t, label: label,
1005
+ };
1006
+ if (strict) {
1007
+ // Throw BEFORE advancing, so a caller that never received the value
1008
+ // cannot have it walk the floor further out of reach on every retry.
1009
+ throw new TimeError("time/monotonic-drift-cap",
1010
+ "b.time.monotonicClock(" + label + "): the monotonic value is " + driftMs +
1011
+ "ms ahead of the clock source, past the " + maxDriftMs + "ms cap. Either the " +
1012
+ "write rate exceeds one row per millisecond or the system clock stepped " +
1013
+ "backwards; b.ntpCheck.checkDrift() distinguishes them.");
1014
+ }
1015
+ // Advance BEFORE the reporter runs. _report hands control to caller code,
1016
+ // and a reporter that calls now() re-enters this function; if the floor
1017
+ // had not moved yet the re-entrant call would compute the SAME value from
1018
+ // the same `last` and two callers would hold one timestamp - exactly the
1019
+ // collision this clock exists to prevent. The strict throw above still
1020
+ // precedes the advance, because there the value is never issued at all.
1021
+ last = value;
1022
+ _report(info);
1023
+ return value;
1024
+ }
1025
+ last = value;
1026
+ return value;
1027
+ }
1028
+
1029
+ // Raise the floor to a value read back out of storage. Lower values are
1030
+ // ignored rather than refused: a stale replica or an out-of-order read must
1031
+ // not be able to rewind a clock that has already moved past it.
1032
+ function observeFloor(ms) {
1033
+ // A safe integer, not merely finite. This value comes from STORAGE - the
1034
+ // chain writer seeds it from a persisted recordedAt column - so it is the
1035
+ // one input a caller does not compose themselves, and a value past 2^53
1036
+ // would push the clock to a ceiling where `last + 1` saturates and the
1037
+ // monotonic guarantee quietly stops holding.
1038
+ if (typeof ms !== "number" || !Number.isSafeInteger(ms)) {
1039
+ throw new TimeError("time/bad-floor",
1040
+ "b.time.monotonicClock.observeFloor: expected a safe-integer number of " +
1041
+ "epoch milliseconds; got " + (typeof ms === "number" ? String(ms) : typeof ms));
1042
+ }
1043
+ if (ms < 0) {
1044
+ throw new TimeError("time/bad-floor",
1045
+ "b.time.monotonicClock.observeFloor: a floor cannot be negative; got " + ms);
1046
+ }
1047
+ // MAX_SAFE_INTEGER is itself a safe integer, so the check above admits it -
1048
+ // and a clock sitting exactly there has no next value: MAX + 1 is not
1049
+ // representable and rounds back to MAX. Refuse the boundary too, so the
1050
+ // guarantee never depends on there being one more tick available.
1051
+ if (ms >= Number.MAX_SAFE_INTEGER) {
1052
+ throw new TimeError("time/bad-floor",
1053
+ "b.time.monotonicClock.observeFloor: a floor at or past " +
1054
+ "Number.MAX_SAFE_INTEGER leaves no representable next value, so the " +
1055
+ "monotonic guarantee could not be kept; got " + ms);
1056
+ }
1057
+ if (ms > last) last = ms;
1058
+ return last;
1059
+ }
1060
+
1061
+ return {
1062
+ now: now,
1063
+ observeFloor: observeFloor,
1064
+ lastValue: function () { return last; },
1065
+ driftMs: function () { return last > lastSourceMs ? last - lastSourceMs : 0; },
1066
+ label: label,
1067
+ };
1068
+ }
1069
+
1070
+ var _sharedMonotonic = monotonicClock({ label: "shared" });
1071
+
1072
+ /**
1073
+ * @primitive b.time.monotonicNow
1074
+ * @signature b.time.monotonicNow()
1075
+ * @since 0.18.44
1076
+ * @status stable
1077
+ * @related b.time.monotonicClock
1078
+ *
1079
+ * The process-wide monotonic clock: epoch milliseconds that never repeat and
1080
+ * never move backwards, whatever `Date.now()` does. The shape most callers
1081
+ * want — one shared sequence, so two unrelated call sites in the same process
1082
+ * cannot mint the same value.
1083
+ *
1084
+ * Takes no arguments. A caller who needs an injectable source, a durable
1085
+ * floor, a different drift cap, or a sequence isolated from everyone else's
1086
+ * builds their own with `b.time.monotonicClock`; configuring a shared clock
1087
+ * from one call site would silently change every other site's behaviour.
1088
+ *
1089
+ * @example
1090
+ * var a = b.time.monotonicNow();
1091
+ * var b2 = b.time.monotonicNow();
1092
+ * // → b2 > a, even inside the same millisecond
1093
+ */
1094
+ function monotonicNow() {
1095
+ if (arguments.length > 0) {
1096
+ throw new TimeError("time/monotonic-now-takes-no-opts",
1097
+ "b.time.monotonicNow() takes no arguments — it is one shared sequence, and " +
1098
+ "configuring it from one call site would change every other. Use " +
1099
+ "b.time.monotonicClock(opts) for a clock of your own.");
1100
+ }
1101
+ return _sharedMonotonic.now();
1102
+ }
1103
+
824
1104
  module.exports = {
825
1105
  toParts: toParts,
826
1106
  format: format,
@@ -836,5 +1116,7 @@ module.exports = {
836
1116
  readDateTime: readDateTime,
837
1117
  toIso8601NoMs: toIso8601NoMs,
838
1118
  stripIsoMilliseconds: stripIsoMilliseconds,
1119
+ monotonicClock: monotonicClock,
1120
+ monotonicNow: monotonicNow,
839
1121
  TimeError: TimeError,
840
1122
  };
@@ -21,7 +21,7 @@
21
21
  "server": "sha256:f3325f480cb8eb814fcb0baaa19336cbbf2b993f48624c6aa9600ffd69d0be5e",
22
22
  "browser": "sha256:0ffd91540bcb586a29b56e52ee1c29df69097b50776beb4036a07558f7a4e12e"
23
23
  },
24
- "refreshedAt": "2026-08-20T15:14:48.311Z"
24
+ "refreshedAt": "2026-08-21T09:57:09.089Z"
25
25
  },
26
26
  "@noble/hashes": {
27
27
  "version": "2.3.0",
@@ -48,7 +48,7 @@
48
48
  "hashes": {
49
49
  "browser": "sha256:dfe4b7ae3c9880e388c8da4b68f44742b229b53afacd1e674179527e33da62b0"
50
50
  },
51
- "refreshedAt": "2026-08-20T15:14:48.311Z"
51
+ "refreshedAt": "2026-08-21T09:57:09.089Z"
52
52
  },
53
53
  "@noble/curves": {
54
54
  "version": "2.3.0",
@@ -70,7 +70,7 @@
70
70
  "hashes": {
71
71
  "server": "sha256:b5fe88d1ea780d0581dee6145d666f89d46fc9531b5db35db2e5b16627840890"
72
72
  },
73
- "refreshedAt": "2026-08-20T15:14:48.311Z",
73
+ "refreshedAt": "2026-08-21T09:57:09.089Z",
74
74
  "components": {
75
75
  "@noble/hashes": {
76
76
  "url": "https://github.com/paulmillr/noble-hashes",
@@ -114,7 +114,7 @@
114
114
  "server": "sha256:fab7ebe5737793862c473444f4ee5912f79dd1edec86683acbb4eecbca0f5892",
115
115
  "browser": "sha256:cae1d5bbdc7184b202b6ca68df6e1db7b0d0f668c77809ded189ca7f271accc9"
116
116
  },
117
- "refreshedAt": "2026-08-20T15:14:48.311Z",
117
+ "refreshedAt": "2026-08-21T09:57:09.089Z",
118
118
  "components": {
119
119
  "@noble/hashes": {
120
120
  "url": "https://github.com/paulmillr/noble-hashes",
@@ -148,7 +148,7 @@
148
148
  },
149
149
  "runtime_artifact": "lib/vendor/common-passwords-top-10000.data.js",
150
150
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
151
- "refreshedAt": "2026-08-20T15:14:48.311Z"
151
+ "refreshedAt": "2026-08-21T09:57:09.089Z"
152
152
  },
153
153
  "bimi-trust-anchors": {
154
154
  "version": "operator-managed",
@@ -173,7 +173,7 @@
173
173
  },
174
174
  "runtime_artifact": "lib/vendor/bimi-trust-anchors.data.js",
175
175
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
176
- "refreshedAt": "2026-08-20T15:14:48.311Z"
176
+ "refreshedAt": "2026-08-21T09:57:09.089Z"
177
177
  },
178
178
  "publicsuffix-list": {
179
179
  "version": "master",
@@ -193,10 +193,10 @@
193
193
  },
194
194
  "runtime_artifact": "lib/vendor/public-suffix-list.data.js",
195
195
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
196
- "refreshedAt": "2026-08-20T15:14:48.311Z"
196
+ "refreshedAt": "2026-08-21T09:57:09.089Z"
197
197
  },
198
198
  "@blamejs/pki": {
199
- "version": "0.5.17",
199
+ "version": "0.5.23",
200
200
  "license": "Apache-2.0",
201
201
  "author": "blamejs",
202
202
  "source": "https://github.com/blamejs/pki",
@@ -216,12 +216,12 @@
216
216
  "server": "lib/vendor/blamejs-pki.cjs"
217
217
  },
218
218
  "bundler": "esbuild --format=cjs --platform=node --external:crypto --external:node:crypto",
219
- "bundledAt": "2026-08-20T00:00:00Z",
220
- "cpe": "cpe:2.3:a:blamejs:pki:0.5.17:*:*:*:*:node.js:*:*",
219
+ "bundledAt": "2026-08-21T00:00:00Z",
220
+ "cpe": "cpe:2.3:a:blamejs:pki:0.5.23:*:*:*:*:node.js:*:*",
221
221
  "hashes": {
222
- "server": "sha256:dbedb80e1725747a24fdbd3dec9fcab31b507ac048b923462382b6ac117937a0"
222
+ "server": "sha256:3e365ca7e4cd0a7d80b20690cb8901c31d1a9e841d95f73674f97e011466f7ed"
223
223
  },
224
- "refreshedAt": "2026-08-20T15:14:48.311Z"
224
+ "refreshedAt": "2026-08-21T09:57:09.089Z"
225
225
  }
226
226
  }
227
227
  }