@samebase/alchemy-convex 0.5.0 → 0.6.1
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 +24 -16
- package/lib/index.d.ts +21 -10
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +277 -158
- package/lib/index.js.map +1 -1
- package/package.json +2 -3
- package/src/Deployment.ts +9 -2
- package/src/ManagementApi.ts +85 -3
- package/src/Project.ts +125 -88
- package/src/Push.ts +74 -46
- package/src/State.ts +281 -159
- package/src/index.ts +1 -1
- package/src/{component/_generated/api.ts → store/convex/_generated/api.d.ts} +9 -8
- package/src/store/convex/_generated/api.js +23 -0
- package/src/{component/_generated/server.ts → store/convex/_generated/server.d.ts} +18 -25
- package/src/store/convex/_generated/server.js +101 -0
- package/src/{component → store/convex}/state.ts +96 -46
- package/src/store/convex/version.ts +6 -0
- package/src/store/package.json +8 -0
- package/src/component/_generated/component.ts +0 -92
- package/src/component/convex.config.ts +0 -5
- /package/src/{component/_generated/dataModel.ts → store/convex/_generated/dataModel.d.ts} +0 -0
- /package/src/{component → store/convex}/schema.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@samebase/alchemy-convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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(
|
|
685
|
-
|
|
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/ManagementApi.ts
CHANGED
|
@@ -69,8 +69,16 @@ export interface ManagementApiService {
|
|
|
69
69
|
deployKey: Redacted.Redacted<string>,
|
|
70
70
|
run: (client: DeploymentClient) => Promise<FetchResult<T>>,
|
|
71
71
|
) => Effect.Effect<T, ConvexApiError>;
|
|
72
|
-
/**
|
|
73
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Team id for a numeric id, a slug, or nothing. A team access token names
|
|
74
|
+
* its team (`GET /token_details`); a slug is then checked against the
|
|
75
|
+
* `teamSlug` of the team's projects, so a token of another team fails. The
|
|
76
|
+
* Convex CLI login and a personal token answer 403 there, so a slug goes to
|
|
77
|
+
* the dashboard's team list, and nothing is an error.
|
|
78
|
+
*/
|
|
79
|
+
readonly resolveTeamId: (
|
|
80
|
+
team: string | number | undefined,
|
|
81
|
+
) => Effect.Effect<number, ConvexApiError>;
|
|
74
82
|
/**
|
|
75
83
|
* True when a deploy key that Convex returned carries the token of the
|
|
76
84
|
* Management API credential itself. With an OAuth token, create_deploy_key
|
|
@@ -179,6 +187,17 @@ export const absentAsUndefined = <A, R>(
|
|
|
179
187
|
const TeamRow = Schema.Struct({ id: Schema.Number, slug: Schema.String });
|
|
180
188
|
const TeamRows = Schema.Array(TeamRow);
|
|
181
189
|
|
|
190
|
+
/** The first page of GET /teams/{team_id}/projects: every row carries the slug of the team. */
|
|
191
|
+
const ProjectSlugRows = Schema.Struct({
|
|
192
|
+
items: Schema.Array(Schema.Struct({ teamSlug: Schema.String })),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/** GET /token_details: the team of a team token, or the project of a project token. */
|
|
196
|
+
const TokenDetails = Schema.Union([
|
|
197
|
+
Schema.Struct({ type: Schema.Literal("teamToken"), teamId: Schema.Number }),
|
|
198
|
+
Schema.Struct({ type: Schema.Literal("projectToken"), projectId: Schema.Number }),
|
|
199
|
+
]);
|
|
200
|
+
|
|
182
201
|
/** The service for one way to get the bearer token. */
|
|
183
202
|
const makeService = (credentials: Effect.Effect<ResolvedCredentials>): ManagementApiService => {
|
|
184
203
|
const client = Effect.cached(
|
|
@@ -203,13 +222,76 @@ const makeService = (credentials: Effect.Effect<ResolvedCredentials>): Managemen
|
|
|
203
222
|
run(createDeploymentClient(deploymentUrlOrName, Redacted.value(deployKey))),
|
|
204
223
|
);
|
|
205
224
|
|
|
225
|
+
// Measured: a team access token answers `GET /token_details` with its
|
|
226
|
+
// team id and gets HTTP 403 from the dashboard's team list; the Convex CLI
|
|
227
|
+
// login answers the team list and gets HTTP 403 `NotOAuthTeamToken` from
|
|
228
|
+
// token_details. Read once for each service.
|
|
229
|
+
// `Effect.cached` only allocates the cache, so runSync makes one for this service.
|
|
230
|
+
const tokenDetails = Effect.runSync(
|
|
231
|
+
Effect.cached(
|
|
232
|
+
request("get token details", (c) => c.GET("/token_details")).pipe(
|
|
233
|
+
Effect.map((body) => Schema.decodeUnknownSync(TokenDetails)(body)),
|
|
234
|
+
Effect.catchIf(
|
|
235
|
+
(error) => error.status === 403,
|
|
236
|
+
() => Effect.succeed(undefined),
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
/** The slug of the team, from the first project of its listing, or undefined for a team without one. Read once. */
|
|
243
|
+
const teamSlug = (teamId: number) =>
|
|
244
|
+
request("list projects", (c) =>
|
|
245
|
+
c.GET("/teams/{team_id}/projects", { params: { path: { team_id: teamId } } }),
|
|
246
|
+
).pipe(
|
|
247
|
+
Effect.map((body) => Schema.decodeUnknownSync(ProjectSlugRows)(body).items[0]?.teamSlug),
|
|
248
|
+
);
|
|
249
|
+
|
|
206
250
|
// The Management API addresses teams by numeric id. The only slug lookup
|
|
207
251
|
// it offers is per project, so a slug falls back to the dashboard's team
|
|
208
252
|
// list, which is unofficial. Pass the numeric team id to skip this.
|
|
209
253
|
const resolveTeamId: ManagementApiService["resolveTeamId"] = (team) =>
|
|
210
254
|
Effect.gen(function* () {
|
|
211
255
|
if (typeof team === "number") return team;
|
|
212
|
-
if (/^\d+$/.test(team)) return Number(team);
|
|
256
|
+
if (team !== undefined && /^\d+$/.test(team)) return Number(team);
|
|
257
|
+
const details = yield* tokenDetails;
|
|
258
|
+
if (details?.type === "teamToken") {
|
|
259
|
+
if (team === undefined) return details.teamId;
|
|
260
|
+
// The Management API has no team-by-id call, so the slug of the
|
|
261
|
+
// team comes from any of its projects. A team without a project
|
|
262
|
+
// cannot be checked: the numeric id, or no team, is the fix.
|
|
263
|
+
const slug = yield* teamSlug(details.teamId);
|
|
264
|
+
if (slug === undefined) {
|
|
265
|
+
return yield* new ConvexApiError({
|
|
266
|
+
operation: "resolve team",
|
|
267
|
+
status: 0,
|
|
268
|
+
message: `team "${team}" cannot be checked against the access token: team ${details.teamId} has no project yet. Set team to ${details.teamId}, or leave it out.`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (slug !== team) {
|
|
272
|
+
return yield* new ConvexApiError({
|
|
273
|
+
operation: "resolve team",
|
|
274
|
+
status: 403,
|
|
275
|
+
message: `the access token belongs to team ${details.teamId} ("${slug}"), and team is "${team}". Use a token of "${team}", or set team to ${details.teamId}.`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return details.teamId;
|
|
279
|
+
}
|
|
280
|
+
if (details?.type === "projectToken") {
|
|
281
|
+
return yield* new ConvexApiError({
|
|
282
|
+
operation: "resolve team",
|
|
283
|
+
status: 403,
|
|
284
|
+
message: `the access token belongs to project ${details.projectId}, and a project token cannot create projects. Use a team access token.`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (team === undefined) {
|
|
288
|
+
return yield* new ConvexApiError({
|
|
289
|
+
operation: "resolve team",
|
|
290
|
+
status: 0,
|
|
291
|
+
message:
|
|
292
|
+
"set team to the slug or the id of the Convex team: this access token does not name a team.",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
213
295
|
const resolved = yield* credentials;
|
|
214
296
|
const rows = yield* Effect.tryPromise({
|
|
215
297
|
try: async () => {
|
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 {
|
|
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
|
-
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
/**
|
|
84
|
-
|
|
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
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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
|
-
|
|
436
|
+
allowedActions: readonly DeployKeyAction[] | undefined,
|
|
373
437
|
use: (key: Redacted.Redacted<string>) => Effect.Effect<A, E, R>,
|
|
374
438
|
) =>
|
|
375
439
|
Effect.acquireUseRelease(
|
|
376
|
-
|
|
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. */
|