@vibeorm/runtime 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,54 @@ Per-operation `countStrategy` takes precedence over the client default.
59
59
 
60
60
  Bridges a `DatabaseAdapter` to a `SqlExecutor` for use with the migrate package.
61
61
 
62
+ ### `withRetry(fn, options?)`
63
+
64
+ Retry utility with exponential backoff and jitter. Only retries `VibeTransientError` instances (connection failures, deadlocks, serialization conflicts, statement timeouts, pool exhaustion). Deterministic errors (`VibeRequestError`) are never retried.
65
+
66
+ ```ts
67
+ import { withRetry } from "@vibeorm/runtime";
68
+
69
+ // Basic — 3 retries, exponential backoff with jitter
70
+ const users = await withRetry(() =>
71
+ db.user.findMany({ where: { active: true } })
72
+ );
73
+
74
+ // Custom options
75
+ const user = await withRetry(
76
+ () => db.user.create({ data: { email: "new@example.com" } }),
77
+ { maxRetries: 5, baseDelay: 100, maxDelay: 10000 },
78
+ );
79
+
80
+ // Retry only specific error codes
81
+ const result = await withRetry(
82
+ () => db.$transaction(async (tx) => {
83
+ /* ... */
84
+ }, { isolationLevel: "Serializable" }),
85
+ { retryOn: ["SERIALIZATION_FAILURE", "DEADLOCK"] },
86
+ );
87
+
88
+ // With abort signal
89
+ const controller = new AbortController();
90
+ const result = await withRetry(
91
+ () => db.user.findFirst({ where: { id: 1 } }),
92
+ { signal: controller.signal },
93
+ );
94
+ ```
95
+
96
+ **Options (`RetryOptions`):**
97
+
98
+ | Option | Default | Description |
99
+ |--------|---------|-------------|
100
+ | `maxRetries` | `3` | Max retry attempts (total executions = maxRetries + 1) |
101
+ | `baseDelay` | `50` | Base delay in ms for exponential backoff |
102
+ | `maxDelay` | `5000` | Maximum delay cap in ms |
103
+ | `maxJitter` | `50` | Random jitter added per retry to prevent thundering herd |
104
+ | `signal` | — | `AbortSignal` to cancel pending retries |
105
+ | `retryOn` | — | Filter to retry only specific `VibeTransientErrorCode`s |
106
+ | `onRetry` | — | Callback `({ error, attempt, delay }) => void` for logging/metrics |
107
+
108
+ Backoff formula: `min(baseDelay * 2^attempt + random(0, maxJitter), maxDelay)`
109
+
62
110
  ### `VibeValidationError`
63
111
 
64
112
  Custom error class thrown when Zod validation fails on input or output data.
