@amerged/ohmyhost-runtime 0.1.23 → 0.1.25

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 (42) hide show
  1. package/README.md +1 -1
  2. package/dist/database-kysely.d.ts +5 -0
  3. package/dist/database-kysely.d.ts.map +1 -0
  4. package/dist/database-kysely.js +95 -0
  5. package/dist/database-kysely.js.map +1 -0
  6. package/dist/database.d.ts.map +1 -1
  7. package/dist/database.js +43 -7
  8. package/dist/database.js.map +1 -1
  9. package/dist/errors.d.ts +5 -4
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +6 -5
  12. package/dist/errors.js.map +1 -1
  13. package/dist/http.d.ts +4 -0
  14. package/dist/http.d.ts.map +1 -1
  15. package/dist/http.js +31 -12
  16. package/dist/http.js.map +1 -1
  17. package/dist/mail-attachments.d.ts.map +1 -1
  18. package/dist/mail-attachments.js +3 -4
  19. package/dist/mail-attachments.js.map +1 -1
  20. package/dist/mail.d.ts.map +1 -1
  21. package/dist/mail.js +16 -11
  22. package/dist/mail.js.map +1 -1
  23. package/dist/storage-batch-stream.d.ts +31 -0
  24. package/dist/storage-batch-stream.d.ts.map +1 -0
  25. package/dist/storage-batch-stream.js +282 -0
  26. package/dist/storage-batch-stream.js.map +1 -0
  27. package/dist/storage.d.ts +11 -0
  28. package/dist/storage.d.ts.map +1 -1
  29. package/dist/storage.js +167 -14
  30. package/dist/storage.js.map +1 -1
  31. package/package.json +13 -1
  32. package/src/packages/customer-runtime/package.json +53 -0
  33. package/src/packages/customer-runtime/src/database-kysely.ts +127 -0
  34. package/src/packages/customer-runtime/{database.ts → src/database.ts} +50 -8
  35. package/src/packages/customer-runtime/{errors.ts → src/errors.ts} +13 -4
  36. package/src/packages/customer-runtime/{http.ts → src/http.ts} +43 -10
  37. package/src/packages/customer-runtime/{mail-attachments.ts → src/mail-attachments.ts} +12 -4
  38. package/src/packages/customer-runtime/{mail.ts → src/mail.ts} +28 -14
  39. package/src/packages/customer-runtime/src/storage-batch-stream.ts +318 -0
  40. package/src/packages/customer-runtime/{storage.ts → src/storage.ts} +236 -13
  41. /package/src/packages/customer-runtime/{index.ts → src/index.ts} +0 -0
  42. /package/src/packages/customer-runtime/{mail-webhook.ts → src/mail-webhook.ts} +0 -0
