@geekmidas/cloud 10.0.0-alpha.4 → 10.0.0-alpha.41

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 (37) hide show
  1. package/dist/index.cjs +3 -3
  2. package/dist/index.d.cts +1 -1
  3. package/dist/index.d.mts +1 -1
  4. package/dist/index.mjs +2 -3
  5. package/dist/utils/index.cjs +106 -5
  6. package/dist/utils/index.cjs.map +1 -0
  7. package/dist/utils/index.d.cts +62 -2
  8. package/dist/utils/index.d.cts.map +1 -0
  9. package/dist/utils/index.d.mts +62 -2
  10. package/dist/utils/index.d.mts.map +1 -0
  11. package/dist/utils/index.mjs +78 -2
  12. package/dist/utils/index.mjs.map +1 -0
  13. package/package.json +7 -7
  14. package/src/dokploy/Application.ts +52 -5
  15. package/src/dokploy/__tests__/Application.provider.spec.ts +206 -0
  16. package/src/sst/__tests__/api.spec.ts +231 -0
  17. package/src/sst/__tests__/fromManifest.integration.spec.ts +427 -0
  18. package/src/sst/__tests__/fromManifest.spec.ts +141 -5
  19. package/src/sst/__tests__/function.spec.ts +192 -0
  20. package/src/sst/__tests__/links.spec.ts +209 -0
  21. package/src/sst/aws/Api.ts +16 -1
  22. package/src/sst/aws/Cache.ts +4 -4
  23. package/src/sst/aws/Credential.ts +42 -9
  24. package/src/sst/aws/Database.ts +15 -1
  25. package/src/sst/aws/DatabaseBootstrap.ts +3 -0
  26. package/src/sst/aws/ExternalApi.ts +58 -0
  27. package/src/sst/aws/Queue.ts +31 -1
  28. package/src/sst/aws/bootstrap/handler.ts +10 -1
  29. package/src/sst/fromManifest.ts +90 -7
  30. package/dist/index-B5CZ1xVf.d.cts +0 -64
  31. package/dist/index-B5CZ1xVf.d.cts.map +0 -1
  32. package/dist/index-DhHRjduZ.d.mts +0 -64
  33. package/dist/index-DhHRjduZ.d.mts.map +0 -1
  34. package/dist/utils-B1a2UuEO.cjs +0 -129
  35. package/dist/utils-B1a2UuEO.cjs.map +0 -1
  36. package/dist/utils-DMOXJ27j.mjs +0 -83
  37. package/dist/utils-DMOXJ27j.mjs.map +0 -1
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { App } from '../App';
3
+ import { Cron } from '../aws/Cron';
4
+ import { Function } from '../aws/Function';
5
+ import { type GkmLinkable, ResourceType } from '../Linkable';
6
+
7
+ /**
8
+ * A Lambda and a schedule as data: the args each hands SST (a recording stub
9
+ * in `test/sst-globals.ts`), and the env validation that fails before deploy.
10
+ */
11
+
12
+ const app = new App({
13
+ name: 'shop',
14
+ stage: 'prod',
15
+ domain: 'shop.test',
16
+ hostedZoneId: 'Z1',
17
+ region: 'eu-west-1',
18
+ });
19
+ const stack = app.stack('jobs');
20
+ const db: GkmLinkable = { _id: 'db', _type: ResourceType.Postgres };
21
+
22
+ const argsOf = (component: unknown) =>
23
+ (component as { args: Record<string, unknown> }).args;
24
+
25
+ describe('Function', () => {
26
+ it('defaults the runtime and logging, and links only what its variables need', () => {
27
+ const fn = new Function(stack, 'Reports', {
28
+ handler: 'reports.handler',
29
+ links: [db],
30
+ envVars: ['DB_URL'],
31
+ environment: { FEATURE: 'on' },
32
+ });
33
+
34
+ expect(fn._type).toBe(ResourceType.Function);
35
+ expect(argsOf(fn)).toMatchObject({
36
+ handler: 'reports.handler',
37
+ runtime: 'nodejs24.x',
38
+ logging: { format: 'json' },
39
+ link: [db],
40
+ environment: expect.objectContaining({
41
+ SERVICE_NAME: 'Reports',
42
+ STAGE: 'prod',
43
+ FEATURE: 'on',
44
+ }),
45
+ });
46
+ expect(fn.validate().valid).toBe(true);
47
+ });
48
+
49
+ it('keeps a runtime and logging it was given', () => {
50
+ const fn = new Function(stack, 'Legacy', {
51
+ handler: 'legacy.handler',
52
+ runtime: 'nodejs20.x',
53
+ logging: { format: 'text' },
54
+ });
55
+
56
+ expect(argsOf(fn)).toMatchObject({
57
+ runtime: 'nodejs20.x',
58
+ logging: { format: 'text' },
59
+ link: [],
60
+ });
61
+ });
62
+
63
+ it('fails the synth on a variable no link provides, unless told not to check', () => {
64
+ expect(
65
+ () =>
66
+ new Function(stack, 'Mailer', {
67
+ handler: 'mailer.handler',
68
+ envVars: ['SMTP_URL'],
69
+ }),
70
+ ).toThrow(/SMTP_URL/);
71
+
72
+ const unchecked = new Function(stack, 'Mailer', {
73
+ handler: 'mailer.handler',
74
+ envVars: ['SMTP_URL'],
75
+ autoValidate: false,
76
+ });
77
+ expect(unchecked.validate()).toMatchObject({
78
+ valid: false,
79
+ invalidVars: ['SMTP_URL'],
80
+ });
81
+ });
82
+
83
+ it('builds one function per manifest entry, named for the stack', () => {
84
+ const functions = Function.fromManifest(
85
+ stack,
86
+ {
87
+ billing: [
88
+ {
89
+ name: 'Invoice',
90
+ handler: '.gkm/aws-lambda/functions/invoice.handler',
91
+ environment: ['DB_URL'],
92
+ timeout: 60,
93
+ memorySize: 512,
94
+ },
95
+ ],
96
+ ops: [
97
+ {
98
+ name: 'Ping',
99
+ handler: '.gkm/aws-lambda/functions/ping.handler',
100
+ environment: [],
101
+ },
102
+ ],
103
+ } as never,
104
+ { links: [db] },
105
+ );
106
+
107
+ expect(functions.map((f) => argsOf(f))).toEqual([
108
+ expect.objectContaining({
109
+ name: stack.logicalPrefixedName('Invoice'),
110
+ timeout: '60 seconds',
111
+ memory: '512 MB',
112
+ link: [db],
113
+ }),
114
+ expect.objectContaining({
115
+ name: stack.logicalPrefixedName('Ping'),
116
+ timeout: undefined,
117
+ memory: undefined,
118
+ link: [],
119
+ }),
120
+ ]);
121
+ });
122
+ });
123
+
124
+ describe('Cron', () => {
125
+ it('schedules the function it is given, by ARN', () => {
126
+ const processor = { arn: 'arn:aws:lambda:eu-west-1:1:function:nightly' };
127
+ const cron = new Cron(stack, 'Nightly', {
128
+ processor: processor as never,
129
+ schedule: 'rate(1 day)',
130
+ enabled: false,
131
+ });
132
+
133
+ expect(argsOf(cron)).toEqual({
134
+ enabled: false,
135
+ schedule: 'rate(1 day)',
136
+ function: processor.arn,
137
+ });
138
+ });
139
+
140
+ it('builds a validated function and a schedule per manifest cron', () => {
141
+ const crons = Cron.fromManifest(
142
+ stack,
143
+ [
144
+ {
145
+ name: 'Digest',
146
+ handler: '.gkm/aws-lambda/crons/digest.handler',
147
+ schedule: 'cron(0 8 * * ? *)',
148
+ environment: ['DB_URL'],
149
+ timeout: 120,
150
+ memorySize: 1024,
151
+ },
152
+ {
153
+ name: 'Sweep',
154
+ handler: '.gkm/aws-lambda/crons/sweep.handler',
155
+ schedule: 'rate(1 hour)',
156
+ environment: [],
157
+ },
158
+ ] as never,
159
+ { links: [db], enabled: true },
160
+ );
161
+
162
+ const [digest, sweep] = crons.map((c) => argsOf(c));
163
+ expect(digest).toMatchObject({
164
+ schedule: 'cron(0 8 * * ? *)',
165
+ enabled: true,
166
+ function: expect.stringContaining('DigestFunction'),
167
+ });
168
+ expect(sweep).toMatchObject({ schedule: 'rate(1 hour)' });
169
+ });
170
+ });
171
+
172
+ describe('App and Stack', () => {
173
+ it('pick a value for the stage, falling back to the default', () => {
174
+ expect(app.select({ prod: 'live', default: 'test' })).toBe('live');
175
+ expect(stack.select({ staging: 'test', default: 'dev' } as never)).toBe(
176
+ 'dev',
177
+ );
178
+ });
179
+
180
+ it('name subdomains and URLs under the root domain', () => {
181
+ expect(stack.domain).toBe('shop.test');
182
+ expect(stack.getSubdomain('api')).toBe('api.shop.test');
183
+ expect(stack.getURL('api')).toBe('https://api.shop.test');
184
+ expect(stack.getURL()).toBe('https://shop.test');
185
+ expect(app.getURL()).toBe('https://shop.test');
186
+ });
187
+
188
+ it('prefix physical names with the stage, app and stack', () => {
189
+ expect(app.logicalPrefixedName('Uploads')).toBe('prod-shop-uploads');
190
+ expect(stack.logicalPrefixedName('Uploads')).toBe('prod-shop-jobs-uploads');
191
+ });
192
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Cache, CacheNeedsProvider, CacheNeedsVpc } from '../aws/Cache';
3
+ import { Database } from '../aws/Database';
4
+ import { DatabaseReader, DatabaseSchema } from '../aws/DerivedDatabase';
5
+ import { Email } from '../aws/Email';
6
+ import { FileServer } from '../aws/FileServer';
7
+ import { Queue } from '../aws/Queue';
8
+ import { RestApiSurface } from '../aws/RestApiSurface';
9
+ import { Secret } from '../aws/Secret';
10
+ import { StaticSite } from '../aws/StaticSite';
11
+ import { Storage } from '../aws/Storage';
12
+ import { Topic } from '../aws/Topic';
13
+ import { ResourceType } from '../Linkable';
14
+
15
+ /**
16
+ * What a linked function receives from each component, and the type that
17
+ * decides which variables a link provides. `getSSTLink()` is the payload SST
18
+ * injects into every handler linked to the resource; `_type` is what
19
+ * `LinkedEnvironment` keys the variable names off.
20
+ */
21
+
22
+ const stack = {} as never;
23
+ const vpc = { subnets: ['subnet-1'], securityGroups: ['sg-1'] } as never;
24
+
25
+ /** Resolves `$util.output(promise)`, which the stub leaves as the promise. */
26
+ const settle = async (value: unknown) =>
27
+ (value as { apply: (fn: (v: unknown) => unknown) => unknown }).apply(
28
+ (v) => v,
29
+ );
30
+
31
+ describe('link payloads', () => {
32
+ it('a queue adds its ARN and connection string to SST’s own link', () => {
33
+ const queue = new Queue(stack, 'Emails');
34
+
35
+ expect(queue._type).toBe(ResourceType.Queue);
36
+ // The connection string itself is parsed back in provides.spec; here, the
37
+ // link carries exactly what `provides()` resolved.
38
+ expect(queue.getSSTLink().properties).toEqual({
39
+ name: 'Emails',
40
+ arn: queue.arn,
41
+ ...queue.provides(),
42
+ });
43
+ });
44
+
45
+ it('a topic carries its publisher connection string', () => {
46
+ const topic = new Topic(stack, 'Users');
47
+
48
+ expect(topic._type).toBe(ResourceType.SnsTopic);
49
+ expect(topic.getSSTLink().properties).toEqual({
50
+ name: 'Users',
51
+ ...topic.provides(),
52
+ });
53
+ });
54
+
55
+ it('a secret carries its value', () => {
56
+ const secret = new Secret(stack, 'AuthSecret');
57
+
58
+ expect(secret._type).toBe(ResourceType.SSTSecret);
59
+ expect(secret.getSSTLink().properties).toMatchObject(secret.provides());
60
+ });
61
+
62
+ it('a mailer carries its URL and sending identity, and nothing else', () => {
63
+ const email = new Email(stack, 'Mail', {
64
+ backend: 'smtp',
65
+ url: 'smtp://relay.test:587',
66
+ from: 'hello@shop.test',
67
+ } as never);
68
+
69
+ expect(email._type).toBe(ResourceType.Email);
70
+ expect(email.getSSTLink()).toEqual({
71
+ properties: { url: 'smtp://relay.test:587', from: 'hello@shop.test' },
72
+ });
73
+ });
74
+
75
+ it('a file server routes everything to its bucket, and links its URL', () => {
76
+ const origin = new Storage(stack, 'Uploads');
77
+ const server = new FileServer(stack, 'Files', { origin } as never);
78
+
79
+ expect(origin._type).toBe(ResourceType.ObjectStorage);
80
+ expect(origin._id).toBe('Uploads');
81
+ expect(server._type).toBe(ResourceType.FileServer);
82
+ expect((server as unknown as { routed: unknown[] }).routed).toEqual([
83
+ { pattern: '/*', bucket: origin },
84
+ ]);
85
+ expect(server.getSSTLink().properties).toMatchObject({
86
+ url: server.url,
87
+ });
88
+ });
89
+
90
+ it('a site is built the way its variant builds, and links its URL', () => {
91
+ const vite = new StaticSite(stack, 'Web');
92
+ const next = new StaticSite(stack, 'Admin', {
93
+ variant: 'next',
94
+ build: { command: 'pnpm build:custom', output: 'out' },
95
+ } as never);
96
+ const args = (site: unknown) => (site as { args: { build: unknown } }).args;
97
+
98
+ expect(vite._type).toBe(ResourceType.StaticSite);
99
+ expect(args(vite).build).toEqual(expect.objectContaining({}));
100
+ // A build the caller supplied wins over the variant's.
101
+ expect(args(next).build).toEqual({
102
+ command: 'pnpm build:custom',
103
+ output: 'out',
104
+ });
105
+ expect(vite.getSSTLink().properties).toMatchObject({ url: vite.url });
106
+ });
107
+
108
+ it('a surface links its address and, once resolved, who may call it', async () => {
109
+ const bare = new RestApiSurface(stack, 'Api');
110
+ const called = new RestApiSurface(stack, 'Api', {
111
+ callers: Promise.resolve({
112
+ trustedOrigins: 'https://web.shop.test',
113
+ cookieDomain: 'shop.test',
114
+ }),
115
+ });
116
+
117
+ expect(bare._type).toBe(ResourceType.ApiGatewayV2);
118
+ expect(await settle(bare.provides().trustedOrigins)).toBe('');
119
+ expect(await settle(bare.provides().cookieDomain)).toBe('');
120
+
121
+ const link = called.getSSTLink().properties as Record<string, unknown>;
122
+ expect(link.url).toBe(called.url);
123
+ expect(await settle(link.trustedOrigins)).toBe('https://web.shop.test');
124
+ expect(await settle(link.cookieDomain)).toBe('shop.test');
125
+ });
126
+
127
+ it('a database and the nodes derived from it each link only their own URL', () => {
128
+ const cluster = new Database(stack, 'Orders', { vpc, schema: 'app' });
129
+ const reader = new DatabaseReader('OrdersReader', cluster);
130
+ const tenant = new DatabaseSchema('AuthDb', cluster, 'authdb');
131
+
132
+ expect(cluster._type).toBe(ResourceType.SSTPostgres);
133
+ expect(reader._type).toBe(ResourceType.SSTPostgres);
134
+ expect(tenant._type).toBe(ResourceType.SSTPostgres);
135
+ expect(cluster.getSSTLink().properties).toMatchObject({
136
+ url: cluster.provides().url,
137
+ });
138
+ // Least privilege: the reader's link is its URL and not the writer's.
139
+ expect(reader.getSSTLink()).toEqual({
140
+ properties: { url: reader.provides().url },
141
+ });
142
+ expect(tenant.getSSTLink()).toEqual({
143
+ properties: { url: tenant.provides().url },
144
+ });
145
+ });
146
+
147
+ it('a cache given its URL links it as-is', () => {
148
+ const cache = new Cache(stack, 'Sessions', {
149
+ backend: 'db',
150
+ url: 'postgres://db.test/app',
151
+ });
152
+
153
+ expect(cache._type).toBe(ResourceType.Cache);
154
+ expect(cache.getSSTLink()).toEqual({
155
+ properties: { url: 'postgres://db.test/app' },
156
+ });
157
+ });
158
+ });
159
+
160
+ describe('Cache on Upstash', () => {
161
+ /** `sst add upstash` installs this global; here, a recording stand-in. */
162
+ const created: { name: string; args: Record<string, unknown> }[] = [];
163
+ class RedisDatabase {
164
+ readonly endpoint = 'eu1-cache.upstash.io';
165
+ readonly restToken = 'tok/en+1';
166
+ constructor(name: string, args: Record<string, unknown>) {
167
+ created.push({ name, args });
168
+ }
169
+ }
170
+
171
+ it('creates a TLS database and puts its token in the URL', () => {
172
+ (globalThis as Record<string, unknown>).upstash = { RedisDatabase };
173
+ try {
174
+ const defaulted = new Cache(stack, 'Sessions', { backend: 'upstash' });
175
+ new Cache(stack, 'Rates', { backend: 'upstash', region: 'us-east-1' });
176
+
177
+ expect(created).toEqual([
178
+ {
179
+ name: 'SessionsCache',
180
+ args: { databaseName: 'Sessions', region: 'eu-west-1', tls: true },
181
+ },
182
+ {
183
+ name: 'RatesCache',
184
+ args: { databaseName: 'Rates', region: 'us-east-1', tls: true },
185
+ },
186
+ ]);
187
+ // The address and the credential that opens it are one fact.
188
+ expect(defaulted.provides().url).toBe(
189
+ `https://:${encodeURIComponent('tok/en+1')}@eu1-cache.upstash.io`,
190
+ );
191
+ } finally {
192
+ delete (globalThis as Record<string, unknown>).upstash;
193
+ }
194
+ });
195
+ });
196
+
197
+ describe('Cache provisioning refusals', () => {
198
+ it('asks for the Upstash provider when it is not installed', () => {
199
+ expect(() => new Cache(stack, 'Sessions', { backend: 'upstash' })).toThrow(
200
+ CacheNeedsProvider,
201
+ );
202
+ });
203
+
204
+ it('asks for a VPC before creating an ElastiCache', () => {
205
+ expect(
206
+ () => new Cache(stack, 'Sessions', { backend: 'elasticache' }),
207
+ ).toThrow(CacheNeedsVpc);
208
+ });
209
+ });
@@ -153,7 +153,7 @@ export class Api<
153
153
  // Fail the whole synth if any route is misconfigured, with one actionable
154
154
  // message per offending route.
155
155
  if (failures.length) {
156
- throw new Error(failures.map((f) => f.message).join('\n\n'));
156
+ throw new RoutesMissingEnvironment(id, failures);
157
157
  }
158
158
  }
