@tstdl/base 0.93.126 → 0.93.128

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 (48) hide show
  1. package/api/client/client.js +45 -9
  2. package/api/client/tests/api-client.test.d.ts +1 -0
  3. package/api/client/tests/api-client.test.js +194 -0
  4. package/api/types.d.ts +34 -2
  5. package/api/types.js +9 -2
  6. package/authentication/client/authentication.service.js +30 -11
  7. package/authentication/client/http-client.middleware.js +10 -3
  8. package/authentication/server/authentication.service.d.ts +12 -0
  9. package/authentication/server/authentication.service.js +14 -2
  10. package/authentication/tests/authentication.client-error-handling.test.js +23 -66
  11. package/authentication/tests/authentication.client-service-refresh.test.js +14 -14
  12. package/cancellation/token.d.ts +6 -0
  13. package/cancellation/token.js +8 -0
  14. package/http/client/adapters/undici.adapter.js +0 -2
  15. package/http/client/http-client-request.d.ts +2 -0
  16. package/http/client/http-client-request.js +4 -0
  17. package/http/client/http-client-response.d.ts +1 -1
  18. package/http/client/http-client-response.js +3 -2
  19. package/http/utils.d.ts +6 -0
  20. package/http/utils.js +71 -0
  21. package/injector/graph.js +27 -6
  22. package/injector/injector.js +2 -0
  23. package/mail/drizzle/0000_numerous_the_watchers.sql +8 -0
  24. package/mail/drizzle/meta/0000_snapshot.json +1 -32
  25. package/mail/drizzle/meta/_journal.json +2 -9
  26. package/object-storage/s3/tests/s3.object-storage.integration.test.js +22 -53
  27. package/orm/tests/repository-expiration.test.js +3 -3
  28. package/package.json +1 -1
  29. package/rate-limit/tests/postgres-rate-limiter.test.js +9 -7
  30. package/task-queue/tests/complex.test.js +22 -22
  31. package/task-queue/tests/dependencies.test.js +15 -13
  32. package/task-queue/tests/queue.test.js +13 -13
  33. package/task-queue/tests/worker.test.js +12 -12
  34. package/testing/integration-setup.d.ts +2 -0
  35. package/testing/integration-setup.js +13 -7
  36. package/utils/backoff.d.ts +27 -3
  37. package/utils/backoff.js +31 -9
  38. package/utils/index.d.ts +1 -0
  39. package/utils/index.js +1 -0
  40. package/utils/retry-with-backoff.d.ts +22 -0
  41. package/utils/retry-with-backoff.js +64 -0
  42. package/utils/tests/backoff.test.d.ts +1 -0
  43. package/utils/tests/backoff.test.js +41 -0
  44. package/utils/tests/retry-with-backoff.test.d.ts +1 -0
  45. package/utils/tests/retry-with-backoff.test.js +49 -0
  46. package/mail/drizzle/0000_previous_malcolm_colcord.sql +0 -13
  47. package/mail/drizzle/0001_flimsy_bloodscream.sql +0 -5
  48. package/mail/drizzle/meta/0001_snapshot.json +0 -69
@@ -1,5 +1,6 @@
1
1
  import type { CancellationSignal } from '../cancellation/token.js';
2
2
  export type BackoffStrategy = 'linear' | 'exponential';
3
+ export type JitterStrategy = 'full' | 'proportional';
3
4
  /**
4
5
  * Configuration for the backoff behavior.
5
6
  */
