@geekmidas/constructs 7.0.1 → 9.1.0

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 (186) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/{AWSScheduledFunction-CTIa90EH.d.cts → AWSScheduledFunction-BHHlWlfV.d.cts} +2 -2
  3. package/dist/{AWSScheduledFunction-ysaCsdWx.d.mts.map → AWSScheduledFunction-BHHlWlfV.d.cts.map} +1 -1
  4. package/dist/{AWSScheduledFunction-ysaCsdWx.d.mts → AWSScheduledFunction-BzGCUbkk.d.mts} +2 -2
  5. package/dist/{AWSScheduledFunction-CTIa90EH.d.cts.map → AWSScheduledFunction-BzGCUbkk.d.mts.map} +1 -1
  6. package/dist/{AWSScheduledFunction-DbkDr8CA.cjs → AWSScheduledFunction-C42adp8A.cjs} +16 -5
  7. package/dist/AWSScheduledFunction-C42adp8A.cjs.map +1 -0
  8. package/dist/{AWSScheduledFunction-CufMMhGB.mjs → AWSScheduledFunction-KP6lqbsW.mjs} +15 -4
  9. package/dist/AWSScheduledFunction-KP6lqbsW.mjs.map +1 -0
  10. package/dist/{Construct-r0U2zBwW.mjs → Construct-BWF5HxaM.mjs} +5 -2
  11. package/dist/Construct-BWF5HxaM.mjs.map +1 -0
  12. package/dist/{Construct-_1wX7p_Y.d.cts → Construct-JlyQkBkv.d.cts} +39 -4
  13. package/dist/Construct-JlyQkBkv.d.cts.map +1 -0
  14. package/dist/{Construct-vXMZJgRp.cjs → Construct-TGIM_E4o.cjs} +5 -2
  15. package/dist/Construct-TGIM_E4o.cjs.map +1 -0
  16. package/dist/{Construct-DCMuYOox.d.mts → Construct-pxwC6fDD.d.mts} +39 -4
  17. package/dist/Construct-pxwC6fDD.d.mts.map +1 -0
  18. package/dist/{Endpoint-CLPSIl6S.d.mts → Endpoint-B0P_pEMr.d.mts} +3 -3
  19. package/dist/{Endpoint-CLPSIl6S.d.mts.map → Endpoint-B0P_pEMr.d.mts.map} +1 -1
  20. package/dist/{Endpoint-CAzNbDEt.d.cts → Endpoint-Cwjhc1Hl.d.cts} +2 -2
  21. package/dist/Endpoint-Cwjhc1Hl.d.cts.map +1 -0
  22. package/dist/{HonoEndpointAdaptor-DJDT3PMx.mjs → HonoEndpointAdaptor-BMVwx2cz.mjs} +18 -11
  23. package/dist/HonoEndpointAdaptor-BMVwx2cz.mjs.map +1 -0
  24. package/dist/{HonoEndpointAdaptor-CkJENANP.cjs → HonoEndpointAdaptor-DPDsDi-D.cjs} +19 -12
  25. package/dist/HonoEndpointAdaptor-DPDsDi-D.cjs.map +1 -0
  26. package/dist/{Queue-HBDf0fqz.d.mts → Queue-BL68oDJ-.d.mts} +2 -2
  27. package/dist/{Queue-DaxjwmOQ.d.cts.map → Queue-BL68oDJ-.d.mts.map} +1 -1
  28. package/dist/{Queue-DaxjwmOQ.d.cts → Queue-DxJ6btRE.d.cts} +2 -2
  29. package/dist/{Queue-HBDf0fqz.d.mts.map → Queue-DxJ6btRE.d.cts.map} +1 -1
  30. package/dist/{Subscriber-B69ngPwl.d.cts → Subscriber-BpVhcGhX.d.cts} +2 -2
  31. package/dist/{Subscriber-B69ngPwl.d.cts.map → Subscriber-BpVhcGhX.d.cts.map} +1 -1
  32. package/dist/{Subscriber-D8Xm-86W.d.mts → Subscriber-DF6xsBAJ.d.mts} +2 -2
  33. package/dist/{Subscriber-D8Xm-86W.d.mts.map → Subscriber-DF6xsBAJ.d.mts.map} +1 -1
  34. package/dist/{Topic-DT10AqHk.d.mts → Topic-DJXoDeMP.d.cts} +3 -3
  35. package/dist/{Topic-C5ZMfveW.d.cts.map → Topic-DJXoDeMP.d.cts.map} +1 -1
  36. package/dist/{Topic-C5ZMfveW.d.cts → Topic-DxWQBgMN.d.mts} +3 -3
  37. package/dist/{Topic-DT10AqHk.d.mts.map → Topic-DxWQBgMN.d.mts.map} +1 -1
  38. package/dist/adaptors/aws.cjs +14 -7
  39. package/dist/adaptors/aws.cjs.map +1 -1
  40. package/dist/adaptors/aws.d.cts +6 -6
  41. package/dist/adaptors/aws.d.cts.map +1 -1
  42. package/dist/adaptors/aws.d.mts +8 -8
  43. package/dist/adaptors/aws.d.mts.map +1 -1
  44. package/dist/adaptors/aws.mjs +14 -7
  45. package/dist/adaptors/aws.mjs.map +1 -1
  46. package/dist/adaptors/hono.cjs +7 -6
  47. package/dist/adaptors/hono.d.cts +3 -3
  48. package/dist/adaptors/hono.d.cts.map +1 -1
  49. package/dist/adaptors/hono.d.mts +4 -4
  50. package/dist/adaptors/hono.d.mts.map +1 -1
  51. package/dist/adaptors/hono.mjs +7 -6
  52. package/dist/adaptors/testing.cjs +161 -126
  53. package/dist/adaptors/testing.cjs.map +1 -1
  54. package/dist/adaptors/testing.d.cts +7 -5
  55. package/dist/adaptors/testing.d.cts.map +1 -1
  56. package/dist/adaptors/testing.d.mts +8 -6
  57. package/dist/adaptors/testing.d.mts.map +1 -1
  58. package/dist/adaptors/testing.mjs +162 -127
  59. package/dist/adaptors/testing.mjs.map +1 -1
  60. package/dist/clone-I_WIpiFf.cjs +45 -0
  61. package/dist/clone-I_WIpiFf.cjs.map +1 -0
  62. package/dist/clone-pdXxc0t3.mjs +39 -0
  63. package/dist/clone-pdXxc0t3.mjs.map +1 -0
  64. package/dist/crons/index.cjs +14 -32
  65. package/dist/crons/index.cjs.map +1 -1
  66. package/dist/crons/index.d.cts +7 -7
  67. package/dist/crons/index.d.cts.map +1 -1
  68. package/dist/crons/index.d.mts +3 -3
  69. package/dist/crons/index.d.mts.map +1 -1
  70. package/dist/crons/index.mjs +14 -32
  71. package/dist/crons/index.mjs.map +1 -1
  72. package/dist/endpoints/index.cjs +74 -61
  73. package/dist/endpoints/index.cjs.map +1 -1
  74. package/dist/endpoints/index.d.cts +14 -6
  75. package/dist/endpoints/index.d.cts.map +1 -1
  76. package/dist/endpoints/index.d.mts +13 -5
  77. package/dist/endpoints/index.d.mts.map +1 -1
  78. package/dist/endpoints/index.mjs +74 -61
  79. package/dist/endpoints/index.mjs.map +1 -1
  80. package/dist/functions/index.cjs +3 -2
  81. package/dist/functions/index.d.cts +2 -2
  82. package/dist/functions/index.d.mts +2 -2
  83. package/dist/functions/index.mjs +3 -2
  84. package/dist/{functions-B-qt91O7.mjs → functions-EqTloHjo.mjs} +107 -46
  85. package/dist/functions-EqTloHjo.mjs.map +1 -0
  86. package/dist/{functions-C8i1kCMM.cjs → functions-SqtCDc9c.cjs} +118 -45
  87. package/dist/functions-SqtCDc9c.cjs.map +1 -0
  88. package/dist/{index-DP6fqv0I.d.mts → index-C7iynzLI.d.mts} +8 -2
  89. package/dist/index-C7iynzLI.d.mts.map +1 -0
  90. package/dist/{index-BGeTScmi.d.cts → index-DdgJRm4q.d.cts} +12 -6
  91. package/dist/index-DdgJRm4q.d.cts.map +1 -0
  92. package/dist/index.cjs +1 -1
  93. package/dist/index.d.cts +2 -2
  94. package/dist/index.d.mts +4 -4
  95. package/dist/index.mjs +1 -1
  96. package/dist/{lazyAccessors-DfqZR6C4.mjs → lazyAccessors-CCTK19xj.mjs} +3 -3
  97. package/dist/{lazyAccessors-DfqZR6C4.mjs.map → lazyAccessors-CCTK19xj.mjs.map} +1 -1
  98. package/dist/{lazyAccessors-DmTG8sQH.cjs → lazyAccessors-D04oftGw.cjs} +3 -3
  99. package/dist/{lazyAccessors-DmTG8sQH.cjs.map → lazyAccessors-D04oftGw.cjs.map} +1 -1
  100. package/dist/monitors/index.cjs +49 -0
  101. package/dist/monitors/index.cjs.map +1 -0
  102. package/dist/monitors/index.d.cts +45 -0
  103. package/dist/monitors/index.d.cts.map +1 -0
  104. package/dist/monitors/index.d.mts +45 -0
  105. package/dist/monitors/index.d.mts.map +1 -0
  106. package/dist/monitors/index.mjs +48 -0
  107. package/dist/monitors/index.mjs.map +1 -0
  108. package/dist/{processAudits-CKjjivyz.cjs → processAudits-BJZT5gmy.cjs} +2 -2
  109. package/dist/{processAudits-CKjjivyz.cjs.map → processAudits-BJZT5gmy.cjs.map} +1 -1
  110. package/dist/{processAudits-ySnizdMG.mjs → processAudits-CwyaaSQc.mjs} +1 -1
  111. package/dist/{processAudits-ySnizdMG.mjs.map → processAudits-CwyaaSQc.mjs.map} +1 -1
  112. package/dist/{publisher-lFQleddL.cjs → publisher-BZgcrVPy.cjs} +1 -1
  113. package/dist/{publisher-lFQleddL.cjs.map → publisher-BZgcrVPy.cjs.map} +1 -1
  114. package/dist/{publisher-BrFOF847.mjs → publisher-CnpeMv-r.mjs} +1 -1
  115. package/dist/{publisher-BrFOF847.mjs.map → publisher-CnpeMv-r.mjs.map} +1 -1
  116. package/dist/queue/index.cjs +9 -22
  117. package/dist/queue/index.cjs.map +1 -1
  118. package/dist/queue/index.d.cts +4 -4
  119. package/dist/queue/index.d.cts.map +1 -1
  120. package/dist/queue/index.d.mts +2 -2
  121. package/dist/queue/index.d.mts.map +1 -1
  122. package/dist/queue/index.mjs +9 -22
  123. package/dist/queue/index.mjs.map +1 -1
  124. package/dist/subscribers/index.cjs +9 -22
  125. package/dist/subscribers/index.cjs.map +1 -1
  126. package/dist/subscribers/index.d.cts +5 -5
  127. package/dist/subscribers/index.d.cts.map +1 -1
  128. package/dist/subscribers/index.d.mts +3 -3
  129. package/dist/subscribers/index.d.mts.map +1 -1
  130. package/dist/subscribers/index.mjs +9 -22
  131. package/dist/subscribers/index.mjs.map +1 -1
  132. package/dist/{telemetry-3vdxGn4q.d.mts → telemetry-C--EgYVr.d.mts} +1 -1
  133. package/dist/{telemetry-3vdxGn4q.d.mts.map → telemetry-C--EgYVr.d.mts.map} +1 -1
  134. package/dist/topic/index.cjs +4 -7
  135. package/dist/topic/index.cjs.map +1 -1
  136. package/dist/topic/index.d.cts +2 -2
  137. package/dist/topic/index.d.cts.map +1 -1
  138. package/dist/topic/index.d.mts +2 -2
  139. package/dist/topic/index.d.mts.map +1 -1
  140. package/dist/topic/index.mjs +4 -7
  141. package/dist/topic/index.mjs.map +1 -1
  142. package/dist/{types-BDNGr7pv.d.mts → types-DNngH0fX.d.mts} +1 -1
  143. package/dist/{types-BDNGr7pv.d.mts.map → types-DNngH0fX.d.mts.map} +1 -1
  144. package/dist/types.d.mts +1 -1
  145. package/package.json +32 -22
  146. package/src/Construct.ts +5 -0
  147. package/src/__tests__/builder-reuse.spec.ts +587 -0
  148. package/src/__tests__/monitor.spec.ts +636 -0
  149. package/src/clone.ts +38 -0
  150. package/src/crons/CronBuilder.ts +35 -45
  151. package/src/crons/__tests__/Cron.spec.ts +10 -2
  152. package/src/crons/__tests__/CronBuilder.state-isolation.spec.ts +64 -18
  153. package/src/endpoints/AmazonApiGatewayEndpointAdaptor.ts +23 -2
  154. package/src/endpoints/EndpointBuilder.ts +61 -70
  155. package/src/endpoints/EndpointFactory.ts +31 -0
  156. package/src/endpoints/HonoEndpointAdaptor.ts +16 -1
  157. package/src/endpoints/TestEndpointAdaptor.ts +194 -150
  158. package/src/endpoints/__tests__/EndpointBuilder.audit.spec.ts +6 -3
  159. package/src/endpoints/__tests__/EndpointBuilder.responseType.spec.ts +4 -1
  160. package/src/endpoints/__tests__/EndpointBuilder.spec.ts +16 -8
  161. package/src/functions/AWSLambdaFunction.ts +28 -2
  162. package/src/functions/BaseFunctionBuilder.ts +33 -22
  163. package/src/functions/FunctionBuilder.ts +41 -51
  164. package/src/functions/TestFunctionAdaptor.ts +66 -31
  165. package/src/functions/__tests__/FunctionBuilder.state-isolation.spec.ts +55 -12
  166. package/src/index.ts +6 -0
  167. package/src/monitor.ts +145 -0
  168. package/src/monitors/heartbeat.ts +86 -0
  169. package/src/monitors/index.ts +6 -0
  170. package/src/queue/QueueBuilder.ts +22 -22
  171. package/src/subscribers/SubscriberBuilder.ts +19 -22
  172. package/src/topic/TopicBuilder.ts +9 -7
  173. package/tsdown.config.ts +1 -0
  174. package/dist/AWSScheduledFunction-CufMMhGB.mjs.map +0 -1
  175. package/dist/AWSScheduledFunction-DbkDr8CA.cjs.map +0 -1
  176. package/dist/Construct-DCMuYOox.d.mts.map +0 -1
  177. package/dist/Construct-_1wX7p_Y.d.cts.map +0 -1
  178. package/dist/Construct-r0U2zBwW.mjs.map +0 -1
  179. package/dist/Construct-vXMZJgRp.cjs.map +0 -1
  180. package/dist/Endpoint-CAzNbDEt.d.cts.map +0 -1
  181. package/dist/HonoEndpointAdaptor-CkJENANP.cjs.map +0 -1
  182. package/dist/HonoEndpointAdaptor-DJDT3PMx.mjs.map +0 -1
  183. package/dist/functions-B-qt91O7.mjs.map +0 -1
  184. package/dist/functions-C8i1kCMM.cjs.map +0 -1
  185. package/dist/index-BGeTScmi.d.cts.map +0 -1
  186. package/dist/index-DP6fqv0I.d.mts.map +0 -1
