@bymax-one/nest-ai-tokens 1.0.4 → 1.1.1

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/CHANGELOG.md CHANGED
@@ -6,7 +6,59 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
6
6
 
7
7
  ---
8
8
 
9
- ## [Unreleased]
9
+ ## [1.1.1] - 2026-10-23
10
+
11
+ ### Fixed
12
+
13
+ - **A failed expired-hold scan no longer crashes the process.** `HoldReaper` read the
14
+ expired holds outside any `try`, and its interval callback discarded the promise
15
+ `sweep()` returned. A database error during that read — a failover or a dropped
16
+ connection — became an unhandled rejection, which terminates Node in every process that
17
+ loads the module. The scan is now guarded: a failure is logged once under the stable key
18
+ `ai_tokens.hold_reaper.scan_failed` with the error's `name`, `message` and string/number
19
+ `code` only, and the sweep ends; the next tick retries. Every timer-driven sweep also
20
+ carries a `catch` (logged as `ai_tokens.hold_reaper.sweep_failed`), so no rejection from
21
+ `sweep()` can escape the interval. Per-hold isolation, the multi-replica claim, `unref()`
22
+ and shutdown are unchanged. (#108)
23
+
24
+ **Apply to a derived backend:** a consumer that wrapped its store's `findExpiredHolds`
25
+ in a local guard (catching the error and returning `[]`) to survive this can drop that
26
+ guard once it runs 1.1.1. The reaper now absorbs and logs the failure itself.
27
+
28
+ ### Security
29
+
30
+ - Advisories on transitive **dev** packages are cleared through `pnpm-workspace.yaml`
31
+ overrides: handlebars, proxy-addr, multer, undici, @grpc/grpc-js, fast-uri, js-yaml and
32
+ brace-expansion (#109); fast-uri and qs (#87); browserslist and mysql2 (#85);
33
+ deepmerge-ts (#80). osv-scanner reports no issues on the lockfile. None of these reach a
34
+ consumer install: the package has no runtime dependencies.
35
+
36
+ ### Changed
37
+
38
+ - The budget counter-key construction moved to its own module, and its injectivity is
39
+ now asserted in tests: the keys are built from a caller-supplied `budgetId` and a
40
+ caller-chosen timestamp, and the specs fail if a field is reordered or a delimiter
41
+ changes. `COUNTER_DIMENSIONS` is the single runtime list the dimension type derives
42
+ from. No exported type or value changed. (#91)
43
+ - Dev dependencies and CI actions updated (#79, #83, #111, and the pinned-action bumps). The
44
+ mutation gate now runs every spec rather than only Jest's related tests, which had silently
45
+ dropped specs reaching the code through helpers under `test/` (#112).
46
+ - Published artifact: every `.d.ts` is byte-identical to 1.1.0, and so are the `shared`,
47
+ `prisma`, `redis` and `prices` bundles. Only the `server` bundle changed, from 38,204 to
48
+ 38,417 B brotli, inside its 40,000 B budget.
49
+
50
+ ## [1.1.0] - 2026-08-11
51
+
52
+ ### Changed
53
+
54
+ - **BREAKING: `ioredis` peer moved to `^6.0.0`** (was `^5.0.0`; the ranges are disjoint, so an
55
+ ioredis 5 consumer no longer satisfies the contract). It aligns the `./redis` budget counter
56
+ with `@bymax-one/nest-queue@1.2.0`, so a backend that consumes both resolves one shared `ioredis`.
57
+ ioredis 6 defaults to RESP3 but keeps `replyMapping: "legacy"`, leaving reply shapes unchanged at
58
+ runtime; the store's URL-only `new Redis(url)` construction is untouched by the RESP3 options
59
+ typing break, so no source changed.
60
+ - **Mutation gate raised to a perfect score.** `stryker.config.json` now breaks below 100 (`break`,
61
+ `high` and `low` are all 100), so no surviving mutant can pass the gate.
10
62
 
11
63
  ## [1.0.4] - 2026-08-10
12
64
 
@@ -35,9 +87,9 @@ Remediation of a local audit's settlement findings (merged in #65). No API chang
35
87
  ### Fixed
36
88
 
37
89
  - **Every suppression reason was dropped from the mutation report.** All 249 `// Stryker
38
- disable` directives in the source wrote their justification after `--`. Stryker reads a
90
+ disable` directives in the source wrote their justification after `--`. Stryker reads a
39
91
  directive with one regular expression, `/^\s?Stryker (disable|restore)(?: (next-line))?
40
- ([a-zA-Z, ]+)(?::(.+)?)?/`, whose mutator list accepts letters, commas and spaces only
92
+ ([a-zA-Z, ]+)(?::(.+)?)?/`, whose mutator list accepts letters, commas and spaces only
41
93
  and which captures the reason exclusively after a colon. A `-` therefore closed the
42
94
  mutator list and left the reason unmatched, so each directive still silenced its mutant
43
95
  but the report recorded Stryker's fallback text, `Ignored using a comment`. The README
@@ -71,14 +123,14 @@ Remediation of a local audit's settlement findings (merged in #65). No API chang
71
123
  implement `emitDecoratorMetadata` because it does not replicate TypeScript's type
72
124
  system — so the second key was never there. The first two parameters had neither, and
73
125
  the container threw `Nest can't resolve dependencies of the MeteringInterceptor
74
- (?, +, Symbol(BYMAX_AI_TOKENS_OPTIONS))` before any provider was created. Both now
126
+ (?, +, Symbol(BYMAX_AI_TOKENS_OPTIONS))` before any provider was created. Both now
75
127
  carry `@Inject`.
76
128
 
77
129
  ### Changed
78
130
 
79
131
  - `emitDecoratorMetadata` is `false` in `tsconfig.json`. It was `true`, which was never
80
132
  true of the artifact: tsup prints `You have emitDecoratorMetadata enabled but
81
- @swc/core was not installed, skipping swc plugin` on every build, and that warning had
133
+ @swc/core was not installed, skipping swc plugin` on every build, and that warning had
82
134
  been printed on every build of this package. Turning it off makes the source compile
83
135
  the way the bundle is built, so a parameter that depends on reflected types now fails
84
136
  where it is cheap to see.
@@ -139,7 +191,6 @@ have regressed from. They are kept because the reasoning is worth having.
139
191
  - **Zero runtime dependencies** — `"dependencies": {}`. All runtime functionality via peer dependencies.
140
192
  - **100% unit test coverage** — 804 tests; 10-scenario Testcontainers e2e suite; Stryker mutation gate at 100.00% (0 surviving mutants, break 95).
141
193
 
142
-
143
194
  - **Prisma 7 support, alongside Prisma 6.** The declared peer range has always
144
195
  been `>=6.0.0`, but nothing verified the claim; the end-to-end suite now runs
145
196
  against Prisma 7 and a real PostgreSQL, and it is what surfaced the conflict
@@ -155,7 +206,7 @@ have regressed from. They are kept because the reasoning is worth having.
155
206
  - **`pnpm check:exports`** runs `attw --pack . --profile strict` against the packed
156
207
  tarball, which is what surfaced both resolution defects above.
157
208
  - **`pnpm check:runtime`** packs the tarball, lays it out the way npm would, and
158
- asserts in ESM *and* CommonJS that an error thrown by `PrismaAiTokensStore`
209
+ asserts in ESM _and_ CommonJS that an error thrown by `PrismaAiTokensStore`
159
210
  satisfies `instanceof AiTokensException` against the class the root exports. No
160
211
  source-based suite can observe this: the unit tests map the subpath specifiers to
161
212
  `src` and therefore see a single copy. Both gates run in CI.
@@ -215,6 +266,8 @@ have regressed from. They are kept because the reasoning is worth having.
215
266
  [1.0.0]: https://github.com/bymaxone/nest-ai-tokens/releases/tag/v1.0.0
216
267
  [1.0.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.0...v1.0.1
217
268
  [1.0.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.1...v1.0.2
269
+ [1.1.0]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...v1.1.0
270
+ [1.1.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.0...v1.1.1
218
271
  [1.0.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.3...v1.0.4
219
272
  [1.0.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.2...v1.0.3
220
- [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...HEAD
273
+ [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.1...HEAD
package/README.md CHANGED
@@ -715,7 +715,7 @@ that reads a tenant id from request input lets a caller bill someone else.
715
715
  - **Runtime:** Node.js 24+
716
716
  - **Framework:** NestJS 11 (guard + interceptor, `ConfigurableModuleBuilder`, `Symbol()` tokens)
717
717
  - **Persistence:** `@prisma/client ^6 || ^7` (peer) over PostgreSQL — both majors supported
718
- - **Budget acceleration:** `ioredis ^5` (peer), optional
718
+ - **Budget acceleration:** `ioredis ^6` (peer), optional
719
719
  - **Events:** `@nestjs/event-emitter >=2` (peer), optional
720
720
  - **Telemetry:** `@opentelemetry/api ^1.9` (peer), optional
721
721
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
@@ -730,7 +730,7 @@ This library decides what a customer is charged, so the suite is held to a bar b
730
730
  tests pass".
731
731
 
732
732
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
733
- - ✅ **100% mutation score against a `break: 95` gate** — the gate is the floor a run must
733
+ - ✅ **100% mutation score against a `break: 100` gate** — the gate is the floor a run must
734
734
  clear; the score the suite actually reaches is 100%, verified with
735
735
  [Stryker](https://stryker-mutator.io/) ([report](./docs/mutation_testing_results.md))
736
736
  - ✅ **Real PostgreSQL in e2e** — Testcontainers, so the unique constraint that prevents a
@@ -752,7 +752,7 @@ tests pass".
752
752
  pnpm test # unit tests
753
753
  pnpm test:cov # unit tests with the 100% coverage gate
754
754
  pnpm test:e2e # end-to-end against PostgreSQL (requires Docker)
755
- pnpm mutation # Stryker mutation testing (break: 95)
755
+ pnpm mutation # Stryker mutation testing (break: 100)
756
756
  pnpm typecheck # tsc strict check
757
757
  pnpm lint # ESLint
758
758
  pnpm build && pnpm size # bundle-size budgets
@@ -3036,6 +3036,36 @@ function isWalletMissing(error) {
3036
3036
  return error.isWalletMissing === true;
3037
3037
  }
3038
3038
 
3039
+ // src/server/services/budget.keys.ts
3040
+ var COUNTER_GRACE_SECONDS = 3600;
3041
+ var TOTAL_WINDOW_TTL_SECONDS = 60 * 60 * 24 * 400;
3042
+ function windowKey(budgetId, windowStart) {
3043
+ return `${budgetId}|${windowStart.toISOString()}`;
3044
+ }
3045
+ function counterKey(budgetId, windowStart, dimension) {
3046
+ return `ai_tokens:budget:${budgetId}:${windowStart.toISOString()}:${dimension}`;
3047
+ }
3048
+ function counterTtlSeconds(windowStart, windowEnd) {
3049
+ if (windowEnd === null) return TOTAL_WINDOW_TTL_SECONDS;
3050
+ return Math.ceil((windowEnd.getTime() - windowStart.getTime()) / 1e3) + COUNTER_GRACE_SECONDS;
3051
+ }
3052
+ function dimensionSpends(spend) {
3053
+ return { cost: spend.spentNanoUsd, tokens: BigInt(spend.spentTokens), count: BigInt(spend.spentCount) };
3054
+ }
3055
+ function counterDimensions(delta, limits) {
3056
+ const dimensions = [];
3057
+ if (limits.nanoUsd !== void 0 && delta.nanoUsd !== 0n) {
3058
+ dimensions.push({ name: "cost", amount: delta.nanoUsd, limit: limits.nanoUsd });
3059
+ }
3060
+ if (limits.tokens !== void 0 && delta.tokens !== 0) {
3061
+ dimensions.push({ name: "tokens", amount: BigInt(delta.tokens), limit: BigInt(limits.tokens) });
3062
+ }
3063
+ if (limits.count !== void 0 && delta.count !== 0) {
3064
+ dimensions.push({ name: "count", amount: BigInt(delta.count), limit: BigInt(limits.count) });
3065
+ }
3066
+ return dimensions;
3067
+ }
3068
+
3039
3069
  // src/server/services/budget-predicate.ts
3040
3070
  function recordConsumesBudget(record, budget, windowStart, windowEnd) {
3041
3071
  if (!record.enforced) return false;
@@ -3119,8 +3149,6 @@ var NOOP_BUDGET_HOOKS = {
3119
3149
  projectedExceeded: () => void 0,
3120
3150
  audit: () => void 0
3121
3151
  };
3122
- var COUNTER_GRACE_SECONDS = 3600;
3123
- var TOTAL_WINDOW_TTL_SECONDS = 60 * 60 * 24 * 400;
3124
3152
  var UNBOUNDED_COUNTER_LIMIT = 9223372036854775807n;
3125
3153
  exports.BudgetService = class BudgetService {
3126
3154
  /**
@@ -3654,32 +3682,6 @@ function buildStatus(budget, windowStart, resetsAt, spend) {
3654
3682
  usedFraction: usedFraction(spend, limits)
3655
3683
  };
3656
3684
  }
3657
- function windowKey(budgetId, windowStart) {
3658
- return `${budgetId}|${windowStart.toISOString()}`;
3659
- }
3660
- function counterKey(budgetId, windowStart, dimension) {
3661
- return `ai_tokens:budget:${budgetId}:${windowStart.toISOString()}:${dimension}`;
3662
- }
3663
- function counterTtlSeconds(windowStart, windowEnd) {
3664
- if (windowEnd === null) return TOTAL_WINDOW_TTL_SECONDS;
3665
- return Math.ceil((windowEnd.getTime() - windowStart.getTime()) / 1e3) + COUNTER_GRACE_SECONDS;
3666
- }
3667
- function dimensionSpends(spend) {
3668
- return { cost: spend.spentNanoUsd, tokens: BigInt(spend.spentTokens), count: BigInt(spend.spentCount) };
3669
- }
3670
- function counterDimensions(delta, limits) {
3671
- const dimensions = [];
3672
- if (limits.nanoUsd !== void 0 && delta.nanoUsd !== 0n) {
3673
- dimensions.push({ name: "cost", amount: delta.nanoUsd, limit: limits.nanoUsd });
3674
- }
3675
- if (limits.tokens !== void 0 && delta.tokens !== 0) {
3676
- dimensions.push({ name: "tokens", amount: BigInt(delta.tokens), limit: BigInt(limits.tokens) });
3677
- }
3678
- if (limits.count !== void 0 && delta.count !== 0) {
3679
- dimensions.push({ name: "count", amount: BigInt(delta.count), limit: BigInt(limits.count) });
3680
- }
3681
- return dimensions;
3682
- }
3683
3685
  function max0(value) {
3684
3686
  return value < 0n ? 0n : value;
3685
3687
  }
@@ -4078,6 +4080,14 @@ function writeHeader(response, name, value) {
4078
4080
  }
4079
4081
  var REAP_BATCH_SIZE = 100;
4080
4082
  var MS_PER_SECOND = 1e3;
4083
+ var SCAN_FAILED_LOG_KEY = "ai_tokens.hold_reaper.scan_failed";
4084
+ var SWEEP_FAILED_LOG_KEY = "ai_tokens.hold_reaper.sweep_failed";
4085
+ function describeError(error) {
4086
+ if (!(error instanceof Error)) return "non-Error rejection";
4087
+ const { code } = error;
4088
+ const suffix = typeof code === "string" || typeof code === "number" ? ` code=${String(code)}` : "";
4089
+ return `name=${error.name} message=${JSON.stringify(error.message)}${suffix}`;
4090
+ }
4081
4091
  var HoldReaper = class {
4082
4092
  /**
4083
4093
  * @param ledger The ledger service (expired-hold scan + atomic void claim).
@@ -4100,7 +4110,11 @@ var HoldReaper = class {
4100
4110
  /** Start the periodic sweep on module boot (interval `unref()`'d so it never blocks exit). */
4101
4111
  onApplicationBootstrap() {
4102
4112
  const intervalMs = this.options.holds.reaperIntervalSeconds * MS_PER_SECOND;
4103
- this.timer = setInterval(() => void this.sweep(), intervalMs);
4113
+ this.timer = setInterval(() => {
4114
+ void this.sweep().catch((error) => {
4115
+ this.logger.warn(`${SWEEP_FAILED_LOG_KEY} ${describeError(error)}`);
4116
+ });
4117
+ }, intervalMs);
4104
4118
  this.timer.unref();
4105
4119
  }
4106
4120
  /** Stop the sweep on shutdown (no open handles left for Jest). */
@@ -4112,12 +4126,19 @@ var HoldReaper = class {
4112
4126
  * Reclaim one batch of expired pending holds. For each: win the atomic
4113
4127
  * `pending → released` claim (a `null` result means another replica won — skip),
4114
4128
  * then run the shared restoration + `hold.released` emission. A failure on one
4115
- * hold is logged and never aborts the batch (deterministic, idempotent, safe to
4116
- * run repeatedly).
4129
+ * hold is logged and never aborts the batch; a failure of the expired-hold scan
4130
+ * itself is logged and ends this sweep without throwing (deterministic, idempotent,
4131
+ * safe to run repeatedly).
4117
4132
  */
4118
4133
  async sweep() {
4119
4134
  const cutoff = new Date(this.now().getTime() - this.options.holds.ttlSeconds * MS_PER_SECOND);
4120
- const expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4135
+ let expired;
4136
+ try {
4137
+ expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4138
+ } catch (error) {
4139
+ this.logger.warn(`${SCAN_FAILED_LOG_KEY} ${describeError(error)}`);
4140
+ return;
4141
+ }
4121
4142
  for (const record of expired) {
4122
4143
  try {
4123
4144
  const claimed = await this.ledger.transition(record.id, "pending", "released");
@@ -3034,6 +3034,36 @@ function isWalletMissing(error) {
3034
3034
  return error.isWalletMissing === true;
3035
3035
  }
3036
3036
 
3037
+ // src/server/services/budget.keys.ts
3038
+ var COUNTER_GRACE_SECONDS = 3600;
3039
+ var TOTAL_WINDOW_TTL_SECONDS = 60 * 60 * 24 * 400;
3040
+ function windowKey(budgetId, windowStart) {
3041
+ return `${budgetId}|${windowStart.toISOString()}`;
3042
+ }
3043
+ function counterKey(budgetId, windowStart, dimension) {
3044
+ return `ai_tokens:budget:${budgetId}:${windowStart.toISOString()}:${dimension}`;
3045
+ }
3046
+ function counterTtlSeconds(windowStart, windowEnd) {
3047
+ if (windowEnd === null) return TOTAL_WINDOW_TTL_SECONDS;
3048
+ return Math.ceil((windowEnd.getTime() - windowStart.getTime()) / 1e3) + COUNTER_GRACE_SECONDS;
3049
+ }
3050
+ function dimensionSpends(spend) {
3051
+ return { cost: spend.spentNanoUsd, tokens: BigInt(spend.spentTokens), count: BigInt(spend.spentCount) };
3052
+ }
3053
+ function counterDimensions(delta, limits) {
3054
+ const dimensions = [];
3055
+ if (limits.nanoUsd !== void 0 && delta.nanoUsd !== 0n) {
3056
+ dimensions.push({ name: "cost", amount: delta.nanoUsd, limit: limits.nanoUsd });
3057
+ }
3058
+ if (limits.tokens !== void 0 && delta.tokens !== 0) {
3059
+ dimensions.push({ name: "tokens", amount: BigInt(delta.tokens), limit: BigInt(limits.tokens) });
3060
+ }
3061
+ if (limits.count !== void 0 && delta.count !== 0) {
3062
+ dimensions.push({ name: "count", amount: BigInt(delta.count), limit: BigInt(limits.count) });
3063
+ }
3064
+ return dimensions;
3065
+ }
3066
+
3037
3067
  // src/server/services/budget-predicate.ts
3038
3068
  function recordConsumesBudget(record, budget, windowStart, windowEnd) {
3039
3069
  if (!record.enforced) return false;
@@ -3117,8 +3147,6 @@ var NOOP_BUDGET_HOOKS = {
3117
3147
  projectedExceeded: () => void 0,
3118
3148
  audit: () => void 0
3119
3149
  };
3120
- var COUNTER_GRACE_SECONDS = 3600;
3121
- var TOTAL_WINDOW_TTL_SECONDS = 60 * 60 * 24 * 400;
3122
3150
  var UNBOUNDED_COUNTER_LIMIT = 9223372036854775807n;
3123
3151
  var BudgetService = class {
3124
3152
  /**
@@ -3652,32 +3680,6 @@ function buildStatus(budget, windowStart, resetsAt, spend) {
3652
3680
  usedFraction: usedFraction(spend, limits)
3653
3681
  };
3654
3682
  }
3655
- function windowKey(budgetId, windowStart) {
3656
- return `${budgetId}|${windowStart.toISOString()}`;
3657
- }
3658
- function counterKey(budgetId, windowStart, dimension) {
3659
- return `ai_tokens:budget:${budgetId}:${windowStart.toISOString()}:${dimension}`;
3660
- }
3661
- function counterTtlSeconds(windowStart, windowEnd) {
3662
- if (windowEnd === null) return TOTAL_WINDOW_TTL_SECONDS;
3663
- return Math.ceil((windowEnd.getTime() - windowStart.getTime()) / 1e3) + COUNTER_GRACE_SECONDS;
3664
- }
3665
- function dimensionSpends(spend) {
3666
- return { cost: spend.spentNanoUsd, tokens: BigInt(spend.spentTokens), count: BigInt(spend.spentCount) };
3667
- }
3668
- function counterDimensions(delta, limits) {
3669
- const dimensions = [];
3670
- if (limits.nanoUsd !== void 0 && delta.nanoUsd !== 0n) {
3671
- dimensions.push({ name: "cost", amount: delta.nanoUsd, limit: limits.nanoUsd });
3672
- }
3673
- if (limits.tokens !== void 0 && delta.tokens !== 0) {
3674
- dimensions.push({ name: "tokens", amount: BigInt(delta.tokens), limit: BigInt(limits.tokens) });
3675
- }
3676
- if (limits.count !== void 0 && delta.count !== 0) {
3677
- dimensions.push({ name: "count", amount: BigInt(delta.count), limit: BigInt(limits.count) });
3678
- }
3679
- return dimensions;
3680
- }
3681
3683
  function max0(value) {
3682
3684
  return value < 0n ? 0n : value;
3683
3685
  }
@@ -4076,6 +4078,14 @@ function writeHeader(response, name, value) {
4076
4078
  }
4077
4079
  var REAP_BATCH_SIZE = 100;
4078
4080
  var MS_PER_SECOND = 1e3;
4081
+ var SCAN_FAILED_LOG_KEY = "ai_tokens.hold_reaper.scan_failed";
4082
+ var SWEEP_FAILED_LOG_KEY = "ai_tokens.hold_reaper.sweep_failed";
4083
+ function describeError(error) {
4084
+ if (!(error instanceof Error)) return "non-Error rejection";
4085
+ const { code } = error;
4086
+ const suffix = typeof code === "string" || typeof code === "number" ? ` code=${String(code)}` : "";
4087
+ return `name=${error.name} message=${JSON.stringify(error.message)}${suffix}`;
4088
+ }
4079
4089
  var HoldReaper = class {
4080
4090
  /**
4081
4091
  * @param ledger The ledger service (expired-hold scan + atomic void claim).
@@ -4098,7 +4108,11 @@ var HoldReaper = class {
4098
4108
  /** Start the periodic sweep on module boot (interval `unref()`'d so it never blocks exit). */
4099
4109
  onApplicationBootstrap() {
4100
4110
  const intervalMs = this.options.holds.reaperIntervalSeconds * MS_PER_SECOND;
4101
- this.timer = setInterval(() => void this.sweep(), intervalMs);
4111
+ this.timer = setInterval(() => {
4112
+ void this.sweep().catch((error) => {
4113
+ this.logger.warn(`${SWEEP_FAILED_LOG_KEY} ${describeError(error)}`);
4114
+ });
4115
+ }, intervalMs);
4102
4116
  this.timer.unref();
4103
4117
  }
4104
4118
  /** Stop the sweep on shutdown (no open handles left for Jest). */
@@ -4110,12 +4124,19 @@ var HoldReaper = class {
4110
4124
  * Reclaim one batch of expired pending holds. For each: win the atomic
4111
4125
  * `pending → released` claim (a `null` result means another replica won — skip),
4112
4126
  * then run the shared restoration + `hold.released` emission. A failure on one
4113
- * hold is logged and never aborts the batch (deterministic, idempotent, safe to
4114
- * run repeatedly).
4127
+ * hold is logged and never aborts the batch; a failure of the expired-hold scan
4128
+ * itself is logged and ends this sweep without throwing (deterministic, idempotent,
4129
+ * safe to run repeatedly).
4115
4130
  */
4116
4131
  async sweep() {
4117
4132
  const cutoff = new Date(this.now().getTime() - this.options.holds.ttlSeconds * MS_PER_SECOND);
4118
- const expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4133
+ let expired;
4134
+ try {
4135
+ expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4136
+ } catch (error) {
4137
+ this.logger.warn(`${SCAN_FAILED_LOG_KEY} ${describeError(error)}`);
4138
+ return;
4139
+ }
4119
4140
  for (const record of expired) {
4120
4141
  try {
4121
4142
  const claimed = await this.ledger.transition(record.id, "pending", "released");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-ai-tokens",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
4
4
  "description": "AI token metering & usage-based billing for NestJS 11: provider usage normalizer (×9), versioned effective-dated pricing, immutable append-only ledger, prepaid wallets, multi-dimension budgets, and markup/margin. Zero runtime dependencies.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -106,7 +106,7 @@
106
106
  "reflect-metadata": "^0.2.0",
107
107
  "rxjs": "^7.8.0",
108
108
  "@prisma/client": "^6.0.0 || ^7.0.0",
109
- "ioredis": "^5.0.0",
109
+ "ioredis": "^6.0.0",
110
110
  "@nestjs/event-emitter": ">=2.0.0",
111
111
  "@opentelemetry/api": "^1.9.0"
112
112
  },
@@ -158,7 +158,7 @@
158
158
  "eslint": "^9.39.5",
159
159
  "eslint-config-prettier": "^10.1.8",
160
160
  "fast-check": "^4.9.0",
161
- "ioredis": "^5.4.2",
161
+ "ioredis": "^6.0.0",
162
162
  "jest": "^30.4.2",
163
163
  "prettier": "^3.9.6",
164
164
  "prisma": "^7.9.1",