@samebase/alchemy-convex 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samebase/alchemy-convex",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The Convex plane for Alchemy v2: projects, deployments, environment variables, and the push of the functions",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -31,8 +31,7 @@
31
31
  ".": {
32
32
  "types": "./lib/index.d.ts",
33
33
  "import": "./lib/index.js"
34
- },
35
- "./convex.config": "./src/component/convex.config.ts"
34
+ }
36
35
  },
37
36
  "publishConfig": {
38
37
  "access": "public"
package/src/Deployment.ts CHANGED
@@ -69,6 +69,7 @@ import {
69
69
  type EnvValue,
70
70
  keyTargetOf,
71
71
  push,
72
+ PUSH_KEY_ACTIONS,
72
73
  pushHash,
73
74
  type Source,
74
75
  withPushKey,
@@ -681,8 +682,14 @@ export const DeploymentProvider = () =>
681
682
  ? push(spawner, access.key, { ...options, previewName: undefined })
682
683
  : access.kind === "previewKey"
683
684
  ? push(spawner, access.key, { ...options, previewName: target.previewName })
684
- : withPushKey(access.api, target.name, news.source, (key) =>
685
- push(spawner, key, { ...options, previewName: undefined }),
685
+ : withPushKey(
686
+ access.api,
687
+ target.name,
688
+ // A push command gets every action; `convex deploy` only its own.
689
+ typeof news.source === "object" && news.source.command !== undefined
690
+ ? undefined
691
+ : PUSH_KEY_ACTIONS,
692
+ (key) => push(spawner, key, { ...options, previewName: undefined }),
686
693
  );
687
694
  yield* pushed.pipe(
688
695
  // A deployment that this reconcile created and could not push to
package/src/Project.ts CHANGED
@@ -25,7 +25,12 @@ import * as Effect from "effect/Effect";
25
25
  import * as Schema from "effect/Schema";
26
26
  import type { components } from "@convex-dev/platform/managementApi";
27
27
  import { shouldAdopt } from "./Adoption.ts";
28
- import { absentAsUndefined, ConvexApiError, ManagementApi } from "./ManagementApi.ts";
28
+ import {
29
+ absentAsUndefined,
30
+ ConvexApiError,
31
+ ManagementApi,
32
+ type ManagementApiService,
33
+ } from "./ManagementApi.ts";
29
34
  import type { Providers } from "./Providers.ts";
30
35
 
31
36
  /** Region names the Management API accepts, from the generated schema. */
@@ -159,6 +164,8 @@ const DeploymentRow = Schema.Struct({
159
164
  deploymentType: Schema.String,
160
165
  isDefault: Schema.Boolean,
161
166
  deploymentUrl: Schema.String,
167
+ /** Absent or null until the first push. */
168
+ lastDeployTime: Schema.optionalKey(Schema.NullOr(Schema.Number)),
162
169
  });
163
170
  const DeploymentRows = Schema.Array(DeploymentRow);
164
171
 
@@ -202,29 +209,123 @@ const isTeamOf = (details: ProjectDetails, team: string | number) =>
202
209
  ? details.teamId === Number(team)
203
210
  : details.teamSlug === team;
204
211
 
212
+ /** The row of the project's default production deployment, or undefined. */
213
+ export const prodDeploymentOf = (api: ManagementApiService, projectId: number) =>
214
+ api
215
+ .request("list deployments", (client) =>
216
+ client.GET("/projects/{project_id}/list_deployments", {
217
+ params: { path: { project_id: projectId } },
218
+ }),
219
+ )
220
+ .pipe(
221
+ Effect.map((rows) =>
222
+ Schema.decodeUnknownSync(DeploymentRows)(rows).find(
223
+ (row) => row.deploymentType === "prod" && row.isDefault,
224
+ ),
225
+ ),
226
+ );
227
+
228
+ /** GET /projects/{id}, or undefined after a 404. */
229
+ export const getProjectById = (api: ManagementApiService, projectId: number) =>
230
+ absentAsUndefined(
231
+ api.request("get project", (client) =>
232
+ client.GET("/projects/{project_id}", { params: { path: { project_id: projectId } } }),
233
+ ),
234
+ ).pipe(
235
+ Effect.map((body) =>
236
+ body === undefined ? undefined : Schema.decodeUnknownSync(ProjectDetails)(body),
237
+ ),
238
+ );
239
+
240
+ /**
241
+ * Every project in the team with exactly this name, in the order of the
242
+ * listing. Reads every page. `q` narrows the listing on the server, and the
243
+ * exact match here decides. Pattern from Confect
244
+ * `packages/alchemy/src/ConvexClient.ts` and `Project.ts`
245
+ * (https://github.com/rjdellecese/confect, ISC license).
246
+ */
247
+ export const listProjectsByName = (api: ManagementApiService, teamId: number, name: string) =>
248
+ Effect.gen(function* () {
249
+ const matches: ProjectDetails[] = [];
250
+ const seenCursors = new Set<string>();
251
+ let cursor: string | undefined;
252
+ while (true) {
253
+ const body = yield* api.request("list projects", (client) =>
254
+ client.GET("/teams/{team_id}/projects", {
255
+ params: {
256
+ path: { team_id: teamId },
257
+ query: { q: name, ...(cursor === undefined ? {} : { cursor }) },
258
+ },
259
+ }),
260
+ );
261
+ const page = Schema.decodeUnknownSync(ProjectPage)(body);
262
+ matches.push(...page.items.filter((row) => row.name === name));
263
+ if (!page.pagination.hasMore) break;
264
+ const next = page.pagination.nextCursor;
265
+ if (next === undefined || next === null || seenCursors.has(next)) {
266
+ return yield* new ConvexApiError({
267
+ operation: "list projects",
268
+ status: 0,
269
+ code: "InvalidPagination",
270
+ message: `The project listing of team ${teamId} reports more pages but gives no new cursor. Convex.Project stops so that it does not miss a project named "${name}".`,
271
+ });
272
+ }
273
+ seenCursors.add(next);
274
+ cursor = next;
275
+ }
276
+ return matches;
277
+ });
278
+
279
+ /** The one project in the team with exactly this name, or undefined. More than one is an error. */
280
+ export const findProjectByName = (api: ManagementApiService, teamId: number, name: string) =>
281
+ listProjectsByName(api, teamId, name).pipe(
282
+ Effect.flatMap((matches) =>
283
+ matches.length > 1
284
+ ? new AmbiguousProject({ teamId, name, projectIds: matches.map((row) => row.id) })
285
+ : Effect.succeed(matches[0]),
286
+ ),
287
+ );
288
+
289
+ /**
290
+ * POST /teams/{team_id}/create_project, then GET /projects/{id}. The identity
291
+ * comes from the create response, never from a name lookup.
292
+ */
293
+ export const createProject = (
294
+ api: ManagementApiService,
295
+ teamId: number,
296
+ props: {
297
+ readonly name: string;
298
+ readonly deploymentType: "prod" | "dev";
299
+ readonly deploymentRegion: DeploymentRegion | undefined;
300
+ },
301
+ ) =>
302
+ Effect.gen(function* () {
303
+ const created = yield* api.request("create project", (client) =>
304
+ client.POST("/teams/{team_id}/create_project", {
305
+ params: { path: { team_id: teamId } },
306
+ body: {
307
+ projectName: props.name,
308
+ deploymentType: props.deploymentType,
309
+ ...(props.deploymentRegion === undefined
310
+ ? {}
311
+ : { deploymentRegion: props.deploymentRegion }),
312
+ },
313
+ }),
314
+ );
315
+ const { projectId } = Schema.decodeUnknownSync(CreatedProject)(created);
316
+ const details = yield* getProjectById(api, projectId);
317
+ if (details === undefined) return yield* new CreatedProjectNotFound({ projectId });
318
+ return details;
319
+ });
320
+
205
321
  export const ProjectProvider = () =>
206
322
  Provider.effect(
207
323
  Project,
208
324
  Effect.gen(function* () {
209
325
  const api = yield* ManagementApi;
210
326
 
211
- const prodDeployment = (projectId: number) =>
212
- api
213
- .request("list deployments", (client) =>
214
- client.GET("/projects/{project_id}/list_deployments", {
215
- params: { path: { project_id: projectId } },
216
- }),
217
- )
218
- .pipe(
219
- Effect.map((rows) =>
220
- Schema.decodeUnknownSync(DeploymentRows)(rows).find(
221
- (row) => row.deploymentType === "prod" && row.isDefault,
222
- ),
223
- ),
224
- );
225
-
226
327
  const attributesOf = (details: ProjectDetails) =>
227
- prodDeployment(details.id).pipe(
328
+ prodDeploymentOf(api, details.id).pipe(
228
329
  Effect.map((prod): ProjectAttributes => ({
229
330
  projectId: details.id,
230
331
  slug: details.slug,
@@ -235,16 +336,7 @@ export const ProjectProvider = () =>
235
336
  })),
236
337
  );
237
338
 
238
- const getById = (projectId: number) =>
239
- absentAsUndefined(
240
- api.request("get project", (client) =>
241
- client.GET("/projects/{project_id}", { params: { path: { project_id: projectId } } }),
242
- ),
243
- ).pipe(
244
- Effect.map((body) =>
245
- body === undefined ? undefined : Schema.decodeUnknownSync(ProjectDetails)(body),
246
- ),
247
- );
339
+ const getById = (projectId: number) => getProjectById(api, projectId);
248
340
 
249
341
  /** The project with this slug in the team, or undefined. Official endpoint; the team can be a slug. */
250
342
  const getBySlug = (team: string | number, slug: string) =>
@@ -260,51 +352,7 @@ export const ProjectProvider = () =>
260
352
  ),
261
353
  );
262
354
 
263
- /**
264
- * The one project in the team with exactly this name, or undefined.
265
- * Reads every page. `q` narrows the listing on the server, and the
266
- * exact match here decides. Pattern from Confect
267
- * `packages/alchemy/src/ConvexClient.ts` and `Project.ts`
268
- * (https://github.com/rjdellecese/confect, ISC license).
269
- */
270
- const findByName = (teamId: number, name: string) =>
271
- Effect.gen(function* () {
272
- const matches: ProjectDetails[] = [];
273
- const seenCursors = new Set<string>();
274
- let cursor: string | undefined;
275
- while (true) {
276
- const body = yield* api.request("list projects", (client) =>
277
- client.GET("/teams/{team_id}/projects", {
278
- params: {
279
- path: { team_id: teamId },
280
- query: { q: name, ...(cursor === undefined ? {} : { cursor }) },
281
- },
282
- }),
283
- );
284
- const page = Schema.decodeUnknownSync(ProjectPage)(body);
285
- matches.push(...page.items.filter((row) => row.name === name));
286
- if (!page.pagination.hasMore) break;
287
- const next = page.pagination.nextCursor;
288
- if (next === undefined || next === null || seenCursors.has(next)) {
289
- return yield* new ConvexApiError({
290
- operation: "list projects",
291
- status: 0,
292
- code: "InvalidPagination",
293
- message: `The project listing of team ${teamId} reports more pages but gives no new cursor. Convex.Project stops so that it does not miss a project named "${name}".`,
294
- });
295
- }
296
- seenCursors.add(next);
297
- cursor = next;
298
- }
299
- if (matches.length > 1) {
300
- return yield* new AmbiguousProject({
301
- teamId,
302
- name,
303
- projectIds: matches.map((row) => row.id),
304
- });
305
- }
306
- return matches[0];
307
- });
355
+ const findByName = (teamId: number, name: string) => findProjectByName(api, teamId, name);
308
356
 
309
357
  /**
310
358
  * Brings an existing project to the desired name. The team cannot
@@ -458,22 +506,11 @@ export const ProjectProvider = () =>
458
506
  return yield* converge(existing, teamId, news.name);
459
507
  }
460
508
  }
461
- const created = yield* api.request("create project", (client) =>
462
- client.POST("/teams/{team_id}/create_project", {
463
- params: { path: { team_id: teamId } },
464
- body: {
465
- projectName: news.name,
466
- deploymentType: news.deploymentType ?? "prod",
467
- ...(news.deploymentRegion === undefined
468
- ? {}
469
- : { deploymentRegion: news.deploymentRegion }),
470
- },
471
- }),
472
- );
473
- // Identity comes from the create response, never from a name lookup.
474
- const { projectId } = Schema.decodeUnknownSync(CreatedProject)(created);
475
- const details = yield* getById(projectId);
476
- if (details === undefined) return yield* new CreatedProjectNotFound({ projectId });
509
+ const details = yield* createProject(api, teamId, {
510
+ name: news.name,
511
+ deploymentType: news.deploymentType ?? "prod",
512
+ deploymentRegion: news.deploymentRegion,
513
+ });
477
514
  return yield* converge(details, teamId, news.name);
478
515
  }),
479
516
 
package/src/Push.ts CHANGED
@@ -27,6 +27,7 @@ import { tmpdir } from "node:os";
27
27
  import { dirname, join, relative, resolve } from "node:path";
28
28
  import { stripVTControlCharacters } from "node:util";
29
29
  import { createDeploymentClient } from "@convex-dev/platform";
30
+ import type { paths } from "@convex-dev/platform/managementApi";
30
31
  import { hashDirectory } from "alchemy/Command/Memo";
31
32
  import * as Clock from "effect/Clock";
32
33
  import * as Effect from "effect/Effect";
@@ -80,12 +81,19 @@ const sourceOf = (source: Source) =>
80
81
  /** Lifetime of a push key. Long enough for a slow push with index backfills. */
81
82
  export const PUSH_KEY_LIFETIME_MS = 2 * 60 * 60 * 1000;
82
83
 
83
- /** The only actions a push key gets. */
84
- const PUSH_KEY_ACTIONS = [
84
+ /** An action of a deploy key, such as `deployment:deploy`, from the generated schema. */
85
+ export type DeployKeyAction = NonNullable<
86
+ NonNullable<
87
+ paths["/deployments/{deployment_name}/create_deploy_key"]["post"]["requestBody"]
88
+ >["content"]["application/json"]["allowedActions"]
89
+ >[number];
90
+
91
+ /** The only actions a key for `convex deploy` gets. */
92
+ export const PUSH_KEY_ACTIONS = [
85
93
  "deployment:deploy",
86
94
  "deployment:env:view",
87
95
  "deployment:env:write",
88
- ] as const;
96
+ ] as const satisfies readonly DeployKeyAction[];
89
97
 
90
98
  /** POST /deployments/{deployment_name}/create_deploy_key */
91
99
  const CreatedDeployKey = Schema.Struct({ deployKey: Schema.String });
@@ -271,7 +279,7 @@ const CliManifest = Schema.Struct({
271
279
  });
272
280
 
273
281
  /** `[node, <convex bin>]` for the `convex` package that `cwd` resolves. Never downloads. */
274
- const installedCli = (cwd: string) =>
282
+ export const installedCli = (cwd: string) =>
275
283
  Effect.tryPromise({
276
284
  try: async (): Promise<readonly [string, ...string[]]> => {
277
285
  const manifestPath = createRequire(join(cwd, "package.json")).resolve("convex/package.json");
@@ -360,58 +368,78 @@ const cliEnvironment = (home: string): Record<string, string | undefined> => ({
360
368
  XDG_CONFIG_HOME: home,
361
369
  });
362
370
 
371
+ /**
372
+ * A deploy key of `deployment` that expires after PUSH_KEY_LIFETIME_MS, with
373
+ * only `allowedActions`, or every action when that is undefined. Delete it
374
+ * with {@link deleteDeployKey} when the work is done.
375
+ */
376
+ export const createDeployKey = (
377
+ api: ManagementApiService,
378
+ deployment: string,
379
+ name: string,
380
+ allowedActions: readonly DeployKeyAction[] | undefined,
381
+ ) =>
382
+ Effect.gen(function* () {
383
+ const now = yield* Clock.currentTimeMillis;
384
+ const created = yield* api.request("create deploy key", (client) =>
385
+ client.POST("/deployments/{deployment_name}/create_deploy_key", {
386
+ params: { path: { deployment_name: deployment } },
387
+ body: {
388
+ name,
389
+ ...(allowedActions === undefined ? {} : { allowedActions: [...allowedActions] }),
390
+ expiresAt: now + PUSH_KEY_LIFETIME_MS,
391
+ },
392
+ }),
393
+ );
394
+ return Redacted.make(Schema.decodeUnknownSync(CreatedDeployKey)(created).deployKey);
395
+ });
396
+
397
+ /**
398
+ * Deletes a key that {@link createDeployKey} made. The delete is skipped for
399
+ * a key that is the OAuth credential. A failed delete is logged: the key
400
+ * expires anyway.
401
+ */
402
+ export const deleteDeployKey = (
403
+ api: ManagementApiService,
404
+ deployment: string,
405
+ key: Redacted.Redacted<string>,
406
+ ) =>
407
+ Effect.gen(function* () {
408
+ if (yield* api.isCredentialToken(Redacted.value(key))) return;
409
+ yield* absentAsUndefined(
410
+ retryIdempotentWrite(
411
+ api.requestVoid("delete deploy key", (client) =>
412
+ client.POST("/deployments/{deployment_name}/delete_deploy_key", {
413
+ params: { path: { deployment_name: deployment } },
414
+ body: { id: Redacted.value(key) },
415
+ }),
416
+ ),
417
+ ),
418
+ );
419
+ }).pipe(
420
+ Effect.catch((error) =>
421
+ Effect.logWarning(
422
+ `Convex.Deployment could not delete its key on ${deployment} (${error.message}). The key expires on its own within ${PUSH_KEY_LIFETIME_MS / 60_000} minutes.`,
423
+ ),
424
+ ),
425
+ );
426
+
363
427
  /**
364
428
  * Creates a deploy key for one push, runs `use` with it, and deletes it
365
- * afterwards. Without a push command, the key can only deploy and read and
366
- * write variables. The delete is skipped for a key that is the OAuth credential.
367
- * A failed delete is logged: the key expires anyway.
429
+ * afterwards, also after a failure. `allowedActions` is PUSH_KEY_ACTIONS for
430
+ * `convex deploy`, or undefined for a push command, which can do more than a
431
+ * push, such as the uploads of convex-hosting through `convex run`.
368
432
  */
369
433
  export const withPushKey = <A, E, R>(
370
434
  api: ManagementApiService,
371
435
  deployment: string,
372
- source: Source | undefined,
436
+ allowedActions: readonly DeployKeyAction[] | undefined,
373
437
  use: (key: Redacted.Redacted<string>) => Effect.Effect<A, E, R>,
374
438
  ) =>
375
439
  Effect.acquireUseRelease(
376
- Effect.gen(function* () {
377
- const now = yield* Clock.currentTimeMillis;
378
- const created = yield* api.request("create deploy key", (client) =>
379
- client.POST("/deployments/{deployment_name}/create_deploy_key", {
380
- params: { path: { deployment_name: deployment } },
381
- body: {
382
- name: "alchemy-push",
383
- // A push command can do more than a push, such as the uploads of
384
- // convex-hosting through `convex run`, so its key has every action.
385
- ...(source === undefined || sourceOf(source).command === undefined
386
- ? { allowedActions: [...PUSH_KEY_ACTIONS] }
387
- : {}),
388
- expiresAt: now + PUSH_KEY_LIFETIME_MS,
389
- },
390
- }),
391
- );
392
- return Redacted.make(Schema.decodeUnknownSync(CreatedDeployKey)(created).deployKey);
393
- }),
440
+ createDeployKey(api, deployment, "alchemy-push", allowedActions),
394
441
  use,
395
- (key) =>
396
- Effect.gen(function* () {
397
- if (yield* api.isCredentialToken(Redacted.value(key))) return;
398
- yield* absentAsUndefined(
399
- retryIdempotentWrite(
400
- api.requestVoid("delete deploy key", (client) =>
401
- client.POST("/deployments/{deployment_name}/delete_deploy_key", {
402
- params: { path: { deployment_name: deployment } },
403
- body: { id: Redacted.value(key) },
404
- }),
405
- ),
406
- ),
407
- );
408
- }).pipe(
409
- Effect.catch((error) =>
410
- Effect.logWarning(
411
- `Convex.Deployment could not delete its push key on ${deployment} (${error.message}). The key expires on its own within ${PUSH_KEY_LIFETIME_MS / 60_000} minutes.`,
412
- ),
413
- ),
414
- ),
442
+ (key) => deleteDeployKey(api, deployment, key),
415
443
  );
416
444
 
417
445
  /** Every variable of the deployment with its value. */