@vibeorm/runtime 1.3.0 → 2.0.0-alpha.2

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 (44) hide show
  1. package/README.md +50 -107
  2. package/dist/adapter.d.ts +124 -0
  3. package/dist/adapter.d.ts.map +1 -0
  4. package/dist/client.d.ts +152 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/codecs.d.ts +170 -0
  7. package/dist/codecs.d.ts.map +1 -0
  8. package/dist/computed.d.ts +43 -0
  9. package/dist/computed.d.ts.map +1 -0
  10. package/dist/extensions.d.ts +102 -0
  11. package/dist/extensions.d.ts.map +1 -0
  12. package/dist/index.d.ts +29 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +6625 -0
  15. package/dist/index.js.map +21 -0
  16. package/dist/model-meta.d.ts +156 -0
  17. package/dist/model-meta.d.ts.map +1 -0
  18. package/dist/nested-writes.d.ts +100 -0
  19. package/dist/nested-writes.d.ts.map +1 -0
  20. package/dist/query-builder.d.ts +250 -0
  21. package/dist/query-builder.d.ts.map +1 -0
  22. package/dist/relation-loader.d.ts +75 -0
  23. package/dist/relation-loader.d.ts.map +1 -0
  24. package/dist/relation-plan.d.ts +103 -0
  25. package/dist/relation-plan.d.ts.map +1 -0
  26. package/dist/render-cache.d.ts +48 -0
  27. package/dist/render-cache.d.ts.map +1 -0
  28. package/dist/views.d.ts +97 -0
  29. package/dist/views.d.ts.map +1 -0
  30. package/package.json +31 -26
  31. package/src/adapter.ts +0 -146
  32. package/src/client.ts +0 -2172
  33. package/src/coerce.ts +0 -184
  34. package/src/count-loader.ts +0 -152
  35. package/src/errors.ts +0 -492
  36. package/src/id-generators.ts +0 -151
  37. package/src/index.ts +0 -55
  38. package/src/lateral-join-builder.ts +0 -1053
  39. package/src/query-builder.ts +0 -1832
  40. package/src/relation-loader.ts +0 -534
  41. package/src/retry.ts +0 -183
  42. package/src/types.ts +0 -317
  43. package/src/view.ts +0 -629
  44. package/src/where-builder.ts +0 -772
