@akanjs/devkit 3.1.0-rc.0 → 3.1.0-rc.2

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.
Files changed (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +25 -13
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +151 -41
  16. package/cloud/updatePackUpload.ts +60 -23
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +46 -0
  24. package/executors.ts +26 -23
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -0
  35. package/mobile/nativeApp.ts +109 -63
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. package/typecheck/projectTypecheck.ts +6 -5
@@ -54,8 +54,14 @@ export class GlobalConfig {
54
54
  });
55
55
  }
56
56
  static async hostFor(workspace: { getConfig(): Promise<{ cloudHost: string | null }> }, host?: string | null) {
57
- if (host) return host;
58
- return (await workspace.getConfig()).cloudHost ?? GlobalConfig.akanCloudHost;
57
+ return GlobalConfig.#normalizeHost(host || ((await workspace.getConfig()).cloudHost ?? GlobalConfig.akanCloudHost));
58
+ }
59
+ //? A login is stored under the host string, so `--host` and the workspace's `cloud.host` must spell one cloud alike.
60
+ static #normalizeHost(host: string) {
61
+ const url = URL.canParse(host) ? new URL(host) : null;
62
+ return url && (url.protocol === "https:" || url.protocol === "http:")
63
+ ? `${url.origin}${url.pathname.replace(/\/+$/, "")}`
64
+ : host.replace(/\/+$/, "");
59
65
  }
60
66
  static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
61
67
  const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
package/cloud/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from "./cloudApi";
2
+ export * from "./cloudHttpError";
3
+ export * from "./cloudUpdates";
2
4
  export * from "./constants";
3
5
  export * from "./globalConfig";
6
+ export * from "./pipelineSecrets";
4
7
  export * from "./updatePackUpload";