159
159
 
@@ -307,3 +307,18 @@ export interface ApiProps<TAuthorizers extends Record<string, unknown> = {}>
307
307
  * may override it. */
308
308
  runtime?: sst.aws.FunctionArgs['runtime'];
309
309
  }
310
+
311
+ /**
312
+ * Routes that read variables nothing links or whitelists. Collected across
313
+ * every route so one synth reports all of them, each with its own suggestions,
314
+ * rather than stopping at the first.
315
+ */
316
+ export class RoutesMissingEnvironment extends Error {
317
+ constructor(
318
+ readonly api: string,
319
+ readonly failures: readonly EnvValidationError[],
320
+ ) {
321
+ super(failures.map((f) => f.message).join('\n\n'));
322
+ this.name = 'RoutesMissingEnvironment';
323
+ }
324
+ }
@@ -206,7 +206,7 @@ export function withCacheTable(url: string, table: string): string {
206
206
  }
207
207
 
208
208
  /**
209
- * `services.cache: 'db'` in an app that declares more than one database.
209
+ * A `'db'` cache in an app that declares more than one database.
210
210
  *
211
211
  * Picking one would put the cache somewhere nobody chose, and the symptom —
212
212
  * entries that are never found — appears long after the deploy reported
@@ -222,7 +222,7 @@ export class CacheIsAmbiguous extends Error {
222
222
  `'${id}' is a cache backed by the database, and this app declares ` +
223
223
  `${databases.length}: ${databases.join(', ')}. Declare the cache from ` +
224
224
  `its database — e.g. ${databases[0]}.cache('${id}') — rather than ` +
225
- `with services.cache.`,
225
+ `as a bare new Cache().`,
226
226
  );
227
227
  this.name = 'CacheIsAmbiguous';
228
228
  }
@@ -232,8 +232,8 @@ export class CacheNeedsDatabase extends Error {
232
232
  constructor(readonly id: string) {
233
233
  super(
234
234
  `'${id}' is a cache backed by the database, and this app declares no ` +
235
- `database for it to live in. Declare one, or set services.cache to ` +
236
- `'upstash' or 'elasticache'.`,
235
+ `database for it to live in. Declare one, or declare the cache from ` +
236
+ `the database it belongs to.`,
237
237
  );
238
238
  this.name = 'CacheNeedsDatabase';
239
239
  }
@@ -1,4 +1,5 @@
1
- import { Secret, type SecretProps } from './Secret';
1
+ import { type GkmLinkable, ResourceType } from '../Linkable';
2
+ import type { StackType } from '../Stack';
2
3
 
3
4
  /**
4
5
  * `Credential` — a third-party credential SST holds, and the infra half of the
@@ -8,21 +9,53 @@ import { Secret, type SecretProps } from './Secret';
8
9
  * the lifecycle rather than the mechanism: a secret is generated and rotated by
9
10
  * the platform, while a credential is issued by someone else and has a shape
10
11
  * the construct validates on the way in. Both are values you set out of band
11
- * with `sst secret set`.
12
+ * with `sst secret set`, under the construct's id.
12
13
  *
13
- * The role is `credential` rather than `value`, and that is not cosmetic. The
14
+ * The role is `credentials` rather than `value`, and that is not cosmetic. The
14
15
  * role *is* the contract — `providedKeyFor` turns it into the key the app
15
16
  * declared — so a credential providing `value` would supply `STRIPE_VALUE`
16
- * against a declared `STRIPE_CREDENTIAL`, and `assertProvides` would reject the
17
- * stack at synth. Which is the check working; renaming here is the fix.
17
+ * against a declared `STRIPE_CREDENTIALS`, and `assertProvides` would reject
18
+ * the stack at synth.
19
+ *
20
+ * It holds an `sst.Secret` rather than being one, and reports its own type,
21
+ * for the same reason one level down: the type decides the keys a link
22
+ * resolves to. As an SST secret it resolved to `STRIPE`, so a function that
23
+ * declared `STRIPE_CREDENTIALS` was linked to nothing.
18
24
  */
