@samebase/alchemy-convex 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +146 -0
  3. package/lib/Credentials.d.ts +39 -0
  4. package/lib/Credentials.d.ts.map +1 -0
  5. package/lib/Credentials.js +49 -0
  6. package/lib/Credentials.js.map +1 -0
  7. package/lib/DefaultEnvironmentVariable.d.ts +58 -0
  8. package/lib/DefaultEnvironmentVariable.d.ts.map +1 -0
  9. package/lib/DefaultEnvironmentVariable.js +93 -0
  10. package/lib/DefaultEnvironmentVariable.js.map +1 -0
  11. package/lib/Deploy.d.ts +62 -0
  12. package/lib/Deploy.d.ts.map +1 -0
  13. package/lib/Deploy.js +92 -0
  14. package/lib/Deploy.js.map +1 -0
  15. package/lib/DeployKey.d.ts +48 -0
  16. package/lib/DeployKey.d.ts.map +1 -0
  17. package/lib/DeployKey.js +104 -0
  18. package/lib/DeployKey.js.map +1 -0
  19. package/lib/EnvironmentVariable.d.ts +37 -0
  20. package/lib/EnvironmentVariable.d.ts.map +1 -0
  21. package/lib/EnvironmentVariable.js +65 -0
  22. package/lib/EnvironmentVariable.js.map +1 -0
  23. package/lib/ManagementApi.d.ts +50 -0
  24. package/lib/ManagementApi.d.ts.map +1 -0
  25. package/lib/ManagementApi.js +103 -0
  26. package/lib/ManagementApi.js.map +1 -0
  27. package/lib/PreviewDeployKey.d.ts +34 -0
  28. package/lib/PreviewDeployKey.d.ts.map +1 -0
  29. package/lib/PreviewDeployKey.js +78 -0
  30. package/lib/PreviewDeployKey.js.map +1 -0
  31. package/lib/Project.d.ts +29 -0
  32. package/lib/Project.d.ts.map +1 -0
  33. package/lib/Project.js +114 -0
  34. package/lib/Project.js.map +1 -0
  35. package/lib/Providers.d.ts +13 -0
  36. package/lib/Providers.d.ts.map +1 -0
  37. package/lib/Providers.js +30 -0
  38. package/lib/Providers.js.map +1 -0
  39. package/lib/index.d.ts +10 -0
  40. package/lib/index.d.ts.map +1 -0
  41. package/lib/index.js +11 -0
  42. package/lib/index.js.map +1 -0
  43. package/package.json +71 -0
  44. package/src/Credentials.ts +78 -0
  45. package/src/DefaultEnvironmentVariable.ts +165 -0
  46. package/src/Deploy.ts +171 -0
  47. package/src/DeployKey.ts +171 -0
  48. package/src/EnvironmentVariable.ts +129 -0
  49. package/src/ManagementApi.ts +193 -0
  50. package/src/PreviewDeployKey.ts +126 -0
  51. package/src/Project.ts +191 -0
  52. package/src/Providers.ts +51 -0
  53. package/src/index.ts +45 -0
