valkyries 0.2.0 → 0.4.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 (64) hide show
  1. package/dist/bloom-filter/rebuilds.d.mts.map +1 -1
  2. package/dist/bloom-filter/rebuilds.mjs +36 -33
  3. package/dist/bloom-filter/rebuilds.mjs.map +1 -1
  4. package/dist/cache/batch-read.d.mts.map +1 -1
  5. package/dist/cache/batch-read.mjs +32 -6
  6. package/dist/cache/batch-read.mjs.map +1 -1
  7. package/dist/cache/core.d.mts +21 -1
  8. package/dist/cache/core.d.mts.map +1 -1
  9. package/dist/cache/core.mjs +33 -5
  10. package/dist/cache/core.mjs.map +1 -1
  11. package/dist/cache/multi-batch-read.d.mts +24 -0
  12. package/dist/cache/multi-batch-read.d.mts.map +1 -0
  13. package/dist/cache/multi-batch-read.mjs +66 -0
  14. package/dist/cache/multi-batch-read.mjs.map +1 -0
  15. package/dist/cache/mutations.d.mts.map +1 -1
  16. package/dist/cache/mutations.mjs +16 -9
  17. package/dist/cache/mutations.mjs.map +1 -1
  18. package/dist/cache.d.mts +1 -0
  19. package/dist/cache.d.mts.map +1 -1
  20. package/dist/cache.mjs +1 -0
  21. package/dist/cache.mjs.map +1 -1
  22. package/dist/clients.d.mts +1 -0
  23. package/dist/clients.d.mts.map +1 -1
  24. package/dist/clients.mjs +1 -1
  25. package/dist/clients.mjs.map +1 -1
  26. package/dist/config.d.mts +2 -0
  27. package/dist/config.d.mts.map +1 -1
  28. package/dist/config.mjs +8 -0
  29. package/dist/config.mjs.map +1 -1
  30. package/dist/dynamic-config/storage.d.mts +12 -6
  31. package/dist/dynamic-config/storage.d.mts.map +1 -1
  32. package/dist/dynamic-config/storage.mjs +22 -20
  33. package/dist/dynamic-config/storage.mjs.map +1 -1
  34. package/dist/dynamic-config.d.mts +1 -0
  35. package/dist/dynamic-config.d.mts.map +1 -1
  36. package/dist/dynamic-config.mjs +14 -4
  37. package/dist/dynamic-config.mjs.map +1 -1
  38. package/dist/idempotency-key.d.mts +29 -0
  39. package/dist/idempotency-key.d.mts.map +1 -0
  40. package/dist/idempotency-key.mjs +119 -0
  41. package/dist/idempotency-key.mjs.map +1 -0
  42. package/dist/index.d.mts +1 -0
  43. package/dist/index.d.mts.map +1 -1
  44. package/dist/index.mjs +1 -0
  45. package/dist/index.mjs.map +1 -1
  46. package/dist/rate-limiter.d.mts +4 -1
  47. package/dist/rate-limiter.d.mts.map +1 -1
  48. package/dist/rate-limiter.mjs +60 -28
  49. package/dist/rate-limiter.mjs.map +1 -1
  50. package/dist/retry.d.mts +31 -0
  51. package/dist/retry.d.mts.map +1 -1
  52. package/dist/retry.mjs +32 -2
  53. package/dist/retry.mjs.map +1 -1
  54. package/dist/scripts/idempotency-key-complete-if-current.lua +26 -0
  55. package/dist/scripts/idempotency-key-release-if-current.lua +10 -0
  56. package/dist/scripts/idempotency-key-reserve.lua +28 -0
  57. package/dist/scripts/rate-limiter-add-and-check-windows.lua +28 -8
  58. package/dist/types.d.mts +33 -1
  59. package/dist/types.d.mts.map +1 -1
  60. package/docs/api.md +4 -0
  61. package/docs/idempotency-key.md +102 -0
  62. package/docs/lua-scripts.md +7 -0
  63. package/docs/rate-limiter.md +58 -2
  64. 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:
@@ -99,6 +99,8 @@ Records and checks heterogeneous windows in one Lua script call.
99
99
  - `limited` is `true` when any post-add count is greater than or equal to its window threshold.
100
100
  - `mode: "record-all"` records every window and is the default.
101
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.
102
104
  - All windows must share the same effective hash tag: `hashTag ?? id`.
103
105
  - Window prefixes must not contain Redis hash tag braces, and generated Valkey keys must be unique.
104
106
  - Unexpected Valkey response types fail open with zero counts.
@@ -109,6 +111,60 @@ Example:
109
111
  const { counts, limited } = await limiter.addAndCheck(["ip:127.0.0.1"], 10);
110
112
  ```
111
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
+
112
168
  ## `isRateLimited(ids, threshold, ttlSeconds?)`
113
169
 
114
170
  ```ts
@@ -148,7 +204,7 @@ limiter.getKey(key: string): string
148
204
  Returns the Valkey sorted-set key for an ID:
149
205
 
150
206
  ```txt
151
- rate-limiter:{prefix}:{id}
207
+ rate-limiter:<prefix>:{<id>}
152
208
  ```
153
209
 
154
210
  ## `invalidate()`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valkyries",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",