@@ -0,0 +1,114 @@
1
+ import { capitalize, Logger, PipelineNames } from "akanjs/common";
2
+ import { CloudApi, type PipelineSecretListing } from "./cloudApi";
3
+
4
+ //* The named secrets an Akan Cloud project hands its pipeline: written and removed by name, listed without values.
5
+ export class PipelineSecrets {
6
+ static readonly #secretBytes = 64 * 1024;
7
+ //? The branches Akan Cloud deploys (its BranchType): a secret scoped to any other is refused by the server.
8
+ static readonly #secretBranches = ["debug", "develop", "main"];
9
+ static readonly #secretsUnavailable = "The server is not configured for pipeline secrets yet";
10
+
11
+ constructor(
12
+ readonly cloudApi: CloudApi,
13
+ readonly devProjectId: string,
14
+ ) {}
15
+
16
+ //* Never an argument: a value on the command line lands in shell history and in any `ps` listing.
17
+ static async readValue(name: string, file: string | null) {
18
+ const raw = file
19
+ ? await Bun.file(file).text()
20
+ : process.stdin.isTTY
21
+ ? await (await import("@inquirer/prompts")).password({
22
+ message: `Value of ${name} (hidden; it can never be read back)`,
23
+ mask: "*",
24
+ })
25
+ : await PipelineSecrets.#readStdin(name);
26
+ const value = raw.replace(/\r?\n$/, "");
27
+ if (!value) throw new Error(`The value of ${name} is empty.`);
28
+ const bytes = new TextEncoder().encode(value).length;
29
+ if (bytes > PipelineSecrets.#secretBytes)
30
+ throw new Error(`The value of ${name} is ${bytes} bytes, over ${PipelineSecrets.#secretBytes}.`);
31
+ return value;
32
+ }
33
+
34
+ static async #readStdin(name: string) {
35
+ process.stderr.write(`Reading the value of ${name} from stdin until it closes (or pass --file <path>).\n`);
36
+ return await Bun.stdin.text();
37
+ }
38
+
39
+ async upload({ name, branch, value }: { name: string; branch: string | null; value: string }) {
40
+ PipelineSecrets.assertName(name);
41
+ PipelineSecrets.assertBranch(branch);
42
+ const saved = await this.cloudApi
43
+ .uploadSecret(this.devProjectId, { scope: branch ?? "*", name, value })
44
+ .catch((error: unknown) => {
45
+ throw CloudApi.refusal(error, "a secret is written by a project member signed in with `akan login`", {
46
+ unavailable: PipelineSecrets.#secretsUnavailable,
47
+ });
48
+ });
49
+ Logger.rawLog(
50
+ `Saved ${saved.name} for ${saved.scope === "*" ? "every branch" : `branch ${saved.scope}`}. Builds that start from now on use it.`,
51
+ );
52
+ }
53
+
54
+ async list(branch: string | null) {
55
+ PipelineSecrets.assertBranch(branch);
56
+ const listed = await this.cloudApi.listSecrets(this.devProjectId).catch((error: unknown) => {
57
+ throw CloudApi.refusal(error, "the secrets are listed for a project member", {
58
+ unavailable: PipelineSecrets.#secretsUnavailable,
59
+ });
60
+ });
61
+ const secrets = listed.filter((secret) => !branch || secret.scope === "*" || secret.scope === branch);
62
+ Logger.rawLog(PipelineSecrets.format(secrets));
63
+ }
64
+
65
+ async remove({ name, branch }: { name: string; branch: string | null }) {
66
+ PipelineSecrets.assertName(name);
67
+ PipelineSecrets.assertBranch(branch);
68
+ const removed = await this.cloudApi
69
+ .removeSecret(this.devProjectId, { scope: branch ?? "*", name })
70
+ .catch((error: unknown) => {
71
+ throw CloudApi.refusal(error, "a secret is removed by a project member signed in with `akan login`", {
72
+ unavailable: PipelineSecrets.#secretsUnavailable,
73
+ });
74
+ });
75
+ Logger.rawLog(
76
+ removed
77
+ ? `Removed ${name} for ${branch ? `branch ${branch}` : "every branch"}.`
78
+ : `No ${name} for ${branch ? `branch ${branch}` : "every branch"} to remove.`,
79
+ );
80
+ }
81
+
82
+ static format(secrets: PipelineSecretListing[]) {
83
+ if (!secrets.length) return "No pipeline secrets.";
84
+ const rows = [...secrets]
85
+ .sort((a, b) => a.name.localeCompare(b.name) || a.scope.localeCompare(b.scope))
86
+ .map((secret) =>
87
+ [secret.name, secret.scope === "*" ? "every branch" : secret.scope, secret.updatedAt, secret.updatedBy].map(
88
+ (cell) => String(cell ?? "-"),
89
+ ),
90
+ );
91
+ const header = ["NAME", "SCOPE", "UPDATED", "BY"];
92
+ const widths = header.map((title, column) =>
93
+ Math.max(title.length, ...rows.map((row) => row[column]?.length ?? 0)),
94
+ );
95
+ return [header, ...rows]
96
+ .map((row) =>
97
+ row
98
+ .map((cell, column) => cell.padEnd(widths[column] ?? 0))
99
+ .join(" ")
100
+ .trimEnd(),
101
+ )
102
+ .join("\n");
103
+ }
104
+
105
+ static assertName(name: string) {
106
+ const problem = PipelineNames.secretNameError(name);
107
+ if (problem) throw new Error(`${capitalize(problem)}.`);
108
+ }
109
+
110
+ static assertBranch(branch: string | null) {
111
+ if (branch !== null && !PipelineSecrets.#secretBranches.includes(branch))
112
+ throw new Error(`--branch ${branch} is not a deployed branch: ${PipelineSecrets.#secretBranches.join(", ")}.`);
113
+ }
114
+ }
@@ -13,13 +13,15 @@ interface Put {
13
13
  body: Uint8Array;
14
14
  }
15
15
 
16
- class FakeAkasys {
16
+ class FakeCloudHost {
17
17
  readonly server: ReturnType<typeof Bun.serve>;
18
18
  readonly requests: Record<string, unknown>[] = [];
19
19
  readonly puts: Put[] = [];
20
20
  readonly commits: string[] = [];
21
- putStatuses: number[] = [];
22
- commitAnswers: number[] = [];
21
+ readonly registrations: Record<string, string>[] = [];
22
+ putStatuses: (number | Response)[] = [];
23
+ stallPuts: boolean = false;
24
+ commitAnswers: (number | [number, string] | Response)[] = [];
23
25
  release: Record<string, unknown> = { id: "release-1", status: "published", problems: [] };
24
26
  requestRefusal: Response | null = null;
25
27
 
@@ -35,7 +37,9 @@ class FakeAkasys {
35
37
  this.requests.length = 0;
36
38
  this.puts.length = 0;
37
39
  this.commits.length = 0;
40
+ this.registrations.length = 0;
38
41
  this.putStatuses = [];
42
+ this.stallPuts = false;
39
43
  this.commitAnswers = [];
40
44
  this.requestRefusal = null;
41
45
  }
@@ -59,23 +63,42 @@ class FakeAkasys {
59
63
  headers: Object.fromEntries(request.headers),
60
64
  body: new Uint8Array(await request.arrayBuffer()),
61
65
  });
62
- return new Response(null, { status: this.putStatuses.shift() ?? 200 });
66
+ if (this.stallPuts) return await new Promise<Response>(() => undefined);
67
+ const answer = this.putStatuses.shift() ?? 200;
68
+ return answer instanceof Response ? answer : new Response(null, { status: answer });
63
69
  }
64
70
  if (url.pathname === "/api/commitUpdateUpload/project-1") {
65
71
  const { uploadId } = (await request.json()) as { uploadId: string };
66
72
  this.commits.push(uploadId);
67
- const answer = this.commitAnswers.shift();
68
- if (answer) return Response.json({ error: "release.error.uploadCommitInProgress" }, { status: answer });
73
+ const answer = this.#answer();
74
+ if (answer) return answer;
69
75
  return Response.json({ ...this.release, uploadId });
70
76
  }
77
+ if (url.pathname === "/api/registerAppBinary/project-1") {
78
+ const form = await request.formData();
79
+ this.registrations.push(
80
+ Object.fromEntries(
81
+ [...form.entries()].map(([key, value]) => [key, typeof value === "string" ? value : "<file>"]),
82
+ ),
83
+ );
84
+ return this.#answer() ?? Response.json({ id: "binary-1" });
85
+ }
71
86
  return new Response("not found", { status: 404 });
72
87
  }