@@ -0,0 +1,318 @@
1
+ import { CustomerRuntimeGatewayError } from "./errors.js";
2
+ import { exactRecord, invalidResponse } from "./http.js";
3
+
4
+ export type StorageReadResult =
5
+ | Readonly<{ state: "not_found"; objectKey: string }>
6
+ | Readonly<{
7
+ state: "found";
8
+ objectKey: string;
9
+ contentType: string;
10
+ contentLength: number;
11
+ etag: string;
12
+ body: ReadableStream<Uint8Array>;
13
+ }>;
14
+ export type StorageDeleteResult =
15
+ | Readonly<{ state: "completed"; objectKey: string; deletionId: string }>
16
+ | Readonly<{ state: "pending"; objectKey: string; retryAt: string }>
17
+ | Readonly<{
18
+ state: "failed";
19
+ objectKey: string;
20
+ code: "object_not_found" | "storage_gateway_unavailable";
21
+ retryable: boolean;
22
+ }>;
23
+ export const STORAGE_BATCH_CONTENT_TYPE = "application/vnd.ohmyhost.storage-batch.v1";
24
+ const MAX_CONTROL_BYTES = 4 * 1024 * 1024;
25
+ const MAX_HEADER_BYTES = 2048;
26
+
27
+ /** Pending reads are bounded; errors never expose provider text. */
28
+ async function timedRead(
29
+ reader: ReadableStreamDefaultReader<Uint8Array>,
30
+ timeoutMilliseconds: number,
31
+ signal?: AbortSignal,
32
+ ): Promise<ReadableStreamReadResult<Uint8Array>> {
33
+ let timer: ReturnType<typeof setTimeout> | undefined;
34
+ let abortListener: (() => void) | undefined;
35
+ try {
36
+ if (signal?.aborted) {
37
+ void reader.cancel().catch(() => undefined);
38
+ throw new CustomerRuntimeGatewayError("gateway_timeout", null, true);
39
+ }
40
+ return await Promise.race([
41
+ reader.read(),
42
+ new Promise<never>((_resolve, reject) => {
43
+ const abort = () => {
44
+ reject(new CustomerRuntimeGatewayError("gateway_timeout", null, true));
45
+ void reader.cancel().catch(() => undefined);
46
+ };
47
+ timer = setTimeout(abort, timeoutMilliseconds);
48
+ if (signal !== undefined) {
49
+ abortListener = abort;
50
+ signal.addEventListener("abort", abortListener, { once: true });
51
+ }
52
+ }),
53
+ ]);
54
+ } catch (error) {
55
+ if (error instanceof CustomerRuntimeGatewayError) throw error;
56
+ if (signal?.aborted) throw new CustomerRuntimeGatewayError("gateway_timeout", null, true);
57
+ throw new CustomerRuntimeGatewayError("gateway_unavailable", null, true);
58
+ } finally {
59
+ clearTimeout(timer);
60
+ if (abortListener !== undefined) signal?.removeEventListener("abort", abortListener);
61
+ }
62
+ }
63
+
64
+ /** Yields one bounded body at a time; completion requires the final frame and exact EOF. */
65
+ export function decodeStorageBatch(
66
+ response: Response,
67
+ keys: readonly string[],
68
+ timeoutMilliseconds: number,
69
+ ): AsyncIterable<StorageReadResult> {
70
+ if (
71
+ response.headers.get("content-type") !== STORAGE_BATCH_CONTENT_TYPE ||
72
+ response.body === null
73
+ ) {
74
+ void response.body?.cancel().catch(() => undefined);
75
+ invalidResponse();
76
+ }
77
+ const source = response.body.getReader();
78
+ let buffer: Uint8Array = new Uint8Array();
79
+ let offset = 0;
80
+ let controlBytes = 0;
81
+ let started = false;
82
+ let finished = false;
83
+ let cancelled = false;
84
+ let released = false;
85
+ let activeBody: ReadableStreamDefaultController<Uint8Array> | undefined;
86
+ function cancellationError() {
87
+ return new CustomerRuntimeGatewayError(
88
+ "gateway_request_invalid",
89
+ null,
90
+ false,
91
+ "this storage read was cancelled; start another readMany() to read more objects",
92
+ );
93
+ }
94
+ function closeSource() {
95
+ if (released) return;
96
+ released = true;
97
+ if (!finished) void source.cancel().catch(() => undefined);
98
+ source.releaseLock();
99
+ }
100
+ async function chunk(maximum: number): Promise<Uint8Array> {
101
+ if (cancelled) throw cancellationError();
102
+ while (offset === buffer.byteLength) {
103
+ const next = await timedRead(source, timeoutMilliseconds);
104
+ if (cancelled) throw cancellationError();
105
+ if (next.done || !(next.value instanceof Uint8Array)) invalidResponse();
106
+ buffer = next.value;
107
+ offset = 0;
108
+ }
109
+ const length = Math.min(maximum, buffer.byteLength - offset);
110
+ const bytes = buffer.subarray(offset, offset + length);
111
+ offset += length;
112
+ return bytes;
113
+ }
114
+ async function exactBytes(length: number): Promise<Uint8Array> {
115
+ const bytes = new Uint8Array(length);
116
+ let written = 0;
117
+ while (written < length) {
118
+ const next = await chunk(length - written);
119
+ bytes.set(next, written);
120
+ written += next.byteLength;
121
+ }
122
+ return bytes;
123
+ }
124
+ async function header(): Promise<unknown> {
125
+ const length = new DataView((await exactBytes(4)).buffer).getUint32(0);
126
+ controlBytes += 4 + length;
127
+ if (length < 1 || length > MAX_HEADER_BYTES || controlBytes > MAX_CONTROL_BYTES)
128
+ invalidResponse();
129
+ const bytes = await exactBytes(length);
130
+ try {
131
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
132
+ } catch {
133
+ return invalidResponse();
134
+ }
135
+ }
136
+ const iterator = iterate();
137
+ // Returning an unstarted async generator skips its finally block.
138
+ return {
139
+ [Symbol.asyncIterator]() {
140
+ return {
141
+ next: () => (cancelled ? Promise.reject(cancellationError()) : iterator.next()),
142
+ async return() {
143
+ cancelled = true;
144
+ activeBody?.error(cancellationError());
145
+ activeBody = undefined;
146
+ closeSource();
147
+ if (!started) return { done: true as const, value: undefined };
148
+ return iterator.return(undefined);
149
+ },
150
+ };
151
+ },
152
+ };
153
+ async function* iterate(): AsyncGenerator<StorageReadResult, void, unknown> {
154
+ started = true;
155
+ let currentConsumed = true;
156
+ let bodyFailure: unknown;
157
+ try {
158
+ for (let index = 0; index < keys.length; index++) {
159
+ const objectKey = keys[index];
160
+ if (objectKey === undefined) invalidResponse();
161
+ if (!currentConsumed)
162
+ throw new CustomerRuntimeGatewayError(
163
+ "gateway_request_invalid",
164
+ null,
165
+ false,
166
+ "consume or cancel each object's body before reading the next object",
167
+ );
168
+ if (bodyFailure !== undefined) throw bodyFailure;
169
+ const value = await header();
170
+ const candidate = value as Record<string, unknown>;
171
+ const record = exactRecord(
172
+ value,
173
+ candidate?.["state"] === "found"
174
+ ? ["index", "objectKey", "state", "contentType", "contentLength", "etag"]
175
+ : ["index", "objectKey", "state"],
176
+ );
177
+ if (record["index"] !== index || record["objectKey"] !== keys[index]) invalidResponse();
178
+ if (record["state"] === "not_found") {
179
+ yield Object.freeze({ state: "not_found", objectKey });
180
+ continue;
181
+ }
182
+ if (
183
+ record["state"] !== "found" ||
184
+ typeof record["contentType"] !== "string" ||
185
+ !/^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/u.test(
186
+ record["contentType"],
187
+ ) ||
188
+ !Number.isSafeInteger(record["contentLength"]) ||
189
+ Number(record["contentLength"]) < 0 ||
190
+ Number(record["contentLength"]) > 1_073_741_824 ||
191
+ typeof record["etag"] !== "string" ||
192
+ !/^[0-9a-f]{32}(?:-[1-9][0-9]{0,4})?$/u.test(record["etag"])
193
+ )
194
+ invalidResponse();
195
+ let remaining = Number(record["contentLength"]);
196
+ currentConsumed = remaining === 0;
197
+ const body = new ReadableStream<Uint8Array>(
198
+ {
199
+ start(controller) {
200
+ if (remaining === 0) controller.close();
201
+ else activeBody = controller;
202
+ },
203
+ async pull(controller) {
204
+ try {
205
+ const bytes = await chunk(remaining);
206
+ remaining -= bytes.byteLength;
207
+ controller.enqueue(bytes);
208
+ if (remaining === 0) {
209
+ currentConsumed = true;
210
+ activeBody = undefined;
211
+ controller.close();
212
+ }
213
+ } catch (error) {
214
+ bodyFailure = error;
215
+ currentConsumed = true;
216
+ activeBody = undefined;
217
+ controller.error(error);
218
+ closeSource();
219
+ }
220
+ },
221
+ async cancel() {
222
+ activeBody = undefined;
223
+ try {
224
+ while (remaining > 0) remaining -= (await chunk(remaining)).byteLength;
225
+ currentConsumed = true;
226
+ } catch (error) {
227
+ bodyFailure = error;
228
+ currentConsumed = true;
229
+ closeSource();
230
+ throw error;
231
+ }
232
+ },
233
+ },
234
+ { highWaterMark: 0 },
235
+ );
236
+ yield Object.freeze({
237
+ state: "found",
238
+ objectKey,
239
+ contentType: record["contentType"],
240
+ contentLength: Number(record["contentLength"]),
241
+ etag: record["etag"],
242
+ body,
243
+ });
244
+ }
245
+ if (!currentConsumed)
246
+ throw new CustomerRuntimeGatewayError(
247
+ "gateway_request_invalid",
248
+ null,
249
+ false,
250
+ "consume or cancel the last object's body before completing the read",
251
+ );
252
+ if (bodyFailure !== undefined) throw bodyFailure;
253
+ const terminal = exactRecord(await header(), ["state", "count"]);
254
+ if (
255
+ terminal["state"] !== "completed" ||
256
+ terminal["count"] !== keys.length ||
257
+ offset !== buffer.byteLength
258
+ )
259
+ invalidResponse();
260
+ const end = await timedRead(source, timeoutMilliseconds);
261
+ if (!end.done) invalidResponse();
262
+ if (cancelled) throw cancellationError();
263
+ finished = true;
264
+ } finally {
265
+ activeBody?.error(bodyFailure ?? cancellationError());
266
+ activeBody = undefined;
267
+ closeSource();
268
+ }
269
+ }
270
+ }
271
+
272
+ /** Control JSON has its own four-MiB budget and never buffers object payloads. */
273
+ export async function readStorageBatchJson(
274
+ response: Response,
275
+ timeoutMilliseconds: number,
276
+ signal?: AbortSignal,
277
+ ): Promise<unknown> {
278
+ const declared = response.headers.get("content-length");
279
+ if (
280
+ response.headers.get("content-type")?.split(";", 1)[0]?.trim() !== "application/json" ||
281
+ (declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > MAX_CONTROL_BYTES)) ||
282
+ response.body === null
283
+ ) {
284
+ void response.body?.cancel().catch(() => undefined);
285
+ invalidResponse();
286
+ }
287
+ const reader = response.body.getReader();
288
+ const chunks: Uint8Array[] = [];
289
+ let length = 0;
290
+ let finished = false;
291
+ try {
292
+ for (;;) {
293
+ const next = await timedRead(reader, timeoutMilliseconds, signal);
294
+ if (next.done) {
295
+ finished = true;
296
+ break;
297
+ }
298
+ if (!(next.value instanceof Uint8Array)) invalidResponse();
299
+ length += next.value.byteLength;
300
+ if (length > MAX_CONTROL_BYTES) invalidResponse();
301
+ chunks.push(next.value);
302
+ }
303
+ } finally {
304
+ if (!finished) void reader.cancel().catch(() => undefined);
305
+ reader.releaseLock();
306
+ }
307
+ const bytes = new Uint8Array(length);
308
+ let offset = 0;
309
+ for (const chunk of chunks) {
310
+ bytes.set(chunk, offset);
311
+ offset += chunk.byteLength;
312
+ }
313
+ try {
314
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
315
+ } catch {
316
+ return invalidResponse();
317
+ }
318
+ }
@@ -1,12 +1,21 @@
1
1
  import { CustomerRuntimeGatewayError } from "./errors.js";