@@ -0,0 +1,165 @@
1
+ // Convex.DefaultEnvironmentVariable: a project default environment variable
2
+ // for one deployment type. Convex sets it on each new deployment of that type,
3
+ // such as auth keys that every pull request preview deployment needs.
4
+ //
5
+ // Defaults live on the Management API and authenticate with the team
6
+ // credential, not with a deploy key. Setting a default and removing it are the
7
+ // same call: update_default_environment_variables with `value: null` removes
8
+ // the default, and removing an absent default also answers 200.
9
+ import { Resource } from "alchemy";
10
+ import { isResolved } from "alchemy/Diff";
11
+ import * as Provider from "alchemy/Provider";
12
+ import * as Effect from "effect/Effect";
13
+ import * as Redacted from "effect/Redacted";
14
+ import * as Schema from "effect/Schema";
15
+ import type { components } from "@convex-dev/platform/managementApi";
16
+ import { absentAsUndefined, ManagementApi, type ManagementApiService } from "./ManagementApi.ts";
17
+ import type { Providers } from "./Providers.ts";
18
+
19
+ /** Deployment types a default can target, from the generated schema. */
20
+ export type DeploymentType = components["schemas"]["DeploymentType"];
21
+
22
+ /**
23
+ * Attributes when the listing has `name` for `deploymentType`. An unfiltered
24
+ * listing merges the deployment types that share one value into one row, so
25
+ * the match checks membership in `deploymentTypes`.
26
+ */
27
+ export const findDefault = (
28
+ list: typeof DefaultEnvironmentVariableList.Type,
29
+ projectId: number,
30
+ name: string,
31
+ deploymentType: DeploymentType,
32
+ ): DefaultEnvironmentVariableAttributes | undefined =>
33
+ list.items.some((item) => item.name === name && item.deploymentTypes.includes(deploymentType))
34
+ ? { projectId, name, deploymentType }
35
+ : undefined;
36
+
37
+ /**
38
+ * Lists the defaults named `name` for `deploymentType`. Convex filters on
39
+ * both, so the listing has at most one row and needs no pagination.
40
+ */
41
+ export const listDefaults = (
42
+ api: ManagementApiService,
43
+ projectId: number,
44
+ name: string,
45
+ deploymentType: DeploymentType,
46
+ ) =>
47
+ api
48
+ .request("list default environment variables", (client) =>
49
+ client.GET("/projects/{project_id}/list_default_environment_variables", {
50
+ params: { path: { project_id: projectId }, query: { name, deploymentType } },
51
+ }),
52
+ )
53
+ .pipe(Effect.map((body) => Schema.decodeUnknownSync(DefaultEnvironmentVariableList)(body)));
54
+
55
+ /**
56
+ * Sets one default, or removes it when `value` is null. Convex answers 200
57
+ * with an empty body, so this reads no response data.
58
+ */
59
+ export const updateDefault = (
60
+ api: ManagementApiService,
61
+ projectId: number,
62
+ name: string,
63
+ deploymentType: DeploymentType,
64
+ value: string | Redacted.Redacted<string> | null,
65
+ ) =>
66
+ api.requestVoid("update default environment variables", (client) =>
67
+ client.POST("/projects/{project_id}/update_default_environment_variables", {
68
+ params: { path: { project_id: projectId } },
69
+ body: {
70
+ changes: [
71
+ {
72
+ name,
73
+ deploymentType,
74
+ value: Redacted.isRedacted(value) ? Redacted.value(value) : value,
75
+ },
76
+ ],
77
+ },
78
+ }),
79
+ );
80
+
81
+ /**
82
+ * GET /projects/{project_id}/list_default_environment_variables. Only the
83
+ * fields the provider uses: the row's `value` is the secret in plain text.
84
+ */
85
+ export const DefaultEnvironmentVariableList = Schema.Struct({
86
+ items: Schema.Array(
87
+ Schema.Struct({ name: Schema.String, deploymentTypes: Schema.Array(Schema.String) }),
88
+ ),
89
+ });
90
+
91
+ export interface DefaultEnvironmentVariableProps {
92
+ /** Numeric project id, such as `project.projectId`. */
93
+ readonly projectId: number;
94
+ readonly name: string;
95
+ readonly value: string | Redacted.Redacted<string>;
96
+ /** Deployments of this type that Convex creates from now on get the variable. */
97
+ readonly deploymentType: DeploymentType;
98
+ }
99
+
100
+ export interface DefaultEnvironmentVariableAttributes {
101
+ readonly projectId: number;
102
+ readonly name: string;
103
+ readonly deploymentType: DeploymentType;
104
+ }
105
+
106
+ export type DefaultEnvironmentVariable = Resource<
107
+ "Convex.DefaultEnvironmentVariable",
108
+ DefaultEnvironmentVariableProps,
109
+ DefaultEnvironmentVariableAttributes,
110
+ never,
111
+ Providers
112
+ >;
113
+ export const DefaultEnvironmentVariable = Resource<DefaultEnvironmentVariable>(
114
+ "Convex.DefaultEnvironmentVariable",
115
+ );
116
+
117
+ export const DefaultEnvironmentVariableProvider = () =>
118
+ Provider.effect(
119
+ DefaultEnvironmentVariable,
120
+ Effect.gen(function* () {
121
+ const api = yield* ManagementApi;
122
+
123
+ return {
124
+ stables: ["projectId", "name", "deploymentType"],
125
+
126
+ diff: ({ olds, news }) => {
127
+ if (!isResolved(news)) return Effect.succeed(undefined);
128
+ // Otherwise undefined: the engine updates when any prop changed and
129
+ // compares a Redacted value by content.
130
+ return Effect.succeed(
131
+ olds.projectId !== news.projectId ||
132
+ olds.name !== news.name ||
133
+ olds.deploymentType !== news.deploymentType
134
+ ? ({ action: "replace" } as const)
135
+ : undefined,
136
+ );
137
+ },
138
+
139
+ read: Effect.fn(function* ({ olds }) {
140
+ // 404 ProjectNotFound: the project no longer exists, so neither does the default.
141
+ const list = yield* absentAsUndefined(
142
+ listDefaults(api, olds.projectId, olds.name, olds.deploymentType),
143
+ );
144
+ if (list === undefined) return undefined;
145
+ return findDefault(list, olds.projectId, olds.name, olds.deploymentType);
146
+ }),
147
+
148
+ reconcile: Effect.fn(function* ({ news }) {
149
+ yield* updateDefault(api, news.projectId, news.name, news.deploymentType, news.value);
150
+ return {
151
+ projectId: news.projectId,
152
+ name: news.name,
153
+ deploymentType: news.deploymentType,
154
+ };
155
+ }),
156
+
157
+ delete: Effect.fn(function* ({ output }) {
158
+ // Removing an absent default succeeds; 404 ProjectNotFound means the project is gone.
159
+ yield* absentAsUndefined(
160
+ updateDefault(api, output.projectId, output.name, output.deploymentType, null),
161
+ );
162
+ }),
163
+ };
164
+ }),
165
+ );
package/src/Deploy.ts ADDED
@@ -0,0 +1,171 @@
1
+ // Convex.Deploy: pushes the functions in a `convex/` directory with
2
+ // `npx convex deploy` and exposes the deployment URL for a frontend build.
3
+ //
4
+ // The deployment itself belongs to the project, so delete leaves the last
5
+ // pushed functions running. The URL comes from the CLI's own output because
6
+ // the deploy key, not this resource, decides which deployment receives the push.
7
+ import { stripVTControlCharacters } from "node:util";
8
+ import { Resource } from "alchemy";
9
+ import { CommandExecutor, type CommandRunProps } from "alchemy/Command";
10
+ import { hashDirectory, type MemoOptions } from "alchemy/Command/Memo";
11
+ import { havePropsChanged, isResolved } from "alchemy/Diff";
12
+ import * as Provider from "alchemy/Provider";
13
+ import * as Effect from "effect/Effect";
14
+ import type * as Redacted from "effect/Redacted";
15
+ import type { Providers } from "./Providers.ts";
16
+
17
+ export interface DeployProps extends Pick<CommandRunProps, "cwd" | "env" | "timeout"> {
18
+ /**
19
+ * Deploy key that selects the target deployment: a production, development,
20
+ * project, or preview deploy key. The CLI receives it as `CONVEX_DEPLOY_KEY`,
21
+ * and Alchemy removes its value from the logged output.
22
+ */
23
+ readonly deployKey: Redacted.Redacted<string>;
24
+ /**
25
+ * Preview deployment name for a preview deploy key. The CLI reuses an
26
+ * existing preview deployment with this name.
27
+ */
28
+ readonly previewName?: string;
29
+ /**
30
+ * Like {@link previewName}, but the CLI deletes and recreates an existing
31
+ * preview deployment with this name, so every run gets a new URL.
32
+ */
33
+ readonly previewCreate?: string;
34
+ /**
35
+ * Function to run after the push, such as `init:seed`. The CLI runs it only
36
+ * when the push created a new preview deployment.
37
+ */
38
+ readonly previewRun?: string;
39
+ /**
40
+ * More `convex deploy` flags, added after the preview flags. Alchemy splits
41
+ * the command on whitespace, so an argument cannot contain whitespace: use
42
+ * `--typecheck=disable`, not `--typecheck disable` as one argument.
43
+ */
44
+ readonly extraArgs?: readonly string[];
45
+ /**
46
+ * Files hashed to skip an unchanged push. By default every non-gitignored
47
+ * file under `cwd` and the nearest lockfile. Use
48
+ * `{ include: ["convex/**"], lockfile: true }` to ignore frontend changes,
49
+ * or `false` to push on every deploy.
50
+ * @default true
51
+ */
52
+ readonly memo?: MemoOptions | boolean;
53
+ }
54
+
55
+ export interface DeployAttributes {
56
+ /** Client URL of the deployment that received the push, such as `https://happy-otter-123.convex.cloud`. */
57
+ readonly url: string;
58
+ /** First label of the URL host, such as `happy-otter-123`. */
59
+ readonly deploymentName: string;
60
+ /** Hash of the memoized input files after the push, or `undefined` when `memo` is `false`. */
61
+ readonly hash: string | undefined;
62
+ }
63
+
64
+ export type Deploy = Resource<"Convex.Deploy", DeployProps, DeployAttributes, never, Providers>;
65
+ export const Deploy = Resource<Deploy>("Convex.Deploy");
66
+
67
+ /**
68
+ * The CLI prints this line with `logFinishedStep` after the push succeeds, to
69
+ * stderr (convex 1.45 `cli/lib/deploy2.js` for existing deployments,
70
+ * `cli/deploy.js` for preview deployments). The host is
71
+ * `<deployment>.convex.cloud` or `<deployment>.<region>.convex.cloud`.
72
+ */
73
+ const DEPLOYED_LINE =
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
+ };
85
+
86
+ /** The `convex deploy` argv, starting with `npx`. */
87
+ export const deployArgs = (
88
+ props: Pick<DeployProps, "previewName" | "previewCreate" | "previewRun" | "extraArgs">,
89
+ ): readonly string[] => [
90
+ "npx",
91
+ "convex",
92
+ "deploy",
93
+ ...(props.previewName === undefined ? [] : ["--preview-name", props.previewName]),
94
+ ...(props.previewCreate === undefined ? [] : ["--preview-create", props.previewCreate]),
95
+ ...(props.previewRun === undefined ? [] : ["--preview-run", props.previewRun]),
96
+ ...(props.extraArgs ?? []),
97
+ ];
98
+
99
+ /** Number of output lines quoted when the CLI exits 0 without the deployed line. */
100
+ const OUTPUT_TAIL_LINES = 20;
101
+
102
+ export const DeployProvider = () =>
103
+ Provider.effect(
104
+ Deploy,
105
+ Effect.gen(function* () {
106
+ const { run } = yield* CommandExecutor;
107
+
108
+ const hashOf = (props: DeployProps) =>
109
+ props.memo === false
110
+ ? Effect.succeed(undefined)
111
+ : hashDirectory({
112
+ ...(props.cwd === undefined ? {} : { cwd: props.cwd }),
113
+ memo: props.memo === true || props.memo === undefined ? {} : props.memo,
114
+ });
115
+
116
+ return {
117
+ // No `stables`: the same deploy key can push to a different URL. A
118
+ // project deploy key follows the project's default production
119
+ // deployment, and a preview deployment can expire or be recreated
120
+ // under the same name.
121
+ diff: Effect.fn(function* ({ olds, news, output }) {
122
+ if (!isResolved(news)) return undefined;
123
+ if (output === undefined || output.hash === undefined || havePropsChanged(olds, news)) {
124
+ return { action: "update" } as const;
125
+ }
126
+ return { action: (yield* hashOf(news)) === output.hash ? "noop" : "update" } as const;
127
+ }),
128
+
129
+ reconcile: Effect.fn(function* ({ news, session }) {
130
+ const args = deployArgs(news);
131
+ 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
+ }
139
+ // A push is idempotent: the CLI uploads the full function set every time.
140
+ const { stdout, stderr } = yield* run(
141
+ {
142
+ command: args.join(" "),
143
+ ...(news.cwd === undefined ? {} : { cwd: news.cwd }),
144
+ ...(news.timeout === undefined ? {} : { timeout: news.timeout }),
145
+ env: { ...news.env, CONVEX_DEPLOY_KEY: news.deployKey },
146
+ },
147
+ session,
148
+ );
149
+ // `run` returns output with the deploy key already replaced.
150
+ const output = `${stdout}\n${stderr}`;
151
+ const deployed = parseDeployOutput(output);
152
+ if (deployed === undefined) {
153
+ const tail = stripVTControlCharacters(output)
154
+ .split(/\r?\n|\r/)
155
+ .filter((line) => line.trim() !== "")
156
+ .slice(-OUTPUT_TAIL_LINES)
157
+ .join("\n");
158
+ return yield* Effect.die(
159
+ new Error(
160
+ `npx convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line. Last output:\n${tail}`,
161
+ ),
162
+ );
163
+ }
164
+ return { ...deployed, hash: yield* hashOf(news) };
165
+ }),
166
+
167
+ // The deployment belongs to the project; its functions stay deployed.
168
+ delete: () => Effect.void,
169
+ };
170
+ }),
171
+ );
@@ -0,0 +1,171 @@
1
+ // Convex.DeployKey: a deploy key for one deployment, such as the key
2
+ // `npx convex deploy` reads from CONVEX_DEPLOY_KEY.
3
+ //
4
+ // Convex returns the secret only once, from create_deploy_key. The listing
5
+ // shows each key under a unique name, the requested name plus a short id
6
+ // suffix such as "my-key (0e71106d)", and delete_deploy_key accepts that
7
+ // unique name. A key cannot be changed after creation, so every property
8
+ // change is a replacement.
9
+ import { Resource } from "alchemy";
10
+ import { havePropsChanged, isResolved } from "alchemy/Diff";
11
+ import * as Provider from "alchemy/Provider";
12
+ import * as Effect from "effect/Effect";
13
+ import * as Redacted from "effect/Redacted";
14
+ import * as Schema from "effect/Schema";
15
+ import type { components } from "@convex-dev/platform/managementApi";
16
+ import { absentAsUndefined, ManagementApi } from "./ManagementApi.ts";
17
+ import type { Providers } from "./Providers.ts";
18
+
19
+ /** Actions a deploy key can be limited to, from the generated schema. */
20
+ export type DeployKeyAction = NonNullable<
21
+ components["schemas"]["PlatformCreateDeployKeyArgs"]["allowedActions"]
22
+ >[number];
23
+
24
+ /**
25
+ * True when `listedName` is how Convex lists a key created as `name`: the
26
+ * requested name, a space, and one parenthesized id without spaces. The id
27
+ * check keeps `my-key` from matching a key created as `my-key (old)`.
28
+ */
29
+ const isListedAs = (listedName: string, name: string) => {
30
+ const prefix = `${name} (`;
31
+ return listedName.startsWith(prefix) && /^[^\s()]+\)$/.test(listedName.slice(prefix.length));
32
+ };
33
+
34
+ /**
35
+ * The listed key that a create call for `name` just made. The newest match
36
+ * wins: an earlier attempt that failed before Alchemy saved state can leave
37
+ * another key with the same requested name, and its secret is lost.
38
+ */
39
+ export const selectCreatedKey = (
40
+ entries: ReadonlyArray<typeof ListedKey.Type>,
41
+ name: string,
42
+ ): typeof ListedKey.Type | undefined =>
43
+ entries
44
+ .filter((entry) => isListedAs(entry.name, name))
45
+ .reduce<typeof ListedKey.Type | undefined>(
46
+ (newest, entry) =>
47
+ newest === undefined || entry.creationTime > newest.creationTime ? entry : newest,
48
+ undefined,
49
+ );
50
+
51
+ /** One row of list_deploy_keys and list_preview_deploy_keys. Only the fields the providers use. */
52
+ export const ListedKey = Schema.Struct({
53
+ name: Schema.String,
54
+ creationTime: Schema.Number,
55
+ });
56
+
57
+ /** GET /deployments/{deployment_name}/list_deploy_keys */
58
+ export const DeployKeyList = Schema.Array(ListedKey);
59
+
60
+ /** POST /deployments/{deployment_name}/create_deploy_key */
61
+ export const CreatedDeployKey = Schema.Struct({ deployKey: Schema.String });
62
+
63
+ export interface DeployKeyProps {
64
+ /** Deployment name, such as `project.prodDeploymentName`. */
65
+ readonly deployment: string;
66
+ /** Requested key name. Convex appends a short id to make it unique. */
67
+ readonly name: string;
68
+ /** Limits the key to these actions. Convex grants every deployment action when omitted. */
69
+ readonly allowedActions?: readonly DeployKeyAction[];
70
+ }
71
+
72
+ export interface DeployKeyAttributes {
73
+ /** The name Convex lists the key under, such as "my-key (0e71106d)". */
74
+ readonly uniqueName: string;
75
+ readonly deployment: string;
76
+ /** The secret, such as `prod:<deployment>|<token>`. Convex returns it only at creation. */
77
+ readonly deployKey: Redacted.Redacted<string>;
78
+ }
79
+
80
+ export type DeployKey = Resource<
81
+ "Convex.DeployKey",
82
+ DeployKeyProps,
83
+ DeployKeyAttributes,
84
+ never,
85
+ Providers
86
+ >;
87
+ export const DeployKey = Resource<DeployKey>("Convex.DeployKey");
88
+
89
+ export const DeployKeyProvider = () =>
90
+ Provider.effect(
91
+ DeployKey,
92
+ Effect.gen(function* () {
93
+ const api = yield* ManagementApi;
94
+
95
+ const listKeys = (deployment: string) =>
96
+ api
97
+ .request("list deploy keys", (client) =>
98
+ client.GET("/deployments/{deployment_name}/list_deploy_keys", {
99
+ params: { path: { deployment_name: deployment } },
100
+ }),
101
+ )
102
+ .pipe(Effect.map((body) => Schema.decodeUnknownSync(DeployKeyList)(body)));
103
+
104
+ return {
105
+ stables: ["uniqueName", "deployment"],
106
+
107
+ diff: ({ olds, news, output }) => {
108
+ if (!isResolved(news)) return Effect.succeed(undefined);
109
+ // State written without the secret cannot hand a key to dependents,
110
+ // and Convex cannot return it again, so only a new key helps.
111
+ return Effect.succeed(
112
+ (output !== undefined && output.deployKey === undefined) || havePropsChanged(olds, news)
113
+ ? ({ action: "replace" } as const)
114
+ : ({ action: "noop" } as const),
115
+ );
116
+ },
117
+
118
+ read: Effect.fn(function* ({ output }) {
119
+ // A key that is not in state has no known secret, so it is never adopted.
120
+ if (output === undefined) return undefined;
121
+ const entries = yield* absentAsUndefined(listKeys(output.deployment));
122
+ return entries?.some((entry) => entry.name === output.uniqueName) ? output : undefined;
123
+ }),
124
+
125
+ reconcile: Effect.fn(function* ({ news, output }) {
126
+ if (output !== undefined) {
127
+ // The secret cannot be read back. A key that still exists keeps the one in state.
128
+ const entries = yield* listKeys(news.deployment);
129
+ if (entries.some((entry) => entry.name === output.uniqueName)) return output;
130
+ }
131
+ const created = yield* api.request("create deploy key", (client) =>
132
+ client.POST("/deployments/{deployment_name}/create_deploy_key", {
133
+ params: { path: { deployment_name: news.deployment } },
134
+ body: {
135
+ name: news.name,
136
+ ...(news.allowedActions === undefined
137
+ ? {}
138
+ : { allowedActions: [...news.allowedActions] }),
139
+ },
140
+ }),
141
+ );
142
+ const { deployKey } = Schema.decodeUnknownSync(CreatedDeployKey)(created);
143
+ const listed = selectCreatedKey(yield* listKeys(news.deployment), news.name);
144
+ if (listed === undefined) {
145
+ return yield* Effect.die(
146
+ new Error(
147
+ `Convex created deploy key "${news.name}" on ${news.deployment} but does not list it`,
148
+ ),
149
+ );
150
+ }
151
+ return {
152
+ uniqueName: listed.name,
153
+ deployment: news.deployment,
154
+ deployKey: Redacted.make(deployKey),
155
+ };
156
+ }),
157
+
158
+ delete: Effect.fn(function* ({ output }) {
159
+ // 404 DeployKeyNotFound: an earlier attempt or someone else deleted it.
160
+ yield* absentAsUndefined(
161
+ api.requestVoid("delete deploy key", (client) =>
162
+ client.POST("/deployments/{deployment_name}/delete_deploy_key", {
163
+ params: { path: { deployment_name: output.deployment } },
164
+ body: { id: output.uniqueName },
165
+ }),
166
+ ),
167
+ );
168
+ }),
169
+ };
170
+ }),
171
+ );
@@ -0,0 +1,129 @@
1
+ // Convex.EnvironmentVariable: one environment variable on one deployment.
2
+ //
3
+ // Environment variables live on the deployment API
4
+ // (https://<deployment>.convex.cloud/api/v1), which authenticates with a
5
+ // deploy key, not with the Management API token. Setting a variable and
6
+ // removing it are the same call: update_environment_variables with
7
+ // `value: null` removes the variable.
8
+ import { createDeploymentClient } from "@convex-dev/platform";
9
+ import { Resource } from "alchemy";
10
+ import { isResolved } from "alchemy/Diff";
11
+ import * as Provider from "alchemy/Provider";
12
+ import * as Effect from "effect/Effect";
13
+ import * as Redacted from "effect/Redacted";
14
+ import * as Schema from "effect/Schema";
15
+ import { absentAsUndefined, ManagementApi, settleVoid } from "./ManagementApi.ts";
16
+ import type { Providers } from "./Providers.ts";
17
+
18
+ /**
19
+ * Attributes when the listing has `name`. Own keys only: a valid variable
20
+ * name such as `toString` must not match the object prototype.
21
+ */
22
+ export const findVariable = (
23
+ list: typeof EnvironmentVariableList.Type,
24
+ deployment: string,
25
+ name: string,
26
+ ): EnvironmentVariableAttributes | undefined =>
27
+ Object.hasOwn(list.environmentVariables, name) ? { deployment, name } : undefined;
28
+
29
+ /**
30
+ * Sets one variable, or removes it when `value` is null. The deployment
31
+ * answers 200 with an empty body, so this reads no response data.
32
+ */
33
+ export const updateVariable = (
34
+ deployment: string,
35
+ deployKey: Redacted.Redacted<string>,
36
+ name: string,
37
+ value: string | Redacted.Redacted<string> | null,
38
+ ) =>
39
+ settleVoid("update environment variables", () =>
40
+ createDeploymentClient(deployment, Redacted.value(deployKey)).POST(
41
+ "/update_environment_variables",
42
+ {
43
+ body: {
44
+ changes: [{ name, value: Redacted.isRedacted(value) ? Redacted.value(value) : value }],
45
+ },
46
+ },
47
+ ),
48
+ );
49
+
50
+ /** GET https://<deployment>.convex.cloud/api/v1/list_environment_variables */
51
+ export const EnvironmentVariableList = Schema.Struct({
52
+ environmentVariables: Schema.Record(Schema.String, Schema.String),
53
+ });
54
+
55
+ export interface EnvironmentVariableProps {
56
+ /** Deployment name, or its URL for deployments outside US East. */
57
+ readonly deployment: string;
58
+ /** Deploy key for `deployment`, such as `deployKey.deployKey`. */
59
+ readonly deployKey: Redacted.Redacted<string>;
60
+ readonly name: string;
61
+ readonly value: string | Redacted.Redacted<string>;
62
+ }
63
+
64
+ export interface EnvironmentVariableAttributes {
65
+ readonly deployment: string;
66
+ readonly name: string;
67
+ }
68
+
69
+ export type EnvironmentVariable = Resource<
70
+ "Convex.EnvironmentVariable",
71
+ EnvironmentVariableProps,
72
+ EnvironmentVariableAttributes,
73
+ never,
74
+ Providers
75
+ >;
76
+ export const EnvironmentVariable = Resource<EnvironmentVariable>("Convex.EnvironmentVariable");
77
+
78
+ export const EnvironmentVariableProvider = () =>
79
+ Provider.effect(
80
+ EnvironmentVariable,
81
+ Effect.gen(function* () {
82
+ const api = yield* ManagementApi;
83
+
84
+ return {
85
+ stables: ["deployment", "name"],
86
+
87
+ diff: ({ olds, news }) => {
88
+ if (!isResolved(news)) return Effect.succeed(undefined);
89
+ // Otherwise undefined: the engine updates when any prop changed and
90
+ // compares Redacted values, such as the value and the deploy key, by content.
91
+ return Effect.succeed(
92
+ olds.deployment !== news.deployment || olds.name !== news.name
93
+ ? ({ action: "replace" } as const)
94
+ : undefined,
95
+ );
96
+ },
97
+
98
+ read: Effect.fn(function* ({ olds }) {
99
+ // 404: the deployment no longer exists, so neither does the variable.
100
+ const body = yield* absentAsUndefined(
101
+ api.deploymentRequest(
102
+ "list environment variables",
103
+ olds.deployment,
104
+ olds.deployKey,
105
+ (client) => client.GET("/list_environment_variables"),
106
+ ),
107
+ );
108
+ if (body === undefined) return undefined;
109
+ return findVariable(
110
+ Schema.decodeUnknownSync(EnvironmentVariableList)(body),
111
+ olds.deployment,
112
+ olds.name,
113
+ );
114
+ }),
115
+
116
+ reconcile: Effect.fn(function* ({ news }) {
117
+ yield* updateVariable(news.deployment, news.deployKey, news.name, news.value);
118
+ return { deployment: news.deployment, name: news.name };
119
+ }),
120
+
121
+ delete: Effect.fn(function* ({ olds, output }) {
122
+ // Removing an absent variable succeeds; 404 means the deployment is gone.
123
+ yield* absentAsUndefined(
124
+ updateVariable(output.deployment, olds.deployKey, output.name, null),
125
+ );
126
+ }),
127
+ };
128
+ }),
129
+ );