@stimulcross/rate-limiter 0.0.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.
Files changed (112) hide show
  1. package/.editorconfig +21 -0
  2. package/.github/workflows/node.yml +87 -0
  3. package/.husky/commit-msg +1 -0
  4. package/.husky/pre-commit +1 -0
  5. package/.megaignore +8 -0
  6. package/.prettierignore +3 -0
  7. package/LICENSE +21 -0
  8. package/README.md +7 -0
  9. package/commitlint.config.js +8 -0
  10. package/eslint.config.js +65 -0
  11. package/lint-staged.config.js +4 -0
  12. package/package.json +89 -0
  13. package/prettier.config.cjs +1 -0
  14. package/src/core/cancellable.ts +4 -0
  15. package/src/core/clock.ts +9 -0
  16. package/src/core/decision.ts +27 -0
  17. package/src/core/rate-limit-policy.ts +15 -0
  18. package/src/core/rate-limiter-status.ts +14 -0
  19. package/src/core/rate-limiter.ts +37 -0
  20. package/src/core/state-storage.ts +51 -0
  21. package/src/enums/rate-limit-error-code.ts +29 -0
  22. package/src/errors/custom.error.ts +14 -0
  23. package/src/errors/invalid-cost.error.ts +33 -0
  24. package/src/errors/rate-limit.error.ts +91 -0
  25. package/src/errors/rate-limiter-destroyed.error.ts +8 -0
  26. package/src/index.ts +11 -0
  27. package/src/interfaces/rate-limiter-options.ts +84 -0
  28. package/src/interfaces/rate-limiter-queue-options.ts +45 -0
  29. package/src/interfaces/rate-limiter-run-options.ts +58 -0
  30. package/src/limiters/abstract-rate-limiter.ts +206 -0
  31. package/src/limiters/composite.policy.ts +102 -0
  32. package/src/limiters/fixed-window/fixed-window.limiter.ts +121 -0
  33. package/src/limiters/fixed-window/fixed-window.options.ts +29 -0
  34. package/src/limiters/fixed-window/fixed-window.policy.ts +159 -0
  35. package/src/limiters/fixed-window/fixed-window.state.ts +10 -0
  36. package/src/limiters/fixed-window/fixed-window.status.ts +46 -0
  37. package/src/limiters/fixed-window/index.ts +4 -0
  38. package/src/limiters/generic-cell/generic-cell.limiter.ts +108 -0
  39. package/src/limiters/generic-cell/generic-cell.options.ts +23 -0
  40. package/src/limiters/generic-cell/generic-cell.policy.ts +115 -0
  41. package/src/limiters/generic-cell/generic-cell.state.ts +8 -0
  42. package/src/limiters/generic-cell/generic-cell.status.ts +54 -0
  43. package/src/limiters/generic-cell/index.ts +4 -0
  44. package/src/limiters/http-response-based/http-limit-info.extractor.ts +20 -0
  45. package/src/limiters/http-response-based/http-limit.info.ts +41 -0
  46. package/src/limiters/http-response-based/http-response-based-limiter.options.ts +18 -0
  47. package/src/limiters/http-response-based/http-response-based-limiter.state.ts +13 -0
  48. package/src/limiters/http-response-based/http-response-based-limiter.status.ts +74 -0
  49. package/src/limiters/http-response-based/http-response-based.limiter.ts +512 -0
  50. package/src/limiters/http-response-based/index.ts +6 -0
  51. package/src/limiters/leaky-bucket/index.ts +4 -0
  52. package/src/limiters/leaky-bucket/leaky-bucket.limiter.ts +105 -0
  53. package/src/limiters/leaky-bucket/leaky-bucket.options.ts +23 -0
  54. package/src/limiters/leaky-bucket/leaky-bucket.policy.ts +134 -0
  55. package/src/limiters/leaky-bucket/leaky-bucket.state.ts +9 -0
  56. package/src/limiters/leaky-bucket/leaky-bucket.status.ts +36 -0
  57. package/src/limiters/sliding-window-counter/index.ts +7 -0
  58. package/src/limiters/sliding-window-counter/sliding-window-counter.limiter.ts +76 -0
  59. package/src/limiters/sliding-window-counter/sliding-window-counter.options.ts +20 -0
  60. package/src/limiters/sliding-window-counter/sliding-window-counter.policy.ts +167 -0
  61. package/src/limiters/sliding-window-counter/sliding-window-counter.state.ts +10 -0
  62. package/src/limiters/sliding-window-counter/sliding-window-counter.status.ts +53 -0
  63. package/src/limiters/sliding-window-log/index.ts +4 -0
  64. package/src/limiters/sliding-window-log/sliding-window-log.limiter.ts +65 -0
  65. package/src/limiters/sliding-window-log/sliding-window-log.options.ts +20 -0
  66. package/src/limiters/sliding-window-log/sliding-window-log.policy.ts +166 -0
  67. package/src/limiters/sliding-window-log/sliding-window-log.state.ts +19 -0
  68. package/src/limiters/sliding-window-log/sliding-window-log.status.ts +44 -0
  69. package/src/limiters/token-bucket/index.ts +4 -0
  70. package/src/limiters/token-bucket/token-bucket.limiter.ts +110 -0
  71. package/src/limiters/token-bucket/token-bucket.options.ts +17 -0
  72. package/src/limiters/token-bucket/token-bucket.policy.ts +155 -0
  73. package/src/limiters/token-bucket/token-bucket.state.ts +10 -0
  74. package/src/limiters/token-bucket/token-bucket.status.ts +36 -0
  75. package/src/runtime/default-clock.ts +8 -0
  76. package/src/runtime/execution-tickets.ts +34 -0
  77. package/src/runtime/in-memory-state-store.ts +135 -0
  78. package/src/runtime/rate-limiter.executor.ts +286 -0
  79. package/src/runtime/semaphore.ts +31 -0
  80. package/src/runtime/task.ts +141 -0
  81. package/src/types/limit-behavior.ts +8 -0
  82. package/src/utils/generate-random-string.ts +16 -0
  83. package/src/utils/promise-with-resolvers.ts +23 -0
  84. package/src/utils/sanitize-error.ts +4 -0
  85. package/src/utils/sanitize-priority.ts +22 -0
  86. package/src/utils/validate-cost.ts +16 -0
  87. package/tests/integration/limiters/fixed-window.limiter.spec.ts +371 -0
  88. package/tests/integration/limiters/generic-cell.limiter.spec.ts +361 -0
  89. package/tests/integration/limiters/http-response-based.limiter.spec.ts +833 -0
  90. package/tests/integration/limiters/leaky-bucket.spec.ts +357 -0
  91. package/tests/integration/limiters/sliding-window-counter.limiter.spec.ts +175 -0
  92. package/tests/integration/limiters/sliding-window-log.spec.ts +185 -0
  93. package/tests/integration/limiters/token-bucket.limiter.spec.ts +363 -0
  94. package/tests/tsconfig.json +4 -0
  95. package/tests/unit/policies/composite.policy.spec.ts +244 -0
  96. package/tests/unit/policies/fixed-window.policy.spec.ts +260 -0
  97. package/tests/unit/policies/generic-cell.policy.spec.ts +178 -0
  98. package/tests/unit/policies/leaky-bucket.policy.spec.ts +215 -0
  99. package/tests/unit/policies/sliding-window-counter.policy.spec.ts +209 -0
  100. package/tests/unit/policies/sliding-window-log.policy.spec.ts +285 -0
  101. package/tests/unit/policies/token-bucket.policy.spec.ts +371 -0
  102. package/tests/unit/runtime/execution-tickets.spec.ts +121 -0
  103. package/tests/unit/runtime/in-memory-state-store.spec.ts +238 -0
  104. package/tests/unit/runtime/rate-limiter.executor.spec.ts +353 -0
  105. package/tests/unit/runtime/semaphore.spec.ts +98 -0
  106. package/tests/unit/runtime/task.spec.ts +182 -0
  107. package/tests/unit/utils/generate-random-string.spec.ts +51 -0
  108. package/tests/unit/utils/promise-with-resolvers.spec.ts +57 -0
  109. package/tests/unit/utils/sanitize-priority.spec.ts +46 -0
  110. package/tests/unit/utils/validate-cost.spec.ts +48 -0
  111. package/tsconfig.json +14 -0
  112. package/vitest.config.js +22 -0
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { InvalidCostError } from '../../../src/index.js';
3
+ import { SlidingWindowCounterPolicy } from '../../../src/limiters/sliding-window-counter/sliding-window-counter.policy.js';
4
+
5
+ describe('SlidingWindowCounterPolicy', () => {
6
+ describe('Constructor and getters', () => {
7
+ it('should initialize with valid parameters', () => {
8
+ const policy = new SlidingWindowCounterPolicy(10, 1000);
9
+ expect(policy.limit).toBe(10);
10
+ expect(policy.windowMs).toBe(1000);
11
+ });
12
+
13
+ it('should throw an error for invalid limit', () => {
14
+ expect(() => new SlidingWindowCounterPolicy(-5, 1000)).toThrow(/Invalid limit/u);
15
+ expect(() => new SlidingWindowCounterPolicy(1.5, 1000)).toThrow(/Invalid limit/u);
16
+ expect(() => new SlidingWindowCounterPolicy(Infinity, 1000)).toThrow(/Invalid limit/u);
17
+ });
18
+
19
+ it('should throw an error for invalid windowMs', () => {
20
+ expect(() => new SlidingWindowCounterPolicy(10, 0)).toThrow(/Invalid windowMs/u);
21
+ expect(() => new SlidingWindowCounterPolicy(10, -100)).toThrow(/Invalid windowMs/u);
22
+ expect(() => new SlidingWindowCounterPolicy(10, 100.5)).toThrow(/Invalid windowMs/u);
23
+ expect(() => new SlidingWindowCounterPolicy(10, Number.NaN)).toThrow(/Invalid windowMs/u);
24
+ });
25
+
26
+ it('should return correct initial state', () => {
27
+ const policy = new SlidingWindowCounterPolicy(5, 1000);
28
+ expect(policy.getInitialState()).toEqual({ windowStart: 0, currentCount: 0, previousCount: 0 });
29
+ });
30
+ });
31
+
32
+ describe('Get Status', () => {
33
+ const policy = new SlidingWindowCounterPolicy(10, 1000);
34
+
35
+ it('should return correct status for empty state', () => {
36
+ const state = policy.getInitialState();
37
+ const status = policy.getStatus(state, 500);
38
+
39
+ expect(status.limit).toBe(10);
40
+ expect(status.windowMs).toBe(1000);
41
+ expect(status.estimatedCount).toBe(0);
42
+ expect(status.remaining).toBe(10);
43
+ expect(status.nextAvailableAt).toBe(500);
44
+ expect(status.resetAt).toBe(500);
45
+ });
46
+
47
+ it('should correctly approximate count based on time into window', () => {
48
+ const state = { windowStart: 0, previousCount: 10, currentCount: 2 };
49
+ const status = policy.getStatus(state, 500);
50
+
51
+ expect(status.estimatedCount).toBe(7);
52
+ expect(status.remaining).toBe(3);
53
+ expect(status.resetAt).toBe(2000);
54
+ });
55
+
56
+ it('should calculate correct nextAvailableAt within the current window', () => {
57
+ const state = { windowStart: 0, previousCount: 10, currentCount: 0 };
58
+ const status = policy.getStatus(state, 100);
59
+
60
+ expect(status.estimatedCount).toBe(9);
61
+ expect(status.nextAvailableAt).toBe(100);
62
+ expect(status.resetAt).toBe(1000);
63
+ });
64
+ });
65
+
66
+ describe('Evaluate', () => {
67
+ describe('Immediate execution (Same Window)', () => {
68
+ const policy = new SlidingWindowCounterPolicy(5, 1000);
69
+
70
+ it('should allow request when within limit', () => {
71
+ const state = policy.getInitialState();
72
+ const result = policy.evaluate(state, 500, 1);
73
+
74
+ expect(result.decision).toEqual({ kind: 'allow' });
75
+ expect(result.nextState).toEqual({ windowStart: 0, previousCount: 0, currentCount: 1 });
76
+ });
77
+
78
+ it('should allow request with cost > 1 if within limit', () => {
79
+ const state = policy.getInitialState();
80
+ const result = policy.evaluate(state, 500, 3);
81
+
82
+ expect(result.decision).toEqual({ kind: 'allow' });
83
+ expect(result.nextState.currentCount).toBe(3);
84
+ });
85
+
86
+ it('should deny request when limit is exceeded', () => {
87
+ const state = { windowStart: 0, currentCount: 5, previousCount: 0 };
88
+ const result = policy.evaluate(state, 500, 1);
89
+
90
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1001 });
91
+ expect(result.nextState).toEqual(state);
92
+
93
+ const result2 = policy.evaluate(state, 1001, 1);
94
+
95
+ expect(result2.decision).toEqual({ kind: 'allow' });
96
+ });
97
+
98
+ it('should deny request if cost itself exceeds limit', () => {
99
+ const state = policy.getInitialState();
100
+ expect(() => policy.evaluate(state, 500, 6)).toThrow(InvalidCostError);
101
+ });
102
+ });
103
+
104
+ describe('Window Transitions and Approximation', () => {
105
+ const policy = new SlidingWindowCounterPolicy(10, 1000);
106
+
107
+ it('should shift current window to previous when exactly one window passes', () => {
108
+ const state = { windowStart: 0, currentCount: 10, previousCount: 0 };
109
+ const result = policy.evaluate(state, 1000, 1);
110
+
111
+ expect(result.decision.kind).toBe('deny');
112
+ expect(result.nextState).toEqual({ windowStart: 1000, currentCount: 0, previousCount: 10 });
113
+ });
114
+
115
+ it('should approximate weight correctly based on time into current window', () => {
116
+ const state = { windowStart: 0, currentCount: 10, previousCount: 0 };
117
+ const allowedResult = policy.evaluate(state, 1250, 3);
118
+
119
+ expect(allowedResult.decision.kind).toBe('allow');
120
+ expect(allowedResult.nextState).toEqual({ windowStart: 1000, currentCount: 3, previousCount: 10 });
121
+
122
+ const deniedResult = policy.evaluate(state, 1250, 4);
123
+ expect(deniedResult.decision.kind).toBe('deny');
124
+ });
125
+
126
+ it('should clear both counters if two or more windows have passed', () => {
127
+ const state = { windowStart: 0, currentCount: 10, previousCount: 5 };
128
+
129
+ const result = policy.evaluate(state, 2500, 5);
130
+
131
+ expect(result.decision.kind).toBe('allow');
132
+ expect(result.nextState).toEqual({ windowStart: 2000, currentCount: 5, previousCount: 0 });
133
+ });
134
+
135
+ it('should rotate window state even when a request is denied', () => {
136
+ const state = { windowStart: 0, currentCount: 10, previousCount: 0 };
137
+ const result = policy.evaluate(state, 1100, 2);
138
+
139
+ expect(result.decision.kind).toBe('deny');
140
+ expect(result.nextState).toEqual({ windowStart: 1000, currentCount: 0, previousCount: 10 });
141
+ });
142
+
143
+ it('should deny and calculate retryAt accurately within current window', () => {
144
+ const state = { windowStart: 0, previousCount: 10, currentCount: 0 };
145
+ const result = policy.evaluate(state, 100, 4);
146
+
147
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 301 });
148
+ expect(result.nextState).toEqual(state);
149
+ });
150
+
151
+ it('should deny and calculate retryAt accurately into the next window', () => {
152
+ const state = { windowStart: 0, previousCount: 5, currentCount: 6 };
153
+ const result = policy.evaluate(state, 500, 5);
154
+
155
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1001 });
156
+ });
157
+ });
158
+ });
159
+
160
+ describe('Revert', () => {
161
+ const policy = new SlidingWindowCounterPolicy(10, 1000);
162
+
163
+ describe('Within the same window', () => {
164
+ it('should return unmodified state if cost is zero or negative', () => {
165
+ const state = { windowStart: 1000, currentCount: 5, previousCount: 2 };
166
+ const nextState = policy.revert(state, 0, 1500);
167
+
168
+ expect(nextState.currentCount).toBe(5);
169
+ });
170
+
171
+ it('should revert cost from currentCount only', () => {
172
+ const state = { windowStart: 1000, currentCount: 5, previousCount: 2 };
173
+ const nextState = policy.revert(state, 3, 1500);
174
+
175
+ expect(nextState).toEqual({ windowStart: 1000, currentCount: 2, previousCount: 2 });
176
+ });
177
+
178
+ it('should not drop currentCount below zero when reverting', () => {
179
+ const state = { windowStart: 1000, currentCount: 2, previousCount: 5 };
180
+ const nextState = policy.revert(state, 5, 1500);
181
+
182
+ expect(nextState).toEqual({ windowStart: 1000, currentCount: 0, previousCount: 5 });
183
+ });
184
+ });
185
+
186
+ describe('With time sync (window transitions)', () => {
187
+ it('should sync time and shift window before reverting', () => {
188
+ const state = { windowStart: 0, currentCount: 10, previousCount: 0 };
189
+ const nextState = policy.revert(state, 2, 1500);
190
+
191
+ expect(nextState).toEqual({ windowStart: 1000, currentCount: 0, previousCount: 10 });
192
+ });
193
+
194
+ it('should sync time and clear state if multiple windows passed before reverting', () => {
195
+ const state = { windowStart: 0, currentCount: 10, previousCount: 5 };
196
+ const nextState = policy.revert(state, 5, 2500);
197
+
198
+ expect(nextState).toEqual({ windowStart: 2000, currentCount: 0, previousCount: 0 });
199
+ });
200
+
201
+ it('should never mutate previousCount during revert even after sync', () => {
202
+ const state = { windowStart: 0, currentCount: 5, previousCount: 10 };
203
+ const nextState = policy.revert(state, 5, 1500);
204
+
205
+ expect(nextState).toEqual({ windowStart: 1000, currentCount: 0, previousCount: 5 });
206
+ });
207
+ });
208
+ });
209
+ });
@@ -0,0 +1,285 @@
1
+ import { Deque } from '@stimulcross/ds-deque';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { InvalidCostError } from '../../../src/index.js';
4
+ import {
5
+ type SlidingWindowLogEntry,
6
+ type SlidingWindowLogState,
7
+ } from '../../../src/limiters/sliding-window-log/index.js';
8
+ import { SlidingWindowLogPolicy } from '../../../src/limiters/sliding-window-log/sliding-window-log.policy.js';
9
+
10
+ function buildState(entries: SlidingWindowLogEntry[]): SlidingWindowLogState {
11
+ const logs = new Deque<SlidingWindowLogEntry>();
12
+ let totalUsed = 0;
13
+
14
+ for (const entry of entries) {
15
+ logs.push({ ...entry });
16
+ totalUsed += entry.count;
17
+ }
18
+
19
+ return { logs, totalUsed };
20
+ }
21
+
22
+ describe('SlidingWindowLogPolicy', () => {
23
+ describe('Constructor and getters', () => {
24
+ it('should initialize with valid parameters', () => {
25
+ const policy = new SlidingWindowLogPolicy(10, 1000);
26
+ expect(policy.limit).toBe(10);
27
+ expect(policy.windowMs).toBe(1000);
28
+ });
29
+
30
+ it('should throw an error for invalid limit', () => {
31
+ expect(() => new SlidingWindowLogPolicy(0, 1000)).toThrow(/Invalid limit/u);
32
+ expect(() => new SlidingWindowLogPolicy(-5, 1000)).toThrow(/Invalid limit/u);
33
+ expect(() => new SlidingWindowLogPolicy(1.5, 1000)).toThrow(/Invalid limit/u);
34
+ expect(() => new SlidingWindowLogPolicy(Infinity, 1000)).toThrow(/Invalid limit/u);
35
+ });
36
+
37
+ it('should throw an error for invalid windowMs', () => {
38
+ expect(() => new SlidingWindowLogPolicy(10, 0)).toThrow(/Invalid windowMs/u);
39
+ expect(() => new SlidingWindowLogPolicy(10, -100)).toThrow(/Invalid windowMs/u);
40
+ expect(() => new SlidingWindowLogPolicy(10, 100.5)).toThrow(/Invalid windowMs/u);
41
+ expect(() => new SlidingWindowLogPolicy(10, Number.NaN)).toThrow(/Invalid windowMs/u);
42
+ });
43
+
44
+ it('should return correct initial state', () => {
45
+ const policy = new SlidingWindowLogPolicy(5, 1000);
46
+ const state = policy.getInitialState();
47
+
48
+ expect(state.totalUsed).toBe(0);
49
+ expect(state.logs).toBeInstanceOf(Deque);
50
+ expect(state.logs.isEmpty).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe('Get Status', () => {
55
+ const policy = new SlidingWindowLogPolicy(5, 1000);
56
+
57
+ it('should return correct status for empty state', () => {
58
+ const state = policy.getInitialState();
59
+ const status = policy.getStatus(state, 500);
60
+
61
+ expect(status.limit).toBe(5);
62
+ expect(status.windowMs).toBe(1000);
63
+ expect(status.totalUsed).toBe(0);
64
+ expect(status.remaining).toBe(5);
65
+ expect(status.nextAvailableAt).toBe(500);
66
+ expect(status.resetAt).toBe(500);
67
+ });
68
+
69
+ it('should clear expired logs during getStatus and return accurate remaining capacity', () => {
70
+ const state = buildState([
71
+ { ts: 100, count: 2 },
72
+ { ts: 500, count: 1 },
73
+ ]);
74
+ const status = policy.getStatus(state, 1200);
75
+
76
+ expect(status.totalUsed).toBe(1);
77
+ expect(status.remaining).toBe(4);
78
+ expect(status.resetAt).toBe(1500);
79
+ });
80
+
81
+ it('should calculate precise nextAvailableAt when queue is full', () => {
82
+ const state = buildState([
83
+ { ts: 100, count: 2 },
84
+ { ts: 500, count: 3 },
85
+ ]);
86
+
87
+ const status = policy.getStatus(state, 600);
88
+
89
+ expect(status.remaining).toBe(0);
90
+ expect(status.nextAvailableAt).toBe(1100);
91
+ expect(status.resetAt).toBe(1500);
92
+ });
93
+ });
94
+
95
+ describe('Evaluate', () => {
96
+ describe('Validation and Edge Cases', () => {
97
+ const policy = new SlidingWindowLogPolicy(5, 1000);
98
+
99
+ it('should throw if cost itself exceeds limit', () => {
100
+ const state = policy.getInitialState();
101
+ expect(() => policy.evaluate(state, 500, 6)).toThrow(InvalidCostError);
102
+ });
103
+ });
104
+
105
+ describe('Immediate execution', () => {
106
+ const policy = new SlidingWindowLogPolicy(2, 1000);
107
+
108
+ it('should allow request when within limit', () => {
109
+ const state = policy.getInitialState();
110
+ const result = policy.evaluate(state, 500, 1);
111
+
112
+ expect(result.decision).toEqual({ kind: 'allow' });
113
+ expect(result.nextState.totalUsed).toBe(1);
114
+ expect(result.nextState.logs.size).toBe(1);
115
+ expect(result.nextState.logs.peekTail()).toEqual({ ts: 500, count: 1 });
116
+ });
117
+
118
+ it('should allow request with cost > 1 if within limit', () => {
119
+ const state = policy.getInitialState();
120
+ const result = policy.evaluate(state, 500, 2);
121
+
122
+ expect(result.decision).toEqual({ kind: 'allow' });
123
+ expect(result.nextState.totalUsed).toBe(2);
124
+ expect(result.nextState.logs.peekTail()?.count).toBe(2);
125
+ });
126
+
127
+ it('should deny request and return precise retryAt when limit is exceeded', () => {
128
+ const state = buildState([{ ts: 100, count: 2 }]);
129
+ const result = policy.evaluate(state, 500, 1);
130
+
131
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1100 });
132
+ expect(result.nextState.totalUsed).toBe(2);
133
+ });
134
+ });
135
+
136
+ describe('Log grouping and aggregation', () => {
137
+ const policy = new SlidingWindowLogPolicy(5, 1000);
138
+
139
+ it('should mutate existing log entry if timestamp exactly matches now', () => {
140
+ const state = buildState([{ ts: 500, count: 1 }]);
141
+ const result = policy.evaluate(state, 500, 2);
142
+
143
+ expect(result.decision.kind).toBe('allow');
144
+ expect(result.nextState.logs.size).toBe(1);
145
+ expect(result.nextState.logs.peekTail()).toEqual({ ts: 500, count: 3 });
146
+ expect(result.nextState.totalUsed).toBe(3);
147
+ });
148
+
149
+ it('should append a new log entry if timestamp differs', () => {
150
+ const state = buildState([{ ts: 500, count: 1 }]);
151
+ const result = policy.evaluate(state, 501, 2);
152
+
153
+ expect(result.nextState.logs.size).toBe(2);
154
+ expect(result.nextState.logs.peekTail()).toEqual({ ts: 501, count: 2 });
155
+ expect(result.nextState.totalUsed).toBe(3);
156
+ });
157
+ });
158
+
159
+ describe('Eviction and Exact retryAt Calculation', () => {
160
+ const policy = new SlidingWindowLogPolicy(5, 1000);
161
+
162
+ it('should evict expired logs from the front of the queue', () => {
163
+ const state = buildState([
164
+ { ts: 100, count: 2 },
165
+ { ts: 500, count: 1 },
166
+ ]);
167
+
168
+ const result = policy.evaluate(state, 1200, 1);
169
+
170
+ expect(result.decision.kind).toBe('allow');
171
+ expect(result.nextState.logs.size).toBe(2);
172
+ expect(result.nextState.logs.peekHead()).toEqual({ ts: 500, count: 1 });
173
+ expect(result.nextState.totalUsed).toBe(2);
174
+ });
175
+
176
+ it('should evaluate retryAt correctly by summing multiple logs if necessary', () => {
177
+ const state = buildState([
178
+ { ts: 100, count: 1 },
179
+ { ts: 200, count: 2 },
180
+ { ts: 500, count: 2 },
181
+ ]);
182
+
183
+ const result = policy.evaluate(state, 600, 3);
184
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1200 });
185
+ });
186
+ });
187
+ });
188
+
189
+ describe('Revert', () => {
190
+ const policy = new SlidingWindowLogPolicy(10, 1000);
191
+
192
+ describe('Within the same window', () => {
193
+ it('should return unmodified state if cost is zero or negative', () => {
194
+ const state = buildState([{ ts: 500, count: 3 }]);
195
+ const nextState = policy.revert(state, 0, 1000);
196
+
197
+ expect(nextState.totalUsed).toBe(3);
198
+ expect(nextState.logs.size).toBe(1);
199
+ });
200
+
201
+ it('should return unmodified state if totalUsed is zero', () => {
202
+ const state = policy.getInitialState();
203
+ const nextState = policy.revert(state, 5, 1000);
204
+
205
+ expect(nextState.totalUsed).toBe(0);
206
+ expect(nextState.logs.isEmpty).toBe(true);
207
+ });
208
+
209
+ it('should revert exact cost by decrementing the tail log count', () => {
210
+ const state = buildState([
211
+ { ts: 100, count: 2 },
212
+ { ts: 200, count: 3 },
213
+ ]);
214
+
215
+ const nextState = policy.revert(state, 2, 1000);
216
+
217
+ expect(nextState.totalUsed).toBe(3);
218
+ expect(nextState.logs.size).toBe(2);
219
+ expect(nextState.logs.peekTail()).toEqual({ ts: 200, count: 1 });
220
+ });
221
+
222
+ it('should remove the tail log entirely if revert cost equals its count', () => {
223
+ const state = buildState([
224
+ { ts: 100, count: 2 },
225
+ { ts: 200, count: 3 },
226
+ ]);
227
+
228
+ const nextState = policy.revert(state, 3, 1000);
229
+
230
+ expect(nextState.totalUsed).toBe(2);
231
+ expect(nextState.logs.size).toBe(1);
232
+ expect(nextState.logs.peekTail()).toEqual({ ts: 100, count: 2 });
233
+ });
234
+
235
+ it('should remove multiple newer entries if cost spans across them', () => {
236
+ const state = buildState([
237
+ { ts: 100, count: 2 },
238
+ { ts: 200, count: 3 },
239
+ { ts: 300, count: 1 },
240
+ ]);
241
+
242
+ const nextState = policy.revert(state, 5, 1000);
243
+
244
+ expect(nextState.totalUsed).toBe(1);
245
+ expect(nextState.logs.size).toBe(1);
246
+ expect(nextState.logs.peekHead()).toEqual({ ts: 100, count: 1 });
247
+ });
248
+
249
+ it('should not drop totalUsed below zero when reverting excessively large costs', () => {
250
+ const state = buildState([{ ts: 100, count: 2 }]);
251
+ const nextState = policy.revert(state, 10, 1000);
252
+
253
+ expect(nextState.totalUsed).toBe(0);
254
+ expect(nextState.logs.isEmpty).toBe(true);
255
+ });
256
+ });
257
+
258
+ describe('With time sync (window transitions)', () => {
259
+ it('should sync time and remove expired logs before reverting', () => {
260
+ const state = buildState([
261
+ { ts: 100, count: 2 },
262
+ { ts: 500, count: 3 },
263
+ ]);
264
+
265
+ const nextState = policy.revert(state, 1, 1200);
266
+
267
+ expect(nextState.totalUsed).toBe(2);
268
+ expect(nextState.logs.size).toBe(1);
269
+ expect(nextState.logs.peekHead()).toEqual({ ts: 500, count: 2 });
270
+ });
271
+
272
+ it('should handle revert safely if all logs expired before revert', () => {
273
+ const state = buildState([
274
+ { ts: 100, count: 2 },
275
+ { ts: 200, count: 3 },
276
+ ]);
277
+
278
+ const nextState = policy.revert(state, 2, 1500);
279
+
280
+ expect(nextState.totalUsed).toBe(0);
281
+ expect(nextState.logs.isEmpty).toBe(true);
282
+ });
283
+ });
284
+ });
285
+ });