@prisma/compute-sdk 0.2.0 → 0.3.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/api-client.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { ManagementApiClient, paths } from "@prisma/management-api-sdk";
2
2
  import { Result } from "better-result";
3
- import { ApiError, AuthenticationError } from "./errors.ts";
3
+ import { ApiError, AuthenticationError, CancelledError } from "./errors.ts";
4
4
 
5
- export type ApiClientError = AuthenticationError | ApiError;
5
+ export type ApiClientError = CancelledError | AuthenticationError | ApiError;
6
6
 
7
7
  /**
8
8
  * Lean internal wrapper around ManagementApiClient.
@@ -26,9 +26,21 @@ export class InternalApiClient {
26
26
  signal,
27
27
  }),
28
28
  "/v1/projects",
29
+ signal,
29
30
  );
30
31
  }
31
32
 
33
+ async createProject(body: CreateProjectBody, signal?: AbortSignal) {
34
+ return this.#request(
35
+ this.#client.POST("/v1/projects", {
36
+ body: body as SdkCreateProjectBody,
37
+ signal,
38
+ }),
39
+ "/v1/projects",
40
+ signal,
41
+ ).then((r) => r.map((body) => body.data));
42
+ }
43
+
32
44
  async getProject(id: string, signal?: AbortSignal) {
33
45
  return this.#request(
34
46
  this.#client.GET("/v1/projects/{id}", {
@@ -36,6 +48,7 @@ export class InternalApiClient {
36
48
  signal,
37
49
  }),
38
50
  "/v1/projects/{id}",
51
+ signal,
39
52
  ).then((r) => r.map((body) => body.data));
40
53
  }
41
54
 
@@ -47,6 +60,7 @@ export class InternalApiClient {
47
60
  signal,
48
61
  }),
49
62
  "/v1/projects/{projectId}/compute-services",
63
+ signal,
50
64
  );
51
65
  }
52
66
 
@@ -62,6 +76,7 @@ export class InternalApiClient {
62
76
  signal,
63
77
  }),
64
78
  "/v1/projects/{projectId}/compute-services",
79
+ signal,
65
80
  ).then((r) => r.map((body) => body.data));
66
81
  }
67
82
 
@@ -72,6 +87,7 @@ export class InternalApiClient {
72
87
  signal,
73
88
  }),
74
89
  "/v1/compute-services/{computeServiceId}",
90
+ signal,
75
91
  ).then((r) => r.map((body) => body.data));
76
92
  }
77
93
 
@@ -84,6 +100,7 @@ export class InternalApiClient {
84
100
  params: { path: { computeServiceId } },
85
101
  signal,
86
102
  }),
103
+ signal,
87
104
  );
88
105
  }
89
106
 
@@ -98,6 +115,7 @@ export class InternalApiClient {
98
115
  signal,
99
116
  }),
100
117
  "/v1/compute-services/{computeServiceId}/versions",
118
+ signal,
101
119
  );
102
120
  }
103
121
 
@@ -113,6 +131,7 @@ export class InternalApiClient {
113
131
  signal,
114
132
  }),
115
133
  "/v1/compute-services/{computeServiceId}/versions",
134
+ signal,
116
135
  ).then((r) => r.map((body) => body.data));
117
136
  }
118
137
 
@@ -123,6 +142,7 @@ export class InternalApiClient {
123
142
  signal,
124
143
  }),
125
144
  "/v1/compute-services/versions/{versionId}",
145
+ signal,
126
146
  ).then((r) => r.map((body) => body.data));
127
147
  }
128
148
 
@@ -133,6 +153,7 @@ export class InternalApiClient {
133
153
  signal,
134
154
  }),
135
155
  "/v1/compute-services/versions/{versionId}/start",
156
+ signal,
136
157
  ).then((r) => r.map((body) => body.data));
137
158
  }
138
159
 
@@ -145,6 +166,7 @@ export class InternalApiClient {
145
166
  params: { path: { versionId } },
146
167
  signal,
147
168
  }),
169
+ signal,
148
170
  );
149
171
  }
150
172
 
@@ -157,23 +179,33 @@ export class InternalApiClient {
157
179
  params: { path: { versionId } },
158
180
  signal,
159
181
  }),
182
+ signal,
160
183
  );
161
184
  }
162
185
 
163
186
  async #request<TData>(
164
187
  request: Promise<RequestResult<TData>>,
165
188
  endpoint: string,
189
+ signal?: AbortSignal,
166
190
  ): Promise<Result<NonNullable<TData>, ApiClientError>> {
167
- const result = await request;
191
+ let result: RequestResult<TData>;
192
+ try {
193
+ result = await request;
194
+ } catch (error) {
195
+ return Result.err(parseRequestError(error, undefined, signal));
196
+ }
168
197
  if (result.error) {
169
- return Result.err(parseApiError(result.error, result.response));
198
+ return Result.err(
199
+ parseRequestError(result.error, result.response, signal),
200
+ );
170
201
  }
171
202
  const body = result.data;
172
203
  if (body == null) {
173
204
  return Result.err(
174
- parseApiError(
205
+ parseRequestError(
175
206
  undefined,
176
207
  undefined,
208
+ signal,
177
209
  `Management API returned an empty response body for ${endpoint}`,
178
210
  ),
179
211
  );
@@ -189,12 +221,13 @@ export class InternalApiClient {
189
221
  }>
190
222
  >,
191
223
  endpoint: string,
224
+ signal?: AbortSignal,
192
225
  ): Promise<Result<TItem[], ApiClientError>> {
193
226
  const allItems: TItem[] = [];
194
227
  let cursor: string | undefined;
195
228
 
196
229
  for (;;) {
197
- const result = await this.#request(makeRequest(cursor), endpoint);
230
+ const result = await this.#request(makeRequest(cursor), endpoint, signal);
198
231
  if (result.isErr()) return result;
199
232
 
200
233
  allItems.push(...result.value.data);
@@ -209,10 +242,18 @@ export class InternalApiClient {
209
242
 
210
243
  async #unwrapVoid(
211
244
  request: Promise<RequestResult<unknown>>,
245
+ signal?: AbortSignal,
212
246
  ): Promise<Result<void, ApiClientError>> {
213
- const result = await request;
247
+ let result: RequestResult<unknown>;
248
+ try {
249
+ result = await request;
250
+ } catch (error) {
251
+ return Result.err(parseRequestError(error, undefined, signal));
252
+ }
214
253
  if (result.error) {
215
- return Result.err(parseApiError(result.error, result.response));
254
+ return Result.err(
255
+ parseRequestError(result.error, result.response, signal),
256
+ );
216
257
  }
217
258
  return Result.ok(undefined);
218
259
  }
@@ -222,6 +263,14 @@ type RequestResult<TData> =
222
263
  | { data: TData; error?: never; response: Response }
223
264
  | { data?: never; error: unknown; response: Response };
224
265
 
266
+ type SdkCreateProjectBody = NonNullable<
267
+ paths["/v1/projects"]["post"]["requestBody"]
268
+ >["content"]["application/json"];
269
+
270
+ type CreateProjectBody = Omit<SdkCreateProjectBody, "region"> & {
271
+ region?: string;
272
+ };
273
+
225
274
  type SdkCreateProjectServiceBody = NonNullable<
226
275
  paths["/v1/projects/{projectId}/compute-services"]["post"]["requestBody"]
227
276
  >["content"]["application/json"];
@@ -303,20 +352,25 @@ function parseApiError(
303
352
  });
304
353
  }
305
354
 
355
+ function parseRequestError(
356
+ error: unknown,
357
+ response?: Response,
358
+ signal?: AbortSignal,
359
+ fallbackMessage?: string,
360
+ ): ApiClientError {
361
+ if (signal?.aborted) {
362
+ return new CancelledError();
363
+ }
364
+ return parseApiError(error, response, fallbackMessage);
365
+ }
366
+
306
367
  /** Extract the upload URL from a version creation response. */
