@geekmidas/constructs 3.0.14 → 3.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 (126) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/AWSScheduledFunction-B3cHQSdp.cjs +255 -0
  3. package/dist/AWSScheduledFunction-B3cHQSdp.cjs.map +1 -0
  4. package/dist/AWSScheduledFunction-BRGjgD-i.mjs +243 -0
  5. package/dist/AWSScheduledFunction-BRGjgD-i.mjs.map +1 -0
  6. package/dist/AWSScheduledFunction-BnYDnzsF.d.mts +117 -0
  7. package/dist/AWSScheduledFunction-BnYDnzsF.d.mts.map +1 -0
  8. package/dist/AWSScheduledFunction-vjanLO3-.d.cts +117 -0
  9. package/dist/AWSScheduledFunction-vjanLO3-.d.cts.map +1 -0
  10. package/dist/{Construct-D0n7QTaI.d.mts → Construct-BY0NBQLj.d.mts} +1 -1
  11. package/dist/{Construct-D0n7QTaI.d.mts.map → Construct-BY0NBQLj.d.mts.map} +1 -1
  12. package/dist/{Construct-CdJY2DJS.mjs → Construct-CLIPPqUP.mjs} +3 -28
  13. package/dist/{Construct-CdJY2DJS.mjs.map → Construct-CLIPPqUP.mjs.map} +1 -1
  14. package/dist/{Construct-CXqvzglu.d.cts → Construct-cjIs74Gb.d.cts} +1 -1
  15. package/dist/{Construct-CXqvzglu.d.cts.map → Construct-cjIs74Gb.d.cts.map} +1 -1
  16. package/dist/{Endpoint-NvjIednd.d.mts → Endpoint-4gVYt12g.d.mts} +5 -5
  17. package/dist/{Endpoint-NvjIednd.d.mts.map → Endpoint-4gVYt12g.d.mts.map} +1 -1
  18. package/dist/{Endpoint-CE7qHX5S.d.cts → Endpoint-BlMZIwBY.d.cts} +4 -4
  19. package/dist/{Endpoint-CE7qHX5S.d.cts.map → Endpoint-BlMZIwBY.d.cts.map} +1 -1
  20. package/dist/{HonoEndpointAdaptor-DyYv0IoU.mjs → HonoEndpointAdaptor-6k-rI44C.mjs} +5 -4
  21. package/dist/{HonoEndpointAdaptor-DyYv0IoU.mjs.map → HonoEndpointAdaptor-6k-rI44C.mjs.map} +1 -1
  22. package/dist/{HonoEndpointAdaptor-DFdgCwYU.cjs → HonoEndpointAdaptor-CNIsjEw-.cjs} +6 -5
  23. package/dist/{HonoEndpointAdaptor-DFdgCwYU.cjs.map → HonoEndpointAdaptor-CNIsjEw-.cjs.map} +1 -1
  24. package/dist/{Subscriber-D8WkA8np.d.cts → Subscriber-C6YXAmTp.d.cts} +3 -3
  25. package/dist/{Subscriber-D8WkA8np.d.cts.map → Subscriber-C6YXAmTp.d.cts.map} +1 -1
  26. package/dist/{Subscriber-Tft61-YG.d.mts → Subscriber-CjOHxO0l.d.mts} +3 -3
  27. package/dist/{Subscriber-Tft61-YG.d.mts.map → Subscriber-CjOHxO0l.d.mts.map} +1 -1
  28. package/dist/adaptors/aws.cjs +12 -209
  29. package/dist/adaptors/aws.cjs.map +1 -1
  30. package/dist/adaptors/aws.d.cts +11 -66
  31. package/dist/adaptors/aws.d.cts.map +1 -1
  32. package/dist/adaptors/aws.d.mts +11 -66
  33. package/dist/adaptors/aws.d.mts.map +1 -1
  34. package/dist/adaptors/aws.mjs +11 -208
  35. package/dist/adaptors/aws.mjs.map +1 -1
  36. package/dist/adaptors/hono.cjs +6 -4
  37. package/dist/adaptors/hono.d.cts +4 -4
  38. package/dist/adaptors/hono.d.mts +4 -4
  39. package/dist/adaptors/hono.mjs +7 -5
  40. package/dist/adaptors/middy.cjs +120 -0
  41. package/dist/adaptors/middy.cjs.map +1 -0
  42. package/dist/adaptors/middy.d.cts +139 -0
  43. package/dist/adaptors/middy.d.cts.map +1 -0
  44. package/dist/adaptors/middy.d.mts +139 -0
  45. package/dist/adaptors/middy.d.mts.map +1 -0
  46. package/dist/adaptors/middy.mjs +118 -0
  47. package/dist/adaptors/middy.mjs.map +1 -0
  48. package/dist/adaptors/testing.cjs +9 -7
  49. package/dist/adaptors/testing.cjs.map +1 -1
  50. package/dist/adaptors/testing.d.cts +6 -6
  51. package/dist/adaptors/testing.d.mts +6 -6
  52. package/dist/adaptors/testing.mjs +7 -5
  53. package/dist/adaptors/testing.mjs.map +1 -1
  54. package/dist/chunk-B4qE4VCv.mjs +27 -0
  55. package/dist/crons/index.cjs +4 -1
  56. package/dist/crons/index.cjs.map +1 -1
  57. package/dist/crons/index.d.cts +6 -22
  58. package/dist/crons/index.d.cts.map +1 -1
  59. package/dist/crons/index.d.mts +5 -21
  60. package/dist/crons/index.d.mts.map +1 -1
  61. package/dist/crons/index.mjs +6 -3
  62. package/dist/crons/index.mjs.map +1 -1
  63. package/dist/endpoints/index.cjs +5 -3
  64. package/dist/endpoints/index.cjs.map +1 -1
  65. package/dist/endpoints/index.d.cts +7 -7
  66. package/dist/endpoints/index.d.mts +5 -5
  67. package/dist/endpoints/index.mjs +6 -3
  68. package/dist/endpoints/index.mjs.map +1 -1
  69. package/dist/functions/index.cjs +1 -1
  70. package/dist/functions/index.d.cts +2 -2
  71. package/dist/functions/index.d.mts +2 -2
  72. package/dist/functions/index.mjs +2 -2
  73. package/dist/{functions-GmSHFbpr.mjs → functions-BMR-1CIQ.mjs} +1639 -1638
  74. package/dist/functions-BMR-1CIQ.mjs.map +1 -0
  75. package/dist/{functions-CX21wstW.cjs → functions-_OAiwGYh.cjs} +1637 -1637
  76. package/dist/functions-_OAiwGYh.cjs.map +1 -0
  77. package/dist/{index-CzIMpK-e.d.cts → index-Dj3uvqCO.d.cts} +39 -38
  78. package/dist/index-Dj3uvqCO.d.cts.map +1 -0
  79. package/dist/{index-Cq4fz8uM.d.mts → index-DoiHTgUQ.d.mts} +35 -34
  80. package/dist/index-DoiHTgUQ.d.mts.map +1 -0
  81. package/dist/index.d.cts +3 -3
  82. package/dist/index.d.mts +3 -3
  83. package/dist/index.mjs +1 -1
  84. package/dist/{lazyAccessors-zNy6P6Cg.cjs → lazyAccessors-B4rtX68K.cjs} +6 -889
  85. package/dist/lazyAccessors-B4rtX68K.cjs.map +1 -0
  86. package/dist/{lazyAccessors-5h-aWohM.mjs → lazyAccessors-D-UHg0YJ.mjs} +9 -873
  87. package/dist/lazyAccessors-D-UHg0YJ.mjs.map +1 -0
  88. package/dist/lodash-CM4k4sZD.cjs +836 -0
  89. package/dist/lodash-CM4k4sZD.cjs.map +1 -0
  90. package/dist/lodash-DiUeLbQy.mjs +831 -0
  91. package/dist/lodash-DiUeLbQy.mjs.map +1 -0
  92. package/dist/{processAudits-B0PbJf1Z.mjs → processAudits-BEiSkgH3.mjs} +1 -1
  93. package/dist/{processAudits-B0PbJf1Z.mjs.map → processAudits-BEiSkgH3.mjs.map} +1 -1
  94. package/dist/{processAudits-B_a9bDPs.cjs → processAudits-CPcACKCA.cjs} +1 -1
  95. package/dist/{processAudits-B_a9bDPs.cjs.map → processAudits-CPcACKCA.cjs.map} +1 -1
  96. package/dist/publisher-BZgcrVPy.cjs +53 -0
  97. package/dist/publisher-BZgcrVPy.cjs.map +1 -0
  98. package/dist/publisher-CnpeMv-r.mjs +41 -0
  99. package/dist/publisher-CnpeMv-r.mjs.map +1 -0
  100. package/dist/subscribers/index.d.cts +2 -2
  101. package/dist/subscribers/index.d.mts +2 -2
  102. package/dist/subscribers/index.mjs +1 -1
  103. package/dist/{telemetry-COvVBKBM.d.mts → telemetry-Bp4DLvdQ.d.mts} +1 -1
  104. package/dist/{telemetry-Bt0vK4ma.d.cts.map → telemetry-Bp4DLvdQ.d.mts.map} +1 -1
  105. package/dist/{telemetry-Bt0vK4ma.d.cts → telemetry-Dqx-D3F7.d.cts} +1 -1
  106. package/dist/{telemetry-COvVBKBM.d.mts.map → telemetry-Dqx-D3F7.d.cts.map} +1 -1
  107. package/dist/{types-B8YlI4cx.d.cts → types-BdAaYDvd.d.cts} +1 -1
  108. package/dist/{types-B8YlI4cx.d.cts.map → types-BdAaYDvd.d.cts.map} +1 -1
  109. package/dist/{types-BA3eK4xj.d.mts → types-j0ti0JTY.d.mts} +1 -1
  110. package/dist/{types-BA3eK4xj.d.mts.map → types-j0ti0JTY.d.mts.map} +1 -1
  111. package/dist/types.d.cts +1 -1
  112. package/dist/types.d.mts +1 -1
  113. package/package.json +21 -11
  114. package/src/adaptors/__tests__/middy.spec.ts +212 -0
  115. package/src/adaptors/aws.ts +1 -0
  116. package/src/adaptors/middy.ts +225 -0
  117. package/src/crons/AWSScheduledFunction.ts +72 -0
  118. package/src/crons/__tests__/AWSScheduledFunction.spec.ts +152 -0
  119. package/src/crons/index.ts +1 -0
  120. package/tsdown.config.ts +1 -0
  121. package/dist/functions-CX21wstW.cjs.map +0 -1
  122. package/dist/functions-GmSHFbpr.mjs.map +0 -1
  123. package/dist/index-Cq4fz8uM.d.mts.map +0 -1
  124. package/dist/index-CzIMpK-e.d.cts.map +0 -1
  125. package/dist/lazyAccessors-5h-aWohM.mjs.map +0 -1
  126. package/dist/lazyAccessors-zNy6P6Cg.cjs.map +0 -1
