valkyries 0.1.7 → 0.3.0

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 (48) hide show
  1. package/dist/bloom-filter/batching.d.mts +4 -0
  2. package/dist/bloom-filter/batching.d.mts.map +1 -1
  3. package/dist/bloom-filter/batching.mjs +5 -0
  4. package/dist/bloom-filter/batching.mjs.map +1 -1
  5. package/dist/bloom-filter/rebuilds.d.mts.map +1 -1
  6. package/dist/bloom-filter/rebuilds.mjs +4 -6
  7. package/dist/bloom-filter/rebuilds.mjs.map +1 -1
  8. package/dist/bloom-filter/types.d.mts +1 -0
  9. package/dist/bloom-filter/types.d.mts.map +1 -1
  10. package/dist/bloom-filter/writes.d.mts.map +1 -1
  11. package/dist/bloom-filter/writes.mjs +13 -14
  12. package/dist/bloom-filter/writes.mjs.map +1 -1
  13. package/dist/bloom-filter.d.mts +2 -0
  14. package/dist/bloom-filter.d.mts.map +1 -1
  15. package/dist/bloom-filter.mjs +6 -1
  16. package/dist/bloom-filter.mjs.map +1 -1
  17. package/dist/cache/batch-read.d.mts.map +1 -1
  18. package/dist/cache/batch-read.mjs +32 -6
  19. package/dist/cache/batch-read.mjs.map +1 -1
  20. package/dist/cache/core.d.mts +2 -1
  21. package/dist/cache/core.d.mts.map +1 -1
  22. package/dist/cache/core.mjs +5 -1
  23. package/dist/cache/core.mjs.map +1 -1
  24. package/dist/idempotency-key.d.mts +29 -0
  25. package/dist/idempotency-key.d.mts.map +1 -0
  26. package/dist/idempotency-key.mjs +119 -0
  27. package/dist/idempotency-key.mjs.map +1 -0
  28. package/dist/index.d.mts +2 -1
  29. package/dist/index.d.mts.map +1 -1
  30. package/dist/index.mjs +1 -0
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/rate-limiter.d.mts +6 -1
  33. package/dist/rate-limiter.d.mts.map +1 -1
  34. package/dist/rate-limiter.mjs +105 -0
  35. package/dist/rate-limiter.mjs.map +1 -1
  36. package/dist/scripts/idempotency-key-complete-if-current.lua +26 -0
  37. package/dist/scripts/idempotency-key-release-if-current.lua +10 -0
  38. package/dist/scripts/idempotency-key-reserve.lua +28 -0
  39. package/dist/scripts/rate-limiter-add-and-check-windows.lua +60 -0
  40. package/dist/types.d.mts +24 -0
  41. package/dist/types.d.mts.map +1 -1
  42. package/docs/api.md +4 -0
  43. package/docs/bloom-filters.md +3 -1
  44. package/docs/cache.md +2 -0
  45. package/docs/idempotency-key.md +102 -0
  46. package/docs/lua-scripts.md +7 -0
  47. package/docs/rate-limiter.md +82 -2
  48. package/package.json +7 -2
