ioredis-toolkit 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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +645 -0
  3. package/dist/cache.d.ts +298 -0
  4. package/dist/cache.js +606 -0
  5. package/dist/client.d.ts +177 -0
  6. package/dist/client.js +958 -0
  7. package/dist/cluster-slot.d.ts +4 -0
  8. package/dist/cluster-slot.js +31 -0
  9. package/dist/cluster.d.ts +79 -0
  10. package/dist/cluster.js +156 -0
  11. package/dist/errors.d.ts +30 -0
  12. package/dist/errors.js +63 -0
  13. package/dist/health.d.ts +39 -0
  14. package/dist/health.js +106 -0
  15. package/dist/index.d.ts +51 -0
  16. package/dist/index.js +44 -0
  17. package/dist/lock.d.ts +215 -0
  18. package/dist/lock.js +385 -0
  19. package/dist/logger.d.ts +12 -0
  20. package/dist/logger.js +40 -0
  21. package/dist/pubsub.d.ts +171 -0
  22. package/dist/pubsub.js +285 -0
  23. package/dist/ratelimiter.d.ts +162 -0
  24. package/dist/ratelimiter.js +289 -0
  25. package/dist/session/index.d.ts +23 -0
  26. package/dist/session/index.js +16 -0
  27. package/dist/session/revocation-store.d.ts +171 -0
  28. package/dist/session/revocation-store.js +310 -0
  29. package/dist/session/scripts/cleanup-index.lua +21 -0
  30. package/dist/session/scripts/conditional-update-encrypted.lua +60 -0
  31. package/dist/session/scripts/conditional-update.lua +63 -0
  32. package/dist/session/scripts/create.lua +68 -0
  33. package/dist/session/scripts/delete-by-user.lua +29 -0
  34. package/dist/session/scripts/delete.lua +15 -0
  35. package/dist/session/scripts/enforce-limit.lua +38 -0
  36. package/dist/session/scripts/revoke.lua +61 -0
  37. package/dist/session/scripts/rotate-encrypted.lua +107 -0
  38. package/dist/session/scripts/rotate.lua +119 -0
  39. package/dist/session/scripts/touch-encrypted.lua +89 -0
  40. package/dist/session/scripts/touch.lua +72 -0
  41. package/dist/session/scripts/validate.lua +90 -0
  42. package/dist/session/session-circuit-breaker.d.ts +42 -0
  43. package/dist/session/session-circuit-breaker.js +129 -0
  44. package/dist/session/session-config.d.ts +335 -0
  45. package/dist/session/session-config.js +162 -0
  46. package/dist/session/session-cookie.d.ts +72 -0
  47. package/dist/session/session-cookie.js +101 -0
  48. package/dist/session/session-encryption.d.ts +87 -0
  49. package/dist/session/session-encryption.js +139 -0
  50. package/dist/session/session-errors.d.ts +85 -0
  51. package/dist/session/session-errors.js +145 -0
  52. package/dist/session/session-health.d.ts +38 -0
  53. package/dist/session/session-health.js +60 -0
  54. package/dist/session/session-keys.d.ts +51 -0
  55. package/dist/session/session-keys.js +113 -0
  56. package/dist/session/session-manager.d.ts +59 -0
  57. package/dist/session/session-manager.js +94 -0
  58. package/dist/session/session-metrics.d.ts +33 -0
  59. package/dist/session/session-metrics.js +112 -0
  60. package/dist/session/session-repository.d.ts +161 -0
  61. package/dist/session/session-repository.js +683 -0
  62. package/dist/session/session-scripts.d.ts +36 -0
  63. package/dist/session/session-scripts.js +130 -0
  64. package/dist/session/session-serializer.d.ts +42 -0
  65. package/dist/session/session-serializer.js +248 -0
  66. package/dist/session/session-service.d.ts +104 -0
  67. package/dist/session/session-service.js +611 -0
  68. package/dist/session/session-token.d.ts +38 -0
  69. package/dist/session/session-token.js +86 -0
  70. package/dist/session/session-types.d.ts +253 -0
  71. package/dist/session/session-types.js +16 -0
  72. package/dist/types.d.ts +782 -0
  73. package/dist/types.js +140 -0
  74. package/package.json +97 -0
