@geekmidas/cloud 10.0.0-alpha.13 → 10.0.0-alpha.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cloud",
3
- "version": "10.0.0-alpha.13",
3
+ "version": "10.0.0-alpha.15",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "lodash.snakecase": "~4.1.1",
44
- "@geekmidas/envkit": "10.0.0-alpha.13",
45
- "@geekmidas/manifest": "10.0.0-alpha.13"
44
+ "@geekmidas/envkit": "10.0.0-alpha.15",
45
+ "@geekmidas/manifest": "10.0.0-alpha.15"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/lodash.get": "~4.4.9",
@@ -58,10 +58,10 @@
58
58
  "@pulumi/aws": ">=7.47.0",
59
59
  "@pulumi/random": ">=4.21.2",
60
60
  "@pulumi/pulumi": ">=3.263.0",
61
- "@geekmidas/manifest": "^10.0.0-alpha.13",
62
- "@geekmidas/storage": "^10.0.0-alpha.13",
63
- "@geekmidas/events": "^10.0.0-alpha.13",
64
- "@geekmidas/db": "^10.0.0-alpha.13"
61
+ "@geekmidas/manifest": "^10.0.0-alpha.15",
62
+ "@geekmidas/storage": "^10.0.0-alpha.15",
63
+ "@geekmidas/events": "^10.0.0-alpha.15",
64
+ "@geekmidas/db": "^10.0.0-alpha.15"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@geekmidas/manifest": {
@@ -195,6 +195,45 @@ const provider: dynamic.ResourceProvider<
195
195
  },
196
196
  };
197
197
 
198
+ /**
199
+ * A Dokploy API call that answered with an error status.
200
+ *
201
+ * Defined here, beside the provider, rather than imported: the provider's
202
+ * functions are serialised into state, and Pulumi's closure serialiser
203
+ * captures a class declared in the same module by value — once, shared by
204
+ * every function that refers to it, so `instanceof` still holds in the
205
+ * deployed provider. An imported class would have to be resolvable as a
206
+ * module where the provider runs, which is the obscure failure the note on
207
+ * `call` below is about.
208
+ *
209
+ * Serialisation rewrites the class into a plain function and drops its link to
210
+ * `Error.prototype`, so the constructor sets `message` and `stack` itself and
211
+ * the class says how to print itself. `Application.provider.spec.ts` runs the
212
+ * serialised provider to keep all of this true.
213
+ */
214
+ export class DokployCallFailed extends Error {
215
+ constructor(
216
+ readonly path: string,
217
+ readonly status: number,
218
+ readonly statusText: string,
219
+ readonly detail: string,
220
+ ) {
221
+ const message = `Dokploy ${path} failed: ${status} ${statusText}${detail ? ` — ${detail}` : ''}. Check the endpoint and API token, and Dokploy's own logs for the request.`;
222
+ super(message);
223
+ // Serialised, this constructor becomes a plain function whose super call
224
+ // does not initialise `this`: without these the provider host, which
225
+ // reports `e.message` and logs `${e}: ${e.stack}`, would show nothing.
226
+ this.message = message;
227
+ Error.captureStackTrace?.(this, DokployCallFailed);
228
+ this.name = 'DokployCallFailed';
229
+ }
230
+
231
+ /** What `${error}` prints once serialisation has dropped Error's own. */
232
+ override toString(): string {
233
+ return `${this.name}: ${this.message}`;
234
+ }
235
+ }
236
+
198
237
  /**
199
238
  * One call to Dokploy.
200
239
  *
@@ -218,10 +257,11 @@ async function call<T>(
218
257
 
219
258
  if (!response.ok) {
220
259
  const detail = await response.text().catch(() => '');
221
- throw new Error(
222
- `Dokploy ${path} failed: ${response.status} ${response.statusText}${
223
- detail ? ` — ${detail}` : ''
224
- }`,
260
+ throw new DokployCallFailed(
261
+ path,
262
+ response.status,
263
+ response.statusText,
264
+ detail,
225
265
  );
226
266
  }
227
267
 
@@ -230,7 +270,7 @@ async function call<T>(
230
270
 
231
271
  /** Whether an error means the thing is already absent. */
232
272
  function isNotFound(error: unknown): boolean {
233
- return error instanceof Error && /\b404\b/.test(error.message);
273
+ return error instanceof DokployCallFailed && error.status === 404;
234
274
  }
235
275
 