19
25
  export class Credential<
20
26
  TStage extends string = string,
21
27
  TDomain extends string = string,
22
- > extends Secret<TStage, TDomain> {
23
- override provides(): Record<string, $util.Input<string>> {
24
- return { credential: this.value };
28
+ > implements GkmLinkable
29
+ {
30
+ readonly _id: string;
31
+
32
+ private readonly secret: sst.Secret;
33
+
34
+ get _type() {
35
+ return ResourceType.Credential;
36
+ }
37
+
38
+ constructor(
39
+ _stack: StackType<TStage, TDomain>,
40
+ name: string,
41
+ props: CredentialProps = {},
42
+ ) {
43
+ this._id = name;
44
+ // The id itself, as it always was: a value already set with
45
+ // `sst secret set Stripe …` is still the one read.
46
+ this.secret = new sst.Secret(name, props.placeholder);
47
+ }
48
+
49
+ provides(): Record<string, $util.Input<string>> {
50
+ return { credentials: this.secret.value };
51
+ }
52
+
53
+ getSSTLink() {
54
+ return { properties: { ...this.provides() } };
25
55
  }
26
56
  }
27
57
 
28
- export interface CredentialProps extends SecretProps {}
58
+ export interface CredentialProps {
59
+ /** A value until one is set, for a preview stage. */
60
+ placeholder?: string;
61
+ }
@@ -84,7 +84,21 @@ export class Database<
84
84
  * produces a database that looks empty.
85
85
  */
86
86
  provides(): Record<string, $util.Input<string>> {
87
- return { url: this.urlFor({}) };
87
+ return { url: this.urlFor(this.role ? { as: this.role } : {}) };
88
+ }
89
+
90
+ /**
91
+ * The runtime role this database's own handlers connect as.
92
+ *
93
+ * Set by the adapter once the bootstrap has registered the role — the
94
+ * cluster exists before any role does, so it cannot be a constructor
95
+ * argument. Until it is set (and under `roles: false`, where it never is),
96
+ * the URL is the master's.
97
+ */
98
+ private role: { user: string; password: $util.Input<string> } | undefined;
99
+
100
+ connectAs(role: { user: string; password: $util.Input<string> }): void {
101
+ this.role = role;
88
102
  }
89
103
 
90
104
  /**
@@ -65,6 +65,8 @@ export class DatabaseBootstrap {
65
65
  runtime: string;
66
66
  owner: string;
67
67
  reader?: string;
68
+ /** The database's own roles, whose owner may create in it. */
69
+ ownsDatabase?: boolean;
68
70
  }): { runtime: $util.Output<string>; reader?: $util.Output<string> } {
69
71
  const runtime = this.password(tenant.runtime);
70
72
  const reader = tenant.reader ? this.password(tenant.reader) : undefined;
@@ -75,6 +77,7 @@ export class DatabaseBootstrap {
75
77
  runtime: tenant.runtime,
76
78
  owner: tenant.owner,
77
79
  ...(tenant.reader ? { reader: tenant.reader } : {}),
80
+ ...(tenant.ownsDatabase ? { ownsDatabase: true } : {}),
78
81
  // Filled in by `bootstrapEvent` once the outputs resolve. The function
79
82
  // reads them straight out of its input and never fetches a secret, so
80
83
  // it needs no IAM to read one.
@@ -0,0 +1,58 @@
1
+ import { externalApiUrl } from '@geekmidas/manifest';
2
+ import { type GkmLinkable, ResourceType } from '../Linkable';
3
+ import type { StackType } from '../Stack';
4
+
5
+ /**
6
+ * `ExternalApi` — the infra half of the `external-api` kind.
7
+ *
8
+ * Nothing is provisioned: the API is somebody else's. What this holds is the
9
+ * two facts a function needs to call it on this stage — the URL the
10
+ * declaration names for it, and the credentials the provider issued, kept as
11
+ * an SST secret and set out of band:
12
+ *
13
+ * ```
14
+ * sst secret set PolarCredentials '{"clientId":"…","clientSecret":"…"}' --stage prod
15
+ * ```
16
+ */
17
+ export class ExternalApi<
18
+ TStage extends string = string,
19
+ TDomain extends string = string,
20
+ > implements GkmLinkable
21
+ {
22
+ readonly _id: string;
23
+
24
+ private readonly url: string;
25
+
26
+ private readonly credentials: sst.Secret;
27
+
28
+ get _type() {
29
+ return ResourceType.ExternalApi;
30
+ }
31
+
32
+ constructor(
33
+ stack: StackType<TStage, TDomain>,
34
+ name: string,
35
+ props: ExternalApiProps,
36
+ ) {
37
+ this._id = name;
38
+ // Read at synth, so a stage the declaration has no URL for fails the
39
+ // deploy — with the stage named — rather than the first request.
40
+ this.url = externalApiUrl({ id: name, url: props.url }, stack.stage);
41
+ this.credentials = new sst.Secret(`${name}Credentials`, props.placeholder);
42
+ }
43
+
44
+ provides(): Record<string, $util.Input<string>> {
45
+ return { url: this.url, credentials: this.credentials.value };
46
+ }
47
+
48
+ getSSTLink() {
49
+ return { properties: { ...this.provides() } };
50
+ }
51
+ }
52
+
53
+ export interface ExternalApiProps {
54
+ /** The declaration's `url` — one for every stage, or one per stage name. */
55
+ url: string | Readonly<Record<string, string>>;
56
+ /** A value for the credentials secret until one is set, for a preview stage. */
57
+ placeholder?: string;
58
+ }
@@ -11,7 +11,8 @@ import type { StackType } from '../Stack';
11
11
  * work queue. Link it to a producer and the runtime resolves `<NAME>_URL`,
12
12
  * `<NAME>_ARN`, and a `<NAME>_PUBLISHER_CONNECTION_STRING` (`sqs://?queueUrl=…`)
13
13
  * that `@geekmidas/events`'s `Publisher.fromConnectionString` consumes. Its
14
- * single consumer is wired by `QueueSubscriber`.
14
+ * one consumer — the handler the queue was declared with — is subscribed by
15
+ * {@link Queue.consume}.
15
16
  *
16
17
  * SST's native `Queue` link exposes only `url`, so `getSSTLink` is overridden to
17
18
  * also expose `arn` (what the resolver needs). `QueueProps` extends
@@ -73,6 +74,25 @@ export class Queue<
73
74
  };
74
75
  }