@@ -0,0 +1,107 @@
1
+ -- rotate-encrypted.lua (version 1) - encrypted (v2) envelopes
2
+ -- Atomic, single-use session rotation for encrypted sessions.
3
+ --
4
+ -- The ciphertext cannot be decoded inside Lua, so the app decrypts the
5
+ -- old record, builds the consumed + successor payloads (re-encrypted with
6
+ -- the current key) and passes them as ARGV. The script is the only
7
+ -- authority for the state transition, decided from the plaintext header
8
+ -- mirrors (st/ver/exp/rn/rj) and Redis server time; the header mirrors of
9
+ -- the written payloads are overwritten from the checked values so a stale
10
+ -- app payload can never misrepresent state.
11
+ --
12
+ -- KEYS[1] = old session record key
13
+ -- KEYS[2] = successor session record key
14
+ -- KEYS[3] = user session index key
15
+ --
16
+ -- ARGV[1] = consumed serialized envelope (app-built, re-encrypted)
17
+ -- ARGV[2] = successor serialized envelope (app-built, re-encrypted)
18
+ -- ARGV[3] = successor jti
19
+ -- ARGV[4] = expected version of the old session ('' = no check)
20
+ -- ARGV[5] = rotation nonce hash ('' = none)
21
+ -- ARGV[6] = retain consumed tombstone (1/0)
22
+ -- ARGV[7] = old jti
23
+ -- ARGV[8] = successor TTL (clamped >= 1)
24
+ -- ARGV[9] = consumed tombstone TTL (clamped >= 1)
25
+ --
26
+ -- Returns: same codes as rotate.lua (1/2/0/-1/-2/-3/-4/5), plus 6 when the
27
+ -- envelope is plain (caller must use the plain path).
28
+ local raw = redis.call('GET', KEYS[1])
29
+
30
+ if not raw then
31
+ return 0
32
+ end
33
+
34
+ local env = cjson.decode(raw)
35
+
36
+ if env.v ~= 2 then
37
+ return 6
38
+ end
39
+
40
+ local now = tonumber(redis.call('TIME')[1])
41
+
42
+ -- Retry-safe replay detection BEFORE rejecting consumed records.
43
+ -- The rotation nonce uniquely identifies the rotation: when the consumed
44
+ -- record carries the same nonce hash, this is a retry of an already-applied
45
+ -- rotation. The stored rotatedTo jti is returned (the retry's own freshly
46
+ -- generated successor jti is discarded - it can never match).
47
+ if env.st == 'consumed' then
48
+ if ARGV[5] ~= '' and env.rn == ARGV[5] then
49
+ return { 2, env.rj }
50
+ end
51
+ return -1
52
+ end
53
+
54
+ if env.st ~= 'active' then
55
+ return -1
56
+ end
57
+
58
+ if tonumber(env.exp) <= now then
59
+ redis.call('DEL', KEYS[1])
60
+ redis.call('ZREM', KEYS[3], ARGV[7])
61
+ return -2
62
+ end
63
+
64
+ if ARGV[4] ~= '' and tostring(env.ver) ~= ARGV[4] then
65
+ return -3
66
+ end
67
+
68
+ if redis.call('EXISTS', KEYS[2]) == 1 then
69
+ return -4
70
+ end
71
+
72
+ local consumedEnv = cjson.decode(ARGV[1])
73
+ consumedEnv.st = 'consumed'
74
+ consumedEnv.ver = env.ver
75
+ consumedEnv.la = env.la
76
+ consumedEnv.idle = env.idle
77
+ consumedEnv.exp = env.exp
78
+ consumedEnv.rn = cjson.null
79
+ consumedEnv.rj = ARGV[3]
80
+
81
+ if ARGV[5] ~= '' then
82
+ consumedEnv.rn = ARGV[5]
83
+ end
84
+
85
+ local nextEnv = cjson.decode(ARGV[2])
86
+ nextEnv.st = 'active'
87
+ nextEnv.ver = 1
88
+ nextEnv.la = now
89
+ if nextEnv.idle ~= cjson.null then
90
+ nextEnv.idle = math.min(tonumber(nextEnv.idle), tonumber(nextEnv.exp))
91
+ end
92
+ nextEnv.rn = cjson.null
93
+ nextEnv.rj = cjson.null
94
+
95
+ local nextTtl = math.max(1, tonumber(ARGV[8]))
96
+
97
+ if ARGV[6] == '1' then
98
+ redis.call('SET', KEYS[1], cjson.encode(consumedEnv), 'EX', math.max(1, tonumber(ARGV[9])))
99
+ else
100
+ redis.call('DEL', KEYS[1])
101
+ end
102
+
103
+ redis.call('SET', KEYS[2], cjson.encode(nextEnv), 'EX', nextTtl)
104
+ redis.call('ZREM', KEYS[3], ARGV[7])
105
+ redis.call('ZADD', KEYS[3], now, ARGV[3])
106
+
107
+ return { 1, ARGV[3] }
@@ -0,0 +1,119 @@
1
+ -- rotate.lua (version 1) - plain (v1) envelopes
2
+ -- Atomic, single-use session rotation.
3
+ --
4
+ -- The old session is consumed (status=consumed, consumedAt, rotatedTo,
5
+ -- rotationNonceHash) and the successor is created, all in one same-slot
6
+ -- script. No preliminary GET can make this safe; the script is the only
7
+ -- authority. Timestamps are stamped from Redis server time.
8
+ --
9
+ -- Retry-safe idempotency: when the caller retries a rotation that already
10
+ -- succeeded (response lost), the consumed record retains rotationNonceHash
11
+ -- and rotatedTo. If both match the retry, the script returns {2, jti} and
12
+ -- the caller returns the existing successor instead of a replay error.
13
+ --
14
+ -- KEYS[1] = old session record key
15
+ -- KEYS[2] = successor session record key
16
+ -- KEYS[3] = user session index key
17
+ --
18
+ -- ARGV[1] = successor serialized envelope (app-built static fields)
19
+ -- ARGV[2] = successor jti
20
+ -- ARGV[3] = expected version of the old session ('' = no check)
21
+ -- ARGV[4] = rotation nonce hash ('' = none)
22
+ -- ARGV[5] = retain consumed tombstone (1/0)
23
+ -- ARGV[6] = old jti
24
+ --
25
+ -- Returns:
26
+ -- {1, successorJti} success
27
+ -- {2, successorJti} idempotent replay of a rotation with the same nonce
28
+ -- 0 old session not found
29
+ -- -1 already consumed / revoked (replay)
30
+ -- -2 old session expired (record removed)
31
+ -- -3 version conflict
32
+ -- -4 successor jti collision
33
+ -- 5 envelope is encrypted (use the encrypted path)
34
+ local raw = redis.call('GET', KEYS[1])
35
+
36
+ if not raw then
37
+ return 0
38
+ end
39
+
40
+ local env = cjson.decode(raw)
41
+
42
+ if env.v ~= 1 then
43
+ return 5
44
+ end
45
+
46
+ local s = env.s
47
+ local now = tonumber(redis.call('TIME')[1])
48
+
49
+ -- Retry-safe replay detection BEFORE rejecting consumed records.
50
+ -- The rotation nonce uniquely identifies the rotation: when the consumed
51
+ -- record carries the same nonce hash, this is a retry of an already-applied
52
+ -- rotation. The stored rotatedTo jti is returned (the retry's own freshly
53
+ -- generated successor jti is discarded - it can never match).
54
+ if s.status == 'consumed' then
55
+ if ARGV[4] ~= '' and s.rotationNonceHash == ARGV[4] then
56
+ return { 2, s.rotatedTo }
57
+ end
58
+ return -1
59
+ end
60
+
61
+ if s.status ~= 'active' then
62
+ return -1
63
+ end
64
+
65
+ if tonumber(s.absoluteExpiresAt) <= now then
66
+ redis.call('DEL', KEYS[1])
67
+ redis.call('ZREM', KEYS[3], ARGV[6])
68
+ return -2
69
+ end
70
+
71
+ if ARGV[3] ~= '' and tostring(s.version) ~= ARGV[3] then
72
+ return -3
73
+ end
74
+
75
+ -- Successor collision: cryptographically improbable, but never overwrite.
76
+ if redis.call('EXISTS', KEYS[2]) == 1 then
77
+ return -4
78
+ end
79
+
80
+ -- Build the successor record; server time is authoritative for ordering.
81
+ local nextEnv = cjson.decode(ARGV[1])
82
+ local ns = nextEnv.s
83
+
84
+ ns.createdAt = now
85
+ ns.lastAccessedAt = now
86
+ ns.rotatedFrom = s.jti
87
+ ns.version = 1
88
+
89
+ if ns.idleExpiresAt and ns.idleExpiresAt ~= cjson.null then
90
+ ns.idleExpiresAt = math.min(tonumber(ns.idleExpiresAt), tonumber(ns.absoluteExpiresAt))
91
+ end
92
+
93
+ local nextTtl = math.max(1, tonumber(ns.absoluteExpiresAt) - now)
94
+
95
+ -- Consume the old session.
96
+ s.status = 'consumed'
97
+ s.consumedAt = now
98
+ s.rotatedTo = ARGV[2]
99
+
100
+ if ARGV[4] ~= '' then
101
+ s.rotationNonceHash = ARGV[4]
102
+ else
103
+ s.rotationNonceHash = cjson.null
104
+ end
105
+
106
+ local oldTtl = math.max(1, tonumber(s.absoluteExpiresAt) - now)
107
+
108
+ if ARGV[5] == '1' then
109
+ -- Short-lived consumed tombstone for replay detection (bounded TTL).
110
+ redis.call('SET', KEYS[1], cjson.encode(env), 'EX', oldTtl)
111
+ else
112
+ redis.call('DEL', KEYS[1])
113
+ end
114
+
115
+ redis.call('SET', KEYS[2], cjson.encode(nextEnv), 'EX', nextTtl)
116
+ redis.call('ZREM', KEYS[3], ARGV[6])
117
+ redis.call('ZADD', KEYS[3], now, ARGV[2])
118
+
119
+ return { 1, ARGV[2] }
@@ -0,0 +1,89 @@
1
+ -- touch-encrypted.lua (version 1) - encrypted (v2) envelopes
2
+ -- Throttled, monotonic activity refresh for encrypted sessions.
3
+ --
4
+ -- The ciphertext cannot be decoded inside Lua, so the app re-encrypts the
5
+ -- record (with its own clock) and passes the new envelope as ARGV[4]. The
6
+ -- script validates state via the plaintext header mirrors and enforces
7
+ -- monotonicity: a stale request can never move lastAccessedAt backwards.
8
+ --
9
+ -- KEYS[1] = session record key
10
+ --
11
+ -- ARGV[1] = touchInterval (seconds)
12
+ -- ARGV[2] = idleTimeout (seconds, '' when disabled)
13
+ -- ARGV[3] = force (1 = ignore throttle)
14
+ -- ARGV[4] = new serialized envelope (re-encrypted with the current key)
15
+ -- ARGV[5] = new lastAccessedAt (app clock, seconds)
16
+ -- ARGV[6] = new idleExpiresAt (app clock, seconds, '' when disabled)
17
+ -- ARGV[7] = new TTL (absoluteExpiresAt - now, clamped >= 1)
18
+ --
19
+ -- Returns:
20
+ -- 1 touched (write performed)
21
+ -- 2 skipped: inside touchInterval
22
+ -- 3 skipped: request older than recorded activity
23
+ -- 0 not found
24
+ -- -1 consumed or revoked
25
+ -- -2 absolute expiry passed; record deleted
26
+ -- -3 idle expired; NOT resurrected
27
+ -- 4 envelope is plain (caller must use the plain touch path)
28
+ -- 5 stale request: ARGV[5] older than recorded activity
29
+ local raw = redis.call('GET', KEYS[1])
30
+
31
+ if not raw then
32
+ return 0
33
+ end
34
+
35
+ local env = cjson.decode(raw)
36
+
37
+ if env.v ~= 2 then
38
+ return 4
39
+ end
40
+
41
+ if env.st ~= 'active' then
42
+ return -1
43
+ end
44
+
45
+ local now = tonumber(redis.call('TIME')[1])
46
+
47
+ if tonumber(env.exp) <= now then
48
+ redis.call('DEL', KEYS[1])
49
+ return -2
50
+ end
51
+
52
+ if env.idle ~= cjson.null and tonumber(env.idle) <= now then
53
+ return -3
54
+ end
55
+
56
+ local interval = tonumber(ARGV[1])
57
+
58
+ if tonumber(ARGV[3]) ~= 1 and now - tonumber(env.la) < interval then
59
+ return 2
60
+ end
61
+
62
+ -- Monotonic guard: a stale app request must never regress activity.
63
+ -- Equal-second requests are allowed (the app clock is second-granular and
64
+ -- cannot advance within the same second); only strictly older writes are
65
+ -- rejected.
66
+ local newLa = tonumber(ARGV[5])
67
+
68
+ if newLa < tonumber(env.la) then
69
+ return 5
70
+ end
71
+
72
+ local newEnv = cjson.decode(ARGV[4])
73
+
74
+ -- Keep the plaintext header mirrors in sync with the checked values.
75
+ newEnv.st = env.st
76
+ newEnv.la = newLa
77
+ if ARGV[6] ~= '' then
78
+ newEnv.idle = tonumber(ARGV[6])
79
+ else
80
+ newEnv.idle = cjson.null
81
+ end
82
+ newEnv.ver = env.ver
83
+ newEnv.exp = env.exp
84
+ newEnv.rn = env.rn
85
+ newEnv.rj = env.rj
86
+
87
+ redis.call('SET', KEYS[1], cjson.encode(newEnv), 'EX', tonumber(ARGV[7]))
88
+
89
+ return 1
@@ -0,0 +1,72 @@
1
+ -- touch.lua (version 1) - plain (v1) envelopes
2
+ -- Throttled, monotonic activity refresh for plain sessions.
3
+ --
4
+ -- The script is authoritative for all state checks (exists, status,
5
+ -- absolute expiry, idle expiry, throttle). Timestamps come from Redis
6
+ -- server time, so distributed application clocks cannot race each other.
7
+ --
8
+ -- KEYS[1] = session record key
9
+ --
10
+ -- ARGV[1] = touchInterval (seconds)
11
+ -- ARGV[2] = idleTimeout (seconds, '' when idle timeout disabled)
12
+ -- ARGV[3] = force (1 = ignore throttle)
13
+ --
14
+ -- Returns:
15
+ -- 1 touched (write performed)
16
+ -- 2 skipped: inside touchInterval
17
+ -- 3 skipped: request older than recorded activity (not possible with
18
+ -- server time; retained for parity with the encrypted path)
19
+ -- 0 not found
20
+ -- -1 consumed or revoked
21
+ -- -2 absolute expiry passed; record deleted
22
+ -- -3 idle expired; NOT resurrected
23
+ -- 4 envelope is encrypted (caller must use the encrypted touch path)
24
+ local raw = redis.call('GET', KEYS[1])
25
+
26
+ if not raw then
27
+ return 0
28
+ end
29
+
30
+ local env = cjson.decode(raw)
31
+
32
+ if env.v ~= 1 then
33
+ return 4
34
+ end
35
+
36
+ local s = env.s
37
+
38
+ if s.status ~= 'active' then
39
+ return -1
40
+ end
41
+
42
+ local now = tonumber(redis.call('TIME')[1])
43
+
44
+ if tonumber(s.absoluteExpiresAt) <= now then
45
+ redis.call('DEL', KEYS[1])
46
+ return -2
47
+ end
48
+
49
+ if s.idleExpiresAt and s.idleExpiresAt ~= cjson.null and tonumber(s.idleExpiresAt) <= now then
50
+ return -3
51
+ end
52
+
53
+ local interval = tonumber(ARGV[1])
54
+
55
+ if tonumber(ARGV[3]) ~= 1 and now - tonumber(s.lastAccessedAt) < interval then
56
+ return 2
57
+ end
58
+
59
+ s.lastAccessedAt = now
60
+
61
+ if ARGV[2] ~= '' then
62
+ local idle = now + tonumber(ARGV[2])
63
+ local abs = tonumber(s.absoluteExpiresAt)
64
+ -- Rolling extends the idle boundary but NEVER the absolute boundary.
65
+ s.idleExpiresAt = math.min(idle, abs)
66
+ end
67
+
68
+ local ttl = math.max(1, tonumber(s.absoluteExpiresAt) - now)
69
+
70
+ redis.call('SET', KEYS[1], cjson.encode(env), 'EX', ttl)
71
+
72
+ return 1
@@ -0,0 +1,90 @@
1
+ -- validate.lua (version 1)
2
+ -- Single-round-trip validation read: fetches the session record, the
3
+ -- user's security version (when the key exists) and lazily cleans up
4
+ -- expired records and their index members, all in one same-slot script.
5
+ --
6
+ -- The script performs the cheap, non-cryptographic state checks; binding
7
+ -- checks and (for encrypted sessions) decryption happen app-side on the
8
+ -- returned payload. A missing security-version key means the check is
9
+ -- skipped (the version is only enforced once setSecurityVersion has run).
10
+ --
11
+ -- KEYS[1] = session record key
12
+ -- KEYS[2] = user security version key (same slot)
13
+ -- KEYS[3] = user session index key (same slot)
14
+ --
15
+ -- ARGV[1] = jti (used for the lazy index cleanup of encrypted records,
16
+ -- whose headers do not carry their own jti)
17
+ --
18
+ -- Returns:
19
+ -- {1, raw, securityVersion?} record exists and passed script checks;
20
+ -- raw is the stored envelope; securityVersion
21
+ -- is the current user version when set
22
+ -- {0} not found
23
+ -- {-1, status} not active (consumed/revoked)
24
+ -- {-2} expired (absolute); record + index entry
25
+ -- removed
26
+ -- {-3} idle expired; record + index entry removed
27
+ -- {-4} security version mismatch (plain records)
28
+ local raw = redis.call('GET', KEYS[1])
29
+
30
+ if not raw then
31
+ return { 0 }
32
+ end
33
+
34
+ local currentRaw = redis.call('GET', KEYS[2])
35
+ local currentVersion = nil
36
+
37
+ if currentRaw then
38
+ currentVersion = tonumber(currentRaw)
39
+ end
40
+
41
+ local env = cjson.decode(raw)
42
+ local now = tonumber(redis.call('TIME')[1])
43
+
44
+ if env.v == 1 then
45
+ local s = env.s
46
+
47
+ if s.status ~= 'active' then
48
+ return { -1, s.status }
49
+ end
50
+
51
+ if tonumber(s.absoluteExpiresAt) <= now then
52
+ redis.call('DEL', KEYS[1])
53
+ redis.call('ZREM', KEYS[3], s.jti)
54
+ return { -2 }
55
+ end
56
+
57
+ if s.idleExpiresAt and s.idleExpiresAt ~= cjson.null and tonumber(s.idleExpiresAt) <= now then
58
+ redis.call('DEL', KEYS[1])
59
+ redis.call('ZREM', KEYS[3], s.jti)
60
+ return { -3 }
61
+ end
62
+
63
+ if currentVersion and (s.securityVersion == nil or tonumber(s.securityVersion) ~= currentVersion) then
64
+ return { -4 }
65
+ end
66
+ elseif env.v == 2 then
67
+ -- Encrypted envelope: only the plaintext header mirrors are readable in
68
+ -- Lua. They are the script-level authority for state decisions; the
69
+ -- ciphertext remains authoritative for the payload (assertHeaderMatches).
70
+ if env.st ~= 'active' then
71
+ return { -1, env.st }
72
+ end
73
+
74
+ if tonumber(env.exp) <= now then
75
+ redis.call('DEL', KEYS[1])
76
+ redis.call('ZREM', KEYS[3], ARGV[1])
77
+ return { -2 }
78
+ end
79
+
80
+ if env.idle ~= cjson.null and tonumber(env.idle) <= now then
81
+ redis.call('DEL', KEYS[1])
82
+ redis.call('ZREM', KEYS[3], ARGV[1])
83
+ return { -3 }
84
+ end
85
+
86
+ -- Security version is checked app-side for encrypted records (it lives
87
+ -- only inside the ciphertext).
88
+ end
89
+
90
+ return { 1, raw, currentVersion }
@@ -0,0 +1,42 @@
1
+ import type { SessionCircuitBreakerConfig } from './session-config.js';
2
+ export type CircuitBreakerState = 'closed' | 'open' | 'half_open';
3
+ /**
4
+ * Fail-closed circuit breaker around session storage operations.
5
+ *
6
+ * `run()` executes a function while the circuit is closed or a half-open
7
+ * probe is allowed, and throws {@link CircuitBreakerOpenError} when open.
8
+ * Callers may instead use {@link tryAcquire} / {@link recordSuccess} /
9
+ * {@link recordFailure} explicitly.
10
+ */
11
+ export declare class SessionCircuitBreaker {
12
+ private readonly config;
13
+ private readonly circuit;
14
+ private readonly now;
15
+ private readonly onTransition;
16
+ constructor(config: SessionCircuitBreakerConfig, options?: {
17
+ now?: () => number;
18
+ onTransition?: (state: CircuitBreakerState) => void;
19
+ });
20
+ get state(): CircuitBreakerState;
21
+ /**
22
+ * Runs an operation under circuit protection. Records success/failure
23
+ * based on the promise outcome.
24
+ *
25
+ * @throws {CircuitBreakerOpenError} when the circuit is open.
26
+ */
27
+ run<T>(fn: () => Promise<T>): Promise<T>;
28
+ /**
29
+ * Attempts to acquire a call slot synchronously. Returns false (fail
30
+ * closed) when the circuit is open and not yet ready for probes.
31
+ */
32
+ tryAcquire(): boolean;
33
+ /** Records a successful operation (closes a half-open circuit). */
34
+ recordSuccess(): void;
35
+ /** Records a failed operation (may open the circuit). */
36
+ recordFailure(): void;
37
+ /** Evaluates whether the open timer has elapsed, rolling to half-open. */
38
+ private rollHalfOpen;
39
+ private transitionTo;
40
+ /** Resets the breaker to closed (admin/repair). */
41
+ reset(): void;
42
+ }
@@ -0,0 +1,129 @@
1
+ import { CircuitBreakerOpenError } from './session-errors.js';
2
+ /**
3
+ * Fail-closed circuit breaker around session storage operations.
4
+ *
5
+ * `run()` executes a function while the circuit is closed or a half-open
6
+ * probe is allowed, and throws {@link CircuitBreakerOpenError} when open.
7
+ * Callers may instead use {@link tryAcquire} / {@link recordSuccess} /
8
+ * {@link recordFailure} explicitly.
9
+ */
10
+ export class SessionCircuitBreaker {
11
+ config;
12
+ circuit;
13
+ now;
14
+ onTransition;
15
+ constructor(config, options = {}) {
16
+ this.config = config;
17
+ this.now = options.now ?? (() => Date.now());
18
+ this.onTransition = options.onTransition ?? null;
19
+ this.circuit = {
20
+ state: 'closed',
21
+ consecutiveFailures: 0,
22
+ openedAt: 0,
23
+ halfOpenInFlight: 0,
24
+ halfOpenSucceeded: 0,
25
+ };
26
+ }
27
+ get state() {
28
+ return this.circuit.state;
29
+ }
30
+ /**
31
+ * Runs an operation under circuit protection. Records success/failure
32
+ * based on the promise outcome.
33
+ *
34
+ * @throws {CircuitBreakerOpenError} when the circuit is open.
35
+ */
36
+ async run(fn) {
37
+ if (!this.tryAcquire()) {
38
+ throw new CircuitBreakerOpenError();
39
+ }
40
+ try {
41
+ const result = await fn();
42
+ this.recordSuccess();
43
+ return result;
44
+ }
45
+ catch (error) {
46
+ this.recordFailure();
47
+ throw error;
48
+ }
49
+ }
50
+ /**
51
+ * Attempts to acquire a call slot synchronously. Returns false (fail
52
+ * closed) when the circuit is open and not yet ready for probes.
53
+ */
54
+ tryAcquire() {
55
+ this.rollHalfOpen();
56
+ if (this.circuit.state === 'closed')
57
+ return true;
58
+ if (this.circuit.state === 'half_open') {
59
+ if (this.circuit.halfOpenInFlight >= this.config.halfOpenMaxRequests) {
60
+ return false;
61
+ }
62
+ this.circuit.halfOpenInFlight += 1;
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+ /** Records a successful operation (closes a half-open circuit). */
68
+ recordSuccess() {
69
+ if (this.circuit.state === 'closed') {
70
+ this.circuit.consecutiveFailures = 0;
71
+ return;
72
+ }
73
+ if (this.circuit.state !== 'half_open')
74
+ return;
75
+ this.circuit.halfOpenInFlight = Math.max(0, this.circuit.halfOpenInFlight - 1);
76
+ this.circuit.halfOpenSucceeded += 1;
77
+ if (this.circuit.halfOpenInFlight === 0 && this.circuit.halfOpenSucceeded >= 1) {
78
+ this.transitionTo('closed');
79
+ }
80
+ }
81
+ /** Records a failed operation (may open the circuit). */
82
+ recordFailure() {
83
+ if (this.circuit.state === 'closed') {
84
+ this.circuit.consecutiveFailures += 1;
85
+ if (this.circuit.consecutiveFailures >= this.config.failureThreshold) {
86
+ this.transitionTo('open');
87
+ }
88
+ return;
89
+ }
90
+ if (this.circuit.state === 'half_open') {
91
+ this.circuit.halfOpenInFlight = Math.max(0, this.circuit.halfOpenInFlight - 1);
92
+ this.transitionTo('open');
93
+ }
94
+ }
95
+ /** Evaluates whether the open timer has elapsed, rolling to half-open. */
96
+ rollHalfOpen() {
97
+ if (this.circuit.state === 'open' &&
98
+ this.now() - this.circuit.openedAt >= this.config.resetTimeoutMs) {
99
+ this.circuit.state = 'half_open';
100
+ this.circuit.halfOpenInFlight = 0;
101
+ this.circuit.halfOpenSucceeded = 0;
102
+ this.onTransition?.('half_open');
103
+ }
104
+ }
105
+ transitionTo(state) {
106
+ if (this.circuit.state === state)
107
+ return;
108
+ this.circuit.state = state;
109
+ if (state === 'open') {
110
+ this.circuit.openedAt = this.now();
111
+ this.circuit.halfOpenInFlight = 0;
112
+ this.circuit.halfOpenSucceeded = 0;
113
+ }
114
+ if (state === 'half_open') {
115
+ this.circuit.halfOpenInFlight = 0;
116
+ this.circuit.halfOpenSucceeded = 0;
117
+ }
118
+ if (state === 'closed') {
119
+ this.circuit.consecutiveFailures = 0;
120
+ this.circuit.halfOpenInFlight = 0;
121
+ this.circuit.halfOpenSucceeded = 0;
122
+ }
123
+ this.onTransition?.(state);
124
+ }
125
+ /** Resets the breaker to closed (admin/repair). */
126
+ reset() {
127
+ this.transitionTo('closed');
128
+ }
129
+ }