@geekmidas/cloud 1.1.1 → 10.0.0-alpha.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 (63) hide show
  1. package/dist/{index-ByEJy40r.d.cts → index-B5CZ1xVf.d.cts} +17 -9
  2. package/dist/index-B5CZ1xVf.d.cts.map +1 -0
  3. package/dist/{index-DZ0QrJQr.d.mts → index-DhHRjduZ.d.mts} +17 -9
  4. package/dist/index-DhHRjduZ.d.mts.map +1 -0
  5. package/dist/index.cjs +1 -1
  6. package/dist/index.d.cts +1 -1
  7. package/dist/index.d.mts +1 -1
  8. package/dist/index.mjs +1 -1
  9. package/dist/utils/index.cjs +1 -1
  10. package/dist/utils/index.d.cts +1 -1
  11. package/dist/utils/index.d.mts +1 -1
  12. package/dist/utils/index.mjs +1 -1
  13. package/dist/{utils-CtMjuIMR.cjs → utils-B1a2UuEO.cjs} +5 -5
  14. package/dist/utils-B1a2UuEO.cjs.map +1 -0
  15. package/dist/{utils-BdKG20_m.mjs → utils-DMOXJ27j.mjs} +5 -5
  16. package/dist/utils-DMOXJ27j.mjs.map +1 -0
  17. package/package.json +44 -7
  18. package/src/dokploy/Application.ts +259 -0
  19. package/src/dokploy/__tests__/Application.spec.ts +69 -0
  20. package/src/dokploy/index.ts +18 -0
  21. package/src/sst/__tests__/LinkedEnvironment.spec.ts +4 -1
  22. package/src/sst/__tests__/backends.spec.ts +249 -0
  23. package/src/sst/__tests__/bootstrap.spec.ts +140 -0
  24. package/src/sst/__tests__/database.spec.ts +119 -0
  25. package/src/sst/__tests__/fromManifest.spec.ts +266 -0
  26. package/src/sst/__tests__/provides.spec.ts +107 -0
  27. package/src/sst/__tests__/ses.spec.ts +93 -0
  28. package/src/sst/__tests__/surfaces.spec.ts +132 -0
  29. package/src/sst/__type-tests__/authorizers.type-test.ts +2 -2
  30. package/src/sst/__type-tests__/manifest.type-test.ts +3 -3
  31. package/src/sst/__type-tests__/messaging.type-test.ts +3 -3
  32. package/src/sst/__type-tests__/storage.type-test.ts +2 -2
  33. package/src/sst/{Api.ts → aws/Api.ts} +3 -3
  34. package/src/sst/aws/Cache.ts +259 -0
  35. package/src/sst/aws/Credential.ts +28 -0
  36. package/src/sst/{Cron.ts → aws/Cron.ts} +2 -2
  37. package/src/sst/aws/Database.ts +190 -0
  38. package/src/sst/aws/DatabaseBootstrap.ts +309 -0
  39. package/src/sst/aws/DerivedDatabase.ts +117 -0
  40. package/src/sst/aws/Email.ts +181 -0
  41. package/src/sst/aws/FileServer.ts +82 -0
  42. package/src/sst/{Function.ts → aws/Function.ts} +3 -3
  43. package/src/sst/aws/ObjectStorage.ts +76 -0
  44. package/src/sst/aws/Queue.ts +116 -0
  45. package/src/sst/aws/RestApiSurface.ts +92 -0
  46. package/src/sst/aws/Secret.ts +76 -0
  47. package/src/sst/aws/StaticSite.ts +92 -0
  48. package/src/sst/{Storage.ts → aws/Storage.ts} +3 -3
  49. package/src/sst/aws/Topic.ts +68 -0
  50. package/src/sst/aws/bootstrap/handler.ts +110 -0
  51. package/src/sst/aws/ses.ts +132 -0
  52. package/src/sst/errors.ts +65 -0
  53. package/src/sst/fromManifest.ts +899 -0
  54. package/src/sst/index.ts +62 -7
  55. package/src/sst/naming.ts +37 -16
  56. package/src/sst/tsconfig.json +2 -2
  57. package/src/sst/upstash.d.ts +38 -0
  58. package/dist/index-ByEJy40r.d.cts.map +0 -1
  59. package/dist/index-DZ0QrJQr.d.mts.map +0 -1
  60. package/dist/utils-BdKG20_m.mjs.map +0 -1
  61. package/dist/utils-CtMjuIMR.cjs.map +0 -1
  62. package/src/sst/Queue.ts +0 -46
  63. package/src/sst/Topic.ts +0 -37
