@periodic/vanadium 1.0.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/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +846 -0
- package/dist/cjs/adapters/memory/index.js +134 -0
- package/dist/cjs/adapters/memory/index.js.map +1 -0
- package/dist/cjs/adapters/mongodb/index.js +189 -0
- package/dist/cjs/adapters/mongodb/index.js.map +1 -0
- package/dist/cjs/adapters/mongoose/index.js +199 -0
- package/dist/cjs/adapters/mongoose/index.js.map +1 -0
- package/dist/cjs/adapters/postgres/index.js +202 -0
- package/dist/cjs/adapters/postgres/index.js.map +1 -0
- package/dist/cjs/adapters/prisma/index.js +176 -0
- package/dist/cjs/adapters/prisma/index.js.map +1 -0
- package/dist/cjs/adapters/redis/index.js +178 -0
- package/dist/cjs/adapters/redis/index.js.map +1 -0
- package/dist/cjs/cleanup/engine.js +100 -0
- package/dist/cjs/cleanup/engine.js.map +1 -0
- package/dist/cjs/core/concurrencyGuard.js +50 -0
- package/dist/cjs/core/concurrencyGuard.js.map +1 -0
- package/dist/cjs/core/metrics.js +39 -0
- package/dist/cjs/core/metrics.js.map +1 -0
- package/dist/cjs/core/stateMachine.js +46 -0
- package/dist/cjs/core/stateMachine.js.map +1 -0
- package/dist/cjs/errors/index.js +127 -0
- package/dist/cjs/errors/index.js.map +1 -0
- package/dist/cjs/http/express.js +84 -0
- package/dist/cjs/http/express.js.map +1 -0
- package/dist/cjs/http/fastify.js +70 -0
- package/dist/cjs/http/fastify.js.map +1 -0
- package/dist/cjs/idempotency/engine.js +266 -0
- package/dist/cjs/idempotency/engine.js.map +1 -0
- package/dist/cjs/index.js +19 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/lock/engine.js +187 -0
- package/dist/cjs/lock/engine.js.map +1 -0
- package/dist/cjs/observability/metrics.js +92 -0
- package/dist/cjs/observability/metrics.js.map +1 -0
- package/dist/cjs/resilience/circuitBreaker.js +129 -0
- package/dist/cjs/resilience/circuitBreaker.js.map +1 -0
- package/dist/cjs/types/index.js +13 -0
- package/dist/cjs/types/index.js.map +1 -0
- package/dist/cjs/utils/crypto.js +64 -0
- package/dist/cjs/utils/crypto.js.map +1 -0
- package/dist/cjs/utils/keys.js +40 -0
- package/dist/cjs/utils/keys.js.map +1 -0
- package/dist/cjs/utils/sleep.js +25 -0
- package/dist/cjs/utils/sleep.js.map +1 -0
- package/dist/esm/adapters/memory/index.js +129 -0
- package/dist/esm/adapters/memory/index.js.map +1 -0
- package/dist/esm/adapters/mongodb/index.js +184 -0
- package/dist/esm/adapters/mongodb/index.js.map +1 -0
- package/dist/esm/adapters/mongoose/index.js +193 -0
- package/dist/esm/adapters/mongoose/index.js.map +1 -0
- package/dist/esm/adapters/postgres/index.js +197 -0
- package/dist/esm/adapters/postgres/index.js.map +1 -0
- package/dist/esm/adapters/prisma/index.js +171 -0
- package/dist/esm/adapters/prisma/index.js.map +1 -0
- package/dist/esm/adapters/redis/index.js +173 -0
- package/dist/esm/adapters/redis/index.js.map +1 -0
- package/dist/esm/cleanup/engine.js +95 -0
- package/dist/esm/cleanup/engine.js.map +1 -0
- package/dist/esm/core/concurrencyGuard.js +46 -0
- package/dist/esm/core/concurrencyGuard.js.map +1 -0
- package/dist/esm/core/metrics.js +35 -0
- package/dist/esm/core/metrics.js.map +1 -0
- package/dist/esm/core/stateMachine.js +40 -0
- package/dist/esm/core/stateMachine.js.map +1 -0
- package/dist/esm/errors/index.js +114 -0
- package/dist/esm/errors/index.js.map +1 -0
- package/dist/esm/http/express.js +81 -0
- package/dist/esm/http/express.js.map +1 -0
- package/dist/esm/http/fastify.js +67 -0
- package/dist/esm/http/fastify.js.map +1 -0
- package/dist/esm/idempotency/engine.js +261 -0
- package/dist/esm/idempotency/engine.js.map +1 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/lock/engine.js +182 -0
- package/dist/esm/lock/engine.js.map +1 -0
- package/dist/esm/observability/metrics.js +89 -0
- package/dist/esm/observability/metrics.js.map +1 -0
- package/dist/esm/resilience/circuitBreaker.js +124 -0
- package/dist/esm/resilience/circuitBreaker.js.map +1 -0
- package/dist/esm/types/index.js +10 -0
- package/dist/esm/types/index.js.map +1 -0
- package/dist/esm/utils/crypto.js +58 -0
- package/dist/esm/utils/crypto.js.map +1 -0
- package/dist/esm/utils/keys.js +35 -0
- package/dist/esm/utils/keys.js.map +1 -0
- package/dist/esm/utils/sleep.js +20 -0
- package/dist/esm/utils/sleep.js.map +1 -0
- package/dist/types/adapters/memory/index.d.ts +49 -0
- package/dist/types/adapters/memory/index.d.ts.map +1 -0
- package/dist/types/adapters/mongodb/index.d.ts +97 -0
- package/dist/types/adapters/mongodb/index.d.ts.map +1 -0
- package/dist/types/adapters/mongoose/index.d.ts +107 -0
- package/dist/types/adapters/mongoose/index.d.ts.map +1 -0
- package/dist/types/adapters/postgres/index.d.ts +85 -0
- package/dist/types/adapters/postgres/index.d.ts.map +1 -0
- package/dist/types/adapters/prisma/index.d.ts +73 -0
- package/dist/types/adapters/prisma/index.d.ts.map +1 -0
- package/dist/types/adapters/redis/index.d.ts +77 -0
- package/dist/types/adapters/redis/index.d.ts.map +1 -0
- package/dist/types/cleanup/engine.d.ts +41 -0
- package/dist/types/cleanup/engine.d.ts.map +1 -0
- package/dist/types/core/concurrencyGuard.d.ts +28 -0
- package/dist/types/core/concurrencyGuard.d.ts.map +1 -0
- package/dist/types/core/metrics.d.ts +13 -0
- package/dist/types/core/metrics.d.ts.map +1 -0
- package/dist/types/core/stateMachine.d.ts +20 -0
- package/dist/types/core/stateMachine.d.ts.map +1 -0
- package/dist/types/errors/index.d.ts +32 -0
- package/dist/types/errors/index.d.ts.map +1 -0
- package/dist/types/http/express.d.ts +50 -0
- package/dist/types/http/express.d.ts.map +1 -0
- package/dist/types/http/fastify.d.ts +48 -0
- package/dist/types/http/fastify.d.ts.map +1 -0
- package/dist/types/idempotency/engine.d.ts +24 -0
- package/dist/types/idempotency/engine.d.ts.map +1 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/lock/engine.d.ts +28 -0
- package/dist/types/lock/engine.d.ts.map +1 -0
- package/dist/types/observability/metrics.d.ts +45 -0
- package/dist/types/observability/metrics.d.ts.map +1 -0
- package/dist/types/resilience/circuitBreaker.d.ts +48 -0
- package/dist/types/resilience/circuitBreaker.d.ts.map +1 -0
- package/dist/types/types/index.d.ts +170 -0
- package/dist/types/types/index.d.ts.map +1 -0
- package/dist/types/utils/crypto.d.ts +20 -0
- package/dist/types/utils/crypto.d.ts.map +1 -0
- package/dist/types/utils/keys.d.ts +15 -0
- package/dist/types/utils/keys.d.ts.map +1 -0
- package/dist/types/utils/sleep.d.ts +13 -0
- package/dist/types/utils/sleep.d.ts.map +1 -0
- package/package.json +140 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis Storage Adapter for @periodic/vanadium
|
|
3
|
+
*
|
|
4
|
+
* Peer dependency: "redis" >= 4.0.0
|
|
5
|
+
* Import: import { createRedisAdapter } from '@periodic/vanadium/adapters/redis'
|
|
6
|
+
*
|
|
7
|
+
* The caller is responsible for creating and connecting the Redis client.
|
|
8
|
+
* This adapter NEVER creates its own connection.
|
|
9
|
+
*/
|
|
10
|
+
import { createStorageError, createConfigurationError } from '../../errors/index.js';
|
|
11
|
+
import { safeJsonStringify, safeJsonParse } from '../../utils/crypto.js';
|
|
12
|
+
import { buildNamespacedKey } from '../../utils/keys.js';
|
|
13
|
+
// ─── Lua Scripts ─────────────────────────────────────────────────────────────
|
|
14
|
+
/** Atomic CAS: compare ownerToken, replace if match */
|
|
15
|
+
const LUA_CAS = `
|
|
16
|
+
local current = redis.call('GET', KEYS[1])
|
|
17
|
+
if current == false then return 0 end
|
|
18
|
+
local record = cjson.decode(current)
|
|
19
|
+
if record['ownerToken'] ~= ARGV[1] then return 0 end
|
|
20
|
+
local ttl = tonumber(ARGV[3])
|
|
21
|
+
if ttl > 0 then
|
|
22
|
+
redis.call('SET', KEYS[1], ARGV[2], 'PX', ttl)
|
|
23
|
+
else
|
|
24
|
+
redis.call('SET', KEYS[1], ARGV[2])
|
|
25
|
+
end
|
|
26
|
+
return 1
|
|
27
|
+
`;
|
|
28
|
+
/** Safe delete: only delete if ownerToken matches */
|
|
29
|
+
const LUA_SAFE_DELETE = `
|
|
30
|
+
local current = redis.call('GET', KEYS[1])
|
|
31
|
+
if current == false then return 0 end
|
|
32
|
+
local record = cjson.decode(current)
|
|
33
|
+
if record['ownerToken'] ~= ARGV[1] then return 0 end
|
|
34
|
+
return redis.call('DEL', KEYS[1])
|
|
35
|
+
`;
|
|
36
|
+
// ─── Redis Adapter ────────────────────────────────────────────────────────────
|
|
37
|
+
export class RedisAdapter {
|
|
38
|
+
constructor(options) {
|
|
39
|
+
this.name = 'redis';
|
|
40
|
+
if (!options.client) {
|
|
41
|
+
throw createConfigurationError('', 'redis', 'Redis adapter requires a connected client.');
|
|
42
|
+
}
|
|
43
|
+
this.client = options.client;
|
|
44
|
+
this.keyPrefix = options.keyPrefix ?? 'vanadium:';
|
|
45
|
+
this.namespace = options.namespace ?? '';
|
|
46
|
+
this.useLua = options.useLua ?? true;
|
|
47
|
+
this.clock = options.clock ?? Date.now;
|
|
48
|
+
}
|
|
49
|
+
// ── Storage Interface ─────────────────────────────────────────────────────
|
|
50
|
+
async get(key) {
|
|
51
|
+
const redisKey = this._buildKey(key);
|
|
52
|
+
try {
|
|
53
|
+
const raw = await this.client.get(redisKey);
|
|
54
|
+
if (raw === null)
|
|
55
|
+
return null;
|
|
56
|
+
return safeJsonParse(raw, `redis:${key}`);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
throw createStorageError(key, this.name, err, this.clock);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async set(key, value, ttlMs) {
|
|
63
|
+
const redisKey = this._buildKey(key);
|
|
64
|
+
let serialized;
|
|
65
|
+
try {
|
|
66
|
+
serialized = safeJsonStringify(value);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw createStorageError(key, this.name, err, this.clock);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
if (ttlMs !== undefined && ttlMs > 0) {
|
|
73
|
+
await this.client.set(redisKey, serialized, { PX: ttlMs });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
await this.client.set(redisKey, serialized);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw createStorageError(key, this.name, err, this.clock);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async delete(key) {
|
|
84
|
+
const redisKey = this._buildKey(key);
|
|
85
|
+
try {
|
|
86
|
+
await this.client.del(redisKey);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
throw createStorageError(key, this.name, err, this.clock);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async compareAndSet(key, expectedOwnerToken, newValue, ttlMs) {
|
|
93
|
+
const redisKey = this._buildKey(key);
|
|
94
|
+
let serialized;
|
|
95
|
+
try {
|
|
96
|
+
serialized = safeJsonStringify(newValue);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
throw createStorageError(key, this.name, err, this.clock);
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
if (this.useLua) {
|
|
103
|
+
const result = await this.client.eval(LUA_CAS, {
|
|
104
|
+
keys: [redisKey],
|
|
105
|
+
arguments: [expectedOwnerToken, serialized, String(ttlMs ?? 0)],
|
|
106
|
+
});
|
|
107
|
+
return result === 1;
|
|
108
|
+
}
|
|
109
|
+
// Fallback: non-atomic (less safe — only for adapters without Lua)
|
|
110
|
+
const current = await this.client.get(redisKey);
|
|
111
|
+
if (!current)
|
|
112
|
+
return false;
|
|
113
|
+
const existing = safeJsonParse(current, `cas:${key}`);
|
|
114
|
+
if (existing.ownerToken !== expectedOwnerToken)
|
|
115
|
+
return false;
|
|
116
|
+
await this.set(key, newValue, ttlMs);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err.type !== undefined)
|
|
121
|
+
throw err;
|
|
122
|
+
throw createStorageError(key, this.name, err, this.clock);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Safe owner-only delete using Lua. Use in lock release.
|
|
127
|
+
*/
|
|
128
|
+
async safeDelete(key, ownerToken) {
|
|
129
|
+
const redisKey = this._buildKey(key);
|
|
130
|
+
try {
|
|
131
|
+
const result = await this.client.eval(LUA_SAFE_DELETE, {
|
|
132
|
+
keys: [redisKey],
|
|
133
|
+
arguments: [ownerToken],
|
|
134
|
+
});
|
|
135
|
+
return result === 1;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
throw createStorageError(key, this.name, err, this.clock);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// ── Capabilities ──────────────────────────────────────────────────────────
|
|
142
|
+
capabilities() {
|
|
143
|
+
return {
|
|
144
|
+
transactions: false,
|
|
145
|
+
cas: true,
|
|
146
|
+
ttl: true,
|
|
147
|
+
advisoryLocks: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
151
|
+
_buildKey(key) {
|
|
152
|
+
return buildNamespacedKey(key, this.keyPrefix, this.namespace);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
156
|
+
/**
|
|
157
|
+
* Create a Redis storage adapter.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* import { createClient } from 'redis';
|
|
162
|
+
* import { createRedisAdapter } from '@periodic/vanadium/adapters/redis';
|
|
163
|
+
*
|
|
164
|
+
* const client = createClient({ url: process.env.REDIS_URL });
|
|
165
|
+
* await client.connect();
|
|
166
|
+
*
|
|
167
|
+
* const adapter = createRedisAdapter({ client, namespace: 'payments' });
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export function createRedisAdapter(options) {
|
|
171
|
+
return new RedisAdapter(options);
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/adapters/redis/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const DEFAULT_INTERVAL_MS = 60000; // 1 minute
|
|
2
|
+
const DEFAULT_STALE_THRESHOLD_MS = 10 * 60000; // 10 minutes
|
|
3
|
+
// ─── Cleanup Engine ───────────────────────────────────────────────────────────
|
|
4
|
+
/**
|
|
5
|
+
* Optional background cleanup engine.
|
|
6
|
+
*
|
|
7
|
+
* Scans for orphaned IN_PROGRESS records and removes or marks them FAILED.
|
|
8
|
+
* Does NOT break distributed semantics — TTL handles the authoritative expiry.
|
|
9
|
+
* This is purely a maintenance utility.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* const cleanup = createCleanupEngine({ adapter, intervalMs: 30_000 });
|
|
14
|
+
* cleanup.start();
|
|
15
|
+
* process.on('SIGTERM', () => cleanup.stop());
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export class CleanupEngine {
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.intervalHandle = null;
|
|
21
|
+
this.running = false;
|
|
22
|
+
this.adapter = options.adapter;
|
|
23
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
24
|
+
this.staleThresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
|
|
25
|
+
this.deleteOnClean = options.deleteOnClean ?? true;
|
|
26
|
+
this.clock = options.clock ?? Date.now;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Start the cleanup timer.
|
|
30
|
+
*/
|
|
31
|
+
start() {
|
|
32
|
+
if (this.running)
|
|
33
|
+
return;
|
|
34
|
+
this.running = true;
|
|
35
|
+
this.intervalHandle = setInterval(() => {
|
|
36
|
+
void this._runCleanup();
|
|
37
|
+
}, this.intervalMs);
|
|
38
|
+
// Allow process to exit without waiting for cleanup timer
|
|
39
|
+
if (this.intervalHandle.unref) {
|
|
40
|
+
this.intervalHandle.unref();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Stop the cleanup timer gracefully.
|
|
45
|
+
*/
|
|
46
|
+
stop() {
|
|
47
|
+
if (this.intervalHandle !== null) {
|
|
48
|
+
clearInterval(this.intervalHandle);
|
|
49
|
+
this.intervalHandle = null;
|
|
50
|
+
}
|
|
51
|
+
this.running = false;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run a single cleanup pass manually (useful for testing).
|
|
55
|
+
*/
|
|
56
|
+
async runOnce(keysToCheck) {
|
|
57
|
+
return this._cleanKeys(keysToCheck);
|
|
58
|
+
}
|
|
59
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
60
|
+
async _runCleanup() {
|
|
61
|
+
// NOTE: The cleanup engine operates on keys that callers explicitly provide.
|
|
62
|
+
// A full scan requires adapter-level support (e.g., SCAN in Redis, SELECT in Postgres).
|
|
63
|
+
// This base implementation is a no-op periodic tick — adapters may extend it.
|
|
64
|
+
// Use runOnce(keys) to clean specific known stale keys.
|
|
65
|
+
}
|
|
66
|
+
async _cleanKeys(keys) {
|
|
67
|
+
const now = this.clock();
|
|
68
|
+
let cleaned = 0;
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
try {
|
|
71
|
+
const record = await this.adapter.get(key);
|
|
72
|
+
if (!record)
|
|
73
|
+
continue;
|
|
74
|
+
if (record.status !== 'IN_PROGRESS')
|
|
75
|
+
continue;
|
|
76
|
+
const staleAt = record.updatedAt + this.staleThresholdMs;
|
|
77
|
+
if (now < staleAt)
|
|
78
|
+
continue;
|
|
79
|
+
if (this.deleteOnClean) {
|
|
80
|
+
await this.adapter.delete(key);
|
|
81
|
+
}
|
|
82
|
+
cleaned++;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Best effort — never let cleanup crash the app
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return cleaned;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
92
|
+
export function createCleanupEngine(options) {
|
|
93
|
+
return new CleanupEngine(options);
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../../../src/cleanup/engine.ts"],"names":[],"mappings":"AAEA,MAAM,mBAAmB,GAAG,KAAM,CAAC,CAAC,WAAW;AAC/C,MAAM,0BAA0B,GAAG,EAAE,GAAG,KAAM,CAAC,CAAC,aAAa;AAE7D,iFAAiF;AAEjF;;;;;;;;;;;;;GAaG;AACH,MAAM,OAAO,aAAa;IAUxB,YAAY,OAAuB;QAT3B,mBAAc,GAA0C,IAAI,CAAC;QAC7D,YAAO,GAAG,KAAK,CAAC;QAStB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;QAC/B,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,mBAAmB,CAAC;QAC5D,IAAI,CAAC,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,IAAI,0BAA0B,CAAC;QAC/E,IAAI,CAAC,aAAa,GAAG,OAAO,CAAC,aAAa,IAAI,IAAI,CAAC;QACnD,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,CAAC;IACzC,CAAC;IAED;;OAEG;IACH,KAAK;QACH,IAAI,IAAI,CAAC,OAAO;YAAE,OAAO;QACzB,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;QACpB,IAAI,CAAC,cAAc,GAAG,WAAW,CAAC,GAAG,EAAE;YACrC,KAAK,IAAI,CAAC,WAAW,EAAE,CAAC;QAC1B,CAAC,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;QAEpB,0DAA0D;QAC1D,IAAI,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,CAAC;YAC9B,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,CAAC;QAC9B,CAAC;IACH,CAAC;IAED;;OAEG;IACH,IAAI;QACF,IAAI,IAAI,CAAC,cAAc,KAAK,IAAI,EAAE,CAAC;YACjC,aAAa,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;YACnC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC;QAC7B,CAAC;QACD,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;IACvB,CAAC;IAED;;OAEG;IACH,KAAK,CAAC,OAAO,CAAC,WAAqB;QACjC,OAAO,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;IACtC,CAAC;IAED,6EAA6E;IAErE,KAAK,CAAC,WAAW;QACvB,6EAA6E;QAC7E,wFAAwF;QACxF,8EAA8E;QAC9E,wDAAwD;IAC1D,CAAC;IAEO,KAAK,CAAC,UAAU,CAAC,IAAc;QACrC,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC;QACzB,IAAI,OAAO,GAAG,CAAC,CAAC;QAEhB,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;gBAC3C,IAAI,CAAC,MAAM;oBAAE,SAAS;gBACtB,IAAI,MAAM,CAAC,MAAM,KAAK,aAAa;oBAAE,SAAS;gBAE9C,MAAM,OAAO,GAAG,MAAM,CAAC,SAAS,GAAG,IAAI,CAAC,gBAAgB,CAAC;gBACzD,IAAI,GAAG,GAAG,OAAO;oBAAE,SAAS;gBAE5B,IAAI,IAAI,CAAC,aAAa,EAAE,CAAC;oBACvB,MAAM,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;gBACjC,CAAC;gBACD,OAAO,EAAE,CAAC;YACZ,CAAC;YAAC,MAAM,CAAC;gBACP,gDAAgD;YAClD,CAAC;QACH,CAAC;QAED,OAAO,OAAO,CAAC;IACjB,CAAC;CACF;AAED,iFAAiF;AAEjF,MAAM,UAAU,mBAAmB,CAAC,OAAuB;IACzD,OAAO,IAAI,aAAa,CAAC,OAAO,CAAC,CAAC;AACpC,CAAC"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local concurrency guard — prevents redundant concurrent calls to the
|
|
3
|
+
* distributed adapter for the same key within a single process.
|
|
4
|
+
*
|
|
5
|
+
* This is a performance optimization ONLY. It does NOT replace the
|
|
6
|
+
* distributed guarantee provided by the storage adapter.
|
|
7
|
+
*/
|
|
8
|
+
export class ConcurrencyGuard {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.inFlight = new Map();
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* If a promise for this key is already in-flight, return it.
|
|
14
|
+
* Otherwise, register the new promise and clean up when it settles.
|
|
15
|
+
*/
|
|
16
|
+
wrap(key, fn) {
|
|
17
|
+
const existing = this.inFlight.get(key);
|
|
18
|
+
if (existing !== undefined) {
|
|
19
|
+
return existing;
|
|
20
|
+
}
|
|
21
|
+
const promise = fn().finally(() => {
|
|
22
|
+
this.inFlight.delete(key);
|
|
23
|
+
});
|
|
24
|
+
this.inFlight.set(key, promise);
|
|
25
|
+
return promise;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check if a key is currently in-flight locally.
|
|
29
|
+
*/
|
|
30
|
+
isInFlight(key) {
|
|
31
|
+
return this.inFlight.has(key);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Return the number of keys currently tracked.
|
|
35
|
+
*/
|
|
36
|
+
size() {
|
|
37
|
+
return this.inFlight.size;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Clear all tracked keys (for testing / cleanup).
|
|
41
|
+
*/
|
|
42
|
+
clear() {
|
|
43
|
+
this.inFlight.clear();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=concurrencyGuard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"concurrencyGuard.js","sourceRoot":"","sources":["../../../src/core/concurrencyGuard.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,OAAO,gBAAgB;IAA7B;QACmB,aAAQ,GAAG,IAAI,GAAG,EAA4B,CAAC;IAwClE,CAAC;IAtCC;;;OAGG;IACH,IAAI,CAAI,GAAW,EAAE,EAAoB;QACvC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACxC,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC3B,OAAO,QAAsB,CAAC;QAChC,CAAC;QAED,MAAM,OAAO,GAAG,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE;YAChC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAC5B,CAAC,CAAC,CAAC;QAEH,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QAChC,OAAO,OAAO,CAAC;IACjB,CAAC;IAED;;OAEG;IACH,UAAU,CAAC,GAAW;QACpB,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAChC,CAAC;IAED;;OAEG;IACH,IAAI;QACF,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;IAC5B,CAAC;IAED;;OAEG;IACH,KAAK;QACH,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;CACF"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ─── Metrics Store ────────────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* Mutable metrics container. One instance per engine.
|
|
4
|
+
* Never uses global state.
|
|
5
|
+
*/
|
|
6
|
+
export class MetricsStore {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.data = {
|
|
9
|
+
totalExecutions: 0,
|
|
10
|
+
totalDuplicates: 0,
|
|
11
|
+
totalLocksAcquired: 0,
|
|
12
|
+
totalLockFailures: 0,
|
|
13
|
+
totalPayloadMismatches: 0,
|
|
14
|
+
inProgressCount: 0,
|
|
15
|
+
totalTakeovers: 0,
|
|
16
|
+
totalStorageErrors: 0,
|
|
17
|
+
totalFailuresCached: 0,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
increment(key, amount = 1) {
|
|
21
|
+
this.data[key] += amount;
|
|
22
|
+
}
|
|
23
|
+
decrement(key, amount = 1) {
|
|
24
|
+
this.data[key] = Math.max(0, this.data[key] - amount);
|
|
25
|
+
}
|
|
26
|
+
get() {
|
|
27
|
+
return { ...this.data };
|
|
28
|
+
}
|
|
29
|
+
reset() {
|
|
30
|
+
for (const key of Object.keys(this.data)) {
|
|
31
|
+
this.data[key] = 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=metrics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metrics.js","sourceRoot":"","sources":["../../../src/core/metrics.ts"],"names":[],"mappings":"AAEA,iFAAiF;AAEjF;;;GAGG;AACH,MAAM,OAAO,YAAY;IAAzB;QACU,SAAI,GAAoB;YAC9B,eAAe,EAAE,CAAC;YAClB,eAAe,EAAE,CAAC;YAClB,kBAAkB,EAAE,CAAC;YACrB,iBAAiB,EAAE,CAAC;YACpB,sBAAsB,EAAE,CAAC;YACzB,eAAe,EAAE,CAAC;YAClB,cAAc,EAAE,CAAC;YACjB,kBAAkB,EAAE,CAAC;YACrB,mBAAmB,EAAE,CAAC;SACvB,CAAC;IAmBJ,CAAC;IAjBC,SAAS,CAAC,GAA0B,EAAE,MAAM,GAAG,CAAC;QAC9C,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC;IAC3B,CAAC;IAED,SAAS,CAAC,GAA0B,EAAE,MAAM,GAAG,CAAC;QAC9C,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC;IACxD,CAAC;IAED,GAAG;QACD,OAAO,EAAE,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;IAC1B,CAAC;IAED,KAAK;QACH,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAA8B,EAAE,CAAC;YACtE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACrB,CAAC;IACH,CAAC;CACF"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createStateTransitionError } from '../errors/index.js';
|
|
2
|
+
// ─── Valid Transitions ────────────────────────────────────────────────────────
|
|
3
|
+
const VALID_TRANSITIONS = new Set([
|
|
4
|
+
'IN_PROGRESS -> COMPLETED',
|
|
5
|
+
'IN_PROGRESS -> FAILED',
|
|
6
|
+
'IN_PROGRESS -> IN_PROGRESS', // expired takeover only
|
|
7
|
+
]);
|
|
8
|
+
// ─── State Machine ────────────────────────────────────────────────────────────
|
|
9
|
+
/**
|
|
10
|
+
* Assert that a transition from one execution status to another is valid.
|
|
11
|
+
* Throws a VanadiumError(STATE_TRANSITION_ERROR) on illegal transitions.
|
|
12
|
+
*/
|
|
13
|
+
export function assertValidTransition(from, to, key, adapterName, clock = Date.now) {
|
|
14
|
+
const transition = `${from} -> ${to}`;
|
|
15
|
+
if (!VALID_TRANSITIONS.has(transition)) {
|
|
16
|
+
throw createStateTransitionError(key, adapterName, from, to, clock);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Check if a transition is valid without throwing.
|
|
21
|
+
*/
|
|
22
|
+
export function isValidTransition(from, to) {
|
|
23
|
+
return VALID_TRANSITIONS.has(`${from} -> ${to}`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Determine whether a stored record has expired based on current time.
|
|
27
|
+
*/
|
|
28
|
+
export function isExpired(expiresAt, clock = Date.now) {
|
|
29
|
+
if (expiresAt === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
return clock() >= expiresAt;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Determine whether an IN_PROGRESS record can be taken over.
|
|
35
|
+
* Requires: status is IN_PROGRESS AND expiresAt has passed.
|
|
36
|
+
*/
|
|
37
|
+
export function canTakeover(status, expiresAt, clock = Date.now) {
|
|
38
|
+
return status === 'IN_PROGRESS' && isExpired(expiresAt, clock);
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=stateMachine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stateMachine.js","sourceRoot":"","sources":["../../../src/core/stateMachine.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,0BAA0B,EAAE,MAAM,oBAAoB,CAAC;AAEhE,iFAAiF;AAEjF,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAS;IACxC,0BAA0B;IAC1B,uBAAuB;IACvB,4BAA4B,EAAE,wBAAwB;CACvD,CAAC,CAAC;AAEH,iFAAiF;AAEjF;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CACnC,IAAqB,EACrB,EAAmB,EACnB,GAAW,EACX,WAAmB,EACnB,QAAsB,IAAI,CAAC,GAAG;IAE9B,MAAM,UAAU,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,CAAC;IACtC,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;QACvC,MAAM,0BAA0B,CAAC,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC;IACtE,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAqB,EAAE,EAAmB;IAC1E,OAAO,iBAAiB,CAAC,GAAG,CAAC,GAAG,IAAI,OAAO,EAAE,EAAE,CAAC,CAAC;AACnD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,SAAS,CAAC,SAA6B,EAAE,QAAsB,IAAI,CAAC,GAAG;IACrF,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAC1C,OAAO,KAAK,EAAE,IAAI,SAAS,CAAC;AAC9B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW,CACzB,MAAuB,EACvB,SAA6B,EAC7B,QAAsB,IAAI,CAAC,GAAG;IAE9B,OAAO,MAAM,KAAK,aAAa,IAAI,SAAS,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;AACjE,CAAC"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export class VanadiumError extends Error {
|
|
2
|
+
constructor(details) {
|
|
3
|
+
super(details.message);
|
|
4
|
+
this.name = 'VanadiumError';
|
|
5
|
+
this.type = details.type;
|
|
6
|
+
this.key = details.key;
|
|
7
|
+
this.adapterName = details.adapterName;
|
|
8
|
+
this.timestamp = details.timestamp;
|
|
9
|
+
this.originalError = details.originalError;
|
|
10
|
+
this.attempts = details.attempts;
|
|
11
|
+
this.payloadHash = details.payloadHash;
|
|
12
|
+
// Maintain proper prototype chain
|
|
13
|
+
Object.setPrototypeOf(this, VanadiumError.prototype);
|
|
14
|
+
if (Error.captureStackTrace) {
|
|
15
|
+
Error.captureStackTrace(this, VanadiumError);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
toJSON() {
|
|
19
|
+
return {
|
|
20
|
+
name: this.name,
|
|
21
|
+
type: this.type,
|
|
22
|
+
message: this.message,
|
|
23
|
+
key: this.key,
|
|
24
|
+
adapterName: this.adapterName,
|
|
25
|
+
timestamp: this.timestamp,
|
|
26
|
+
attempts: this.attempts,
|
|
27
|
+
payloadHash: this.payloadHash,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// ─── Error Factories ──────────────────────────────────────────────────────────
|
|
32
|
+
export function createDuplicateExecutionError(key, adapterName, clock = Date.now) {
|
|
33
|
+
return new VanadiumError({
|
|
34
|
+
type: 'DUPLICATE_EXECUTION',
|
|
35
|
+
key,
|
|
36
|
+
adapterName,
|
|
37
|
+
timestamp: clock(),
|
|
38
|
+
message: `Duplicate execution detected for key "${key}". Returning cached result.`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function createInProgressError(key, adapterName, attempts, clock = Date.now) {
|
|
42
|
+
return new VanadiumError({
|
|
43
|
+
type: 'IN_PROGRESS',
|
|
44
|
+
key,
|
|
45
|
+
adapterName,
|
|
46
|
+
timestamp: clock(),
|
|
47
|
+
attempts,
|
|
48
|
+
message: `Execution for key "${key}" is currently in progress (attempt ${attempts}). Another node owns execution.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function createLockAcquisitionFailedError(key, adapterName, clock = Date.now) {
|
|
52
|
+
return new VanadiumError({
|
|
53
|
+
type: 'LOCK_ACQUISITION_FAILED',
|
|
54
|
+
key,
|
|
55
|
+
adapterName,
|
|
56
|
+
timestamp: clock(),
|
|
57
|
+
message: `Failed to acquire lock for key "${key}".`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function createLockTimeoutError(key, adapterName, maxWaitMs, clock = Date.now) {
|
|
61
|
+
return new VanadiumError({
|
|
62
|
+
type: 'LOCK_TIMEOUT',
|
|
63
|
+
key,
|
|
64
|
+
adapterName,
|
|
65
|
+
timestamp: clock(),
|
|
66
|
+
message: `Timed out waiting to acquire lock for key "${key}" after ${maxWaitMs}ms.`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function createPayloadMismatchError(key, adapterName, storedHash, incomingHash, clock = Date.now) {
|
|
70
|
+
return new VanadiumError({
|
|
71
|
+
type: 'PAYLOAD_MISMATCH',
|
|
72
|
+
key,
|
|
73
|
+
adapterName,
|
|
74
|
+
timestamp: clock(),
|
|
75
|
+
payloadHash: incomingHash,
|
|
76
|
+
message: `Payload mismatch for key "${key}". ` +
|
|
77
|
+
`Stored hash: "${storedHash ?? 'none'}", Incoming hash: "${incomingHash}". ` +
|
|
78
|
+
`The same idempotency key cannot be used with different payloads.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export function createConfigurationError(key, adapterName, message, clock = Date.now) {
|
|
82
|
+
return new VanadiumError({
|
|
83
|
+
type: 'CONFIGURATION_ERROR',
|
|
84
|
+
key,
|
|
85
|
+
adapterName,
|
|
86
|
+
timestamp: clock(),
|
|
87
|
+
message,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export function createStorageError(key, adapterName, originalError, clock = Date.now) {
|
|
91
|
+
const msg = originalError instanceof Error ? originalError.message : String(originalError);
|
|
92
|
+
return new VanadiumError({
|
|
93
|
+
type: 'STORAGE_ERROR',
|
|
94
|
+
key,
|
|
95
|
+
adapterName,
|
|
96
|
+
timestamp: clock(),
|
|
97
|
+
originalError,
|
|
98
|
+
message: `Storage error for key "${key}" in adapter "${adapterName}": ${msg}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export function createStateTransitionError(key, adapterName, from, to, clock = Date.now) {
|
|
102
|
+
return new VanadiumError({
|
|
103
|
+
type: 'STATE_TRANSITION_ERROR',
|
|
104
|
+
key,
|
|
105
|
+
adapterName,
|
|
106
|
+
timestamp: clock(),
|
|
107
|
+
message: `Invalid state transition for key "${key}": ${from} -> ${to}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// ─── Type Guard ───────────────────────────────────────────────────────────────
|
|
111
|
+
export function isVanadiumError(err) {
|
|
112
|
+
return err instanceof VanadiumError;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/errors/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware for @periodic/vanadium
|
|
3
|
+
*
|
|
4
|
+
* Automatically wraps HTTP handlers with idempotency semantics.
|
|
5
|
+
* Reads the Idempotency-Key header and caches successful responses.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import express from 'express';
|
|
10
|
+
* import { createIdempotency, createMemoryAdapter } from '@periodic/vanadium';
|
|
11
|
+
* import { vanadiumMiddleware } from '@periodic/vanadium/http/express';
|
|
12
|
+
*
|
|
13
|
+
* const app = express();
|
|
14
|
+
* const idempotency = createIdempotency({ adapter: createMemoryAdapter(), ttlMs: 86_400_000 });
|
|
15
|
+
*
|
|
16
|
+
* app.post('/payments', vanadiumMiddleware(idempotency), async (req, res) => {
|
|
17
|
+
* const result = await chargeCard(req.body);
|
|
18
|
+
* res.json(result);
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import { isVanadiumError } from '../errors/index.js';
|
|
23
|
+
// ─── Middleware Factory ───────────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* Create Express middleware that enforces idempotency on HTTP handlers.
|
|
26
|
+
*/
|
|
27
|
+
export function vanadiumMiddleware(idempotency, options = {}) {
|
|
28
|
+
const headerName = options.headerName ?? 'idempotency-key';
|
|
29
|
+
const methods = (options.methods ?? ['POST', 'PUT', 'PATCH']).map((m) => m.toUpperCase());
|
|
30
|
+
return function vanadiumHandler(req, res, next) {
|
|
31
|
+
if (!methods.includes(req.method.toUpperCase())) {
|
|
32
|
+
next();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const rawHeader = req.headers[headerName.toLowerCase()];
|
|
36
|
+
const idempotencyKey = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
37
|
+
if (!idempotencyKey) {
|
|
38
|
+
next();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const fullKey = `http:${req.method}:${req.path}:${idempotencyKey}`;
|
|
42
|
+
void idempotency
|
|
43
|
+
.execute(fullKey, () => {
|
|
44
|
+
return new Promise((resolve, _reject) => {
|
|
45
|
+
// Intercept res.json to capture the response
|
|
46
|
+
const originalJson = res.json.bind(res);
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
res.json = function (body) {
|
|
49
|
+
const statusCode = res.statusCode ?? 200;
|
|
50
|
+
originalJson(body);
|
|
51
|
+
resolve({ statusCode, body });
|
|
52
|
+
};
|
|
53
|
+
next();
|
|
54
|
+
});
|
|
55
|
+
})
|
|
56
|
+
.then((cached) => {
|
|
57
|
+
// If we got here without executing the handler (duplicate), replay cached response
|
|
58
|
+
if (cached) {
|
|
59
|
+
res.status(cached.statusCode).json(cached.body);
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.catch((err) => {
|
|
63
|
+
if (isVanadiumError(err)) {
|
|
64
|
+
if (err.type === 'DUPLICATE_EXECUTION') {
|
|
65
|
+
// Already handled — no-op
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (err.type === 'IN_PROGRESS') {
|
|
69
|
+
res.status(409).json({
|
|
70
|
+
error: 'Request is currently being processed',
|
|
71
|
+
type: 'IN_PROGRESS',
|
|
72
|
+
key: err.key,
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
next(err);
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=express.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.js","sourceRoot":"","sources":["../../../src/http/express.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAyCrD,iFAAiF;AAEjF;;GAEG;AACH,MAAM,UAAU,kBAAkB,CAChC,WAA8B,EAC9B,UAAqC,EAAE;IAEvC,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,iBAAiB,CAAC;IAC3D,MAAM,OAAO,GAAG,CAAC,OAAO,CAAC,OAAO,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;IAE1F,OAAO,SAAS,eAAe,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;QAC7E,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC;YAChD,IAAI,EAAE,CAAC;YACP,OAAO;QACT,CAAC;QAED,MAAM,SAAS,GAAG,GAAG,CAAC,OAAO,CAAC,UAAU,CAAC,WAAW,EAAE,CAAC,CAAC;QACxD,MAAM,cAAc,GAAG,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QAE3E,IAAI,CAAC,cAAc,EAAE,CAAC;YACpB,IAAI,EAAE,CAAC;YACP,OAAO;QACT,CAAC;QAED,MAAM,OAAO,GAAG,QAAQ,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,IAAI,IAAI,cAAc,EAAE,CAAC;QAEnE,KAAK,WAAW;aACb,OAAO,CAAiB,OAAO,EAAE,GAAG,EAAE;YACrC,OAAO,IAAI,OAAO,CAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE;gBACtD,6CAA6C;gBAC7C,MAAM,YAAY,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBAExC,8DAA8D;gBAC9D,GAAG,CAAC,IAAI,GAAG,UAAU,IAAS;oBAC5B,MAAM,UAAU,GAAG,GAAG,CAAC,UAAU,IAAI,GAAG,CAAC;oBACzC,YAAY,CAAC,IAAI,CAAC,CAAC;oBACnB,OAAO,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;gBAChC,CAAC,CAAC;gBAEF,IAAI,EAAE,CAAC;YACT,CAAC,CAAC,CAAC;QACL,CAAC,CAAC;aACD,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE;YACf,mFAAmF;YACnF,IAAI,MAAM,EAAE,CAAC;gBACX,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YAClD,CAAC;QACH,CAAC,CAAC;aACD,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;YACb,IAAI,eAAe,CAAC,GAAG,CAAC,EAAE,CAAC;gBACzB,IAAI,GAAG,CAAC,IAAI,KAAK,qBAAqB,EAAE,CAAC;oBACvC,0BAA0B;oBAC1B,OAAO;gBACT,CAAC;gBACD,IAAI,GAAG,CAAC,IAAI,KAAK,aAAa,EAAE,CAAC;oBAC/B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;wBACnB,KAAK,EAAE,sCAAsC;wBAC7C,IAAI,EAAE,aAAa;wBACnB,GAAG,EAAE,GAAG,CAAC,GAAG;qBACb,CAAC,CAAC;oBACH,OAAO;gBACT,CAAC;YACH,CAAC;YACD,IAAI,CAAC,GAAG,CAAC,CAAC;QACZ,CAAC,CAAC,CAAC;IACP,CAAC,CAAC;AACJ,CAAC"}
|