75
76
 
77
+ /**
78
+ * Subscribe the queue's one consumer: a Lambda running `handler`, linked to
79
+ * what the consumer depends on and to this queue itself.
80
+ *
81
+ * A queue has exactly one consumer, so this is called once per queue — by
82
+ * `fromManifest`, from the handler the build wrote for it.
83
+ */
84
+ consume(consumer: QueueConsumer) {
85
+ return this.subscribe(
86
+ {
87
+ handler: consumer.handler,
88
+ link: [this, ...(consumer.link ?? [])],
89
+ runtime: 'nodejs24.x',
90
+ ...(consumer.timeout ? { timeout: consumer.timeout } : {}),
91
+ },
92
+ consumer.batchSize ? { batch: { size: consumer.batchSize } } : {},
93
+ );
94
+ }
95
+
76
96
  override getSSTLink() {
77
97
  const link = super.getSSTLink();
78
98
  return {
@@ -82,6 +102,16 @@ export class Queue<
82
102
  }
83
103
  }
84
104
 
105
+ /** The queue's one consumer, as {@link Queue.consume} subscribes it. */
106
+ export interface QueueConsumer {
107
+ /** The built handler — `.gkm/aws-lambda/queues/emails.handler`. */
108
+ handler: string;
109
+ /** What the consumer depends on; the queue itself is always linked. */
110
+ link?: unknown[];
111
+ timeout?: `${number} seconds` | `${number} minutes`;
112
+ batchSize?: number;
113
+ }
114
+
85
115
  export interface QueueProps extends sst.aws.QueueArgs {
86
116
  /**
87
117
  * The physical queue name, where the auto-generated one will not do.