@@ -11,6 +12,13 @@ export type BackoffOptions = {
11
12
  * @default 'exponential'
12
13
  */
13
14
  strategy?: BackoffStrategy;
15
+ /**
16
+ * The strategy to use for applying jitter.
17
+ * - `full`: Random value between 0 and the calculated delay.
18
+ * - `proportional`: Random value within `± jitter * delay`.
19
+ * @default 'proportional'
20
+ */
21
+ jitterStrategy?: JitterStrategy;
14
22
  /**
15
23
  * The initial delay in milliseconds. Must be non-negative.
16
24
  * @default 1000
@@ -32,7 +40,8 @@ export type BackoffOptions = {
32
40
  /**
33
41
  * A factor to randomize the delay, e.g., 0.1 for 10% jitter.
34
42
  * This helps prevent the "thundering herd" problem in distributed systems.
35
- * The actual delay will be `delay ± delay * jitter`.
43
+ * For `proportional` jitter, the actual delay will be `delay ± delay * jitter`.
44
+ * For `full` jitter, this value is ignored.
36
45
  * Must be a value between 0 and 1.
37
46
  * @default 0.15
38
47
  */
@@ -59,14 +68,20 @@ export type BackoffLoopController = {
59
68
  /**
60
69
  * Schedules a backoff delay before the next iteration of the loop.
61
70
  * If this is not called, the backoff delay is reset for the next attempt.
71
+ * @param delay An optional explicit delay in milliseconds to use for the next backoff.
72
+ * If not provided, the backoff strategy is used.
62
73
  */
63
- backoff: () => void;
74
+ backoff: (delay?: number) => void;
64
75
  /**
65
76
  * Immediately breaks out of the loop.
66
77
  */
67
78
  break: () => void;
68
79
  };
69
- export type BackoffLoopFunction = (controller: BackoffLoopController, cancellationSignal: CancellationSignal) => unknown;
80
+ export type BackoffStatus = {
81
+ attempt: number;
82
+ currentDelay: number;
83
+ };
84
+ export type BackoffLoopFunction = (controller: BackoffLoopController, cancellationSignal: CancellationSignal, status: BackoffStatus) => unknown;
70
85
  /**
71
86
  * A function yielded by `backoffGenerator` to control the next iteration.
72
87
  */
@@ -82,6 +97,7 @@ export type BackoffGeneratorCallback = (options?: {
82
97
  */
83
98
  export declare const DEFAULT_BACKOFF_OPTIONS: {
84
99
  readonly strategy: "exponential";
100
+ readonly jitterStrategy: "proportional";
85
101
  readonly initialDelay: 1000;
86
102
  readonly increase: 2;
87
103
  readonly maximumDelay: 30000;
@@ -93,6 +109,14 @@ export declare const DEFAULT_BACKOFF_OPTIONS: {
93
109
  export declare class BackoffHelper {
94
110
  private readonly options;
95
111
  private currentDelay;
112
+ private attempt;
113
+ /**
114
+ * The current status of the backoff.
115
+ */
116
+ get status(): {
117
+ attempt: number;
118
+ currentDelay: number;
119
+ };
96
120
  /**
97
121
  * Creates a new BackoffHelper.
98
122
  * @param options Partial backoff options, which will be merged with sane defaults.
package/utils/backoff.js CHANGED
@@ -8,6 +8,7 @@ import { isDefined } from './type-guards.js';
8
8
  */
9
9
  export const DEFAULT_BACKOFF_OPTIONS = {
10
10
  strategy: 'exponential',
11
+ jitterStrategy: 'proportional',
11
12
  initialDelay: 1000,
12
13
  increase: 2,
13
14
  maximumDelay: 30_000, // 30 seconds
@@ -19,6 +20,16 @@ export const DEFAULT_BACKOFF_OPTIONS = {
19
20
  export class BackoffHelper {
20
21
  options;
21
22
  currentDelay;
23
+ attempt;
24
+ /**
25
+ * The current status of the backoff.
26
+ */
27
+ get status() {
28
+ return {
29
+ attempt: this.attempt,
30
+ currentDelay: this.currentDelay,
31
+ };
32
+ }
22
33
  /**
23
34
  * Creates a new BackoffHelper.
24
35
  * @param options Partial backoff options, which will be merged with sane defaults.
@@ -39,12 +50,14 @@ export class BackoffHelper {
39
50
  console.warn('Using an exponential backoff with an increase factor <= 1 is not recommended as the delay will not grow.');
40
51
  }
41
52
  this.currentDelay = this.options.initialDelay;
53
+ this.attempt = 0;
42
54
  }
43
55
  /**
44
56
  * Resets the current delay to the initial delay.
45
57
  */
46
58
  reset() {
47
59
  this.currentDelay = this.options.initialDelay;
60
+ this.attempt = 0;
48
61
  }
49
62
  /**
50
63
  * Calculates and returns the next backoff delay based on the configured strategy.
@@ -52,13 +65,18 @@ export class BackoffHelper {
52
65
  * @returns The next delay in milliseconds.
53
66
  */
54
67
  getNextDelay() {
55
- const newDelay = getNewDelay(this.options.strategy, this.currentDelay, this.options.increase, this.options.maximumDelay);
68
+ const baseDelay = this.currentDelay;
69
+ const newDelay = getNewDelay(this.options.strategy, baseDelay, this.options.increase, this.options.maximumDelay);
56
70
  this.currentDelay = newDelay;
71
+ this.attempt++;
72
+ if (this.options.jitterStrategy == 'full') {
73
+ return randomFloat(0, baseDelay);
74
+ }
57
75
  if (this.options.jitter > 0) {
58
- const jitterAmount = newDelay * randomFloat(-this.options.jitter, this.options.jitter); // ±jitter
59
- return Math.max(0, newDelay + jitterAmount);
76
+ const jitterAmount = baseDelay * randomFloat(-this.options.jitter, this.options.jitter); // ±jitter
77
+ return Math.max(0, baseDelay + jitterAmount);
60
78
  }
61
- return newDelay;
79
+ return baseDelay;
62
80
  }
63
81
  }
64
82
  /**
@@ -72,9 +90,9 @@ export class BackoffHelper {
72
90
  */
73
91
  export async function autoBackoffLoop(loopFunction, options = {}) {
74
92
  const { errorHandler, ...loopOptions } = options;
75
- await backoffLoop(async (controller, signal) => {
93
+ await backoffLoop(async (controller, signal, status) => {
76
94
  try {
77
- await loopFunction(controller, signal);
95
+ await loopFunction(controller, signal, status);
78
96
  }
79
97
  catch (error) {
80
98
  errorHandler?.(error);
@@ -95,22 +113,26 @@ export async function backoffLoop(loopFunction, options = {}) {
95
113
  const backoffHelper = new BackoffHelper(backoffOptions);
96
114
  const loopToken = new CancellationToken();
97
115
  let shouldBackoff = false;
116
+ let customBackoffDelay;
98
117
  if (isDefined(cancellationSignal)) {
99
118
  loopToken.inherit(cancellationSignal, { set: true, unset: false, complete: false, error: false });
100
119
  }
101
120
  const controller = {
102
- backoff: () => shouldBackoff = true,
121
+ backoff: (delay) => {
122
+ shouldBackoff = true;
123
+ customBackoffDelay = delay;
124
+ },
103
125
  break: () => loopToken.set(),
104
126
  };
105
127
  while (loopToken.isUnset) {
106
- await loopFunction(controller, loopToken.signal);
128
+ await loopFunction(controller, loopToken.signal, backoffHelper.status);
107
129
  // Exit immediately if the loop function requested a break.
108
130
  if (loopToken.isSet) {
109
131
  return;
110
132
  }
111
133
  if (shouldBackoff) {
112
134
  shouldBackoff = false;
113
- const delay = backoffHelper.getNextDelay();
135
+ const delay = customBackoffDelay ?? backoffHelper.getNextDelay();
114
136
  await cancelableTimeout(delay, loopToken.signal);
115
137
  }
116
138
  else {
package/utils/index.d.ts CHANGED
@@ -39,6 +39,7 @@ export * from './random.js';
39
39
  export * from './reactive-value-to-signal.js';
40
40
  export * from './reflection.js';
41
41
  export * from './repl.js';
42
+ export * from './retry-with-backoff.js';
42
43
  export * from './set.js';
43
44
  export * from './singleton.js';
44
45
  export * from './sort.js';
package/utils/index.js CHANGED
@@ -39,6 +39,7 @@ export * from './random.js';
39
39
  export * from './reactive-value-to-signal.js';
40
40
  export * from './reflection.js';
41
41
  export * from './repl.js';
42
+ export * from './retry-with-backoff.js';
42
43
  export * from './set.js';
43
44
  export * from './singleton.js';
44
45
  export * from './sort.js';
@@ -0,0 +1,22 @@
1
+ import type { CancellationSignal } from '../cancellation/token.js';
2
+ import { type BackoffOptions } from './backoff.js';
3
+ export type RetryWithBackoffOptions = {
4
+ /** Timeout for each individual attempt in milliseconds. */
5
+ timeout?: number;
6
+ /** Retry configuration. If not provided, it will not retry. */
7
+ retry?: {
8
+ /** Whether to retry when an error occurs. */
9
+ shouldRetry: (error: Error, attempt: number) => boolean | Promise<boolean>;
10
+ /** Backoff configuration for retries. */
11
+ backoff?: BackoffOptions;
12
+ };
13
+ /** Optional cancellation signal to abort the execution. */
14
+ signal?: CancellationSignal;
15
+ };
16
+ /**
17
+ * Executes a function with a specified policy including timeout and retries with backoff.
18
+ * @param func The function to execute. It receives a cancellation signal that is set when the attempt times out or is cancelled.
19
+ * @param options The retry options.
20
+ * @returns The result of the function.
21
+ */
22
+ export declare function retryWithBackoff<T>(func: (signal: CancellationSignal) => T | Promise<T>, options?: RetryWithBackoffOptions): Promise<T>;
@@ -0,0 +1,64 @@
1
+ import { HttpError } from '../http/http.error.js';
2
+ import { isDefined, isInstanceOf, isNumber, isString } from '../utils/type-guards.js';
3
+ import { backoffLoop } from './backoff.js';
4
+ import { withTimeout } from './timing.js';
5
+ /**
6
+ * Executes a function with a specified policy including timeout and retries with backoff.
7
+ * @param func The function to execute. It receives a cancellation signal that is set when the attempt times out or is cancelled.
8
+ * @param options The retry options.
9
+ * @returns The result of the function.
10
+ */
11
+ export async function retryWithBackoff(func, options = {}) {
12
+ const { timeout, retry, signal } = options;
13
+ let result;
14
+ let lastError;
15
+ await backoffLoop(async (controller, loopSignal, status) => {
16
+ try {
17
+ const attemptSignal = isDefined(signal)
18
+ ? signal.createChild().inherit(loopSignal)
19
+ : loopSignal;
20
+ if (isDefined(timeout)) {
21
+ result = await withTimeout(timeout, async () => await func(attemptSignal));
22
+ }
23
+ else {
24
+ result = await func(attemptSignal);
25
+ }
26
+ controller.break();
27
+ }
28
+ catch (error) {
29
+ lastError = error;
30
+ if (isDefined(retry) && await retry.shouldRetry(lastError, status.attempt)) {
31
+ const retryAfter = tryGetRetryAfterDelay(lastError);
32
+ controller.backoff(retryAfter);
33
+ }
34
+ else {
35
+ throw error;
36
+ }
37
+ }
38
+ }, {
39
+ ...retry?.backoff,
40
+ cancellationSignal: signal,
41
+ });
42
+ return result;
43
+ }
44
+ function tryGetRetryAfterDelay(error) {
45
+ if (isInstanceOf(error, HttpError)) {
46
+ const retryAfter = error.responseInstance?.headers.tryGetSingle('Retry-After');
47
+ if (isDefined(retryAfter)) {
48
+ if (isNumber(retryAfter)) {
49
+ return retryAfter * 1000;
50
+ }
51
+ if (isString(retryAfter)) {
52
+ const seconds = Number.parseInt(retryAfter, 10);
53
+ if (!Number.isNaN(seconds)) {
54
+ return seconds * 1000;
55
+ }
56
+ const date = Date.parse(retryAfter);
57
+ if (!Number.isNaN(date)) {
58
+ return Math.max(0, date - Date.now());
59
+ }
60
+ }
61
+ }
62
+ }
63
+ return undefined;
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { BackoffHelper } from '../backoff.js';
3
+ describe('BackoffHelper', () => {
4
+ it('should support full jitter', () => {
5
+ const helper = new BackoffHelper({
6
+ initialDelay: 1000,
7
+ jitter: 1,
8
+ jitterStrategy: 'full',
9
+ });
10
+ for (let i = 0; i < 100; i++) {
11
+ const delay = helper.getNextDelay();
12
+ // Full jitter should be between 0 and the base delay (1000 for first call, then 2000, 4000, etc.)
13
+ expect(delay).toBeGreaterThanOrEqual(0);
14
+ expect(delay).toBeLessThanOrEqual(1000 * Math.pow(2, i));
15
+ }
16
+ });
17
+ it('should support proportional jitter (default)', () => {
18
+ const helper = new BackoffHelper({
19
+ initialDelay: 1000,
20
+ jitter: 0.1,
21
+ jitterStrategy: 'proportional',
22
+ });
23
+ for (let i = 0; i < 1; i++) {
24
+ const delay = helper.getNextDelay();
25
+ // Proportional jitter should be 1000 ± 100 (900 to 1100)
26
+ expect(delay).toBeGreaterThanOrEqual(900);
27
+ expect(delay).toBeLessThanOrEqual(1100);
28
+ }
29
+ });
30
+ it('should provide status emission', () => {
31
+ const helper = new BackoffHelper({ initialDelay: 1000 });
32
+ expect(helper.status.attempt).toBe(0);
33
+ expect(helper.status.currentDelay).toBe(1000);
34
+ helper.getNextDelay();
35
+ expect(helper.status.attempt).toBe(1);
36
+ expect(helper.status.currentDelay).toBe(2000);
37
+ helper.reset();
38
+ expect(helper.status.attempt).toBe(0);
39
+ expect(helper.status.currentDelay).toBe(1000);
40
+ });
41
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { TimeoutError } from '../../errors/timeout.error.js';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { retryWithBackoff } from '../retry-with-backoff.js';
4
+ describe('retryWithBackoff', () => {
5
+ it('should return result on success', async () => {
6
+ const result = await retryWithBackoff(async () => 'success');
7
+ expect(result).toBe('success');
8
+ });
9
+ it('should retry on failure if shouldRetry returns true', async () => {
10
+ let attempts = 0;
11
+ const result = await retryWithBackoff(async () => {
12
+ attempts++;
13
+ if (attempts < 3) {
14
+ throw new Error('fail');
15
+ }
16
+ return 'success';
17
+ }, {
18
+ retry: {
19
+ shouldRetry: (_error, attempt) => attempt < 5,
20
+ backoff: { initialDelay: 0 },
21
+ },
22
+ });
23
+ expect(result).toBe('success');
24
+ expect(attempts).toBe(3);
25
+ });
26
+ it('should throw after max retries', async () => {
27
+ let attempts = 0;
28
+ const execute = retryWithBackoff(async () => {
29
+ attempts++;
30
+ throw new Error('fail');
31
+ }, {
32
+ retry: {
33
+ shouldRetry: (_error, attempt) => attempt < 2,
34
+ backoff: { initialDelay: 0 },
35
+ },
36
+ });
37
+ await expect(execute).rejects.toThrow('fail');
38
+ expect(attempts).toBe(3); // 0, 1, 2
39
+ });
40
+ it('should timeout if execution takes too long', async () => {
41
+ const execute = retryWithBackoff(async () => {
42
+ await new Promise((resolve) => setTimeout(resolve, 100));
43
+ return 'success';
44
+ }, {
45
+ timeout: 50,
46
+ });
47
+ await expect(execute).rejects.toThrow(TimeoutError);
48
+ });
49
+ });
@@ -1,13 +0,0 @@
1
- CREATE TABLE "mail"."log" (
2
- "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
- "timestamp" timestamp with time zone NOT NULL,
4
- "template" text,
5
- "data" jsonb NOT NULL,
6
- "send_result" jsonb,
7
- "errors" text[],
8
- "revision" integer NOT NULL,
9
- "revision_timestamp" timestamp with time zone NOT NULL,
10
- "create_timestamp" timestamp with time zone NOT NULL,
11
- "delete_timestamp" timestamp with time zone,
12
- "attributes" jsonb DEFAULT '{}'::jsonb NOT NULL
13
- );
@@ -1,5 +0,0 @@
1
- ALTER TABLE "mail"."log" DROP COLUMN "revision";--> statement-breakpoint
2
- ALTER TABLE "mail"."log" DROP COLUMN "revision_timestamp";--> statement-breakpoint
3
- ALTER TABLE "mail"."log" DROP COLUMN "create_timestamp";--> statement-breakpoint
4
- ALTER TABLE "mail"."log" DROP COLUMN "delete_timestamp";--> statement-breakpoint
5
- ALTER TABLE "mail"."log" DROP COLUMN "attributes";
@@ -1,69 +0,0 @@
1
- {
2
- "id": "28ddafef-64f4-437d-9406-82a02c76570e",
3
- "prevId": "f8cdba37-11b9-477a-9f5a-5ef4b5026011",
4
- "version": "7",
5
- "dialect": "postgresql",
6
- "tables": {
7
- "mail.log": {
8
- "name": "log",
9
- "schema": "mail",
10
- "columns": {
11
- "id": {
12
- "name": "id",
13
- "type": "uuid",
14
- "primaryKey": true,
15
- "notNull": true,
16
- "default": "gen_random_uuid()"
17
- },
18
- "timestamp": {
19
- "name": "timestamp",
20
- "type": "timestamp with time zone",
21
- "primaryKey": false,
22
- "notNull": true
23
- },
24
- "template": {
25
- "name": "template",
26
- "type": "text",
27
- "primaryKey": false,
28
- "notNull": false
29
- },
30
- "data": {
31
- "name": "data",
32
- "type": "jsonb",
33
- "primaryKey": false,
34
- "notNull": true
35
- },
36
- "send_result": {
37
- "name": "send_result",
38
- "type": "jsonb",
39
- "primaryKey": false,
40
- "notNull": false
41
- },
42
- "errors": {
43
- "name": "errors",
44
- "type": "text[]",
45
- "primaryKey": false,
46
- "notNull": false
47
- }
48
- },
49
- "indexes": {},
50
- "foreignKeys": {},
51
- "compositePrimaryKeys": {},
52
- "uniqueConstraints": {},
53
- "policies": {},
54
- "checkConstraints": {},
55
- "isRLSEnabled": false
56
- }
57
- },
58
- "enums": {},
59
- "schemas": {},
60
- "sequences": {},
61
- "roles": {},
62
- "policies": {},
63
- "views": {},
64
- "_meta": {
65
- "columns": {},
66
- "schemas": {},
67
- "tables": {}
68
- }
69
- }