@@ -1,534 +0,0 @@
1
- /**
2
- * Relation Loader
3
- *
4
- * Implements the hybrid loading strategy:
5
- * - To-one relations: LEFT JOIN in the main query (future optimization)
6
- * - To-many relations: Separate batched WHERE IN queries
7
- *
8
- * Currently uses batched queries for all relation types for simplicity.
9
- * The JOIN strategy for to-one can be added later as an optimization.
10
- */
11
-
12
- import type {
13
- ModelMeta,
14
- ModelMetaMap,
15
- RelationFieldMeta,
16
- ProfilingContext,
17
- } from "./types.ts";
18
- import { getModelByNameMap, getScalarFieldMap, PgArray } from "./types.ts";
19
- import { buildRelationQuery, buildManyToManyQuery, buildSelectQuery } from "./query-builder.ts";
20
- import type { RelationSqlQuery } from "./query-builder.ts";
21
- import { buildWhereClause } from "./where-builder.ts";
22
- import { coerceFieldTypes } from "./coerce.ts";
23
-
24
- type SqlExecutor = (params: {
25
- text: string;
26
- values: unknown[];
27
- }) => Promise<Record<string, unknown>[]>;
28
-
29
- /**
30
- * Load relations for a set of parent records.
31
- *
32
- * Examines the select/include args to determine which relations to load,
33
- * executes batched queries in PARALLEL, and stitches the results onto
34
- * parent records.
35
- *
36
- * Sibling relations are independent — they query different tables and write
37
- * to different keys on the parent records — so they can safely run concurrently.
38
- */
39
- export async function loadRelations(params: {
40
- parentRecords: Record<string, unknown>[];
41
- parentModelMeta: ModelMeta;
42
- allModelsMeta: ModelMetaMap;
43
- args: Record<string, unknown>;
44
- executor: SqlExecutor;
45
- profilingCtx?: ProfilingContext;
46
- defaultOrderByPk?: boolean;
47
- }): Promise<Record<string, unknown>[]> {
48
- const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx, defaultOrderByPk = false } =
49
- params;
50
-
51
- if (parentRecords.length === 0) return parentRecords;
52
-
53
- const relationsToLoad = resolveRelationsToLoad({
54
- parentModelMeta,
55
- args,
56
- });
57
-
58
- if (relationsToLoad.length === 0) return parentRecords;
59
-
60
- const modelMap = getModelByNameMap({ allModelsMeta });
61
- const pkField = parentModelMeta.primaryKey[0]!;
62
- const parentIds = [
63
- ...new Set(
64
- parentRecords
65
- .map((r) => r[pkField])
66
- .filter((id) => id !== undefined && id !== null)
67
- ),
68
- ];
69
-
70
- if (parentIds.length === 0) return parentRecords;
71
-
72
- // Phase 1: Load all sibling relations in parallel.
73
- // Each relation query hits a different table and writes to a unique key
74
- // on the parent records, so there are no data races.
75
- await Promise.all(
76
- relationsToLoad.map(async ({ relationMeta, nestedArgs }) => {
77
- const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
78
- if (!relatedModelMeta) return;
79
-
80
- if (relationMeta.isList) {
81
- await loadToManyRelation({
82
- parentRecords,
83
- parentModelMeta,
84
- relationMeta,
85
- relatedModelMeta,
86
- parentIds,
87
- nestedArgs,
88
- allModelsMeta,
89
- executor,
90
- pkField,
91
- profilingCtx,
92
- defaultOrderByPk,
93
- });
94
- } else if (relationMeta.isForeignKey) {
95
- await loadToOneWithFk({
96
- parentRecords,
97
- parentModelMeta,
98
- relationMeta,
99
- relatedModelMeta,
100
- nestedArgs,
101
- allModelsMeta,
102
- executor,
103
- profilingCtx,
104
- });
105
- } else {
106
- await loadToOneWithoutFk({
107
- parentRecords,
108
- parentModelMeta,
109
- relationMeta,
110
- relatedModelMeta,
111
- parentIds,
112
- nestedArgs,
113
- allModelsMeta,
114
- executor,
115
- pkField,
116
- profilingCtx,
117
- defaultOrderByPk,
118
- });
119
- }
120
- })
121
- );
122
-
123
- // Phase 2: Recursively load nested relations in parallel.
124
- // Each nested load operates on a different set of child records.
125
- await Promise.all(
126
- relationsToLoad
127
- .filter(({ nestedArgs }) => hasNestedRelations({ nestedArgs }))
128
- .map(async ({ relationMeta, nestedArgs }) => {
129
- const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
130
- if (!relatedModelMeta) return;
131
-
132
- const loadedRecords = parentRecords
133
- .flatMap((r) => {
134
- const val = r[relationMeta.name];
135
- if (Array.isArray(val)) return val;
136
- if (val && typeof val === "object") return [val];
137
- return [];
138
- })
139
- .filter((r): r is Record<string, unknown> => r !== null);
140
-
141
- if (loadedRecords.length > 0) {
142
- await loadRelations({
143
- parentRecords: loadedRecords,
144
- parentModelMeta: relatedModelMeta,
145
- allModelsMeta,
146
- args: nestedArgs,
147
- executor,
148
- });
149
-
150
- // Strip auto-included PKs from child records if user's select didn't include them
151
- const nestedSelect = nestedArgs.select as Record<string, boolean | object> | undefined;
152
- if (nestedSelect) {
153
- for (const pkName of relatedModelMeta.primaryKey) {
154
- if (nestedSelect[pkName] === undefined) {
155
- for (const rec of loadedRecords) {
156
- delete rec[pkName];
157
- }
158
- }
159
- }
160
- }
161
- }
162
- })
163
- );
164
-
165
- return parentRecords;
166
- }
167
-
168
- // ─── To-Many Loader ───────────────────────────────────────────────
169
-
170
- async function loadToManyRelation(params: {
171
- parentRecords: Record<string, unknown>[];
172
- parentModelMeta: ModelMeta;
173
- relationMeta: RelationFieldMeta;
174
- relatedModelMeta: ModelMeta;
175
- parentIds: unknown[];
176
- nestedArgs: Record<string, unknown>;
177
- allModelsMeta: ModelMetaMap;
178
- executor: SqlExecutor;
179
- pkField: string;
180
- profilingCtx?: ProfilingContext;
181
- defaultOrderByPk?: boolean;
182
- }): Promise<void> {
183
- const {
184
- parentRecords,
185
- parentModelMeta,
186
- relationMeta,
187
- relatedModelMeta,
188
- parentIds,
189
- nestedArgs,
190
- allModelsMeta,
191
- executor,
192
- pkField,
193
- profilingCtx,
194
- defaultOrderByPk = false,
195
- } = params;
196
-
197
- // Choose builder based on relation type
198
- const isM2M = relationMeta.type === "manyToMany" && (relationMeta as { joinTable?: string }).joinTable;
199
-
200
- const query = isM2M
201
- ? buildManyToManyQuery({
202
- parentModelMeta,
203
- relationMeta,
204
- relatedModelMeta,
205
- parentIds,
206
- args: nestedArgs,
207
- allModelsMeta,
208
- defaultOrderByPk,
209
- })
210
- : buildRelationQuery({
211
- parentModelMeta,
212
- relationMeta,
213
- relatedModelMeta,
214
- parentIds,
215
- args: nestedArgs,
216
- allModelsMeta,
217
- defaultOrderByPk,
218
- });
219
-
220
- const t0 = profilingCtx ? performance.now() : 0;
221
- const rows = await executor(query);
222
- if (profilingCtx) {
223
- profilingCtx.relationProfiles.push({
224
- relation: relationMeta.name,
225
- sqlExecMs: performance.now() - t0,
226
- rowCount: rows.length,
227
- sql: query.text,
228
- });
229
- }
230
-
231
- // Group by FK — use the already-selected field name when FK was deduped,
232
- // otherwise use the __vibeorm_fk alias
233
- const fkKey = query.fkFieldName ?? "__vibeorm_fk";
234
- const grouped = new Map<unknown, Record<string, unknown>[]>();
235
- for (const row of rows) {
236
- const fk = row[fkKey];
237
- if (!query.fkFieldName) delete row.__vibeorm_fk;
238
- // Also clean up __vibeorm_rn from ROW_NUMBER windowed queries
239
- if ("__vibeorm_rn" in row) delete row.__vibeorm_rn;
240
- if (!grouped.has(fk)) {
241
- grouped.set(fk, []);
242
- }
243
- grouped.get(fk)!.push(row);
244
- }
245
-
246
- // Coerce scalar types on the loaded child records (e.g., BigInt from string).
247
- // Mirrors what `loadRelationsForStrategy` does on parent records.
248
- coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
249
-
250
- // Stitch onto parent records
251
- for (const parent of parentRecords) {
252
- const parentId = parent[pkField];
253
- parent[relationMeta.name] = grouped.get(parentId) ?? [];
254
- }
255
- }
256
-
257
- // ─── To-One with FK (this side has foreignKey) ────────────────────
258
-
259
- async function loadToOneWithFk(params: {
260
- parentRecords: Record<string, unknown>[];
261
- parentModelMeta: ModelMeta;
262
- relationMeta: RelationFieldMeta;
263
- relatedModelMeta: ModelMeta;
264
- nestedArgs: Record<string, unknown>;
265
- allModelsMeta: ModelMetaMap;
266
- executor: SqlExecutor;
267
- profilingCtx?: ProfilingContext;
268
- }): Promise<void> {
269
- const {
270
- parentRecords,
271
- parentModelMeta,
272
- relationMeta,
273
- relatedModelMeta,
274
- nestedArgs,
275
- allModelsMeta,
276
- executor,
277
- profilingCtx,
278
- } = params;
279
-
280
- // Get FK values from parent records (deduplicated for smaller IN clauses)
281
- const fkFieldName = relationMeta.fields[0]!;
282
- const fkValues = [
283
- ...new Set(
284
- parentRecords
285
- .map((r) => r[fkFieldName])
286
- .filter((v) => v !== undefined && v !== null)
287
- ),
288
- ];
289
-
290
- if (fkValues.length === 0) {
291
- for (const parent of parentRecords) {
292
- parent[relationMeta.name] = null;
293
- }
294
- return;
295
- }
296
-
297
- // Query related model by its PK (which is what our FK references)
298
- const refField = relationMeta.references[0]!;
299
- const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
300
- const refScalarField = sfMap.get(refField);
301
- const refDbName = refScalarField?.dbName ?? refField;
302
-
303
- const columns = resolveSelectColumnsForRelation({
304
- modelMeta: relatedModelMeta,
305
- args: nestedArgs,
306
- });
307
- const table = `"${relatedModelMeta.dbName}"`;
308
- const columnsSql = columns
309
- .map((c) => `${table}."${c.dbName}" AS "${c.name}"`)
310
- .join(", ");
311
-
312
- // Build the base WHERE: pk = ANY($1) for batched loading
313
- const allValues: unknown[] = [new PgArray(fkValues)];
314
- let paramIdx = 1;
315
- let whereSql = `${table}."${refDbName}" = ANY($${paramIdx})`;
316
-
317
- // Apply nested where filters (e.g., include: { author: { where: { isActive: true } } })
318
- if (nestedArgs.where) {
319
- const subWhere = buildWhereClause({
320
- where: nestedArgs.where as Record<string, unknown>,
321
- modelMeta: relatedModelMeta,
322
- allModelsMeta,
323
- paramOffset: paramIdx,
324
- });
325
- if (subWhere.sql) {
326
- whereSql += ` AND (${subWhere.sql})`;
327
- allValues.push(...subWhere.values);
328
- paramIdx += subWhere.values.length;
329
- }
330
- }
331
-
332
- const text = `SELECT ${columnsSql}, ${table}."${refDbName}" AS "__vibeorm_pk" FROM ${table} WHERE ${whereSql}`;
333
-
334
- const t0 = profilingCtx ? performance.now() : 0;
335
- const rows = await executor({ text, values: allValues });
336
- if (profilingCtx) {
337
- profilingCtx.relationProfiles.push({
338
- relation: relationMeta.name,
339
- sqlExecMs: performance.now() - t0,
340
- rowCount: rows.length,
341
- sql: text,
342
- });
343
- }
344
-
345
- // Index by PK
346
- const byPk = new Map<unknown, Record<string, unknown>>();
347
- for (const row of rows) {
348
- const pk = row.__vibeorm_pk;
349
- delete row.__vibeorm_pk;
350
- byPk.set(pk, row);
351
- }
352
-
353
- // Coerce scalar types on the loaded child records before stitching.
354
- coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
355
-
356
- // Stitch
357
- for (const parent of parentRecords) {
358
- const fkValue = parent[fkFieldName];
359
- parent[relationMeta.name] = byPk.get(fkValue) ?? null;
360
- }
361
- }
362
-
363
- // ─── To-One without FK (other side has FK) ────────────────────────
364
-
365
- async function loadToOneWithoutFk(params: {
366
- parentRecords: Record<string, unknown>[];
367
- parentModelMeta: ModelMeta;
368
- relationMeta: RelationFieldMeta;
369
- relatedModelMeta: ModelMeta;
370
- parentIds: unknown[];
371
- nestedArgs: Record<string, unknown>;
372
- allModelsMeta: ModelMetaMap;
373
- executor: SqlExecutor;
374
- pkField: string;
375
- profilingCtx?: ProfilingContext;
376
- defaultOrderByPk?: boolean;
377
- }): Promise<void> {
378
- const {
379
- parentRecords,
380
- parentModelMeta,
381
- relationMeta,
382
- relatedModelMeta,
383
- parentIds,
384
- nestedArgs,
385
- allModelsMeta,
386
- executor,
387
- pkField,
388
- profilingCtx,
389
- defaultOrderByPk = false,
390
- } = params;
391
-
392
- const query = buildRelationQuery({
393
- parentModelMeta,
394
- relationMeta,
395
- relatedModelMeta,
396
- parentIds,
397
- args: nestedArgs,
398
- allModelsMeta,
399
- defaultOrderByPk,
400
- });
401
-
402
- const t0 = profilingCtx ? performance.now() : 0;
403
- const rows = await executor(query);
404
- if (profilingCtx) {
405
- profilingCtx.relationProfiles.push({
406
- relation: relationMeta.name,
407
- sqlExecMs: performance.now() - t0,
408
- rowCount: rows.length,
409
- sql: query.text,
410
- });
411
- }
412
-
413
- // Index by FK (each FK should map to at most one record for to-one)
414
- const fkKey = query.fkFieldName ?? "__vibeorm_fk";
415
- const byFk = new Map<unknown, Record<string, unknown>>();
416
- for (const row of rows) {
417
- const fk = row[fkKey];
418
- if (!query.fkFieldName) delete row.__vibeorm_fk;
419
- byFk.set(fk, row);
420
- }
421
-
422
- // Coerce scalar types on the loaded child records before stitching.
423
- coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
424
-
425
- // Stitch
426
- for (const parent of parentRecords) {
427
- const parentId = parent[pkField];
428
- parent[relationMeta.name] = byFk.get(parentId) ?? null;
429
- }
430
- }
431
-
432
- // ─── Helpers ──────────────────────────────────────────────────────
433
-
434
- export type RelationToLoad = {
435
- relationMeta: RelationFieldMeta;
436
- nestedArgs: Record<string, unknown>;
437
- };
438
-
439
- export function resolveRelationsToLoad(params: {
440
- parentModelMeta: ModelMeta;
441
- args: Record<string, unknown>;
442
- }): RelationToLoad[] {
443
- const { parentModelMeta, args } = params;
444
- const result: RelationToLoad[] = [];
445
-
446
- const select = args.select as Record<string, unknown> | undefined;
447
- const include = args.include as Record<string, unknown> | undefined;
448
-
449
- for (const relationMeta of parentModelMeta.relationFields) {
450
- let shouldLoad = false;
451
- let nestedArgs: Record<string, unknown> = {};
452
-
453
- if (select) {
454
- const val = select[relationMeta.name];
455
- if (val === true) {
456
- shouldLoad = true;
457
- } else if (typeof val === "object" && val !== null) {
458
- shouldLoad = true;
459
- nestedArgs = val as Record<string, unknown>;
460
- }
461
- }
462
-
463
- if (include) {
464
- const val = include[relationMeta.name];
465
- if (val === true) {
466
- shouldLoad = true;
467
- } else if (typeof val === "object" && val !== null) {
468
- shouldLoad = true;
469
- nestedArgs = val as Record<string, unknown>;
470
- }
471
- }
472
-
473
- if (shouldLoad) {
474
- result.push({ relationMeta, nestedArgs });
475
- }
476
- }
477
-
478
- return result;
479
- }
480
-
481
- export function hasNestedRelations(params: {
482
- nestedArgs: Record<string, unknown>;
483
- }): boolean {
484
- const { nestedArgs } = params;
485
- return (
486
- nestedArgs.include !== undefined ||
487
- (typeof nestedArgs.select === "object" && nestedArgs.select !== null)
488
- );
489
- }
490
-
491
- function resolveSelectColumnsForRelation(params: {
492
- modelMeta: ModelMeta;
493
- args: Record<string, unknown>;
494
- }): { name: string; dbName: string }[] {
495
- const { modelMeta, args } = params;
496
- const select = args.select as Record<string, boolean | object> | undefined;
497
-
498
- if (!select) {
499
- return modelMeta.scalarFields.map((f) => ({
500
- name: f.name,
501
- dbName: f.dbName,
502
- }));
503
- }
504
-
505
- const columns = modelMeta.scalarFields
506
- .filter((f) => {
507
- const val = select[f.name];
508
- return val === true || (typeof val === "object" && val !== null);
509
- })
510
- .map((f) => ({ name: f.name, dbName: f.dbName }));
511
-
512
- // Check if select has nested relations (object values referencing relation fields)
513
- const relationNameSet = new Set(modelMeta.relationFields.map((r) => r.name));
514
- const hasNested = Object.entries(select).some(([key, val]) => {
515
- if (typeof val !== "object" || val === null) return false;
516
- return relationNameSet.has(key);
517
- });
518
-
519
- if (hasNested) {
520
- // Auto-include PK fields if not already selected
521
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
522
- const selectedNames = new Set(columns.map((c) => c.name));
523
- for (const pkName of modelMeta.primaryKey) {
524
- if (!selectedNames.has(pkName)) {
525
- const sf = sfMap.get(pkName);
526
- if (sf) {
527
- columns.push({ name: sf.name, dbName: sf.dbName });
528
- }
529
- }
530
- }
531
- }
532
-
533
- return columns;
534
- }
package/src/retry.ts DELETED
@@ -1,183 +0,0 @@
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
- }