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