@samebase/alchemy-convex 0.1.0 → 0.3.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/README.md +246 -55
- package/lib/index.d.ts +510 -9
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1411 -10
- package/lib/index.js.map +1 -1
- package/package.json +8 -5
- package/src/Adoption.ts +30 -0
- package/src/Code.ts +463 -0
- package/src/DefaultEnvironmentVariable.ts +127 -29
- package/src/Deploy.ts +47 -35
- package/src/DeployKey.ts +221 -60
- package/src/Deployment.ts +409 -0
- package/src/EnvironmentVariable.ts +204 -44
- package/src/ManagementApi.ts +57 -1
- package/src/PreviewDeployKey.ts +65 -38
- package/src/Project.ts +236 -52
- package/src/Providers.ts +9 -1
- package/src/index.ts +29 -1
- package/lib/Credentials.d.ts +0 -39
- package/lib/Credentials.d.ts.map +0 -1
- package/lib/Credentials.js +0 -49
- package/lib/Credentials.js.map +0 -1
- package/lib/DefaultEnvironmentVariable.d.ts +0 -58
- package/lib/DefaultEnvironmentVariable.d.ts.map +0 -1
- package/lib/DefaultEnvironmentVariable.js +0 -93
- package/lib/DefaultEnvironmentVariable.js.map +0 -1
- package/lib/Deploy.d.ts +0 -62
- package/lib/Deploy.d.ts.map +0 -1
- package/lib/Deploy.js +0 -92
- package/lib/Deploy.js.map +0 -1
- package/lib/DeployKey.d.ts +0 -48
- package/lib/DeployKey.d.ts.map +0 -1
- package/lib/DeployKey.js +0 -104
- package/lib/DeployKey.js.map +0 -1
- package/lib/EnvironmentVariable.d.ts +0 -37
- package/lib/EnvironmentVariable.d.ts.map +0 -1
- package/lib/EnvironmentVariable.js +0 -65
- package/lib/EnvironmentVariable.js.map +0 -1
- package/lib/ManagementApi.d.ts +0 -50
- package/lib/ManagementApi.d.ts.map +0 -1
- package/lib/ManagementApi.js +0 -103
- package/lib/ManagementApi.js.map +0 -1
- package/lib/PreviewDeployKey.d.ts +0 -34
- package/lib/PreviewDeployKey.d.ts.map +0 -1
- package/lib/PreviewDeployKey.js +0 -78
- package/lib/PreviewDeployKey.js.map +0 -1
- package/lib/Project.d.ts +0 -29
- package/lib/Project.d.ts.map +0 -1
- package/lib/Project.js +0 -114
- package/lib/Project.js.map +0 -1
- package/lib/Providers.d.ts +0 -13
- package/lib/Providers.d.ts.map +0 -1
- package/lib/Providers.js +0 -30
- package/lib/Providers.js.map +0 -1
package/src/Deploy.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// Convex.Deploy
|
|
2
|
-
// `npx convex deploy` and exposes
|
|
1
|
+
// Convex.Deploy (deprecated since 0.3.0, use Convex.Code): pushes the
|
|
2
|
+
// functions in a `convex/` directory with `npx convex deploy` and exposes
|
|
3
|
+
// the deployment URL for a frontend build. It stays so that stacks and state
|
|
4
|
+
// from 0.1 and 0.2 keep working: Convex.Code has other props.
|
|
3
5
|
//
|
|
4
6
|
// The deployment itself belongs to the project, so delete leaves the last
|
|
5
7
|
// pushed functions running. The URL comes from the CLI's own output because
|
|
@@ -12,6 +14,8 @@ import { havePropsChanged, isResolved } from "alchemy/Diff";
|
|
|
12
14
|
import * as Provider from "alchemy/Provider";
|
|
13
15
|
import * as Effect from "effect/Effect";
|
|
14
16
|
import type * as Redacted from "effect/Redacted";
|
|
17
|
+
import * as Schema from "effect/Schema";
|
|
18
|
+
import { parseDeployOutput } from "./Code.ts";
|
|
15
19
|
import type { Providers } from "./Providers.ts";
|
|
16
20
|
|
|
17
21
|
export interface DeployProps extends Pick<CommandRunProps, "cwd" | "env" | "timeout"> {
|
|
@@ -46,7 +50,9 @@ export interface DeployProps extends Pick<CommandRunProps, "cwd" | "env" | "time
|
|
|
46
50
|
* Files hashed to skip an unchanged push. By default every non-gitignored
|
|
47
51
|
* file under `cwd` and the nearest lockfile. Use
|
|
48
52
|
* `{ include: ["convex/**"], lockfile: true }` to ignore frontend changes,
|
|
49
|
-
* or `false` to push on every deploy.
|
|
53
|
+
* or `false` to push on every deploy. A preview deploy (`previewName` or
|
|
54
|
+
* `previewCreate`) pushes on every deploy: Convex can delete an expired
|
|
55
|
+
* preview deployment, and the hash cannot show that.
|
|
50
56
|
* @default true
|
|
51
57
|
*/
|
|
52
58
|
readonly memo?: MemoOptions | boolean;
|
|
@@ -62,26 +68,13 @@ export interface DeployAttributes {
|
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
export type Deploy = Resource<"Convex.Deploy", DeployProps, DeployAttributes, never, Providers>;
|
|
65
|
-
export const Deploy = Resource<Deploy>("Convex.Deploy");
|
|
66
|
-
|
|
67
71
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
+
* @deprecated Since 0.3.0. Use Convex.Code with a Convex.Deployment: it
|
|
73
|
+
* runs the installed Convex CLI, keeps the deploy key out of the process
|
|
74
|
+
* environment, checks that the key fits the deployment, and outputs the site
|
|
75
|
+
* URL. Removing a Deploy resource changes nothing in Convex.
|
|
72
76
|
*/
|
|
73
|
-
const
|
|
74
|
-
/Deployed Convex functions to (https:\/\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud)\/?(?=\s|$)/m;
|
|
75
|
-
|
|
76
|
-
/** Reads the deployment URL from `convex deploy` output. ANSI escape codes are removed first. */
|
|
77
|
-
export const parseDeployOutput = (
|
|
78
|
-
text: string,
|
|
79
|
-
): { url: string; deploymentName: string } | undefined => {
|
|
80
|
-
const match = DEPLOYED_LINE.exec(stripVTControlCharacters(text));
|
|
81
|
-
const url = match?.[1];
|
|
82
|
-
const deploymentName = match?.[2];
|
|
83
|
-
return url === undefined || deploymentName === undefined ? undefined : { url, deploymentName };
|
|
84
|
-
};
|
|
77
|
+
export const Deploy = Resource<Deploy>("Convex.Deploy");
|
|
85
78
|
|
|
86
79
|
/** The `convex deploy` argv, starting with `npx`. */
|
|
87
80
|
export const deployArgs = (
|
|
@@ -99,6 +92,26 @@ export const deployArgs = (
|
|
|
99
92
|
/** Number of output lines quoted when the CLI exits 0 without the deployed line. */
|
|
100
93
|
const OUTPUT_TAIL_LINES = 20;
|
|
101
94
|
|
|
95
|
+
/** A `convex deploy` argument contains whitespace, which Alchemy's command runner splits on. */
|
|
96
|
+
export class DeployArgumentError extends Schema.TaggedError<DeployArgumentError>()(
|
|
97
|
+
"DeployArgumentError",
|
|
98
|
+
{ argument: Schema.String },
|
|
99
|
+
) {
|
|
100
|
+
override get message() {
|
|
101
|
+
return `Convex.Deploy argument "${this.argument}" contains whitespace, and Alchemy splits the command on whitespace. Use the --flag=value form, such as --typecheck=disable, and names without spaces.`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `npx convex deploy` exited 0 but did not print the deployment URL. */
|
|
106
|
+
export class DeployOutputError extends Schema.TaggedError<DeployOutputError>()(
|
|
107
|
+
"DeployOutputError",
|
|
108
|
+
{ outputTail: Schema.String },
|
|
109
|
+
) {
|
|
110
|
+
override get message() {
|
|
111
|
+
return `npx convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line, so Convex.Deploy has no deployment URL. Remove --dry-run from extraArgs if it is there, and read the CLI output. Last output:\n${this.outputTail}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
102
115
|
export const DeployProvider = () =>
|
|
103
116
|
Provider.effect(
|
|
104
117
|
Deploy,
|
|
@@ -120,7 +133,16 @@ export const DeployProvider = () =>
|
|
|
120
133
|
// under the same name.
|
|
121
134
|
diff: Effect.fn(function* ({ olds, news, output }) {
|
|
122
135
|
if (!isResolved(news)) return undefined;
|
|
123
|
-
|
|
136
|
+
// A preview deployment can expire. The same hash then hides a
|
|
137
|
+
// missing deployment, so a preview always pushes. The CLI reuses
|
|
138
|
+
// a live preview with the same name.
|
|
139
|
+
const preview = news.previewName !== undefined || news.previewCreate !== undefined;
|
|
140
|
+
if (
|
|
141
|
+
preview ||
|
|
142
|
+
output === undefined ||
|
|
143
|
+
output.hash === undefined ||
|
|
144
|
+
havePropsChanged(olds, news)
|
|
145
|
+
) {
|
|
124
146
|
return { action: "update" } as const;
|
|
125
147
|
}
|
|
126
148
|
return { action: (yield* hashOf(news)) === output.hash ? "noop" : "update" } as const;
|
|
@@ -129,13 +151,7 @@ export const DeployProvider = () =>
|
|
|
129
151
|
reconcile: Effect.fn(function* ({ news, session }) {
|
|
130
152
|
const args = deployArgs(news);
|
|
131
153
|
const spaced = args.find((arg) => /\s/.test(arg));
|
|
132
|
-
if (spaced !== undefined) {
|
|
133
|
-
return yield* Effect.die(
|
|
134
|
-
new Error(
|
|
135
|
-
`Convex.Deploy argument "${spaced}" contains whitespace; Alchemy splits commands on whitespace`,
|
|
136
|
-
),
|
|
137
|
-
);
|
|
138
|
-
}
|
|
154
|
+
if (spaced !== undefined) return yield* new DeployArgumentError({ argument: spaced });
|
|
139
155
|
// A push is idempotent: the CLI uploads the full function set every time.
|
|
140
156
|
const { stdout, stderr } = yield* run(
|
|
141
157
|
{
|
|
@@ -150,16 +166,12 @@ export const DeployProvider = () =>
|
|
|
150
166
|
const output = `${stdout}\n${stderr}`;
|
|
151
167
|
const deployed = parseDeployOutput(output);
|
|
152
168
|
if (deployed === undefined) {
|
|
153
|
-
const
|
|
169
|
+
const outputTail = stripVTControlCharacters(output)
|
|
154
170
|
.split(/\r?\n|\r/)
|
|
155
171
|
.filter((line) => line.trim() !== "")
|
|
156
172
|
.slice(-OUTPUT_TAIL_LINES)
|
|
157
173
|
.join("\n");
|
|
158
|
-
return yield*
|
|
159
|
-
new Error(
|
|
160
|
-
`npx convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line. Last output:\n${tail}`,
|
|
161
|
-
),
|
|
162
|
-
);
|
|
174
|
+
return yield* new DeployOutputError({ outputTail });
|
|
163
175
|
}
|
|
164
176
|
return { ...deployed, hash: yield* hashOf(news) };
|
|
165
177
|
}),
|
package/src/DeployKey.ts
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
// Convex.DeployKey: a deploy key for one deployment, such as the key
|
|
2
2
|
// `npx convex deploy` reads from CONVEX_DEPLOY_KEY.
|
|
3
3
|
//
|
|
4
|
-
// Convex returns the secret only once, from create_deploy_key
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Convex returns the secret only once, from create_deploy_key, and the
|
|
5
|
+
// response has no id. The listing shows each key with a numeric id and a
|
|
6
|
+
// name: the requested name, or, when a listed key already has that name, the
|
|
7
|
+
// requested name plus a suffix such as " (870993b4-ffe6-4911-adbd-e29f7fd712f2)".
|
|
8
|
+
//
|
|
9
|
+
// One resource must never revoke the key of another resource:
|
|
10
|
+
// - The requested name is the `name` prop plus a hash of the resource's fqn
|
|
11
|
+
// and instance id, so two resources with the same `name` request
|
|
12
|
+
// different names.
|
|
13
|
+
// - After the create, exactly one listed key must have the requested name.
|
|
14
|
+
// Otherwise the create fails with a typed error that lists the keys. State
|
|
15
|
+
// keeps that key's numeric id.
|
|
16
|
+
// - Delete sends the secret as `id`, so it can revoke only the key that this
|
|
17
|
+
// resource created. It never deletes by a listed name.
|
|
18
|
+
// - With an OAuth token as the Management API credential, Convex returns
|
|
19
|
+
// that token as the "new" key. The provider never stores, revokes, or
|
|
20
|
+
// deletes such a key: the create fails with DeployKeyIsCredential.
|
|
21
|
+
// - Delete runs only for a key with a `keyId` in state. A 0.1.x key, which
|
|
22
|
+
// has none, stays in Convex with a warning.
|
|
23
|
+
// A key cannot change after creation, so every property change is a replacement.
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
9
25
|
import { Resource } from "alchemy";
|
|
10
26
|
import { havePropsChanged, isResolved } from "alchemy/Diff";
|
|
11
27
|
import * as Provider from "alchemy/Provider";
|
|
@@ -13,7 +29,12 @@ import * as Effect from "effect/Effect";
|
|
|
13
29
|
import * as Redacted from "effect/Redacted";
|
|
14
30
|
import * as Schema from "effect/Schema";
|
|
15
31
|
import type { components } from "@convex-dev/platform/managementApi";
|
|
16
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
absentAsUndefined,
|
|
34
|
+
type ConvexApiError,
|
|
35
|
+
ManagementApi,
|
|
36
|
+
retryIdempotentWrite,
|
|
37
|
+
} from "./ManagementApi.ts";
|
|
17
38
|
import type { Providers } from "./Providers.ts";
|
|
18
39
|
|
|
19
40
|
/** Actions a deploy key can be limited to, from the generated schema. */
|
|
@@ -23,35 +44,50 @@ export type DeployKeyAction = NonNullable<
|
|
|
23
44
|
|
|
24
45
|
/**
|
|
25
46
|
* True when `listedName` is how Convex lists a key created as `name`: the
|
|
26
|
-
* requested name, a space, and one
|
|
27
|
-
*
|
|
47
|
+
* requested name itself, or the requested name, a space, and one
|
|
48
|
+
* parenthesized suffix without spaces. The suffix check keeps `my-key` from
|
|
49
|
+
* matching a key created as `my-key (old)`.
|
|
28
50
|
*/
|
|
29
51
|
const isListedAs = (listedName: string, name: string) => {
|
|
30
52
|
const prefix = `${name} (`;
|
|
31
|
-
return
|
|
53
|
+
return (
|
|
54
|
+
listedName === name ||
|
|
55
|
+
(listedName.startsWith(prefix) && /^[^\s()]+\)$/.test(listedName.slice(prefix.length)))
|
|
56
|
+
);
|
|
32
57
|
};
|
|
33
58
|
|
|
34
59
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* another key with the same requested name, and its secret is lost.
|
|
60
|
+
* True when the listing still has the key in state: by numeric id, or by
|
|
61
|
+
* listed name for state written by 0.1.x, which has no id.
|
|
38
62
|
*/
|
|
39
|
-
export const
|
|
63
|
+
export const isKeyListed = (
|
|
64
|
+
entries: ReadonlyArray<typeof ListedKey.Type>,
|
|
65
|
+
key: { readonly keyId: number | undefined; readonly uniqueName: string },
|
|
66
|
+
): boolean =>
|
|
67
|
+
entries.some((entry) =>
|
|
68
|
+
key.keyId === undefined ? entry.name === key.uniqueName : entry.id === key.keyId,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/** The listed keys that Convex shows for a key created as `name`. */
|
|
72
|
+
export const keysListedAs = (
|
|
40
73
|
entries: ReadonlyArray<typeof ListedKey.Type>,
|
|
41
74
|
name: string,
|
|
42
|
-
): typeof ListedKey.Type
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
75
|
+
): ReadonlyArray<typeof ListedKey.Type> => entries.filter((entry) => isListedAs(entry.name, name));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The name that a resource instance requests: `name`, a dash, and the first
|
|
79
|
+
* 12 hex characters of sha256("<fqn>:<instanceId>"). Two resources, or the
|
|
80
|
+
* old and the new generation of one replaced resource, never request the
|
|
81
|
+
* same name. Pattern from Confect `packages/alchemy/src/internal/ResourceIdentity.ts`
|
|
82
|
+
* (https://github.com/rjdellecese/confect, ISC license).
|
|
83
|
+
*/
|
|
84
|
+
export const requestedKeyName = (name: string, fqn: string, instanceId: string): string =>
|
|
85
|
+
`${name}-${createHash("sha256").update(`${fqn}:${instanceId}`).digest("hex").slice(0, 12)}`;
|
|
50
86
|
|
|
51
87
|
/** One row of list_deploy_keys and list_preview_deploy_keys. Only the fields the providers use. */
|
|
52
88
|
export const ListedKey = Schema.Struct({
|
|
89
|
+
id: Schema.Number,
|
|
53
90
|
name: Schema.String,
|
|
54
|
-
creationTime: Schema.Number,
|
|
55
91
|
});
|
|
56
92
|
|
|
57
93
|
/** GET /deployments/{deployment_name}/list_deploy_keys */
|
|
@@ -60,18 +96,132 @@ export const DeployKeyList = Schema.Array(ListedKey);
|
|
|
60
96
|
/** POST /deployments/{deployment_name}/create_deploy_key */
|
|
61
97
|
export const CreatedDeployKey = Schema.Struct({ deployKey: Schema.String });
|
|
62
98
|
|
|
99
|
+
/**
|
|
100
|
+
* A key cannot be tied to one resource: before the create, listed keys
|
|
101
|
+
* already have the requested name, or after the create, not exactly one has it.
|
|
102
|
+
*/
|
|
103
|
+
export class DeployKeyRecoveryRequired extends Schema.TaggedError<DeployKeyRecoveryRequired>()(
|
|
104
|
+
"DeployKeyRecoveryRequired",
|
|
105
|
+
{
|
|
106
|
+
/** Where the keys live, such as `deployment happy-otter-123` or `project 3145389`. */
|
|
107
|
+
target: Schema.String,
|
|
108
|
+
/** The requested name. */
|
|
109
|
+
name: Schema.String,
|
|
110
|
+
/** The listed keys with the requested name. */
|
|
111
|
+
keys: Schema.Array(ListedKey),
|
|
112
|
+
/** True when this run created a key before the listing check failed. */
|
|
113
|
+
afterCreate: Schema.Boolean,
|
|
114
|
+
},
|
|
115
|
+
) {
|
|
116
|
+
override get message() {
|
|
117
|
+
const listed =
|
|
118
|
+
this.keys.length === 0
|
|
119
|
+
? "no key"
|
|
120
|
+
: this.keys.map((key) => `"${key.name}" (id ${key.id})`).join(", ");
|
|
121
|
+
return this.afterCreate
|
|
122
|
+
? `Convex created deploy key "${this.name}" on ${this.target}, but the listing shows ${listed} with that name, not exactly one, so this resource cannot track the new key. Alchemy does not revoke it. Delete the keys with that name in the Convex dashboard, then run the deploy again.`
|
|
123
|
+
: `${this.target} already lists ${listed} with the name "${this.name}", but Alchemy state has no such key. An interrupted run probably created it, and its secret is lost. Delete these keys in the Convex dashboard, then run the deploy again.`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Convex returned the Management API credential itself as the new key. This
|
|
129
|
+
* happens with an OAuth token: Convex creates no key. A delete of the
|
|
130
|
+
* returned secret would revoke the credential, so the resource does not keep it.
|
|
131
|
+
*/
|
|
132
|
+
export class DeployKeyIsCredential extends Schema.TaggedError<DeployKeyIsCredential>()(
|
|
133
|
+
"DeployKeyIsCredential",
|
|
134
|
+
{ target: Schema.String, name: Schema.String },
|
|
135
|
+
) {
|
|
136
|
+
override get message() {
|
|
137
|
+
return `Convex returned the Management API credential itself as deploy key "${this.name}" on ${this.target}. With an OAuth token, Convex creates no new key, and deleting the returned key would revoke the OAuth token. Alchemy does not keep it. Use a team access token in CONVEX_ACCESS_TOKEN, or the Convex CLI login.`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Creates a key named `name` and ties it to the caller. Fails before the
|
|
143
|
+
* create when keys with that name already exist. After the create, fails
|
|
144
|
+
* when the secret is the Management API credential itself, or when the
|
|
145
|
+
* listing does not show exactly one key with that name. It never revokes a
|
|
146
|
+
* key: the returned secret can be a shared credential.
|
|
147
|
+
*/
|
|
148
|
+
export const createTrackedKey = (options: {
|
|
149
|
+
readonly target: string;
|
|
150
|
+
readonly name: string;
|
|
151
|
+
readonly list: Effect.Effect<ReadonlyArray<typeof ListedKey.Type>, ConvexApiError>;
|
|
152
|
+
readonly create: Effect.Effect<string, ConvexApiError>;
|
|
153
|
+
readonly isCredentialToken: (secret: string) => Effect.Effect<boolean>;
|
|
154
|
+
}) =>
|
|
155
|
+
Effect.gen(function* () {
|
|
156
|
+
const { target, name } = options;
|
|
157
|
+
const existing = keysListedAs(yield* options.list, name);
|
|
158
|
+
if (existing.length > 0) {
|
|
159
|
+
return yield* new DeployKeyRecoveryRequired({
|
|
160
|
+
target,
|
|
161
|
+
name,
|
|
162
|
+
keys: existing,
|
|
163
|
+
afterCreate: false,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const secret = yield* options.create;
|
|
167
|
+
if (yield* options.isCredentialToken(secret)) {
|
|
168
|
+
return yield* new DeployKeyIsCredential({ target, name });
|
|
169
|
+
}
|
|
170
|
+
const listed = keysListedAs(yield* options.list, name);
|
|
171
|
+
const [only] = listed;
|
|
172
|
+
if (only === undefined || listed.length !== 1) {
|
|
173
|
+
return yield* new DeployKeyRecoveryRequired({
|
|
174
|
+
target,
|
|
175
|
+
name,
|
|
176
|
+
keys: listed,
|
|
177
|
+
afterCreate: true,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return { keyId: only.id, uniqueName: only.name, secret: Redacted.make(secret) };
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The secret to send to a delete call, or undefined when a delete is not
|
|
185
|
+
* safe. Only a key with a `keyId` in state is known to be a new key that
|
|
186
|
+
* this resource owns: createTrackedKey found exactly one new listed key, and
|
|
187
|
+
* the secret was not the credential. State from 0.1.x has no `keyId`, and its
|
|
188
|
+
* secret can be an OAuth token that Convex returned as a key, so a delete
|
|
189
|
+
* could revoke that OAuth token. Such a key stays, and a warning names it.
|
|
190
|
+
*/
|
|
191
|
+
export const secretToDelete = (
|
|
192
|
+
key: {
|
|
193
|
+
readonly keyId: number | undefined;
|
|
194
|
+
readonly secret: Redacted.Redacted<string> | undefined;
|
|
195
|
+
},
|
|
196
|
+
label: string,
|
|
197
|
+
) =>
|
|
198
|
+
Effect.gen(function* () {
|
|
199
|
+
if (key.keyId === undefined || !Redacted.isRedacted(key.secret)) {
|
|
200
|
+
yield* Effect.logWarning(
|
|
201
|
+
`${label} has no verified key id and secret in state (state from 0.1.x, for example), so Alchemy does not delete it. Delete it in the Convex dashboard.`,
|
|
202
|
+
);
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
return Redacted.value(key.secret);
|
|
206
|
+
});
|
|
207
|
+
|
|
63
208
|
export interface DeployKeyProps {
|
|
64
209
|
/** Deployment name, such as `project.prodDeploymentName`. */
|
|
65
210
|
readonly deployment: string;
|
|
66
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Key name. The provider requests this name, a dash, and a hash of the
|
|
213
|
+
* resource identity, so two resources with one `name` get different keys.
|
|
214
|
+
*/
|
|
67
215
|
readonly name: string;
|
|
68
216
|
/** Limits the key to these actions. Convex grants every deployment action when omitted. */
|
|
69
217
|
readonly allowedActions?: readonly DeployKeyAction[];
|
|
70
218
|
}
|
|
71
219
|
|
|
72
220
|
export interface DeployKeyAttributes {
|
|
73
|
-
/** The name Convex lists the key under, such as "
|
|
221
|
+
/** The name Convex lists the key under, such as "ci-3f2a1b0c9d8e". */
|
|
74
222
|
readonly uniqueName: string;
|
|
223
|
+
/** Numeric id of the key in the listing. Undefined in state written by 0.1.x. */
|
|
224
|
+
readonly keyId: number | undefined;
|
|
75
225
|
readonly deployment: string;
|
|
76
226
|
/** The secret, such as `prod:<deployment>|<token>`. Convex returns it only at creation. */
|
|
77
227
|
readonly deployKey: Redacted.Redacted<string>;
|
|
@@ -101,6 +251,20 @@ export const DeployKeyProvider = () =>
|
|
|
101
251
|
)
|
|
102
252
|
.pipe(Effect.map((body) => Schema.decodeUnknownSync(DeployKeyList)(body)));
|
|
103
253
|
|
|
254
|
+
/**
|
|
255
|
+
* `id` is the secret: Convex accepts it, and it matches only the key
|
|
256
|
+
* that this resource created. A second delete answers 404.
|
|
257
|
+
*/
|
|
258
|
+
const deleteKey = (deployment: string, secret: string) =>
|
|
259
|
+
retryIdempotentWrite(
|
|
260
|
+
api.requestVoid("delete deploy key", (client) =>
|
|
261
|
+
client.POST("/deployments/{deployment_name}/delete_deploy_key", {
|
|
262
|
+
params: { path: { deployment_name: deployment } },
|
|
263
|
+
body: { id: secret },
|
|
264
|
+
}),
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
|
|
104
268
|
return {
|
|
105
269
|
stables: ["uniqueName", "deployment"],
|
|
106
270
|
|
|
@@ -119,52 +283,49 @@ export const DeployKeyProvider = () =>
|
|
|
119
283
|
// A key that is not in state has no known secret, so it is never adopted.
|
|
120
284
|
if (output === undefined) return undefined;
|
|
121
285
|
const entries = yield* absentAsUndefined(listKeys(output.deployment));
|
|
122
|
-
return entries
|
|
286
|
+
return entries !== undefined && isKeyListed(entries, output) ? output : undefined;
|
|
123
287
|
}),
|
|
124
288
|
|
|
125
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
289
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
126
290
|
if (output !== undefined) {
|
|
127
291
|
// The secret cannot be read back. A key that still exists keeps the one in state.
|
|
128
|
-
|
|
129
|
-
if (entries.some((entry) => entry.name === output.uniqueName)) return output;
|
|
292
|
+
if (isKeyListed(yield* listKeys(news.deployment), output)) return output;
|
|
130
293
|
}
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
294
|
+
const name = requestedKeyName(news.name, fqn, instanceId);
|
|
295
|
+
const { keyId, uniqueName, secret } = yield* createTrackedKey({
|
|
296
|
+
target: `deployment ${news.deployment}`,
|
|
297
|
+
name,
|
|
298
|
+
list: listKeys(news.deployment),
|
|
299
|
+
create: api
|
|
300
|
+
.request("create deploy key", (client) =>
|
|
301
|
+
client.POST("/deployments/{deployment_name}/create_deploy_key", {
|
|
302
|
+
params: { path: { deployment_name: news.deployment } },
|
|
303
|
+
body: {
|
|
304
|
+
name,
|
|
305
|
+
...(news.allowedActions === undefined
|
|
306
|
+
? {}
|
|
307
|
+
: { allowedActions: [...news.allowedActions] }),
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
)
|
|
311
|
+
.pipe(
|
|
312
|
+
Effect.map((body) => Schema.decodeUnknownSync(CreatedDeployKey)(body).deployKey),
|
|
148
313
|
),
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
return {
|
|
152
|
-
uniqueName: listed.name,
|
|
153
|
-
deployment: news.deployment,
|
|
154
|
-
deployKey: Redacted.make(deployKey),
|
|
155
|
-
};
|
|
314
|
+
isCredentialToken: api.isCredentialToken,
|
|
315
|
+
});
|
|
316
|
+
return { uniqueName, keyId, deployment: news.deployment, deployKey: secret };
|
|
156
317
|
}),
|
|
157
318
|
|
|
158
319
|
delete: Effect.fn(function* ({ output }) {
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
body: { id: output.uniqueName },
|
|
165
|
-
}),
|
|
166
|
-
),
|
|
320
|
+
// Only the secret names this key alone: 0.1.x state can hold the
|
|
321
|
+
// listed name of another resource's key.
|
|
322
|
+
const secret = yield* secretToDelete(
|
|
323
|
+
{ keyId: output.keyId, secret: output.deployKey },
|
|
324
|
+
`Convex.DeployKey "${output.uniqueName}" on ${output.deployment}`,
|
|
167
325
|
);
|
|
326
|
+
if (secret === undefined) return;
|
|
327
|
+
// 404 DeployKeyNotFound: an earlier attempt or someone else deleted it.
|
|
328
|
+
yield* absentAsUndefined(deleteKey(output.deployment, secret));
|
|
168
329
|
}),
|
|
169
330
|
};
|
|
170
331
|
}),
|