@samebase/alchemy-convex 0.2.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/src/index.ts CHANGED
@@ -1,46 +1,21 @@
1
- // @samebase/alchemy-convex: Convex resources for Alchemy v2.
1
+ // @samebase/alchemy-convex: the Convex plane for Alchemy v2.
2
2
  export {
3
3
  ACCESS_TOKEN_ENV,
4
4
  Credentials,
5
5
  CredentialsError,
6
+ DEPLOY_KEY_ENV,
6
7
  fromConfig,
7
- fromToken,
8
+ fromValues,
8
9
  } from "./Credentials.ts";
9
10
  export {
10
- DefaultEnvironmentVariable,
11
- type DefaultEnvironmentVariableAttributes,
12
- type DefaultEnvironmentVariableProps,
13
- type DeploymentType,
14
- } from "./DefaultEnvironmentVariable.ts";
15
- export {
16
- Deploy,
17
- DeployArgumentError,
18
- type DeployAttributes,
19
- deployArgs,
20
- DeployOutputError,
21
- type DeployProps,
22
- parseDeployOutput,
23
- } from "./Deploy.ts";
24
- export {
25
- DeployKey,
26
- type DeployKeyAction,
27
- type DeployKeyAttributes,
28
- DeployKeyIsCredential,
29
- type DeployKeyProps,
30
- DeployKeyRecoveryRequired,
31
- } from "./DeployKey.ts";
32
- export {
33
- EnvironmentVariable,
34
- type EnvironmentVariableAttributes,
35
- type EnvironmentVariableProps,
36
- VariableIdentityChange,
37
- } from "./EnvironmentVariable.ts";
11
+ AmbiguousDeployment,
12
+ Deployment,
13
+ type DeploymentAttributes,
14
+ DeploymentIdentityChange,
15
+ type DeploymentProps,
16
+ ProductionDeploymentNotFound,
17
+ } from "./Deployment.ts";
38
18
  export { ConvexApiError, ManagementApi } from "./ManagementApi.ts";