@@ -0,0 +1,636 @@
1
+ import { EnvironmentParser } from '@geekmidas/envkit';
2
+ import { NotFoundError } from '@geekmidas/errors';
3
+ import { ServiceDiscovery } from '@geekmidas/services';
4
+ import { createMockContext, createMockV2Event } from '@geekmidas/testkit/aws';
5
+ import { Hono } from 'hono';
6
+ import { HttpResponse, http } from 'msw';
7
+ import { setupServer } from 'msw/node';
8
+ import {
9
+ afterAll,
10
+ afterEach,
11
+ beforeAll,
12
+ beforeEach,
13
+ describe,
14
+ expect,
15
+ it,
16
+ vi,
17
+ } from 'vitest';
18
+ import { z } from 'zod';
19
+ import { AWSScheduledFunction } from '../crons/AWSScheduledFunction';
20
+ import { CronBuilder } from '../crons/CronBuilder';
21
+ import { AmazonApiGatewayV2Endpoint } from '../endpoints/AmazonApiGatewayV2EndpointAdaptor';
22
+ import { EndpointFactory } from '../endpoints/EndpointFactory';
23
+ import { HonoEndpoint } from '../endpoints/HonoEndpointAdaptor';
24
+ import { TestEndpointAdaptor } from '../endpoints/TestEndpointAdaptor';
25
+ import { AWSLambdaFunction } from '../functions/AWSLambdaFunction';
26
+ import { FunctionBuilder } from '../functions/FunctionBuilder';
27
+ import { TestFunctionAdaptor } from '../functions/TestFunctionAdaptor';
28
+ import type { ConstructMonitor, MonitorService } from '../monitor';
29
+ import { heartbeat } from '../monitors';
30
+
31
+ const HEARTBEAT = 'https://heartbeat.test/api/v1/heartbeat/abc';
32
+
33
+ /** Every heartbeat request MSW saw, as `path`. */
34
+ const pings: string[] = [];
35
+
36
+ const server = setupServer(
37
+ http.get('https://heartbeat.test/*', ({ request }) => {
38
+ pings.push(new URL(request.url).pathname.replace('/api/v1/heartbeat', ''));
39
+ return new HttpResponse(null, { status: 200 });
40
+ }),
41
+ );
42
+
43
+ /** A monitor that writes down what it was told, in order. */
44
+ function recorder(serviceName = 'recorder') {
45
+ const calls: string[] = [];
46
+ const service = {
47
+ serviceName,
48
+ async register(): Promise<ConstructMonitor<string>> {
49
+ return {
50
+ async start() {
51
+ calls.push('start');
52
+ return 'run-1';
53
+ },
54
+ async success({ run, status }) {
55
+ calls.push(`success:${run}${status ? `:${status}` : ''}`);
56
+ },
57
+ async failure({ run, status, error }) {
58
+ calls.push(
59
+ `failure:${run}${status ? `:${status}` : ''}:${(error as Error).message}`,
60
+ );
61
+ },
62
+ };
63
+ },
64
+ } satisfies MonitorService;
65
+
66
+ return { calls, service };
67
+ }
68
+
69
+ const throwing = {
70
+ serviceName: 'throwing',
71
+ async register(): Promise<ConstructMonitor> {
72
+ return {
73
+ async start() {
74
+ throw new Error('start failed');
75
+ },
76
+ async success() {
77
+ throw new Error('success failed');
78
+ },
79
+ async failure() {
80
+ throw new Error('failure failed');
81
+ },
82
+ };
83
+ },
84
+ } satisfies MonitorService;
85
+
86
+ function discovery(env: Record<string, string> = {}) {
87
+ return ServiceDiscovery.getInstance(new EnvironmentParser(env));
88
+ }
89
+
90
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
91
+ afterAll(() => server.close());
92
+ beforeEach(() => {
93
+ // Services are cached by name, so each test starts from its own env.
94
+ ServiceDiscovery.reset();
95
+ pings.length = 0;
96
+ });
97
+ afterEach(() => server.resetHandlers());
98
+
99
+ describe('heartbeat', () => {
100
+ it('pings the URL when a run succeeds', async () => {
101
+ const cron = new CronBuilder()
102
+ .input(z.object({}))
103
+ .schedule('rate(1 day)')
104
+ .monitor(heartbeat.betterStack())
105
+ .handle(async () => ({ ok: true }));
106
+
107
+ await new TestFunctionAdaptor(
108
+ cron,
109
+ discovery({ HEARTBEAT_URL: HEARTBEAT }),
110
+ ).invoke({ input: {}, services: {} });
111
+
112
+ expect(pings).toEqual(['/abc']);
113
+ });
114
+
115
+ it('pings the failure URL and rethrows when a run fails', async () => {
116
+ const cron = new CronBuilder()
117
+ .input(z.object({}))
118
+ .schedule('rate(1 day)')
119
+ .monitor(heartbeat.betterStack())
120
+ .handle(async () => {
121
+ throw new Error('boom');
122
+ });
123
+
124
+ await expect(
125
+ new TestFunctionAdaptor(
126
+ cron,
127
+ discovery({ HEARTBEAT_URL: HEARTBEAT }),
128
+ ).invoke({ input: {}, services: {} }),
129
+ ).rejects.toThrow('boom');
130
+
131
+ expect(pings).toEqual(['/abc/fail']);
132
+ });
133
+
134
+ it('pings start before success for providers that take one', async () => {
135
+ const fn = new FunctionBuilder()
136
+ .input(z.object({}))
137
+ .monitor(heartbeat.healthchecks())
138
+ .handle(async () => ({}));
139
+
140
+ await new TestFunctionAdaptor(
141
+ fn,
142
+ discovery({ HEARTBEAT_URL: HEARTBEAT }),
143
+ ).invoke({ input: {}, services: {} });
144
+
145
+ expect(pings).toEqual(['/abc/start', '/abc']);
146
+ });
147
+
148
+ it('does nothing when its env var is unset', async () => {
149
+ const cron = new CronBuilder()
150
+ .input(z.object({}))
151
+ .schedule('rate(1 day)')
152
+ .monitor(heartbeat.betterStack())
153
+ .handle(async () => ({}));
154
+
155
+ await new TestFunctionAdaptor(cron, discovery()).invoke({
156
+ input: {},
157
+ services: {},
158
+ });
159
+
160
+ expect(pings).toEqual([]);
161
+ });
162
+
163
+ it('reads the env var it was given', async () => {
164
+ const cron = new CronBuilder()
165
+ .input(z.object({}))
166
+ .schedule('rate(1 day)')
167
+ .monitor(heartbeat.betterStack('NIGHTLY_HEARTBEAT_URL'))
168
+ .handle(async () => ({}));
169
+
170
+ await new TestFunctionAdaptor(
171
+ cron,
172
+ discovery({ NIGHTLY_HEARTBEAT_URL: `${HEARTBEAT}/nightly` }),
173
+ ).invoke({ input: {}, services: {} });
174
+
175
+ expect(pings).toEqual(['/abc/nightly']);
176
+ });
177
+
178
+ it('does not fail the run when the provider is down', async () => {
179
+ server.use(
180
+ http.get('https://heartbeat.test/*', () =>
181
+ HttpResponse.json({ error: 'down' }, { status: 503 }),
182
+ ),
183
+ );
184
+ const cron = new CronBuilder()
185
+ .input(z.object({}))
186
+ .schedule('rate(1 day)')
187
+ .monitor(heartbeat.betterStack())
188
+ .output(z.object({ ok: z.boolean() }))
189
+ .handle(async () => ({ ok: true }));
190
+
191
+ const output = await new TestFunctionAdaptor(
192
+ cron,
193
+ discovery({ HEARTBEAT_URL: HEARTBEAT }),
194
+ ).invoke({ input: {}, services: {} });
195
+
196
+ expect(output).toEqual({ ok: true });
197
+ });
198
+
199
+ it('declares its env var as optional', async () => {
200
+ const cron = new CronBuilder()
201
+ .input(z.object({}))
202
+ .schedule('rate(1 day)')
203
+ .monitor(heartbeat.betterStack('NIGHTLY_HEARTBEAT_URL'))
204
+ .handle(async () => ({}));
205
+
206
+ expect(await cron.getEnvironment({ markOptional: true })).toContain(
207
+ 'NIGHTLY_HEARTBEAT_URL?',
208
+ );
209
+ });
210
+ });
211
+
212
+ describe('duplicate monitors', () => {
213
+ it('refuses two heartbeats reading the same env var', () => {
214
+ expect(() =>
215
+ new CronBuilder()
216
+ .monitor(heartbeat.betterStack())
217
+ .monitor(heartbeat.healthchecks()),
218
+ ).toThrow('Monitor "heartbeat:HEARTBEAT_URL" is already set');
219
+ });
220
+
221
+ it('refuses an endpoint repeating its factory’s monitor', () => {
222
+ const e = new EndpointFactory().monitor(heartbeat.betterStack());
223
+
224
+ expect(() => e.get('/x').monitor(heartbeat.betterStack())).toThrow(
225
+ 'is already set',
226
+ );
227
+ });
228
+
229
+ it('refuses the same monitor twice on a factory', () => {
230
+ expect(() =>
231
+ new EndpointFactory()
232
+ .monitor(heartbeat.betterStack())
233
+ .monitor(heartbeat.healthchecks()),
234
+ ).toThrow('is already set');
235
+ });
236
+
237
+ it('allows two heartbeats on their own env vars', async () => {
238
+ const fn = new FunctionBuilder()
239
+ .input(z.object({}))
240
+ .monitor(heartbeat.betterStack('BETTER_STACK_HEARTBEAT_URL'))
241
+ .monitor(heartbeat.healthchecks('HEALTHCHECKS_HEARTBEAT_URL'))
242
+ .handle(async () => ({}));
243
+
244
+ await new TestFunctionAdaptor(
245
+ fn,
246
+ discovery({
247
+ BETTER_STACK_HEARTBEAT_URL: `${HEARTBEAT}/bs`,
248
+ HEALTHCHECKS_HEARTBEAT_URL: `${HEARTBEAT}/hc`,
249
+ }),
250
+ ).invoke({ input: {}, services: {} });
251
+
252
+ // Each provider gets its own pings, in its own URL shape.
253
+ expect(pings.sort()).toEqual(['/abc/bs', '/abc/hc', '/abc/hc/start']);
254
+ });
255
+ });
256
+
257
+ describe('functions and crons', () => {
258
+ it('report start, then success with what start returned', async () => {
259
+ const { calls, service } = recorder();
260
+ const fn = new FunctionBuilder()
261
+ .input(z.object({}))
262
+ .monitor(service)
263
+ .handle(async () => ({}));
264
+
265
+ await new TestFunctionAdaptor(fn, discovery()).invoke({
266
+ input: {},
267
+ services: {},
268
+ });
269
+
270
+ expect(calls).toEqual(['start', 'success:run-1']);
271
+ });
272
+
273
+ it('tell every monitor, in the AWS Lambda handler', async () => {
274
+ const one = recorder('one');
275
+ const two = recorder('two');
276
+ const cron = new CronBuilder()
277
+ .input(z.object({}))
278
+ .schedule('rate(1 day)')
279
+ .monitor(one.service)
280
+ .monitor(two.service)
281
+ .handle(async () => {
282
+ throw new Error('boom');
283
+ });
284
+ discovery();
285
+
286
+ const handler = new AWSScheduledFunction(new EnvironmentParser({}), cron)
287
+ .handler;
288
+
289
+ // The handler rethrows the wrapped error; monitors get the original.
290
+ await expect(handler({}, createMockContext(), vi.fn())).rejects.toThrow();
291
+ expect(one.calls).toEqual(['start', 'failure:run-1:boom']);
292
+ expect(two.calls).toEqual(['start', 'failure:run-1:boom']);
293
+ });
294
+
295
+ it('ignore a monitor that throws', async () => {
296
+ const fn = new FunctionBuilder()
297
+ .input(z.object({}))
298
+ .monitor(throwing)
299
+ .output(z.object({ ok: z.boolean() }))
300
+ .handle(async () => ({ ok: true }));
301
+
302
+ const output = await new TestFunctionAdaptor(fn, discovery()).invoke({
303
+ input: {},
304
+ services: {},
305
+ });
306
+
307
+ expect(output).toEqual({ ok: true });
308
+ });
309
+
310
+ it('inherit a shared base’s monitor, and add their own', async () => {
311
+ const shared = recorder('shared');
312
+ const own = recorder('own');
313
+ const base = new CronBuilder().monitor(shared.service);
314
+
315
+ const nightly = base
316
+ .schedule('rate(1 day)')
317
+ .monitor(own.service)
318
+ .handle(async () => ({}));
319
+ const hourly = base.schedule('rate(1 hour)').handle(async () => ({}));
320
+
321
+ expect(nightly.monitors.map((m) => m.serviceName)).toEqual([
322
+ 'shared',
323
+ 'own',
324
+ ]);
325
+ expect(hourly.monitors.map((m) => m.serviceName)).toEqual(['shared']);
326
+ });
327
+ });
328
+
329
+ describe('endpoints', () => {
330
+ function api(monitor: MonitorService) {
331
+ const e = new EndpointFactory().monitor(monitor);
332
+ return {
333
+ ok: e.get('/ok').handle(async () => ({ ok: true })),
334
+ missing: e.get('/missing').handle(async () => {
335
+ throw new NotFoundError('nope');
336
+ }),
337
+ broken: e.get('/broken').handle(async () => {
338
+ throw new Error('boom');
339
+ }),
340
+ };
341
+ }
342
+
343
+ it('inherit the factory’s monitor, and add their own', () => {
344
+ const shared = recorder('shared');
345
+ const own = recorder('own');
346
+ const e = new EndpointFactory().monitor(shared.service);
347
+
348
+ const withOwn = e
349
+ .get('/a')
350
+ .monitor(own.service)
351
+ .handle(async () => ({}));
352
+ const without = e.get('/b').handle(async () => ({}));
353
+
354
+ expect(withOwn.monitors.map((m) => m.serviceName)).toEqual([
355
+ 'shared',
356
+ 'own',
357
+ ]);
358
+ expect(without.monitors.map((m) => m.serviceName)).toEqual(['shared']);
359
+ });
360
+
361
+ describe('TestEndpointAdaptor', () => {
362
+ const request = (endpoint: any) =>
363
+ new TestEndpointAdaptor(endpoint, discovery()).fullRequest({
364
+ services: {},
365
+ headers: {},
366
+ } as any);
367
+
368
+ it('reports a 2xx as a success', async () => {
369
+ const { calls, service } = recorder();
370
+ await request(api(service).ok);
371
+ expect(calls).toEqual(['start', 'success:run-1:200']);
372
+ });
373
+
374
+ it('reports a 4xx as a success: the caller erred, not the service', async () => {
375
+ const { calls, service } = recorder();
376
+ await expect(request(api(service).missing)).rejects.toThrow('nope');
377
+ expect(calls).toEqual(['start', 'success:run-1:404']);
378
+ });
379
+
380
+ it('reports a thrown error as a 500 failure', async () => {
381
+ const { calls, service } = recorder();
382
+ await expect(request(api(service).broken)).rejects.toThrow('boom');
383
+ expect(calls).toEqual(['start', 'failure:run-1:500:boom']);
384
+ });
385
+ });
386
+
387
+ describe('Hono', () => {
388
+ async function serve(endpoint: any, path: string) {
389
+ const app = new Hono();
390
+ new HonoEndpoint(endpoint).addRoute(discovery(), app);
391
+ return app.request(path);
392
+ }
393
+
394
+ it('reports a 2xx as a success, without holding the response', async () => {
395
+ const { calls, service } = recorder();
396
+ const response = await serve(api(service).ok, '/ok');
397
+
398
+ expect(response.status).toBe(200);
399
+ await vi.waitFor(() =>
400
+ expect(calls).toEqual(['start', 'success:run-1:200']),
401
+ );
402
+ });
403
+
404
+ it('reports a 4xx as a success', async () => {
405
+ const { calls, service } = recorder();
406
+ const response = await serve(api(service).missing, '/missing');
407
+
408
+ expect(response.status).toBe(404);
409
+ await vi.waitFor(() =>
410
+ expect(calls).toEqual(['start', 'success:run-1:404']),
411
+ );
412
+ });
413
+
414
+ it('reports a 5xx as a failure, with the error', async () => {
415
+ const { calls, service } = recorder();
416
+ const response = await serve(api(service).broken, '/broken');
417
+
418
+ expect(response.status).toBe(500);
419
+ await vi.waitFor(() =>
420
+ expect(calls).toEqual(['start', 'failure:run-1:500:boom']),
421
+ );
422
+ });
423
+ });
424
+
425
+ describe('API Gateway', () => {
426
+ async function invoke(endpoint: any) {
427
+ discovery();
428
+ const handler = new AmazonApiGatewayV2Endpoint(
429
+ new EnvironmentParser({}),
430
+ endpoint,
431
+ ).handler;
432
+ return handler(createMockV2Event(), createMockContext(), vi.fn());
433
+ }
434
+
435
+ it('reports a 2xx as a success', async () => {
436
+ const { calls, service } = recorder();
437
+ const response: any = await invoke(api(service).ok);
438
+
439
+ expect(response.statusCode).toBe(200);
440
+ expect(calls).toEqual(['start', 'success:run-1:200']);
441
+ });
442
+
443
+ it('reports a 4xx as a success', async () => {
444
+ const { calls, service } = recorder();
445
+ const response: any = await invoke(api(service).missing);
446
+
447
+ expect(response.statusCode).toBe(404);
448
+ expect(calls).toEqual(['start', 'success:run-1:404']);
449
+ });
450
+
451
+ it('reports a 5xx as a failure, with the error', async () => {
452
+ const { calls, service } = recorder();
453
+ const response: any = await invoke(api(service).broken);
454
+
455
+ expect(response.statusCode).toBe(500);
456
+ expect(calls).toEqual(['start', 'failure:run-1:500:boom']);
457
+ });
458
+ });
459
+ });
460
+
461
+ /**
462
+ * The heartbeat over real HTTP (MSW), on every construct and runtime: what
463
+ * actually reaches the provider when a run succeeds or fails.
464
+ */
465
+ describe('heartbeat requests, per construct', () => {
466
+ const env = { HEARTBEAT_URL: HEARTBEAT };
467
+
468
+ function endpoints() {
469
+ const e = new EndpointFactory().monitor(heartbeat.betterStack());
470
+ return {
471
+ ok: e.get('/ok').handle(async () => ({ ok: true })),
472
+ missing: e.get('/missing').handle(async () => {
473
+ throw new NotFoundError('nope');
474
+ }),
475
+ broken: e.get('/broken').handle(async () => {
476
+ throw new Error('boom');
477
+ }),
478
+ };
479
+ }
480
+
481
+ describe('function, AWS Lambda handler', () => {
482
+ const invoke = (fn: any) =>
483
+ new AWSLambdaFunction(new EnvironmentParser(env), fn).handler(
484
+ {},
485
+ createMockContext(),
486
+ vi.fn(),
487
+ );
488
+
489
+ it('sends the success ping', async () => {
490
+ await invoke(
491
+ new FunctionBuilder()
492
+ .monitor(heartbeat.betterStack())
493
+ .handle(async () => ({})),
494
+ );
495
+ expect(pings).toEqual(['/abc']);
496
+ });
497
+
498
+ it('sends the failure ping', async () => {
499
+ await expect(
500
+ invoke(
501
+ new FunctionBuilder()
502
+ .monitor(heartbeat.betterStack())
503
+ .handle(async () => {
504
+ throw new Error('boom');
505
+ }),
506
+ ),
507
+ ).rejects.toThrow();
508
+ expect(pings).toEqual(['/abc/fail']);
509
+ });
510
+ });
511
+
512
+ describe('cron, AWS Lambda handler', () => {
513
+ const invoke = (cron: any) =>
514
+ new AWSScheduledFunction(new EnvironmentParser(env), cron).handler(
515
+ {},
516
+ createMockContext(),
517
+ vi.fn(),
518
+ );
519
+
520
+ it('sends the success ping', async () => {
521
+ await invoke(
522
+ new CronBuilder()
523
+ .schedule('rate(1 day)')
524
+ .monitor(heartbeat.betterStack())
525
+ .handle(async () => ({})),
526
+ );
527
+ expect(pings).toEqual(['/abc']);
528
+ });
529
+
530
+ it('sends the failure ping', async () => {
531
+ await expect(
532
+ invoke(
533
+ new CronBuilder()
534
+ .schedule('rate(1 day)')
535
+ .monitor(heartbeat.betterStack())
536
+ .handle(async () => {
537
+ throw new Error('boom');
538
+ }),
539
+ ),
540
+ ).rejects.toThrow();
541
+ expect(pings).toEqual(['/abc/fail']);
542
+ });
543
+
544
+ it('sends the start ping first, for Healthchecks.io', async () => {
545
+ await invoke(
546
+ new CronBuilder()
547
+ .schedule('rate(1 day)')
548
+ .monitor(heartbeat.healthchecks())
549
+ .handle(async () => ({})),
550
+ );
551
+ expect(pings).toEqual(['/abc/start', '/abc']);
552
+ });
553
+
554
+ it('sends one ping per run, from a shared base', async () => {
555
+ const base = new CronBuilder().monitor(heartbeat.betterStack());
556
+
557
+ await invoke(base.schedule('rate(1 day)').handle(async () => ({})));
558
+ await invoke(base.schedule('rate(1 hour)').handle(async () => ({})));
559
+
560
+ expect(pings).toEqual(['/abc', '/abc']);
561
+ });
562
+ });
563
+
564
+ describe('endpoint, TestEndpointAdaptor', () => {
565
+ const request = (endpoint: any) =>
566
+ new TestEndpointAdaptor(endpoint, discovery(env)).fullRequest({
567
+ services: {},
568
+ headers: {},
569
+ } as any);
570
+
571
+ it('sends the success ping on a 2xx', async () => {
572
+ await request(endpoints().ok);
573
+ expect(pings).toEqual(['/abc']);
574
+ });
575
+
576
+ it('sends the success ping on a 4xx', async () => {
577
+ await expect(request(endpoints().missing)).rejects.toThrow();
578
+ expect(pings).toEqual(['/abc']);
579
+ });
580
+
581
+ it('sends the failure ping on a 5xx', async () => {
582
+ await expect(request(endpoints().broken)).rejects.toThrow();
583
+ expect(pings).toEqual(['/abc/fail']);
584
+ });
585
+ });
586
+
587
+ describe('endpoint, Hono', () => {
588
+ async function serve(endpoint: any, path: string) {
589
+ const app = new Hono();
590
+ new HonoEndpoint(endpoint).addRoute(discovery(env), app);
591
+ return app.request(path);
592
+ }
593
+
594
+ // Hono does not wait on the ping, so each test waits for it to land.
595
+ it('sends the success ping on a 2xx', async () => {
596
+ expect((await serve(endpoints().ok, '/ok')).status).toBe(200);
597
+ await vi.waitFor(() => expect(pings).toEqual(['/abc']));
598
+ });
599
+
600
+ it('sends the success ping on a 4xx', async () => {
601
+ expect((await serve(endpoints().missing, '/missing')).status).toBe(404);
602
+ await vi.waitFor(() => expect(pings).toEqual(['/abc']));
603
+ });
604
+
605
+ it('sends the failure ping on a 5xx', async () => {
606
+ expect((await serve(endpoints().broken, '/broken')).status).toBe(500);
607
+ await vi.waitFor(() => expect(pings).toEqual(['/abc/fail']));
608
+ });
609
+ });
610
+
611
+ describe('endpoint, API Gateway', () => {
612
+ const invoke = (endpoint: any) =>
613
+ new AmazonApiGatewayV2Endpoint(
614
+ new EnvironmentParser(env),
615
+ endpoint,
616
+ ).handler(createMockV2Event(), createMockContext(), vi.fn()) as Promise<{
617
+ statusCode: number;
618
+ }>;
619
+
620
+ // Awaited before the response returns: the ping is there already.
621
+ it('sends the success ping on a 2xx', async () => {
622
+ expect((await invoke(endpoints().ok)).statusCode).toBe(200);
623
+ expect(pings).toEqual(['/abc']);
624
+ });
625
+
626
+ it('sends the success ping on a 4xx', async () => {
627
+ expect((await invoke(endpoints().missing)).statusCode).toBe(404);
628
+ expect(pings).toEqual(['/abc']);
629
+ });
630
+
631
+ it('sends the failure ping on a 5xx', async () => {
632
+ expect((await invoke(endpoints().broken)).statusCode).toBe(500);
633
+ expect(pings).toEqual(['/abc/fail']);
634
+ });
635
+ });
636
+ });
package/src/clone.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Cloning a builder, without enumerating its fields.
3
+ *
4
+ * The builders were mutable singletons that reset themselves by listing every
5
+ * field at the end of `.handle()`. Two things followed from that, and both were
6
+ * real:
7
+ *
8
+ * A configured base could not be reused. `const fn = f.logger(log)` handed back
9
+ * `f` itself, and the first `.handle()` reset `_logger` to the default — so the
10
+ * second handler built from that base silently logged somewhere else. Every
11
+ * field in the reset block behaved the same way, which made
12
+ * `f.logger(l).timeout(60_000)` a base that worked exactly once.
13
+ *
14
+ * And a field left out of the reset list leaked into the next handler instead.
15
+ * `_constructs` was left out, so a function was granted a bucket a *previous*
16
+ * function had declared — an over-grant in the field a deploy reads to size an
17
+ * IAM policy.
18
+ *
19
+ * Both are the same defect: a hand-maintained list of fields that has to be
20
+ * edited every time a field is added, and is silent when it isn't. So this
21
+ * copies whatever the instance actually has, and the list stops existing. The
22
+ * prototype is carried over, so `instanceof` and every method still resolve,
23
+ * and the constructor is deliberately not re-run — this copies state rather
24
+ * than initialising it.
25
+ *
26
+ * Fields must be replaced rather than mutated in place: a clone and its parent
27
+ * share whatever array a field points at, so `_events.push(…)` would be seen by
28
+ * both. Every builder assigns a fresh array instead, which is what makes
29
+ * sharing the reference safe.
30
+ */
31
+ export function cloneWith<T extends object>(
32
+ instance: T,
33
+ delta: Record<string, unknown>,
34
+ ): T {
35
+ const next = Object.create(Object.getPrototypeOf(instance)) as T;
36
+
37
+ return Object.assign(next, instance, delta);
38
+ }