@@ -0,0 +1,102 @@
1
+ # Idempotency Keys
2
+
3
+ Generic Valkey primitives for consume-once values and token-fenced idempotency keys.
4
+
5
+ ## `getAndDelete(key, options?)`
6
+
7
+ ```ts
8
+ getAndDelete(key: string, options?: { client?: GlideClient }): Promise<string | null>
9
+ ```
10
+
11
+ Atomically reads and deletes a key using native `GETDEL`.
12
+
13
+ - Returns the stored string value.
14
+ - Returns `null` when the key does not exist.
15
+ - Throws Valkey/client errors.
16
+ - Requires Valkey support for `GETDEL`.
17
+
18
+ ## `reserveIdempotencyKey(key, ttlSeconds, options?)`
19
+
20
+ ```ts
21
+ reserveIdempotencyKey(
22
+ key: string,
23
+ ttlSeconds: number,
24
+ options?: {
25
+ client?: GlideClient;
26
+ processingPrefix?: string;
27
+ completedValue?: string;
28
+ token?: string;
29
+ },
30
+ ): Promise<
31
+ | { state: "reserved"; token: string }
32
+ | { state: "processing" | "completed" }
33
+ >
34
+ ```
35
+
36
+ Atomically reserves a key when it is absent by storing `processing:<token>` with `EX ttlSeconds`.
37
+
38
+ - Generates a random token by default.
39
+ - Returns `processing` when another reservation is active.
40
+ - Returns `completed` when the dedup key has already completed.
41
+ - The caller owns key naming and Redis Cluster hash tags.
42
+
43
+ ## `completeIdempotencyKey(key, token, ttlSeconds, options?)`
44
+
45
+ ```ts
46
+ completeIdempotencyKey(
47
+ key: string,
48
+ token: string,
49
+ ttlSeconds: number,
50
+ options?: IdempotencyKeyOptions,
51
+ ): Promise<"completed" | "missing" | "changed">
52
+ ```
53
+
54
+ Atomically changes `processing:<token>` to `completed` with `EX ttlSeconds`.
55
+
56
+ - `completed`: the caller still owned the reservation and completion was recorded.
57
+ - `missing`: the key was absent.
58
+ - `changed`: the key existed but did not match the caller's token.
59
+
60
+ ## `releaseIdempotencyKey(key, token, options?)`
61
+
62
+ ```ts
63
+ releaseIdempotencyKey(
64
+ key: string,
65
+ token: string,
66
+ options?: IdempotencyKeyOptions,
67
+ ): Promise<boolean>
68
+ ```
69
+
70
+ Atomically deletes the key only when it still contains `processing:<token>`.
71
+
72
+ - Returns `true` when released.
73
+ - Returns `false` when the key is missing or owned by another token.
74
+
75
+ ## Example
76
+
77
+ ```ts
78
+ const key = `webhook:{${event.id}}`;
79
+ const reservation = await reserveIdempotencyKey(key, 60);
80
+
81
+ if (reservation.state === "completed") return;
82
+ if (reservation.state === "processing") throw new Error("event is already processing");
83
+
84
+ try {
85
+ await processEvent(event);
86
+ await completeIdempotencyKey(key, reservation.token, 24 * 60 * 60);
87
+ } catch (error) {
88
+ await releaseIdempotencyKey(key, reservation.token);
89
+ throw error;
90
+ }
91
+ ```
92
+
93
+ ## Validation
94
+
95
+ - `key`, `token`, `processingPrefix`, and `completedValue` must be non-empty.
96
+ - `ttlSeconds` must be greater than `0`.
97
+ - `processingPrefix` must not equal `completedValue`.
98
+ - `processingPrefix` and `completedValue` must not equal script result sentinels:
99
+ `reserved`, `missing`, or `changed`.
100
+ - `completedValue` must not be inside the processing namespace
101
+ (`${processingPrefix}:...`).
102
+ - Valkey/client errors throw instead of failing open.
@@ -45,8 +45,15 @@ Dynamic config scripts:
45
45
 
46
46
  - `dynamic-config-set-fields.lua`
47
47
 
48
+ Idempotency key scripts:
49
+
50
+ - `idempotency-key-complete-if-current.lua`
51
+ - `idempotency-key-release-if-current.lua`
52
+ - `idempotency-key-reserve.lua`
53
+
48
54
  Rate limiter scripts:
49
55
 
50
56
  - `rate-limiter-add.lua`
51
57
  - `rate-limiter-add-and-check.lua`
58
+ - `rate-limiter-add-and-check-windows.lua`
52
59
  - `rate-limiter-get.lua`
@@ -5,7 +5,7 @@
5
5
  Key format:
6
6
 
7
7
  ```txt
8
- rate-limiter:{prefix}:{id}
8
+ rate-limiter:<prefix>:{<id>}
9
9
  ```
10
10
 
11
11
  Use `addAndCheck(ids, threshold)` for request paths. It atomically adds an event, removes expired events, counts the current window, and returns:
@@ -16,6 +16,8 @@ Use `addAndCheck(ids, threshold)` for request paths. It atomically adds an event
16
16
 
17
17
  A threshold of `N` blocks the Nth request because the count is checked after incrementing.
18
18
 
19
+ Use `RateLimiter.addAndCheckWindows(windows)` when one logical request must be checked against multiple TTL/threshold windows in one Valkey round trip. All windows in that call must share one Redis Cluster hash tag.
20
+
19
21
  ## Import
20
22
 
21
23
  ```ts
@@ -79,12 +81,90 @@ Atomically records events and returns the current counts.
79
81
  - `ttlSeconds` overrides the instance TTL for this check.
80
82
  - Unexpected Valkey response types fail open with zero counts.
81
83
 
84
+ ## `RateLimiter.addAndCheckWindows(windows, options?)`
85
+
86
+ ```ts
87
+ RateLimiter.addAndCheckWindows(
88
+ [
89
+ { prefix: "web-risk-minute", id: "global", hashTag: "2026-06", ttlSeconds: 60, threshold: 5001 },
90
+ { prefix: "web-risk-month", id: "global", hashTag: "2026-06", ttlSeconds: 31 * 24 * 60 * 60, threshold: 90001 },
91
+ ],
92
+ { mode: "stop-on-limited" },
93
+ ): Promise<{ counts: number[]; limited: boolean }>
94
+ ```
95
+
96
+ Records and checks heterogeneous windows in one Lua script call.
97
+
98
+ - `counts` aligns with `windows`.
99
+ - `limited` is `true` when any post-add count is greater than or equal to its window threshold.
100
+ - `mode: "record-all"` records every window and is the default.
101
+ - `mode: "stop-on-limited"` stops recording later windows after an earlier window is limited.
102
+ - `skipWriteWhenLimited: true` on a window trims expired entries and checks the current count before writing. If the window is already at or above its threshold, that window returns its current count and does not add another sorted-set member.
103
+ - The request that reaches the threshold is still recorded and returns the post-add count.
104
+ - All windows must share the same effective hash tag: `hashTag ?? id`.
105
+ - Window prefixes must not contain Redis hash tag braces, and generated Valkey keys must be unique.
106
+ - Unexpected Valkey response types fail open with zero counts.
107
+
82
108
  Example:
83
109
 
84
110
  ```ts
