@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
@@ -70,6 +70,12 @@ describe('AuthenticationClientService Error Handling & Stuck States', () => {
70
70
  afterEach(async () => {
71
71
  await service.dispose();
72
72
  });
73
+ function setupServiceWithToken(iatOffset = -10, expOffset = 5) {
74
+ const now = Math.floor(Date.now() / 1000);
75
+ const initialToken = { iat: now + iatOffset, exp: now + expOffset, jti: 'initial' };
76
+ globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
77
+ service = injector.resolve(AuthenticationClientService);
78
+ }
73
79
  test('Corrupt Storage: should handle invalid JSON in storage gracefully', async () => {
74
80
  // initialize with corrupt token
75
81
  globalThis.localStorage.setItem('AuthenticationService:token', '{ invalid-json');
@@ -77,84 +83,35 @@ describe('AuthenticationClientService Error Handling & Stuck States', () => {
77
83
  expect(service.token()).toBeUndefined();
78
84
  expect(mockLogger.warn).toHaveBeenCalled(); // Should warn about parse error
79
85
  });
80
- test('Unrecoverable Errors: should logout on InvalidTokenError during refresh', async () => {
81
- const now = Math.floor(Date.now() / 1000);
82
- const initialToken = { exp: now + 5, jti: 'initial' };
83
- globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
84
- service = injector.resolve(AuthenticationClientService);
85
- mockApiClient.refresh.mockRejectedValue(new InvalidTokenError());
86
+ test.each([
87
+ ['InvalidTokenError', new InvalidTokenError()],
88
+ ['NotFoundError', new NotFoundError('Session not found')],
89
+ ['ForbiddenError', new ForbiddenError()],
90
+ ['UnauthorizedError', new UnauthorizedError()],
91
+ ['NotSupportedError', new NotSupportedError()],
92
+ ['BadRequestError', new BadRequestError()],
93
+ ])('Unrecoverable Errors: should logout on %s during refresh', async (_, error) => {
94
+ setupServiceWithToken();
95
+ mockApiClient.refresh.mockRejectedValue(error);
86
96
  // Wait for loop to pick up refresh
87
- await timeout(100);
88
- expect(mockApiClient.endSession).toHaveBeenCalled();
89
- expect(service.token()).toBeUndefined();
90
- });
91
- test('Unrecoverable Errors: should logout on NotFoundError during refresh', async () => {
92
- const now = Math.floor(Date.now() / 1000);
93
- const initialToken = { exp: now + 5, jti: 'initial' };
94
- globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
95
- service = injector.resolve(AuthenticationClientService);
96
- mockApiClient.refresh.mockRejectedValue(new NotFoundError('Session not found'));
97
- await timeout(100);
98
- expect(mockApiClient.endSession).toHaveBeenCalled();
99
- expect(service.token()).toBeUndefined();
100
- });
101
- test('Unrecoverable Errors: should logout on ForbiddenError during refresh', async () => {
102
- const now = Math.floor(Date.now() / 1000);
103
- const initialToken = { exp: now + 5, jti: 'initial' };
104
- globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
105
- service = injector.resolve(AuthenticationClientService);
106
- mockApiClient.refresh.mockRejectedValue(new ForbiddenError());
107
- await timeout(100);
108
- expect(mockApiClient.endSession).toHaveBeenCalled();
109
- expect(service.token()).toBeUndefined();
110
- });
111
- test('Unrecoverable Errors: should logout on UnauthorizedError during refresh', async () => {
112
- const now = Math.floor(Date.now() / 1000);
113
- const initialToken = { exp: now + 5, jti: 'initial' };
114
- globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
115
- service = injector.resolve(AuthenticationClientService);
116
- mockApiClient.refresh.mockRejectedValue(new UnauthorizedError());
117
- await timeout(100);
118
- expect(mockApiClient.endSession).toHaveBeenCalled();
119
- expect(service.token()).toBeUndefined();
120
- });
121
- test('Unrecoverable Errors: should logout on NotSupportedError during refresh', async () => {
122
- const now = Math.floor(Date.now() / 1000);
123
- const initialToken = { exp: now + 5, jti: 'initial' };
124
- globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
125
- service = injector.resolve(AuthenticationClientService);
126
- mockApiClient.refresh.mockRejectedValue(new NotSupportedError());
127
- await timeout(100);
128
- expect(mockApiClient.endSession).toHaveBeenCalled();
129
- expect(service.token()).toBeUndefined();
130
- });
131
- test('Unrecoverable Errors: should logout on BadRequestError during refresh', async () => {
132
- const now = Math.floor(Date.now() / 1000);
133
- const initialToken = { exp: now + 5, jti: 'initial' };
134
- globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
135
- service = injector.resolve(AuthenticationClientService);
136
- mockApiClient.refresh.mockRejectedValue(new BadRequestError());
137
- await timeout(100);
97
+ await timeout(20);
138
98
  expect(mockApiClient.endSession).toHaveBeenCalled();
139
99
  expect(service.token()).toBeUndefined();
140
100
  });
141
101
  test('Recoverable Errors: should NOT logout on generic Error during refresh', async () => {
142
- const now = Math.floor(Date.now() / 1000);
143
- const initialToken = { exp: now + 5, jti: 'initial' };
144
- globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
145
- service = injector.resolve(AuthenticationClientService);
102
+ setupServiceWithToken();
146
103
  mockApiClient.refresh.mockRejectedValue(new Error('Network failure'));
147
- await timeout(100);
104
+ await timeout(20);
148
105
  // Should NOT have called endSession (logout)
149
106
  expect(mockApiClient.endSession).not.toHaveBeenCalled();
150
107
  // Token should still be present (retry logic handles it)
151
108
  expect(service.token()).toBeDefined();
109
+ // Trigger immediate retry to finish test quickly
110
+ service.requestRefresh();
111
+ await timeout(20);
152
112
  });
153
113
  test('Logout Failure: should clear local token even if server logout fails', async () => {
154
- const now = Math.floor(Date.now() / 1000);
155
- const initialToken = { exp: now + 1000, jti: 'initial' };
156
- globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
157
- service = injector.resolve(AuthenticationClientService);
114
+ setupServiceWithToken(-1000, 1000);
158
115
  mockApiClient.endSession.mockRejectedValue(new Error('Network error during logout'));
159
116
  await service.logout();
160
117
  expect(mockApiClient.endSession).toHaveBeenCalled();
@@ -65,59 +65,59 @@ describe('AuthenticationClientService Refresh Loop Reproduction', () => {
65
65
  test('Zombie Timer: loop should wake up immediately when token changes', async () => {
66
66
  // 1. Mock a long expiration
67
67
  const now = Math.floor(Date.now() / 1000);
68
- const initialToken = { exp: now + 3600, jti: 'initial' };
68
+ const initialToken = { iat: now - 3600, exp: now + 3600, jti: 'initial' };
69
69
  // Set in storage so initialize() (called by resolve) loads it
70
70
  globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
71
71
  service = injector.resolve(AuthenticationClientService);
72
72
  // Wait for loop to enter the race condition (wait phase)
73
- await timeout(100);
73
+ await timeout(20);
74
74
  // 2. Change token
75
- const newToken = { exp: now + 3600, jti: 'new' };
75
+ const newToken = { iat: now - 1800, exp: now + 3600, jti: 'new' };
76
76
  mockApiClient.refresh.mockResolvedValue(newToken);
77
77
  service.requestRefresh(); // This should trigger immediate wake up
78
78
  // Wait for loop to process
79
- await timeout(100);
79
+ await timeout(20);
80
80
  expect(mockApiClient.refresh).toHaveBeenCalled();
81
81
  });
82
82
  test('Forced Refresh Loss: forceRefreshToken should not be cleared on failure', async () => {
83
83
  const now = Math.floor(Date.now() / 1000);
84
- const initialToken = { exp: now + 3600, jti: 'initial' };
84
+ const initialToken = { iat: now - 3600, exp: now + 3600, jti: 'initial' };
85
85
  globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
86
86
  service = injector.resolve(AuthenticationClientService);
87
- await timeout(100);
87
+ await timeout(20);
88
88
  // 1. Mock refresh failure
89
89
  mockApiClient.refresh.mockRejectedValue(new Error('Network Error'));
90
90
  service.requestRefresh();
91
91
  // Wait for loop to attempt refresh and fail
92
- await timeout(200);
92
+ await timeout(50);
93
93
  expect(mockApiClient.refresh).toHaveBeenCalled();
94
94
  expect(service.forceRefreshToken.isSet).toBe(true); // Should STILL be set
95
95
  });
96
96
  test('Lock Contention Backoff: should wait 5 seconds and not busy-loop', async () => {
97
97
  const now = Math.floor(Date.now() / 1000);
98
- const initialToken = { exp: now + 5, jti: 'initial' }; // Expiring soon
98
+ const initialToken = { iat: now - 3600, exp: now + 5, jti: 'initial' }; // Expiring soon
99
99
  globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
100
100
  // 1. Mock lock already held
101
101
  mockLock.tryUse.mockResolvedValue(undefined); // lockAcquired = false
102
102
  const startTime = Date.now();
103
103
  service = injector.resolve(AuthenticationClientService);
104
- // We expect it to try once, fail to get lock, and then wait 5 seconds.
105
- await timeout(300);
104
+ // We expect it to try once, fail to get lock, and then wait some time.
105
+ await timeout(50);
106
106
  expect(mockLock.tryUse).toHaveBeenCalledTimes(1);
107
107
  // Check if it's still waiting (not finished loop)
108
108
  const duration = Date.now() - startTime;
109
- expect(duration).toBeLessThan(1000);
109
+ expect(duration).toBeLessThan(500);
110
110
  });
111
111
  test('Busy Loop: should not busy loop when forceRefreshToken is set and lock is held', async () => {
112
112
  const now = Math.floor(Date.now() / 1000);
113
- const initialToken = { exp: now + 3600, jti: 'initial' };
113
+ const initialToken = { iat: now - 3600, exp: now + 3600, jti: 'initial' };
114
114
  globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
115
115
  // Mock lock already held
116
116
  mockLock.tryUse.mockResolvedValue(undefined);
117
117
  service = injector.resolve(AuthenticationClientService);
118
- await timeout(100);
118
+ await timeout(20);
119
119
  service.requestRefresh(); // Set the flag
120
- await timeout(300);
120
+ await timeout(50);
121
121
  // If it busy loops, this will be much higher than 1.
122
122
  expect(mockLock.tryUse.mock.calls.length).toBeLessThan(5);
123
123
  });
@@ -46,6 +46,7 @@ export type ConnectConfig = {
46
46
  * (emits when set).
47
47
  */
48
48
  export declare class CancellationSignal implements PromiseLike<void>, Subscribable<void> {
49
+ #private;
49
50
  protected readonly _stateSubject: BehaviorSubject<boolean>;
50
51
  /**
51
52
  * Observable which emits the current state (true for set, false for unset)
@@ -91,6 +92,11 @@ export declare class CancellationSignal implements PromiseLike<void>, Subscribab
91
92
  * @internal
92
93
  */
93
94
  constructor(stateSubject: BehaviorSubject<boolean>);
95
+ /**
96
+ * Returns a standard `AbortSignal` that is aborted when this token is set.
97
+ * Useful for interoperability with APIs like `fetch`.
98
+ */
99
+ get abortSignal(): AbortSignal;
94
100
  /**
95
101
  * Returns a standard `AbortSignal` that is aborted when this token is set.
96
102
  * Useful for interoperability with APIs like `fetch`.
@@ -15,6 +15,7 @@ import { isBoolean } from '../utils/type-guards.js';
15
15
  */
16
16
  export class CancellationSignal {
17
17
  _stateSubject;
18
+ #abortSignal;
18
19
  /**
19
20
  * Observable which emits the current state (true for set, false for unset)
20
21
  * and any subsequent state changes.
@@ -74,6 +75,13 @@ export class CancellationSignal {
74
75
  constructor(stateSubject) {
75
76
  this._stateSubject = stateSubject;
76
77
  }
78
+ /**
79
+ * Returns a standard `AbortSignal` that is aborted when this token is set.
80
+ * Useful for interoperability with APIs like `fetch`.
81
+ */
82
+ get abortSignal() {
83
+ return (this.#abortSignal ??= this.asAbortSignal());
84
+ }
77
85
  /**
78
86
  * Returns a standard `AbortSignal` that is aborted when this token is set.
79
87
  * Useful for interoperability with APIs like `fetch`.
@@ -20,7 +20,6 @@ export class UndiciHttpClientAdapterOptions {
20
20
  ;
21
21
  let UndiciHttpClientAdapter = class UndiciHttpClientAdapter extends HttpClientAdapter {
22
22
  options = inject(UndiciHttpClientAdapterOptions, undefined, { optional: true }) ?? {};
23
- // eslint-disable-next-line max-lines-per-function, max-statements
24
23
  async call(httpClientRequest) {
25
24
  let body;
26
25
  if (isDefined(httpClientRequest.body?.json)) {
@@ -64,7 +63,6 @@ let UndiciHttpClientAdapter = class UndiciHttpClientAdapter extends HttpClientAd
64
63
  const httpClientResponse = new HttpClientResponse({
65
64
  request: httpClientRequest,
66
65
  statusCode: response.statusCode,
67
- statusMessage: '?',
68
66
  headers: new HttpHeaders(response.headers),
69
67
  body: response.body,
70
68
  closeHandler: () => response.body.destroy(),
@@ -34,6 +34,7 @@ export type HttpClientRequestOptions = Partial<TypedOmit<HttpClientRequest, 'url
34
34
  formData?: HttpFormDataObject | FormData;
35
35
  };
36
36
  abortSignal?: CancellationSignal;
37
+ priority?: RequestPriority;
37
38
  };
38
39
  export type HttpRequestCredentials = 'omit' | 'same-origin' | 'include';
39
40
  export type HttpClientRequestObject = HttpClientRequestOptions & {
@@ -95,6 +96,7 @@ export declare class HttpClientRequest implements Disposable {
95
96
  credentials: HttpRequestCredentials | undefined;
96
97
  authorization: HttpRequestAuthorization | undefined;
97
98
  body: HttpRequestBody | undefined;
99
+ priority: RequestPriority | undefined;
98
100
  /**
99
101
  * Request timeout in milliseconds
100
102
  * @default 30000
@@ -61,6 +61,7 @@ export class HttpClientRequest {
61
61
  credentials;
62
62
  authorization;
63
63
  body;
64
+ priority;
64
65
  /**
65
66
  * Request timeout in milliseconds
66
67
  * @default 30000
@@ -101,6 +102,7 @@ export class HttpClientRequest {
101
102
  this.authorization = requestOptions.authorization;
102
103
  this.body = normalizeBody(requestOptions.body);
103
104
  this.credentials = requestOptions.credentials;
105
+ this.priority = requestOptions.priority;
104
106
  this.timeout = requestOptions.timeout ?? 30000;
105
107
  this.throwOnNon200 = requestOptions.throwOnNon200 ?? true;
106
108
  this.context = requestOptions.context ?? {};
@@ -122,6 +124,7 @@ export class HttpClientRequest {
122
124
  request.authorization = clone(request.authorization, true);
123
125
  request.urlParameters = new HttpUrlParameters(request.urlParameters);
124
126
  request.body = normalizeBody(request.body);
127
+ request.priority = this.priority;
125
128
  return request;
126
129
  }
127
130
  asObject() {
@@ -135,6 +138,7 @@ export class HttpClientRequest {
135
138
  query: this.query.asObject(),
136
139
  authorization: this.authorization,
137
140
  body,
141
+ priority: this.priority,
138
142
  timeout: this.timeout,
139
143
  throwOnNon200: this.throwOnNon200,
140
144
  context: this.context,
@@ -9,7 +9,7 @@ export type HttpClientResponseObject = TypedOmit<HttpClientResponse, 'hasBody' |
9
9
  export type HttpClientResponseOptions = {
10
10
  request: HttpClientRequest;
11
11
  statusCode: number;
12
- statusMessage: string | null;
12
+ statusMessage?: string | null;
13
13
  headers: HttpHeadersObject | HttpHeaders;
14
14
  body: HttpBody | HttpBodySource;
15
15
  closeHandler: () => void;
@@ -1,6 +1,7 @@
1
1
  import { isString } from '../../utils/type-guards.js';
2
2
  import { HttpBody } from '../http-body.js';
3
3
  import { HttpHeaders } from '../http-headers.js';
4
+ import { getHttpStatusMessage } from '../utils.js';
4
5
  export class HttpClientResponse {
5
6
  closeHandler;
6
7
  request;
@@ -14,7 +15,7 @@ export class HttpClientResponse {
14
15
  constructor(options) {
15
16
  this.request = options.request;
16
17
  this.statusCode = options.statusCode;
17
- this.statusMessage = (isString(options.statusMessage) && (options.statusMessage.length > 0)) ? options.statusMessage : null;
18
+ this.statusMessage = (isString(options.statusMessage) && (options.statusMessage.length > 0)) ? options.statusMessage : getHttpStatusMessage(this.statusCode);
18
19
  this.headers = new HttpHeaders(options.headers);
19
20
  this.body = (options.body instanceof HttpBody) ? options.body : new HttpBody(options.body, this.headers);
20
21
  this.closeHandler = options.closeHandler;
@@ -28,7 +29,7 @@ export class HttpClientResponse {
28
29
  statusCode: this.statusCode,
29
30
  statusMessage: this.statusMessage,
30
31
  headers: this.headers.asObject(),
31
- body: this.body
32
+ body: this.body,
32
33
  };
33
34
  return obj;
34
35
  }
package/http/utils.d.ts CHANGED
@@ -15,4 +15,10 @@ export declare function readBodyAsTextStream(body: Body, headers: HttpHeaders, o
15
15
  export declare function readBodyAsJson(body: Body, headers: HttpHeaders, options?: ReadBodyAsJsonOptions): Promise<UndefinableJson>;
16
16
  export declare function readBody(body: Body, headers: HttpHeaders, options?: ReadBodyOptions): Promise<string | UndefinableJson | Uint8Array>;
17
17
  export declare function readBodyAsStream(body: Body, headers: HttpHeaders, options?: ReadBodyOptions): ReadableStream<string> | ReadableStream<Uint8Array>;
18
+ /**
19
+ * Returns the standard HTTP status message for a given status code.
20
+ * @param statusCode The HTTP status code.
21
+ * @returns The standard status message, or 'Unknown' if the code is not recognized.
22
+ */
23
+ export declare function getHttpStatusMessage(statusCode: number): string;
18
24
  export {};
package/http/utils.js CHANGED
@@ -104,3 +104,74 @@ function ensureSize(length, options) {
104
104
  throw MaxBytesExceededError.fromBytes(options.maxBytes);
105
105
  }
106
106
  }
107
+ const httpStatusMessages = {
108
+ 100: 'Continue',
109
+ 101: 'Switching Protocols',
110
+ 102: 'Processing',
111
+ 103: 'Early Hints',
112
+ 200: 'OK',
113
+ 201: 'Created',
114
+ 202: 'Accepted',
115
+ 203: 'Non-Authoritative Information',
116
+ 204: 'No Content',
117
+ 205: 'Reset Content',
118
+ 206: 'Partial Content',
119
+ 207: 'Multi-Status',
120
+ 208: 'Already Reported',
121
+ 226: 'IM Used',
122
+ 300: 'Multiple Choices',
123
+ 301: 'Moved Permanently',
124
+ 302: 'Found',
125
+ 303: 'See Other',
126
+ 304: 'Not Modified',
127
+ 307: 'Temporary Redirect',
128
+ 308: 'Permanent Redirect',
129
+ 400: 'Bad Request',
130
+ 401: 'Unauthorized',
131
+ 402: 'Payment Required',
132
+ 403: 'Forbidden',
133
+ 404: 'Not Found',
134
+ 405: 'Method Not Allowed',
135
+ 406: 'Not Acceptable',
136
+ 407: 'Proxy Authentication Required',
137
+ 408: 'Request Timeout',
138
+ 409: 'Conflict',
139
+ 410: 'Gone',
140
+ 411: 'Length Required',
141
+ 412: 'Precondition Failed',
142
+ 413: 'Content Too Large',
143
+ 414: 'URI Too Long',
144
+ 415: 'Unsupported Media Type',
145
+ 416: 'Range Not Satisfiable',
146
+ 417: 'Expectation Failed',
147
+ 418: 'I\'m a teapot',
148
+ 421: 'Misdirected Request',
149
+ 422: 'Unprocessable Content',
150
+ 423: 'Locked',
151
+ 424: 'Failed Dependency',
152
+ 425: 'Too Early',
153
+ 426: 'Upgrade Required',
154
+ 428: 'Precondition Required',
155
+ 429: 'Too Many Requests',
156
+ 431: 'Request Header Fields Too Large',
157
+ 451: 'Unavailable For Legal Reasons',
158
+ 500: 'Internal Server Error',
159
+ 501: 'Not Implemented',
160
+ 502: 'Bad Gateway',
161
+ 503: 'Service Unavailable',
162
+ 504: 'Gateway Timeout',
163
+ 505: 'HTTP Version Not Supported',
164
+ 506: 'Variant Also Negotiates',
165
+ 507: 'Insufficient Storage',
166
+ 508: 'Loop Detected',
167
+ 510: 'Not Extended',
168
+ 511: 'Network Authentication Required',
169
+ };
170
+ /**
171
+ * Returns the standard HTTP status message for a given status code.
172
+ * @param statusCode The HTTP status code.
173
+ * @returns The standard status message, or 'Unknown' if the code is not recognized.
174
+ */
175
+ export function getHttpStatusMessage(statusCode) {
176
+ return httpStatusMessages[statusCode] ?? `Unknown (${statusCode})`;
177
+ }
@@ -7,6 +7,7 @@ import { DeferredPromise } from '../promise/deferred-promise.js';
7
7
  import { reflectionRegistry } from '../reflection/registry.js';
8
8
  import { toArray } from '../utils/array/array.js';
9
9
  import { FactoryMap } from '../utils/factory-map.js';
10
+ import { noop } from '../utils/noop.js';
10
11
  import { ForwardRef } from '../utils/object/forward-ref.js';
11
12
  import { objectEntries } from '../utils/object/object.js';
12
13
  import { assert, isArray, isDefined, isFunction, isNotNull, isNotObject, isNull, isPromise, isUndefined } from '../utils/type-guards.js';
@@ -597,6 +598,7 @@ function newInternalResolveContext(injector) {
597
598
  forwardRefs: new Set(),
598
599
  $done: new DeferredPromise(),
599
600
  };
601
+ void context.$done.catch(noop); // prevent unhandled rejection if no one is awaiting it
600
602
  return context;
601
603
  }
602
604
  function postProcess(context) {
@@ -0,0 +1,8 @@
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
+ );
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "f8cdba37-11b9-477a-9f5a-5ef4b5026011",
2
+ "id": "0c48afa4-9ab0-4965-a93e-05a6c1b88e58",
3
3
  "prevId": "00000000-0000-0000-0000-000000000000",
4
4
  "version": "7",
5
5
  "dialect": "postgresql",
@@ -44,37 +44,6 @@
44
44
  "type": "text[]",
45
45
  "primaryKey": false,
46
46
  "notNull": false
47
- },
48
- "revision": {
49
- "name": "revision",
50
- "type": "integer",
51
- "primaryKey": false,
52
- "notNull": true
53
- },
54
- "revision_timestamp": {
55
- "name": "revision_timestamp",
56
- "type": "timestamp with time zone",
57
- "primaryKey": false,
58
- "notNull": true
59
- },
60
- "create_timestamp": {
61
- "name": "create_timestamp",
62
- "type": "timestamp with time zone",
63
- "primaryKey": false,
64
- "notNull": true
65
- },
66
- "delete_timestamp": {
67
- "name": "delete_timestamp",
68
- "type": "timestamp with time zone",
69
- "primaryKey": false,
70
- "notNull": false
71
- },
72
- "attributes": {
73
- "name": "attributes",
74
- "type": "jsonb",
75
- "primaryKey": false,
76
- "notNull": true,
77
- "default": "'{}'::jsonb"
78
47
  }
79
48
  },
80
49
  "indexes": {},
@@ -5,15 +5,8 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "7",
8
- "when": 1740059198387,
9
- "tag": "0000_previous_malcolm_colcord",
10
- "breakpoints": true
11
- },
12
- {
13
- "idx": 1,
14
- "version": "7",
15
- "when": 1761124592125,
16
- "tag": "0001_flimsy_bloodscream",
8
+ "when": 1771240070681,
9
+ "tag": "0000_numerous_the_watchers",
17
10
  "breakpoints": true
18
11
  }
19
12
  ]
@@ -1,27 +1,20 @@
1
1
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
2
  import { setupIntegrationTest } from '../../../testing/index.js';
3
3
  import { readBinaryStream } from '../../../utils/stream/stream-reader.js';
4
- import { configureS3ObjectStorage } from '../s3.object-storage-provider.js';
4
+ import { isUndefined } from '../../../utils/type-guards.js';
5
5
  import { S3ObjectStorage } from '../s3.object-storage.js';
6
6
  describe('S3ObjectStorage Integration', () => {
7
7
  let storage;
8
- const bucketName = 'integration-test-bucket';
9
8
  beforeAll(async () => {
10
9
  const { injector } = await setupIntegrationTest({
11
10
  modules: { objectStorage: true },
12
11
  });
13
- configureS3ObjectStorage({
14
- endpoint: 'http://127.0.0.1:9000',
15
- accessKey: 'tstdl-dev',
16
- secretKey: 'tstdl-dev',
17
- bucket: bucketName,
18
- region: 'us-east-1',
19
- forcePathStyle: true,
20
- injector,
21
- });
22
12
  storage = await injector.resolveAsync(S3ObjectStorage, 'test-module');
23
13
  });
24
14
  afterAll(async () => {
15
+ if (isUndefined(storage)) {
16
+ return;
17
+ }
25
18
  const objects = await storage.getObjects();
26
19
  for (const obj of objects) {
27
20
  await storage.deleteObject(obj.key);
@@ -80,7 +73,7 @@ describe('S3ObjectStorage Integration', () => {
80
73
  const key = 'signed-download.txt';
81
74
  await storage.uploadObject(key, new TextEncoder().encode('signed download'));
82
75
  const url = await storage.getDownloadUrl(key, Date.now() + 60000);
83
- expect(url).toContain('http://127.0.0.1:9000');
76
+ expect(url).toMatch(/http:\/\/(127\.0\.0\.1|localhost):9000/);
84
77
  const response = await fetch(url);
85
78
  expect(response.status).toBe(200);
86
79
  expect(await response.text()).toBe('signed download');
@@ -154,15 +147,8 @@ describe('S3ObjectStorage Integration', () => {
154
147
  expect(stat.metadata['extra-key']).toBe('extra-value');
155
148
  });
156
149
  it('should work with bucket per module', async () => {
157
- const { injector } = await setupIntegrationTest();
158
- configureS3ObjectStorage({
159
- endpoint: 'http://127.0.0.1:9000',
160
- accessKey: 'tstdl-dev',
161
- secretKey: 'tstdl-dev',
162
- bucketPerModule: true,
163
- region: 'us-east-1',
164
- forcePathStyle: true,
165
- injector,
150
+ const { injector } = await setupIntegrationTest({
151
+ modules: { objectStorage: true },
166
152
  });
167
153
  const moduleName = `test-bucket-per-module-${Math.floor(Math.random() * 1000000)}`;
168
154
  const perModuleStorage = await injector.resolveAsync(S3ObjectStorage, moduleName);
@@ -177,7 +163,7 @@ describe('S3ObjectStorage Integration', () => {
177
163
  const metadata = { 's3-test': 'true' };
178
164
  await storage.uploadObject(key, content, { metadata });
179
165
  const obj = await storage.getObject(key);
180
- expect(await obj.getResourceUri()).toBe(`s3://integration-test-bucket/test-module/${key}`);
166
+ expect(await obj.getResourceUri()).toBe(`s3://test-module/${key}`);
181
167
  expect(await obj.getContentLength()).toBe(content.length);
182
168
  expect(await obj.getMetadata()).toMatchObject(metadata);
183
169
  expect(new TextDecoder().decode(await obj.getContent())).toBe('s3 object');
@@ -242,25 +228,22 @@ describe('S3ObjectStorage Integration', () => {
242
228
  const key = 'signed-download-expires.txt';
243
229
  await storage.uploadObject(key, new TextEncoder().encode('signed download expires'));
244
230
  const url = await storage.getDownloadUrl(key, Date.now() + 60000, {
245
- 'Expires': new Date(Date.now() + 60000).toUTCString(),
231
+ Expires: new Date(Date.now() + 60000).toUTCString(),
246
232
  });
247
- expect(url).toContain('http://127.0.0.1:9000');
233
+ expect(url).toMatch(/http:\/\/(127\.0\.0\.1|localhost):9000/);
248
234
  const response = await fetch(url);
249
235
  expect(response.status).toBe(200);
250
236
  });
251
237
  it('should handle Forbidden error in ensureBucketExists with wrong credentials', async () => {
252
- const { injector } = await setupIntegrationTest();
253
- configureS3ObjectStorage({
254
- endpoint: 'http://127.0.0.1:9000',
255
- accessKey: 'wrong',
256
- secretKey: 'wrong',
257
- bucket: 'forbidden-bucket',
258
- region: 'us-east-1',
259
- forcePathStyle: true,
260
- injector,
238
+ const { injector } = await setupIntegrationTest({
239
+ modules: { objectStorage: true },
240
+ s3: {
241
+ accessKey: 'wrong',
242
+ secretKey: 'wrong',
243
+ },
261
244
  });
262
245
  try {
263
- await injector.resolveAsync(S3ObjectStorage, 'test-module');
246
+ await injector.resolveAsync(S3ObjectStorage, 'forbidden-bucket');
264
247
  expect.fail('Should have thrown');
265
248
  }
266
249
  catch (error) {
@@ -268,17 +251,10 @@ describe('S3ObjectStorage Integration', () => {
268
251
  }
269
252
  });
270
253
  it('should copy object between different storages', async () => {
271
- const { injector } = await setupIntegrationTest();
272
- configureS3ObjectStorage({
273
- endpoint: 'http://127.0.0.1:9000',
274
- accessKey: 'tstdl-dev',
275
- secretKey: 'tstdl-dev',
276
- bucket: 'another-bucket',
277
- region: 'us-east-1',
278
- forcePathStyle: true,
279
- injector,
254
+ const { injector } = await setupIntegrationTest({
255
+ modules: { objectStorage: true },
280
256
  });
281
- const anotherStorage = await injector.resolveAsync(S3ObjectStorage, 'another-module');
257
+ const anotherStorage = await injector.resolveAsync(S3ObjectStorage, 'another-bucket');
282
258
  const sourceKey = 'cross-storage-source.txt';
283
259
  const destKey = 'cross-storage-dest.txt';
284
260
  await storage.uploadObject(sourceKey, new TextEncoder().encode('cross storage content'));
@@ -288,15 +264,8 @@ describe('S3ObjectStorage Integration', () => {
288
264
  expect(new TextDecoder().decode(content)).toBe('cross storage content');
289
265
  });
290
266
  it('should cover ensureBucketExists with region', async () => {
291
- const { injector } = await setupIntegrationTest();
292
- configureS3ObjectStorage({
293
- endpoint: 'http://127.0.0.1:9000',
294
- accessKey: 'tstdl-dev',
295
- secretKey: 'tstdl-dev',
296
- bucketPerModule: true,
297
- region: 'us-east-1',
298
- forcePathStyle: true,
299
- injector,
267
+ const { injector } = await setupIntegrationTest({
268
+ modules: { objectStorage: true },
300
269
  });
301
270
  const perModuleStorage = await injector.resolveAsync(S3ObjectStorage, `region-test-${Math.floor(Math.random() * 1000000)}`);
302
271
  await perModuleStorage.ensureBucketExists('us-east-1', { objectLocking: true });