236
276
  export interface ApplicationArgs {
@@ -1,7 +1,12 @@
1
+ import { runInNewContext } from 'node:vm';
2
+ import { runtime } from '@pulumi/pulumi';
1
3
  import { HttpResponse, http } from 'msw';
2
4
  import { setupServer } from 'msw/node';
3
5
  import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
4
- import { applicationProvider as provider } from '../Application';
6
+ import {
7
+ DokployCallFailed,
8
+ applicationProvider as provider,
9
+ } from '../Application';
5
10
 
6
11
  /**
7
12
  * The provider against Dokploy's tRPC API, served by MSW: what each lifecycle
@@ -132,8 +137,13 @@ describe('Dokploy application provider', () => {
132
137
  }),
133
138
  );
134
139
  await expect(provider.delete!('app-1', outs)).rejects.toThrow(
135
- 'Dokploy application.remove failed: 500 Internal Server Error — database is locked',
140
+ DokployCallFailed,
136
141
  );
142
+ await expect(provider.delete!('app-1', outs)).rejects.toMatchObject({
143
+ path: 'application.remove',
144
+ status: 500,
145
+ detail: 'database is locked',
146
+ });
137
147
 
138
148
  server.resetHandlers();
139
149
  answer(
@@ -141,8 +151,56 @@ describe('Dokploy application provider', () => {
141
151
  'application.create',
142
152
  () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }),
143
153
  );
144
- await expect(provider.create!(inputs)).rejects.toThrow(
145
- 'Dokploy application.create failed: 401 Unauthorized',
154
+ await expect(provider.create!(inputs)).rejects.toMatchObject({
155
+ name: 'DokployCallFailed',
156
+ status: 401,
157
+ });
158
+ });
159
+
160
+ /**
161
+ * What actually runs: Pulumi serialises the provider into state and the
162
+ * deploy runs that text, not this module. Serialisation rewrites
163
+ * `DokployCallFailed` into a plain function that drops `Error.prototype` and
164
+ * never initialises `message` — which the provider host reports — so this
165
+ * runs the serialised provider and checks the error still says something.
166
+ */
167
+ it('still recognises a 404, and reports a failure, once serialised', async () => {
168
+ const serialised = await runtime.serializeFunction(() => provider, {
169
+ isFactoryFunction: true,
170
+ });
171
+ const module = { exports: {} as Record<string, typeof provider> };
172
+ runInNewContext(serialised.text, {
173
+ module,
174
+ exports: module.exports,
175
+ require,
176
+ global: globalThis,
177
+ fetch: (...args: Parameters<typeof fetch>) => fetch(...args),
178
+ Error,
179
+ });
180
+ const deployed = module.exports[serialised.exportName]!;
181
+
182
+ answer('post', 'application.remove', () =>
183
+ HttpResponse.json({}, { status: 404, statusText: 'Not Found' }),
184
+ );
185
+ await expect(deployed.delete!('app-1', outs)).resolves.toBeUndefined();
186
+
187
+ server.resetHandlers();
188
+ answer('post', 'application.remove', () =>
189
+ HttpResponse.text('database is locked', {
190
+ status: 500,
191
+ statusText: 'Internal Server Error',
192
+ }),
193
+ );
194
+ const failure = await deployed.delete!('app-1', outs).catch(
195
+ (error: unknown) => error as Error & { status: number },
196
+ );
197
+ expect(failure).toMatchObject({ name: 'DokployCallFailed', status: 500 });
198
+ expect(failure?.message).toContain(
199
+ 'Dokploy application.remove failed: 500 Internal Server Error — database is locked',
200
+ );
201
+ expect(failure?.stack).toBeTruthy();
202
+ expect(`${failure}`).toMatch(
203
+ /^DokployCallFailed: Dokploy application\.remove failed/,
146
204
  );
147
205
  });
148
206
  });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { App } from '../App';
3
- import { Api } from '../aws/Api';
3
+ import { Api, RoutesMissingEnvironment } from '../aws/Api';
4
4
  import { type GkmLinkable, ResourceType } from '../Linkable';
5
5
 
6
6
  /**
@@ -167,6 +167,7 @@ describe('Api', () => {
167
167
  ],
168
168
  });
169
169
 
170
+ expect(build).toThrow(RoutesMissingEnvironment);
170
171
  expect(build).toThrow(/Http POST \/pay[\s\S]*STRIPE_KEY/);
171
172
  expect(build).toThrow(/Http GET \/mail[\s\S]*SMTP_URL/);
172
173
  });
@@ -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
+ }