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.
- package/dist/bloom-filter/rebuilds.d.mts.map +1 -1
- package/dist/bloom-filter/rebuilds.mjs +36 -33
- package/dist/bloom-filter/rebuilds.mjs.map +1 -1
- package/dist/cache/batch-read.d.mts.map +1 -1
- package/dist/cache/batch-read.mjs +32 -6
- package/dist/cache/batch-read.mjs.map +1 -1
- package/dist/cache/core.d.mts +21 -1
- package/dist/cache/core.d.mts.map +1 -1
- package/dist/cache/core.mjs +33 -5
- package/dist/cache/core.mjs.map +1 -1
- package/dist/cache/multi-batch-read.d.mts +24 -0
- package/dist/cache/multi-batch-read.d.mts.map +1 -0
- package/dist/cache/multi-batch-read.mjs +66 -0
- package/dist/cache/multi-batch-read.mjs.map +1 -0
- package/dist/cache/mutations.d.mts.map +1 -1
- package/dist/cache/mutations.mjs +16 -9
- package/dist/cache/mutations.mjs.map +1 -1
- package/dist/cache.d.mts +1 -0
- package/dist/cache.d.mts.map +1 -1
- package/dist/cache.mjs +1 -0
- package/dist/cache.mjs.map +1 -1
- package/dist/clients.d.mts +1 -0
- package/dist/clients.d.mts.map +1 -1
- package/dist/clients.mjs +1 -1
- package/dist/clients.mjs.map +1 -1
- package/dist/config.d.mts +2 -0
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +8 -0
- package/dist/config.mjs.map +1 -1
- package/dist/dynamic-config/storage.d.mts +12 -6
- package/dist/dynamic-config/storage.d.mts.map +1 -1
- package/dist/dynamic-config/storage.mjs +22 -20
- package/dist/dynamic-config/storage.mjs.map +1 -1
- package/dist/dynamic-config.d.mts +1 -0
- package/dist/dynamic-config.d.mts.map +1 -1
- package/dist/dynamic-config.mjs +14 -4
- package/dist/dynamic-config.mjs.map +1 -1
- package/dist/idempotency-key.d.mts +29 -0
- package/dist/idempotency-key.d.mts.map +1 -0
- package/dist/idempotency-key.mjs +119 -0
- package/dist/idempotency-key.mjs.map +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -1
- package/dist/rate-limiter.d.mts +4 -1
- package/dist/rate-limiter.d.mts.map +1 -1
- package/dist/rate-limiter.mjs +60 -28
- package/dist/rate-limiter.mjs.map +1 -1
- package/dist/retry.d.mts +31 -0
- package/dist/retry.d.mts.map +1 -1
- package/dist/retry.mjs +32 -2
- package/dist/retry.mjs.map +1 -1
- package/dist/scripts/idempotency-key-complete-if-current.lua +26 -0
- package/dist/scripts/idempotency-key-release-if-current.lua +10 -0
- package/dist/scripts/idempotency-key-reserve.lua +28 -0
- package/dist/scripts/rate-limiter-add-and-check-windows.lua +28 -8
- package/dist/types.d.mts +33 -1
- package/dist/types.d.mts.map +1 -1
- package/docs/api.md +4 -0
- package/docs/idempotency-key.md +102 -0
- package/docs/lua-scripts.md +7 -0
- package/docs/rate-limiter.md +58 -2
- 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.
|
package/docs/lua-scripts.md
CHANGED
|
@@ -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`
|
package/docs/rate-limiter.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Key format:
|
|
6
6
|
|
|
7
7
|
```txt
|
|
8
|
-
rate-limiter
|
|
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
|
|
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.
|
|
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.
|
|
63
|
+
"oxfmt": "^0.52.0",
|
|
59
64
|
"oxlint": "^1.65.0",
|
|
60
65
|
"oxlint-tsgolint": "^0.23.0",
|
|
61
66
|
"tsx": "^4.22.1",
|