88
+
89
+ #answer() {
90
+ const answer = this.commitAnswers.shift();
91
+ if (!answer) return null;
92
+ if (answer instanceof Response) return answer;
93
+ const [status, key] = typeof answer === "number" ? [answer, "release.error.uploadCommitInProgress"] : answer;
94
+ return Response.json({ error: key }, { status });
95
+ }
73
96
  }
74
97
 
75
98
  const makeDir = tempDirs("akan-update-upload-");
76
- const akasys = new FakeAkasys();
99
+ const cloudHost = new FakeCloudHost();
77
100
  const api = new CloudApi({} as Workspace, {
78
- host: akasys.host,
101
+ host: cloudHost.host,
79
102
  auth: { accessToken: { jwt: "jwt-1", refreshToken: null, expiresAt: null } },
80
103
  });
81
104
  const fields = { appName: "lu", target: "default", platform: "android" as const, channel: "develop", notes: null };
@@ -87,8 +110,8 @@ const archiveOf = async (bytes: string) => {
87
110
  return archive;
88
111
  };
89
112
 
90
- beforeEach(() => akasys.reset());
91
- afterAll(() => akasys.server.stop(true));
113
+ beforeEach(() => cloudHost.reset());
114
+ afterAll(() => cloudHost.server.stop(true));
92
115
 