2
2
  import {
3
+ configurationInvalid,
3
4
  exactRecord,
4
5
  fetchWithTimeout,
5
6
  invalidResponse,
6
7
  postGatewayJson,
8
+ requireConfiguredUlid,
7
9
  validateGatewayConnection,
8
10
  type GatewayConnection,
9
11
  } from "./http.js";
12
+ import {
13
+ decodeStorageBatch,
14
+ readStorageBatchJson,
15
+ type StorageReadResult,
16
+ type StorageDeleteResult,
17
+ } from "./storage-batch-stream.js";
18
+ export type { StorageReadResult, StorageDeleteResult } from "./storage-batch-stream.js";
10
19
 
11
20
  export interface StorageCapability {
12
21
  readonly operation: "GET" | "PUT";
@@ -33,11 +42,19 @@ export function createPrivateStorageClient(
33
42
  ) {
34
43
  const connection = validateGatewayConnection(configuration);
35
44
  if (typeof configuration.capabilityFetch !== "function") {
36
- throw new CustomerRuntimeGatewayError("gateway_configuration_invalid", null, false);
45
+ configurationInvalid("capabilityFetch is not a function; pass (request) => fetch(request)");
37
46
  }
38
47
  const capabilityFetch = configuration.capabilityFetch;
39
- const projectId = requireUlid(configuration.projectId);
40
- const environmentId = requireUlid(configuration.environmentId);
48
+ const projectId = requireConfiguredUlid(
49
+ configuration.projectId,
50
+ "projectId",
51
+ "OHMYHOST_PROJECT_ID",
52
+ );
53
+ const environmentId = requireConfiguredUlid(
54
+ configuration.environmentId,
55
+ "environmentId",
56
+ "OHMYHOST_ENVIRONMENT_ID",
57
+ );
41
58
  const authenticatedBody = Object.freeze({ projectId, environmentId });
42
59
 
43
60
  async function reserveUpload(
@@ -59,7 +76,7 @@ export function createPrivateStorageClient(
59
76
  8 * 1024,
60
77
  input.idempotencyKey,
61
78
  );
62
- if (response.status !== 200) throwStorageError(response.status, response.body);
79
+ if (response.status !== 200) throwStorageError(response.status, response.body, environmentId);
63
80
  const record = response.body as Record<string, unknown>;
64
81
  if (record?.["state"] === "completed") {
65
82
  const completed = exactRecord(response.body, ["state", "transfer_id"]);
@@ -88,7 +105,7 @@ export function createPrivateStorageClient(
88
105
  8 * 1024,
89
106
  );
90
107
  if (response.status !== 200 && response.status !== 202) {
91
- throwStorageError(response.status, response.body);
108
+ throwStorageError(response.status, response.body, environmentId);
92
109
  }
93
110
  const record = exactRecord(response.body, ["state", "transfer_id"]);
94
111
  if (record["state"] !== "completed" && record["state"] !== "pending") invalidResponse();
@@ -109,7 +126,7 @@ export function createPrivateStorageClient(
109
126
  },
110
127
  8 * 1024,
111
128
  );
112
- if (response.status !== 200) throwStorageError(response.status, response.body);
129
+ if (response.status !== 200) throwStorageError(response.status, response.body, environmentId);
113
130
  const record = exactRecord(response.body, ["state", "capability"]);
114
131
  if (record["state"] !== "ready") invalidResponse();
115
132
  return parseCapability(record["capability"], "GET", objectKey);
@@ -124,7 +141,7 @@ export function createPrivateStorageClient(
124
141
  8 * 1024,
125
142
  input.idempotencyKey,
126
143
  );
127
- if (response.status !== 200) throwStorageError(response.status, response.body);
144
+ if (response.status !== 200) throwStorageError(response.status, response.body, environmentId);
128
145
  const record = exactRecord(response.body, ["state", "deletion_id"]);
129
146
  if (record["state"] !== "completed") invalidResponse();
130
147
  return Object.freeze({
@@ -151,13 +168,177 @@ export function createPrivateStorageClient(
151
168
  }),
152
169
  connection.timeoutMilliseconds,
153
170
  );
171
+ // The create-only PUT answers 412 when an earlier attempt already stored these bytes under
172
+ // this reservation; recording them is the retry's whole remaining work.
173
+ if (uploaded.status === 412) return completeUpload(reservation.transferId);
154
174
  if (uploaded.status < 200 || uploaded.status >= 300) {
155
- throw new CustomerRuntimeGatewayError("storage_upload_rejected", uploaded.status, false);
175
+ throw new CustomerRuntimeGatewayError(
176
+ "storage_upload_rejected",
177
+ uploaded.status,
178
+ uploaded.status >= 500 || uploaded.status === 429,
179
+ `the signed upload answered ${String(uploaded.status)}. For 5xx or 429, repeat upload() with the same idempotencyKey and objectKey.`,
180
+ );
156
181
  }
157
182
  return completeUpload(reservation.transferId);
158
183
  }
159
184
 
160
- return Object.freeze({ reserveUpload, completeUpload, createSignedRead, deleteObject, upload });
185
+ async function batchRequest(
186
+ path: string,
187
+ body: Readonly<Record<string, unknown>>,
188
+ signal?: AbortSignal,
189
+ ) {
190
+ const serialized = JSON.stringify({ ...authenticatedBody, ...body });
191
+ if (new TextEncoder().encode(serialized).byteLength > 4 * 1024 * 1024) invalidRequest();
192
+ return fetchWithTimeout(
193
+ connection.fetch,
194
+ new Request(new URL(path, connection.endpoint), {
195
+ method: "POST",
196
+ headers: { authorization: `Bearer ${connection.key}`, "content-type": "application/json" },
197
+ body: serialized,
198
+ ...(signal === undefined ? {} : { signal }),
199
+ }),
200
+ connection.timeoutMilliseconds,
201
+ );
202
+ }
203
+ async function readMany(input: {
204
+ readonly objectKeys: readonly string[];
205
+ }): Promise<AsyncIterable<StorageReadResult>> {
206
+ if (
207
+ typeof input !== "object" ||
208
+ input === null ||
209
+ Object.keys(input).join(",") !== "objectKeys"
210
+ )
211
+ invalidRequest();
212
+ const objectKeys = validateBatchKeys(input.objectKeys);
213
+ const response = await batchRequest("/v1/downloads/batch", { objectKeys });
214
+ if (response.status !== 200)
215
+ throwStorageError(
216
+ response.status,
217
+ await readStorageBatchJson(response, connection.timeoutMilliseconds),
218
+ environmentId,
219
+ );
220
+ return decodeStorageBatch(response, objectKeys, connection.timeoutMilliseconds);
221
+ }
222
+ async function deleteMany(input: {
223
+ readonly objects: readonly { readonly objectKey: string; readonly idempotencyKey: string }[];
224
+ }): Promise<readonly StorageDeleteResult[]> {
225
+ if (
226
+ typeof input !== "object" ||
227
+ input === null ||
228
+ Object.keys(input).join(",") !== "objects" ||
229
+ !Array.isArray(input.objects) ||
230
+ input.objects.length === 0 ||
231
+ input.objects.length > 4000
232
+ )
233
+ invalidRequest();
234
+ const objects = input.objects.map((item) => {
235
+ if (
236
+ typeof item !== "object" ||
237
+ item === null ||
238
+ Object.keys(item).sort().join(",") !== "idempotencyKey,objectKey" ||
239
+ typeof item.objectKey !== "string" ||
240
+ typeof item.idempotencyKey !== "string"
241
+ )
242
+ invalidRequest();
243
+ validateStorageMutation(item.idempotencyKey, item.objectKey);
244
+ return { objectKey: item.objectKey, idempotencyKey: item.idempotencyKey };
245
+ });
246
+ validateBatchKeys(objects.map((item) => item.objectKey));
247
+ if (new Set(objects.map((item) => item.idempotencyKey)).size !== objects.length)
248
+ invalidRequest();
249
+ const controller = new AbortController();
250
+ const deadline = Date.now() + connection.timeoutMilliseconds;
251
+ const timer = setTimeout(() => controller.abort(), connection.timeoutMilliseconds);
252
+ try {
253
+ const response = await batchRequest("/v1/deletions/batch", { objects }, controller.signal);
254
+ const value = await readStorageBatchJson(
255
+ response,
256
+ connection.timeoutMilliseconds,
257
+ controller.signal,
258
+ );
259
+ if (response.status !== 200) throwStorageError(response.status, value, environmentId);
260
+ const record = exactRecord(value, ["state", "results"]);
261
+ if (!Array.isArray(record["results"]) || record["results"].length !== objects.length)
262
+ invalidResponse();
263
+ const results = record["results"].map((value, index): StorageDeleteResult => {
264
+ const candidate = value as Record<string, unknown>;
265
+ const item = exactRecord(
266
+ value,
267
+ candidate?.["state"] === "completed"
268
+ ? ["object_key", "state", "deletion_id"]
269
+ : candidate?.["state"] === "pending"
270
+ ? ["object_key", "state", "retry_at"]
271
+ : ["object_key", "state", "code", "retryable"],
272
+ );
273
+ const requested = objects[index];
274
+ if (requested === undefined || item["object_key"] !== requested.objectKey)
275
+ invalidResponse();
276
+ const objectKey = requested.objectKey;
277
+ if (item["state"] === "completed")
278
+ return Object.freeze({
279
+ state: "completed",
280
+ objectKey,
281
+ deletionId: requireUlid(item["deletion_id"]),
282
+ });
283
+ if (item["state"] === "pending") {
284
+ return Object.freeze({
285
+ state: "pending",
286
+ objectKey,
287
+ retryAt: requireRetryAt(item["retry_at"]),
288
+ });
289
+ }
290
+ if (
291
+ item["state"] !== "failed" ||
292
+ (item["code"] !== "object_not_found" && item["code"] !== "storage_gateway_unavailable") ||
293
+ typeof item["retryable"] !== "boolean" ||
294
+ (item["code"] === "object_not_found" && item["retryable"] !== false)
295
+ )
296
+ invalidResponse();
297
+ return Object.freeze({
298
+ state: "failed",
299
+ objectKey,
300
+ code: item["code"],
301
+ retryable: item["retryable"],
302
+ });
303
+ });
304
+ if (
305
+ record["state"] !==
306
+ (results.every((item) => item.state === "completed") ? "completed" : "partial")
307
+ )
308
+ invalidResponse();
309
+ if (controller.signal.aborted || Date.now() >= deadline) {
310
+ controller.abort();
311
+ throw new CustomerRuntimeGatewayError("gateway_timeout", null, true);
312
+ }
313
+ return Object.freeze(results);
314
+ } finally {
315
+ clearTimeout(timer);
316
+ }
317
+ }
318
+ return Object.freeze({
319
+ reserveUpload,
320
+ completeUpload,
321
+ createSignedRead,
322
+ deleteObject,
323
+ upload,
324
+ readMany,
325
+ deleteMany,
326
+ });
327
+ }
328
+
329
+ function validateBatchKeys(value: readonly string[]): readonly string[] {
330
+ if (
331
+ !Array.isArray(value) ||
332
+ value.length === 0 ||
333
+ value.length > 4000 ||
334
+ new Set(value).size !== value.length
335
+ )
336
+ invalidRequest();
337
+ for (const key of value) {
338
+ if (typeof key !== "string") invalidRequest();
339
+ validateObjectKey(key);
340
+ }
341
+ return Object.freeze(Array.from(value));
161
342
  }
162
343
 
163
344
  function parseCapability(value: unknown, operation: "GET" | "PUT", objectKey: string) {
@@ -214,12 +395,18 @@ function exactHeaders(value: unknown): Readonly<Record<string, string>> {
214
395
  return Object.freeze(Object.fromEntries(entries) as Record<string, string>);
215
396
  }
216
397
 
217
- function throwStorageError(status: number, body: unknown): never {
398
+ function throwStorageError(status: number, body: unknown, environmentId: string): never {
399
+ const candidate = body as Record<string, unknown>;
218
400
  const record = exactRecord(
219
401
  body,
220
- typeof body === "object" && body !== null && "transfer_id" in body
221
- ? ["code", "transfer_id"]
222
- : ["code"],
402
+ candidate?.["code"] === "storage_delete_pending"
403
+ ? ["code", "retry_at"]
404
+ : candidate?.["code"] === "data_change_in_progress" ||
405
+ candidate?.["code"] === "storage_binding_data_changed"
406
+ ? ["code"]
407
+ : typeof body === "object" && body !== null && "transfer_id" in body
408
+ ? ["code", "transfer_id"]
409
+ : ["code"],
223
410
  );
224
411
  const code = record["code"];
225
412
  if (status === 401 && code === "unauthorized") {
@@ -232,6 +419,31 @@ function throwStorageError(status: number, body: unknown): never {
232
419
  requireUlid(record["transfer_id"]);
233
420
  throw new CustomerRuntimeGatewayError("storage_upload_expired", status, false);
234
421
  }
422
+ if (status === 409 && code === "data_change_in_progress") {
423
+ throw new CustomerRuntimeGatewayError(
424
+ "data_change_in_progress",
425
+ status,
426
+ true,
427
+ "a data change is running for this project; wait for that existing operation to finish, then repeat the same storage request with the same idempotency keys",
428
+ );
429
+ }
430
+ if (status === 409 && code === "storage_binding_data_changed") {
431
+ throw new CustomerRuntimeGatewayError(
432
+ "storage_binding_data_changed",
433
+ status,
434
+ false,
435
+ `environment ${environmentId} now uses a different data area; create a new deployment plan and redeploy that environment. Repeating this request will not update this deployment's binding`,
436
+ );
437
+ }
438
+ if (status === 409 && code === "storage_delete_pending") {
439
+ const retryAt = requireRetryAt(record["retry_at"]);
440
+ throw new CustomerRuntimeGatewayError(
441
+ "storage_delete_pending",
442
+ status,
443
+ true,
444
+ `a previously accepted upload or deletion is still settling; wait until ${retryAt}, then repeat deleteObject() with the same objectKey and idempotencyKey`,
445
+ );
446
+ }
235
447
  if (status === 409 && typeof code === "string") {
236
448
  throw new CustomerRuntimeGatewayError("storage_conflict", status, false);
237
449
  }
@@ -244,6 +456,17 @@ function throwStorageError(status: number, body: unknown): never {
244
456
  return invalidResponse();
245
457
  }
246
458
 
459
+ function requireRetryAt(value: unknown): string {
460
+ if (
461
+ typeof value !== "string" ||
462
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value) ||
463
+ !Number.isFinite(Date.parse(value)) ||
464
+ new Date(value).toISOString() !== value
465
+ )
466
+ invalidResponse();
467
+ return value;
468
+ }
469
+
247
470
  function validateStorageMutation(idempotencyKey: string, objectKey: string): void {
248
471
  if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(idempotencyKey)) invalidRequest();
249
472
  validateObjectKey(objectKey);