@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.
@@ -11,7 +11,7 @@ import * as Layer from "effect/Layer";
11
11
  import * as Redacted from "effect/Redacted";
12
12
  import * as Schedule from "effect/Schedule";
13
13
  import * as Schema from "effect/Schema";
14
- import { Credentials } from "./Credentials.ts";
14
+ import { Credentials, type ResolvedCredentials } from "./Credentials.ts";
15
15
 
16
16
  export type ManagementClient = ReturnType<typeof createManagementClient>;
17
17
  export type DeploymentClient = ReturnType<typeof createDeploymentClient>;
@@ -79,6 +79,11 @@ export interface ManagementApiService {
79
79
  * that key would revoke the credential.
80
80
  */
81
81
  readonly isCredentialToken: (secret: string) => Effect.Effect<boolean>;
82
+ /**
83
+ * The same calls with another bearer token, such as a preview deploy key,
84
+ * which the Management API takes for the preview deployments of its project.
85
+ */
86
+ readonly withToken: (token: Redacted.Redacted<string>) => ManagementApiService;
82
87
  }
83
88
 
84
89
  /** The part of a Convex token after the first "|", or the whole value when it has none. */
@@ -174,76 +179,91 @@ export const absentAsUndefined = <A, R>(
174
179
  const TeamRow = Schema.Struct({ id: Schema.Number, slug: Schema.String });
175
180
  const TeamRows = Schema.Array(TeamRow);
176
181
 
177
- export const ManagementApiLive = (): Layer.Layer<ManagementApi, never, Credentials> =>
178
- Layer.effect(
179
- ManagementApi,
180
- Effect.gen(function* () {
181
- const credentials = yield* Credentials;
182
- const client = Effect.cached(
183
- credentials.pipe(
184
- Effect.map((resolved) => createManagementClient(Redacted.value(resolved.accessToken))),
185
- ),
186
- ).pipe(Effect.flatten);
182
+ /** The service for one way to get the bearer token. */
183
+ const makeService = (credentials: Effect.Effect<ResolvedCredentials>): ManagementApiService => {
184
+ const client = Effect.cached(
185
+ credentials.pipe(
186
+ Effect.map((resolved) => createManagementClient(Redacted.value(resolved.accessToken))),
187
+ ),
188
+ ).pipe(Effect.flatten);
187
189
 
188
- const request: ManagementApiService["request"] = (operation, run) =>
189
- client.pipe(Effect.flatMap((c) => settle(operation, () => run(c))));
190
+ const request: ManagementApiService["request"] = (operation, run) =>
191
+ client.pipe(Effect.flatMap((c) => settle(operation, () => run(c))));
190
192
 
191
- const requestVoid: ManagementApiService["requestVoid"] = (operation, run) =>
192
- client.pipe(Effect.flatMap((c) => settleVoid(operation, () => run(c))));
193
+ const requestVoid: ManagementApiService["requestVoid"] = (operation, run) =>
194
+ client.pipe(Effect.flatMap((c) => settleVoid(operation, () => run(c))));
193
195
 
194
- const deploymentRequest: ManagementApiService["deploymentRequest"] = (
195
- operation,
196
- deploymentUrlOrName,
197
- deployKey,
198
- run,
199
- ) =>
200
- settle(operation, () =>
201
- run(createDeploymentClient(deploymentUrlOrName, Redacted.value(deployKey))),
202
- );
203
-
204
- // The Management API addresses teams by numeric id. The only slug lookup
205
- // it offers is per project, so a slug falls back to the dashboard's team
206
- // list, which is unofficial. Pass the numeric team id to skip this.
207
- const resolveTeamId: ManagementApiService["resolveTeamId"] = (team) =>
208
- Effect.gen(function* () {
209
- if (typeof team === "number") return team;
210
- if (/^\d+$/.test(team)) return Number(team);
211
- const resolved = yield* credentials;
212
- const rows = yield* Effect.tryPromise({
213
- try: async () => {
214
- const response = await fetch("https://api.convex.dev/api/dashboard/teams", {
215
- headers: { Authorization: `Bearer ${Redacted.value(resolved.accessToken)}` },
216
- });
217
- if (!response.ok) {
218
- throw new Error(`team lookup for "${team}" failed with HTTP ${response.status}`);
219
- }
220
- return Schema.decodeUnknownSync(TeamRows)(await response.json());
221
- },
222
- catch: (cause) =>
223
- new ConvexApiError({
224
- operation: "resolve team",
225
- status: 0,
226
- message: cause instanceof Error ? cause.message : String(cause),
227
- }),
196
+ const deploymentRequest: ManagementApiService["deploymentRequest"] = (
197
+ operation,
198
+ deploymentUrlOrName,
199
+ deployKey,
200
+ run,
201
+ ) =>
202
+ settle(operation, () =>
203
+ run(createDeploymentClient(deploymentUrlOrName, Redacted.value(deployKey))),
204
+ );
205
+
206
+ // The Management API addresses teams by numeric id. The only slug lookup
207
+ // it offers is per project, so a slug falls back to the dashboard's team
208
+ // list, which is unofficial. Pass the numeric team id to skip this.
209
+ const resolveTeamId: ManagementApiService["resolveTeamId"] = (team) =>
210
+ Effect.gen(function* () {
211
+ if (typeof team === "number") return team;
212
+ if (/^\d+$/.test(team)) return Number(team);
213
+ const resolved = yield* credentials;
214
+ const rows = yield* Effect.tryPromise({
215
+ try: async () => {
216
+ const response = await fetch("https://api.convex.dev/api/dashboard/teams", {
217
+ headers: { Authorization: `Bearer ${Redacted.value(resolved.accessToken)}` },
228
218
  });
229
- const match = rows.find((row) => row.slug === team);
230
- if (match === undefined) {
231
- return yield* new ConvexApiError({
232
- operation: "resolve team",
233
- status: 404,
234
- message: `no team with slug "${team}" for this token`,
235
- });
219
+ if (!response.ok) {
220
+ throw new Error(`team lookup for "${team}" failed with HTTP ${response.status}`);
236
221
  }
237
- return match.id;
222
+ return Schema.decodeUnknownSync(TeamRows)(await response.json());
223
+ },
224
+ catch: (cause) =>
225
+ new ConvexApiError({
226
+ operation: "resolve team",
227
+ status: 0,
228
+ message: cause instanceof Error ? cause.message : String(cause),
229
+ }),
230
+ });
231
+ const match = rows.find((row) => row.slug === team);
232
+ if (match === undefined) {
233
+ return yield* new ConvexApiError({
234
+ operation: "resolve team",
235
+ status: 404,
236
+ message: `no team with slug "${team}" for this token`,
238
237
  });
238
+ }
239
+ return match.id;
240
+ });
241
+
242
+ const isCredentialToken: ManagementApiService["isCredentialToken"] = (secret) =>
243
+ credentials.pipe(
244
+ Effect.map(
245
+ (resolved) => tokenPart(Redacted.value(resolved.accessToken)) === tokenPart(secret),
246
+ ),
247
+ );
239
248
 
240
- const isCredentialToken: ManagementApiService["isCredentialToken"] = (secret) =>
241
- credentials.pipe(
242
- Effect.map(
243
- (resolved) => tokenPart(Redacted.value(resolved.accessToken)) === tokenPart(secret),
244
- ),
245
- );
249
+ const withToken: ManagementApiService["withToken"] = (token) =>
250
+ makeService(Effect.succeed({ accessToken: token, source: "explicit" }));
251
+
252
+ return {
253
+ request,
254
+ requestVoid,
255
+ deploymentRequest,
256
+ resolveTeamId,
257
+ isCredentialToken,
258
+ withToken,
259
+ };
260
+ };
246
261
 
247
- return { request, requestVoid, deploymentRequest, resolveTeamId, isCredentialToken };
262
+ export const ManagementApiLive = (): Layer.Layer<ManagementApi, never, Credentials> =>
263
+ Layer.effect(
264
+ ManagementApi,
265
+ Effect.gen(function* () {
266
+ const credentials = yield* Credentials;
267
+ return makeService(credentials.accessToken);
248
268
  }),
249
269
  );
package/src/Project.ts CHANGED
@@ -12,6 +12,11 @@
12
12
  // - With state, every call addresses the project by `output.projectId`. Only
13
13
  // a resource without state looks a project up by name, and it takes over a
14
14
  // project that it finds only when adoption is on.
15
+ // - With `slug` instead of `name`, the resource is an existing project,
16
+ // found by GET /teams/{team}/projects/{slug}. It never creates, renames, or
17
+ // (by default) deletes that project. A stack that only pushes to an
18
+ // existing project can pass `{ projectId }` to Convex.Deployment instead
19
+ // and needs no Management API token for the project.
15
20
  import { Resource } from "alchemy";
16
21
  import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
17
22
  import { isResolved } from "alchemy/Diff";
@@ -26,16 +31,23 @@ import type { Providers } from "./Providers.ts";
26
31
  /** Region names the Management API accepts, from the generated schema. */
27
32
  export type DeploymentRegion = components["schemas"]["RegionName"];
28
33
 
29
- export interface ProjectProps {
30
- /** Team slug or numeric team id. A slug costs one extra lookup per run. Cannot change. */
31
- readonly team: string | number;
32
- /** Display name. Convex derives the slug from it at creation. A change renames the project. */
33
- readonly name: string;
34
- /** Region of the deployment created with the project. Defaults to the team default. Cannot change. */
35
- readonly deploymentRegion?: DeploymentRegion;
36
- /** Deployment created with the project. Defaults to `prod`. Cannot change. */
37
- readonly deploymentType?: "prod" | "dev";
38
- }
34
+ export type ProjectProps =
35
+ | {
36
+ /** Team slug or numeric team id. A slug costs one extra lookup per run. Cannot change. */
37
+ readonly team: string | number;
38
+ /** Display name. Convex derives the slug from it at creation. A change renames the project. */
39
+ readonly name: string;
40
+ /** Region of the deployment created with the project. Defaults to the team default. Cannot change. */
41
+ readonly deploymentRegion?: DeploymentRegion;
42
+ /** Deployment created with the project. Defaults to `prod`. Cannot change. */
43
+ readonly deploymentType?: "prod" | "dev";
44
+ }
45
+ | {
46
+ /** Team slug or numeric team id. Cannot change. */
47
+ readonly team: string | number;
48
+ /** Slug of an existing project, such as `score-four`. The resource never creates a project. Cannot change. */
49
+ readonly slug: string;
50
+ };
39
51
 
40
52
  export interface ProjectAttributes {
41
53
  readonly projectId: number;
@@ -99,12 +111,33 @@ export class CreatedProjectNotFound extends Schema.TaggedError<CreatedProjectNot
99
111
  }
100
112
  }
101
113
 
114
+ /** No project in the team has the slug. A `slug` resource never creates one. */
115
+ export class ProjectNotFound extends Schema.TaggedError<ProjectNotFound>()("ProjectNotFound", {
116
+ team: Schema.String,
117
+ slug: Schema.String,
118
+ }) {
119
+ override get message() {
120
+ return `Team ${this.team} has no project with the slug "${this.slug}", and a Convex.Project with a slug never creates one. Check the slug in the project URL of the Convex dashboard, or use name to create a project.`;
121
+ }
122
+ }
123
+
124
+ /** The props change the slug of a `slug` resource: that is another project. */
125
+ export class ProjectSlugChange extends Schema.TaggedError<ProjectSlugChange>()(
126
+ "ProjectSlugChange",
127
+ { projectId: Schema.Number, slug: Schema.String, requestedSlug: Schema.String },
128
+ ) {
129
+ override get message() {
130
+ return `Convex.Project ${this.projectId} has the slug "${this.slug}", and the props ask for "${this.requestedSlug}", which is another project. Set slug back to "${this.slug}". For the other project, create a new resource with a new logical id.`;
131
+ }
132
+ }
133
+
102
134
  /** Fields of GET /projects/{id}, PATCH /projects/{id}, and GET /teams/{team}/projects rows that the provider uses. */
103
135
  const ProjectDetails = Schema.Struct({
104
136
  id: Schema.Number,
105
137
  name: Schema.String,
106
138
  slug: Schema.String,
107
139
  teamId: Schema.Number,
140
+ teamSlug: Schema.String,
108
141
  prodDeploymentName: Schema.NullOr(Schema.String),
109
142
  });
110
143
  type ProjectDetails = typeof ProjectDetails.Type;
@@ -133,7 +166,11 @@ const DeploymentRows = Schema.Array(DeploymentRow);
133
166
  * Fails with {@link ProjectDeploymentChange} when a create-time prop
134
167
  * changed. An omitted `deploymentType` means `prod`.
135
168
  */
136
- const checkCreateTimeProps = (projectId: number, olds: ProjectProps, news: ProjectProps) => {
169
+ const checkCreateTimeProps = (
170
+ projectId: number,
171
+ olds: Extract<ProjectProps, { name: string }>,
172
+ news: Extract<ProjectProps, { name: string }>,
173
+ ) => {
137
174
  const oldType = olds.deploymentType ?? "prod";
138
175
  const newType = news.deploymentType ?? "prod";
139
176
  if (oldType !== newType) {
@@ -159,6 +196,12 @@ const checkCreateTimeProps = (projectId: number, olds: ProjectProps, news: Proje
159
196
  return Effect.void;
160
197
  };
161
198
 
199
+ /** True when `team`, a slug or a numeric id, names the team of the project. */
200
+ const isTeamOf = (details: ProjectDetails, team: string | number) =>
201
+ typeof team === "number" || /^\d+$/.test(team)
202
+ ? details.teamId === Number(team)
203
+ : details.teamSlug === team;
204
+
162
205
  export const ProjectProvider = () =>
163
206
  Provider.effect(
164
207
  Project,
@@ -203,6 +246,20 @@ export const ProjectProvider = () =>
203
246
  ),
204
247
  );
205
248
 
249
+ /** The project with this slug in the team, or undefined. Official endpoint; the team can be a slug. */
250
+ const getBySlug = (team: string | number, slug: string) =>
251
+ absentAsUndefined(
252
+ api.request("get project by slug", (client) =>
253
+ client.GET("/teams/{team_id_or_slug}/projects/{project_slug}", {
254
+ params: { path: { team_id_or_slug: String(team), project_slug: slug } },
255
+ }),
256
+ ),
257
+ ).pipe(
258
+ Effect.map((body) =>
259
+ body === undefined ? undefined : Schema.decodeUnknownSync(ProjectDetails)(body),
260
+ ),
261
+ );
262
+
206
263
  /**
207
264
  * The one project in the team with exactly this name, or undefined.
208
265
  * Reads every page. `q` narrows the listing on the server, and the
@@ -279,6 +336,27 @@ export const ProjectProvider = () =>
279
336
  if (!isResolved(news)) return undefined;
280
337
  // Nothing exists yet, so nothing can be lost.
281
338
  if (output === undefined) return undefined;
339
+ if ("slug" in news) {
340
+ if (news.slug !== output.slug) {
341
+ return yield* new ProjectSlugChange({
342
+ projectId: output.projectId,
343
+ slug: output.slug,
344
+ requestedSlug: news.slug,
345
+ });
346
+ }
347
+ if (olds.team !== news.team) {
348
+ const details = yield* getById(output.projectId);
349
+ if (details !== undefined && !isTeamOf(details, news.team)) {
350
+ return yield* new ProjectTeamChange({
351
+ projectId: output.projectId,
352
+ teamId: output.teamId,
353
+ requestedTeamId: yield* api.resolveTeamId(news.team),
354
+ });
355
+ }
356
+ }
357
+ // The resource changes nothing on the project.
358
+ return { action: "noop" } as const;
359
+ }
282
360
  // A deploymentType or deploymentRegion change fails in reconcile,
283
361
  // before any API call. Reconcile compares with the props of the last
284
362
  // successful deploy. Here, `olds` can be the props of a refused
@@ -295,7 +373,9 @@ export const ProjectProvider = () =>
295
373
  }
296
374
  // Never "replace". A name change is an update; reconcile renames
297
375
  // the project with PATCH. Otherwise the engine compares the props.
298
- return olds.name === news.name ? undefined : ({ action: "update" } as const);
376
+ return "name" in olds && olds.name === news.name
377
+ ? undefined
378
+ : ({ action: "update" } as const);
299
379
  }),
300
380
 
301
381
  read: Effect.fn(function* ({ olds, output }) {
@@ -303,17 +383,54 @@ export const ProjectProvider = () =>
303
383
  const details = yield* getById(output.projectId);
304
384
  return details === undefined ? undefined : yield* attributesOf(details);
305
385
  }
306
- // No state: an existing project with this name belongs to someone
307
- // else until the user passes --adopt. Convex has no ownership tags.
308
- const existing = yield* findByName(yield* api.resolveTeamId(olds.team), olds.name);
386
+ // No state: an existing project belongs to someone else until the
387
+ // user passes --adopt. Convex has no ownership tags.
388
+ const existing =
389
+ "slug" in olds
390
+ ? yield* getBySlug(olds.team, olds.slug)
391
+ : yield* findByName(yield* api.resolveTeamId(olds.team), olds.name);
309
392
  return existing === undefined ? undefined : Unowned(yield* attributesOf(existing));
310
393
  }),
311
394
 
312
395
  reconcile: Effect.fn(function* ({ fqn, olds, news, output }) {
396
+ if ("slug" in news) {
397
+ // An existing project: found, checked, never created or changed.
398
+ const details =
399
+ output === undefined
400
+ ? yield* getBySlug(news.team, news.slug)
401
+ : yield* getById(output.projectId);
402
+ if (details === undefined) {
403
+ return yield* new ProjectNotFound({ team: String(news.team), slug: news.slug });
404
+ }
405
+ if (details.slug !== news.slug) {
406
+ return yield* new ProjectSlugChange({
407
+ projectId: details.id,
408
+ slug: details.slug,
409
+ requestedSlug: news.slug,
410
+ });
411
+ }
412
+ if (!isTeamOf(details, news.team)) {
413
+ return yield* new ProjectTeamChange({
414
+ projectId: details.id,
415
+ teamId: details.teamId,
416
+ requestedTeamId: yield* api.resolveTeamId(news.team),
417
+ });
418
+ }
419
+ if (output === undefined && !(yield* shouldAdopt(fqn))) {
420
+ return yield* new OwnedBySomeoneElse({
421
+ message: `Convex project ${details.id} ("${news.slug}") exists and is not in this stack's state. Re-run with --adopt to take it over.`,
422
+ resourceType: Project.Type,
423
+ physicalName: news.slug,
424
+ });
425
+ }
426
+ return yield* attributesOf(details);
427
+ }
313
428
  const teamId = yield* api.resolveTeamId(news.team);
314
429
  if (output !== undefined) {
315
430
  // State names the project. Never look it up by name here.
316
- if (olds !== undefined) yield* checkCreateTimeProps(output.projectId, olds, news);
431
+ if (olds !== undefined && "name" in olds) {
432
+ yield* checkCreateTimeProps(output.projectId, olds, news);
433
+ }
317
434
  if (output.teamId !== teamId) {
318
435
  return yield* new ProjectTeamChange({
319
436
  projectId: output.projectId,
package/src/Providers.ts CHANGED
@@ -1,59 +1,23 @@
1
1
  // The provider collection users merge into a stack:
2
2
  // providers: Layer.mergeAll(Cloudflare.providers(), Convex.providers())
3
- import { CommandExecutorLive } from "alchemy/Command";
4
3
  import * as Provider from "alchemy/Provider";
5
4
  import * as Layer from "effect/Layer";
6
- import { Code, CodeProvider } from "./Code.ts";
7
5
  import { type Credentials, fromConfig } from "./Credentials.ts";
8
- import {
9
- DefaultEnvironmentVariable,
10
- DefaultEnvironmentVariableProvider,
11
- } from "./DefaultEnvironmentVariable.ts";
12
- import { Deploy, DeployProvider } from "./Deploy.ts";
13
- import { DeployKey, DeployKeyProvider } from "./DeployKey.ts";
14
6
  import { Deployment, DeploymentProvider } from "./Deployment.ts";
15
- import { EnvironmentVariable, EnvironmentVariableProvider } from "./EnvironmentVariable.ts";
16
7
  import { ManagementApiLive } from "./ManagementApi.ts";
17
- import { PreviewDeployKey, PreviewDeployKeyProvider } from "./PreviewDeployKey.ts";
18
8
  import { Project, ProjectProvider } from "./Project.ts";
19
9
 
20
10
  export class Providers extends Provider.ProviderCollection<Providers>()("Convex") {}
21
11
 
22
12
  /**
23
- * All Convex resource providers. Pass a credentials layer to override the
24
- * default lookup (`CONVEX_ACCESS_TOKEN`, then the Convex CLI login).
13
+ * The Convex resource providers. Pass a credentials layer to override the
14
+ * default lookup (`CONVEX_ACCESS_TOKEN`, then the Convex CLI login, and
15
+ * `CONVEX_DEPLOY_KEY`).
25
16
  */
26
17
  export const providers = (credentials: Layer.Layer<Credentials> = fromConfig()) =>
27
- Layer.effect(
28
- Providers,
29
- Provider.collection([
30
- Project,
31
- Deployment,
32
- DeployKey,
33
- PreviewDeployKey,
34
- EnvironmentVariable,
35
- DefaultEnvironmentVariable,
36
- Code,
37
- Deploy,
38
- ]),
39
- ).pipe(
40
- Layer.provide(
41
- Layer.mergeAll(
42
- ProjectProvider(),
43
- DeploymentProvider(),
44
- DeployKeyProvider(),
45
- PreviewDeployKeyProvider(),
46
- EnvironmentVariableProvider(),
47
- DefaultEnvironmentVariableProvider(),
48
- CodeProvider(),
49
- DeployProvider(),
50
- ),
51
- ),
18
+ Layer.effect(Providers, Provider.collection([Project, Deployment])).pipe(
19
+ Layer.provide(Layer.mergeAll(ProjectProvider(), DeploymentProvider())),
52
20
  Layer.provide(ManagementApiLive()),
53
21
  Layer.provide(credentials),
54
- // The deprecated Convex.Deploy runs the Convex CLI through Alchemy's
55
- // command executor. Convex.Code spawns it itself, so that its output
56
- // stays out of the logs.
57
- Layer.provide(CommandExecutorLive()),
58
22
  Layer.orDie,
59
23
  );