93
116
  describe("UpdatePackUpload", () => {
94
117
  test("announces the archive's size and digest, PUTs it with nothing but its content type, then commits", async () => {
@@ -97,7 +120,7 @@ describe("UpdatePackUpload", () => {
97
120
  const release = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
98
121
 
99
122
  expect(release).toEqual({ id: "release-1", status: "published", problems: [], uploadId: "upload-1" });
100
- expect(akasys.requests).toEqual([
123
+ expect(cloudHost.requests).toEqual([
101
124
  {
102
125
  ...fields,
103
126
  size: 10,
@@ -105,91 +128,178 @@ describe("UpdatePackUpload", () => {
105
128
  authorization: "Bearer jwt-1",
106
129
  },
107
130
  ]);
108
- const [put] = akasys.puts;
131
+ const [put] = cloudHost.puts;
109
132
  expect(new TextDecoder().decode(put?.body)).toBe("pack bytes");
110
133
  expect(put?.headers["content-type"]).toBe("application/gzip");
111
134
  expect(put?.headers["content-length"]).toBe("10");
112
135
  expect(put?.headers.authorization).toBeUndefined();
113
136
  expect(Object.keys(put?.headers ?? {}).filter((name) => name.startsWith("x-amz-"))).toEqual([]);
114
- expect(akasys.commits).toEqual(["upload-1"]);
137
+ expect(cloudHost.commits).toEqual(["upload-1"]);
115
138
  });
116
139
 
117
140
  test("takes one fresh ticket when storage answers 403, and gives up on the second without naming the URL", async () => {
118
141
  const archive = await archiveOf("pack");
119
- akasys.putStatuses = [403];
142
+ cloudHost.putStatuses = [403];
120
143
 
121
144
  await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
122
- expect(akasys.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-2"]);
123
- expect(akasys.commits).toEqual(["upload-2"]);
145
+ expect(cloudHost.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-2"]);
146
+ expect(cloudHost.commits).toEqual(["upload-2"]);
124
147
 
125
- akasys.reset();
126
- akasys.putStatuses = [403, 403];
148
+ cloudHost.reset();
149
+ cloudHost.putStatuses = [403, 403];
127
150
  const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
128
151
  expect(String(refusal)).toContain("HTTP 403");
129
152
  expect(String(refusal)).not.toContain("secret-signature");
130
- expect(akasys.commits).toEqual([]);
153
+ expect(cloudHost.commits).toEqual([]);
131
154
  });
132
155
 
133
156
  test("retries a PUT the way curl --retry does, and stops at a refusal that is not transient", async () => {
134
157
  const archive = await archiveOf("pack");
135
- akasys.putStatuses = [503, 502];
158
+ cloudHost.putStatuses = [503, 502];
136
159
  await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
137
- expect(akasys.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-1", "upload-1"]);
160
+ expect(cloudHost.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-1", "upload-1"]);
138
161
 
139
- akasys.reset();
140
- akasys.putStatuses = [400];
162
+ cloudHost.reset();
163
+ cloudHost.putStatuses = [400];
141
164
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("HTTP 400");
142
- expect(akasys.puts).toHaveLength(1);
165
+ expect(cloudHost.puts).toHaveLength(1);
143
166
  });
144
167
 
145
168
  test("asks again with the same id while the commit is cut off or still running, but not past a real answer", async () => {
146
169
  const archive = await archiveOf("pack");
147
- akasys.commitAnswers = [524, 409, 502];
170
+ cloudHost.commitAnswers = [524, 409, 502];
148
171
  await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
149
- expect(akasys.commits).toEqual(["upload-1", "upload-1", "upload-1", "upload-1"]);
172
+ expect(cloudHost.commits).toEqual(["upload-1", "upload-1", "upload-1", "upload-1"]);
150
173
 
151
- akasys.reset();
152
- akasys.commitAnswers = [404];
174
+ cloudHost.reset();
175
+ cloudHost.commitAnswers = [404];
153
176
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("404");
154
- expect(akasys.commits).toEqual(["upload-1"]);
177
+ expect(cloudHost.commits).toEqual(["upload-1"]);
155
178
 
156
- akasys.reset();
157
- akasys.commitAnswers = Array.from({ length: 100 }, () => 524);
179
+ cloudHost.reset();
180
+ cloudHost.commitAnswers = Array.from({ length: 100 }, () => 524);
158
181
  const cutOff = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 35, commitLimitMs: 10_000 };
159
182
  await expect(new UpdatePackUpload(api, "project-1", cutOff).send(archive, fields)).rejects.toThrow("524");
160
- expect(akasys.commits.length).toBeLessThan(6);
183
+ expect(cloudHost.commits.length).toBeLessThan(6);
161
184
  });
162
185
 
163
186
  test("keeps waiting on a live claim past the cut-off window, up to the overall limit", async () => {
164
187
  const archive = await archiveOf("pack");
165
- akasys.commitAnswers = [...Array.from({ length: 6 }, () => 409), 524, ...Array.from({ length: 6 }, () => 409)];
188
+ cloudHost.commitAnswers = [...Array.from({ length: 6 }, () => 409), 524, ...Array.from({ length: 6 }, () => 409)];
166
189
  const timing = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 25, commitLimitMs: 10_000 };
167
190
  await new UpdatePackUpload(api, "project-1", timing).send(archive, fields);
168
- expect(akasys.commits).toHaveLength(14);
191
+ expect(cloudHost.commits).toHaveLength(14);
169
192
 
170
- akasys.reset();
171
- akasys.commitAnswers = Array.from({ length: 100 }, () => 409);
193
+ cloudHost.reset();
194
+ cloudHost.commitAnswers = Array.from({ length: 100 }, () => 409);
172
195
  const limited = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 10_000, commitLimitMs: 35 };
173
196
  await expect(new UpdatePackUpload(api, "project-1", limited).send(archive, fields)).rejects.toThrow("409");
174
- expect(akasys.commits.length).toBeLessThan(6);
197
+ expect(cloudHost.commits.length).toBeLessThan(6);
198
+ });
199
+
200
+ test("gives up a stalled PUT after a time its size sets, and tries it again as a dropped one", async () => {
201
+ const archive = await archiveOf("pack");
202
+ cloudHost.stallPuts = true;
203
+
204
+ await expect(
205
+ new UpdatePackUpload(api, "project-1", { ...quick, putTimeoutMs: 50 }).send(archive, fields),
206
+ ).rejects.toThrow("no response");
207
+ expect(cloudHost.puts).toHaveLength(4);
208
+ expect(cloudHost.commits).toEqual([]);
209
+ });
210
+
211
+ test("asks again past a gateway's 503, but not past one the server answers with its own refusal", async () => {
212
+ const archive = await archiveOf("pack");
213
+ cloudHost.commitAnswers = [
214
+ new Response("No healthy federation child is ready", { status: 503 }),
215
+ [503, "base.error.serverUnavailable"],
216
+ ];
217
+ await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
218
+ expect(cloudHost.commits).toEqual(["upload-1", "upload-1", "upload-1"]);
219
+
220
+ cloudHost.reset();
221
+ cloudHost.commitAnswers = [[503, "release.error.signingUnavailable"]];
222
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow(
223
+ "release.error.signingUnavailable",
224
+ );
225
+ expect(cloudHost.commits).toEqual(["upload-1"]);
175
226
  });
176
227
 
177
228
  test("tells a server without staged uploads apart from one whose 404 is a refusal", async () => {
178
229
  const archive = await archiveOf("pack");
179
- akasys.requestRefusal = new Response("Not Found", { status: 404 });
230
+ cloudHost.requestRefusal = new Response("Not Found", { status: 404 });
180
231
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow(
181
232
  "does not take direct pack uploads yet",
182
233
  );
183
234
 
184
- akasys.requestRefusal = Response.json({ error: "devProject.error.appNotFound", statusCode: 404 }, { status: 404 });
235
+ cloudHost.requestRefusal = Response.json(
236
+ { error: "devProject.error.appNotFound", statusCode: 404 },
237
+ { status: 404 },
238
+ );
185
239
  const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
186
240
  expect(String(refusal)).toContain("devProject.error.appNotFound");
187
- expect(akasys.puts).toEqual([]);
241
+ expect(cloudHost.puts).toEqual([]);
242
+ });
243
+
244
+ test("stops at once on a 409 that is not a commit in progress: the upload is already used", async () => {
245
+ const archive = await archiveOf("pack");
246
+ cloudHost.commitAnswers = [[409, "release.error.uploadEmbedded"]];
247
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("uploadEmbedded");
248
+ expect(cloudHost.commits).toHaveLength(1);
249
+ });
250
+
251
+ test("stages a binary's pack and registers it by upload id, asking again while the server is still at it", async () => {
252
+ const archive = await archiveOf("pack");
253
+ cloudHost.commitAnswers = [524, 409];
254
+ const upload = new UpdatePackUpload(api, "project-1", quick);
255
+ const binary = await upload.stage(archive, fields).then((packUploadId) =>
256
+ upload.settle(() =>
257
+ api.registerAppBinary("project-1", {
258
+ appName: "lu",
259
+ target: "default",
260
+ distribution: "playTesting",
261
+ bundle: new Blob(["{}"]),
262
+ packUploadId,
263
+ channel: "develop",
264
+ }),
265
+ ),
266
+ );
267
+
268
+ expect(binary).toEqual({ id: "binary-1" });
269
+ expect(cloudHost.commits).toEqual([]);
270
+ expect(cloudHost.registrations).toHaveLength(3);
271
+ expect(cloudHost.registrations[2]).toEqual({
272
+ appName: "lu",
273
+ target: "default",
274
+ distribution: "playTesting",
275
+ channel: "develop",
276
+ bundle: "<file>",
277
+ packUploadId: "upload-1",
278
+ });
279
+ });
280
+
281
+ test("waits as long as storage's Retry-After asks before trying a PUT again", async () => {
282
+ const archive = await archiveOf("pack");
283
+ cloudHost.putStatuses = [new Response(null, { status: 429, headers: { "Retry-After": "1" } })];
284
+ const startedAt = performance.now();
285
+
286
+ await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
287
+
288
+ expect(performance.now() - startedAt).toBeGreaterThanOrEqual(950);
289
+ expect(cloudHost.puts).toHaveLength(2);
290
+ });
291
+
292
+ test("never asks again sooner than the server's Retry-After, nor past the limit it would wait to", async () => {
293
+ const archive = await archiveOf("pack");
294
+ cloudHost.commitAnswers = [new Response("busy", { status: 503, headers: { "Retry-After": "120" } })];
295
+
296
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("503");
297
+ expect(cloudHost.commits).toEqual(["upload-1"]);
188
298
  });
189
299
 
190
300
  test("refuses an empty archive before asking for a ticket", async () => {
191
301
  const archive = await archiveOf("");
192
302
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("0 bytes");
193
- expect(akasys.requests).toEqual([]);
303
+ expect(cloudHost.requests).toEqual([]);
194
304
  });
195
305
  });
@@ -1,22 +1,30 @@
1
1
  import { Logger, sleep } from "akanjs/common";
2
2
  import type { BunFile } from "bun";
3
- import { CloudApi, type CloudRecord, type UpdateUploadRequest } from "./cloudApi";
3
+ import type { CloudApi, CloudRecord, UpdateUploadRequest } from "./cloudApi";
4
+ import { CloudHttpError } from "./cloudHttpError";
4
5
 
5
6
  interface UpdatePackUploadOptions {
6
7
  putRetryMs?: number;
8
+ putTimeoutMs?: number;
7
9
  commitRetryMs?: number;
8
10
  cutOffWindowMs?: number;
9
11
  commitLimitMs?: number;
10
12
  }
11
13
 
12
14
  //* akasys sits behind Cloudflare's 100 s request cut, so the pack goes straight to its staging bucket and akasys
13
- //* only sees two small JSON calls: request a presigned PUT, then commit the object, which akasys re-hashes.
15
+ //* only sees small calls: request a presigned PUT, then commit the object, or register a binary with it as its
16
+ //* embedded pack; either re-hashes it, and an upload is used once, one way.
14
17
  export class UpdatePackUpload {
15
18
  static readonly maxBytes = 200 * 1024 * 1024;
16
19
  static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
20
+ //? A stalled PUT never fails on its own, so each try ends once the pack would be through even at this crawl.
21
+ static readonly #putFloorBytesPerSecond = 64 * 1024;
22
+ //? Storage's Retry-After is honoured up to this: past it the presigned URL may lapse, and a fresh one is asked for.
23
+ static readonly #maxPutRetryAfterMs = 60_000;
17
24
  readonly #api: CloudApi;
18
25
  readonly #devProjectId: string;
19
26
  readonly #putRetryMs: number;
27
+ readonly #putTimeoutMs: number;
20
28
  readonly #commitRetryMs: number;
21
29
  readonly #cutOffWindowMs: number;
22
30
  readonly #commitLimitMs: number;
@@ -26,6 +34,7 @@ export class UpdatePackUpload {
26
34
  devProjectId: string,
27
35
  {
28
36
  putRetryMs = 1_000,
37
+ putTimeoutMs = 60_000,
29
38
  commitRetryMs = 10_000,
30
39
  cutOffWindowMs = 15 * 60_000,
31
40
  commitLimitMs = 60 * 60_000,
@@ -34,12 +43,19 @@ export class UpdatePackUpload {
34
43
  this.#api = api;
35
44
  this.#devProjectId = devProjectId;
36
45
  this.#putRetryMs = putRetryMs;
46
+ this.#putTimeoutMs = putTimeoutMs;
37
47
  this.#commitRetryMs = commitRetryMs;
38
48
  this.#cutOffWindowMs = cutOffWindowMs;
39
49
  this.#commitLimitMs = commitLimitMs;
40
50
  }
41
51
 
42
52
  async send(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<CloudRecord> {
53
+ const uploadId = await this.stage(archive, fields);
54
+ return await this.settle(() => this.#api.commitUpdateUpload(this.#devProjectId, uploadId));
55
+ }
56
+
57
+ /** Puts the pack in the project's staging storage and answers the upload id a commit or a registration names. */
58
+ async stage(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<string> {
43
59
  const file = Bun.file(archive);
44
60
  if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
45
61
  throw new Error(`The pack archive is ${file.size} bytes; a project takes 1 byte to 200 MiB.`);
@@ -50,7 +66,7 @@ export class UpdatePackUpload {
50
66
  for (let ticket = 1; ; ticket++) {
51
67
  const { uploadId, uploadUrl, contentType } = await this.#request(upload);
52
68
  const status = await this.#put(uploadUrl, contentType, file);
53
- if (status >= 200 && status < 300) return await this.#commit(uploadId);
69
+ if (status >= 200 && status < 300) return uploadId;
54
70
  if (status !== 403 || ticket === 2)
55
71
  throw new Error(`The storage refused the pack upload (${status ? `HTTP ${status}` : "no response"}).`);
56
72
  }
@@ -59,10 +75,9 @@ export class UpdatePackUpload {
59
75
  async #request(upload: UpdateUploadRequest) {
60
76
  return await this.#api.requestUpdateUpload(this.#devProjectId, upload).catch((error: unknown) => {
61
77
  //? A server older than staged uploads has no such route: its 404 is a page, where every refusal is an Err key.
62
- const message = error instanceof Error ? error.message : String(error);
63
- if (CloudApi.statusOf(error) === 404 && !/"error"\s*:\s*"[\w$-]+\.error\.[\w$-]+"/.test(message))
78
+ if (error instanceof CloudHttpError && error.status === 404 && !error.errKey)
64
79
  throw new Error(
65
- "The project's server does not take direct pack uploads yet: update akasys and try again, or upload the pack from the project's release screen.",
80
+ "The project's server does not take direct pack uploads yet: update Akan Cloud and try again, or upload the pack from the project's release screen.",
66
81
  );
67
82
  throw error;
68
83
  });
@@ -71,45 +86,67 @@ export class UpdatePackUpload {
71
86
  // The URL signs only its host: an unsigned x-amz-* header (a checksum included) makes storage refuse the PUT.
72
87
  // A Bun fetch error names the URL, a bearer credential, so nothing but the status leaves this method.
73
88
  async #put(url: string, contentType: string, file: BunFile) {
89
+ const timeoutMs = this.#putTimeoutMs + Math.ceil((file.size / UpdatePackUpload.#putFloorBytesPerSecond) * 1000);
74
90
  for (let retry = 0; ; retry++) {
75
- const status = await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": contentType } })
91
+ const { status, retryAfterMs } = await fetch(url, {
92
+ method: "PUT",
93
+ body: file,
94
+ headers: { "Content-Type": contentType },
95
+ signal: AbortSignal.timeout(timeoutMs),
96
+ })
76
97
  .then(async (response) => {
77
98
  await response.arrayBuffer().catch(() => undefined);
78
- return response.status;
99
+ return {
100
+ status: response.status,
101
+ retryAfterMs: CloudHttpError.retryAfterMs(response.headers.get("retry-after")),
102
+ };
79
103
  })
80
- .catch(() => 0);
104
+ .catch(() => ({ status: 0, retryAfterMs: null }));
81
105
  if (retry === 3 || !UpdatePackUpload.#transientPut.has(status)) return status;
82
- await sleep(this.#putRetryMs * (retry + 1));
106
+ await sleep(
107
+ Math.max(this.#putRetryMs * (retry + 1), Math.min(retryAfterMs ?? 0, UpdatePackUpload.#maxPutRetryAfterMs)),
108
+ );
83
109
  }
84
110
  }
85
111
 
86
- // The server finishes a commit whoever stopped listening, and the same id answers the same release afterwards.
87
- // A 409 is a live claim the server heartbeats, so only the overall limit bounds it; a run of cut-offs with no 409
88
- // between them may be a server that died, whose claim lapses within minutes, so that run gets the shorter window.
89
- async #commit(uploadId: string) {
112
+ /**
113
+ * Sends `call`, which consumes a staged upload, until the server answers it: the server finishes whoever stopped
114
+ * listening, and the same upload id answers the same record afterwards.
115
+ */
116
+ // `uploadCommitInProgress` is a live claim the server heartbeats, so only the overall limit bounds it; a run of
117
+ // cut-offs with none between them may be a server that died, whose claim lapses within minutes: the shorter window.
118
+ async settle<T>(call: () => Promise<T>): Promise<T> {
90
119
  const limit = performance.now() + this.#commitLimitMs;
91
120
  let cutOffSince: number | null = null;
92
121
  for (;;) {
93
122
  try {
94
- return await this.#api.commitUpdateUpload(this.#devProjectId, uploadId);
123
+ return await call();
95
124
  } catch (error) {
96
125
  const now = performance.now();
97
- if (CloudApi.statusOf(error) === 409) cutOffSince = null;
126
+ if (UpdatePackUpload.#inProgress(error)) cutOffSince = null;
98
127
  else if (UpdatePackUpload.#cutOff(error)) cutOffSince ??= now;
99
128
  else throw error;
100
129
  const deadline = cutOffSince === null ? limit : Math.min(limit, cutOffSince + this.#cutOffWindowMs);
101
- if (now + this.#commitRetryMs > deadline) throw error;
102
- Logger.rawLog(
103
- `The project is still taking the pack; asking again in ${Math.round(this.#commitRetryMs / 1000)}s.`,
104
- );
105
- await sleep(this.#commitRetryMs);
130
+ const waitMs = Math.max(this.#commitRetryMs, (error instanceof CloudHttpError && error.retryAfterMs) || 0);
131
+ if (now + waitMs > deadline) throw error;
132
+ Logger.rawLog(`The project is still taking the pack; asking again in ${Math.round(waitMs / 1000)}s.`);
133
+ await sleep(waitMs);
106
134
  }
107
135
  }
108
136
  }
109
137
 
138
+ //? Not every 409: an upload already committed or embedded is answered 409 too, and asking again changes nothing.
139
+ static #inProgress(error: unknown) {
140
+ return (
141
+ error instanceof CloudHttpError && error.status === 409 && !!error.errKey?.endsWith(".uploadCommitInProgress")
142
+ );
143
+ }
144
+
110
145
  static #cutOff(error: unknown) {
111
- const status = CloudApi.statusOf(error);
112
- if (status === null) return !(error instanceof SyntaxError);
146
+ if (!(error instanceof CloudHttpError)) return !(error instanceof SyntaxError);
147
+ const { status, errKey } = error;
148
+ //? A gateway with no replica ready answers 503; the server's own 503 names an Err key and is a final refusal.
149
+ if (status === 503) return errKey === null || errKey === "base.error.serverUnavailable";
113
150
  return status === 502 || status === 504 || (status >= 520 && status <= 527);
114
151
  }
115
152
  }
@@ -49,11 +49,15 @@ export interface ArgMeta<Context = CommandContext> {
49
49
  idx: number;
50
50
  type: ArgType;
51
51
  }
52
+ export interface InternalArgOption {
53
+ nullable?: boolean;
54
+ onlyApp?: boolean;
55
+ }
52
56
  export interface InternalArgMeta {
53
57
  key: string;
54
58
  idx: number;
55
59
  type: InternalArgType;
56
- option?: { nullable?: boolean };
60
+ option?: InternalArgOption;
57
61
  }
58
62
 
59
63
  export const getArgMetas = (
@@ -69,9 +73,11 @@ export const getArgMetas = (
69
73
  export interface InternalArgToken<T = unknown, Type extends InternalArgType = InternalArgType> {
70
74
  type: Type;
71
75
  _value: T;
76
+ option?: InternalArgOption;
72
77
  }
73
78
 
74
- const createInternalArgToken = <T, Type extends InternalArgType>(type: Type) => ({ type }) as InternalArgToken<T, Type>;
79
+ const createInternalArgToken = <T, Type extends InternalArgType>(type: Type, option?: InternalArgOption) =>
80
+ (option ? { type, option } : { type }) as InternalArgToken<T, Type>;
75
81
 
76
82
  export const normalizePrimitiveArgType = (type: PrimitiveArgType): NormalizedPrimitiveArgType => {
77
83
  if (type === String) return "string";
@@ -93,6 +99,9 @@ export type Lib = LibExecutor;
93
99
  export const Sys = createInternalArgToken<SysExecutor, "Sys">("Sys");
94
100
  export type Sys = SysExecutor;
95
101
 
102
+ /** Sys, except that a one-app workspace given no name means its app, as with App. */
103
+ export const SysOrOnlyApp = createInternalArgToken<SysExecutor, "Sys">("Sys", { onlyApp: true });
104
+
96
105
  export const Exec = createInternalArgToken<Executor, "Exec">("Exec");
97
106
  export type Exec = Executor;
98
107