@@ -0,0 +1,76 @@
1
+ import { s3Url } from '@geekmidas/storage/aws';
2
+ import { type GkmLinkable, ResourceType } from '../Linkable';
3
+ import type { StackType } from '../Stack';
4
+
5
+ /**
6
+ * `ObjectStorage` — a linkable S3 bucket (wraps `sst.aws.Bucket`), and the
7
+ * infra half of the `objects` construct.
8
+ *
9
+ * Its whole contribution is the link. SST injects the linked resource's
10
+ * properties at runtime and `@geekmidas/envkit`'s resolvers turn them into the
11
+ * env a construct reads — so composing a URL here would produce a value nothing
12
+ * ever reads.
13
+ *
14
+ * Usable on its own. `Stack.fromManifest` translates a manifest into these, but
15
+ * the component takes ordinary props so it works in a hand-written
16
+ * `sst.config.ts` with no manifest anywhere.
17
+ */
18
+ export class ObjectStorage<
19
+ TStage extends string = string,
20
+ TDomain extends string = string,
21
+ >
22
+ extends sst.aws.Bucket
23
+ implements GkmLinkable
24
+ {
25
+ readonly _id!: string;
26
+
27
+ get _type() {
28
+ return ResourceType.ObjectStorage;
29
+ }
30
+
31
+ constructor(
32
+ _stack: StackType<TStage, TDomain>,
33
+ name: string,
34
+ props: ObjectStorageProps = {},
35
+ ) {
36
+ super(name, props);
37
+ this._id = name;
38
+ }
39
+
40
+ /**
41
+ * The values this bucket resolves onto anything that depends on it, keyed by
42
+ * role. `provideKey` turns a role into the env key the app declared, so
43
+ * `url` here is `UPLOADS_URL` there.
44
+ *
45
+ * Composed *here* because only the resource knows its own shape: the name may
46
+ * be supplied through props or generated, and the region is the bucket's, not
47
+ * the reader's. It stays a method rather than being inlined below so the
48
+ * contract can be asserted — declared keys against supplied ones — without a
49
+ * deploy.
50
+ */
51
+ provides(): Record<string, $util.Input<string>> {
52
+ return {
53
+ url: $util
54
+ .all([this.name, this.nodes.bucket.region])
55
+ .apply(([bucket, region]) => s3Url.build({ bucket, region })),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The link carries those values, because the link is what reaches the running
61
+ * code: SST injects these properties and envkit's resolvers flatten them into
62
+ * env. Composing anywhere else produces a value nothing reads.
63
+ *
64
+ * The permissions `super` includes are untouched: what a link grants stays
65
+ * SST's business.
66
+ */
67
+ override getSSTLink() {
68
+ const link = super.getSSTLink();
69
+ return {
70
+ ...link,
71
+ properties: { ...link.properties, ...this.provides() },
72
+ };
73
+ }
74
+ }
75
+
76
+ export interface ObjectStorageProps extends sst.aws.BucketArgs {}
@@ -0,0 +1,116 @@
1
+ // The codec alone, not the barrel: `@geekmidas/events/sqs` re-exports the
2
+ // publisher and its AWS SDK client, and a deploy config that only needs to
3
+ // *compose a URL* should not have to resolve a runtime it never calls.
4
+ import * as sqsUrl from '@geekmidas/events/sqs/url';
5
+ import { type GkmLinkable, ResourceType } from '../Linkable';
6
+ import { regionOfArn } from '../naming';
7
+ import type { StackType } from '../Stack';
8
+
9
+ /**
10
+ * `Queue` — a linkable SQS queue (wraps `sst.aws.Queue`), the point-to-point
11
+ * work queue. Link it to a producer and the runtime resolves `<NAME>_URL`,
12
+ * `<NAME>_ARN`, and a `<NAME>_PUBLISHER_CONNECTION_STRING` (`sqs://?queueUrl=…`)
13
+ * that `@geekmidas/events`'s `Publisher.fromConnectionString` consumes. Its
14
+ * single consumer is wired by `QueueSubscriber`.
15
+ *
16
+ * SST's native `Queue` link exposes only `url`, so `getSSTLink` is overridden to
17
+ * also expose `arn` (what the resolver needs). `QueueProps` extends
18
+ * `sst.aws.QueueArgs`. Source-only (extends ambient `sst.aws.*`); see docs §2.
19
+ */
20
+ export class Queue<
21
+ TStage extends string = string,
22
+ TDomain extends string = string,
23
+ >
24
+ extends sst.aws.Queue
25
+ implements GkmLinkable
26
+ {
27
+ readonly _id!: string;
28
+
29
+ get _type() {
30
+ return ResourceType.Queue;
31
+ }
32
+
33
+ constructor(
34
+ _stack: StackType<TStage, TDomain>,
35
+ name: string,
36
+ props: QueueProps = {},
37
+ ) {
38
+ const { queueName, ...args } = props;
39
+
40
+ super(name, {
41
+ ...args,
42
+ // A supplied name has to satisfy SQS's rule; an auto-generated one is
43
+ // Pulumi's problem and it already handles it.
44
+ ...(queueName
45
+ ? {
46
+ transform: {
47
+ ...args.transform,
48
+ queue: { name: fifoName(queueName, args.fifo) },
49
+ },
50
+ }
51
+ : {}),
52
+ });
53
+ this._id = name;
54
+ }
55
+
56
+ /**
57
+ * The values this queue resolves onto anything that publishes to it, keyed
58
+ * by role. One key, the producer's: a worker is reached *through* the queue,
59
+ * so there is no second address and nothing can depend on the handler.
60
+ *
61
+ * The region is read out of the queue's own ARN rather than left for the SDK
62
+ * to infer. `AWS_REGION` inside a Lambda is the *function's* region, so a
63
+ * connection string that omits it works until the day the queue lives
64
+ * somewhere else and then fails at runtime with nothing to point at.
65
+ */
66
+ provides(): Record<string, $util.Input<string>> {
67
+ return {
68
+ publisherConnectionString: $util
69
+ .all([this.url, this.arn])
70
+ .apply(([queueUrl, arn]) =>
71
+ sqsUrl.build({ queueUrl, region: regionOfArn(arn) }),
72
+ ),
73
+ };
74
+ }
75
+
76
+ override getSSTLink() {
77
+ const link = super.getSSTLink();
78
+ return {
79
+ ...link,
80
+ properties: { ...link.properties, arn: this.arn, ...this.provides() },
81
+ };
82
+ }
83
+ }
84
+
85
+ export interface QueueProps extends sst.aws.QueueArgs {
86
+ /**
87
+ * The physical queue name, where the auto-generated one will not do.
88
+ *
89
+ * Normalised for SQS's FIFO rule — see {@link fifoName}. This is a separate
90
+ * prop rather than a `transform` because the rule is easy to not know and
91
+ * expensive to discover: the deploy fails at the API call, long after the
92
+ * plan looked fine.
93
+ */
94
+ queueName?: string;
95
+ }
96
+
97
+ /**
98
+ * A FIFO queue's name must end in `.fifo`, and a standard queue's must not.
99
+ *
100
+ * AWS rejects either mistake at the API call rather than at plan time, so the
101
+ * component fixes it where the fact lives instead of leaving it as something
102
+ * every caller has to remember. Appending is safe: `.fifo` counts toward the
103
+ * 80-character limit, so a name already carrying it must not get a second one.
104
+ */
105
+ export function fifoName(
106
+ name: string,
107
+ fifo: sst.aws.QueueArgs['fifo'],
108
+ ): string {
109
+ // `fifo` is an Input and may be an object (`{ contentBasedDeduplication }`),
110
+ // which is still FIFO — only `false` and `undefined` are not.
111
+ const isFifo = fifo !== undefined && fifo !== false;
112
+
113
+ if (!isFifo) return name.replace(/\.fifo$/, '');
114
+
115
+ return name.endsWith('.fifo') ? name : `${name}.fifo`;
116
+ }
@@ -0,0 +1,92 @@
1
+ import { type GkmLinkable, ResourceType } from '../Linkable';
2
+ import type { StackType } from '../Stack';
3
+
4
+ /**
5
+ * `RestApiSurface` — an HTTP API, and the infra half of the `rest-api` kind.
6
+ *
7
+ * Deliberately *not* `Api`, which takes a route table and validates each
8
+ * route's environment at synth. That component is the endpoint pipeline's, and
9
+ * it needs routes; this one is the manifest's, and the manifest's `rest-api`
10
+ * node currently carries `endpoints: []` for an application's own API.
11
+ *
12
+ * So what this provisions is the surface and nothing on it. An API Gateway with
13
+ * no routes 404s everything, which is the honest state of things — and it is
14
+ * still worth provisioning, because the *address* is what everything downstream
15
+ * needs: a site inlines it as `VITE_API_URL`, an auth server puts it on its
16
+ * trusted-origin list, and the cookie domain derives from it. Those are blocked
17
+ * on the API existing, not on it answering.
18
+ *
19
+ * The two collapse into one component when the endpoint merge lands and the
20
+ * surface node carries its routes.
21
+ */
22
+
23
+ export interface RestApiSurfaceProps extends sst.aws.ApiGatewayV2Args {
24
+ /**
25
+ * Who may call this surface, resolved after everything is provisioned.
26
+ *
27
+ * A promise rather than a value, because the answer depends on constructs
28
+ * that do not exist yet when this one is built — see `provides()`.
29
+ */
30
+ callers?: Promise<{ trustedOrigins: string; cookieDomain?: string }>;
31
+ }
32
+ export class RestApiSurface<
33
+ TStage extends string = string,
34
+ TDomain extends string = string,
35
+ >
36
+ extends sst.aws.ApiGatewayV2
37
+ implements GkmLinkable
38
+ {
39
+ readonly _id!: string;
40
+
41
+ get _type() {
42
+ return ResourceType.ApiGatewayV2;
43
+ }
44
+
45
+ private readonly callers: Promise<{
46
+ trustedOrigins: string;
47
+ cookieDomain?: string;
48
+ }>;
49
+
50
+ constructor(
51
+ _stack: StackType<TStage, TDomain>,
52
+ name: string,
53
+ props: RestApiSurfaceProps = {},
54
+ ) {
55
+ const { callers, ...args } = props;
56
+
57
+ super(name, args);
58
+ this._id = name;
59
+ this.callers = callers ?? Promise.resolve({ trustedOrigins: '' });
60
+ }
61
+
62
+ /**
63
+ * Where the surface answers, and who may call it.
64
+ *
65
+ * `trustedOrigins` and `cookieDomain` are declared by the construct and are
66
+ * *caller*-derived — read off the graph, not off this resource — so they are
67
+ * supplied through props rather than composed here. A surface knows its own
68
+ * address and nothing about who points at it.
69
+ */
70
+ provides(): Record<string, $util.Input<string>> {
71
+ return {
72
+ url: this.url,
73
+ // Lazy, and it has to be. A surface's callers include the site, and
74
+ // the site's own build needs *this* surface's address — so the two
75
+ // values are circular even though the two resources are not. A
76
+ // promise resolved after everything is provisioned breaks it at the
77
+ // value level, which is the level the cycle is actually on.
78
+ trustedOrigins: $util.output(this.callers.then((c) => c.trustedOrigins)),
79
+ cookieDomain: $util.output(
80
+ this.callers.then((c) => c.cookieDomain ?? ''),
81
+ ),
82
+ };
83
+ }
84
+
85
+ override getSSTLink() {
86
+ const link = super.getSSTLink();
87
+ return {
88
+ ...link,
89
+ properties: { ...link.properties, ...this.provides() },
90
+ };
91
+ }
92
+ }
@@ -0,0 +1,76 @@
1
+ import { type GkmLinkable, ResourceType } from '../Linkable';
2
+ import type { StackType } from '../Stack';
3
+
4
+ /**
5
+ * `Secret` — a linkable SST secret, and the infra half of the `secret` kind.
6
+ *
7
+ * The odd one among the kinds: it provisions no cloud resource of its own. An
8
+ * SST secret is a value held in the app's own encrypted state and set out of
9
+ * band (`sst secret set AuthSecret …`), which is why the component is thin and
10
+ * why the *deploy* fails rather than the *runtime* when nobody set one — SST
11
+ * raises `SecretMissingError` at synth, which is the earliest anything can.
12
+ *
13
+ * It provides a value rather than an address, which is the whole reason it is a
14
+ * node instead of a field on whatever needs it: the thing that generates a
15
+ * signing key, the thing that stores it, and the thing that reads it are three
16
+ * different systems deployed, and one derived string locally.
17
+ *
18
+ * Usable on its own. `Stack.fromManifest` translates a manifest into these, but
19
+ * the component takes an ordinary placeholder so it works in a hand-written
20
+ * `sst.config.ts` with no manifest anywhere.
21
+ */
22
+ export class Secret<
23
+ TStage extends string = string,
24
+ TDomain extends string = string,
25
+ >
26
+ extends sst.Secret
27
+ implements GkmLinkable
28
+ {
29
+ readonly _id!: string;
30
+
31
+ get _type() {
32
+ return ResourceType.SSTSecret;
33
+ }
34
+
35
+ constructor(
36
+ _stack: StackType<TStage, TDomain>,
37
+ name: string,
38
+ props: SecretProps = {},
39
+ ) {
40
+ super(name, props.placeholder);
41
+ this._id = name;
42
+ }
43
+
44
+ /**
45
+ * The one value this secret resolves onto anything that depends on it.
46
+ *
47
+ * `value` is the role, so `provideKey` turns it into the key the app
48
+ * declared. Note that a secret's key is its *name* rather than
49
+ * `<NAME>_VALUE` — `Auth` signs with `AUTH_SECRET`, which is also what
50
+ * better-auth's own tooling looks for — so the manifest declares that key
51
+ * directly and this contributes the value behind it.
52
+ */
53
+ provides(): Record<string, $util.Input<string>> {
54
+ return { value: this.value };
55
+ }
56
+
57
+ override getSSTLink() {
58
+ const link = super.getSSTLink();
59
+ return {
60
+ ...link,
61
+ properties: { ...link.properties, ...this.provides() },
62
+ };
63
+ }
64
+ }
65
+
66
+ export interface SecretProps {
67
+ /**
68
+ * A value to fall back to when none is set.
69
+ *
70
+ * Deliberately not a default for anything that signs: a placeholder signing
71
+ * key is one every deploy of every app shares, so leaving this unset — and
72
+ * letting the deploy fail — is the correct handling for a real secret. It
73
+ * exists for the values that are secrets by habit rather than by need.
74
+ */
75
+ placeholder?: string;
76
+ }
@@ -0,0 +1,92 @@
1
+ import { type GkmLinkable, ResourceType } from '../Linkable';
2
+ import type { StackType } from '../Stack';
3
+
4
+ /**
5
+ * `StaticSite` — a built frontend on a distribution, and the infra half of the
6
+ * `site` kind.
7
+ *
8
+ * The same infrastructure a file server is, which is why they are neighbours
9
+ * here: a bucket, a distribution, a certificate and a domain. What differs is
10
+ * only what sits in the bucket — a build output rather than live contents.
11
+ *
12
+ * Its `environment` is the interesting part and is not composed here. The values
13
+ * a site inlines are derived from its *edges* by `publicEnvFor`, filtered by
14
+ * which provided values may be shipped to a browser, and renamed by the
15
+ * variant's prefix. That derivation is shared with the local target, so a site
16
+ * built by `gkm dev` and the same site built by a deploy inline the same names.
17
+ */
18
+ export class StaticSite<
19
+ TStage extends string = string,
20
+ TDomain extends string = string,
21
+ >
22
+ extends sst.aws.StaticSite
23
+ implements GkmLinkable
24
+ {
25
+ readonly _id!: string;
26
+
27
+ get _type() {
28
+ return ResourceType.StaticSite;
29
+ }
30
+
31
+ constructor(
32
+ _stack: StackType<TStage, TDomain>,
33
+ name: string,
34
+ props: StaticSiteProps = {},
35
+ ) {
36
+ const { variant = 'static', ...args } = props;
37
+
38
+ super(name, {
39
+ // The variant's build, unless the caller supplied one. A site is
40
+ // source until something builds it — uploading `path` directly is what
41
+ // puts `src/` and `node_modules/` in a bucket — and *how* to build is
42
+ // exactly what the variant knows and the declaration deliberately does
43
+ // not.
44
+ build: BUILDS[variant],
45
+ ...args,
46
+ });
47
+ this._id = name;
48
+ }
49
+
50
+ /**
51
+ * Where the site is served.
52
+ *
53
+ * Worth providing even though a browser gets there by typing it: an email
54
+ * templating a link back to the console needs this URL, and that is otherwise
55
+ * one more hand-maintained variable — the exact class of thing declaring a
56
+ * site was meant to remove.
57
+ */
58
+ provides(): Record<string, $util.Input<string>> {
59
+ return { url: this.url };
60
+ }
61
+
62
+ override getSSTLink() {
63
+ const link = super.getSSTLink();
64
+ return {
65
+ ...link,
66
+ properties: { ...link.properties, ...this.provides() },
67
+ };
68
+ }
69
+ }
70
+
71
+ export interface StaticSiteProps extends sst.aws.StaticSiteArgs {
72
+ /** Which framework builds it, for the default build command. */
73
+ variant?: 'static' | 'next' | 'tanstack';
74
+ }
75
+
76
+ /**
77
+ * How each variant builds, and where it leaves the result.
78
+ *
79
+ * One neutral name from the construct, one build per framework — the same
80
+ * arrangement the `VITE_`/`NEXT_PUBLIC_` prefixes have, and for the same
81
+ * reason: the framework changes the code you write, so it changes this and
82
+ * nothing else.
83
+ */
84
+ const BUILDS: Record<
85
+ NonNullable<StaticSiteProps['variant']>,
86
+ { command: string; output: string }
87
+ > = {
88
+ static: { command: 'npm run build', output: 'dist' },
89
+ tanstack: { command: 'npm run build', output: 'dist' },
90
+ // Next's static export lands in `out`, not `dist`.
91
+ next: { command: 'npm run build', output: 'out' },
92
+ };
@@ -1,5 +1,5 @@
1
- import { type GkmLinkable, ResourceType } from './Linkable';
2
- import type { StackType } from './Stack';
1
+ import { type GkmLinkable, ResourceType } from '../Linkable';
2
+ import type { StackType } from '../Stack';
3
3
 
4
4
  /**
5
5
  * `Storage` — a linkable S3 bucket (wraps `sst.aws.Bucket`). Link it to a
@@ -39,7 +39,7 @@ export class Storage<
39
39
  readonly _id!: string;
40
40
 
41
41
  get _type() {
42
- return ResourceType.Bucket;
42
+ return ResourceType.ObjectStorage;
43
43
  }
44
44
 
45
45
  constructor(
@@ -0,0 +1,68 @@
1
+ // The codec alone — see the note in `Queue.ts`.
2
+ import * as snsUrl from '@geekmidas/events/sns/url';
3
+ import { type GkmLinkable, ResourceType } from '../Linkable';
4
+ import { regionOfArn } from '../naming';
5
+ import type { StackType } from '../Stack';
6
+
7
+ /**
8
+ * `Topic` — a linkable SNS topic (wraps `sst.aws.SnsTopic`), the pub/sub fan-out
9
+ * bus. Link it to a publisher and the runtime resolves `<NAME>_ARN` and a
10
+ * `<NAME>_PUBLISHER_CONNECTION_STRING` (`sns://?topicArn=…`) that
11
+ * `@geekmidas/events`'s `Publisher.fromConnectionString` consumes. Subscribers
12
+ * attach via `TopicSubscriber`/`Subscriber`.
13
+ *
14
+ * `StorageProps`-style: `TopicProps` extends `sst.aws.SnsTopicArgs`.
15
+ * Source-only (extends ambient `sst.aws.*`); see docs §2.
16
+ */
17
+ export class Topic<
18
+ TStage extends string = string,
19
+ TDomain extends string = string,
20
+ >
21
+ extends sst.aws.SnsTopic
22
+ implements GkmLinkable
23
+ {
24
+ readonly _id!: string;
25
+
26
+ get _type() {
27
+ return ResourceType.SnsTopic;
28
+ }
29
+
30
+ constructor(
31
+ _stack: StackType<TStage, TDomain>,
32
+ name: string,
33
+ props: TopicProps = {},
34
+ ) {
35
+ super(name, props);
36
+ this._id = name;
37
+ }
38
+
39
+ /**
40
+ * The publisher's connection string, and nothing else.
41
+ *
42
+ * A subscriber is *bound* to a topic rather than depending on it, so the
43
+ * binding is an edge the deploy target reads and not a key anyone can hold —
44
+ * which is what keeps a subscriber from being able to publish.
45
+ *
46
+ * The region comes out of the topic's own ARN, for the same reason it does
47
+ * on a queue: the reader's region is not the resource's.
48
+ */
49
+ provides(): Record<string, $util.Input<string>> {
50
+ return {
51
+ publisherConnectionString: $util
52
+ .output(this.arn)
53
+ .apply((topicArn) =>
54
+ snsUrl.build({ topicArn, region: regionOfArn(topicArn) }),
55
+ ),
56
+ };
57
+ }
58
+
59
+ override getSSTLink() {
60
+ const link = super.getSSTLink();
61
+ return {
62
+ ...link,
63
+ properties: { ...link.properties, ...this.provides() },
64
+ };
65
+ }
66
+ }
67
+
68
+ export interface TopicProps extends sst.aws.SnsTopicArgs {}
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The database bootstrap — role and schema DDL, run inside the VPC.
3
+ *
4
+ * SST provisions a cluster; nothing in Pulumi runs SQL. So the statements a
5
+ * tenant needs — its owner, its runtime role, its schema, and the grants between
6
+ * them — have to be applied by something that can reach the database, which on
7
+ * AWS means a Lambda in the same VPC.
8
+ *
9
+ * It connects **as the cluster master**, because that is the only credential
10
+ * that exists before any role does. That is also why this runs once per deploy
11
+ * and nothing else holds those credentials: the master reaches this function
12
+ * through its link and reaches nothing else.
13
+ *
14
+ * The statements come from `@geekmidas/db/pg/roles`, the same generator the
15
+ * local target uses. One implementation is what stops "works locally, fails
16
+ * deployed" — and it means the grants a developer tested against are the grants
17
+ * production gets.
18
+ */
19
+
20
+ import { roleStatements } from '@geekmidas/db/pg/roles';
21
+ import pg from 'pg';
22
+
23
+ /** One tenant to bootstrap. */
24
+ export interface BootstrapTenant {
25
+ /** The construct id, so a failure can name it. */
26
+ id: string;
27
+ schema: string;
28
+ runtime: string;
29
+ owner: string;
30
+ reader?: string;
31
+ passwords: { runtime: string; owner: string; reader?: string };
32
+ }
33
+
34
+ export interface BootstrapEvent {
35
+ /** The master credential, from the cluster's link. */
36
+ master: {
37
+ host: string;
38
+ port: number;
39
+ database: string;
40
+ username: string;
41
+ password: string;
42
+ };
43
+ tenants: BootstrapTenant[];
44
+ }
45
+
46
+ export interface BootstrapResult {
47
+ applied: { tenant: string; describe: string; created: boolean }[];
48
+ }
49
+
50
+ /**
51
+ * Apply every tenant's DDL, convergently.
52
+ *
53
+ * Each statement asks whether it is needed first, exactly as the local applier
54
+ * does, so a re-deploy is free and a half-applied run recovers by running again.
55
+ * That matters more here than locally: a Lambda can time out mid-way, and the
56
+ * recovery has to be "invoke it again" rather than "work out what happened".
57
+ */
58
+ export async function handler(event: BootstrapEvent): Promise<BootstrapResult> {
59
+ const client = new pg.Client({
60
+ host: event.master.host,
61
+ port: event.master.port,
62
+ database: event.master.database,
63
+ user: event.master.username,
64
+ password: event.master.password,
65
+ // The cluster is inside the VPC and RDS presents a certificate this
66
+ // runtime does not carry a root for. Encrypted, unverified — which is the
67
+ // same posture the AWS SDK's own RDS helpers take, and worth naming rather
68
+ // than leaving as an unexplained `false`.
69
+ ssl: { rejectUnauthorized: false },
70
+ });
71
+
72
+ await client.connect();
73
+
74
+ const applied: BootstrapResult['applied'] = [];
75
+
76
+ try {
77
+ for (const tenant of event.tenants) {
78
+ for (const statement of roleStatements(tenant)) {
79
+ if (statement.exists) {
80
+ const { rows } = await client.query(
81
+ statement.exists.sql,
82
+ statement.exists.values,
83
+ );
84
+
85
+ if (rows.length > 0) {
86
+ applied.push({
87
+ tenant: tenant.id,
88
+ describe: statement.describe,
89
+ created: false,
90
+ });
91
+ continue;
92
+ }
93
+ }
94
+
95
+ await client.query(statement.sql);
96
+ applied.push({
97
+ tenant: tenant.id,
98
+ describe: statement.describe,
99
+ // A grant has no existence check and re-granting is a no-op, so it
100
+ // reports unchanged — a converged deploy still reads as converged.
101
+ created: Boolean(statement.exists),
102
+ });
103
+ }
104
+ }
105
+ } finally {
106
+ await client.end();
107
+ }
108
+
109
+ return { applied };
110
+ }