@@ -86,7 +134,7 @@ type DatabaseAdapter = {
86
134
 
87
135
  ### Exported Types
88
136
 
89
- `DatabaseAdapter`, `QueryResult`, `SqlExecutor`, `VibeClientOptions`, `ModelMeta`, `ModelMetaMap`, `ModelSchemas`, `ValidationSchema`, `QueryProfile`, `RelationProfile`, `ScalarFieldMeta`, `RelationFieldMeta`, `SqlQuery`, `Operation`.
137
+ `DatabaseAdapter`, `QueryResult`, `SqlExecutor`, `VibeClientOptions`, `ModelMeta`, `ModelMetaMap`, `ModelSchemas`, `ValidationSchema`, `QueryProfile`, `RelationProfile`, `ScalarFieldMeta`, `RelationFieldMeta`, `SqlQuery`, `Operation`, `RetryOptions`.
90
138
 
91
139
  ## License
92
140
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibeorm/runtime",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Driver-agnostic query engine and client runtime for VibeORM",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -20,8 +20,10 @@ export {
20
20
  executeLateralJoinQuery,
21
21
  } from "./lateral-join-builder.ts";
22
22
  export { toSqlExecutor } from "./adapter.ts";
23
+ export { withRetry } from "./retry.ts";
23
24
  export { ViewResult, createView } from "./view.ts";
24
25
  export type { ViewDefinition } from "./view.ts";
26
+ export type { RetryOptions } from "./retry.ts";
25
27
 
26
28
  export type {
27
29
  DatabaseAdapter,
package/src/retry.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Retry utility with exponential backoff and jitter.
3
+ *
4
+ * Only retries on transient errors (VibeTransientError) — connection failures,
5
+ * deadlocks, serialization failures, statement timeouts, and pool exhaustion.
6
+ * Deterministic errors (VibeRequestError) are never retried.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { withRetry } from "@vibeorm/runtime";
11
+ *
12
+ * // Basic — 3 retries, exponential backoff with jitter
13
+ * const users = await withRetry(() =>
14
+ * db.user.findMany({ where: { active: true } })
15
+ * );
16
+ *
17
+ * // Custom options
18
+ * const user = await withRetry(
19
+ * () => db.user.create({ data: { email: "new@example.com" } }),
20
+ * { maxRetries: 5, baseDelay: 100, maxDelay: 10000 },
21
+ * );
22
+ *
23
+ * // With abort signal
24
+ * const controller = new AbortController();
25
+ * const result = await withRetry(
26
+ * () => db.user.findFirst({ where: { id: 1 } }),
27
+ * { signal: controller.signal },
28
+ * );
29
+ *
30
+ * // Retry only specific error codes
31
+ * const result = await withRetry(
32
+ * () => db.$transaction(async (tx) => { ... }, { isolationLevel: "Serializable" }),
33
+ * { retryOn: ["SERIALIZATION_FAILURE", "DEADLOCK"] },
34
+ * );
35
+ * ```
36
+ */
37
+
38
+ import { VibeTransientError } from "./errors.ts";
39
+ import type { VibeTransientErrorCode } from "./errors.ts";
40
+
41
+ // ─── Types ───────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Options for configuring retry behavior.
45
+ */
46
+ export type RetryOptions = {
47
+ /**
48
+ * Maximum number of retry attempts (default: 3).
49
+ * The first execution is not counted as a retry, so the operation
50
+ * may execute up to `maxRetries + 1` times total.
51
+ */
52
+ maxRetries?: number;
53
+ /**
54
+ * Base delay in milliseconds for exponential backoff (default: 50).
55
+ * Actual delay = min(baseDelay * 2^attempt + jitter, maxDelay).
56
+ */
57
+ baseDelay?: number;
58
+ /**
59
+ * Maximum delay in milliseconds between retries (default: 5000).
60
+ * Caps the exponential growth to prevent excessively long waits.
61
+ */
62
+ maxDelay?: number;
63
+ /**
64
+ * Maximum jitter in milliseconds added to each delay (default: 50).
65
+ * Randomized per retry to prevent thundering herd effects when
66
+ * many clients retry simultaneously after a shared failure.
67
+ */
68
+ maxJitter?: number;
69
+ /**
70
+ * Optional AbortSignal to cancel pending retries.
71
+ * When aborted, the last error is thrown immediately without
72
+ * further retry attempts.
73
+ */
74
+ signal?: AbortSignal;
75
+ /**
76
+ * Optional filter to retry only specific transient error codes.
77
+ * When provided, only errors with matching codes are retried;
78
+ * other transient errors are thrown immediately.
79
+ *
80
+ * When not provided, all transient errors are retried.
81
+ */
82
+ retryOn?: VibeTransientErrorCode[];
83
+ /**
84
+ * Optional callback invoked before each retry attempt.
85
+ * Useful for logging or metrics.
86
+ */
87
+ onRetry?: (params: { error: VibeTransientError; attempt: number; delay: number }) => void;
88
+ };
89
+
90
+ // ─── Implementation ──────────────────────────────────────────────
91
+
92
+ /**
93
+ * Compute the delay for a retry attempt using exponential backoff with jitter.
94
+ * Formula: min(baseDelay * 2^attempt + random(0, maxJitter), maxDelay)
95
+ */
96
+ function computeDelay(params: {
97
+ attempt: number;
98
+ baseDelay: number;
99
+ maxDelay: number;
100
+ maxJitter: number;
101
+ }): number {
102
+ const { attempt, baseDelay, maxDelay, maxJitter } = params;
103
+ const exponential = baseDelay * Math.pow(2, attempt);
104
+ const jitter = Math.random() * maxJitter;
105
+ return Math.min(exponential + jitter, maxDelay);
106
+ }
107
+
108
+ /**
109
+ * Sleep for a given number of milliseconds.
110
+ * Resolves immediately if the signal is already aborted.
111
+ */
112
+ function sleep(params: { ms: number; signal?: AbortSignal }): Promise<void> {
113
+ const { ms, signal } = params;
114
+ if (signal?.aborted) return Promise.resolve();
115
+ return new Promise((resolve) => {
116
+ const timer = setTimeout(resolve, ms);
117
+ signal?.addEventListener("abort", () => {
118
+ clearTimeout(timer);
119
+ resolve();
120
+ }, { once: true });
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Execute a function with automatic retry on transient errors.
126
+ *
127
+ * Uses exponential backoff with jitter to space out retries and
128
+ * prevent thundering herd effects. Only retries `VibeTransientError`
129
+ * instances — deterministic errors (constraint violations, validation
130
+ * errors, etc.) are thrown immediately.
131
+ *
132
+ * @param fn - The async operation to execute and potentially retry.
133
+ * @param options - Optional retry configuration.
134
+ * @returns The result of the first successful execution.
135
+ * @throws The last error if all retries are exhausted.
136
+ */
137
+ export async function withRetry<T>(
138
+ fn: () => Promise<T>,
139
+ options?: RetryOptions,
140
+ ): Promise<T> {
141
+ const maxRetries = options?.maxRetries ?? 3;
142
+ const baseDelay = options?.baseDelay ?? 50;
143
+ const maxDelay = options?.maxDelay ?? 5000;
144
+ const maxJitter = options?.maxJitter ?? 50;
145
+ const signal = options?.signal;
146
+ const retryOn = options?.retryOn;
147
+ const onRetry = options?.onRetry;
148
+
149
+ let lastError: unknown;
150
+
151
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
152
+ try {
153
+ return await fn();
154
+ } catch (err) {
155
+ lastError = err;
156
+
157
+ // Only retry transient errors
158
+ if (!(err instanceof VibeTransientError)) throw err;
159
+
160
+ // If retryOn filter is set, only retry matching codes
161
+ if (retryOn && !retryOn.includes(err.code)) throw err;
162
+
163
+ // Don't retry if we've exhausted attempts
164
+ if (attempt >= maxRetries) throw err;
165
+
166
+ // Don't retry if aborted
167
+ if (signal?.aborted) throw err;
168
+
169
+ const delay = computeDelay({ attempt, baseDelay, maxDelay, maxJitter });
170
+
171
+ // Notify before sleeping
172
+ onRetry?.({ error: err, attempt: attempt + 1, delay });
173
+
174
+ await sleep({ ms: delay, signal });
175
+
176
+ // Check abort again after sleep
177
+ if (signal?.aborted) throw err;
178
+ }
179
+ }
180
+
181
+ // Should never reach here, but TypeScript needs it
182
+ throw lastError;
183
+ }