@bymax-one/nest-ai-tokens 1.1.0 → 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,6 +6,47 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
6
6
 
7
7
  ---
8
8
 
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
+
9
50
  ## [1.1.0] - 2026-08-11
10
51
 
11
52
  ### Changed
@@ -226,6 +267,7 @@ have regressed from. They are kept because the reasoning is worth having.
226
267
  [1.0.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.0...v1.0.1
227
268
  [1.0.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.1...v1.0.2
228
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
229
271
  [1.0.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.3...v1.0.4
230
272
  [1.0.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.2...v1.0.3
231
- [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
@@ -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.1.0",
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",