@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.
- package/dist/{index-ByEJy40r.d.cts → index-B5CZ1xVf.d.cts} +17 -9
- package/dist/index-B5CZ1xVf.d.cts.map +1 -0
- package/dist/{index-DZ0QrJQr.d.mts → index-DhHRjduZ.d.mts} +17 -9
- package/dist/index-DhHRjduZ.d.mts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.d.cts +1 -1
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-CtMjuIMR.cjs → utils-B1a2UuEO.cjs} +5 -5
- package/dist/utils-B1a2UuEO.cjs.map +1 -0
- package/dist/{utils-BdKG20_m.mjs → utils-DMOXJ27j.mjs} +5 -5
- package/dist/utils-DMOXJ27j.mjs.map +1 -0
- package/package.json +44 -7
- package/src/dokploy/Application.ts +259 -0
- package/src/dokploy/__tests__/Application.spec.ts +69 -0
- package/src/dokploy/index.ts +18 -0
- package/src/sst/__tests__/LinkedEnvironment.spec.ts +4 -1
- package/src/sst/__tests__/backends.spec.ts +249 -0
- package/src/sst/__tests__/bootstrap.spec.ts +140 -0
- package/src/sst/__tests__/database.spec.ts +119 -0
- package/src/sst/__tests__/fromManifest.spec.ts +266 -0
- package/src/sst/__tests__/provides.spec.ts +107 -0
- package/src/sst/__tests__/ses.spec.ts +93 -0
- package/src/sst/__tests__/surfaces.spec.ts +132 -0
- package/src/sst/__type-tests__/authorizers.type-test.ts +2 -2
- package/src/sst/__type-tests__/manifest.type-test.ts +3 -3
- package/src/sst/__type-tests__/messaging.type-test.ts +3 -3
- package/src/sst/__type-tests__/storage.type-test.ts +2 -2
- package/src/sst/{Api.ts → aws/Api.ts} +3 -3
- package/src/sst/aws/Cache.ts +259 -0
- package/src/sst/aws/Credential.ts +28 -0
- package/src/sst/{Cron.ts → aws/Cron.ts} +2 -2
- package/src/sst/aws/Database.ts +190 -0
- package/src/sst/aws/DatabaseBootstrap.ts +309 -0
- package/src/sst/aws/DerivedDatabase.ts +117 -0
- package/src/sst/aws/Email.ts +181 -0
- package/src/sst/aws/FileServer.ts +82 -0
- package/src/sst/{Function.ts → aws/Function.ts} +3 -3
- package/src/sst/aws/ObjectStorage.ts +76 -0
- package/src/sst/aws/Queue.ts +116 -0
- package/src/sst/aws/RestApiSurface.ts +92 -0
- package/src/sst/aws/Secret.ts +76 -0
- package/src/sst/aws/StaticSite.ts +92 -0
- package/src/sst/{Storage.ts → aws/Storage.ts} +3 -3
- package/src/sst/aws/Topic.ts +68 -0
- package/src/sst/aws/bootstrap/handler.ts +110 -0
- package/src/sst/aws/ses.ts +132 -0
- package/src/sst/errors.ts +65 -0
- package/src/sst/fromManifest.ts +899 -0
- package/src/sst/index.ts +62 -7
- package/src/sst/naming.ts +37 -16
- package/src/sst/tsconfig.json +2 -2
- package/src/sst/upstash.d.ts +38 -0
- package/dist/index-ByEJy40r.d.cts.map +0 -1
- package/dist/index-DZ0QrJQr.d.mts.map +0 -1
- package/dist/utils-BdKG20_m.mjs.map +0 -1
- package/dist/utils-CtMjuIMR.cjs.map +0 -1
- package/src/sst/Queue.ts +0 -46
- package/src/sst/Topic.ts +0 -37
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { elasticache } from '@pulumi/aws';
|
|
2
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
3
|
+
import type { StackType } from '../Stack';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `Cache` — a key/value cache, and the infra half of the `cache` kind.
|
|
7
|
+
*
|
|
8
|
+
* The kind where the *backend* is the whole design, because unlike mail the
|
|
9
|
+
* three do not speak the same protocol. What this composes is a URL whose
|
|
10
|
+
* scheme names one of them, and the generated entry registers the matching
|
|
11
|
+
* driver — so the two agree by construction rather than by being kept in step.
|
|
12
|
+
*
|
|
13
|
+
* Only one of the three provisions anything:
|
|
14
|
+
*
|
|
15
|
+
* - **`db`** provisions nothing at all. The cache is a table in the database
|
|
16
|
+
* already declared, so this resolves that database's URL and the table is
|
|
17
|
+
* created by the same thing that creates the schema. No second resource, no
|
|
18
|
+
* second credential, and nothing to pay for while idle.
|
|
19
|
+
* - **`upstash`** provisions a Redis database through Upstash's own Pulumi
|
|
20
|
+
* provider, which SST installs on demand: `sst add upstash`. It is not one of
|
|
21
|
+
* the two providers SST preloads, so the component checks for its global and
|
|
22
|
+
* says which command is missing rather than failing on an undefined name. A
|
|
23
|
+
* URL can still be supplied instead, for a database that already exists.
|
|
24
|
+
* - **`elasticache`** is the one that creates something, and it comes with the
|
|
25
|
+
* constraints of a thing in a VPC: it is reachable from functions in that VPC
|
|
26
|
+
* and from nothing else.
|
|
27
|
+
*/
|
|
28
|
+
export class Cache<
|
|
29
|
+
TStage extends string = string,
|
|
30
|
+
TDomain extends string = string,
|
|
31
|
+
> implements GkmLinkable
|
|
32
|
+
{
|
|
33
|
+
readonly _id: string;
|
|
34
|
+
|
|
35
|
+
private readonly url: $util.Input<string>;
|
|
36
|
+
|
|
37
|
+
get _type() {
|
|
38
|
+
return ResourceType.Cache;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
_stack: StackType<TStage, TDomain>,
|
|
43
|
+
name: string,
|
|
44
|
+
props: CacheProps,
|
|
45
|
+
) {
|
|
46
|
+
this._id = name;
|
|
47
|
+
this.url = props.url ?? this.provision(name, props);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create the cache, for the two backends that can be created.
|
|
52
|
+
*
|
|
53
|
+
* `db` never reaches here — it resolves the declared database's URL, which
|
|
54
|
+
* the caller passes in as `url`.
|
|
55
|
+
*/
|
|
56
|
+
private provision(name: string, props: CacheProps): $util.Input<string> {
|
|
57
|
+
return props.backend === 'upstash'
|
|
58
|
+
? this.provisionUpstash(name, props)
|
|
59
|
+
: this.provisionElastiCache(name, props);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An Upstash Redis database, over HTTP.
|
|
64
|
+
*
|
|
65
|
+
* The default backend, because HTTP with a token is reachable from a Lambda
|
|
66
|
+
* with no VPC and no connection pool — the same argument that makes it worth
|
|
67
|
+
* running a proxy in front of Redis locally so both speak one protocol.
|
|
68
|
+
*
|
|
69
|
+
* The token goes in the URL's userinfo rather than a second key, because an
|
|
70
|
+
* address and the credential that opens it are one fact.
|
|
71
|
+
*/
|
|
72
|
+
private provisionUpstash(
|
|
73
|
+
name: string,
|
|
74
|
+
props: CacheProps,
|
|
75
|
+
): $util.Input<string> {
|
|
76
|
+
// Upstash is installed on demand rather than preloaded, so its global is
|
|
77
|
+
// absent until `sst add upstash` has run. Checking for it turns an
|
|
78
|
+
// undefined-name crash into a sentence naming the command.
|
|
79
|
+
if (typeof upstash === 'undefined') throw new CacheNeedsProvider(name);
|
|
80
|
+
|
|
81
|
+
const database = new upstash.RedisDatabase(`${name}Cache`, {
|
|
82
|
+
databaseName: name,
|
|
83
|
+
// `global` needs a primary region named alongside it, so a plain
|
|
84
|
+
// region is the default that needs no second decision.
|
|
85
|
+
region: props.region ?? 'eu-west-1',
|
|
86
|
+
// In transit, always. The client speaks HTTPS either way, and a cache
|
|
87
|
+
// reachable unencrypted over the public internet is not one.
|
|
88
|
+
tls: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return $util
|
|
92
|
+
.all([database.endpoint, database.restToken])
|
|
93
|
+
.apply(
|
|
94
|
+
([endpoint, token]) =>
|
|
95
|
+
`https://:${encodeURIComponent(token)}@${endpoint}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A serverless Valkey cache, in the VPC the caller named.
|
|
101
|
+
*
|
|
102
|
+
* Serverless rather than a node group because this design provisions per
|
|
103
|
+
* stage, and a node running around the clock under every preview stage is a
|
|
104
|
+
* bill nobody chose. The database went the other way — a plain RDS instance —
|
|
105
|
+
* because a cluster buys complexity there rather than savings; the two are
|
|
106
|
+
* separate calls, not one principle applied twice.
|
|
107
|
+
*
|
|
108
|
+
* TLS is not optional — ElastiCache Serverless only accepts encrypted
|
|
109
|
+
* connections — which is why the URL is `rediss://` and why the driver
|
|
110
|
+
* registers both schemes.
|
|
111
|
+
*/
|
|
112
|
+
private provisionElastiCache(
|
|
113
|
+
name: string,
|
|
114
|
+
props: CacheProps,
|
|
115
|
+
): $util.Input<string> {
|
|
116
|
+
if (!props.vpc) throw new CacheNeedsVpc(name);
|
|
117
|
+
|
|
118
|
+
const cache = new elasticache.ServerlessCache(`${name}Cache`, {
|
|
119
|
+
engine: 'valkey',
|
|
120
|
+
name,
|
|
121
|
+
subnetIds: props.vpc.subnets,
|
|
122
|
+
securityGroupIds: props.vpc.securityGroups,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return $util.output(cache.endpoints).apply((endpoints) => {
|
|
126
|
+
const endpoint = endpoints[0];
|
|
127
|
+
if (!endpoint) throw new CacheNeedsVpc(name);
|
|
128
|
+
|
|
129
|
+
return $util
|
|
130
|
+
.all([endpoint.address, endpoint.port])
|
|
131
|
+
.apply(([address, port]) => `rediss://${address}:${port}`);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One key, the URL — and its scheme is the contract with the driver. */
|
|
136
|
+
provides(): Record<string, $util.Input<string>> {
|
|
137
|
+
return { url: this.url };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getSSTLink() {
|
|
141
|
+
return { properties: { ...this.provides() } };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface CacheProps {
|
|
146
|
+
/** Which backend to create. `db` never creates anything and never gets here. */
|
|
147
|
+
backend?: 'upstash' | 'elasticache' | 'db';
|
|
148
|
+
/** The region to create an Upstash database in. */
|
|
149
|
+
region?: string;
|
|
150
|
+
/**
|
|
151
|
+
* The cache's URL, whose scheme picks the driver.
|
|
152
|
+
*
|
|
153
|
+
* Supplied for the two backends that are not provisioned here — the declared
|
|
154
|
+
* database's URL for `db`, a secret for `upstash`. Absent for `elasticache`,
|
|
155
|
+
* which creates a cluster and composes its own.
|
|
156
|
+
*/
|
|
157
|
+
url?: $util.Input<string>;
|
|
158
|
+
/**
|
|
159
|
+
* The VPC to put an ElastiCache cluster in.
|
|
160
|
+
*
|
|
161
|
+
* Required for that backend and meaningless for the others. A cache in a VPC
|
|
162
|
+
* is reachable from functions in that VPC and from nothing else, which is
|
|
163
|
+
* the trade that comes with choosing it.
|
|
164
|
+
*/
|
|
165
|
+
vpc?: {
|
|
166
|
+
subnets: $util.Input<$util.Input<string>[]>;
|
|
167
|
+
securityGroups: $util.Input<$util.Input<string>[]>;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** An ElastiCache-backed cache was declared and no VPC was supplied for it. */
|
|
172
|
+
export class CacheNeedsVpc extends Error {
|
|
173
|
+
constructor(readonly id: string) {
|
|
174
|
+
super(
|
|
175
|
+
`'${id}' is an ElastiCache cache and needs a VPC to live in. Supply ` +
|
|
176
|
+
`one through the deploy layer — ` +
|
|
177
|
+
`fromManifest(stack, manifest, { ${id}: { vpc } }) — the same one ` +
|
|
178
|
+
`the functions that read it run in, since a cache in a VPC is ` +
|
|
179
|
+
`reachable from nowhere else.`,
|
|
180
|
+
);
|
|
181
|
+
this.name = 'CacheNeedsVpc';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A cache backed by the database was declared, and no database was.
|
|
187
|
+
*
|
|
188
|
+
* The same rule pg-boss has, and the same reason: provisioning a database to
|
|
189
|
+
* hold only a cache is the resource this design refuses to invent on your
|
|
190
|
+
* behalf.
|
|
191
|
+
*/
|
|
192
|
+
/**
|
|
193
|
+
* A cache's address: its database's URL, carrying the table it reads.
|
|
194
|
+
*
|
|
195
|
+
* The table travels in the URL rather than beside it because a database-backed
|
|
196
|
+
* cache has no address of its own — two caches in one database resolve the same
|
|
197
|
+
* connection string, so the parameter is the only thing that identifies which
|
|
198
|
+
* one a client is holding. Mirrors what the local target composes, so the same
|
|
199
|
+
* application code reads the same shape in both places.
|
|
200
|
+
*/
|
|
201
|
+
export function withCacheTable(url: string, table: string): string {
|
|
202
|
+
const parsed = new URL(url);
|
|
203
|
+
parsed.searchParams.set('table', table);
|
|
204
|
+
|
|
205
|
+
return parsed.toString();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `services.cache: 'db'` in an app that declares more than one database.
|
|
210
|
+
*
|
|
211
|
+
* Picking one would put the cache somewhere nobody chose, and the symptom —
|
|
212
|
+
* entries that are never found — appears long after the deploy reported
|
|
213
|
+
* success. The fix is a stronger statement in application code:
|
|
214
|
+
* `orders.cache('Sessions')` names the database.
|
|
215
|
+
*/
|
|
216
|
+
export class CacheIsAmbiguous extends Error {
|
|
217
|
+
constructor(
|
|
218
|
+
readonly id: string,
|
|
219
|
+
readonly databases: readonly string[],
|
|
220
|
+
) {
|
|
221
|
+
super(
|
|
222
|
+
`'${id}' is a cache backed by the database, and this app declares ` +
|
|
223
|
+
`${databases.length}: ${databases.join(', ')}. Declare the cache from ` +
|
|
224
|
+
`its database — e.g. ${databases[0]}.cache('${id}') — rather than ` +
|
|
225
|
+
`with services.cache.`,
|
|
226
|
+
);
|
|
227
|
+
this.name = 'CacheIsAmbiguous';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export class CacheNeedsDatabase extends Error {
|
|
232
|
+
constructor(readonly id: string) {
|
|
233
|
+
super(
|
|
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'.`,
|
|
237
|
+
);
|
|
238
|
+
this.name = 'CacheNeedsDatabase';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* An Upstash cache was declared and Upstash's provider is not installed.
|
|
244
|
+
*
|
|
245
|
+
* SST preloads two providers and installs the rest on demand, so the global this
|
|
246
|
+
* component reaches for does not exist until somebody runs the command. Naming
|
|
247
|
+
* the command beats an undefined-name crash halfway through a synth.
|
|
248
|
+
*/
|
|
249
|
+
export class CacheNeedsProvider extends Error {
|
|
250
|
+
constructor(readonly id: string) {
|
|
251
|
+
super(
|
|
252
|
+
`'${id}' is an Upstash cache and the Upstash provider is not ` +
|
|
253
|
+
`installed. Run \`sst add upstash\` in the app, or supply a URL for ` +
|
|
254
|
+
`a database that already exists — ` +
|
|
255
|
+
`fromManifest(stack, manifest, { ${id}: { url } }).`,
|
|
256
|
+
);
|
|
257
|
+
this.name = 'CacheNeedsProvider';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Secret, type SecretProps } from './Secret';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `Credential` — a third-party credential SST holds, and the infra half of the
|
|
5
|
+
* `credential` kind.
|
|
6
|
+
*
|
|
7
|
+
* The *same storage* as a secret and a different kind, because what differs is
|
|
8
|
+
* the lifecycle rather than the mechanism: a secret is generated and rotated by
|
|
9
|
+
* the platform, while a credential is issued by someone else and has a shape
|
|
10
|
+
* the construct validates on the way in. Both are values you set out of band
|
|
11
|
+
* with `sst secret set`.
|
|
12
|
+
*
|
|
13
|
+
* The role is `credential` rather than `value`, and that is not cosmetic. The
|
|
14
|
+
* role *is* the contract — `providedKeyFor` turns it into the key the app
|
|
15
|
+
* 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.
|
|
18
|
+
*/
|
|
19
|
+
export class Credential<
|
|
20
|
+
TStage extends string = string,
|
|
21
|
+
TDomain extends string = string,
|
|
22
|
+
> extends Secret<TStage, TDomain> {
|
|
23
|
+
override provides(): Record<string, $util.Input<string>> {
|
|
24
|
+
return { credential: this.value };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CredentialProps extends SecretProps {}
|
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
flattenManifestField,
|
|
4
4
|
type ManifestField,
|
|
5
5
|
} from '@geekmidas/manifest';
|
|
6
|
+
import type { GkmLinkable } from '../Linkable';
|
|
7
|
+
import type { StackType } from '../Stack';
|
|
6
8
|
import { Function } from './Function';
|
|
7
|
-
import type { GkmLinkable } from './Linkable';
|
|
8
|
-
import type { StackType } from './Stack';
|
|
9
9
|
|
|
10
10
|
export type CronExpressionValue = number | '*' | '?' | `${number}/${number}`;
|
|
11
11
|
export type CronExpressionDay =
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import * as postgresUrl from '@geekmidas/db/pg/url';
|
|
2
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
3
|
+
import type { StackType } from '../Stack';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `Database` — an RDS Postgres instance, and the infra half of the `database`
|
|
7
|
+
* kind.
|
|
8
|
+
*
|
|
9
|
+
* **A provisioned instance rather than Aurora Serverless v2.** Aurora is the
|
|
10
|
+
* more interesting answer on paper — it scales to zero, so an idle preview
|
|
11
|
+
* stage costs storage and nothing else, which suits a design that provisions
|
|
12
|
+
* per stage. It is also a cluster: more moving parts, a different resource
|
|
13
|
+
* type, and pricing that is harder to predict for the steady-state workload
|
|
14
|
+
* most stages actually are. A plain instance is the ordinary thing, and the
|
|
15
|
+
* ordinary thing is the better default.
|
|
16
|
+
*
|
|
17
|
+
* Aurora is not reachable from here: this class *is* the RDS component and its
|
|
18
|
+
* props are that component's args, so a stage wanting a cluster needs a second
|
|
19
|
+
* class rather than an override. Worth adding when something wants it; not
|
|
20
|
+
* worth pretending it already exists.
|
|
21
|
+
*
|
|
22
|
+
* :::caution
|
|
23
|
+
* Moving between the two replaces the database. They are different resources,
|
|
24
|
+
* not different settings — the data does not come with you.
|
|
25
|
+
* :::
|
|
26
|
+
*
|
|
27
|
+
* **Who provisions the read replica** — nobody does, and a reader resolves to
|
|
28
|
+
* the writer's address. An Aurora cluster has a reader endpoint; an RDS
|
|
29
|
+
* instance has no second address to hand out, so `DatabaseReader` returns this
|
|
30
|
+
* one. That is the fallback the design already specified for `--target=server`
|
|
31
|
+
* and it is safe for the same reason: read-only is enforced by the role's
|
|
32
|
+
* grants, never by which endpoint you happened to reach. Adding `replicas`
|
|
33
|
+
* creates instances but no endpoint that balances across them, so it changes
|
|
34
|
+
* nothing here.
|
|
35
|
+
*/
|
|
36
|
+
export class Database<
|
|
37
|
+
TStage extends string = string,
|
|
38
|
+
TDomain extends string = string,
|
|
39
|
+
>
|
|
40
|
+
extends sst.aws.Postgres
|
|
41
|
+
implements GkmLinkable
|
|
42
|
+
{
|
|
43
|
+
readonly _id!: string;
|
|
44
|
+
|
|
45
|
+
get _type() {
|
|
46
|
+
return ResourceType.SSTPostgres;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The schema pinned on the connection's `search_path`, where one applies. */
|
|
50
|
+
private readonly schema: string | undefined;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The VPC this cluster lives in.
|
|
54
|
+
*
|
|
55
|
+
* Kept so the bootstrap function can be put in the same one — a database
|
|
56
|
+
* reachable from outside its VPC is the problem the requirement avoids, and
|
|
57
|
+
* the DDL has to reach it from inside.
|
|
58
|
+
*/
|
|
59
|
+
readonly vpc: sst.aws.Vpc;
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
_stack: StackType<TStage, TDomain>,
|
|
63
|
+
name: string,
|
|
64
|
+
props: DatabaseProps,
|
|
65
|
+
) {
|
|
66
|
+
const { schema, ...args } = props;
|
|
67
|
+
|
|
68
|
+
super(name, args);
|
|
69
|
+
this._id = name;
|
|
70
|
+
this.schema = schema;
|
|
71
|
+
this.vpc = args.vpc;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One key, the URL a running handler connects with.
|
|
76
|
+
*
|
|
77
|
+
* The owner URL is deliberately not here. It exists — a migrator needs DDL
|
|
78
|
+
* rights — but it is wired straight into the migrator by the adapter, so no
|
|
79
|
+
* edge in any manifest can name it and nothing can be granted it by mistake.
|
|
80
|
+
*
|
|
81
|
+
* Composed through `@geekmidas/db`'s codec rather than by hand, which is what
|
|
82
|
+
* puts `search_path` in as a libpq `options` parameter. A plain
|
|
83
|
+
* `?search_path=` is accepted by every URL parser, ignored by the server, and
|
|
84
|
+
* produces a database that looks empty.
|
|
85
|
+
*/
|
|
86
|
+
provides(): Record<string, $util.Input<string>> {
|
|
87
|
+
return { url: this.urlFor({}) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* This cluster's connection URL, optionally through the reader endpoint or
|
|
92
|
+
* pinned to a different schema.
|
|
93
|
+
*
|
|
94
|
+
* One composition serving three callers — the cluster itself, a reader, and
|
|
95
|
+
* a schema tenant — so the three cannot come to disagree about how a URL is
|
|
96
|
+
* put together. That matters more than it sounds: `search_path` is the part
|
|
97
|
+
* that goes quietly missing when the join is done by hand.
|
|
98
|
+
*/
|
|
99
|
+
urlFor(options: {
|
|
100
|
+
/**
|
|
101
|
+
* Ask for the read path.
|
|
102
|
+
*
|
|
103
|
+
* Kept in the signature and currently resolved to the same address: an
|
|
104
|
+
* RDS instance has one endpoint. It states the caller's intent — a reader
|
|
105
|
+
* wants the read path — so moving a stage to a cluster that *has* a second
|
|
106
|
+
* endpoint changes one line here rather than every call site. What makes
|
|
107
|
+
* the connection read-only is the role it authenticates as, not this.
|
|
108
|
+
*/
|
|
109
|
+
reader?: boolean;
|
|
110
|
+
schema?: string;
|
|
111
|
+
/**
|
|
112
|
+
* Connect as a role other than the master.
|
|
113
|
+
*
|
|
114
|
+
* What every tenant does. The master credential exists before any role
|
|
115
|
+
* does, which is why the bootstrap uses it and why nothing else should: a
|
|
116
|
+
* handler holding it could drop the database it was reading.
|
|
117
|
+
*/
|
|
118
|
+
as?: { user: $util.Input<string>; password: $util.Input<string> };
|
|
119
|
+
}): $util.Input<string> {
|
|
120
|
+
// One address: an RDS instance has no reader endpoint. Reading through the
|
|
121
|
+
// writer is safe because the reader *role* is what forbids writing.
|
|
122
|
+
const host = this.host;
|
|
123
|
+
// A role carries its own `search_path`, pinned by `ALTER ROLE`. It goes in
|
|
124
|
+
// the URL only for the master, which has no role of its own to pin it on.
|
|
125
|
+
const searchPath = options.as ? undefined : (options.schema ?? this.schema);
|
|
126
|
+
|
|
127
|
+
return $util
|
|
128
|
+
.all([
|
|
129
|
+
host,
|
|
130
|
+
this.port,
|
|
131
|
+
this.database,
|
|
132
|
+
options.as?.user ?? this.username,
|
|
133
|
+
options.as?.password ?? this.password,
|
|
134
|
+
])
|
|
135
|
+
.apply(([resolvedHost, port, database, username, password]) =>
|
|
136
|
+
postgresUrl.build({
|
|
137
|
+
host: resolvedHost,
|
|
138
|
+
port,
|
|
139
|
+
database,
|
|
140
|
+
username,
|
|
141
|
+
password,
|
|
142
|
+
...(searchPath ? { searchPath } : {}),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
override getSSTLink() {
|
|
148
|
+
const link = super.getSSTLink();
|
|
149
|
+
return {
|
|
150
|
+
...link,
|
|
151
|
+
properties: { ...link.properties, ...this.provides() },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface DatabaseProps extends Omit<sst.aws.PostgresArgs, 'vpc'> {
|
|
157
|
+
/** The schema to pin on the connection's `search_path`. */
|
|
158
|
+
schema?: string;
|
|
159
|
+
/**
|
|
160
|
+
* The VPC the database lives in.
|
|
161
|
+
*
|
|
162
|
+
* Narrowed to the component from the wider argument the RDS component
|
|
163
|
+
* accepts, because the bootstrap function has to run in this same VPC and a
|
|
164
|
+
* function needs security groups as well as subnets — which the loose form
|
|
165
|
+
* cannot carry. Requiring the component means one thing is passed and both
|
|
166
|
+
* halves can use it.
|
|
167
|
+
*/
|
|
168
|
+
vpc: sst.aws.Vpc;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A database was declared and the deploy layer supplied no VPC.
|
|
173
|
+
*
|
|
174
|
+
* Not something the adapter can default. RDS lives in a VPC, and creating one
|
|
175
|
+
* means creating a NAT gateway — a real monthly cost, in an account whose
|
|
176
|
+
* networking may already be someone else's decision. So it is required, named,
|
|
177
|
+
* and supplied where other provider-specific props are.
|
|
178
|
+
*/
|
|
179
|
+
export class DatabaseNeedsVpc extends Error {
|
|
180
|
+
constructor(readonly id: string) {
|
|
181
|
+
super(
|
|
182
|
+
`'${id}' is a database, and a database needs a VPC to live in. ` +
|
|
183
|
+
`Supply one through the deploy layer — ` +
|
|
184
|
+
`fromManifest(stack, manifest, { ${id}: { vpc } }) — because ` +
|
|
185
|
+
`creating one means creating a NAT gateway, which costs money in an ` +
|
|
186
|
+
`account whose networking may already be someone else's decision.`,
|
|
187
|
+
);
|
|
188
|
+
this.name = 'DatabaseNeedsVpc';
|
|
189
|
+
}
|
|
190
|
+
}
|