@geekmidas/cloud 1.0.1 → 1.1.1

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.
package/README.md CHANGED
@@ -1 +1,126 @@
1
1
  # @geekmidas/cloud
2
+
3
+ SST (ion / Pulumi) integration for `@geekmidas` apps — opinionated, linkable
4
+ constructs that map 1:1 to deployable units and **validate their environment
5
+ before deploy**, plus the runtime helpers that resolve linked resources into
6
+ environment variables.
7
+
8
+ Two halves:
9
+
10
+ - **`@geekmidas/cloud/sst`** — the **infra-time** constructs you instantiate in
11
+ `sst.config.ts` (`App`, `Stack`, `Function`, `Api`, `Cron`).
12
+ - **`@geekmidas/cloud` / `@geekmidas/cloud/utils`** — the **runtime** helpers
13
+ (`buildResourceEnv`, `ResourceType`) that turn SST `Resource` links into flat
14
+ environment variables inside a Lambda.
15
+
16
+ > Design notes and rationale live in [`docs/sst-constructs.md`](./docs/sst-constructs.md)
17
+ > (constructs) and [`docs/sst-testing.md`](./docs/sst-testing.md) (testing).
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pnpm add @geekmidas/cloud
23
+ ```
24
+
25
+ `./sst` targets **SST v4** (peer dependency `sst@^4`) and is distributed as raw
26
+ TypeScript source — it extends the ambient `sst.aws.*` globals that only exist
27
+ after `sst install` in your app.
28
+
29
+ ## `@geekmidas/cloud/sst`
30
+
31
+ ```ts
32
+ import { App, Api, Function, Cron } from '@geekmidas/cloud/sst';
33
+ ```
34
+
35
+ ### App & Stack
36
+
37
+ `App` is a plain synchronous construct; resolve the hosted zone once at the call
38
+ site and pass it in. `app.stack(name)` creates a `Stack` bound to the app.
39
+
40
+ ```ts
41
+ const { zoneId } = await aws.route53.getZone({ name: 'example.com' });
42
+
43
+ const app = new App({
44
+ name: 'my-app',
45
+ stage: 'prod',
46
+ domain: 'example.com',
47
+ hostedZoneId: zoneId,
48
+ region: 'us-east-1',
49
+ });
50
+
51
+ const stack = app.stack('api');
52
+ stack.logicalPrefixedName('handler'); // "prod-my-app-api-handler"
53
+ stack.select({ prod: 'live', default: 'dev' }); // by-stage value with a default
54
+ ```
55
+
56
+ ### Function
57
+
58
+ Extends `sst.aws.FunctionArgs` (native options pass through), merges standard
59
+ env defaults, defaults to `nodejs24.x` + JSON logging, and **validates `envVars`
60
+ against `links` at synth time** — attaching only the links a function needs
61
+ (least privilege).
62
+
63
+ ```ts
64
+ const fn = new Function(stack, 'Processor', {
65
+ handler: 'src/processor.handler',
66
+ links: [db, topic],
67
+ envVars: ['DATABASE_URL'], // validated against links; fails synth if missing
68
+ });
69
+ ```
70
+
71
+ ### Api
72
+
73
+ Extends `sst.aws.ApiGatewayV2Args` (CORS/domain/etc. pass through). Routes are a
74
+ typed table with per-route env validation, least-privilege linking, and a typed
75
+ authorizer model.
76
+
77
+ ```ts
78
+ const api = new Api(stack, 'Api', {
79
+ links: [db],
80
+ authorizers: {
81
+ jwt: { issuer: 'https://issuer', audiences: ['aud'] }, // 'jwt' → JWT settings
82
+ employee: { handler: 'src/employee-auth.handler' }, // custom → Lambda authorizer
83
+ },
84
+ routes: [
85
+ { method: 'GET', path: '/me', handler: 'me.handler', authorizer: 'jwt' },
86
+ { method: 'GET', path: '/adm', handler: 'adm.handler', authorizer: 'employee' },
87
+ { method: 'POST', path: '/pub', handler: 'pub.handler' }, // public (none)
88
+ ],
89
+ });
90
+ ```
91
+
92
+ A route's `authorizer` is type-constrained to `'iam' | 'none'` plus the declared
93
+ authorizer names — an undeclared name is a compile error, and `jwt` requires JWT
94
+ settings while custom authorizers require a `handler`.
95
+
96
+ ### Cron
97
+
98
+ Wraps `sst.aws.CronV2` (`sst.aws.Cron` is deprecated). `processor` is a
99
+ `Function` (or anything with an `arn`); `schedule` is a typed `rate(…)` /
100
+ `cron(…)` / `at(…)`.
101
+
102
+ ```ts
103
+ const cron = new Cron(stack, 'Nightly', { processor: fn, schedule: 'rate(1 day)' });
104
+ ```
105
+
106
+ ### From a `gkm build` manifest
107
+
108
+ `gkm build` emits a deployment manifest (types in `@geekmidas/manifest`). Each
109
+ construct has a static `fromManifest` factory that maps it straight into infra:
110
+
111
+ ```ts
112
+ import routes from './.gkm/routes-manifest.json';
113
+
114
+ const api = Api.fromManifest(stack, 'Api', routes, { links: [db], authorizers });
115
+ const workers = Function.fromManifest(stack, functionsManifest, { links: [db] });
116
+ const crons = Cron.fromManifest(stack, cronsManifest, { links: [db] });
117
+ ```
118
+
119
+ ## `@geekmidas/cloud/utils` (runtime)
120
+
121
+ ```ts
122
+ import { buildResourceEnv } from '@geekmidas/cloud';
123
+ ```
124
+
125
+ `buildResourceEnv` turns a record of SST `Resource` links into flat environment
126
+ variables at runtime, using the shared `ResourceType` vocabulary.
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "@geekmidas/cloud",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
+ "files": [
6
+ "dist",
7
+ "src/sst"
8
+ ],
5
9
  "exports": {
6
10
  ".": {
7
11
  "import": {
@@ -22,7 +26,8 @@
22
26
  "types": "./dist/utils/index.d.cts",
23
27
  "default": "./dist/utils/index.cjs"
24
28
  }
25
- }
29
+ },
30
+ "./sst": "./src/sst/index.ts"
26
31
  },
27
32
  "repository": {
28
33
  "type": "git",
@@ -33,14 +38,22 @@
33
38
  "access": "public"
34
39
  },
35
40
  "dependencies": {
36
- "lodash.snakecase": "~4.1.1"
41
+ "lodash.snakecase": "~4.1.1",
42
+ "@geekmidas/envkit": "1.1.1",
43
+ "@geekmidas/manifest": "0.1.1"
37
44
  },
38
45
  "devDependencies": {
39
46
  "@types/lodash.get": "~4.4.9",
40
47
  "@types/lodash.set": "~4.3.9",
41
- "@types/lodash.snakecase": "~4.1.9"
48
+ "@types/lodash.snakecase": "~4.1.9",
49
+ "@types/node": "~24.9.1",
50
+ "sst": "4.15.2"
42
51
  },
43
52
  "peerDependencies": {
44
- "sst": "~3.17.23"
53
+ "sst": "^4.15.2"
54
+ },
55
+ "scripts": {
56
+ "sst:install": "sst install",
57
+ "ts:check:sst": "tsc --noEmit -p src/sst/tsconfig.json 2>&1 | grep -E 'src/sst/.*error TS' && exit 1 || exit 0"
45
58
  }
46
59
  }
package/src/sst/Api.ts ADDED
@@ -0,0 +1,309 @@
1
+ import path from 'node:path';
2
+ import { EnvValidationError } from '@geekmidas/envkit/sst';
3
+ import {
4
+ flattenManifestField,
5
+ type ManifestField,
6
+ type RouteInfo,
7
+ } from '@geekmidas/manifest';
8
+ import type { Function } from './Function';
9
+ import { type GkmLinkable, ResourceType } from './Linkable';
10
+ import { LinkedEnvironment } from './LinkedEnvironment';
11
+ import type { StackType } from './Stack';
12
+
13
+ /**
14
+ * `Api` — wraps SST's `sst.aws.ApiGatewayV2` (HTTP API) with a typed route
15
+ * table and per-route environment validation that fails at synth time (before
16
+ * deploy) when a route requires variables its links cannot provide. Native
17
+ * `ApiGatewayV2Args` (CORS, domain, …) pass through untouched.
18
+ *
19
+ * NOTE: this module is distributed as raw TypeScript source. It extends the
20
+ * ambient `sst.aws.*` globals that only exist inside an `sst install`ed app, so
21
+ * it cannot be type-checked or built in this repo (see docs §2). It targets
22
+ * SST v4 (ion); the route/auth shape follows the v3 reference and should be
23
+ * verified against v4 in a consuming app.
24
+ */
25
+ export class Api<
26
+ TAuthorizers extends Record<string, unknown> = {},
27
+ TStage extends string = string,
28
+ TDomain extends string = string,
29
+ >
30
+ extends sst.aws.ApiGatewayV2
31
+ implements GkmLinkable
32
+ {
33
+ readonly _id!: string;
34
+
35
+ get _type() {
36
+ return ResourceType.ApiGatewayV2;
37
+ }
38
+
39
+ constructor(
40
+ stack: StackType<TStage, TDomain>,
41
+ id: string,
42
+ props: ApiProps<TAuthorizers>,
43
+ ) {
44
+ const {
45
+ links = [],
46
+ routes,
47
+ root = process.cwd(),
48
+ vpc,
49
+ environment: apiEnvironment,
50
+ runtime: apiRuntime = 'nodejs24.x',
51
+ authorizers,
52
+ ...apiArgs
53
+ } = props;
54
+
55
+ // Pass the consumer's native `ApiGatewayV2Args` straight through — CORS,
56
+ // domain, access logs, etc. are entirely the consumer's call.
57
+ super(id, apiArgs);
58
+
59
+ this._id = id;
60
+
61
+ // Register each declared authorizer and map its name to the created id.
62
+ // The reserved `jwt` entry is a JWT authorizer; any other name is a Lambda
63
+ // authorizer (enforced by the `ApiAuthorizers` type via the `handler`).
64
+ const authorizerIds = new Map<string, $util.Output<string>>();
65
+ for (const [name, config] of Object.entries(authorizers ?? {}) as [
66
+ string,
67
+ JwtAuthorizer | LambdaAuthorizer,
68
+ ][]) {
69
+ const authorizer =
70
+ 'handler' in config
71
+ ? this.addAuthorizer({
72
+ name,
73
+ lambda: {
74
+ // Accept a handler path, function args, or one of our
75
+ // `Function` constructs (passed through as its `arn`).
76
+ function:
77
+ typeof config.handler === 'object' && 'arn' in config.handler
78
+ ? config.handler.arn
79
+ : config.handler,
80
+ identitySources: config.identitySources,
81
+ payload: config.payload,
82
+ },
83
+ })
84
+ : this.addAuthorizer({
85
+ name,
86
+ jwt: {
87
+ issuer: config.issuer,
88
+ audiences: config.audiences,
89
+ identitySource: config.identitySource,
90
+ },
91
+ });
92
+ authorizerIds.set(name, authorizer.id);
93
+ }
94
+
95
+ const relativeRoot = path.relative(process.cwd(), root);
96
+
97
+ const environment = {
98
+ ...LinkedEnvironment.createBaseEnvironment(stack),
99
+ ...apiEnvironment,
100
+ };
101
+
102
+ // Same links + whitelist for every route, so build the linker once; the
103
+ // failing route is identified via the per-route error context below.
104
+ const linked = new LinkedEnvironment(links, {
105
+ whitelist: Object.keys(environment),
106
+ });
107
+
108
+ const failures: EnvValidationError[] = [];
109
+
110
+ for (const route of routes) {
111
+ const routeKey = `${route.method} ${route.path}`;
112
+ const names = route.environment ?? [];
113
+
114
+ const result = linked.validator.validate(names);
115
+ if (!result.valid) {
116
+ failures.push(
117
+ new EnvValidationError({
118
+ missing: result.invalidVars,
119
+ available: linked.validator.availableVars,
120
+ linkVars: linked.validator.linkVars,
121
+ suggestions: result.suggestions,
122
+ context: `${id} ${routeKey}`,
123
+ }),
124
+ );
125
+ }
126
+
127
+ const link = linked.resolveLink(names);
128
+
129
+ const auth = Api.buildRouteAuth(
130
+ route,
131
+ authorizers as
132
+ | Record<string, JwtAuthorizer | LambdaAuthorizer>
133
+ | undefined,
134
+ authorizerIds,
135
+ );
136
+
137
+ this.route(
138
+ routeKey,
139
+ {
140
+ handler: path.join(relativeRoot, route.handler),
141
+ vpc,
142
+ environment,
143
+ link,
144
+ runtime: route.runtime ?? apiRuntime,
145
+ nodejs: route.nodejs,
146
+ timeout: route.timeout,
147
+ memory: route.memory,
148
+ },
149
+ auth,
150
+ );
151
+ }
152
+
153
+ // Fail the whole synth if any route is misconfigured, with one actionable
154
+ // message per offending route.
155
+ if (failures.length) {
156
+ throw new Error(failures.map((f) => f.message).join('\n\n'));
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Build an `Api` from a `gkm build` manifest's `routes` field (flat or
162
+ * partitioned): each `RouteInfo` becomes a route (env vars, authorizer,
163
+ * timeout/memory mapped). Supply `authorizers` (JWT/Lambda settings),
164
+ * `links`, and any native args via `props`.
165
+ *
166
+ * ```ts
167
+ * import { manifest } from './.gkm/manifest/aws';
168
+ * Api.fromManifest(stack, 'Api', manifest.routes, { links: [db] });
169
+ * ```
170
+ */
171
+ static fromManifest<
172
+ TAuthorizers extends Record<string, unknown> = {},
173
+ TStage extends string = string,
174
+ TDomain extends string = string,
175
+ >(
176
+ stack: StackType<TStage, TDomain>,
177
+ id: string,
178
+ routes: ManifestField<RouteInfo>,
179
+ props: Omit<ApiProps<TAuthorizers>, 'routes'> = {},
180
+ ): Api<TAuthorizers, TStage, TDomain> {
181
+ const routeTable = flattenManifestField(routes).map(
182
+ (route): Route<AuthorizerName<TAuthorizers>> => ({
183
+ method: route.method as Route['method'],
184
+ path: route.path,
185
+ handler: route.handler,
186
+ environment: route.environment,
187
+ authorizer: route.authorizer as AuthorizerName<TAuthorizers>,
188
+ timeout: route.timeout ? `${route.timeout} seconds` : undefined,
189
+ memory: route.memorySize ? `${route.memorySize} MB` : undefined,
190
+ }),
191
+ );
192
+ return new Api(stack, id, {
193
+ ...props,
194
+ routes: routeTable,
195
+ } as ApiProps<TAuthorizers>);
196
+ }
197
+
198
+ /** Resolves a route's `authorizer` name to the SST `auth` option. */
199
+ private static buildRouteAuth(
200
+ route: Route<string>,
201
+ authorizers: Record<string, JwtAuthorizer | LambdaAuthorizer> | undefined,
202
+ authorizerIds: Map<string, $util.Output<string>>,
203
+ ) {
204
+ const name = route.authorizer;
205
+ if (!name || name === 'none') return undefined;
206
+ if (name === 'iam') return { auth: { iam: true } };
207
+
208
+ const authorizerId = authorizerIds.get(name);
209
+ if (!authorizerId) return undefined;
210
+
211
+ const config = authorizers?.[name];
212
+ if (config && 'handler' in config) {
213
+ return { auth: { lambda: authorizerId } };
214
+ }
215
+ const jwt = config as JwtAuthorizer | undefined;
216
+ return {
217
+ auth: {
218
+ jwt: {
219
+ authorizer: authorizerId,
220
+ scopes: route.scopes ?? jwt?.scopes,
221
+ },
222
+ },
223
+ };
224
+ }
225
+ }
226
+
227
+ /** JWT authorizer settings — `issuer` and `audiences` are required. */
228
+ export interface JwtAuthorizer {
229
+ issuer: $util.Input<string>;
230
+ audiences: $util.Input<$util.Input<string>[]>;
231
+ identitySource?: $util.Input<string>;
232
+ /** Default OAuth scopes required by routes that use this authorizer. */
233
+ scopes?: string[];
234
+ }
235
+
236
+ /**
237
+ * Lambda (request) authorizer — requires a `handler`: a handler path, full
238
+ * function args, or one of our `Function` constructs.
239
+ */
240
+ export interface LambdaAuthorizer {
241
+ handler: string | sst.aws.FunctionArgs | Function;
242
+ identitySources?: $util.Input<$util.Input<string>[]>;
243
+ payload?: '1.0' | '2.0';
244
+ }
245
+
246
+ /**
247
+ * Authorizer config map. The reserved `jwt` key must be {@link JwtAuthorizer}
248
+ * settings; every other named authorizer must be a {@link LambdaAuthorizer}
249
+ * (enforced via the required `handler`).
250
+ */
251
+ export type ApiAuthorizers<T> = {
252
+ [K in keyof T]: K extends 'jwt' ? JwtAuthorizer : LambdaAuthorizer;
253
+ };
254
+
255
+ /** Valid `authorizer` values for a route: the built-ins plus declared names. */
256
+ export type AuthorizerName<T> = 'iam' | 'none' | (keyof T & string);
257
+
258
+ export interface Route<TAuthorizer extends string = 'iam' | 'none'> {
259
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'ALL';
260
+ path: string;
261
+ /** Handler entrypoint, resolved relative to `root` (default `cwd`). */
262
+ handler: string;
263
+ /** Required env vars for this route; validated against `links`. */
264
+ environment?: readonly string[];
265
+ /**
266
+ * Authorization for this route:
267
+ * - `iam` — AWS SigV4 signed requests
268
+ * - `none` (default) — public
269
+ * - a declared authorizer name (`jwt`, or a custom Lambda authorizer)
270
+ */
271
+ authorizer?: TAuthorizer;
272
+ /** OAuth scopes for a `jwt` route (overrides the authorizer's defaults). */
273
+ scopes?: string[];
274
+ nodejs?: { install?: string[]; externals?: string[] };
275
+ /** Lambda runtime for this route. Overrides the API default (`nodejs24.x`). */
276
+ runtime?: sst.aws.FunctionArgs['runtime'];
277
+ /** Lambda timeout for this route, e.g. `30 seconds`. */
278
+ timeout?: sst.aws.FunctionArgs['timeout'];
279
+ /** Lambda memory for this route, e.g. `1024 MB`. */
280
+ memory?: sst.aws.FunctionArgs['memory'];
281
+ }
282
+
283
+ /**
284
+ * `ApiProps` extends SST's native `sst.aws.ApiGatewayV2Args`, so every native
285
+ * option (`cors`, `domain`, `accessLog`, `transform`, …) passes straight
286
+ * through untouched. We only add the route table, authorizers, and the
287
+ * linking/validation inputs on top.
288
+ */
289
+ export interface ApiProps<TAuthorizers extends Record<string, unknown> = {}>
290
+ extends sst.aws.ApiGatewayV2Args {
291
+ routes: Route<AuthorizerName<TAuthorizers>>[];
292
+ /**
293
+ * Named authorizers. The reserved `jwt` key configures a JWT authorizer; any
294
+ * other name configures a Lambda authorizer (requires a `handler`). Route
295
+ * `authorizer` values are constrained to these names plus `iam`/`none`.
296
+ */
297
+ authorizers?: ApiAuthorizers<TAuthorizers>;
298
+ /** Pool of linkable resources routes may draw on. */
299
+ links?: GkmLinkable[];
300
+ /** Base directory for resolving route `handler` paths. Defaults to `cwd`. */
301
+ root?: string;
302
+ /** Env vars applied to every route, merged over the API defaults. */
303
+ environment?: Record<string, string>;
304
+ /** VPC to place each route's Lambda in. */
305
+ vpc?: sst.aws.Vpc;
306
+ /** Default Lambda runtime for every route. Defaults to `nodejs24.x`; a route
307
+ * may override it. */
308
+ runtime?: sst.aws.FunctionArgs['runtime'];
309
+ }
package/src/sst/App.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { prefixedName } from './naming';
2
+ import { Stack } from './Stack';
3
+
4
+ /**
5
+ * A by-stage value map with a required `default` fallback — the argument to
6
+ * {@link App.select} / {@link Stack.select}. Keys are stages (`TStage`).
7
+ */
8
+ export type StageValues<TStage extends string, T> = Partial<
9
+ Record<TStage, T>
10
+ > & {
11
+ default: T;
12
+ };
13
+
14
+ export interface AppProps<TStage extends string, TDomain extends string> {
15
+ /** Application name; part of resource name prefixes. */
16
+ name: string;
17
+ /** Deployment stage, e.g. `dev` or `prod`. */
18
+ stage: TStage;
19
+ /** Root domain backed by a Route53 hosted zone. */
20
+ domain: TDomain;
21
+ /**
22
+ * Pre-resolved Route53 hosted zone id for `domain`. Resolve it once at the
23
+ * call site (`aws.route53.getZone`, or `getZoneOutput` for a lazy `Output`)
24
+ * and pass it in — the constructor stays synchronous (see docs §4).
25
+ */
26
+ hostedZoneId: $util.Output<string> | string;
27
+ /** AWS region. */
28
+ region: string;
29
+ }
30
+
31
+ /**
32
+ * Application-level context: identity, deployment target, and a pre-resolved
33
+ * hosted zone. A plain synchronous constructor — zone resolution is the caller's
34
+ * responsibility, so every construct stays uniform (`new X(...)`).
35
+ */
36
+ export class App<
37
+ TStage extends string = string,
38
+ TDomain extends string = string,
39
+ > {
40
+ readonly name: string;
41
+ readonly stage: TStage;
42
+ readonly domain: TDomain;
43
+ readonly region: string;
44
+ readonly hostedZoneId: $util.Output<string> | string;
45
+
46
+ constructor(props: AppProps<TStage, TDomain>) {
47
+ this.name = props.name;
48
+ this.stage = props.stage;
49
+ this.domain = props.domain;
50
+ this.region = props.region;
51
+ this.hostedZoneId = props.hostedZoneId;
52
+ }
53
+
54
+ /** Create a {@link Stack} bound to this app (sugar for `new Stack(app, name)`). */
55
+ stack(name: string): Stack<TStage, TDomain> {
56
+ return new Stack(this, name);
57
+ }
58
+
59
+ /**
60
+ * Picks a value for the current stage from a by-stage map, falling back to
61
+ * `default`. e.g. `app.select({ prod: 'live', staging: 'test', default: 'dev' })`.
62
+ */
63
+ select<T>(values: StageValues<TStage, T>): T {
64
+ return values[this.stage] ?? values.default;
65
+ }
66
+
67
+ getSubdomain<TSub extends string>(subdomain: TSub) {
68
+ return `${subdomain}.${this.domain}` as const;
69
+ }
70
+
71
+ getURL<TSub extends string>(subdomain?: TSub) {
72
+ const prefix = subdomain ? (`${subdomain}.` as const) : '';
73
+ return `https://${prefix}${this.domain}` as const;
74
+ }
75
+
76
+ /** Kebab-cased, stage/app-name-prefixed physical resource name. */
77
+ logicalPrefixedName(id: string): string {
78
+ return prefixedName([this.stage, this.name], id);
79
+ }
80
+ }
@@ -0,0 +1,113 @@
1
+ import {
2
+ type CronInfo,
3
+ flattenManifestField,
4
+ type ManifestField,
5
+ } from '@geekmidas/manifest';
6
+ import { Function } from './Function';
7
+ import type { GkmLinkable } from './Linkable';
8
+ import type { StackType } from './Stack';
9
+
10
+ export type CronExpressionValue = number | '*' | '?' | `${number}/${number}`;
11
+ export type CronExpressionDay =
12
+ | '?'
13
+ | 'SUN'
14
+ | 'MON'
15
+ | 'TUE'
16
+ | 'WED'
17
+ | 'THU'
18
+ | 'FRI'
19
+ | 'SAT';
20
+
21
+ /**
22
+ * A 6-field cron expression body: `minute hour day-of-month month day-of-week year`.
23
+ */
24
+ export type CronString =
25
+ `${CronExpressionValue} ${CronExpressionValue} ${CronExpressionValue} ${CronExpressionValue} ${CronExpressionDay} ${CronExpressionValue}`;
26
+ export type CronExpression = `cron(${CronString})`;
27
+ export type CronRate = `rate(${number} ${
28
+ | 'minute'
29
+ | 'minutes'
30
+ | 'hour'
31
+ | 'hours'
32
+ | 'day'
33
+ | 'days'})`;
34
+ /** One-time `at(…)` schedule (e.g. `at(2025-06-01T10:00:00)`). */
35
+ export type CronAt = `at(${string})`;
36
+ export type CronSchedule = CronExpression | CronRate | CronAt;
37
+
38
+ /**
39
+ * `Cron` — wraps `sst.aws.CronV2` to invoke a `Function` on a schedule.
40
+ * (`sst.aws.Cron` is deprecated in SST v4 in favour of
41
+ * [`CronV2`](https://sst.dev/docs/component/aws/cron-v2).)
42
+ *
43
+ * `CronProps` extends the native `sst.aws.CronV2Args` (so `enabled`, `timezone`,
44
+ * `transform`, etc. pass through), but replaces `function`/`schedule` with the
45
+ * friendlier `processor` (a `Function`, or anything exposing `arn`) and a fully
46
+ * type-checked `schedule`. A cron is not a link target, so it is not `Linkable`
47
+ * and carries no `_type` (see docs §11.3).
48
+ *
49
+ * Source-only (extends ambient `sst.aws.*`); see docs §2.
50
+ */
51
+ export class Cron<
52
+ TStage extends string = string,
53
+ TDomain extends string = string,
54
+ > extends sst.aws.CronV2 {
55
+ constructor(
56
+ _stack: StackType<TStage, TDomain>,
57
+ name: string,
58
+ props: CronProps,
59
+ ) {
60
+ const { processor, schedule, ...cronArgs } = props;
61
+ super(name, {
62
+ ...cronArgs,
63
+ schedule,
64
+ function: processor.arn,
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Build one `Cron` per entry in a `gkm build` manifest's `crons` field (flat
70
+ * or partitioned). Each cron's handler becomes a validated `Function` (the
71
+ * cron's target), so pass `links` for that function's env validation;
72
+ * remaining `props` are CronV2 args.
73
+ *
74
+ * ```ts
75
+ * Cron.fromManifest(stack, manifest.crons, { links: [db] });
76
+ * ```
77
+ */
78
+ static fromManifest<
79
+ TStage extends string = string,
80
+ TDomain extends string = string,
81
+ >(
82
+ stack: StackType<TStage, TDomain>,
83
+ crons: ManifestField<CronInfo>,
84
+ props: Omit<CronProps, 'processor' | 'schedule'> & {
85
+ links?: GkmLinkable[];
86
+ } = {},
87
+ ): Cron<TStage, TDomain>[] {
88
+ const { links, ...cronArgs } = props;
89
+ return flattenManifestField(crons).map((cron) => {
90
+ const processor = new Function(stack, `${cron.name}Function`, {
91
+ name: stack.logicalPrefixedName(cron.name),
92
+ handler: cron.handler,
93
+ envVars: cron.environment,
94
+ links,
95
+ timeout: cron.timeout ? `${cron.timeout} seconds` : undefined,
96
+ memory: cron.memorySize ? `${cron.memorySize} MB` : undefined,
97
+ });
98
+ return new Cron(stack, cron.name, {
99
+ ...cronArgs,
100
+ processor,
101
+ schedule: cron.schedule as CronSchedule,
102
+ });
103
+ });
104
+ }
105
+ }
106
+
107
+ export interface CronProps
108
+ extends Omit<sst.aws.CronV2Args, 'function' | 'schedule'> {
109
+ /** The function invoked on schedule — a `Function` (or anything with an `arn`). */
110
+ processor: Function | { arn: $util.Input<string> };
111
+ /** Cron `rate(…)`, `cron(…)`, or `at(…)` schedule, fully type-checked. */
112
+ schedule: CronSchedule;
113
+ }