@tstdl/base 0.93.127 → 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 (47) 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/injector.js +2 -0
  22. package/mail/drizzle/0000_numerous_the_watchers.sql +8 -0
  23. package/mail/drizzle/meta/0000_snapshot.json +1 -32
  24. package/mail/drizzle/meta/_journal.json +2 -9
  25. package/object-storage/s3/tests/s3.object-storage.integration.test.js +22 -53
  26. package/orm/tests/repository-expiration.test.js +3 -3
  27. package/package.json +1 -1
  28. package/rate-limit/tests/postgres-rate-limiter.test.js +9 -7
  29. package/task-queue/tests/complex.test.js +22 -22
  30. package/task-queue/tests/dependencies.test.js +15 -13
  31. package/task-queue/tests/queue.test.js +13 -13
  32. package/task-queue/tests/worker.test.js +12 -12
  33. package/testing/integration-setup.d.ts +2 -0
  34. package/testing/integration-setup.js +13 -7
  35. package/utils/backoff.d.ts +27 -3
  36. package/utils/backoff.js +31 -9
  37. package/utils/index.d.ts +1 -0
  38. package/utils/index.js +1 -0
  39. package/utils/retry-with-backoff.d.ts +22 -0
  40. package/utils/retry-with-backoff.js +64 -0
  41. package/utils/tests/backoff.test.d.ts +1 -0
  42. package/utils/tests/backoff.test.js +41 -0
  43. package/utils/tests/retry-with-backoff.test.d.ts +1 -0
  44. package/utils/tests/retry-with-backoff.test.js +49 -0
  45. package/mail/drizzle/0000_previous_malcolm_colcord.sql +0 -13
  46. package/mail/drizzle/0001_flimsy_bloodscream.sql +0 -5
  47. package/mail/drizzle/meta/0001_snapshot.json +0 -69
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
- }