85
111
  const { counts, limited } = await limiter.addAndCheck(["ip:127.0.0.1"], 10);
86
112
  ```
87
113
 
114
+ Scarce provider quota windows can avoid member churn after a long-window quota is capped:
115
+
116
+ ```ts
117
+ const month = "2026-06";
118
+
119
+ const result = await RateLimiter.addAndCheckWindows(
120
+ [
121
+ {
122
+ prefix: "web-risk-minute",
123
+ id: "global",
124
+ hashTag: month,
125
+ ttlSeconds: 60,
126
+ threshold: 5001,
127
+ },
128
+ {
129
+ prefix: "web-risk-month",
130
+ id: "",
131
+ hashTag: month,
132
+ ttlSeconds: 31 * 24 * 60 * 60,
133
+ threshold: 90001,
134
+ skipWriteWhenLimited: true,
135
+ },
136
+ ],
137
+ { mode: "stop-on-limited" },
138
+ );
139
+ ```
140
+
141
+ ## `RateLimiter.getWindowKey(window)`
142
+
143
+ ```ts
144
+ RateLimiter.getWindowKey(window: RateLimiterWindow): string
145
+ ```
146
+
147
+ Returns the Valkey sorted-set key for a multi-window definition. Use this to assert Redis Cluster compatibility and stable quota bucket shapes without duplicating key logic.
148
+
149
+ ```ts
150
+ RateLimiter.getWindowKey({
151
+ prefix: "web-risk-month",
152
+ id: "",
153
+ hashTag: "2026-06",
154
+ ttlSeconds: 31 * 24 * 60 * 60,
155
+ threshold: 90001,
156
+ });
157
+ // "rate-limiter:web-risk-month:{2026-06}"
158
+ ```
159
+
160
+ Key shapes:
161
+
162
+ ```txt
163
+ rate-limiter:<prefix>:{<id>}
164
+ rate-limiter:<prefix>:{<hashTag>}:<id>
165
+ rate-limiter:<prefix>:{<hashTag>}
166
+ ```
167
+
88
168
  ## `isRateLimited(ids, threshold, ttlSeconds?)`
89
169
 
90
170
  ```ts
@@ -124,7 +204,7 @@ limiter.getKey(key: string): string
124
204
  Returns the Valkey sorted-set key for an ID:
125
205
 
126
206
  ```txt
127
- rate-limiter:{prefix}:{id}
207
+ rate-limiter:<prefix>:{<id>}
128
208
  ```
129
209
 
130
210
  ## `invalidate()`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valkyries",
3
- "version": "0.1.7",
3
+ "version": "0.3.0",
4
4
  "description": "Valkey utilities for caches, bloom filters, dynamic configuration, and rate limiting.",
5
5
  "homepage": "https://github.com/jonathanong/valkyries#readme",
6
6
  "bugs": {
@@ -42,6 +42,11 @@
42
42
  "import": "./dist/dynamic-config.mjs",
43
43
  "default": "./dist/dynamic-config.mjs"
44
44
  },
45
+ "./idempotency-key": {
46
+ "types": "./dist/idempotency-key.d.mts",
47
+ "import": "./dist/idempotency-key.mjs",
48
+ "default": "./dist/idempotency-key.mjs"
49
+ },
45
50
  "./rate-limiter": {
46
51
  "types": "./dist/rate-limiter.d.mts",
47
52
  "import": "./dist/rate-limiter.mjs",
@@ -55,7 +60,7 @@
55
60
  "@types/node": "^25.8.0",
56
61
  "@vitest/coverage-v8": "^4.1.6",
57
62
  "github-actionlint": "^1.7.12",
58
- "oxfmt": "^0.51.0",
63
+ "oxfmt": "^0.52.0",
59
64
  "oxlint": "^1.65.0",
60
65
  "oxlint-tsgolint": "^0.23.0",
61
66
  "tsx": "^4.22.1",