@@ -0,0 +1,212 @@
1
+ import { EnvironmentParser } from '@geekmidas/envkit';
2
+ import type { Logger } from '@geekmidas/logger';
3
+ import type { Service } from '@geekmidas/services';
4
+ import { ServiceDiscovery, serviceContext } from '@geekmidas/services';
5
+ import middy from '@middy/core';
6
+ import type { Context } from 'aws-lambda';
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { addServices, requestContext, withServices } from '../middy';
9
+
10
+ const createMockContext = (): Context =>
11
+ ({
12
+ functionName: 'standalone',
13
+ functionVersion: '1',
14
+ invokedFunctionArn:
15
+ 'arn:aws:lambda:us-east-1:123456789012:function:standalone',
16
+ memoryLimitInMB: '128',
17
+ awsRequestId: 'aws-req-1',
18
+ logGroupName: '/aws/lambda/standalone',
19
+ logStreamName: 'stream',
20
+ getRemainingTimeInMillis: () => 30000,
21
+ done: vi.fn(),
22
+ fail: vi.fn(),
23
+ succeed: vi.fn(),
24
+ callbackWaitsForEmptyEventLoop: true,
25
+ }) as unknown as Context;
26
+
27
+ /**
28
+ * Invoke a Middy-wrapped handler with a mock Lambda context. The service
29
+ * middlewares augment the context type to require `services`, but at runtime
30
+ * they populate it before the handler runs — so the mock starts without it and
31
+ * we invoke through a loosely-typed shim.
32
+ */
33
+ const invoke = (
34
+ handler: (event: any, context: any) => any,
35
+ event: unknown = {},
36
+ ): Promise<unknown> => Promise.resolve(handler(event, createMockContext()));
37
+
38
+ function makeSpyLogger(): Logger {
39
+ const logger: Logger = {
40
+ trace: vi.fn(),
41
+ debug: vi.fn(),
42
+ info: vi.fn(),
43
+ warn: vi.fn(),
44
+ error: vi.fn(),
45
+ fatal: vi.fn(),
46
+ child: vi.fn(() => logger),
47
+ };
48
+ return logger;
49
+ }
50
+
51
+ interface Greeter {
52
+ greet(): string;
53
+ registeredRequestId: string;
54
+ }
55
+
56
+ class GreeterService implements Service<'greeter', Greeter> {
57
+ serviceName = 'greeter' as const;
58
+ async register(): Promise<Greeter> {
59
+ // Service code can read the request context during registration.
60
+ const registeredRequestId = serviceContext.getRequestId();
61
+ return {
62
+ registeredRequestId,
63
+ greet() {
64
+ serviceContext.getLogger().info('greeting');
65
+ return 'hello';
66
+ },
67
+ };
68
+ }
69
+ }
70
+
71
+ interface Counter {
72
+ next(): number;
73
+ }
74
+
75
+ class CounterService implements Service<'counter', Counter> {
76
+ serviceName = 'counter' as const;
77
+ async register(): Promise<Counter> {
78
+ let n = 0;
79
+ return { next: () => ++n };
80
+ }
81
+ }
82
+
83
+ describe('middy adaptor', () => {
84
+ beforeEach(() => {
85
+ // ServiceDiscovery is a process-wide singleton that caches resolved
86
+ // instances; reset so each test starts clean.
87
+ ServiceDiscovery.reset();
88
+ });
89
+
90
+ describe('requestContext', () => {
91
+ it('makes serviceContext available inside a plain handler', async () => {
92
+ const logger = makeSpyLogger();
93
+ let seenRequestId: string | undefined;
94
+ let hadContext = false;
95
+
96
+ const handler = middy(async () => {
97
+ hadContext = serviceContext.hasContext();
98
+ seenRequestId = serviceContext.getRequestId();
99
+ serviceContext.getLogger().info('tick');
100
+ }).use(requestContext({ logger }));
101
+
102
+ await invoke(handler);
103
+
104
+ expect(hadContext).toBe(true);
105
+ expect(seenRequestId).toBe('aws-req-1');
106
+ expect(logger.info).toHaveBeenCalledWith('tick');
107
+ });
108
+
109
+ it('gives each invocation its own fresh context', async () => {
110
+ const seen: string[] = [];
111
+ let counter = 0;
112
+ const handler = middy(async () => {
113
+ seen.push(serviceContext.getRequestId());
114
+ }).use(
115
+ requestContext({
116
+ logger: makeSpyLogger(),
117
+ getRequestId: () => `req-${++counter}`,
118
+ }),
119
+ );
120
+
121
+ await invoke(handler);
122
+ await invoke(handler);
123
+
124
+ // The second invocation must not inherit the first's request id.
125
+ expect(seen).toEqual(['req-1', 'req-2']);
126
+ });
127
+
128
+ it('propagates handler errors', async () => {
129
+ const handler = middy(async () => {
130
+ throw new Error('boom');
131
+ }).use(requestContext({ logger: makeSpyLogger() }));
132
+
133
+ await expect(invoke(handler)).rejects.toThrow('boom');
134
+ });
135
+
136
+ it('derives the request id via getRequestId override', async () => {
137
+ let seen: string | undefined;
138
+ const handler = middy(async () => {
139
+ seen = serviceContext.getRequestId();
140
+ }).use(
141
+ requestContext({
142
+ logger: makeSpyLogger(),
143
+ getRequestId: () => 'custom-id',
144
+ }),
145
+ );
146
+
147
+ await invoke(handler);
148
+ expect(seen).toBe('custom-id');
149
+ });
150
+ });
151
+
152
+ describe('addServices', () => {
153
+ it('resolves services onto event.services (paired with requestContext)', async () => {
154
+ const envParser = new EnvironmentParser({});
155
+ const logger = makeSpyLogger();
156
+ let greeting: string | undefined;
157
+ let counted: number | undefined;
158
+ let registeredRequestId: string | undefined;
159
+
160
+ const handler = middy(async (event: { services: any }) => {
161
+ registeredRequestId = event.services.greeter.registeredRequestId;
162
+ greeting = event.services.greeter.greet();
163
+ counted = event.services.counter.next();
164
+ })
165
+ .use(requestContext({ logger, getRequestId: () => 'shared-id' }))
166
+ .use(
167
+ addServices([new GreeterService(), new CounterService()], {
168
+ envParser,
169
+ }),
170
+ );
171
+
172
+ await invoke(handler);
173
+
174
+ expect(greeting).toBe('hello');
175
+ expect(counted).toBe(1);
176
+ // register() and the service method ran inside the requestContext.
177
+ expect(registeredRequestId).toBe('shared-id');
178
+ expect(logger.info).toHaveBeenCalledWith('greeting');
179
+ });
180
+
181
+ it('resolves context-free services without a requestContext', async () => {
182
+ const envParser = new EnvironmentParser({});
183
+ let counted: number | undefined;
184
+
185
+ const handler = middy(async (event: { services: any }) => {
186
+ counted = event.services.counter.next();
187
+ }).use(addServices([new CounterService()], { envParser }));
188
+
189
+ await invoke(handler);
190
+
191
+ expect(counted).toBe(1);
192
+ });
193
+ });
194
+
195
+ describe('withServices', () => {
196
+ it('establishes context and resolves services out of the box', async () => {
197
+ const envParser = new EnvironmentParser({});
198
+ let greeting: string | undefined;
199
+ let registeredRequestId: string | undefined;
200
+
201
+ const handler = middy(async (event: { services: any }) => {
202
+ registeredRequestId = event.services.greeter.registeredRequestId;
203
+ greeting = event.services.greeter.greet();
204
+ }).use(withServices([new GreeterService()], { envParser }));
205
+
206
+ await invoke(handler);
207
+
208
+ expect(greeting).toBe('hello');
209
+ expect(registeredRequestId).toBe('aws-req-1');
210
+ });
211
+ });
212
+ });
@@ -1,3 +1,4 @@
1
+ export { AWSScheduledFunction } from '../crons/AWSScheduledFunction';
1
2
  export { AmazonApiGatewayV1Endpoint } from '../endpoints/AmazonApiGatewayV1EndpointAdaptor';
