@samebase/alchemy-convex 0.3.0 → 0.4.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/NOTICE +24 -0
- package/README.md +128 -280
- package/lib/index.d.ts +159 -327
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +754 -905
- package/lib/index.js.map +1 -1
- package/package.json +4 -3
- package/src/Credentials.ts +85 -27
- package/src/Deployment.ts +461 -122
- package/src/ManagementApi.ts +84 -64
- package/src/Project.ts +133 -16
- package/src/Providers.ts +5 -41
- package/src/Push.ts +646 -0
- package/src/index.ts +15 -47
- package/src/Code.ts +0 -463
- package/src/DefaultEnvironmentVariable.ts +0 -263
- package/src/Deploy.ts +0 -183
- package/src/DeployKey.ts +0 -332
- package/src/EnvironmentVariable.ts +0 -289
- package/src/PreviewDeployKey.ts +0 -153
package/src/Deployment.ts
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
// Convex.Deployment: one deployment of a Convex project,
|
|
2
|
-
// frontend or a Worker
|
|
1
|
+
// Convex.Deployment: one deployment of a Convex project, its environment
|
|
2
|
+
// variables, and its functions, with the URLs that a frontend or a Worker
|
|
3
|
+
// needs. This is the Convex plane of a stack: no deploy key leaves it.
|
|
3
4
|
//
|
|
5
|
+
// - `source` is the Convex functions directory, such as `./convex`. The
|
|
6
|
+
// update pushes it with the Convex CLI that the app installed (Push.ts).
|
|
7
|
+
// The content identity of the push is a hash of the source files, the
|
|
8
|
+
// app's package.json, convex.json, and lockfile, the deployment name, and
|
|
9
|
+
// `env`. The same hash is a no-op. The hash is taken again after the push,
|
|
10
|
+
// so files that `convex deploy` generates are part of it.
|
|
11
|
+
// `source.command` replaces `convex deploy`, such as `convex-hosting
|
|
12
|
+
// deploy`, and `source.include` adds the files that it reads to the hash.
|
|
13
|
+
// - `env` is set on the deployment before the push. A name that leaves `env`
|
|
14
|
+
// is removed. A variable that exists with another value and that this
|
|
15
|
+
// resource did not set is someone else's: it is overwritten only with
|
|
16
|
+
// --adopt. Delete leaves the variables: they go with the deployment.
|
|
17
|
+
// - Credentials by scope (see Access below): a deploy key of the deployment,
|
|
18
|
+
// a preview deploy key of the project, or the Management API token.
|
|
4
19
|
// - `prod` is the project's default production deployment. Convex creates it
|
|
5
20
|
// with the project, so this resource never creates one. It takes the
|
|
6
21
|
// deployment over without --adopt: it never changes or deletes a production
|
|
7
22
|
// deployment, so the takeover cannot lose data. It follows the default:
|
|
8
23
|
// when another production deployment becomes the default, the next deploy
|
|
9
|
-
// outputs that one.
|
|
24
|
+
// outputs that one. With a deploy key, `prod` is the production deployment
|
|
25
|
+
// of that key. To delete a production deployment, delete its project
|
|
10
26
|
// (Convex.Project with RemovalPolicy.destroy()). Delete reads the live type
|
|
11
27
|
// first, so a dev or preview deployment that became a production
|
|
12
28
|
// deployment is not deleted either.
|
|
@@ -28,37 +44,67 @@
|
|
|
28
44
|
// preview deployment with the resource, add RemovalPolicy.destroy().
|
|
29
45
|
//
|
|
30
46
|
// Patterns from Confect `packages/alchemy/src/Deployment.ts`
|
|
31
|
-
// (https://github.com/rjdellecese/confect, ISC license).
|
|
47
|
+
// (https://github.com/rjdellecese/confect, ISC license, see NOTICE).
|
|
32
48
|
import { Resource } from "alchemy";
|
|
33
49
|
import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
|
|
34
50
|
import { isResolved } from "alchemy/Diff";
|
|
35
51
|
import * as Provider from "alchemy/Provider";
|
|
36
52
|
import * as Effect from "effect/Effect";
|
|
53
|
+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
54
|
+
import * as Redacted from "effect/Redacted";
|
|
37
55
|
import * as Schema from "effect/Schema";
|
|
38
56
|
import { shouldAdopt } from "./Adoption.ts";
|
|
39
|
-
import {
|
|
57
|
+
import { Credentials } from "./Credentials.ts";
|
|
58
|
+
import {
|
|
59
|
+
absentAsUndefined,
|
|
60
|
+
type ConvexApiError,
|
|
61
|
+
ManagementApi,
|
|
62
|
+
type ManagementApiService,
|
|
63
|
+
retryIdempotentWrite,
|
|
64
|
+
} from "./ManagementApi.ts";
|
|
40
65
|
import type { Providers } from "./Providers.ts";
|
|
66
|
+
import {
|
|
67
|
+
checkSource,
|
|
68
|
+
EnvironmentNeedsDeployKey,
|
|
69
|
+
type EnvValue,
|
|
70
|
+
keyTargetOf,
|
|
71
|
+
push,
|
|
72
|
+
pushHash,
|
|
73
|
+
type Source,
|
|
74
|
+
withPushKey,
|
|
75
|
+
} from "./Push.ts";
|
|
76
|
+
|
|
77
|
+
interface DeploymentInputs {
|
|
78
|
+
/** The project: a Convex.Project resource, or `{ projectId }`. Cannot change. */
|
|
79
|
+
readonly project: { readonly projectId: number };
|
|
80
|
+
/**
|
|
81
|
+
* The Convex functions directory, such as `./convex`, or that directory
|
|
82
|
+
* with a push command and more hashed files. The push runs in the nearest
|
|
83
|
+
* directory above it with a package.json. Without `source`, the resource
|
|
84
|
+
* pushes nothing.
|
|
85
|
+
*/
|
|
86
|
+
readonly source?: Source;
|
|
87
|
+
/** Environment variables to set on the deployment before the push. */
|
|
88
|
+
readonly env?: Readonly<Record<string, EnvValue>>;
|
|
89
|
+
}
|
|
41
90
|
|
|
42
91
|
export type DeploymentProps =
|
|
43
|
-
| {
|
|
44
|
-
/** Numeric project id, such as `project.projectId`. Cannot change. */
|
|
45
|
-
readonly projectId: number;
|
|
92
|
+
| (DeploymentInputs & {
|
|
46
93
|
/** The project's default production deployment. Cannot change. */
|
|
47
94
|
readonly type: "prod";
|
|
48
|
-
}
|
|
49
|
-
| {
|
|
50
|
-
/** Numeric project id, such as `project.projectId`. Cannot change. */
|
|
51
|
-
readonly projectId: number;
|
|
95
|
+
})
|
|
96
|
+
| (DeploymentInputs & {
|
|
52
97
|
/** A dev deployment or a preview deployment that this resource creates. Cannot change. */
|
|
53
98
|
readonly type: "dev" | "preview";
|
|
54
99
|
/**
|
|
55
|
-
* The dev reference, such as `staging`, or the preview name, such as
|
|
56
|
-
*
|
|
100
|
+
* The dev reference, such as `staging`, or the preview name, such as
|
|
101
|
+
* the stage name. Unique in the project. Cannot change.
|
|
57
102
|
*/
|
|
58
103
|
readonly name: string;
|
|
59
|
-
};
|
|
104
|
+
});
|
|
60
105
|
|
|
61
|
-
|
|
106
|
+
/** The deployment itself, without the state of the push. */
|
|
107
|
+
interface DeploymentTarget {
|
|
62
108
|
readonly projectId: number;
|
|
63
109
|
readonly type: DeploymentProps["type"];
|
|
64
110
|
/** Deployment name, such as `happy-animal-123`. */
|
|
@@ -73,6 +119,13 @@ export interface DeploymentAttributes {
|
|
|
73
119
|
readonly siteUrl: string;
|
|
74
120
|
}
|
|
75
121
|
|
|
122
|
+
export interface DeploymentAttributes extends DeploymentTarget {
|
|
123
|
+
/** Content identity of the last push and `env`. Undefined when the resource pushed nothing. */
|
|
124
|
+
readonly hash: string | undefined;
|
|
125
|
+
/** The names of the `env` variables that this resource set on the deployment, sorted. */
|
|
126
|
+
readonly envNames: readonly string[];
|
|
127
|
+
}
|
|
128
|
+
|
|
76
129
|
export type Deployment = Resource<
|
|
77
130
|
"Convex.Deployment",
|
|
78
131
|
DeploymentProps,
|
|
@@ -149,15 +202,31 @@ export const DeploymentRow = Schema.Struct({
|
|
|
149
202
|
/** Set in list_deployments and create_deployment rows only: GET /deployments/{name} answers null. */
|
|
150
203
|
previewIdentifier: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
151
204
|
deploymentUrl: Schema.String.check(Schema.isPattern(CLOUD_URL)),
|
|
205
|
+
/** Absent or null until the first push: list_deployments leaves it out for a new deployment. */
|
|
206
|
+
lastDeployTime: Schema.optionalKey(Schema.NullOr(Schema.Number)),
|
|
152
207
|
});
|
|
153
208
|
type DeploymentRow = typeof DeploymentRow.Type;
|
|
154
209
|
const DeploymentRows = Schema.Array(DeploymentRow);
|
|
155
210
|
|
|
156
|
-
|
|
211
|
+
/** GET https://<deployment>/api/v1/deployment_info, for a cloud deployment. Only the fields the provider uses. */
|
|
212
|
+
const DeploymentInfo = Schema.Struct({
|
|
213
|
+
kind: Schema.Literal("cloud"),
|
|
214
|
+
projectId: Schema.Number,
|
|
215
|
+
deploymentType: Schema.String,
|
|
216
|
+
reference: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** GET https://<deployment>/api/v1/get_canonical_urls */
|
|
220
|
+
const CanonicalUrls = Schema.Struct({
|
|
221
|
+
convexCloudUrl: Schema.String,
|
|
222
|
+
convexSiteUrl: Schema.String,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const targetOf = (
|
|
157
226
|
row: DeploymentRow,
|
|
158
227
|
type: DeploymentProps["type"],
|
|
159
228
|
previewName: string | undefined,
|
|
160
|
-
):
|
|
229
|
+
): DeploymentTarget => ({
|
|
161
230
|
projectId: row.projectId,
|
|
162
231
|
type,
|
|
163
232
|
name: row.name,
|
|
@@ -167,19 +236,23 @@ const attributesOf = (
|
|
|
167
236
|
siteUrl: siteUrlOf(row.deploymentUrl),
|
|
168
237
|
});
|
|
169
238
|
|
|
170
|
-
/**
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
239
|
+
/** True when the deployment has received a push. */
|
|
240
|
+
const isDeployed = (row: DeploymentRow) =>
|
|
241
|
+
row.lastDeployTime !== undefined && row.lastDeployTime !== null;
|
|
242
|
+
|
|
243
|
+
/** The `name` prop of a deployment: the dev reference or the preview name. */
|
|
244
|
+
const nameOf = (target: DeploymentTarget) =>
|
|
245
|
+
target.type === "dev"
|
|
246
|
+
? target.reference
|
|
247
|
+
: target.type === "preview"
|
|
248
|
+
? target.previewName
|
|
176
249
|
: undefined;
|
|
177
250
|
|
|
178
251
|
/**
|
|
179
252
|
* Fails with {@link DeploymentIdentityChange} when the props name another
|
|
180
|
-
* deployment than `current`: the attributes in state, or a live
|
|
253
|
+
* deployment than `current`: the attributes in state, or a live deployment.
|
|
181
254
|
*/
|
|
182
|
-
const checkIdentity = (current:
|
|
255
|
+
const checkIdentity = (current: DeploymentTarget, news: DeploymentProps) => {
|
|
183
256
|
const change = (field: "projectId" | "type" | "name", from: string, to: string) =>
|
|
184
257
|
Effect.fail(
|
|
185
258
|
new DeploymentIdentityChange({
|
|
@@ -189,8 +262,8 @@ const checkIdentity = (current: DeploymentAttributes, news: DeploymentProps) =>
|
|
|
189
262
|
requested: to,
|
|
190
263
|
}),
|
|
191
264
|
);
|
|
192
|
-
if (current.projectId !== news.projectId) {
|
|
193
|
-
return change("projectId", String(current.projectId), String(news.projectId));
|
|
265
|
+
if (current.projectId !== news.project.projectId) {
|
|
266
|
+
return change("projectId", String(current.projectId), String(news.project.projectId));
|
|
194
267
|
}
|
|
195
268
|
if (current.type !== news.type) return change("type", current.type, news.type);
|
|
196
269
|
const currentName = nameOf(current);
|
|
@@ -200,16 +273,78 @@ const checkIdentity = (current: DeploymentAttributes, news: DeploymentProps) =>
|
|
|
200
273
|
return Effect.void;
|
|
201
274
|
};
|
|
202
275
|
|
|
276
|
+
/** The deployment that a resource addresses: its project, type, and dev or preview name. */
|
|
277
|
+
interface Address {
|
|
278
|
+
readonly projectId: number;
|
|
279
|
+
readonly type: DeploymentProps["type"];
|
|
280
|
+
readonly name: string | undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const addressOf = (props: DeploymentProps): Address => ({
|
|
284
|
+
projectId: props.project.projectId,
|
|
285
|
+
type: props.type,
|
|
286
|
+
name: props.type === "prod" ? undefined : props.name,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/** True when an update writes or removes variables: `env` has names, or state has names to remove. */
|
|
290
|
+
const changesEnv = (news: DeploymentProps, output: DeploymentAttributes | undefined) =>
|
|
291
|
+
Object.keys(news.env ?? {}).length > 0 || envNamesOf(output).length > 0;
|
|
292
|
+
|
|
293
|
+
/** The `env` names in state. State from 0.3 has none. */
|
|
294
|
+
const envNamesOf = (output: DeploymentAttributes | undefined): readonly string[] =>
|
|
295
|
+
output?.envNames ?? [];
|
|
296
|
+
|
|
297
|
+
/** The address of the deployment in state. */
|
|
298
|
+
const addressInState = (output: DeploymentAttributes): Address => ({
|
|
299
|
+
projectId: output.projectId,
|
|
300
|
+
type: output.type,
|
|
301
|
+
name: nameOf(output),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* How the provider reaches one deployment, by the narrowest credential that
|
|
306
|
+
* fits:
|
|
307
|
+
*
|
|
308
|
+
* - `deployKey`: the configured deploy key belongs to this deployment. The
|
|
309
|
+
* deployment API answers its identity and URLs, the key sets variables and
|
|
310
|
+
* pushes, and no Management API call is made.
|
|
311
|
+
* - `previewKey`: the configured preview deploy key belongs to this project.
|
|
312
|
+
* The Management API takes it for the preview deployments of the project,
|
|
313
|
+
* and the CLI pushes with it and `--preview-name`.
|
|
314
|
+
* - `token`: the Management API access token. Each push gets a short-lived
|
|
315
|
+
* deploy key (Push.ts).
|
|
316
|
+
*/
|
|
317
|
+
type Access =
|
|
318
|
+
| {
|
|
319
|
+
readonly kind: "deployKey";
|
|
320
|
+
readonly key: Redacted.Redacted<string>;
|
|
321
|
+
readonly target: DeploymentTarget;
|
|
322
|
+
}
|
|
323
|
+
| {
|
|
324
|
+
readonly kind: "previewKey";
|
|
325
|
+
readonly key: Redacted.Redacted<string>;
|
|
326
|
+
readonly api: ManagementApiService;
|
|
327
|
+
}
|
|
328
|
+
| { readonly kind: "token"; readonly api: ManagementApiService };
|
|
329
|
+
|
|
330
|
+
const ProjectSlugs = Schema.Struct({ slug: Schema.String, teamSlug: Schema.String });
|
|
331
|
+
|
|
332
|
+
/** True for an answer that says this credential cannot read the object. */
|
|
333
|
+
const isRefused = (error: ConvexApiError) =>
|
|
334
|
+
error.status === 400 || error.status === 401 || error.status === 403 || error.status === 404;
|
|
335
|
+
|
|
203
336
|
export const DeploymentProvider = () =>
|
|
204
337
|
Provider.effect(
|
|
205
338
|
Deployment,
|
|
206
339
|
Effect.gen(function* () {
|
|
207
340
|
const api = yield* ManagementApi;
|
|
341
|
+
const credentials = yield* Credentials;
|
|
342
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
208
343
|
|
|
209
344
|
/** The live deployment, or undefined after a 404. GET /deployments/{name} answers no preview identifier. */
|
|
210
|
-
const getByName = (name: string) =>
|
|
345
|
+
const getByName = (management: ManagementApiService, name: string) =>
|
|
211
346
|
absentAsUndefined(
|
|
212
|
-
|
|
347
|
+
management.request("get deployment", (client) =>
|
|
213
348
|
client.GET("/deployments/{deployment_name}", {
|
|
214
349
|
params: { path: { deployment_name: name } },
|
|
215
350
|
}),
|
|
@@ -221,11 +356,12 @@ export const DeploymentProvider = () =>
|
|
|
221
356
|
);
|
|
222
357
|
|
|
223
358
|
const listDeployments = (
|
|
359
|
+
management: ManagementApiService,
|
|
224
360
|
projectId: number,
|
|
225
361
|
type: DeploymentProps["type"],
|
|
226
362
|
isDefault?: boolean,
|
|
227
363
|
) =>
|
|
228
|
-
|
|
364
|
+
management
|
|
229
365
|
.request("list deployments", (client) =>
|
|
230
366
|
client.GET("/projects/{project_id}/list_deployments", {
|
|
231
367
|
params: {
|
|
@@ -236,24 +372,32 @@ export const DeploymentProvider = () =>
|
|
|
236
372
|
)
|
|
237
373
|
.pipe(Effect.map((body) => Schema.decodeUnknownSync(DeploymentRows)(body)));
|
|
238
374
|
|
|
375
|
+
/** The row of the project's default production deployment, or undefined. */
|
|
376
|
+
const productionRow = (management: ManagementApiService, projectId: number) =>
|
|
377
|
+
listDeployments(management, projectId, "prod", true).pipe(
|
|
378
|
+
Effect.map((rows) =>
|
|
379
|
+
rows.find((candidate) => candidate.deploymentType === "prod" && candidate.isDefault),
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
|
|
239
383
|
/** The project's default production deployment, or undefined. */
|
|
240
|
-
const production = (projectId: number) =>
|
|
241
|
-
|
|
242
|
-
Effect.map((
|
|
243
|
-
const row = rows.find(
|
|
244
|
-
(candidate) => candidate.deploymentType === "prod" && candidate.isDefault,
|
|
245
|
-
);
|
|
246
|
-
return row === undefined ? undefined : attributesOf(row, "prod", undefined);
|
|
247
|
-
}),
|
|
384
|
+
const production = (management: ManagementApiService, projectId: number) =>
|
|
385
|
+
productionRow(management, projectId).pipe(
|
|
386
|
+
Effect.map((row) => (row === undefined ? undefined : targetOf(row, "prod", undefined))),
|
|
248
387
|
);
|
|
249
388
|
|
|
250
389
|
/**
|
|
251
390
|
* The one dev deployment with this reference, or the one preview
|
|
252
391
|
* deployment with this preview name, or undefined.
|
|
253
392
|
*/
|
|
254
|
-
const findByName = (
|
|
393
|
+
const findByName = (
|
|
394
|
+
management: ManagementApiService,
|
|
395
|
+
projectId: number,
|
|
396
|
+
type: "dev" | "preview",
|
|
397
|
+
name: string,
|
|
398
|
+
) =>
|
|
255
399
|
Effect.gen(function* () {
|
|
256
|
-
const matches = (yield* listDeployments(projectId, type)).filter(
|
|
400
|
+
const matches = (yield* listDeployments(management, projectId, type)).filter(
|
|
257
401
|
(row) =>
|
|
258
402
|
row.deploymentType === type &&
|
|
259
403
|
(type === "dev" ? row.reference === name : row.previewIdentifier === name),
|
|
@@ -269,16 +413,114 @@ export const DeploymentProvider = () =>
|
|
|
269
413
|
return matches[0];
|
|
270
414
|
});
|
|
271
415
|
|
|
272
|
-
/** The deployment that the
|
|
273
|
-
const lookup = (
|
|
274
|
-
|
|
275
|
-
? production(
|
|
276
|
-
: findByName(
|
|
416
|
+
/** The deployment that the address names, through the Management API, or undefined. */
|
|
417
|
+
const lookup = (management: ManagementApiService, address: Address) =>
|
|
418
|
+
address.type === "prod" || address.name === undefined
|
|
419
|
+
? production(management, address.projectId)
|
|
420
|
+
: findByName(management, address.projectId, address.type, address.name).pipe(
|
|
277
421
|
Effect.map((row) =>
|
|
278
|
-
row === undefined ? undefined :
|
|
422
|
+
row === undefined ? undefined : targetOf(row, address.type, address.name),
|
|
279
423
|
),
|
|
280
424
|
);
|
|
281
425
|
|
|
426
|
+
/** The deployment of a deploy key, when it is the deployment that the address names. */
|
|
427
|
+
const deployKeyTarget = (
|
|
428
|
+
key: Redacted.Redacted<string>,
|
|
429
|
+
deployment: string,
|
|
430
|
+
address: Address,
|
|
431
|
+
) =>
|
|
432
|
+
Effect.gen(function* () {
|
|
433
|
+
const info = Schema.decodeUnknownSync(DeploymentInfo)(
|
|
434
|
+
yield* api.deploymentRequest("get deployment info", deployment, key, (client) =>
|
|
435
|
+
client.GET("/deployment_info"),
|
|
436
|
+
),
|
|
437
|
+
);
|
|
438
|
+
const reference = info.reference ?? "";
|
|
439
|
+
if (
|
|
440
|
+
info.projectId !== address.projectId ||
|
|
441
|
+
info.deploymentType !== address.type ||
|
|
442
|
+
(address.type === "dev" && reference !== address.name)
|
|
443
|
+
) {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
const urls = Schema.decodeUnknownSync(CanonicalUrls)(
|
|
447
|
+
yield* api.deploymentRequest("get canonical urls", deployment, key, (client) =>
|
|
448
|
+
client.GET("/get_canonical_urls"),
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
return {
|
|
452
|
+
projectId: info.projectId,
|
|
453
|
+
type: address.type,
|
|
454
|
+
name: deployment,
|
|
455
|
+
reference,
|
|
456
|
+
previewName: undefined,
|
|
457
|
+
url: urls.convexCloudUrl,
|
|
458
|
+
siteUrl: urls.convexSiteUrl,
|
|
459
|
+
} satisfies DeploymentTarget;
|
|
460
|
+
}).pipe(Effect.catchIf(isRefused, () => Effect.succeed(undefined)));
|
|
461
|
+
|
|
462
|
+
/** True when the preview deploy key belongs to the project. */
|
|
463
|
+
const previewKeyFits = (
|
|
464
|
+
management: ManagementApiService,
|
|
465
|
+
slugs: { readonly team: string; readonly project: string },
|
|
466
|
+
projectId: number,
|
|
467
|
+
) =>
|
|
468
|
+
management
|
|
469
|
+
.request("get project", (client) =>
|
|
470
|
+
client.GET("/projects/{project_id}", { params: { path: { project_id: projectId } } }),
|
|
471
|
+
)
|
|
472
|
+
.pipe(
|
|
473
|
+
Effect.map((body) => {
|
|
474
|
+
const project = Schema.decodeUnknownSync(ProjectSlugs)(body);
|
|
475
|
+
return project.slug === slugs.project && project.teamSlug === slugs.team;
|
|
476
|
+
}),
|
|
477
|
+
Effect.catchIf(isRefused, () => Effect.succeed(false)),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The narrowest credential that reaches the deployment. See {@link Access}.
|
|
482
|
+
* When `changesEnv`, a preview deploy key gives way to the access token,
|
|
483
|
+
* because it cannot change variables.
|
|
484
|
+
*/
|
|
485
|
+
const accessFor = (address: Address, changesEnv: boolean) =>
|
|
486
|
+
Effect.gen(function* (): Effect.fn.Return<Access, ConvexApiError> {
|
|
487
|
+
const key = yield* credentials.deployKey;
|
|
488
|
+
if (key !== undefined) {
|
|
489
|
+
const target = keyTargetOf(Redacted.value(key));
|
|
490
|
+
if (target.kind === "deployment" && address.type !== "preview") {
|
|
491
|
+
const found = yield* deployKeyTarget(key, target.deployment, address);
|
|
492
|
+
if (found !== undefined) return { kind: "deployKey", key, target: found };
|
|
493
|
+
}
|
|
494
|
+
if (target.kind === "preview" && address.type === "preview") {
|
|
495
|
+
const scoped = api.withToken(key);
|
|
496
|
+
if (
|
|
497
|
+
!(changesEnv && (yield* credentials.hasAccessToken)) &&
|
|
498
|
+
(yield* previewKeyFits(scoped, target, address.projectId))
|
|
499
|
+
) {
|
|
500
|
+
return { kind: "previewKey", key, api: scoped };
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return { kind: "token", api };
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
/** The live deployment of a resource in state, or undefined when it is gone. */
|
|
508
|
+
const live = (access: Access, output: DeploymentAttributes) =>
|
|
509
|
+
Effect.gen(function* () {
|
|
510
|
+
if (access.kind === "deployKey") return access.target;
|
|
511
|
+
// 404 for a deleted project: its production deployment is gone too.
|
|
512
|
+
if (output.type === "prod")
|
|
513
|
+
return yield* absentAsUndefined(production(access.api, output.projectId));
|
|
514
|
+
const row = yield* getByName(access.api, output.name);
|
|
515
|
+
return row === undefined ? undefined : targetOf(row, output.type, output.previewName);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
/** The content identity of `source` and `env` on a deployment, or undefined when there is nothing to push. */
|
|
519
|
+
const wantedHash = (news: DeploymentProps, deployment: string) =>
|
|
520
|
+
news.source === undefined && Object.keys(news.env ?? {}).length === 0
|
|
521
|
+
? Effect.succeed(undefined)
|
|
522
|
+
: pushHash({ source: news.source, deployment, env: news.env ?? {} });
|
|
523
|
+
|
|
282
524
|
return {
|
|
283
525
|
// `name`, `reference`, and the URLs change when an expired preview is
|
|
284
526
|
// created again, or when another production deployment becomes the
|
|
@@ -290,113 +532,210 @@ export const DeploymentProvider = () =>
|
|
|
290
532
|
diff: Effect.fn(function* ({ news, output }) {
|
|
291
533
|
if (!isResolved(news) || output === undefined) return undefined;
|
|
292
534
|
yield* checkIdentity(output, news);
|
|
293
|
-
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const current = yield* absentAsUndefined(production(news.projectId));
|
|
298
|
-
return current?.name === output.name ? undefined : ({ action: "update" } as const);
|
|
299
|
-
}
|
|
300
|
-
// A preview expires, and anyone can delete a deployment. Reconcile
|
|
301
|
-
// then finds or creates it again, so the stack never hands out the
|
|
535
|
+
const access = yield* accessFor(addressOf(news), changesEnv(news, output));
|
|
536
|
+
// A preview expires, anyone can delete a deployment, and another
|
|
537
|
+
// production deployment can become the default. Reconcile then
|
|
538
|
+
// finds or creates the right one, so the stack never hands out the
|
|
302
539
|
// URL of a deployment that is gone.
|
|
303
|
-
const
|
|
304
|
-
|
|
540
|
+
const current = yield* live(access, output);
|
|
541
|
+
if (current?.name !== output.name) return { action: "update" } as const;
|
|
542
|
+
const hash = yield* wantedHash(news, output.name);
|
|
543
|
+
return hash === output.hash
|
|
544
|
+
? ({ action: "noop" } as const)
|
|
545
|
+
: ({ action: "update" } as const);
|
|
305
546
|
}),
|
|
306
547
|
|
|
307
548
|
read: Effect.fn(function* ({ olds, output }) {
|
|
308
|
-
if (output?.type === "prod") {
|
|
309
|
-
// 404: the project is gone, and so is its production deployment.
|
|
310
|
-
return yield* absentAsUndefined(production(output.projectId));
|
|
311
|
-
}
|
|
312
549
|
if (output !== undefined) {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
550
|
+
const access = yield* accessFor(addressInState(output), false);
|
|
551
|
+
const current = yield* live(access, output);
|
|
552
|
+
return current === undefined ? undefined : { ...output, ...current };
|
|
553
|
+
}
|
|
554
|
+
const address = addressOf(olds);
|
|
555
|
+
const access = yield* accessFor(address, false);
|
|
556
|
+
if (access.kind === "deployKey") {
|
|
557
|
+
// The deployment API does not say whether the deployment has code.
|
|
558
|
+
return Unowned({ ...access.target, hash: undefined, envNames: [] });
|
|
559
|
+
}
|
|
560
|
+
if (olds.type === "prod") {
|
|
561
|
+
const row = yield* productionRow(access.api, address.projectId);
|
|
562
|
+
if (row === undefined) return undefined;
|
|
563
|
+
const attributes = {
|
|
564
|
+
...targetOf(row, "prod", undefined),
|
|
565
|
+
hash: undefined,
|
|
566
|
+
envNames: [],
|
|
567
|
+
};
|
|
568
|
+
// A production deployment that never received a push, such as
|
|
569
|
+
// the one of a project that this stack created, loses nothing to
|
|
570
|
+
// a push. One with code is someone else's until --adopt.
|
|
571
|
+
return isDeployed(row) ? Unowned(attributes) : attributes;
|
|
317
572
|
}
|
|
318
|
-
const found = yield* lookup(
|
|
573
|
+
const found = yield* lookup(access.api, address);
|
|
319
574
|
if (found === undefined) return undefined;
|
|
320
|
-
// The production deployment comes with the project, and this
|
|
321
|
-
// resource never changes or deletes it, so it is ours to read.
|
|
322
575
|
// A dev or preview deployment that is not in state is someone
|
|
323
576
|
// else's until the user passes --adopt.
|
|
324
|
-
return
|
|
577
|
+
return Unowned({ ...found, hash: undefined, envNames: [] });
|
|
325
578
|
}),
|
|
326
579
|
|
|
327
580
|
reconcile: Effect.fn(function* ({ fqn, news, output }) {
|
|
328
581
|
// Also here: with an unresolved prop in plan, diff could not check.
|
|
329
582
|
if (output !== undefined) yield* checkIdentity(output, news);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
583
|
+
// The functions directory and the program, before any change.
|
|
584
|
+
if (news.source !== undefined) yield* checkSource(news.source);
|
|
585
|
+
const address = addressOf(news);
|
|
586
|
+
const access = yield* accessFor(address, changesEnv(news, output));
|
|
587
|
+
const env = news.env ?? {};
|
|
588
|
+
// Before any create: a preview deploy key cannot change variables,
|
|
589
|
+
// and accessFor chose it because there is no access token.
|
|
590
|
+
if (access.kind === "previewKey" && changesEnv(news, output)) {
|
|
591
|
+
return yield* new EnvironmentNeedsDeployKey({
|
|
592
|
+
deployment: output?.name ?? address.name ?? "(new)",
|
|
593
|
+
});
|
|
338
594
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
595
|
+
const adopt = yield* shouldAdopt(fqn);
|
|
596
|
+
const refuse = (name: string) =>
|
|
597
|
+
new OwnedBySomeoneElse({
|
|
598
|
+
message: `Project ${address.projectId} already has ${news.type} deployment ${name}${news.type === "prod" ? " with code" : ` named "${news.name}"`}. Re-run with --adopt to take it over${news.type === "prod" ? "" : ", or use another name"}.`,
|
|
599
|
+
resourceType: Deployment.Type,
|
|
600
|
+
physicalName: name,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// 1. The deployment. `created` is true when this reconcile created it.
|
|
604
|
+
const { target, created } = yield* Effect.gen(function* () {
|
|
605
|
+
if (access.kind === "deployKey") {
|
|
606
|
+
// read() refused it without --adopt; this covers an apply without a plan.
|
|
607
|
+
if (output === undefined && !adopt) return yield* refuse(access.target.name);
|
|
608
|
+
return { target: access.target, created: false };
|
|
609
|
+
}
|
|
610
|
+
if (news.type === "prod") {
|
|
611
|
+
// Always the current default, also when another production
|
|
612
|
+
// deployment became the default after the last deploy.
|
|
613
|
+
const row = yield* productionRow(access.api, address.projectId);
|
|
614
|
+
if (row === undefined) {
|
|
615
|
+
return yield* new ProductionDeploymentNotFound({ projectId: address.projectId });
|
|
349
616
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return current;
|
|
617
|
+
// Code that appeared after the plan is someone else's.
|
|
618
|
+
if (output === undefined && isDeployed(row) && !adopt) return yield* refuse(row.name);
|
|
619
|
+
return { target: targetOf(row, "prod", undefined), created: false };
|
|
354
620
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
621
|
+
if (output !== undefined) {
|
|
622
|
+
const row = yield* getByName(access.api, output.name);
|
|
623
|
+
if (row !== undefined) {
|
|
624
|
+
if (row.deploymentType !== output.type) {
|
|
625
|
+
return yield* new DeploymentIdentityChange({
|
|
626
|
+
deployment: row.name,
|
|
627
|
+
field: "type",
|
|
628
|
+
current: row.deploymentType,
|
|
629
|
+
requested: news.type,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
const current = targetOf(row, output.type, output.previewName);
|
|
633
|
+
// The live deployment can have moved to another project.
|
|
634
|
+
yield* checkIdentity(current, news);
|
|
635
|
+
return { target: current, created: false };
|
|
636
|
+
}
|
|
637
|
+
// The deployment in state is gone, such as an expired preview.
|
|
638
|
+
// Find or create it below like a resource without state.
|
|
369
639
|
}
|
|
370
|
-
|
|
640
|
+
// Never create over an existing deployment: a preview create
|
|
641
|
+
// replaces the preview deployment with the same name. Another
|
|
642
|
+
// writer can still create one between this lookup and the create.
|
|
643
|
+
const existing = yield* lookup(access.api, address);
|
|
644
|
+
if (existing !== undefined) {
|
|
645
|
+
if (!adopt) return yield* refuse(existing.name);
|
|
646
|
+
return { target: existing, created: false };
|
|
647
|
+
}
|
|
648
|
+
const row = yield* access.api.request("create deployment", (client) =>
|
|
649
|
+
client.POST("/projects/{project_id}/create_deployment", {
|
|
650
|
+
params: { path: { project_id: address.projectId } },
|
|
651
|
+
body: { type: news.type, reference: news.name },
|
|
652
|
+
}),
|
|
653
|
+
);
|
|
654
|
+
return {
|
|
655
|
+
target: targetOf(Schema.decodeUnknownSync(DeploymentRow)(row), news.type, news.name),
|
|
656
|
+
created: true,
|
|
657
|
+
};
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// 2. The variables and the push, only when their content changed.
|
|
661
|
+
// Variables set on another deployment stay there: they are not ours
|
|
662
|
+
// on this one.
|
|
663
|
+
const previousEnvNames = output?.name === target.name ? envNamesOf(output) : [];
|
|
664
|
+
const before = yield* wantedHash(news, target.name);
|
|
665
|
+
if (before === undefined && previousEnvNames.length === 0) {
|
|
666
|
+
return { ...target, hash: undefined, envNames: [] };
|
|
371
667
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
668
|
+
if (output?.name === target.name && before === output.hash) {
|
|
669
|
+
return { ...target, hash: output.hash, envNames: previousEnvNames };
|
|
670
|
+
}
|
|
671
|
+
const options = {
|
|
672
|
+
deployment: target.name,
|
|
673
|
+
url: target.url,
|
|
674
|
+
source: news.source,
|
|
675
|
+
env,
|
|
676
|
+
previousEnvNames,
|
|
677
|
+
adopt,
|
|
678
|
+
};
|
|
679
|
+
const pushed =
|
|
680
|
+
access.kind === "deployKey"
|
|
681
|
+
? push(spawner, access.key, { ...options, previewName: undefined })
|
|
682
|
+
: access.kind === "previewKey"
|
|
683
|
+
? push(spawner, access.key, { ...options, previewName: target.previewName })
|
|
684
|
+
: withPushKey(access.api, target.name, news.source, (key) =>
|
|
685
|
+
push(spawner, key, { ...options, previewName: undefined }),
|
|
686
|
+
);
|
|
687
|
+
yield* pushed.pipe(
|
|
688
|
+
// A deployment that this reconcile created and could not push to
|
|
689
|
+
// would be someone else's on the next run, and a dev deployment
|
|
690
|
+
// never expires. It is new and empty, so it goes.
|
|
691
|
+
Effect.onError(() =>
|
|
692
|
+
created && access.kind !== "deployKey"
|
|
693
|
+
? absentAsUndefined(
|
|
694
|
+
retryIdempotentWrite(
|
|
695
|
+
access.api.requestVoid("delete deployment", (client) =>
|
|
696
|
+
client.POST("/deployments/{deployment_name}/delete", {
|
|
697
|
+
params: { path: { deployment_name: target.name } },
|
|
698
|
+
}),
|
|
699
|
+
),
|
|
700
|
+
),
|
|
701
|
+
).pipe(
|
|
702
|
+
Effect.catch((error) =>
|
|
703
|
+
Effect.logWarning(
|
|
704
|
+
`Convex.Deployment could not delete ${target.name} after the failed push (${error.message}). The next run takes it over only with --adopt.`,
|
|
705
|
+
),
|
|
706
|
+
),
|
|
707
|
+
)
|
|
708
|
+
: Effect.void,
|
|
709
|
+
),
|
|
382
710
|
);
|
|
711
|
+
// Taken again after the push, so that files the push generates,
|
|
712
|
+
// such as convex/_generated, are part of the identity.
|
|
713
|
+
return {
|
|
714
|
+
...target,
|
|
715
|
+
hash: yield* wantedHash(news, target.name),
|
|
716
|
+
envNames: Object.keys(env).sort(),
|
|
717
|
+
};
|
|
383
718
|
}),
|
|
384
719
|
|
|
385
|
-
// Runs only with RemovalPolicy.destroy(): the default policy is
|
|
720
|
+
// Runs only with RemovalPolicy.destroy(): the default policy is
|
|
721
|
+
// `retain`. The variables go with the deployment.
|
|
386
722
|
delete: Effect.fn(function* ({ output }) {
|
|
387
723
|
const keep = Effect.logWarning(
|
|
388
724
|
`Convex.Deployment does not delete production deployment ${output.name}: it belongs to project ${output.projectId}. To delete it, delete the project with RemovalPolicy.destroy() on Convex.Project.`,
|
|
389
725
|
);
|
|
390
726
|
if (output.type === "prod") return yield* keep;
|
|
727
|
+
const access = yield* accessFor(addressInState(output), false);
|
|
728
|
+
// A deploy key cannot delete its deployment.
|
|
729
|
+
const management = access.kind === "deployKey" ? api : access.api;
|
|
391
730
|
// The type in state can be old: a dev or preview deployment can
|
|
392
731
|
// become a production deployment in Convex. The live type decides.
|
|
393
|
-
const
|
|
732
|
+
const row = yield* getByName(management, output.name);
|
|
394
733
|
// 404: an earlier attempt, the expiry, or someone else deleted it.
|
|
395
|
-
if (
|
|
396
|
-
if (
|
|
734
|
+
if (row === undefined) return;
|
|
735
|
+
if (row.deploymentType === "prod") return yield* keep;
|
|
397
736
|
yield* absentAsUndefined(
|
|
398
737
|
retryIdempotentWrite(
|
|
399
|
-
|
|
738
|
+
management.requestVoid("delete deployment", (client) =>
|
|
400
739
|
client.POST("/deployments/{deployment_name}/delete", {
|
|
401
740
|
params: { path: { deployment_name: output.name } },
|
|
402
741
|
}),
|