307
368
  export function readUploadUrl(data: {
308
369
  uploadUrl?: string | null;
309
- artifactUploadUrl?: string | null;
310
370
  }): string | null {
311
371
  if (typeof data.uploadUrl === "string" && data.uploadUrl.length > 0) {
312
372
  return data.uploadUrl;
313
373
  }
314
- if (
315
- typeof data.artifactUploadUrl === "string" &&
316
- data.artifactUploadUrl.length > 0
317
- ) {
318
- return data.artifactUploadUrl;
319
- }
320
374
  return null;
321
375
  }
322
376
 
package/src/archive.ts CHANGED
@@ -19,9 +19,10 @@ export async function createArchive(
19
19
  entrypoint: string,
20
20
  ): Promise<Uint8Array> {
21
21
  const packer = pack();
22
+ const rootPath = path.resolve(directory);
22
23
 
23
24
  // Walk the directory and add all files
24
- await addDirectory(packer, directory, "bundle");
25
+ await addDirectory(packer, rootPath, rootPath, "bundle");
25
26
 
26
27
  // Inject the manifest as a synthetic entry
27
28
  const manifest = JSON.stringify(
@@ -43,42 +44,49 @@ export async function createArchive(
43
44
 
44
45
  async function addDirectory(
45
46
  packer: Pack,
47
+ rootPath: string,
46
48
  fsPath: string,
47
49
  tarPrefix: string,
48
50
  ): Promise<void> {
49
- const entries = await readdir(fsPath, { withFileTypes: true });
51
+ const directoryPath = resolvePathWithinRoot(rootPath, fsPath);
52
+ const entries = await readdir(directoryPath, { withFileTypes: true });
50
53
 
51
54
  for (const entry of entries) {
52
- const fullPath = path.join(fsPath, entry.name);
55
+ const fullPath = path.join(directoryPath, entry.name);
53
56
  const tarPath = `${tarPrefix}/${entry.name}`;
54
57
 
55
58
  if (entry.isSymbolicLink()) {
56
59
  // Dereference symlinks: stat the target and add accordingly
57
60
  const target = await readlink(fullPath);
58
- const resolvedPath = path.resolve(fsPath, target);
61
+ const resolvedPath = resolvePathWithinRoot(
62
+ rootPath,
63
+ path.resolve(directoryPath, target),
64
+ );
59
65
  const resolvedStat = await stat(resolvedPath);
60
66
 
61
67
  if (resolvedStat.isDirectory()) {
62
- await addDirectory(packer, resolvedPath, tarPath);
68
+ await addDirectory(packer, rootPath, resolvedPath, tarPath);
63
69
  } else if (resolvedStat.isFile()) {
64
- await addFile(packer, resolvedPath, tarPath, resolvedStat);
70
+ await addFile(packer, rootPath, resolvedPath, tarPath, resolvedStat);
65
71
  }
66
72
  } else if (entry.isDirectory()) {
67
- await addDirectory(packer, fullPath, tarPath);
73
+ await addDirectory(packer, rootPath, fullPath, tarPath);
68
74
  } else if (entry.isFile()) {
69
75
  const fileStat = await lstat(fullPath);
70
- await addFile(packer, fullPath, tarPath, fileStat);
76
+ await addFile(packer, rootPath, fullPath, tarPath, fileStat);
71
77
  }
72
78
  }
73
79
  }
74
80
 
75
81
  async function addFile(
76
82
  packer: Pack,
83
+ rootPath: string,
77
84
  fsPath: string,
78
85
  tarPath: string,
79
86
  fileStat: { size: number; mode: number; mtime: Date },
80
87
  ): Promise<void> {
81
- const content = await readFile(fsPath);
88
+ const filePath = resolvePathWithinRoot(rootPath, fsPath);
89
+ const content = await readFile(filePath);
82
90
  packer.entry(
83
91
  {
84
92
  name: tarPath,
@@ -90,6 +98,22 @@ async function addFile(
90
98
  );
91
99
  }
92
100
 
101
+ function resolvePathWithinRoot(rootPath: string, fsPath: string): string {
102
+ const normalizedRootPath = path.resolve(rootPath);
103
+ const resolvedPath = path.resolve(fsPath);
104
+ const relativePath = path.relative(normalizedRootPath, resolvedPath);
105
+
106
+ if (
107
+ relativePath === ".." ||
108
+ relativePath.startsWith(`..${path.sep}`) ||
109
+ path.isAbsolute(relativePath)
110
+ ) {
111
+ throw new Error(`Symlink target escapes archive root: ${resolvedPath}`);
112
+ }
113
+
114
+ return resolvedPath;
115
+ }
116
+
93
117
  function collectGzipped(packer: Pack): Promise<Uint8Array> {
94
118
  return new Promise<Uint8Array>((resolve, reject) => {
95
119
  const chunks: Buffer[] = [];
@@ -1,5 +1,6 @@
1
1
  import type { ManagementApiClient } from "@prisma/management-api-sdk";
2
2
  import { type InferOk, Result } from "better-result";
3
+ import invariant from "tiny-invariant";
3
4
  import {
4
5
  InternalApiClient,
5
6
  readUploadUrl,
@@ -28,11 +29,13 @@ import {
28
29
  type DestroyVersionError,
29
30
  MissingArgumentError,
30
31
  NoExistingVersionError,
32
+ NoVersionsFoundError,
31
33
  type UpdateEnvError,
32
34
  type VersionOperationError,
33
35
  } from "./errors.ts";
34
36
  import { pollVersionStatus } from "./polling.ts";
35
37
  import type {
38
+ CreateProjectResult,
36
39
  PortMapping,
37
40
  ProjectInfo,
38
41
  ResolvedConfig,
@@ -116,6 +119,13 @@ export interface DeleteServiceOptions {
116
119
  signal?: AbortSignal;
117
120
  }
118
121
 
122
+ export interface CreateProjectOptions {
123
+ name: string;
124
+ createDatabase?: boolean;
125
+ region?: string;
126
+ signal?: AbortSignal;
127
+ }
128
+
119
129
  export interface ListProjectsOptions {
120
130
  signal?: AbortSignal;
121
131
  }
@@ -252,7 +262,9 @@ export class ComputeClient {
252
262
  yield* self.#checkAborted(options.signal);
253
263
 
254
264
  options.progress?.onUploadStart?.();
255
- yield* Result.await(self.#uploadArtifact(uploadUrl, archiveBytes));
265
+ yield* Result.await(
266
+ self.#uploadArtifact(uploadUrl, archiveBytes, options.signal),
267
+ );
256
268
  options.progress?.onUploadComplete?.();
257
269
 
258
270
  yield* self.#checkAborted(options.signal);
@@ -408,7 +420,7 @@ export class ComputeClient {
408
420
  .sort(versionSortComparator);
409
421
 
410
422
  if (versionInfos.length === 0) {
411
- return Result.err(new MissingArgumentError({ field: "versionId" }));
423
+ return Result.err(new NoVersionsFoundError({ serviceId }));
412
424
  }
413
425
 
414
426
  if (!options.interaction?.selectVersion) {
@@ -603,6 +615,45 @@ export class ComputeClient {
603
615
  });
604
616
  }
605
617
 
618
+ async createProject(
619
+ options: CreateProjectOptions,
620
+ ): Promise<Result<CreateProjectResult, ApiRequestError>> {
621
+ const self = this;
622
+ return Result.gen(async function* () {
623
+ yield* self.#checkAborted(options.signal);
624
+ const createDatabase = options.createDatabase ?? false;
625
+ const response = yield* Result.await(
626
+ self.#api.createProject(
627
+ {
628
+ name: options.name,
629
+ createDatabase,
630
+ region: options.region,
631
+ },
632
+ options.signal,
633
+ ),
634
+ );
635
+
636
+ const result: CreateProjectResult = {
637
+ id: response.id,
638
+ name: response.name,
639
+ defaultRegion: response.defaultRegion ?? undefined,
640
+ };
641
+
642
+ if (response.database) {
643
+ const connection = response.database.connections?.[0];
644
+ result.database = {
645
+ id: response.database.id,
646
+ name: response.database.name,
647
+ region: response.database.region.id,
648
+ connectionString:
649
+ connection?.endpoints?.direct?.connectionString ?? undefined,
650
+ };
651
+ }
652
+
653
+ return Result.ok(result);
654
+ });
655
+ }
656
+
606
657
  async listProjects(
607
658
  options: ListProjectsOptions = {},
608
659
  ): Promise<Result<ProjectInfo[], ApiRequestError>> {
@@ -819,7 +870,7 @@ export class ComputeClient {
819
870
  serviceName: string;
820
871
  region: string;
821
872
  },
822
- MissingArgumentError | AuthenticationError | ApiError
873
+ MissingArgumentError | CancelledError | AuthenticationError | ApiError
823
874
  >
824
875
  > {
825
876
  const self = this;
@@ -900,9 +951,7 @@ export class ComputeClient {
900
951
 
901
952
  if (selectedServiceId !== null) {
902
953
  const service = services.find((s) => s.id === selectedServiceId);
903
- if (!service) {
904
- throw new Error("selectService returned service ID not in the list");
905
- }
954
+ invariant(service, "selectService returned service ID not in the list");
906
955
  return Result.ok({
907
956
  projectId,
908
957
  serviceId: selectedServiceId,
@@ -950,13 +999,15 @@ export class ComputeClient {
950
999
  async #uploadArtifact(
951
1000
  url: string,
952
1001
  body: Uint8Array,
953
- ): Promise<Result<void, ArtifactError>> {
1002
+ signal?: AbortSignal,
1003
+ ): Promise<Result<void, ArtifactError | CancelledError>> {
954
1004
  return Result.tryPromise({
955
1005
  try: async () => {
956
1006
  const res = await fetch(url, {
957
1007
  method: "PUT",
958
1008
  headers: { "content-type": "application/gzip" },
959
1009
  body,
1010
+ signal,
960
1011
  });
961
1012
 
962
1013
  if (!res.ok) {
@@ -973,10 +1024,12 @@ export class ComputeClient {
973
1024
  }
974
1025
  },
975
1026
  catch: (e) =>
976
- new ArtifactError({
977
- message: e instanceof Error ? e.message : String(e),
978
- cause: e,
979
- }),
1027
+ signal?.aborted
1028
+ ? new CancelledError()
1029
+ : new ArtifactError({
1030
+ message: e instanceof Error ? e.message : String(e),
1031
+ cause: e,
1032
+ }),
980
1033
  });
981
1034
  }
982
1035
  }
package/src/errors.ts CHANGED
@@ -80,6 +80,18 @@ export class NoExistingVersionError extends TaggedError(
80
80
  }
81
81
  }
82
82
 
83
+ export class NoVersionsFoundError extends TaggedError("NoVersionsFoundError")<{
84
+ serviceId: string;
85
+ message: string;
86
+ }>() {
87
+ constructor(args: { serviceId: string }) {
88
+ super({
89
+ serviceId: args.serviceId,
90
+ message: `Service ${args.serviceId} has no versions to destroy`,
91
+ });
92
+ }
93
+ }
94
+
83
95
  export class CancelledError extends TaggedError("CancelledError")<{
84
96
  message: string;
85
97
  }>() {
@@ -131,6 +143,7 @@ export type UpdateEnvError =
131
143
  export type DestroyVersionError =
132
144
  | CancelledError
133
145
  | MissingArgumentError
146
+ | NoVersionsFoundError
134
147
  | AuthenticationError
135
148
  | ApiError
136
149
  | TimeoutError
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export type {
21
21
  UpdateEnvProgress,
22
22
  } from "./callbacks.ts";
23
23
  export type {
24
+ CreateProjectOptions,
24
25
  CreateServiceOptions,
25
26
  DeleteServiceOptions,
26
27
  DeleteVersionOptions,
@@ -60,11 +61,14 @@ export {
60
61
  DestroyAggregateError,
61
62
  MissingArgumentError,
62
63
  NoExistingVersionError,
64
+ NoVersionsFoundError,
63
65
  TimeoutError,
64
66
  VersionFailedError,
65
67
  } from "./errors.ts";
66
68
  // Domain types
67
69
  export type {
70
+ CreateProjectResult,
71
+ DatabaseInfo,
68
72
  PortMapping,
69
73
  ProjectInfo,
70
74
  RegionInfo,
package/src/polling.ts CHANGED
@@ -45,7 +45,9 @@ export async function pollVersionStatus(
45
45
  return Result.err(new CancelledError());
46
46
  }
47
47
 
48
- const version = yield* Result.await(api.getVersion(versionId));
48
+ const version = yield* Result.await(
49
+ api.getVersion(versionId, options.signal),
50
+ );
49
51
 
50
52
  if (version.status !== lastStatus) {
51
53
  lastStatus = version.status;
package/src/types.ts CHANGED
@@ -12,6 +12,17 @@ export interface ProjectInfo {
12
12
  defaultRegion?: string;
13
13
  }
14
14
 
15
+ export interface DatabaseInfo {
16
+ id: string;
17
+ name: string;
18
+ region: string;
19
+ connectionString?: string;
20
+ }
21
+
22
+ export interface CreateProjectResult extends ProjectInfo {
23
+ database?: DatabaseInfo;
24
+ }
25
+
15
26
  export interface ServiceInfo {
16
27
  id: string;
17
28
  name: string;