2
3
  export { AmazonApiGatewayV2Endpoint } from '../endpoints/AmazonApiGatewayV2EndpointAdaptor';
3
4
  export { AWSLambdaFunction } from '../functions/AWSLambdaFunction';
@@ -0,0 +1,225 @@
1
+ import { EnvironmentParser } from '@geekmidas/envkit';
2
+ import type { Logger } from '@geekmidas/logger';
3
+ import { ConsoleLogger } from '@geekmidas/logger/console';
4
+ import {
5
+ enterRequestContext,
6
+ exitRequestContext,
7
+ type Service,
8
+ ServiceDiscovery,
9
+ type ServiceRecord,
10
+ } from '@geekmidas/services';
11
+ import type { MiddlewareObj } from '@middy/core';
12
+ import type { Context } from 'aws-lambda';
13
+ import set from 'lodash.set';
14
+
15
+ /**
16
+ * Middy middleware helpers that bring `@geekmidas/services` request context and
17
+ * service discovery to **standalone** Middy Lambda handlers — i.e. functions
18
+ * that aren't built with the `@geekmidas/constructs` Function/Cron constructs
19
+ * but still want `serviceContext.getLogger()` and resolved services.
20
+ *
21
+ * Why middleware (and not `runWithRequestContext`)? Middy runs `before → handler
22
+ * → after` as sequential awaits in a single async context, so establishing the
23
+ * context with `AsyncLocalStorage.enterWith` (via `enterRequestContext`) in a
24
+ * `before` hook propagates to the handler. `after`/`onError` reset it.
25
+ *
26
+ * Teardown is best-effort: like `enterRequestContext`, the reset is observable
27
+ * to code the handler reaches but not necessarily to the frame that invoked the
28
+ * Middy handler. This is fine for Lambda, where each invocation runs in its own
29
+ * fresh async context — `requestContext` always establishes a brand-new context
30
+ * per invocation, so requests never inherit a previous invocation's logger.
31
+ *
32
+ * @module
33
+ */
34
+
35
+ /**
36
+ * Options shared by the request-context-aware middlewares.
37
+ */
38
+ export interface RequestContextOptions {
39
+ /**
40
+ * Base logger to derive the per-request child logger from.
41
+ * Defaults to a new {@link ConsoleLogger}.
42
+ */
43
+ logger?: Logger;
44
+ /**
45
+ * Derive the request id from the event/context.
46
+ * Defaults to `context.awsRequestId`, falling back to `crypto.randomUUID()`.
47
+ */
48
+ getRequestId?: (event: unknown, context: Context) => string;
49
+ /**
50
+ * Extra bindings to attach to the per-request child logger.
51
+ */
52
+ bindings?: (event: unknown, context: Context) => Record<string, unknown>;
53
+ }
54
+
55
+ /**
56
+ * Options for the service-resolving middlewares.
57
+ */
58
+ export interface ServiceMiddlewareOptions extends RequestContextOptions {
59
+ /**
60
+ * Environment parser used to build the default {@link ServiceDiscovery}.
61
+ * Defaults to `new EnvironmentParser({ ...process.env })`.
62
+ */
63
+ envParser?: EnvironmentParser<{}>;
64
+ /**
65
+ * Explicit {@link ServiceDiscovery} to resolve services from. Takes
66
+ * precedence over `envParser`.
67
+ */
68
+ serviceDiscovery?: ServiceDiscovery;
69
+ }
70
+
71
+ function deriveRequestId(
72
+ options: RequestContextOptions,
73
+ event: unknown,
74
+ context: Context | undefined,
75
+ ): string {
76
+ return (
77
+ options.getRequestId?.(event, context as Context) ??
78
+ context?.awsRequestId ??
79
+ crypto.randomUUID()
80
+ );
81
+ }
82
+
83
+ function buildLogger(
84
+ baseLogger: Logger,
85
+ options: RequestContextOptions,
86
+ requestId: string,
87
+ event: unknown,
88
+ context: Context | undefined,
89
+ ): Logger {
90
+ return baseLogger.child({
91
+ requestId,
92
+ ...(options.bindings?.(event, context as Context) ?? {}),
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Middy middleware that establishes a request context for the handler so any
98
+ * code it reaches — including `@geekmidas/services` service methods — can call
99
+ * `serviceContext.getLogger()` / `getRequestId()` / `getRequestStartTime()`.
100
+ *
101
+ * Use this on standalone functions that need request-scoped logging. To also
102
+ * resolve services, pair it with {@link addServices}, or use
103
+ * {@link withServices} which bundles both.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * import middy from '@middy/core';
108
+ * import { serviceContext } from '@geekmidas/services';
109
+ * import { requestContext } from '@geekmidas/constructs/middy';
110
+ *
111
+ * export const handler = middy(async () => {
112
+ * serviceContext.getLogger().info('tick');
113
+ * }).use(requestContext());
114
+ * ```
115
+ */
116
+ export function requestContext(
117
+ options: RequestContextOptions = {},
118
+ ): MiddlewareObj<unknown, unknown, Error, Context> {
119
+ const baseLogger = options.logger ?? new ConsoleLogger();
120
+ return {
121
+ before: (request) => {
122
+ const { event, context } = request;
123
+ const requestId = deriveRequestId(options, event, context);
124
+ const logger = buildLogger(
125
+ baseLogger,
126
+ options,
127
+ requestId,
128
+ event,
129
+ context,
130
+ );
131
+ enterRequestContext({ logger, requestId, startTime: Date.now() });
132
+ },
133
+ after: () => {
134
+ exitRequestContext();
135
+ },
136
+ onError: () => {
137
+ exitRequestContext();
138
+ },
139
+ };
140
+ }
141
+
142
+ function resolveDiscovery(options: ServiceMiddlewareOptions): ServiceDiscovery {
143
+ return (
144
+ options.serviceDiscovery ??
145
+ ServiceDiscovery.getInstance(
146
+ options.envParser ?? new EnvironmentParser({ ...process.env }),
147
+ )
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Event augmentation applied by {@link addServices} / {@link withServices}:
153
+ * resolved services keyed by `serviceName`. Intersect it with your own event
154
+ * type to type the handler, e.g. `(event: EventServices<T> & APIGatewayEvent)`.
155
+ */
156
+ export type EventServices<T extends Service[]> = {
157
+ services: ServiceRecord<T>;
158
+ };
159
+
160
+ /**
161
+ * Middy middleware that resolves an array of {@link Service}s via
162
+ * {@link ServiceDiscovery} and attaches the resolved record to `event.services`
163
+ * (keyed by each service's `serviceName`), matching how the `Function`/`Cron`
164
+ * constructs expose services on the event.
165
+ *
166
+ * This middleware only resolves services; it does **not** establish a request
167
+ * context. If your services read `serviceContext` (e.g. `getLogger()`), pair it
168
+ * with {@link requestContext}, or use {@link withServices} which bundles both.
169
+ *
170
+ * Chainable — `.use(addServices([a])).use(addServices([b]))` accumulates onto
171
+ * `event.services`.
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * import middy from '@middy/core';
176
+ * import { addServices, requestContext } from '@geekmidas/constructs/middy';
177
+ *
178
+ * export const handler = middy(async (event) => {
179
+ * await event.services.database.users.deletePast();
180
+ * event.services.cache.clear();
181
+ * })
182
+ * .use(requestContext())
183
+ * .use(addServices([databaseService, cacheService], { envParser }));
184
+ * ```
185
+ */
186
+ export function addServices<const T extends Service[]>(
187
+ services: [...T],
188
+ options: ServiceMiddlewareOptions = {},
189
+ ): MiddlewareObj<EventServices<T>, unknown, Error, Context> {
190
+ const discovery = resolveDiscovery(options);
191
+
192
+ return {
193
+ before: async (request) => {
194
+ const resolved = await discovery.register(services);
195
+ set(request, 'event.services', resolved);
196
+ },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Batteries-included Middy setup for service-backed handlers: returns a pair of
202
+ * middlewares — {@link requestContext} followed by {@link addServices} — so a
203
+ * single `.use(withServices([...]))` gives the handler both a request context
204
+ * and the resolved services on `event.services`.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * import middy from '@middy/core';
209
+ * import { withServices } from '@geekmidas/constructs/middy';
210
+ *
211
+ * export const handler = middy(async (event) => {
212
+ * await event.services.database.users.deletePast();
213
+ * event.services.cache.clear();
214
+ * }).use(withServices([databaseService, cacheService], { envParser }));
215
+ * ```
216
+ */
217
+ export function withServices<const T extends Service[]>(
218
+ services: [...T],
219
+ options: ServiceMiddlewareOptions = {},
220
+ ): [
221
+ MiddlewareObj<unknown, unknown, Error, Context>,
222
+ MiddlewareObj<EventServices<T>, unknown, Error, Context>,
223
+ ] {
224
+ return [requestContext(options), addServices(services, options)];
225
+ }
@@ -0,0 +1,72 @@
1
+ import type { EnvironmentParser } from '@geekmidas/envkit';
2
+ import type { EventPublisher } from '@geekmidas/events';
3
+ import type { Logger } from '@geekmidas/logger';
4
+ import type { ComposableStandardSchema } from '@geekmidas/schema';
5
+ import type { Service } from '@geekmidas/services';
6
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
7
+ import { AWSLambdaFunction } from '../functions/AWSLambdaFunction';
8
+ import type { Cron } from './Cron';
9
+
10
+ /**
11
+ * AWS Lambda adaptor for {@link Cron} constructs.
12
+ *
13
+ * A `Cron` is a {@link import('../functions').Function} that is invoked on a
14
+ * schedule (an EventBridge rule) rather than by an API request. Because of
15
+ * that, the runtime execution pipeline is identical to a regular Lambda
16
+ * function: input parsing, service/database/auditor wiring, event publishing,
17
+ * error handling, and the `runWithRequestContext` wrapper that powers
18
+ * request-scoped logging via `serviceContext.getLogger()`.
19
+ *
20
+ * This adaptor therefore reuses {@link AWSLambdaFunction} wholesale and only
21
+ * narrows the constructor to accept a `Cron`. The schedule expression itself
22
+ * (`cron.schedule`) is **deploy-time** infrastructure consumed by the CLI to
23
+ * provision the EventBridge rule — it is not needed at runtime.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * import { AWSScheduledFunction } from '@geekmidas/constructs/crons';
28
+ * import { cancelPastBookings } from '../../../src/crons/cancelPastBookings.js';
29
+ * import { envParser } from '../../../src/env';
30
+ *
31
+ * const adapter = new AWSScheduledFunction(envParser, cancelPastBookings);
32
+ *
33
+ * export const handler = adapter.handler;
34
+ * ```
35
+ */
36
+ export class AWSScheduledFunction<
37
+ TInput extends ComposableStandardSchema | undefined = undefined,
38
+ TServices extends Service[] = [],
39
+ TLogger extends Logger = Logger,
40
+ TOutSchema extends StandardSchemaV1 | undefined = undefined,
41
+ TEventPublisher extends EventPublisher<any> | undefined = undefined,
42
+ TEventPublisherServiceName extends string = string,
43
+ TDatabase = undefined,
44
+ TDatabaseServiceName extends string = string,
45
+ > extends AWSLambdaFunction<
46
+ TInput,
47
+ TOutSchema,
48
+ TServices,
49
+ TLogger,
50
+ TEventPublisher,
51
+ TEventPublisherServiceName,
52
+ undefined,
53
+ string,
54
+ TDatabase,
55
+ TDatabaseServiceName
56
+ > {
57
+ constructor(
58
+ envParser: EnvironmentParser<{}>,
59
+ cron: Cron<
60
+ TInput,
61
+ TServices,
62
+ TLogger,
63
+ TOutSchema,
64
+ TEventPublisher,
65
+ TEventPublisherServiceName,
66
+ TDatabase,
67
+ TDatabaseServiceName
68
+ >,
69
+ ) {
70
+ super(envParser, cron);
71
+ }
72
+ }
@@ -0,0 +1,152 @@
1
+ import { EnvironmentParser } from '@geekmidas/envkit';
2
+ import type { Logger } from '@geekmidas/logger';
3
+ import type { Service } from '@geekmidas/services';
4
+ import { ServiceDiscovery, serviceContext } from '@geekmidas/services';
5
+ import type { Context } from 'aws-lambda';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { z } from 'zod/v4';
8
+ import { AWSScheduledFunction } from '../AWSScheduledFunction';
9
+ import { CronBuilder } from '../CronBuilder';
10
+
11
+ /** Spy logger whose `child()` returns itself, so all log calls land on it. */
12
+ function makeSpyLogger(): Logger {
13
+ const logger: Logger = {
14
+ trace: vi.fn(),
15
+ debug: vi.fn(),
16
+ info: vi.fn(),
17
+ warn: vi.fn(),
18
+ error: vi.fn(),
19
+ fatal: vi.fn(),
20
+ child: vi.fn(() => logger),
21
+ };
22
+ return logger;
23
+ }
24
+
25
+ // Minimal EventBridge scheduled-event envelope (no user payload).
26
+ const scheduledEvent = {
27
+ id: 'cdc73f9d-aea9-11e3-9d5a-835b769c0d9c',
28
+ version: '0',
29
+ account: '123456789012',
30
+ time: '2026-06-09T00:00:00Z',
31
+ region: 'us-east-1',
32
+ source: 'aws.events',
33
+ resources: ['arn:aws:events:us-east-1:123456789012:rule/daily'],
34
+ 'detail-type': 'Scheduled Event',
35
+ detail: {},
36
+ };
37
+
38
+ const createMockContext = (): Context =>
39
+ ({
40
+ functionName: 'cancelPastBookings',
41
+ functionVersion: '1',
42
+ invokedFunctionArn:
43
+ 'arn:aws:lambda:us-east-1:123456789012:function:cancelPastBookings',
44
+ memoryLimitInMB: '128',
45
+ awsRequestId: 'test-request-id',
46
+ logGroupName: '/aws/lambda/cancelPastBookings',
47
+ logStreamName: '2026/06/09/[$LATEST]test-stream',
48
+ getRemainingTimeInMillis: () => 30000,
49
+ done: vi.fn(),
50
+ fail: vi.fn(),
51
+ succeed: vi.fn(),
52
+ callbackWaitsForEmptyEventLoop: true,
53
+ }) as unknown as Context;
54
+
55
+ class CounterService implements Service<'counter', CounterService> {
56
+ serviceName = 'counter' as const;
57
+ count = 0;
58
+ async register() {
59
+ return this;
60
+ }
61
+ increment() {
62
+ this.count += 1;
63
+ return this.count;
64
+ }
65
+ }
66
+
67
+ describe('AWSScheduledFunction', () => {
68
+ let envParser: EnvironmentParser<{}>;
69
+
70
+ beforeEach(() => {
71
+ // ServiceDiscovery is a process-wide singleton that caches resolved
72
+ // instances; reset so each test (and ordering) starts clean.
73
+ ServiceDiscovery.reset();
74
+ envParser = new EnvironmentParser({});
75
+ vi.clearAllMocks();
76
+ });
77
+
78
+ it('runs the cron handler on a scheduled event', async () => {
79
+ const ran = vi.fn();
80
+ const cron = new CronBuilder().schedule('rate(1 day)').handle(async () => {
81
+ ran();
82
+ });
83
+
84
+ const adapter = new AWSScheduledFunction(envParser, cron);
85
+ const result = await adapter.handler(
86
+ scheduledEvent,
87
+ createMockContext(),
88
+ vi.fn(),
89
+ );
90
+
91
+ expect(ran).toHaveBeenCalledTimes(1);
92
+ // With no output schema the adaptor returns undefined, matching
93
+ // AWSLambdaFunction — crons typically don't return a response.
94
+ expect(result).toBeUndefined();
95
+ });
96
+
97
+ it('parses and returns output when the cron declares an output schema', async () => {
98
+ const cron = new CronBuilder()
99
+ .schedule('rate(1 day)')
100
+ .output(z.object({ cancelled: z.number() }))
101
+ .handle(async () => ({ cancelled: 3 }));
102
+
103
+ const adapter = new AWSScheduledFunction(envParser, cron);
104
+ const result = await adapter.handler(
105
+ scheduledEvent,
106
+ createMockContext(),
107
+ vi.fn(),
108
+ );
109
+
110
+ expect(result).toEqual({ cancelled: 3 });
111
+ });
112
+
113
+ it('exposes services to the cron handler', async () => {
114
+ let observed = 0;
115
+ const cron = new CronBuilder()
116
+ .schedule('rate(1 hour)')
117
+ .services([new CounterService()])
118
+ .handle(async ({ services }) => {
119
+ observed = services.counter.increment();
120
+ });
121
+
122
+ const adapter = new AWSScheduledFunction(envParser, cron);
123
+ await adapter.handler(scheduledEvent, createMockContext(), vi.fn());
124
+
125
+ expect(observed).toBe(1);
126
+ });
127
+
128
+ it('establishes request context so serviceContext.getLogger() works', async () => {
129
+ // The cron runs through the same runWithRequestContext wrapper as
130
+ // functions, so service code can resolve the request-scoped logger. We
131
+ // assert delegation via the cron's own logger rather than spying on the
132
+ // shared serviceContext proxy (which is a process-wide singleton).
133
+ let hadContext = false;
134
+ const logger = makeSpyLogger();
135
+
136
+ const cron = new CronBuilder()
137
+ .schedule('rate(1 day)')
138
+ .logger(logger)
139
+ .handle(async () => {
140
+ hadContext = serviceContext.hasContext();
141
+ serviceContext.getLogger().info('cron tick');
142
+ });
143
+
144
+ const adapter = new AWSScheduledFunction(envParser, cron);
145
+ await adapter.handler(scheduledEvent, createMockContext(), vi.fn());
146
+
147
+ expect(hadContext).toBe(true);
148
+ expect(logger.info).toHaveBeenCalledWith('cron tick');
149
+ // Context must not leak past the invocation.
150
+ expect(serviceContext.hasContext()).toBe(false);
151
+ });
152
+ });
@@ -1,3 +1,4 @@
1
+ export { AWSScheduledFunction } from './AWSScheduledFunction';
1
2
  export {
2
3
  Cron,
3
4
  type CronExpression,
package/tsdown.config.ts CHANGED
@@ -12,6 +12,7 @@ export default defineConfig({
12
12
  'src/adaptors/aws.ts',
13
13
  'src/adaptors/testing.ts',
14
14
  'src/adaptors/trpc.ts',
15
+ 'src/adaptors/middy.ts',
15
16
  ],
16
17
  clean: true,
17
18
  outDir: 'dist',