@oncely/core 0.2.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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Storage adapter interface for oncely.
3
+ * Implement this interface to create custom storage backends.
4
+ */
5
+ interface StorageAdapter {
6
+ /**
7
+ * Attempt to acquire a lock for the given key.
8
+ * Returns the current state of the key.
9
+ */
10
+ acquire(key: string, hash: string | null, ttl: number): Promise<AcquireResult>;
11
+ /**
12
+ * Save a completed response for the given key.
13
+ */
14
+ save(key: string, response: StoredResponse): Promise<void>;
15
+ /**
16
+ * Release a lock without saving a response.
17
+ * Called when the handler fails and the request should be retryable.
18
+ */
19
+ release(key: string): Promise<void>;
20
+ /**
21
+ * Delete a key from storage.
22
+ * Useful for testing or manual cleanup.
23
+ */
24
+ delete(key: string): Promise<void>;
25
+ /**
26
+ * Clear all keys from storage.
27
+ * Useful for testing.
28
+ */
29
+ clear(): Promise<void>;
30
+ }
31
+ /**
32
+ * Result of attempting to acquire a lock.
33
+ */
34
+ type AcquireResult = {
35
+ status: 'acquired';
36
+ } | {
37
+ status: 'hit';
38
+ response: StoredResponse;
39
+ } | {
40
+ status: 'conflict';
41
+ startedAt: number;
42
+ } | {
43
+ status: 'mismatch';
44
+ existingHash: string;
45
+ providedHash: string;
46
+ };
47
+ /**
48
+ * A stored idempotency response.
49
+ */
50
+ interface StoredResponse {
51
+ /** The response data returned by the handler */
52
+ data: unknown;
53
+ /** When this response was created */
54
+ createdAt: number;
55
+ /** The hash of the original request (if provided) */
56
+ hash: string | null;
57
+ }
58
+ /**
59
+ * Callback fired when a cached response is returned.
60
+ */
61
+ type OnHitCallback = (key: string, response: StoredResponse) => void;
62
+ /**
63
+ * Callback fired when a new request is processed (cache miss).
64
+ */
65
+ type OnMissCallback = (key: string) => void;
66
+ /**
67
+ * Callback fired when a conflict occurs (request already in progress).
68
+ */
69
+ type OnConflictCallback = (key: string) => void;
70
+ /**
71
+ * Callback fired when an error occurs.
72
+ */
73
+ type OnErrorCallback = (key: string, error: Error) => void;
74
+ /**
75
+ * Global configuration options for oncely.
76
+ */
77
+ interface OncelyConfig {
78
+ /** Storage adapter for persisting idempotency records */
79
+ storage?: StorageAdapter;
80
+ /**
81
+ * Time-to-live for idempotency keys.
82
+ * Can be a number (milliseconds) or a string like '24h', '7d', '30m'.
83
+ * @default '24h'
84
+ */
85
+ ttl?: number | string;
86
+ /**
87
+ * Whether to validate request fingerprints (hash).
88
+ * When true, reusing a key with different payload throws MismatchError.
89
+ * @default true
90
+ */
91
+ fingerprint?: boolean;
92
+ /**
93
+ * Whether to log debug information.
94
+ * @default false
95
+ */
96
+ debug?: boolean;
97
+ /**
98
+ * Callback fired when a cached response is returned.
99
+ */
100
+ onHit?: OnHitCallback;
101
+ /**
102
+ * Callback fired when a new request is processed (cache miss).
103
+ */
104
+ onMiss?: OnMissCallback;
105
+ /**
106
+ * Callback fired when a conflict occurs (request already in progress).
107
+ */
108
+ onConflict?: OnConflictCallback;
109
+ /**
110
+ * Callback fired when an error occurs.
111
+ */
112
+ onError?: OnErrorCallback;
113
+ }
114
+ /**
115
+ * Options for creating an oncely instance.
116
+ */
117
+ interface OncelyOptions extends OncelyConfig {
118
+ /** Storage adapter for persisting idempotency records (required for instance) */
119
+ storage: StorageAdapter;
120
+ }
121
+ /**
122
+ * Options for running an idempotent operation.
123
+ */
124
+ interface RunOptions<T> {
125
+ /** Unique idempotency key for this request */
126
+ key: string;
127
+ /**
128
+ * Hash of the request body/params for mismatch detection.
129
+ * If provided and a cached response exists with a different hash,
130
+ * a MismatchError will be thrown.
131
+ */
132
+ hash?: string;
133
+ /** The handler function to execute */
134
+ handler: () => T | Promise<T>;
135
+ }
136
+ /**
137
+ * Result of running an idempotent operation.
138
+ */
139
+ interface RunResult<T> {
140
+ /** The data returned by the handler (or cached) */
141
+ data: T;
142
+ /** Whether this was a cache hit */
143
+ cached: boolean;
144
+ /** Status of the operation */
145
+ status: 'created' | 'hit';
146
+ /** When the response was originally created (if cached) */
147
+ createdAt?: number;
148
+ }
149
+
150
+ /**
151
+ * Oncely idempotency service.
152
+ * Ensures operations are executed exactly once per idempotency key.
153
+ */
154
+ declare class Oncely {
155
+ private readonly storage;
156
+ private readonly ttl;
157
+ private readonly debug;
158
+ private readonly onHit?;
159
+ private readonly onMiss?;
160
+ private readonly onConflict?;
161
+ private readonly onError?;
162
+ constructor(options: OncelyOptions);
163
+ /**
164
+ * Run an operation with idempotency protection.
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * const result = await idempotency.run({
169
+ * key: 'order-123',
170
+ * handler: async () => {
171
+ * const order = await createOrder(data);
172
+ * return order;
173
+ * },
174
+ * });
175
+ *
176
+ * if (result.cached) {
177
+ * console.log('Returned cached response');
178
+ * }
179
+ * ```
180
+ */
181
+ run<T>(options: RunOptions<T>): Promise<RunResult<T>>;
182
+ private log;
183
+ }
184
+ /**
185
+ * Create an oncely idempotency instance.
186
+ *
187
+ * Options are optional - will use global config or sensible defaults.
188
+ *
189
+ * @example
190
+ * ```typescript
191
+ * import { createInstance } from '@oncely/core';
192
+ *
193
+ * // Zero-config (uses memory storage)
194
+ * const idempotency = createInstance();
195
+ *
196
+ * // With explicit storage
197
+ * const idempotency = createInstance({ storage: myRedis });
198
+ *
199
+ * const result = await idempotency.run({
200
+ * key: 'order-123',
201
+ * handler: async () => createOrder(data),
202
+ * });
203
+ * ```
204
+ */
205
+ declare function createInstance(options?: Partial<OncelyOptions>): Oncely;
206
+
207
+ /**
208
+ * In-memory storage adapter.
209
+ * Suitable for development, testing, and single-instance deployments.
210
+ *
211
+ * **Warning:** Data is lost on restart and not shared across instances.
212
+ * Use Redis for production multi-instance deployments.
213
+ */
214
+ declare class MemoryStorage implements StorageAdapter {
215
+ private store;
216
+ private cleanupInterval;
217
+ constructor();
218
+ acquire(key: string, hash: string | null, ttl: number): Promise<AcquireResult>;
219
+ save(key: string, response: StoredResponse): Promise<void>;
220
+ release(key: string): Promise<void>;
221
+ delete(key: string): Promise<void>;
222
+ clear(): Promise<void>;
223
+ /**
224
+ * Stop the cleanup interval.
225
+ * Call this when shutting down to prevent memory leaks in tests.
226
+ */
227
+ destroy(): void;
228
+ private cleanup;
229
+ }
230
+
231
+ /**
232
+ * RFC 7807 Problem Details response format.
233
+ * @see https://www.rfc-editor.org/rfc/rfc7807
234
+ */
235
+ interface ProblemDetails {
236
+ /** URI reference identifying the problem type */
237
+ type: string;
238
+ /** Short human-readable summary */
239
+ title: string;
240
+ /** HTTP status code */
241
+ status: number;
242
+ /** Detailed human-readable explanation */
243
+ detail: string;
244
+ /** URI reference to the specific occurrence (optional) */
245
+ instance?: string;
246
+ /** Additional properties */
247
+ [key: string]: unknown;
248
+ }
249
+ /**
250
+ * Base class for all oncely errors.
251
+ * Provides RFC 7807 Problem Details format for HTTP responses.
252
+ */
253
+ declare class IdempotencyError extends Error {
254
+ /** HTTP status code for this error */
255
+ readonly statusCode: number;
256
+ /** Error type identifier (URL) */
257
+ readonly type: string;
258
+ /** Short title for the error */
259
+ readonly title: string;
260
+ constructor(message: string, statusCode: number, type: string, title: string);
261
+ /**
262
+ * Convert to RFC 7807 Problem Details format.
263
+ */
264
+ toProblemDetails(): ProblemDetails;
265
+ /**
266
+ * Convert to JSON (RFC 7807 format).
267
+ */
268
+ toJSON(): ProblemDetails;
269
+ }
270
+ /**
271
+ * Thrown when an idempotency key is required but not provided.
272
+ * HTTP 400 Bad Request
273
+ */
274
+ declare class MissingKeyError extends IdempotencyError {
275
+ constructor();
276
+ }
277
+ /**
278
+ * Thrown when a request with the same key is already being processed.
279
+ * HTTP 409 Conflict
280
+ */
281
+ declare class ConflictError extends IdempotencyError {
282
+ /** When the in-progress request started */
283
+ readonly startedAt: number;
284
+ /** Suggested retry delay in seconds */
285
+ readonly retryAfter: number;
286
+ constructor(startedAt: number);
287
+ toProblemDetails(): ProblemDetails;
288
+ }
289
+ /**
290
+ * Thrown when the same idempotency key is used with a different request payload.
291
+ * HTTP 422 Unprocessable Content
292
+ */
293
+ declare class MismatchError extends IdempotencyError {
294
+ /** Hash of the original request */
295
+ readonly existingHash: string;
296
+ /** Hash of the current request */
297
+ readonly providedHash: string;
298
+ constructor(existingHash: string, providedHash: string);
299
+ }
300
+ /**
301
+ * Thrown when the storage adapter encounters an error.
302
+ * HTTP 500 Internal Server Error
303
+ */
304
+ declare class StorageError extends IdempotencyError {
305
+ /** The underlying error from the storage adapter */
306
+ readonly cause: Error;
307
+ constructor(message: string, cause: Error);
308
+ }
309
+
310
+ export { type AcquireResult as A, ConflictError as C, IdempotencyError as I, MemoryStorage as M, type OncelyConfig as O, type ProblemDetails as P, type RunOptions as R, type StorageAdapter as S, Oncely as a, MissingKeyError as b, createInstance as c, MismatchError as d, StorageError as e, type StoredResponse as f, type OncelyOptions as g, type RunResult as h, type OnHitCallback as i, type OnMissCallback as j, type OnConflictCallback as k, type OnErrorCallback as l };
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Storage adapter interface for oncely.
3
+ * Implement this interface to create custom storage backends.
4
+ */
5
+ interface StorageAdapter {
6
+ /**
7
+ * Attempt to acquire a lock for the given key.
8
+ * Returns the current state of the key.
9
+ */
10
+ acquire(key: string, hash: string | null, ttl: number): Promise<AcquireResult>;
11
+ /**
12
+ * Save a completed response for the given key.
13
+ */
14
+ save(key: string, response: StoredResponse): Promise<void>;
15
+ /**
16
+ * Release a lock without saving a response.
17
+ * Called when the handler fails and the request should be retryable.
18
+ */
19
+ release(key: string): Promise<void>;
20
+ /**
21
+ * Delete a key from storage.
22
+ * Useful for testing or manual cleanup.
23
+ */
24
+ delete(key: string): Promise<void>;
25
+ /**
26
+ * Clear all keys from storage.
27
+ * Useful for testing.
28
+ */
29
+ clear(): Promise<void>;
30
+ }
31
+ /**
32
+ * Result of attempting to acquire a lock.
33
+ */
34
+ type AcquireResult = {
35
+ status: 'acquired';
36
+ } | {
37
+ status: 'hit';
38
+ response: StoredResponse;
39
+ } | {
40
+ status: 'conflict';
41
+ startedAt: number;
42
+ } | {
43
+ status: 'mismatch';
44
+ existingHash: string;
45
+ providedHash: string;
46
+ };
47
+ /**
48
+ * A stored idempotency response.
49
+ */
50
+ interface StoredResponse {
51
+ /** The response data returned by the handler */
52
+ data: unknown;
53
+ /** When this response was created */
54
+ createdAt: number;
55
+ /** The hash of the original request (if provided) */
56
+ hash: string | null;
57
+ }
58
+ /**
59
+ * Callback fired when a cached response is returned.
60
+ */
61
+ type OnHitCallback = (key: string, response: StoredResponse) => void;
62
+ /**
63
+ * Callback fired when a new request is processed (cache miss).
64
+ */
65
+ type OnMissCallback = (key: string) => void;
66
+ /**
67
+ * Callback fired when a conflict occurs (request already in progress).
68
+ */
69
+ type OnConflictCallback = (key: string) => void;
70
+ /**
71
+ * Callback fired when an error occurs.
72
+ */
73
+ type OnErrorCallback = (key: string, error: Error) => void;
74
+ /**
75
+ * Global configuration options for oncely.
76
+ */
77
+ interface OncelyConfig {
78
+ /** Storage adapter for persisting idempotency records */
79
+ storage?: StorageAdapter;
80
+ /**
81
+ * Time-to-live for idempotency keys.
82
+ * Can be a number (milliseconds) or a string like '24h', '7d', '30m'.
83
+ * @default '24h'
84
+ */
85
+ ttl?: number | string;
86
+ /**
87
+ * Whether to validate request fingerprints (hash).
88
+ * When true, reusing a key with different payload throws MismatchError.
89
+ * @default true
90
+ */
91
+ fingerprint?: boolean;
92
+ /**
93
+ * Whether to log debug information.
94
+ * @default false
95
+ */
96
+ debug?: boolean;
97
+ /**
98
+ * Callback fired when a cached response is returned.
99
+ */
100
+ onHit?: OnHitCallback;
101
+ /**
102
+ * Callback fired when a new request is processed (cache miss).
103
+ */
104
+ onMiss?: OnMissCallback;
105
+ /**
106
+ * Callback fired when a conflict occurs (request already in progress).
107
+ */
108
+ onConflict?: OnConflictCallback;
109
+ /**
110
+ * Callback fired when an error occurs.
111
+ */
112
+ onError?: OnErrorCallback;
113
+ }
114
+ /**
115
+ * Options for creating an oncely instance.
116
+ */
117
+ interface OncelyOptions extends OncelyConfig {
118
+ /** Storage adapter for persisting idempotency records (required for instance) */
119
+ storage: StorageAdapter;
120
+ }
121
+ /**
122
+ * Options for running an idempotent operation.
123
+ */
124
+ interface RunOptions<T> {
125
+ /** Unique idempotency key for this request */
126
+ key: string;
127
+ /**
128
+ * Hash of the request body/params for mismatch detection.
129
+ * If provided and a cached response exists with a different hash,
130
+ * a MismatchError will be thrown.
131
+ */
132
+ hash?: string;
133
+ /** The handler function to execute */
134
+ handler: () => T | Promise<T>;
135
+ }
136
+ /**
137
+ * Result of running an idempotent operation.
138
+ */
139
+ interface RunResult<T> {
140
+ /** The data returned by the handler (or cached) */
141
+ data: T;
142
+ /** Whether this was a cache hit */
143
+ cached: boolean;
144
+ /** Status of the operation */
145
+ status: 'created' | 'hit';
146
+ /** When the response was originally created (if cached) */
147
+ createdAt?: number;
148
+ }
149
+
150
+ /**
151
+ * Oncely idempotency service.
152
+ * Ensures operations are executed exactly once per idempotency key.
153
+ */
154
+ declare class Oncely {
155
+ private readonly storage;
156
+ private readonly ttl;
157
+ private readonly debug;
158
+ private readonly onHit?;
159
+ private readonly onMiss?;
160
+ private readonly onConflict?;
161
+ private readonly onError?;
162
+ constructor(options: OncelyOptions);
163
+ /**
164
+ * Run an operation with idempotency protection.
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * const result = await idempotency.run({
169
+ * key: 'order-123',
170
+ * handler: async () => {
171
+ * const order = await createOrder(data);
172
+ * return order;
173
+ * },
174
+ * });
175
+ *
176
+ * if (result.cached) {
177
+ * console.log('Returned cached response');
178
+ * }
179
+ * ```
180
+ */
181
+ run<T>(options: RunOptions<T>): Promise<RunResult<T>>;
182
+ private log;
183
+ }
184
+ /**
185
+ * Create an oncely idempotency instance.
186
+ *
187
+ * Options are optional - will use global config or sensible defaults.
188
+ *
189
+ * @example
190
+ * ```typescript
191
+ * import { createInstance } from '@oncely/core';
192
+ *
193
+ * // Zero-config (uses memory storage)
194
+ * const idempotency = createInstance();
195
+ *
196
+ * // With explicit storage
197
+ * const idempotency = createInstance({ storage: myRedis });
198
+ *
199
+ * const result = await idempotency.run({
200
+ * key: 'order-123',
201
+ * handler: async () => createOrder(data),
202
+ * });
203
+ * ```
204
+ */
205
+ declare function createInstance(options?: Partial<OncelyOptions>): Oncely;
206
+
207
+ /**
208
+ * In-memory storage adapter.
209
+ * Suitable for development, testing, and single-instance deployments.
210
+ *
211
+ * **Warning:** Data is lost on restart and not shared across instances.
212
+ * Use Redis for production multi-instance deployments.
213
+ */
214
+ declare class MemoryStorage implements StorageAdapter {
215
+ private store;
216
+ private cleanupInterval;
217
+ constructor();
218
+ acquire(key: string, hash: string | null, ttl: number): Promise<AcquireResult>;
219
+ save(key: string, response: StoredResponse): Promise<void>;
220
+ release(key: string): Promise<void>;
221
+ delete(key: string): Promise<void>;
222
+ clear(): Promise<void>;
223
+ /**
224
+ * Stop the cleanup interval.
225
+ * Call this when shutting down to prevent memory leaks in tests.
226
+ */
227
+ destroy(): void;
228
+ private cleanup;
229
+ }
230
+
231
+ /**
232
+ * RFC 7807 Problem Details response format.
233
+ * @see https://www.rfc-editor.org/rfc/rfc7807
234
+ */
235
+ interface ProblemDetails {
236
+ /** URI reference identifying the problem type */
237
+ type: string;
238
+ /** Short human-readable summary */
239
+ title: string;
240
+ /** HTTP status code */
241
+ status: number;
242
+ /** Detailed human-readable explanation */
243
+ detail: string;
244
+ /** URI reference to the specific occurrence (optional) */
245
+ instance?: string;
246
+ /** Additional properties */
247
+ [key: string]: unknown;
248
+ }
249
+ /**
250
+ * Base class for all oncely errors.
251
+ * Provides RFC 7807 Problem Details format for HTTP responses.
252
+ */
253
+ declare class IdempotencyError extends Error {
254
+ /** HTTP status code for this error */
255
+ readonly statusCode: number;
256
+ /** Error type identifier (URL) */
257
+ readonly type: string;
258
+ /** Short title for the error */
259
+ readonly title: string;
260
+ constructor(message: string, statusCode: number, type: string, title: string);
261
+ /**
262
+ * Convert to RFC 7807 Problem Details format.
263
+ */
264
+ toProblemDetails(): ProblemDetails;
265
+ /**
266
+ * Convert to JSON (RFC 7807 format).
267
+ */
268
+ toJSON(): ProblemDetails;
269
+ }
270
+ /**
271
+ * Thrown when an idempotency key is required but not provided.
272
+ * HTTP 400 Bad Request
273
+ */
274
+ declare class MissingKeyError extends IdempotencyError {
275
+ constructor();
276
+ }
277
+ /**
278
+ * Thrown when a request with the same key is already being processed.
279
+ * HTTP 409 Conflict
280
+ */
281
+ declare class ConflictError extends IdempotencyError {
282
+ /** When the in-progress request started */
283
+ readonly startedAt: number;
284
+ /** Suggested retry delay in seconds */
285
+ readonly retryAfter: number;
286
+ constructor(startedAt: number);
287
+ toProblemDetails(): ProblemDetails;
288
+ }
289
+ /**
290
+ * Thrown when the same idempotency key is used with a different request payload.
291
+ * HTTP 422 Unprocessable Content
292
+ */
293
+ declare class MismatchError extends IdempotencyError {
294
+ /** Hash of the original request */
295
+ readonly existingHash: string;
296
+ /** Hash of the current request */
297
+ readonly providedHash: string;
298
+ constructor(existingHash: string, providedHash: string);
299
+ }
300
+ /**
301
+ * Thrown when the storage adapter encounters an error.
302
+ * HTTP 500 Internal Server Error
303
+ */
304
+ declare class StorageError extends IdempotencyError {
305
+ /** The underlying error from the storage adapter */
306
+ readonly cause: Error;
307
+ constructor(message: string, cause: Error);
308
+ }
309
+
310
+ export { type AcquireResult as A, ConflictError as C, IdempotencyError as I, MemoryStorage as M, type OncelyConfig as O, type ProblemDetails as P, type RunOptions as R, type StorageAdapter as S, Oncely as a, MissingKeyError as b, createInstance as c, MismatchError as d, StorageError as e, type StoredResponse as f, type OncelyOptions as g, type RunResult as h, type OnHitCallback as i, type OnMissCallback as j, type OnConflictCallback as k, type OnErrorCallback as l };