@vibeorm/runtime 1.1.4 → 1.1.6
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 +49 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/lateral-join-builder.ts +56 -32
- package/src/retry.ts +183 -0
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
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,
|
|
@@ -12,20 +12,29 @@
|
|
|
12
12
|
* __lat_1."__vibeorm_rel_profile"
|
|
13
13
|
* FROM "User"
|
|
14
14
|
* LEFT JOIN LATERAL (
|
|
15
|
-
* SELECT COALESCE(
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* SELECT COALESCE(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb)
|
|
16
|
+
* AS "__vibeorm_rel_posts"
|
|
17
|
+
* FROM (
|
|
18
|
+
* SELECT __rel."id" AS "id", __rel."title" AS "title", ...
|
|
19
|
+
* FROM "Post" __rel WHERE __rel."authorId" = "User"."id"
|
|
20
|
+
* ) "__sub"
|
|
19
21
|
* ) __lat_0 ON true
|
|
20
22
|
* LEFT JOIN LATERAL (
|
|
21
|
-
* SELECT
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* SELECT to_jsonb("__sub".*) AS "__vibeorm_rel_profile"
|
|
24
|
+
* FROM (
|
|
25
|
+
* SELECT __rel."id" AS "id", __rel."bio" AS "bio", ...
|
|
26
|
+
* FROM "Profile" __rel WHERE __rel."userId" = "User"."id"
|
|
27
|
+
* LIMIT 1
|
|
28
|
+
* ) "__sub"
|
|
25
29
|
* ) __lat_1 ON true
|
|
26
30
|
* WHERE "User"."role" = $1
|
|
27
31
|
* ORDER BY "User"."id" ASC
|
|
28
32
|
* LIMIT 20
|
|
33
|
+
*
|
|
34
|
+
* Uses to_jsonb() on a subselect instead of json_build_object() to avoid
|
|
35
|
+
* PostgreSQL's 100-argument function limit (models with >50 scalar fields
|
|
36
|
+
* would exceed this). Column aliases in the inner SELECT handle the
|
|
37
|
+
* dbName→tsName remapping at zero cost.
|
|
29
38
|
*/
|
|
30
39
|
|
|
31
40
|
import type {
|
|
@@ -183,14 +192,16 @@ export function buildLateralJoinQuery(params: {
|
|
|
183
192
|
const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
|
|
184
193
|
if (!relatedModelMeta) continue;
|
|
185
194
|
|
|
186
|
-
// Build
|
|
195
|
+
// Build aliased column list for the inner subquery.
|
|
196
|
+
// Uses to_jsonb("__sub".*) instead of json_build_object() to avoid
|
|
197
|
+
// PostgreSQL's 100-argument function limit on wide models.
|
|
187
198
|
// Respect nested select/omit to avoid serialising columns the caller doesn't need.
|
|
188
199
|
const relatedScalars = resolveRelatedScalars({
|
|
189
200
|
relatedModelMeta,
|
|
190
201
|
nestedArgs,
|
|
191
202
|
});
|
|
192
|
-
const
|
|
193
|
-
.map((f) =>
|
|
203
|
+
const columnAliases = relatedScalars
|
|
204
|
+
.map((f) => `${relatedAlias}."${f.dbName}" AS "${f.name}"`)
|
|
194
205
|
.join(", ");
|
|
195
206
|
|
|
196
207
|
let lateralSubquery: string;
|
|
@@ -288,22 +299,27 @@ export function buildLateralJoinQuery(params: {
|
|
|
288
299
|
allValues.push(nestedArgs.skip);
|
|
289
300
|
}
|
|
290
301
|
|
|
291
|
-
//
|
|
302
|
+
// Wrap in inner subquery with column aliases + to_jsonb to avoid
|
|
303
|
+
// PostgreSQL's 100-argument limit on json_build_object.
|
|
292
304
|
if (nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
293
305
|
lateralSubquery = `LEFT JOIN LATERAL (
|
|
294
|
-
SELECT COALESCE(jsonb_agg(
|
|
306
|
+
SELECT COALESCE(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb) AS "${colAlias}"
|
|
295
307
|
FROM (
|
|
296
|
-
SELECT ${
|
|
308
|
+
SELECT ${columnAliases}
|
|
309
|
+
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
297
310
|
${junctionJoin}
|
|
298
311
|
WHERE ${subWhere}${nestedWhereSql}${orderBySql}${limitSql}${offsetSql}
|
|
299
|
-
)
|
|
312
|
+
) "__sub"
|
|
300
313
|
) ${alias} ON true`;
|
|
301
314
|
} else {
|
|
302
315
|
lateralSubquery = `LEFT JOIN LATERAL (
|
|
303
|
-
SELECT COALESCE(jsonb_agg(
|
|
304
|
-
FROM
|
|
305
|
-
|
|
306
|
-
|
|
316
|
+
SELECT COALESCE(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb) AS "${colAlias}"
|
|
317
|
+
FROM (
|
|
318
|
+
SELECT ${columnAliases}
|
|
319
|
+
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
320
|
+
${junctionJoin}
|
|
321
|
+
WHERE ${subWhere}${nestedWhereSql}${orderBySql}
|
|
322
|
+
) "__sub"
|
|
307
323
|
) ${alias} ON true`;
|
|
308
324
|
}
|
|
309
325
|
|
|
@@ -396,24 +412,29 @@ export function buildLateralJoinQuery(params: {
|
|
|
396
412
|
allValues.push(nestedArgs.skip);
|
|
397
413
|
}
|
|
398
414
|
|
|
399
|
-
//
|
|
415
|
+
// Wrap in inner subquery with column aliases + to_jsonb to avoid
|
|
416
|
+
// PostgreSQL's 100-argument limit on json_build_object.
|
|
400
417
|
if (nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
401
418
|
lateralSubquery = `LEFT JOIN LATERAL (
|
|
402
|
-
SELECT COALESCE(jsonb_agg(
|
|
419
|
+
SELECT COALESCE(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb) AS "${colAlias}"
|
|
403
420
|
FROM (
|
|
404
|
-
SELECT
|
|
421
|
+
SELECT ${columnAliases}
|
|
422
|
+
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
405
423
|
WHERE ${subWhere}${nestedWhereSql}${orderBySql}${limitSql}${offsetSql}
|
|
406
|
-
)
|
|
424
|
+
) "__sub"
|
|
407
425
|
) ${alias} ON true`;
|
|
408
426
|
} else {
|
|
409
427
|
lateralSubquery = `LEFT JOIN LATERAL (
|
|
410
|
-
SELECT COALESCE(jsonb_agg(
|
|
411
|
-
FROM
|
|
412
|
-
|
|
428
|
+
SELECT COALESCE(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb) AS "${colAlias}"
|
|
429
|
+
FROM (
|
|
430
|
+
SELECT ${columnAliases}
|
|
431
|
+
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
432
|
+
WHERE ${subWhere}${nestedWhereSql}${orderBySql}
|
|
433
|
+
) "__sub"
|
|
413
434
|
) ${alias} ON true`;
|
|
414
435
|
}
|
|
415
436
|
} else {
|
|
416
|
-
// To-one: use
|
|
437
|
+
// To-one: use to_jsonb on subselect with LIMIT 1
|
|
417
438
|
let joinCondition: string;
|
|
418
439
|
|
|
419
440
|
if (fkTable === "parent") {
|
|
@@ -432,10 +453,13 @@ export function buildLateralJoinQuery(params: {
|
|
|
432
453
|
}
|
|
433
454
|
|
|
434
455
|
lateralSubquery = `LEFT JOIN LATERAL (
|
|
435
|
-
SELECT
|
|
436
|
-
FROM
|
|
437
|
-
|
|
438
|
-
|
|
456
|
+
SELECT to_jsonb("__sub".*) AS "${colAlias}"
|
|
457
|
+
FROM (
|
|
458
|
+
SELECT ${columnAliases}
|
|
459
|
+
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
460
|
+
WHERE ${joinCondition}
|
|
461
|
+
LIMIT 1
|
|
462
|
+
) "__sub"
|
|
439
463
|
) ${alias} ON true`;
|
|
440
464
|
}
|
|
441
465
|
}
|
|
@@ -886,7 +910,7 @@ export function whereHasRelationFilter(params: {
|
|
|
886
910
|
}
|
|
887
911
|
|
|
888
912
|
/**
|
|
889
|
-
* Resolve which scalar columns to include in a lateral join's
|
|
913
|
+
* Resolve which scalar columns to include in a lateral join's inner subquery.
|
|
890
914
|
* Respects nested select/omit so we don't serialise unnecessary columns.
|
|
891
915
|
* Always includes the PK so nested relation loading can stitch correctly.
|
|
892
916
|
*/
|
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
|
+
}
|