@geekmidas/cloud 9.0.2 → 10.0.0-alpha.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.
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,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { appNameFor, diffApplication } from '../Application';
3
+
4
+ /**
5
+ * The decisions, without a Dokploy server. Instantiating the resource needs
6
+ * Pulumi and a real endpoint; deciding what a change *costs* does not, which is
7
+ * the half worth asserting — a wrong answer here is a destroy nobody previewed.
8
+ */
9
+
10
+ const base = {
11
+ name: 'Api',
12
+ projectId: 'proj-1',
13
+ environmentId: 'env-1',
14
+ };
15
+
16
+ describe('appNameFor', () => {
17
+ it('is what Dokploy will actually call it', () => {
18
+ expect(appNameFor('My API')).toBe('my-api');
19
+ });
20
+
21
+ it('collapses anything that is not a name character', () => {
22
+ expect(appNameFor('Orders_v2!')).toBe('orders-v2-');
23
+ });
24
+ });
25
+
26
+ describe('diffApplication', () => {
27
+ it('sees no change when nothing changed', () => {
28
+ expect(diffApplication(base, base)).toMatchObject({
29
+ changes: false,
30
+ replaces: [],
31
+ });
32
+ });
33
+
34
+ it('replaces on a renamed application, because appName derives from it', () => {
35
+ // Dokploy computes `appName` from the name, so changing it is a different
36
+ // application rather than an edit to this one.
37
+ expect(diffApplication(base, { ...base, name: 'Gateway' })).toMatchObject({
38
+ changes: true,
39
+ replaces: ['name'],
40
+ });
41
+ });
42
+
43
+ it('does not replace when only the casing changed', () => {
44
+ // `Api` and `API` are the same `appName`, so there is nothing to destroy —
45
+ // and destroying an application to restyle its name in the UI would be a
46
+ // spectacular way to lose one.
47
+ expect(diffApplication(base, { ...base, name: 'API' })).toMatchObject({
48
+ replaces: [],
49
+ changes: true,
50
+ });
51
+ });
52
+
53
+ it('replaces on a move between projects or environments', () => {
54
+ expect(
55
+ diffApplication(base, { ...base, projectId: 'proj-2' }).replaces,
56
+ ).toEqual(['projectId']);
57
+ expect(
58
+ diffApplication(base, { ...base, environmentId: 'env-2' }).replaces,
59
+ ).toEqual(['environmentId']);
60
+ });
61
+
62
+ it('creates the replacement before removing the old one', () => {
63
+ // A Dokploy project can hold both for a moment; it cannot hold a gap
64
+ // where the application used to be.
65
+ expect(
66
+ diffApplication(base, { ...base, name: 'Gateway' }).deleteBeforeReplace,
67
+ ).toBe(false);
68
+ });
69
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Dokploy as Pulumi resources — a prototype.
3
+ *
4
+ * The question it exists to answer: is wrapping Dokploy's REST API in a dynamic
5
+ * provider worth doing for the rest of the resources? If it is, `deploy/state.ts`
6
+ * and `SSMStateProvider` stop being necessary — remembering what you created is
7
+ * the whole job of a state file — and `--target=server` becomes another
8
+ * provisioner table rather than a deployment engine written by hand.
9
+ *
10
+ * One resource so far, and nothing has run against a real server.
11
+ */
12
+
13
+ export {
14
+ Application,
15
+ type ApplicationArgs,
16
+ appNameFor,
17
+ diffApplication,
18
+ } from './Application';
@@ -14,7 +14,10 @@ const stack = new App({
14
14
  }).stack('api');
15
15
 
16
16
  const db: GkmLinkable = { _id: 'db', _type: ResourceType.Postgres };
17
- const uploads: GkmLinkable = { _id: 'uploads', _type: ResourceType.Bucket };
17
+ const uploads: GkmLinkable = {
18
+ _id: 'uploads',
19
+ _type: ResourceType.ObjectStorage,
20
+ };
18
21
 
19
22
  describe('LinkedEnvironment', () => {
20
23
  describe('createBaseEnvironment', () => {
@@ -0,0 +1,249 @@
1
+ import type { ConstructManifest } from '@geekmidas/manifest';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ CacheIsAmbiguous,
5
+ CacheNeedsDatabase,
6
+ CacheNeedsProvider,
7
+ CacheNeedsVpc,
8
+ } from '../aws/Cache';
9
+ import { EmailNeedsSender, EmailNeedsUrl } from '../aws/Email';
10
+ import { type ProvisionContext, provisionerFor } from '../fromManifest';
11
+
12
+ const stack = {} as never;
13
+
14
+ const manifest = {
15
+ Sessions: { kind: 'cache', id: 'Sessions', provides: ['SESSIONS_URL'] },
16
+ Mail: { kind: 'email', id: 'Mail', provides: ['MAIL_URL'] },
17
+ Orders: { kind: 'database', id: 'Orders', provides: ['ORDERS_URL'] },
18
+ } as const satisfies ConstructManifest;
19
+
20
+ const context = (
21
+ overrides: Partial<ProvisionContext> = {},
22
+ ): ProvisionContext => ({
23
+ manifest,
24
+ provisioned: {},
25
+ bootstraps: new Map(),
26
+ ...overrides,
27
+ });
28
+
29
+ const provided = (values: Record<string, string>) =>
30
+ ({ provides: () => values }) as never;
31
+
32
+ describe('cache', () => {
33
+ it('resolves the declared database for the db backend', () => {
34
+ // No second address and no second credential — which is the whole reason
35
+ // this backend costs nothing to run.
36
+ const cache = provisionerFor('cache')(
37
+ stack,
38
+ manifest.Sessions,
39
+ {},
40
+ context({
41
+ cache: 'db',
42
+ provisioned: { Orders: provided({ url: 'postgres://app@db/orders' }) },
43
+ }),
44
+ );
45
+
46
+ // The table travels in the URL: two caches in one database resolve the
47
+ // same connection string, so it is the only thing that says which one a
48
+ // client is holding.
49
+ expect(cache.provides().url).toBe(
50
+ 'postgres://app@db/orders?table=cache_sessions',
51
+ );
52
+ });
53
+
54
+ it('gives two caches in one database a table each', () => {
55
+ // Sharing a table would mean sharing a keyspace: each would read the
56
+ // other's entries and evict the other's keys.
57
+ const twoCaches = {
58
+ ...manifest,
59
+ Rates: {
60
+ kind: 'cache',
61
+ id: 'Rates',
62
+ of: 'Orders',
63
+ provides: ['RATES_URL'],
64
+ },
65
+ } as const satisfies ConstructManifest;
66
+
67
+ const urlOf = (id: 'Sessions' | 'Rates') =>
68
+ provisionerFor('cache')(
69
+ stack,
70
+ twoCaches[id],
71
+ {},
72
+ context({
73
+ cache: 'db',
74
+ manifest: twoCaches,
75
+ provisioned: {
76
+ Orders: provided({ url: 'postgres://app@db/orders' }),
77
+ },
78
+ }),
79
+ ).provides().url;
80
+
81
+ expect(urlOf('Sessions')).not.toBe(urlOf('Rates'));
82
+ expect(urlOf('Rates')).toContain('table=cache_rates');
83
+ });
84
+
85
+ it('refuses to guess which database, rather than picking the first', () => {
86
+ const twoDatabases = {
87
+ ...manifest,
88
+ Reports: { kind: 'database', id: 'Reports', provides: ['REPORTS_URL'] },
89
+ } as const satisfies ConstructManifest;
90
+
91
+ expect(() =>
92
+ provisionerFor('cache')(
93
+ stack,
94
+ twoDatabases.Sessions,
95
+ {},
96
+ context({ cache: 'db', manifest: twoDatabases }),
97
+ ),
98
+ ).toThrow(CacheIsAmbiguous);
99
+ });
100
+
101
+ it('refuses a database-backed cache with no database', () => {
102
+ expect(() =>
103
+ provisionerFor('cache')(
104
+ stack,
105
+ manifest.Sessions,
106
+ {},
107
+ context({ cache: 'db' }),
108
+ ),
109
+ ).toThrow(CacheNeedsDatabase);
110
+ });
111
+
112
+ it('names the command when Upstash’s provider is not installed', () => {
113
+ // SST preloads two providers and installs the rest on demand, so the
114
+ // global this reaches for is absent until somebody runs the command.
115
+ // Naming it beats an undefined-name crash halfway through a synth.
116
+ expect(() =>
117
+ provisionerFor('cache')(stack, manifest.Sessions, {}, context()),
118
+ ).toThrow(CacheNeedsProvider);
119
+ });
120
+
121
+ it('takes a URL instead, for a database that already exists', () => {
122
+ const cache = provisionerFor('cache')(
123
+ stack,
124
+ manifest.Sessions,
125
+ { url: 'https://:token@eu1.upstash.io' },
126
+ context(),
127
+ );
128
+
129
+ expect(cache.provides().url).toBe('https://:token@eu1.upstash.io');
130
+ });
131
+
132
+ it('refuses an ElastiCache cache with no VPC', () => {
133
+ // A cache in a VPC is reachable from functions in that VPC and from
134
+ // nowhere else, so the VPC is the choice rather than a detail.
135
+ expect(() =>
136
+ provisionerFor('cache')(
137
+ stack,
138
+ manifest.Sessions,
139
+ {},
140
+ context({ cache: 'elasticache' }),
141
+ ),
142
+ ).toThrow(CacheNeedsVpc);
143
+ });
144
+ });
145
+
146
+ describe('email', () => {
147
+ it('passes a supplied URL straight through for resend', () => {
148
+ // Nothing to provision: Resend is an API key you hold, and the URL it
149
+ // composes into is the same smtp:// shape every other backend produces.
150
+ const email = provisionerFor('email')(
151
+ stack,
152
+ manifest.Mail,
153
+ { url: 'smtp://resend:re_xxx@smtp.resend.com:587', from: 'a@b.com' },
154
+ context({ email: 'resend' }),
155
+ );
156
+
157
+ expect(email.provides().url).toBe(
158
+ 'smtp://resend:re_xxx@smtp.resend.com:587',
159
+ );
160
+ });
161
+
162
+ it('derives its own credential for ses when given none', () => {
163
+ // The only backend that is a chain rather than a value: a user, a key,
164
+ // and a password computed from it.
165
+ const email = provisionerFor('email')(
166
+ stack,
167
+ manifest.Mail,
168
+ { from: 'a@b.com' },
169
+ context({ email: 'ses' }),
170
+ );
171
+
172
+ expect(email.provides().url).toMatch(
173
+ /^smtp:\/\/.+:.+@email-smtp\.eu-west-1\.amazonaws\.com:587$/,
174
+ );
175
+ });
176
+
177
+ it('uses credentials that already exist rather than making more', () => {
178
+ // The common case: a sending identity set up once, by hand. Creating a
179
+ // second IAM user for it would be a deploy quietly adding another way
180
+ // into the account.
181
+ const email = provisionerFor('email')(
182
+ stack,
183
+ manifest.Mail,
184
+ {
185
+ url: 'smtp://AKIAOLD:pw@email-smtp.eu-west-1.amazonaws.com:587',
186
+ from: 'a@b.com',
187
+ },
188
+ context({ email: 'ses' }),
189
+ );
190
+
191
+ expect(email.provides().url).toBe(
192
+ 'smtp://AKIAOLD:pw@email-smtp.eu-west-1.amazonaws.com:587',
193
+ );
194
+ });
195
+
196
+ it('refuses a backend that cannot mint its own and was given none', () => {
197
+ // A sender alone is not enough for Resend: it is an account somebody
198
+ // created, so there is nothing for a deploy to provision and the URL is
199
+ // a missing setup step rather than something to default.
200
+ expect(() =>
201
+ provisionerFor('email')(
202
+ stack,
203
+ manifest.Mail,
204
+ { from: 'a@b.com' },
205
+ context({ email: 'resend' }),
206
+ ),
207
+ ).toThrow(EmailNeedsUrl);
208
+ });
209
+
210
+ it('sends through SES when nothing said otherwise', () => {
211
+ // The default, because it is what this repo's projects actually use.
212
+ const email = provisionerFor('email')(
213
+ stack,
214
+ manifest.Mail,
215
+ { from: 'a@b.com' },
216
+ context(),
217
+ );
218
+
219
+ expect(email.provides().url).toContain('email-smtp.');
220
+ });
221
+
222
+ it('produces an smtp:// URL whichever backend it is', () => {
223
+ // The property that lets one client serve all of them, and the reason the
224
+ // declaration names no provider.
225
+ const backends = ['resend', 'smtp'] as const;
226
+
227
+ for (const backend of backends) {
228
+ const provisioned = provisionerFor('email')(
229
+ stack,
230
+ manifest.Mail,
231
+ { url: 'smtp://user:pw@relay.example.com:587', from: 'a@b.com' },
232
+ context({ email: backend }),
233
+ );
234
+
235
+ expect(String(provisioned.provides().url).startsWith('smtp://')).toBe(
236
+ true,
237
+ );
238
+ }
239
+ });
240
+
241
+ it('refuses to invent a sending identity', () => {
242
+ // Every provider rejects an unverified sender, so a guess would deploy
243
+ // cleanly and fail at the first send — which is the worst place to
244
+ // discover it.
245
+ expect(() =>
246
+ provisionerFor('email')(stack, manifest.Mail, {}, context()),
247
+ ).toThrow(EmailNeedsSender);
248
+ });
249
+ });
@@ -0,0 +1,140 @@
1
+ import { roleStatements } from '@geekmidas/db/pg/roles';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { Database } from '../aws/Database';
4
+ import { bootstrapEvent, DatabaseBootstrap } from '../aws/DatabaseBootstrap';
5
+
6
+ const stack = {} as never;
7
+ const vpc = { subnets: ['subnet-1'], securityGroups: ['sg-1'] } as never;
8
+
9
+ const cluster = () => new Database(stack, 'Orders', { vpc });
10
+
11
+ const bootstrapWith = (
12
+ tenants: { id: string; schema: string; runtime: string; owner: string }[],
13
+ ) => {
14
+ const bootstrap = new DatabaseBootstrap('Orders', cluster());
15
+ for (const tenant of tenants) bootstrap.add(tenant);
16
+
17
+ return bootstrap;
18
+ };
19
+
20
+ describe('DatabaseBootstrap', () => {
21
+ it('does nothing when nothing needs bootstrapping', () => {
22
+ expect(new DatabaseBootstrap('Orders', cluster()).empty).toBe(true);
23
+ });
24
+
25
+ it('creates nothing merely by registering a tenant', () => {
26
+ // The reason one secret is enough: nothing reads it at runtime. A node
27
+ // publishes only its own URL through its link, so a handler is *given* its
28
+ // role and has no IAM to read Secrets Manager at all. The secret is for
29
+ // out-of-band use and is written once, by `run()`.
30
+ const bootstrap = bootstrapWith([
31
+ {
32
+ id: 'AuthDb',
33
+ schema: 'authdb',
34
+ runtime: 'authdb',
35
+ owner: 'authdb_owner',
36
+ },
37
+ { id: 'Jobs', schema: 'jobs', runtime: 'jobs', owner: 'jobs_owner' },
38
+ ]);
39
+
40
+ expect(bootstrap.secret).toBeUndefined();
41
+ expect(bootstrap.empty).toBe(false);
42
+ });
43
+
44
+ it('has no reader credential unless a reader was asked for', () => {
45
+ const bootstrap = bootstrapWith([
46
+ {
47
+ id: 'AuthDb',
48
+ schema: 'authdb',
49
+ runtime: 'authdb',
50
+ owner: 'authdb_owner',
51
+ },
52
+ ]);
53
+
54
+ expect(bootstrap.readerFor('authdb')).toBeUndefined();
55
+ });
56
+
57
+ it('has one when it was', () => {
58
+ const bootstrap = new DatabaseBootstrap('Orders', cluster());
59
+ bootstrap.add({
60
+ id: 'AuthDb',
61
+ schema: 'authdb',
62
+ runtime: 'authdb',
63
+ owner: 'authdb_owner',
64
+ reader: 'authdb_reader',
65
+ });
66
+
67
+ expect(bootstrap.readerFor('authdb')?.user).toBe('authdb_reader');
68
+ });
69
+ });
70
+
71
+ describe('bootstrapEvent', () => {
72
+ const master = {
73
+ host: 'db.stub.rds.amazonaws.com',
74
+ port: 5432,
75
+ database: 'orders',
76
+ username: 'postgres',
77
+ password: 'master-pw',
78
+ };
79
+
80
+ const tenant = {
81
+ id: 'AuthDb',
82
+ schema: 'authdb',
83
+ runtime: 'authdb',
84
+ owner: 'authdb_owner',
85
+ passwords: { runtime: '', owner: '' },
86
+ };
87
+
88
+ const passwords = new Map([
89
+ ['authdb', 'runtime-pw'],
90
+ ['authdb_owner', 'owner-pw'],
91
+ ]);
92
+
93
+ it('carries the master credential, which is the only one that exists first', () => {
94
+ const event = JSON.parse(bootstrapEvent(master, [tenant], passwords));
95
+
96
+ expect(event.master.username).toBe('postgres');
97
+ });
98
+
99
+ it('gives every role a password of its own', () => {
100
+ const event = JSON.parse(bootstrapEvent(master, [tenant], passwords));
101
+
102
+ expect(event.tenants[0].passwords).toEqual({
103
+ runtime: 'runtime-pw',
104
+ owner: 'owner-pw',
105
+ });
106
+ });
107
+
108
+ it('produces exactly what the handler’s generator accepts', () => {
109
+ // The contract between the two halves. A change to one is a type error in
110
+ // the other, and this asserts the runtime shape agrees too.
111
+ const event = JSON.parse(bootstrapEvent(master, [tenant], passwords));
112
+
113
+ expect(roleStatements(event.tenants[0]).length).toBeGreaterThan(0);
114
+ });
115
+
116
+ it('carries a reader password only where a reader was named', () => {
117
+ const withReader = { ...tenant, reader: 'authdb_reader' };
118
+ const event = JSON.parse(
119
+ bootstrapEvent(
120
+ master,
121
+ [withReader],
122
+ new Map([...passwords, ['authdb_reader', 'reader-pw']]),
123
+ ),
124
+ );
125
+
126
+ expect(event.tenants[0].passwords.reader).toBe('reader-pw');
127
+ expect(
128
+ JSON.parse(bootstrapEvent(master, [tenant], passwords)).tenants[0]
129
+ .passwords,
130
+ ).not.toHaveProperty('reader');
131
+ });
132
+
133
+ it('is stable, so a deploy that changed nothing re-runs nothing', () => {
134
+ // The invocation keys off this input; an unstable one re-applies the DDL
135
+ // on every deploy.
136
+ expect(bootstrapEvent(master, [tenant], passwords)).toBe(
137
+ bootstrapEvent(master, [tenant], passwords),
138
+ );
139
+ });
140
+ });
@@ -0,0 +1,119 @@
1
+ import type { ConstructManifest } from '@geekmidas/manifest';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { Database, DatabaseNeedsVpc } from '../aws/Database';
4
+ import { DatabaseReader, DatabaseSchema } from '../aws/DerivedDatabase';
5
+ import { type ProvisionContext, provisionerFor } from '../fromManifest';
6
+
7
+ const stack = {} as never;
8
+ const vpc = { id: 'stub-vpc' } as never;
9
+
10
+ const manifest = {
11
+ Orders: {
12
+ kind: 'database',
13
+ id: 'Orders',
14
+ engine: 'postgres',
15
+ schema: 'app',
16
+ provides: ['ORDERS_URL'],
17
+ },
18
+ OrdersReader: {
19
+ kind: 'database-reader',
20
+ id: 'OrdersReader',
21
+ of: 'Orders',
22
+ provides: ['ORDERS_READER_URL'],
23
+ },
24
+ AuthDb: {
25
+ kind: 'database-schema',
26
+ id: 'AuthDb',
27
+ of: 'Orders',
28
+ schema: 'authdb',
29
+ provides: ['AUTH_DB_URL'],
30
+ },
31
+ } as const satisfies ConstructManifest;
32
+
33
+ const cluster = () => new Database(stack, 'Orders', { vpc, schema: 'app' });
34
+
35
+ const context = (provisioned: Record<string, unknown>): ProvisionContext => ({
36
+ manifest,
37
+ provisioned: provisioned as never,
38
+ bootstraps: new Map(),
39
+ });
40
+
41
+ describe('Database', () => {
42
+ it('composes one URL, the runtime role’s', () => {
43
+ expect(Object.keys(cluster().provides())).toEqual(['url']);
44
+ });
45
+
46
+ it('puts search_path in as a libpq option, not a query parameter', () => {
47
+ // A plain `?search_path=` is accepted by every URL parser, ignored by the
48
+ // server, and produces a database that looks empty.
49
+ const url = cluster().provides().url as string;
50
+
51
+ expect(url).toContain('options=-c+search_path%3Dapp');
52
+ expect(url).not.toMatch(/[?&]search_path=/);
53
+ });
54
+
55
+ it('refuses to invent a VPC', () => {
56
+ // Creating one means creating a NAT gateway, which costs money in an
57
+ // account whose networking may already be someone else's decision.
58
+ expect(() =>
59
+ provisionerFor('database')(stack, manifest.Orders, {}, context({})),
60
+ ).toThrow(DatabaseNeedsVpc);
61
+ });
62
+ });
63
+
64
+ describe('DatabaseReader', () => {
65
+ it('points at the one endpoint the instance has', () => {
66
+ // Nobody provisions a replica, and an RDS instance has no second address:
67
+ // a reader resolves to the writer's endpoint. Safe because read-only is
68
+ // enforced by the role's grants, never by which host was reached.
69
+ const url = new DatabaseReader('OrdersReader', cluster()).provides()
70
+ .url as string;
71
+
72
+ expect(url).toContain('@db.stub.rds.amazonaws.com');
73
+ expect(url).not.toContain('db-ro.');
74
+ });
75
+
76
+ it('is reachable through the provisioner, from its parent', () => {
77
+ const reader = provisionerFor('database-reader')(
78
+ stack,
79
+ manifest.OrdersReader,
80
+ {},
81
+ context({ Orders: cluster() }),
82
+ );
83
+
84
+ expect(reader.provides().url).toContain('@db.stub.rds.amazonaws.com');
85
+ });
86
+ });
87
+
88
+ describe('DatabaseSchema', () => {
89
+ it('is the parent’s connection on a different search_path', () => {
90
+ const url = new DatabaseSchema('AuthDb', cluster(), 'authdb').provides()
91
+ .url as string;
92
+
93
+ expect(url).toContain('options=-c+search_path%3Dauthdb');
94
+ // The same host: a tenant is a schema inside the parent's database, not a
95
+ // database of its own.
96
+ expect(url).toContain('db.stub.rds.amazonaws.com');
97
+ });
98
+
99
+ it('resolves through a chain of derived nodes to the cluster', () => {
100
+ const tenant = provisionerFor('database-schema')(
101
+ stack,
102
+ manifest.AuthDb,
103
+ {},
104
+ context({ Orders: cluster() }),
105
+ );
106
+
107
+ // A reader on a tenant walks up two links to reach the cluster.
108
+ const readerOnTenant = provisionerFor('database-reader')(
109
+ stack,
110
+ { kind: 'database-reader', id: 'AuthDbReader', of: 'AuthDb' },
111
+ {},
112
+ context({ Orders: cluster(), AuthDb: tenant }),
113
+ );
114
+
115
+ expect(readerOnTenant.provides().url).toContain(
116
+ '@db.stub.rds.amazonaws.com',
117
+ );
118
+ });
119
+ });