ingenium-redis 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.
- package/README.md +209 -0
- package/dist/index.cjs +233 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +176 -0
- package/dist/index.d.ts +176 -0
- package/dist/index.js +228 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/client.ts +37 -0
- package/src/idempotency.ts +81 -0
- package/src/index.ts +53 -0
- package/src/queue.ts +240 -0
- package/src/rate-limit.ts +68 -0
- package/src/session.ts +52 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { SessionStore, IdempotencyStore, CachedResponse, RateLimitStore, QueueStore } from 'ingenium';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Redis-client surface the stores rely on.
|
|
5
|
+
*
|
|
6
|
+
* Intentionally duck-typed against node-redis v4+ (`@redis/client`) so users
|
|
7
|
+
* can pass their existing `createClient()` instance directly without an extra
|
|
8
|
+
* type adapter. ioredis users can shim it in a few lines if they prefer that
|
|
9
|
+
* client.
|
|
10
|
+
*
|
|
11
|
+
* The surface is intentionally tiny — only the commands the three stores
|
|
12
|
+
* actually call. Adding methods here means tightening what a custom client
|
|
13
|
+
* must implement, so resist.
|
|
14
|
+
*/
|
|
15
|
+
interface RedisSetOptions {
|
|
16
|
+
/** Set TTL in seconds. Mutually exclusive with `PX`. */
|
|
17
|
+
EX?: number;
|
|
18
|
+
/** Set TTL in milliseconds. Mutually exclusive with `EX`. */
|
|
19
|
+
PX?: number;
|
|
20
|
+
/** Only set if the key does not already exist. */
|
|
21
|
+
NX?: boolean;
|
|
22
|
+
}
|
|
23
|
+
interface RedisClientLike {
|
|
24
|
+
get(key: string): Promise<string | null>;
|
|
25
|
+
set(key: string, value: string, options?: RedisSetOptions): Promise<string | null>;
|
|
26
|
+
del(key: string | readonly string[]): Promise<number>;
|
|
27
|
+
expire(key: string, seconds: number): Promise<boolean | number>;
|
|
28
|
+
/**
|
|
29
|
+
* Run a Lua script server-side. node-redis v4 uses the `{ keys, arguments }`
|
|
30
|
+
* options bag — ioredis uses positional args, so ioredis adopters need to
|
|
31
|
+
* shim this method.
|
|
32
|
+
*/
|
|
33
|
+
eval(script: string, options: {
|
|
34
|
+
keys: readonly string[];
|
|
35
|
+
arguments: readonly string[];
|
|
36
|
+
}): Promise<unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RedisSessionStoreOptions {
|
|
40
|
+
/** Connected Redis client. Caller owns lifecycle. */
|
|
41
|
+
client: RedisClientLike;
|
|
42
|
+
/** Key prefix for every entry. Default `'ingenium:sess:'`. */
|
|
43
|
+
prefix?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Redis-backed {@link SessionStore}. JSON-encodes session data and uses
|
|
47
|
+
* `SET ... EX` so Redis owns TTL expiry — no sweeper, no clock drift between
|
|
48
|
+
* the cookie expiry and the stored value.
|
|
49
|
+
*
|
|
50
|
+
* The store does NOT manage the client connection. Create + `.connect()` your
|
|
51
|
+
* `createClient(...)` before constructing the store; close it during your
|
|
52
|
+
* graceful shutdown hook.
|
|
53
|
+
*/
|
|
54
|
+
declare class RedisSessionStore implements SessionStore {
|
|
55
|
+
private readonly client;
|
|
56
|
+
private readonly prefix;
|
|
57
|
+
constructor(opts: RedisSessionStoreOptions);
|
|
58
|
+
get(id: string): Promise<Record<string, unknown> | null>;
|
|
59
|
+
set(id: string, data: Record<string, unknown>, ttlSeconds: number): Promise<void>;
|
|
60
|
+
destroy(id: string): Promise<void>;
|
|
61
|
+
touch(id: string, ttlSeconds: number): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface RedisIdempotencyStoreOptions {
|
|
65
|
+
/** Connected Redis client. Caller owns lifecycle. */
|
|
66
|
+
client: RedisClientLike;
|
|
67
|
+
/** Key prefix for every entry. Default `'ingenium:idem:'`. */
|
|
68
|
+
prefix?: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Redis-backed {@link IdempotencyStore}. Cached responses are JSON-serialized
|
|
72
|
+
* (with Buffer bodies base64-encoded) and stored with `SET ... PX` so Redis
|
|
73
|
+
* owns expiry. Suitable for multi-replica deployments where the replica that
|
|
74
|
+
* served the original request may not be the one handling the retry.
|
|
75
|
+
*/
|
|
76
|
+
declare class RedisIdempotencyStore implements IdempotencyStore {
|
|
77
|
+
private readonly client;
|
|
78
|
+
private readonly prefix;
|
|
79
|
+
constructor(opts: RedisIdempotencyStoreOptions);
|
|
80
|
+
get(key: string): Promise<CachedResponse | null>;
|
|
81
|
+
set(key: string, value: CachedResponse, ttlMs: number): Promise<void>;
|
|
82
|
+
delete(key: string): Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface RedisRateLimitStoreOptions {
|
|
86
|
+
/** Connected Redis client. Caller owns lifecycle. */
|
|
87
|
+
client: RedisClientLike;
|
|
88
|
+
/** Key prefix for every entry. Default `'ingenium:rl:'`. */
|
|
89
|
+
prefix?: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Redis-backed {@link RateLimitStore}. Uses a single Lua call per hit so the
|
|
93
|
+
* INCR + PEXPIRE + PTTL trio is atomic — no race where two replicas both see
|
|
94
|
+
* `count == 1` and each set their own expiry, and no race where the counter
|
|
95
|
+
* exists without a TTL because the expire happened in a separate round-trip
|
|
96
|
+
* that lost.
|
|
97
|
+
*
|
|
98
|
+
* `resetAt` is computed from `PTTL` on the server side, so the value is
|
|
99
|
+
* consistent across replicas even if their clocks drift. We add `Date.now()`
|
|
100
|
+
* locally only to produce the absolute timestamp the rest of Ingenium
|
|
101
|
+
* works with; a small clock skew there affects header reporting only, not
|
|
102
|
+
* the actual rate-limit decision (which Redis owns).
|
|
103
|
+
*/
|
|
104
|
+
declare class RedisRateLimitStore implements RateLimitStore {
|
|
105
|
+
private readonly client;
|
|
106
|
+
private readonly prefix;
|
|
107
|
+
constructor(opts: RedisRateLimitStoreOptions);
|
|
108
|
+
hit(key: string, windowMs: number): Promise<{
|
|
109
|
+
count: number;
|
|
110
|
+
resetAt: number;
|
|
111
|
+
}>;
|
|
112
|
+
reset(key: string): Promise<void>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface RedisQueueStoreOptions {
|
|
116
|
+
/** Connected Redis client. Caller owns lifecycle. */
|
|
117
|
+
client: RedisClientLike;
|
|
118
|
+
/**
|
|
119
|
+
* Key prefix for every structure owned by this queue instance. Default
|
|
120
|
+
* `'ingenium:queue:'`. Two queues that must not share work need distinct
|
|
121
|
+
* prefixes (e.g. `'ingenium:queue:emails:'`).
|
|
122
|
+
*/
|
|
123
|
+
prefix?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Clock used to stamp ready-times (ZSET scores). Defaults to `Date.now`.
|
|
126
|
+
* Overridable so tests can drive virtual time; production code never sets it.
|
|
127
|
+
*/
|
|
128
|
+
now?: () => number;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Redis-backed {@link QueueStore}. A FIFO job queue with delayed retries and a
|
|
132
|
+
* dead-letter list, sharing state across replicas so any worker on any pod can
|
|
133
|
+
* pick up any job.
|
|
134
|
+
*
|
|
135
|
+
* Atomicity: every operation that touches more than one key (`next`, `retry`,
|
|
136
|
+
* `fail`, and—for uniformity and to keep {@link RedisClientLike} tiny—`enqueue`,
|
|
137
|
+
* `ack`, `size`, `failedCount`) runs as a single Lua `EVAL`. This is what keeps
|
|
138
|
+
* the client surface unchanged: we never need MULTI/WATCH or any command beyond
|
|
139
|
+
* `eval`. Redis runs each script to completion atomically, so concurrent workers
|
|
140
|
+
* can't double-deliver a job or lose a payload between the ZREM and the
|
|
141
|
+
* in-flight SADD.
|
|
142
|
+
*
|
|
143
|
+
* Delivery guarantee: at-least-once. A job that is `next()`-ed but whose worker
|
|
144
|
+
* crashes before `ack`/`retry`/`fail` stays in the `inflight` set and in the
|
|
145
|
+
* `jobs` hash, but NOT in `pending` — it will not be re-delivered automatically
|
|
146
|
+
* by this store (there is no visibility-timeout sweeper). That matches
|
|
147
|
+
* {@link MemoryQueueStore}, which also leaves crashed jobs stuck in its
|
|
148
|
+
* in-flight map. Add a reaper that re-enqueues stale inflight ids if you need
|
|
149
|
+
* crash recovery; the data model (inflight SET + jobs HASH) supports it.
|
|
150
|
+
*
|
|
151
|
+
* `size()` returns the pending count INCLUDING delayed (not-yet-ready) jobs,
|
|
152
|
+
* mirroring `MemoryQueueStore.size()` which returns `pending.length`. Delayed
|
|
153
|
+
* jobs live in the same ZSET with a future score, so `ZCARD` counts them.
|
|
154
|
+
*/
|
|
155
|
+
declare class RedisQueueStore<TData> implements QueueStore<TData> {
|
|
156
|
+
private readonly client;
|
|
157
|
+
private readonly now;
|
|
158
|
+
/** KEYS passed to every script, in fixed order: pending, jobs, inflight, failed, seq. */
|
|
159
|
+
private readonly keys;
|
|
160
|
+
constructor(opts: RedisQueueStoreOptions);
|
|
161
|
+
enqueue(data: TData): Promise<{
|
|
162
|
+
id: string;
|
|
163
|
+
}>;
|
|
164
|
+
next(): Promise<{
|
|
165
|
+
id: string;
|
|
166
|
+
data: TData;
|
|
167
|
+
attempt: number;
|
|
168
|
+
} | null>;
|
|
169
|
+
ack(id: string): Promise<void>;
|
|
170
|
+
retry(id: string, delayMs: number): Promise<void>;
|
|
171
|
+
fail(id: string): Promise<void>;
|
|
172
|
+
size(): Promise<number>;
|
|
173
|
+
failedCount(): Promise<number>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { type RedisClientLike, RedisIdempotencyStore, type RedisIdempotencyStoreOptions, RedisQueueStore, type RedisQueueStoreOptions, RedisRateLimitStore, type RedisRateLimitStoreOptions, RedisSessionStore, type RedisSessionStoreOptions, type RedisSetOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
|
|
3
|
+
// src/session.ts
|
|
4
|
+
var RedisSessionStore = class {
|
|
5
|
+
client;
|
|
6
|
+
prefix;
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
this.client = opts.client;
|
|
9
|
+
this.prefix = opts.prefix ?? "ingenium:sess:";
|
|
10
|
+
}
|
|
11
|
+
async get(id) {
|
|
12
|
+
const raw = await this.client.get(this.prefix + id);
|
|
13
|
+
if (raw === null) return null;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
17
|
+
return parsed;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async set(id, data, ttlSeconds) {
|
|
23
|
+
await this.client.set(this.prefix + id, JSON.stringify(data), { EX: ttlSeconds });
|
|
24
|
+
}
|
|
25
|
+
async destroy(id) {
|
|
26
|
+
await this.client.del(this.prefix + id);
|
|
27
|
+
}
|
|
28
|
+
async touch(id, ttlSeconds) {
|
|
29
|
+
await this.client.expire(this.prefix + id, ttlSeconds);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
function encode(value) {
|
|
33
|
+
let body = null;
|
|
34
|
+
if (Buffer.isBuffer(value.body)) {
|
|
35
|
+
body = { t: "b", v: value.body.toString("base64") };
|
|
36
|
+
} else if (typeof value.body === "string") {
|
|
37
|
+
body = { t: "s", v: value.body };
|
|
38
|
+
}
|
|
39
|
+
const env = { s: value.statusCode, h: value.headers, b: body };
|
|
40
|
+
return JSON.stringify(env);
|
|
41
|
+
}
|
|
42
|
+
function decode(raw) {
|
|
43
|
+
let env;
|
|
44
|
+
try {
|
|
45
|
+
env = JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (env === null || typeof env !== "object") return null;
|
|
50
|
+
let body = null;
|
|
51
|
+
if (env.b !== null) {
|
|
52
|
+
body = env.b.t === "b" ? Buffer.from(env.b.v, "base64") : env.b.v;
|
|
53
|
+
}
|
|
54
|
+
return { statusCode: env.s, headers: env.h, body };
|
|
55
|
+
}
|
|
56
|
+
var RedisIdempotencyStore = class {
|
|
57
|
+
client;
|
|
58
|
+
prefix;
|
|
59
|
+
constructor(opts) {
|
|
60
|
+
this.client = opts.client;
|
|
61
|
+
this.prefix = opts.prefix ?? "ingenium:idem:";
|
|
62
|
+
}
|
|
63
|
+
async get(key) {
|
|
64
|
+
const raw = await this.client.get(this.prefix + key);
|
|
65
|
+
if (raw === null) return null;
|
|
66
|
+
return decode(raw);
|
|
67
|
+
}
|
|
68
|
+
async set(key, value, ttlMs) {
|
|
69
|
+
await this.client.set(this.prefix + key, encode(value), { PX: ttlMs });
|
|
70
|
+
}
|
|
71
|
+
async delete(key) {
|
|
72
|
+
await this.client.del(this.prefix + key);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/rate-limit.ts
|
|
77
|
+
var HIT_SCRIPT = `-- INGENIUM_RATELIMIT_HIT v1
|
|
78
|
+
local current = redis.call('INCR', KEYS[1])
|
|
79
|
+
if current == 1 then
|
|
80
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
81
|
+
end
|
|
82
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
83
|
+
return {current, ttl}`;
|
|
84
|
+
var RedisRateLimitStore = class {
|
|
85
|
+
client;
|
|
86
|
+
prefix;
|
|
87
|
+
constructor(opts) {
|
|
88
|
+
this.client = opts.client;
|
|
89
|
+
this.prefix = opts.prefix ?? "ingenium:rl:";
|
|
90
|
+
}
|
|
91
|
+
async hit(key, windowMs) {
|
|
92
|
+
const result = await this.client.eval(HIT_SCRIPT, {
|
|
93
|
+
keys: [this.prefix + key],
|
|
94
|
+
arguments: [String(windowMs)]
|
|
95
|
+
});
|
|
96
|
+
if (!Array.isArray(result) || result.length !== 2) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`RedisRateLimitStore: unexpected EVAL result shape: ${JSON.stringify(result)}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const [count, ttlMs] = result;
|
|
102
|
+
return { count, resetAt: Date.now() + Math.max(0, ttlMs) };
|
|
103
|
+
}
|
|
104
|
+
async reset(key) {
|
|
105
|
+
await this.client.del(this.prefix + key);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// src/queue.ts
|
|
110
|
+
var ENQUEUE_SCRIPT = `-- INGENIUM_QUEUE_ENQUEUE v1
|
|
111
|
+
local id = tostring(redis.call('INCR', KEYS[5]))
|
|
112
|
+
redis.call('HSET', KEYS[2], id, ARGV[2])
|
|
113
|
+
redis.call('HSET', KEYS[2], id .. ':a', '1')
|
|
114
|
+
redis.call('ZADD', KEYS[1], ARGV[1], id)
|
|
115
|
+
return id`;
|
|
116
|
+
var NEXT_SCRIPT = `-- INGENIUM_QUEUE_NEXT v1
|
|
117
|
+
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)
|
|
118
|
+
if not ids[1] then
|
|
119
|
+
return nil
|
|
120
|
+
end
|
|
121
|
+
local id = ids[1]
|
|
122
|
+
redis.call('ZREM', KEYS[1], id)
|
|
123
|
+
redis.call('SADD', KEYS[3], id)
|
|
124
|
+
local payload = redis.call('HGET', KEYS[2], id)
|
|
125
|
+
local attempt = redis.call('HGET', KEYS[2], id .. ':a')
|
|
126
|
+
return {id, payload, attempt}`;
|
|
127
|
+
var ACK_SCRIPT = `-- INGENIUM_QUEUE_ACK v1
|
|
128
|
+
redis.call('SREM', KEYS[3], ARGV[1])
|
|
129
|
+
redis.call('HDEL', KEYS[2], ARGV[1], ARGV[1] .. ':a')
|
|
130
|
+
return 1`;
|
|
131
|
+
var RETRY_SCRIPT = `-- INGENIUM_QUEUE_RETRY v1
|
|
132
|
+
if redis.call('SREM', KEYS[3], ARGV[1]) == 0 then
|
|
133
|
+
return 0
|
|
134
|
+
end
|
|
135
|
+
if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 0 then
|
|
136
|
+
return 0
|
|
137
|
+
end
|
|
138
|
+
redis.call('HINCRBY', KEYS[2], ARGV[1] .. ':a', 1)
|
|
139
|
+
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])
|
|
140
|
+
return 1`;
|
|
141
|
+
var FAIL_SCRIPT = `-- INGENIUM_QUEUE_FAIL v1
|
|
142
|
+
if redis.call('SREM', KEYS[3], ARGV[1]) == 0 then
|
|
143
|
+
return 0
|
|
144
|
+
end
|
|
145
|
+
local payload = redis.call('HGET', KEYS[2], ARGV[1])
|
|
146
|
+
if payload then
|
|
147
|
+
local attempt = redis.call('HGET', KEYS[2], ARGV[1] .. ':a') or '1'
|
|
148
|
+
local envelope = '{"id":"' .. ARGV[1] .. '","d":' .. payload .. ',"a":' .. attempt .. '}'
|
|
149
|
+
redis.call('RPUSH', KEYS[4], envelope)
|
|
150
|
+
redis.call('HDEL', KEYS[2], ARGV[1], ARGV[1] .. ':a')
|
|
151
|
+
end
|
|
152
|
+
return 1`;
|
|
153
|
+
var SIZE_SCRIPT = `-- INGENIUM_QUEUE_SIZE v1
|
|
154
|
+
return redis.call('ZCARD', KEYS[1])`;
|
|
155
|
+
var FAILED_COUNT_SCRIPT = `-- INGENIUM_QUEUE_FAILED_COUNT v1
|
|
156
|
+
return redis.call('LLEN', KEYS[4])`;
|
|
157
|
+
var RedisQueueStore = class {
|
|
158
|
+
client;
|
|
159
|
+
now;
|
|
160
|
+
/** KEYS passed to every script, in fixed order: pending, jobs, inflight, failed, seq. */
|
|
161
|
+
keys;
|
|
162
|
+
constructor(opts) {
|
|
163
|
+
this.client = opts.client;
|
|
164
|
+
this.now = opts.now ?? Date.now;
|
|
165
|
+
const prefix = opts.prefix ?? "ingenium:queue:";
|
|
166
|
+
this.keys = [
|
|
167
|
+
prefix + "pending",
|
|
168
|
+
prefix + "jobs",
|
|
169
|
+
prefix + "inflight",
|
|
170
|
+
prefix + "failed",
|
|
171
|
+
prefix + "seq"
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
async enqueue(data) {
|
|
175
|
+
const id = await this.client.eval(ENQUEUE_SCRIPT, {
|
|
176
|
+
keys: this.keys,
|
|
177
|
+
arguments: [String(this.now()), JSON.stringify(data)]
|
|
178
|
+
});
|
|
179
|
+
return { id: String(id) };
|
|
180
|
+
}
|
|
181
|
+
async next() {
|
|
182
|
+
const result = await this.client.eval(NEXT_SCRIPT, {
|
|
183
|
+
keys: this.keys,
|
|
184
|
+
arguments: [String(this.now())]
|
|
185
|
+
});
|
|
186
|
+
if (result == null || !Array.isArray(result)) return null;
|
|
187
|
+
const [id, payload, attempt] = result;
|
|
188
|
+
if (id == null || payload == null) return null;
|
|
189
|
+
let data;
|
|
190
|
+
try {
|
|
191
|
+
data = JSON.parse(payload);
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return { id: String(id), data, attempt: Number(attempt) || 1 };
|
|
196
|
+
}
|
|
197
|
+
async ack(id) {
|
|
198
|
+
await this.client.eval(ACK_SCRIPT, { keys: this.keys, arguments: [id] });
|
|
199
|
+
}
|
|
200
|
+
async retry(id, delayMs) {
|
|
201
|
+
const readyAt = this.now() + Math.max(0, delayMs);
|
|
202
|
+
await this.client.eval(RETRY_SCRIPT, {
|
|
203
|
+
keys: this.keys,
|
|
204
|
+
arguments: [id, String(readyAt)]
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async fail(id) {
|
|
208
|
+
await this.client.eval(FAIL_SCRIPT, { keys: this.keys, arguments: [id] });
|
|
209
|
+
}
|
|
210
|
+
async size() {
|
|
211
|
+
const n = await this.client.eval(SIZE_SCRIPT, {
|
|
212
|
+
keys: this.keys,
|
|
213
|
+
arguments: []
|
|
214
|
+
});
|
|
215
|
+
return Number(n) || 0;
|
|
216
|
+
}
|
|
217
|
+
async failedCount() {
|
|
218
|
+
const n = await this.client.eval(FAILED_COUNT_SCRIPT, {
|
|
219
|
+
keys: this.keys,
|
|
220
|
+
arguments: []
|
|
221
|
+
});
|
|
222
|
+
return Number(n) || 0;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export { RedisIdempotencyStore, RedisQueueStore, RedisRateLimitStore, RedisSessionStore };
|
|
227
|
+
//# sourceMappingURL=index.js.map
|
|
228
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/session.ts","../src/idempotency.ts","../src/rate-limit.ts","../src/queue.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { SessionStore } from 'ingenium'\nimport type { RedisClientLike } from './client.ts'\n\nexport interface RedisSessionStoreOptions {\n /** Connected Redis client. Caller owns lifecycle. */\n client: RedisClientLike\n /** Key prefix for every entry. Default `'ingenium:sess:'`. */\n prefix?: string\n}\n\n/**\n * Redis-backed {@link SessionStore}. JSON-encodes session data and uses\n * `SET ... EX` so Redis owns TTL expiry — no sweeper, no clock drift between\n * the cookie expiry and the stored value.\n *\n * The store does NOT manage the client connection. Create + `.connect()` your\n * `createClient(...)` before constructing the store; close it during your\n * graceful shutdown hook.\n */\nexport class RedisSessionStore implements SessionStore {\n private readonly client: RedisClientLike\n private readonly prefix: string\n\n constructor(opts: RedisSessionStoreOptions) {\n this.client = opts.client\n this.prefix = opts.prefix ?? 'ingenium:sess:'\n }\n\n async get(id: string): Promise<Record<string, unknown> | null> {\n const raw = await this.client.get(this.prefix + id)\n if (raw === null) return null\n try {\n const parsed = JSON.parse(raw)\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null\n return parsed as Record<string, unknown>\n } catch {\n return null\n }\n }\n\n async set(id: string, data: Record<string, unknown>, ttlSeconds: number): Promise<void> {\n await this.client.set(this.prefix + id, JSON.stringify(data), { EX: ttlSeconds })\n }\n\n async destroy(id: string): Promise<void> {\n await this.client.del(this.prefix + id)\n }\n\n async touch(id: string, ttlSeconds: number): Promise<void> {\n await this.client.expire(this.prefix + id, ttlSeconds)\n }\n}\n","import { Buffer } from 'node:buffer'\nimport type { CachedResponse, IdempotencyStore } from 'ingenium'\nimport type { RedisClientLike } from './client.ts'\n\n/**\n * Wire-format envelope used to JSON-encode a {@link CachedResponse} for\n * storage. Buffers are base64-encoded with a tag so we can faithfully restore\n * them on read — JSON.stringify of a Buffer would otherwise serialize as\n * `{ type: 'Buffer', data: [...] }`, which we'd have to special-case anyway.\n */\ninterface Envelope {\n /** statusCode */\n s: number\n /** headers */\n h: Record<string, string | string[]>\n /** body: null | string | base64-Buffer */\n b: null | { t: 's'; v: string } | { t: 'b'; v: string }\n}\n\nfunction encode(value: CachedResponse): string {\n let body: Envelope['b'] = null\n if (Buffer.isBuffer(value.body)) {\n body = { t: 'b', v: value.body.toString('base64') }\n } else if (typeof value.body === 'string') {\n body = { t: 's', v: value.body }\n }\n const env: Envelope = { s: value.statusCode, h: value.headers, b: body }\n return JSON.stringify(env)\n}\n\nfunction decode(raw: string): CachedResponse | null {\n let env: Envelope\n try {\n env = JSON.parse(raw) as Envelope\n } catch {\n return null\n }\n if (env === null || typeof env !== 'object') return null\n let body: CachedResponse['body'] = null\n if (env.b !== null) {\n body = env.b.t === 'b' ? Buffer.from(env.b.v, 'base64') : env.b.v\n }\n return { statusCode: env.s, headers: env.h, body }\n}\n\nexport interface RedisIdempotencyStoreOptions {\n /** Connected Redis client. Caller owns lifecycle. */\n client: RedisClientLike\n /** Key prefix for every entry. Default `'ingenium:idem:'`. */\n prefix?: string\n}\n\n/**\n * Redis-backed {@link IdempotencyStore}. Cached responses are JSON-serialized\n * (with Buffer bodies base64-encoded) and stored with `SET ... PX` so Redis\n * owns expiry. Suitable for multi-replica deployments where the replica that\n * served the original request may not be the one handling the retry.\n */\nexport class RedisIdempotencyStore implements IdempotencyStore {\n private readonly client: RedisClientLike\n private readonly prefix: string\n\n constructor(opts: RedisIdempotencyStoreOptions) {\n this.client = opts.client\n this.prefix = opts.prefix ?? 'ingenium:idem:'\n }\n\n async get(key: string): Promise<CachedResponse | null> {\n const raw = await this.client.get(this.prefix + key)\n if (raw === null) return null\n return decode(raw)\n }\n\n async set(key: string, value: CachedResponse, ttlMs: number): Promise<void> {\n await this.client.set(this.prefix + key, encode(value), { PX: ttlMs })\n }\n\n async delete(key: string): Promise<void> {\n await this.client.del(this.prefix + key)\n }\n}\n","import type { RateLimitStore } from 'ingenium'\nimport type { RedisClientLike } from './client.ts'\n\n/**\n * Atomic fixed-window counter. INCR is atomic on its own; the `PEXPIRE`\n * piggybacks on the first hit so the window starts the moment the counter is\n * created. The trailing `PTTL` gives us the precise reset time without a\n * second round-trip and without trusting the caller's clock.\n *\n * The comment marker on line 1 is load-bearing for the in-memory fake used\n * by the test suite — see test/fake-client.ts. Real Redis ignores it.\n */\nconst HIT_SCRIPT = `-- INGENIUM_RATELIMIT_HIT v1\nlocal current = redis.call('INCR', KEYS[1])\nif current == 1 then\n redis.call('PEXPIRE', KEYS[1], ARGV[1])\nend\nlocal ttl = redis.call('PTTL', KEYS[1])\nreturn {current, ttl}`\n\nexport interface RedisRateLimitStoreOptions {\n /** Connected Redis client. Caller owns lifecycle. */\n client: RedisClientLike\n /** Key prefix for every entry. Default `'ingenium:rl:'`. */\n prefix?: string\n}\n\n/**\n * Redis-backed {@link RateLimitStore}. Uses a single Lua call per hit so the\n * INCR + PEXPIRE + PTTL trio is atomic — no race where two replicas both see\n * `count == 1` and each set their own expiry, and no race where the counter\n * exists without a TTL because the expire happened in a separate round-trip\n * that lost.\n *\n * `resetAt` is computed from `PTTL` on the server side, so the value is\n * consistent across replicas even if their clocks drift. We add `Date.now()`\n * locally only to produce the absolute timestamp the rest of Ingenium\n * works with; a small clock skew there affects header reporting only, not\n * the actual rate-limit decision (which Redis owns).\n */\nexport class RedisRateLimitStore implements RateLimitStore {\n private readonly client: RedisClientLike\n private readonly prefix: string\n\n constructor(opts: RedisRateLimitStoreOptions) {\n this.client = opts.client\n this.prefix = opts.prefix ?? 'ingenium:rl:'\n }\n\n async hit(key: string, windowMs: number): Promise<{ count: number; resetAt: number }> {\n const result = (await this.client.eval(HIT_SCRIPT, {\n keys: [this.prefix + key],\n arguments: [String(windowMs)],\n })) as [number, number]\n\n if (!Array.isArray(result) || result.length !== 2) {\n throw new Error(\n `RedisRateLimitStore: unexpected EVAL result shape: ${JSON.stringify(result)}`,\n )\n }\n const [count, ttlMs] = result\n return { count, resetAt: Date.now() + Math.max(0, ttlMs) }\n }\n\n async reset(key: string): Promise<void> {\n await this.client.del(this.prefix + key)\n }\n}\n","import type { QueueStore } from 'ingenium'\nimport type { RedisClientLike } from './client.ts'\n\n/**\n * Lua scripts backing {@link RedisQueueStore}. Every multi-step operation runs\n * server-side in a single `EVAL` so the steps are atomic against concurrent\n * workers on other replicas — Redis executes a script to completion before\n * servicing any other command, so there is no window where (e.g.) two workers\n * both ZREM the same id, or where a job is removed from `pending` but not yet\n * recorded in-flight.\n *\n * The marker comment on line 1 of each script is load-bearing for the\n * in-memory fake used by the test suite — see test/fake-client.ts, which\n * dispatches on it. Real Redis ignores the comment.\n *\n * Key layout (KEYS, in the order every script receives them):\n * 1. pending — ZSET, score = ready-time ms (notBefore), member = id.\n * Picking the lowest score <= now gives FIFO with delay support.\n * 2. jobs — HASH. Two fields per job: `<id>` holds the raw JSON payload,\n * `<id>:a` holds the attempt count as a plain integer. Splitting\n * the count into its own field lets `retry` use `HINCRBY` —\n * no parsing of the (arbitrary, possibly `}`-containing) payload\n * JSON inside Lua, which a single-regex envelope can't do safely.\n * 3. inflight — SET of ids currently delivered but not yet acked/retried/failed.\n * 4. failed — LIST (dead-letter); RPUSH on fail, LLEN for the count.\n * 5. seq — STRING counter; INCR yields monotonic ids.\n *\n * The `:a` field suffix is an ARGV (not hard-coded in the script) only for the\n * fake's benefit; in real Lua it's concatenated. Both store + fake build it the\n * same way: `id .. ':a'`.\n */\n\n/**\n * Append a job to the tail. `now` (ARGV[1]) is the score so a freshly enqueued\n * job is immediately ready and ordered after everything already pending —\n * equal scores tie-break by member, and ids are monotonic via INCR, so equal\n * scores still resolve to enqueue order. attempt starts at 1, mirroring\n * MemoryQueueStore.\n */\nconst ENQUEUE_SCRIPT = `-- INGENIUM_QUEUE_ENQUEUE v1\nlocal id = tostring(redis.call('INCR', KEYS[5]))\nredis.call('HSET', KEYS[2], id, ARGV[2])\nredis.call('HSET', KEYS[2], id .. ':a', '1')\nredis.call('ZADD', KEYS[1], ARGV[1], id)\nreturn id`\n\n/**\n * Atomically pop the next ready job. ZRANGEBYSCORE with `-inf`..now and\n * `LIMIT 0 1` yields the lowest-score member whose delay has elapsed; if none\n * is ready (queue empty, or every pending job is still delayed) we return nil\n * — matching MemoryQueueStore returning `null`. The chosen id is ZREM'd from\n * pending and SADD'd to inflight in the same script so it can never be picked\n * twice. Returns `{id, payloadJson, attempt}`.\n */\nconst NEXT_SCRIPT = `-- INGENIUM_QUEUE_NEXT v1\nlocal ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)\nif not ids[1] then\n return nil\nend\nlocal id = ids[1]\nredis.call('ZREM', KEYS[1], id)\nredis.call('SADD', KEYS[3], id)\nlocal payload = redis.call('HGET', KEYS[2], id)\nlocal attempt = redis.call('HGET', KEYS[2], id .. ':a')\nreturn {id, payload, attempt}`\n\n/**\n * Remove a completed in-flight job entirely. SREM + HDEL (both the payload and\n * the attempt field) clears all trace; a no-op if the id is unknown (already\n * acked), so ack is idempotent.\n */\nconst ACK_SCRIPT = `-- INGENIUM_QUEUE_ACK v1\nredis.call('SREM', KEYS[3], ARGV[1])\nredis.call('HDEL', KEYS[2], ARGV[1], ARGV[1] .. ':a')\nreturn 1`\n\n/**\n * Re-enqueue an in-flight job after a delay, incrementing its attempt counter\n * (the MUST-increment contract from QueueStore). `HINCRBY` on the `<id>:a`\n * field is atomic and needs no payload parsing. ZADD back into pending at score\n * `readyAt` (ARGV[2] = now + delayMs). If the id is not in-flight we do nothing\n * — a job already acked/failed can't be retried.\n */\nconst RETRY_SCRIPT = `-- INGENIUM_QUEUE_RETRY v1\nif redis.call('SREM', KEYS[3], ARGV[1]) == 0 then\n return 0\nend\nif redis.call('HEXISTS', KEYS[2], ARGV[1]) == 0 then\n return 0\nend\nredis.call('HINCRBY', KEYS[2], ARGV[1] .. ':a', 1)\nredis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])\nreturn 1`\n\n/**\n * Move an in-flight job to the dead-letter list. We RPUSH a self-contained\n * envelope `{\"d\":<payload>,\"a\":<attempt>}` onto `failed` (built by concatenating\n * the raw payload JSON and the integer attempt — no re-encode of the payload),\n * then drop the in-flight marker and both hash fields. No-op if the id is not\n * in-flight, so fail is idempotent.\n */\nconst FAIL_SCRIPT = `-- INGENIUM_QUEUE_FAIL v1\nif redis.call('SREM', KEYS[3], ARGV[1]) == 0 then\n return 0\nend\nlocal payload = redis.call('HGET', KEYS[2], ARGV[1])\nif payload then\n local attempt = redis.call('HGET', KEYS[2], ARGV[1] .. ':a') or '1'\n local envelope = '{\"id\":\"' .. ARGV[1] .. '\",\"d\":' .. payload .. ',\"a\":' .. attempt .. '}'\n redis.call('RPUSH', KEYS[4], envelope)\n redis.call('HDEL', KEYS[2], ARGV[1], ARGV[1] .. ':a')\nend\nreturn 1`\n\n/** `size()` = ZCARD pending (includes delayed jobs). */\nconst SIZE_SCRIPT = `-- INGENIUM_QUEUE_SIZE v1\nreturn redis.call('ZCARD', KEYS[1])`\n\n/** `failedCount()` = LLEN of the dead-letter list. */\nconst FAILED_COUNT_SCRIPT = `-- INGENIUM_QUEUE_FAILED_COUNT v1\nreturn redis.call('LLEN', KEYS[4])`\n\nexport interface RedisQueueStoreOptions {\n /** Connected Redis client. Caller owns lifecycle. */\n client: RedisClientLike\n /**\n * Key prefix for every structure owned by this queue instance. Default\n * `'ingenium:queue:'`. Two queues that must not share work need distinct\n * prefixes (e.g. `'ingenium:queue:emails:'`).\n */\n prefix?: string\n /**\n * Clock used to stamp ready-times (ZSET scores). Defaults to `Date.now`.\n * Overridable so tests can drive virtual time; production code never sets it.\n */\n now?: () => number\n}\n\n/**\n * Redis-backed {@link QueueStore}. A FIFO job queue with delayed retries and a\n * dead-letter list, sharing state across replicas so any worker on any pod can\n * pick up any job.\n *\n * Atomicity: every operation that touches more than one key (`next`, `retry`,\n * `fail`, and—for uniformity and to keep {@link RedisClientLike} tiny—`enqueue`,\n * `ack`, `size`, `failedCount`) runs as a single Lua `EVAL`. This is what keeps\n * the client surface unchanged: we never need MULTI/WATCH or any command beyond\n * `eval`. Redis runs each script to completion atomically, so concurrent workers\n * can't double-deliver a job or lose a payload between the ZREM and the\n * in-flight SADD.\n *\n * Delivery guarantee: at-least-once. A job that is `next()`-ed but whose worker\n * crashes before `ack`/`retry`/`fail` stays in the `inflight` set and in the\n * `jobs` hash, but NOT in `pending` — it will not be re-delivered automatically\n * by this store (there is no visibility-timeout sweeper). That matches\n * {@link MemoryQueueStore}, which also leaves crashed jobs stuck in its\n * in-flight map. Add a reaper that re-enqueues stale inflight ids if you need\n * crash recovery; the data model (inflight SET + jobs HASH) supports it.\n *\n * `size()` returns the pending count INCLUDING delayed (not-yet-ready) jobs,\n * mirroring `MemoryQueueStore.size()` which returns `pending.length`. Delayed\n * jobs live in the same ZSET with a future score, so `ZCARD` counts them.\n */\nexport class RedisQueueStore<TData> implements QueueStore<TData> {\n private readonly client: RedisClientLike\n private readonly now: () => number\n /** KEYS passed to every script, in fixed order: pending, jobs, inflight, failed, seq. */\n private readonly keys: readonly [string, string, string, string, string]\n\n constructor(opts: RedisQueueStoreOptions) {\n this.client = opts.client\n this.now = opts.now ?? Date.now\n const prefix = opts.prefix ?? 'ingenium:queue:'\n this.keys = [\n prefix + 'pending',\n prefix + 'jobs',\n prefix + 'inflight',\n prefix + 'failed',\n prefix + 'seq',\n ]\n }\n\n async enqueue(data: TData): Promise<{ id: string }> {\n const id = (await this.client.eval(ENQUEUE_SCRIPT, {\n keys: this.keys,\n arguments: [String(this.now()), JSON.stringify(data)],\n })) as string\n return { id: String(id) }\n }\n\n async next(): Promise<{ id: string; data: TData; attempt: number } | null> {\n const result = (await this.client.eval(NEXT_SCRIPT, {\n keys: this.keys,\n arguments: [String(this.now())],\n })) as [string, string | null, string | null] | null\n\n if (result == null || !Array.isArray(result)) return null\n const [id, payload, attempt] = result\n if (id == null || payload == null) return null\n let data: TData\n try {\n data = JSON.parse(payload) as TData\n } catch {\n return null\n }\n return { id: String(id), data, attempt: Number(attempt) || 1 }\n }\n\n async ack(id: string): Promise<void> {\n await this.client.eval(ACK_SCRIPT, { keys: this.keys, arguments: [id] })\n }\n\n async retry(id: string, delayMs: number): Promise<void> {\n const readyAt = this.now() + Math.max(0, delayMs)\n await this.client.eval(RETRY_SCRIPT, {\n keys: this.keys,\n arguments: [id, String(readyAt)],\n })\n }\n\n async fail(id: string): Promise<void> {\n await this.client.eval(FAIL_SCRIPT, { keys: this.keys, arguments: [id] })\n }\n\n async size(): Promise<number> {\n const n = (await this.client.eval(SIZE_SCRIPT, {\n keys: this.keys,\n arguments: [],\n })) as number\n return Number(n) || 0\n }\n\n async failedCount(): Promise<number> {\n const n = (await this.client.eval(FAILED_COUNT_SCRIPT, {\n keys: this.keys,\n arguments: [],\n })) as number\n return Number(n) || 0\n }\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ingenium-redis",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Redis-backed session, idempotency, rate-limit, and background-job queue stores for Ingenium. Drop-in replacements for the in-memory defaults; required for multi-instance deployments.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": ["dist", "src", "README.md"],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.0.0"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"redis",
|
|
28
|
+
"session",
|
|
29
|
+
"idempotency",
|
|
30
|
+
"rate-limit",
|
|
31
|
+
"queue",
|
|
32
|
+
"jobs",
|
|
33
|
+
"background-jobs",
|
|
34
|
+
"ingenium",
|
|
35
|
+
"multi-instance"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"ingenium": "*"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"ingenium": { "optional": false }
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"vitest": "^2.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Redis-client surface the stores rely on.
|
|
3
|
+
*
|
|
4
|
+
* Intentionally duck-typed against node-redis v4+ (`@redis/client`) so users
|
|
5
|
+
* can pass their existing `createClient()` instance directly without an extra
|
|
6
|
+
* type adapter. ioredis users can shim it in a few lines if they prefer that
|
|
7
|
+
* client.
|
|
8
|
+
*
|
|
9
|
+
* The surface is intentionally tiny — only the commands the three stores
|
|
10
|
+
* actually call. Adding methods here means tightening what a custom client
|
|
11
|
+
* must implement, so resist.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface RedisSetOptions {
|
|
15
|
+
/** Set TTL in seconds. Mutually exclusive with `PX`. */
|
|
16
|
+
EX?: number
|
|
17
|
+
/** Set TTL in milliseconds. Mutually exclusive with `EX`. */
|
|
18
|
+
PX?: number
|
|
19
|
+
/** Only set if the key does not already exist. */
|
|
20
|
+
NX?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RedisClientLike {
|
|
24
|
+
get(key: string): Promise<string | null>
|
|
25
|
+
set(key: string, value: string, options?: RedisSetOptions): Promise<string | null>
|
|
26
|
+
del(key: string | readonly string[]): Promise<number>
|
|
27
|
+
expire(key: string, seconds: number): Promise<boolean | number>
|
|
28
|
+
/**
|
|
29
|
+
* Run a Lua script server-side. node-redis v4 uses the `{ keys, arguments }`
|
|
30
|
+
* options bag — ioredis uses positional args, so ioredis adopters need to
|
|
31
|
+
* shim this method.
|
|
32
|
+
*/
|
|
33
|
+
eval(
|
|
34
|
+
script: string,
|
|
35
|
+
options: { keys: readonly string[]; arguments: readonly string[] },
|
|
36
|
+
): Promise<unknown>
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import type { CachedResponse, IdempotencyStore } from 'ingenium'
|
|
3
|
+
import type { RedisClientLike } from './client.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wire-format envelope used to JSON-encode a {@link CachedResponse} for
|
|
7
|
+
* storage. Buffers are base64-encoded with a tag so we can faithfully restore
|
|
8
|
+
* them on read — JSON.stringify of a Buffer would otherwise serialize as
|
|
9
|
+
* `{ type: 'Buffer', data: [...] }`, which we'd have to special-case anyway.
|
|
10
|
+
*/
|
|
11
|
+
interface Envelope {
|
|
12
|
+
/** statusCode */
|
|
13
|
+
s: number
|
|
14
|
+
/** headers */
|
|
15
|
+
h: Record<string, string | string[]>
|
|
16
|
+
/** body: null | string | base64-Buffer */
|
|
17
|
+
b: null | { t: 's'; v: string } | { t: 'b'; v: string }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function encode(value: CachedResponse): string {
|
|
21
|
+
let body: Envelope['b'] = null
|
|
22
|
+
if (Buffer.isBuffer(value.body)) {
|
|
23
|
+
body = { t: 'b', v: value.body.toString('base64') }
|
|
24
|
+
} else if (typeof value.body === 'string') {
|
|
25
|
+
body = { t: 's', v: value.body }
|
|
26
|
+
}
|
|
27
|
+
const env: Envelope = { s: value.statusCode, h: value.headers, b: body }
|
|
28
|
+
return JSON.stringify(env)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function decode(raw: string): CachedResponse | null {
|
|
32
|
+
let env: Envelope
|
|
33
|
+
try {
|
|
34
|
+
env = JSON.parse(raw) as Envelope
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
if (env === null || typeof env !== 'object') return null
|
|
39
|
+
let body: CachedResponse['body'] = null
|
|
40
|
+
if (env.b !== null) {
|
|
41
|
+
body = env.b.t === 'b' ? Buffer.from(env.b.v, 'base64') : env.b.v
|
|
42
|
+
}
|
|
43
|
+
return { statusCode: env.s, headers: env.h, body }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RedisIdempotencyStoreOptions {
|
|
47
|
+
/** Connected Redis client. Caller owns lifecycle. */
|
|
48
|
+
client: RedisClientLike
|
|
49
|
+
/** Key prefix for every entry. Default `'ingenium:idem:'`. */
|
|
50
|
+
prefix?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Redis-backed {@link IdempotencyStore}. Cached responses are JSON-serialized
|
|
55
|
+
* (with Buffer bodies base64-encoded) and stored with `SET ... PX` so Redis
|
|
56
|
+
* owns expiry. Suitable for multi-replica deployments where the replica that
|
|
57
|
+
* served the original request may not be the one handling the retry.
|
|
58
|
+
*/
|
|
59
|
+
export class RedisIdempotencyStore implements IdempotencyStore {
|
|
60
|
+
private readonly client: RedisClientLike
|
|
61
|
+
private readonly prefix: string
|
|
62
|
+
|
|
63
|
+
constructor(opts: RedisIdempotencyStoreOptions) {
|
|
64
|
+
this.client = opts.client
|
|
65
|
+
this.prefix = opts.prefix ?? 'ingenium:idem:'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async get(key: string): Promise<CachedResponse | null> {
|
|
69
|
+
const raw = await this.client.get(this.prefix + key)
|
|
70
|
+
if (raw === null) return null
|
|
71
|
+
return decode(raw)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async set(key: string, value: CachedResponse, ttlMs: number): Promise<void> {
|
|
75
|
+
await this.client.set(this.prefix + key, encode(value), { PX: ttlMs })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async delete(key: string): Promise<void> {
|
|
79
|
+
await this.client.del(this.prefix + key)
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-backed stores for Ingenium. Drop-in replacements for the in-memory
|
|
3
|
+
* defaults shipped in the core package — required for multi-instance
|
|
4
|
+
* deployments where sessions, idempotency replays, rate-limit counters, and
|
|
5
|
+
* background-job queues must share state across replicas.
|
|
6
|
+
*
|
|
7
|
+
* Bring your own connected client (node-redis v4+ recommended). The package
|
|
8
|
+
* intentionally does not own connection lifecycle.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createClient } from 'redis'
|
|
13
|
+
* import { ingenium, sessionMiddleware, IdempotencyMemoryStore } from 'ingenium'
|
|
14
|
+
* import {
|
|
15
|
+
* RedisSessionStore,
|
|
16
|
+
* RedisIdempotencyStore,
|
|
17
|
+
* RedisRateLimitStore,
|
|
18
|
+
* RedisQueueStore,
|
|
19
|
+
* } from 'ingenium-redis'
|
|
20
|
+
*
|
|
21
|
+
* const client = createClient({ url: process.env.REDIS_URL })
|
|
22
|
+
* await client.connect()
|
|
23
|
+
*
|
|
24
|
+
* const app = ingenium()
|
|
25
|
+
* app.use(sessionMiddleware({
|
|
26
|
+
* secret: [process.env.SESSION_SECRET!],
|
|
27
|
+
* store: new RedisSessionStore({ client }),
|
|
28
|
+
* }))
|
|
29
|
+
* app.use(ingenium.idempotency({ store: new RedisIdempotencyStore({ client }) }))
|
|
30
|
+
* app.use(ingenium.rateLimit({
|
|
31
|
+
* windowMs: 60_000,
|
|
32
|
+
* limit: 100,
|
|
33
|
+
* store: new RedisRateLimitStore({ client }),
|
|
34
|
+
* }))
|
|
35
|
+
*
|
|
36
|
+
* // Background jobs shared across replicas:
|
|
37
|
+
* app.queue('emails', {
|
|
38
|
+
* store: new RedisQueueStore({ client }),
|
|
39
|
+
* }, async (job) => { await sendEmail(job.data) })
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export { RedisSessionStore, type RedisSessionStoreOptions } from './session.ts'
|
|
44
|
+
export {
|
|
45
|
+
RedisIdempotencyStore,
|
|
46
|
+
type RedisIdempotencyStoreOptions,
|
|
47
|
+
} from './idempotency.ts'
|
|
48
|
+
export {
|
|
49
|
+
RedisRateLimitStore,
|
|
50
|
+
type RedisRateLimitStoreOptions,
|
|
51
|
+
} from './rate-limit.ts'
|
|
52
|
+
export { RedisQueueStore, type RedisQueueStoreOptions } from './queue.ts'
|
|
53
|
+
export type { RedisClientLike, RedisSetOptions } from './client.ts'
|