39
- export {
40
- PreviewDeployKey,
41
- type PreviewDeployKeyAttributes,
42
- type PreviewDeployKeyProps,
43
- } from "./PreviewDeployKey.ts";
44
19
  export {
45
20
  AmbiguousProject,
46
21
  CreatedProjectNotFound,
@@ -48,7 +23,19 @@ export {
48
23
  Project,
49
24
  ProjectDeploymentChange,
50
25
  type ProjectAttributes,
26
+ ProjectNotFound,
51
27
  type ProjectProps,
52
28
  ProjectTeamChange,
53
29
  } from "./Project.ts";
54
30
  export { Providers, providers } from "./Providers.ts";
31
+ export {
32
+ CodePushFailed,
33
+ ConvexAppNotFound,
34
+ ConvexCliNotFound,
35
+ EnvironmentNeedsDeployKey,
36
+ type EnvValue,
37
+ parseDeployOutput,
38
+ PushTargetMismatch,
39
+ type Source,
40
+ VariablesOwnedBySomeoneElse,
41
+ } from "./Push.ts";
@@ -1,263 +0,0 @@
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
- //
10
- // A default that exists but is not in state belongs to someone else: its old
11
- // value is lost when this resource writes it. Alchemy takes it over only with
12
- // --adopt.
13
- //
14
- // The default's project, name, and deployment type cannot change. A change
15
- // fails with VariableIdentityChange: use a new resource with a new logical
16
- // id. Read and delete use the identity in the attributes: after a refused
17
- // change, the props name the refused one.
18
- import { Resource } from "alchemy";
19
- import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
20
- import { isResolved } from "alchemy/Diff";
21
- import * as Provider from "alchemy/Provider";
22
- import * as Effect from "effect/Effect";
23
- import * as Redacted from "effect/Redacted";
24
- import * as Schema from "effect/Schema";
25
- import type { components } from "@convex-dev/platform/managementApi";
26
- import { shouldAdopt } from "./Adoption.ts";
27
- import { VariableIdentityChange } from "./EnvironmentVariable.ts";
28
- import {
29
- absentAsUndefined,
30
- ManagementApi,
31
- type ManagementApiService,
32
- retryIdempotentWrite,
33
- } from "./ManagementApi.ts";
34
- import type { Providers } from "./Providers.ts";
35
-
36
- /** Deployment types a default can target, from the generated schema. */
37
- export type DeploymentType = components["schemas"]["DeploymentType"];
38
-
39
- /**
40
- * Attributes when the listing has `name` for `deploymentType`. An unfiltered
41
- * listing merges the deployment types that share one value into one row, so
42
- * the match checks membership in `deploymentTypes`.
43
- */
44
- export const findDefault = (
45
- list: typeof DefaultEnvironmentVariableList.Type,
46
- projectId: number,
47
- name: string,
48
- deploymentType: DeploymentType,
49
- ): DefaultEnvironmentVariableAttributes | undefined =>
50
- list.items.some((item) => item.name === name && item.deploymentTypes.includes(deploymentType))
51
- ? { projectId, name, deploymentType }
52
- : undefined;
53
-
54
- /**
55
- * Lists the defaults named `name` for `deploymentType`. Convex filters on
56
- * both, so the listing has at most one row and needs no pagination.
57
- */
58
- export const listDefaults = (
59
- api: ManagementApiService,
60
- projectId: number,
61
- name: string,
62
- deploymentType: DeploymentType,
63
- ) =>
64
- api
65
- .request("list default environment variables", (client) =>
66
- client.GET("/projects/{project_id}/list_default_environment_variables", {
67
- params: { path: { project_id: projectId }, query: { name, deploymentType } },
68
- }),
69
- )
70
- .pipe(Effect.map((body) => Schema.decodeUnknownSync(DefaultEnvironmentVariableList)(body)));
71
-
72
- /** One attempt to set one default, or to remove it when `value` is null. */
73
- const writeDefault = (
74
- api: ManagementApiService,
75
- projectId: number,
76
- name: string,
77
- deploymentType: DeploymentType,
78
- value: string | Redacted.Redacted<string> | null,
79
- ) =>
80
- api.requestVoid("update default environment variables", (client) =>
81
- client.POST("/projects/{project_id}/update_default_environment_variables", {
82
- params: { path: { project_id: projectId } },
83
- body: {
84
- changes: [
85
- {
86
- name,
87
- deploymentType,
88
- value: Redacted.isRedacted(value) ? Redacted.value(value) : value,
89
- },
90
- ],
91
- },
92
- }),
93
- );
94
-
95
- /**
96
- * Sets one default, or removes it when `value` is null. Convex answers 200
97
- * with an empty body, so this reads no response data. The call sets a value,
98
- * so it runs again after a write conflict or a 5xx answer, for example when
99
- * another resource writes a default of the same project at the same time.
100
- */
101
- export const updateDefault = (
102
- api: ManagementApiService,
103
- projectId: number,
104
- name: string,
105
- deploymentType: DeploymentType,
106
- value: string | Redacted.Redacted<string> | null,
107
- ) => retryIdempotentWrite(writeDefault(api, projectId, name, deploymentType, value));
108
-
109
- /** The default's identity, or undefined when the default or the project is absent. */
110
- const readDefault = (
111
- api: ManagementApiService,
112
- projectId: number,
113
- name: string,
114
- deploymentType: DeploymentType,
115
- ) =>
116
- // 404 ProjectNotFound: the project no longer exists, so neither does the default.
117
- absentAsUndefined(listDefaults(api, projectId, name, deploymentType)).pipe(
118
- Effect.map((list) =>
119
- list === undefined ? undefined : findDefault(list, projectId, name, deploymentType),
120
- ),
121
- );
122
-
123
- /**
124
- * GET /projects/{project_id}/list_default_environment_variables. Only the
125
- * fields the provider uses: the row's `value` is the secret in plain text.
126
- */
127
- export const DefaultEnvironmentVariableList = Schema.Struct({
128
- items: Schema.Array(
129
- Schema.Struct({ name: Schema.String, deploymentTypes: Schema.Array(Schema.String) }),
130
- ),
131
- });
132
-
133
- export interface DefaultEnvironmentVariableProps {
134
- /** Numeric project id, such as `project.projectId`. */
135
- readonly projectId: number;
136
- readonly name: string;
137
- readonly value: string | Redacted.Redacted<string>;
138
- /** Deployments of this type that Convex creates from now on get the variable. */
139
- readonly deploymentType: DeploymentType;
140
- }
141
-
142
- export interface DefaultEnvironmentVariableAttributes {
143
- readonly projectId: number;
144
- readonly name: string;
145
- readonly deploymentType: DeploymentType;
146
- }
147
-
148
- export type DefaultEnvironmentVariable = Resource<
149
- "Convex.DefaultEnvironmentVariable",
150
- DefaultEnvironmentVariableProps,
151
- DefaultEnvironmentVariableAttributes,
152
- never,
153
- Providers
154
- >;
155
- export const DefaultEnvironmentVariable = Resource<DefaultEnvironmentVariable>(
156
- "Convex.DefaultEnvironmentVariable",
157
- );
158
-
159
- /** Fails with VariableIdentityChange when the props name another default than the one in state. */
160
- const checkIdentity = (
161
- output: DefaultEnvironmentVariableAttributes,
162
- news: DefaultEnvironmentVariableProps,
163
- ) => {
164
- const changed =
165
- output.projectId !== news.projectId
166
- ? ({
167
- field: "projectId",
168
- current: `${output.projectId}`,
169
- requested: `${news.projectId}`,
170
- } as const)
171
- : output.name !== news.name
172
- ? ({ field: "name", current: output.name, requested: news.name } as const)
173
- : output.deploymentType !== news.deploymentType
174
- ? ({
175
- field: "deploymentType",
176
- current: output.deploymentType,
177
- requested: news.deploymentType,
178
- } as const)
179
- : undefined;
180
- return changed === undefined
181
- ? Effect.void
182
- : Effect.fail(
183
- new VariableIdentityChange({ resourceType: DefaultEnvironmentVariable.Type, ...changed }),
184
- );
185
- };
186
-
187
- export const DefaultEnvironmentVariableProvider = () =>
188
- Provider.effect(
189
- DefaultEnvironmentVariable,
190
- Effect.gen(function* () {
191
- const api = yield* ManagementApi;
192
-
193
- return {
194
- // Compares with the attributes: after a refused change, `olds` are
195
- // the refused props. Otherwise undefined: the engine updates when any
196
- // prop changed and compares a Redacted value by content.
197
- diff: Effect.fn(function* ({ news, output }) {
198
- if (!isResolved(news) || output === undefined) return undefined;
199
- yield* checkIdentity(output, news);
200
- return undefined;
201
- }),
202
-
203
- read: Effect.fn(function* ({ olds, output }) {
204
- const at = output ?? olds;
205
- const found = yield* readDefault(api, at.projectId, at.name, at.deploymentType);
206
- if (found === undefined) return undefined;
207
- // No state: the default belongs to someone else until the user passes --adopt.
208
- return output ?? Unowned(found);
209
- }),
210
-
211
- reconcile: Effect.fn(function* ({ fqn, news, output }) {
212
- // Also here: with an unresolved prop in plan, diff could not check.
213
- if (output !== undefined) yield* checkIdentity(output, news);
214
- const write = writeDefault(
215
- api,
216
- news.projectId,
217
- news.name,
218
- news.deploymentType,
219
- news.value,
220
- );
221
- if (output !== undefined || (yield* shouldAdopt(fqn))) {
222
- // In state, or adopted: the default is ours to set.
223
- yield* retryIdempotentWrite(write);
224
- } else {
225
- // Not in state: overwriting a default that exists loses its
226
- // value, so that needs adoption. Each attempt checks first, so a
227
- // retry after a write conflict never overwrites a default that
228
- // another writer created in the meantime.
229
- yield* retryIdempotentWrite(
230
- Effect.gen(function* () {
231
- const existing = yield* readDefault(
232
- api,
233
- news.projectId,
234
- news.name,
235
- news.deploymentType,
236
- );
237
- if (existing !== undefined) {
238
- return yield* new OwnedBySomeoneElse({
239
- message: `Project ${news.projectId} already has a ${news.deploymentType} default environment variable ${news.name}. Re-run with --adopt to take it over and overwrite its value, or use a different name.`,
240
- resourceType: DefaultEnvironmentVariable.Type,
241
- physicalName: news.name,
242
- });
243
- }
244
- yield* write;
245
- }),
246
- );
247
- }
248
- return {
249
- projectId: news.projectId,
250
- name: news.name,
251
- deploymentType: news.deploymentType,
252
- };
253
- }),
254
-
255
- delete: Effect.fn(function* ({ output }) {
256
- // Removing an absent default succeeds; 404 ProjectNotFound means the project is gone.
257
- yield* absentAsUndefined(
258
- updateDefault(api, output.projectId, output.name, output.deploymentType, null),
259
- );
260
- }),
261
- };
262
- }),
263
- );
package/src/Deploy.ts DELETED
@@ -1,193 +0,0 @@
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 * as Schema from "effect/Schema";
16
- import type { Providers } from "./Providers.ts";
17
-
18
- export interface DeployProps extends Pick<CommandRunProps, "cwd" | "env" | "timeout"> {
19
- /**
20
- * Deploy key that selects the target deployment: a production, development,
21
- * project, or preview deploy key. The CLI receives it as `CONVEX_DEPLOY_KEY`,
22
- * and Alchemy removes its value from the logged output.
23
- */
24
- readonly deployKey: Redacted.Redacted<string>;
25
- /**
26
- * Preview deployment name for a preview deploy key. The CLI reuses an
27
- * existing preview deployment with this name.
28
- */
29
- readonly previewName?: string;
30
- /**
31
- * Like {@link previewName}, but the CLI deletes and recreates an existing
32
- * preview deployment with this name, so every run gets a new URL.
33
- */
34
- readonly previewCreate?: string;
35
- /**
36
- * Function to run after the push, such as `init:seed`. The CLI runs it only
37
- * when the push created a new preview deployment.
38
- */
39
- readonly previewRun?: string;
40
- /**
41
- * More `convex deploy` flags, added after the preview flags. Alchemy splits
42
- * the command on whitespace, so an argument cannot contain whitespace: use
43
- * `--typecheck=disable`, not `--typecheck disable` as one argument.
44
- */
45
- readonly extraArgs?: readonly string[];
46
- /**
47
- * Files hashed to skip an unchanged push. By default every non-gitignored
48
- * file under `cwd` and the nearest lockfile. Use
49
- * `{ include: ["convex/**"], lockfile: true }` to ignore frontend changes,
50
- * or `false` to push on every deploy. A preview deploy (`previewName` or
51
- * `previewCreate`) pushes on every deploy: Convex can delete an expired
52
- * preview deployment, and the hash cannot show that.
53
- * @default true
54
- */
55
- readonly memo?: MemoOptions | boolean;
56
- }
57
-
58
- export interface DeployAttributes {
59
- /** Client URL of the deployment that received the push, such as `https://happy-otter-123.convex.cloud`. */
60
- readonly url: string;
61
- /** First label of the URL host, such as `happy-otter-123`. */
62
- readonly deploymentName: string;
63
- /** Hash of the memoized input files after the push, or `undefined` when `memo` is `false`. */
64
- readonly hash: string | undefined;
65
- }
66
-
67
- export type Deploy = Resource<"Convex.Deploy", DeployProps, DeployAttributes, never, Providers>;
68
- export const Deploy = Resource<Deploy>("Convex.Deploy");
69
-
70
- /**
71
- * The CLI prints this line with `logFinishedStep` after the push succeeds, to
72
- * stderr (convex 1.45 `cli/lib/deploy2.js` for existing deployments,
73
- * `cli/deploy.js` for preview deployments). The host is
74
- * `<deployment>.convex.cloud` or `<deployment>.<region>.convex.cloud`.
75
- */
76
- const DEPLOYED_LINE =
77
- /Deployed Convex functions to (https:\/\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud)\/?(?=\s|$)/m;
78
-
79
- /** Reads the deployment URL from `convex deploy` output. ANSI escape codes are removed first. */
80
- export const parseDeployOutput = (
81
- text: string,
82
- ): { url: string; deploymentName: string } | undefined => {
83
- const match = DEPLOYED_LINE.exec(stripVTControlCharacters(text));
84
- const url = match?.[1];
85
- const deploymentName = match?.[2];
86
- return url === undefined || deploymentName === undefined ? undefined : { url, deploymentName };
87
- };
88
-
89
- /** The `convex deploy` argv, starting with `npx`. */
90
- export const deployArgs = (
91
- props: Pick<DeployProps, "previewName" | "previewCreate" | "previewRun" | "extraArgs">,
92
- ): readonly string[] => [
93
- "npx",
94
- "convex",
95
- "deploy",
96
- ...(props.previewName === undefined ? [] : ["--preview-name", props.previewName]),
97
- ...(props.previewCreate === undefined ? [] : ["--preview-create", props.previewCreate]),
98
- ...(props.previewRun === undefined ? [] : ["--preview-run", props.previewRun]),
99
- ...(props.extraArgs ?? []),
100
- ];
101
-
102
- /** Number of output lines quoted when the CLI exits 0 without the deployed line. */
103
- const OUTPUT_TAIL_LINES = 20;
104
-
105
- /** A `convex deploy` argument contains whitespace, which Alchemy's command runner splits on. */
106
- export class DeployArgumentError extends Schema.TaggedError<DeployArgumentError>()(
107
- "DeployArgumentError",
108
- { argument: Schema.String },
109
- ) {
110
- override get message() {
111
- 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.`;
112
- }
113
- }
114
-
115
- /** `npx convex deploy` exited 0 but did not print the deployment URL. */
116
- export class DeployOutputError extends Schema.TaggedError<DeployOutputError>()(
117
- "DeployOutputError",
118
- { outputTail: Schema.String },
119
- ) {
120
- override get message() {
121
- 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}`;
122
- }
123
- }
124
-
125
- export const DeployProvider = () =>
126
- Provider.effect(
127
- Deploy,
128
- Effect.gen(function* () {
129
- const { run } = yield* CommandExecutor;
130
-
131
- const hashOf = (props: DeployProps) =>
132
- props.memo === false
133
- ? Effect.succeed(undefined)
134
- : hashDirectory({
135
- ...(props.cwd === undefined ? {} : { cwd: props.cwd }),
136
- memo: props.memo === true || props.memo === undefined ? {} : props.memo,
137
- });
138
-
139
- return {
140
- // No `stables`: the same deploy key can push to a different URL. A
141
- // project deploy key follows the project's default production
142
- // deployment, and a preview deployment can expire or be recreated
143
- // under the same name.
144
- diff: Effect.fn(function* ({ olds, news, output }) {
145
- if (!isResolved(news)) return undefined;
146
- // A preview deployment can expire. The same hash then hides a
147
- // missing deployment, so a preview always pushes. The CLI reuses
148
- // a live preview with the same name.
149
- const preview = news.previewName !== undefined || news.previewCreate !== undefined;
150
- if (
151
- preview ||
152
- output === undefined ||
153
- output.hash === undefined ||
154
- havePropsChanged(olds, news)
155
- ) {
156
- return { action: "update" } as const;
157
- }
158
- return { action: (yield* hashOf(news)) === output.hash ? "noop" : "update" } as const;
159
- }),
160
-
161
- reconcile: Effect.fn(function* ({ news, session }) {
162
- const args = deployArgs(news);
163
- const spaced = args.find((arg) => /\s/.test(arg));
164
- if (spaced !== undefined) return yield* new DeployArgumentError({ argument: spaced });
165
- // A push is idempotent: the CLI uploads the full function set every time.
166
- const { stdout, stderr } = yield* run(
167
- {
168
- command: args.join(" "),
169
- ...(news.cwd === undefined ? {} : { cwd: news.cwd }),
170
- ...(news.timeout === undefined ? {} : { timeout: news.timeout }),
171
- env: { ...news.env, CONVEX_DEPLOY_KEY: news.deployKey },
172
- },
173
- session,
174
- );
175
- // `run` returns output with the deploy key already replaced.
176
- const output = `${stdout}\n${stderr}`;
177
- const deployed = parseDeployOutput(output);
178
- if (deployed === undefined) {
179
- const outputTail = stripVTControlCharacters(output)
180
- .split(/\r?\n|\r/)
181
- .filter((line) => line.trim() !== "")
182
- .slice(-OUTPUT_TAIL_LINES)
183
- .join("\n");
184
- return yield* new DeployOutputError({ outputTail });
185
- }
186
- return { ...deployed, hash: yield* hashOf(news) };
187
- }),
188
-
189
- // The deployment belongs to the project; its functions stay deployed.
190
- delete: () => Effect.void,
191
- };
192
- }),
193
- );