@cloudbitmaps/gcs 0.11.1 → 0.12.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/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // src/backend.ts
2
- import { ValidationError as ValidationError3, brandAsBackend } from "@cloudbitmaps/core/driver-kit";
2
+ import { ValidationError as ValidationError5, brandAsBackend } from "@cloudbitmaps/core/driver-kit";
3
3
  import { Storage as GcsClient } from "@google-cloud/storage";
4
4
 
5
5
  // src/storage.ts
6
6
  import {
7
7
  NotFoundError,
8
8
  TransientError,
9
- ValidationError as ValidationError2,
9
+ ValidationError as ValidationError3,
10
10
  WriteConflictError,
11
11
  isNotFoundError,
12
12
  isValidationError,
@@ -72,20 +72,145 @@ function networkCode(err) {
72
72
  function isPreconditionFailed(err) {
73
73
  return httpStatus(err) === 412;
74
74
  }
75
+ function isThrottle(err) {
76
+ const status = httpStatus(err);
77
+ return status === 429 || status === 503;
78
+ }
75
79
  function isNotFound(err) {
76
80
  return httpStatus(err) === 404;
77
81
  }
78
82
  function isInvalidRange(err) {
79
83
  return httpStatus(err) === 416;
80
84
  }
85
+ var SDK_RETRIED_STATUSES = [408, 429, 500, 502, 503, 504];
86
+ function isSdkConnectionProblem(reason) {
87
+ return reason.includes("eai_again") || reason === "econnreset" || reason === "unexpected connection closure" || reason === "epipe" || reason === "socket connection timeout";
88
+ }
89
+ var TRANSPORT_CODES = /* @__PURE__ */ new Set([
90
+ "ECONNRESET",
91
+ "ECONNREFUSED",
92
+ "ECONNABORTED",
93
+ "ETIMEDOUT",
94
+ "ENOTFOUND",
95
+ "EAI_AGAIN",
96
+ "EHOSTUNREACH",
97
+ "ENETUNREACH",
98
+ "EPIPE",
99
+ "ERR_STREAM_PREMATURE_CLOSE"
100
+ ]);
101
+ function isTransportFault(err) {
102
+ if (httpStatus(err) !== void 0) return false;
103
+ const code = networkCode(err);
104
+ return code !== void 0 && TRANSPORT_CODES.has(code);
105
+ }
106
+ function isDownloadRetryable(err) {
107
+ if (err === null || typeof err !== "object") return false;
108
+ if (isTransportFault(err)) return true;
109
+ const e = err;
110
+ if (typeof e.code === "number" && SDK_RETRIED_STATUSES.includes(e.code)) return true;
111
+ if (typeof e.code === "string") {
112
+ if (SDK_RETRIED_STATUSES.map(String).includes(e.code)) return true;
113
+ if (isSdkConnectionProblem(e.code.toLowerCase())) return true;
114
+ }
115
+ if (Array.isArray(e.errors)) {
116
+ for (const inner of e.errors) {
117
+ const reason = inner?.reason?.toString().toLowerCase();
118
+ if (reason !== void 0 && isSdkConnectionProblem(reason)) return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
81
123
  function isTransient(err) {
82
124
  if (isPreconditionFailed(err) || isNotFound(err) || isInvalidRange(err)) return false;
83
125
  const status = httpStatus(err);
84
- if (status === 429 || status !== void 0 && status >= 500 && status < 600) return true;
126
+ if (status === 408 || status === 429 || status !== void 0 && status >= 500 && status < 600)
127
+ return true;
85
128
  const net = networkCode(err);
86
129
  return net === "ECONNRESET" || net === "ETIMEDOUT" || net === "ECONNREFUSED" || net === "EPIPE" || net === "EAI_AGAIN" || net === "ENOTFOUND";
87
130
  }
88
131
 
132
+ // src/read-timeout.ts
133
+ import { ValidationError as ValidationError2 } from "@cloudbitmaps/core/driver-kit";
134
+ var DEFAULT_READ_TIMEOUT_MS = 0;
135
+ var MAX_TIMER_MS = 2147483647;
136
+ function resolveReadTimeoutMs(value) {
137
+ if (value === void 0) return DEFAULT_READ_TIMEOUT_MS;
138
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > MAX_TIMER_MS) {
139
+ throw new ValidationError2(
140
+ `readTimeoutMs must be a non-negative safe integer no larger than ${MAX_TIMER_MS}; got ${describe(value)}`
141
+ );
142
+ }
143
+ return value;
144
+ }
145
+ function describe(value) {
146
+ if (typeof value === "string") return JSON.stringify(value);
147
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
148
+ return String(value);
149
+ }
150
+ return value === null ? "null" : typeof value;
151
+ }
152
+ var ReadTimedOut = class extends Error {
153
+ code = "ETIMEDOUT";
154
+ constructor(read, ms, cause) {
155
+ super(`GCS ${read} timed out after ${ms} ms`, cause === void 0 ? void 0 : { cause });
156
+ this.name = "ReadTimedOut";
157
+ }
158
+ };
159
+ var Deadline = class {
160
+ constructor(ms, read) {
161
+ this.ms = ms;
162
+ this.read = read;
163
+ this.endsAt = performance.now() + ms;
164
+ }
165
+ ms;
166
+ read;
167
+ endsAt;
168
+ /** What is left of it, in milliseconds: 0 once it has passed. */
169
+ remaining() {
170
+ return Math.max(0, this.endsAt - performance.now());
171
+ }
172
+ /** The error a read fails with once this has passed; `cause` is the last attempt's own error, if one failed. */
173
+ expired(cause) {
174
+ return new ReadTimedOut(this.read, this.ms, cause);
175
+ }
176
+ };
177
+ function startDeadline(ms, read) {
178
+ return ms === 0 ? void 0 : new Deadline(ms, read);
179
+ }
180
+ function withDeadline(send, deadline) {
181
+ if (deadline === void 0) return send();
182
+ if (deadline.remaining() === 0) return Promise.reject(deadline.expired());
183
+ const request = send();
184
+ let timer;
185
+ const timedOut = new Promise((_, reject) => {
186
+ timer = setTimeout(() => reject(deadline.expired()), deadline.remaining());
187
+ });
188
+ return Promise.race([request, timedOut]).finally(() => clearTimeout(timer));
189
+ }
190
+
191
+ // src/download-retry.ts
192
+ var RETRIES = 3;
193
+ var BASE_DELAY_MS = 100;
194
+ var MAX_DELAY_MS = 1e3;
195
+ var MIN_RETRY_MS = 10;
196
+ async function retryDownload(download, deadline) {
197
+ for (let attempt = 0; ; attempt++) {
198
+ try {
199
+ return await download();
200
+ } catch (err) {
201
+ if (err instanceof ReadTimedOut) throw err;
202
+ if (attempt >= RETRIES || !isDownloadRetryable(err)) throw err;
203
+ const left = deadline?.remaining() ?? Infinity;
204
+ if (deadline !== void 0 && left < MIN_RETRY_MS) throw deadline.expired(err);
205
+ const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
206
+ const delay = Math.random() * ceiling;
207
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay, left)));
208
+ if (deadline !== void 0 && deadline.remaining() < MIN_RETRY_MS)
209
+ throw deadline.expired(err);
210
+ }
211
+ }
212
+ }
213
+
89
214
  // src/send-once.ts
90
215
  import { once } from "node:events";
91
216
  async function saveOnce(file, body, options) {
@@ -95,18 +220,96 @@ async function saveOnce(file, body, options) {
95
220
  await stored;
96
221
  }
97
222
 
223
+ // src/read-once.ts
224
+ var OFF_THE_SHARED_AGENT = {
225
+ request: (reqOpts) => ({ ...reqOpts, forever: false })
226
+ };
227
+ function downloadFile(storage, bucket, name) {
228
+ const file = storage.bucket(bucket).file(name);
229
+ file.interceptors.push(OFF_THE_SHARED_AGENT);
230
+ return file;
231
+ }
232
+ function singleHeader(headers, name) {
233
+ const value = headers[name];
234
+ return typeof value === "string" && value.length > 0 ? value : void 0;
235
+ }
236
+ function readOnce(file, options, maxBytes, oversize, deadline) {
237
+ if (deadline !== void 0 && deadline.remaining() === 0)
238
+ return Promise.reject(deadline.expired());
239
+ return new Promise((resolve, reject) => {
240
+ const stream = file.createReadStream(options);
241
+ const chunks = [];
242
+ let total = 0;
243
+ let status = 0;
244
+ let headers = {};
245
+ let responded = false;
246
+ let settled = false;
247
+ const timer = deadline === void 0 ? void 0 : setTimeout(() => fail(deadline.expired()), deadline.remaining());
248
+ const release = () => void setImmediate(() => stream.destroy());
249
+ const fail = (err) => {
250
+ if (settled) return;
251
+ settled = true;
252
+ clearTimeout(timer);
253
+ if (responded) release();
254
+ reject(err);
255
+ };
256
+ stream.on("response", (res) => {
257
+ responded = true;
258
+ if (settled) return release();
259
+ status = typeof res.statusCode === "number" ? res.statusCode : 0;
260
+ headers = res.headers ?? {};
261
+ if (status < 200 || status > 299) return;
262
+ const advertised = singleHeader(headers, "content-length");
263
+ if (advertised !== void 0 && /^\d+$/.test(advertised) && Number(advertised) > maxBytes) {
264
+ fail(oversize(Number(advertised)));
265
+ }
266
+ });
267
+ stream.on("data", (chunk) => {
268
+ if (settled) return;
269
+ total += chunk.length;
270
+ if (total > maxBytes) return fail(oversize(void 0));
271
+ chunks.push(chunk);
272
+ });
273
+ stream.on("error", fail);
274
+ stream.on("end", () => {
275
+ if (settled) return;
276
+ settled = true;
277
+ clearTimeout(timer);
278
+ resolve({ status, headers, bytes: new Uint8Array(Buffer.concat(chunks, total)) });
279
+ });
280
+ stream.on(
281
+ "close",
282
+ () => fail(
283
+ Object.assign(new Error("GCS read ended before the response completed"), {
284
+ code: "ECONNRESET"
285
+ })
286
+ )
287
+ );
288
+ });
289
+ }
290
+
98
291
  // src/storage.ts
99
292
  var DEFAULT_MAX_OBJECT_BYTES = 5 * 1024 * 1024 * 1024 * 1024;
100
293
  var WRITE_ID_KEY = "cbwid";
294
+ var THROTTLE_RESENDS = 3;
295
+ var THROTTLE_BASE_DELAY_MS = 500;
296
+ var REAL_TIME = {
297
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
298
+ };
101
299
  var DEFAULT_UPLOAD_THRESHOLD_BYTES = 8 * 1024 * 1024;
102
300
  var GcsStorageDriver = class {
103
301
  storage;
302
+ readStorage;
104
303
  bucket;
105
304
  prefix;
106
305
  maxObjectBytes;
107
306
  threshold;
307
+ readTimeoutMs;
308
+ clock;
108
309
  constructor(options) {
109
310
  this.storage = options.storage;
311
+ this.clock = options.clock ?? REAL_TIME;
312
+ this.readStorage = options.readStorage ?? options.storage;
110
313
  this.bucket = options.bucket;
111
314
  this.prefix = normalizeGcsPrefix(options.prefix);
112
315
  for (const [name, value] of [
@@ -114,11 +317,12 @@ var GcsStorageDriver = class {
114
317
  ["simpleUploadThresholdBytes", options.simpleUploadThresholdBytes]
115
318
  ]) {
116
319
  if (value !== void 0 && (!Number.isSafeInteger(value) || value < 1)) {
117
- throw new ValidationError2(`${name} must be a positive safe integer; got ${value}`);
320
+ throw new ValidationError3(`${name} must be a positive safe integer; got ${value}`);
118
321
  }
119
322
  }
120
323
  this.maxObjectBytes = options.maxObjectBytes ?? DEFAULT_MAX_OBJECT_BYTES;
121
324
  this.threshold = options.simpleUploadThresholdBytes ?? DEFAULT_UPLOAD_THRESHOLD_BYTES;
325
+ this.readTimeoutMs = resolveReadTimeoutMs(options.readTimeoutMs);
122
326
  }
123
327
  capabilities() {
124
328
  return { rangeRead: true, maxObjectBytes: this.maxObjectBytes, conditionalPut: true };
@@ -126,9 +330,22 @@ var GcsStorageDriver = class {
126
330
  file(name) {
127
331
  return this.storage.bucket(this.bucket).file(name);
128
332
  }
333
+ /** The same object on the download client, off the SDK's shared agent ({@link downloadFile}). */
334
+ downloadable(name) {
335
+ return downloadFile(this.readStorage, this.bucket, name);
336
+ }
337
+ /** The deadline a read of `key` made now runs under, every attempt included, named for its error; none when off. */
338
+ deadline(read, key) {
339
+ return startDeadline(this.readTimeoutMs, `${read} of ${key.segment}.${key.generation}`);
340
+ }
129
341
  async putImmutable(key, write) {
130
342
  const objectName = storageObjectName(this.prefix, key);
131
- const sink = new GcsUploadSink(this.file(objectName), this.maxObjectBytes, this.threshold);
343
+ const sink = new GcsUploadSink(
344
+ this.file(objectName),
345
+ this.maxObjectBytes,
346
+ this.threshold,
347
+ this.clock
348
+ );
132
349
  try {
133
350
  await write(sink);
134
351
  return await sink.finish();
@@ -145,41 +362,150 @@ var GcsStorageDriver = class {
145
362
  }
146
363
  async getRange(key, offset, length) {
147
364
  if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 0) {
148
- throw new ValidationError2(`invalid range offset=${offset} length=${length}`);
365
+ throw new ValidationError3(`invalid range offset=${offset} length=${length}`);
149
366
  }
150
367
  if (length === 0) return new Uint8Array(0);
151
368
  const objectName = storageObjectName(this.prefix, key);
152
369
  try {
153
- const [buf] = await this.file(objectName).download({
154
- start: offset,
155
- end: offset + length - 1
156
- });
157
- if (buf.length !== length) {
158
- throw new ValidationError2(
159
- `range [${offset}, ${offset + length}) out of bounds (got ${buf.length}B)`
160
- );
161
- }
162
- return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
370
+ const deadline = this.deadline("range read", key);
371
+ const res = await retryDownload(
372
+ () => readOnce(
373
+ this.downloadable(objectName),
374
+ { start: offset, end: offset + length - 1, decompress: false },
375
+ length,
376
+ () => this.badRange(key, `the response is longer than the ${length}B requested`),
377
+ deadline
378
+ ),
379
+ deadline
380
+ );
381
+ this.checkRange(res, key, offset, length);
382
+ return res.bytes;
163
383
  } catch (err) {
164
384
  throw this.mapReadError(err, key);
165
385
  }
166
386
  }
387
+ /**
388
+ * Refuse a range response unless it is exactly the bytes asked for. A 206 must name them in `Content-Range: bytes
389
+ * a-b/total`, starting at `offset`, and hold that many; a 200 (a server that ignored the range) is the whole object,
390
+ * which is the range only when the range starts at 0. A short read means the range ran past the end of the object:
391
+ * out of bounds, never a partial result.
392
+ */
393
+ checkRange(res, key, offset, length) {
394
+ const received = res.bytes.length;
395
+ if (res.status === 200) {
396
+ if (offset !== 0)
397
+ throw this.badRange(key, `whole object returned for a range from ${offset}`);
398
+ } else if (res.status === 206) {
399
+ const header = singleHeader(res.headers, "content-range");
400
+ const m = header === void 0 ? null : /^bytes (\d+)-(\d+)\/(\d+)$/.exec(header);
401
+ if (m === null) throw this.badRange(key, "Content-Range is missing or malformed");
402
+ const [first, last, total] = [Number(m[1]), Number(m[2]), Number(m[3])];
403
+ if (![first, last, total].every(Number.isSafeInteger)) {
404
+ throw this.badRange(key, "Content-Range holds a number that is not a safe integer");
405
+ }
406
+ if (first !== offset || last - first + 1 !== received || last >= total) {
407
+ throw this.badRange(
408
+ key,
409
+ `Content-Range ${first}-${last}/${total} disagrees with ${received}B received from ${offset}`
410
+ );
411
+ }
412
+ } else {
413
+ throw this.badRange(key, `unexpected HTTP ${res.status}`);
414
+ }
415
+ if (received !== length) {
416
+ throw new ValidationError3(
417
+ `range [${offset}, ${offset + length}) out of bounds (got ${received}B)`
418
+ );
419
+ }
420
+ }
421
+ badRange(key, why) {
422
+ return new ValidationError3(
423
+ `GCS range read of ${key.segment}.${key.generation} refused: ${why}`
424
+ );
425
+ }
167
426
  async getTail(key, maxBytes) {
168
427
  const objectName = storageObjectName(this.prefix, key);
428
+ const deadline = this.deadline("tail read", key);
169
429
  try {
170
- const [meta] = await this.file(objectName).getMetadata();
171
- const size = Number(meta.size ?? 0);
172
- if (!Number.isSafeInteger(size) || size < 0) {
173
- throw new ValidationError2(`GCS returned an invalid object size: ${String(meta.size)}`);
430
+ if (maxBytes <= 0)
431
+ return { bytes: new Uint8Array(0), size: await this.sizeOf(objectName, deadline) };
432
+ if (!Number.isSafeInteger(maxBytes)) {
433
+ throw new ValidationError3(`invalid tail length ${maxBytes}`);
434
+ }
435
+ let res;
436
+ try {
437
+ res = await retryDownload(
438
+ () => readOnce(
439
+ this.downloadable(objectName),
440
+ { end: -maxBytes, decompress: false },
441
+ maxBytes,
442
+ () => this.badTail(key, `the response is longer than the ${maxBytes}B requested`),
443
+ deadline
444
+ ),
445
+ deadline
446
+ );
447
+ } catch (err) {
448
+ if (!isInvalidRange(err)) throw err;
449
+ const size = await this.sizeOf(objectName, deadline);
450
+ if (size !== 0) throw err;
451
+ return { bytes: new Uint8Array(0), size };
452
+ }
453
+ if (res.bytes.length === 0) {
454
+ const size = await this.sizeOf(objectName, deadline);
455
+ if (size !== 0) throw this.badTail(key, `no bytes returned for an object of ${size}B`);
456
+ return { bytes: res.bytes, size };
174
457
  }
175
- if (maxBytes <= 0 || size === 0) return { bytes: new Uint8Array(0), size };
176
- const start = Math.max(0, size - maxBytes);
177
- const [buf] = await this.file(objectName).download({ start, end: size - 1 });
178
- return { bytes: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), size };
458
+ return { bytes: res.bytes, size: this.tailSize(res, maxBytes, key) };
179
459
  } catch (err) {
180
460
  throw this.mapReadError(err, key);
181
461
  }
182
462
  }
463
+ /**
464
+ * The object's size from its metadata, validated. Read on the download client and retried as a download is, within
465
+ * the calling read's `deadline` ({@link withDeadline}), so nothing but the one request in flight runs past it.
466
+ */
467
+ async sizeOf(objectName, deadline) {
468
+ const [meta] = await retryDownload(
469
+ () => withDeadline(() => this.downloadable(objectName).getMetadata(), deadline),
470
+ deadline
471
+ );
472
+ const size = Number(meta.size ?? 0);
473
+ if (!Number.isSafeInteger(size) || size < 0) {
474
+ throw new ValidationError3(`GCS returned an invalid object size: ${String(meta.size)}`);
475
+ }
476
+ return size;
477
+ }
478
+ /**
479
+ * The object's total size from a tail response, refused unless every header agrees with the bytes received. A 206
480
+ * must carry `Content-Range: bytes a-b/total` that names exactly the suffix asked for and exactly the bytes held;
481
+ * a 200 (a server that ignored the range) is the whole object, and is accepted only when it fits in the request.
482
+ */
483
+ tailSize(res, maxBytes, key) {
484
+ const received = res.bytes.length;
485
+ if (res.status === 200) {
486
+ if (received > maxBytes)
487
+ throw this.badTail(key, "whole object returned past the requested length");
488
+ return received;
489
+ }
490
+ if (res.status !== 206) throw this.badTail(key, `unexpected HTTP ${res.status}`);
491
+ const header = singleHeader(res.headers, "content-range");
492
+ const m = header === void 0 ? null : /^bytes (\d+)-(\d+)\/(\d+)$/.exec(header);
493
+ if (m === null) throw this.badTail(key, `Content-Range is missing or malformed`);
494
+ const [first, last, total] = [Number(m[1]), Number(m[2]), Number(m[3])];
495
+ if (![first, last, total].every(Number.isSafeInteger)) {
496
+ throw this.badTail(key, "Content-Range holds a number that is not a safe integer");
497
+ }
498
+ if (last !== total - 1 || first !== Math.max(0, total - maxBytes) || last - first + 1 !== received) {
499
+ throw this.badTail(
500
+ key,
501
+ `Content-Range ${first}-${last}/${total} disagrees with ${received}B received for a ${maxBytes}B tail`
502
+ );
503
+ }
504
+ return total;
505
+ }
506
+ badTail(key, why) {
507
+ return new ValidationError3(`GCS tail read of ${key.segment}.${key.generation} refused: ${why}`);
508
+ }
183
509
  async delete(key) {
184
510
  try {
185
511
  await this.file(storageObjectName(this.prefix, key)).delete({ ignoreNotFound: true });
@@ -208,15 +534,24 @@ var GcsStorageDriver = class {
208
534
  if (isNotFound(err))
209
535
  return new NotFoundError(`no such generation: ${key.segment}.${key.generation}`);
210
536
  if (isInvalidRange(err)) {
211
- return new ValidationError2(`range out of bounds for ${key.segment}.${key.generation}`);
537
+ return new ValidationError3(`range out of bounds for ${key.segment}.${key.generation}`);
538
+ }
539
+ if (err instanceof ReadTimedOut) return new TransientError(err.message, { cause: err });
540
+ if (isTransportFault(err)) {
541
+ return new TransientError(
542
+ `transient GCS fault: ${String(err.code)}`,
543
+ {
544
+ cause: err
545
+ }
546
+ );
212
547
  }
213
548
  return this.mapError(err);
214
549
  }
215
550
  /**
216
551
  * Reclassify a transient GCS fault (throttle/5xx/dropped connection) as a retryable {@link TransientError},
217
552
  * so the store's read retry can ride it out and a write's caller can tell it from a deterministic failure;
218
- * everything else propagates unchanged. The final fallback at every client-call site, so callers and the read
219
- * retry only ever see typed errors.
553
+ * everything else propagates unchanged. The final fallback at every client-call site: an HTTP status and a fault
554
+ * in transit arrive typed, and anything else (a checksum mismatch, a credentials failure) as the SDK raised it.
220
555
  */
221
556
  mapError(err) {
222
557
  if (isTransient(err)) {
@@ -238,14 +573,16 @@ function concatBytes(parts, total) {
238
573
  return out;
239
574
  }
240
575
  var GcsUploadSink = class _GcsUploadSink {
241
- constructor(file, maxObjectBytes, threshold) {
576
+ constructor(file, maxObjectBytes, threshold, clock) {
242
577
  this.file = file;
243
578
  this.maxObjectBytes = maxObjectBytes;
244
579
  this.threshold = threshold;
580
+ this.clock = clock;
245
581
  }
246
582
  file;
247
583
  maxObjectBytes;
248
584
  threshold;
585
+ clock;
249
586
  hash = createHash("sha256");
250
587
  total = 0;
251
588
  buffered = [];
@@ -260,16 +597,26 @@ var GcsUploadSink = class _GcsUploadSink {
260
597
  metadata: { contentType: "application/octet-stream" }
261
598
  };
262
599
  /**
263
- * This write's own id, which the resumable upload stores in the object's custom metadata (outside the `.crbm`
264
- * bytes) so a conflict can be told from a replay of this same write. The simple upload is sent once and needs none.
600
+ * This write's own id, stored in the object's custom metadata (outside the `.crbm` bytes) so a conflict can be told
601
+ * from a replay of this same write: the resumable session's, or the simple upload's re-send after a throttle.
265
602
  */
266
603
  writeId = randomBytes(16).toString("hex");
604
+ /** The upload options with this write's id in the object's custom metadata. */
605
+ tagged() {
606
+ return {
607
+ ..._GcsUploadSink.WRITE_OPTS,
608
+ metadata: {
609
+ ..._GcsUploadSink.WRITE_OPTS.metadata,
610
+ metadata: { [WRITE_ID_KEY]: this.writeId }
611
+ }
612
+ };
613
+ }
267
614
  async write(bytes) {
268
615
  if (this.failure !== void 0) throw this.failure;
269
616
  if (bytes.length === 0) return;
270
617
  this.total += bytes.length;
271
618
  if (this.total > this.maxObjectBytes) {
272
- throw new ValidationError2(`object exceeds maxObjectBytes ${this.maxObjectBytes}`);
619
+ throw new ValidationError3(`object exceeds maxObjectBytes ${this.maxObjectBytes}`);
273
620
  }
274
621
  this.hash.update(bytes);
275
622
  if (this.stream !== void 0) {
@@ -282,14 +629,7 @@ var GcsUploadSink = class _GcsUploadSink {
282
629
  }
283
630
  /** Cross into resumable streaming: open the stream, flush the buffered bytes, keep only ~one threshold resident. */
284
631
  startResumable() {
285
- const stream = this.file.createWriteStream({
286
- resumable: true,
287
- ..._GcsUploadSink.WRITE_OPTS,
288
- metadata: {
289
- ..._GcsUploadSink.WRITE_OPTS.metadata,
290
- metadata: { [WRITE_ID_KEY]: this.writeId }
291
- }
292
- });
632
+ const stream = this.file.createWriteStream({ resumable: true, ...this.tagged() });
293
633
  this.stream = stream;
294
634
  this.done = once2(stream, "finish");
295
635
  stream.on("error", (e) => {
@@ -304,11 +644,7 @@ var GcsUploadSink = class _GcsUploadSink {
304
644
  async finish() {
305
645
  const sha256 = this.hash.digest("hex");
306
646
  if (this.stream === void 0) {
307
- await saveOnce(
308
- this.file,
309
- concatBytes(this.buffered, this.bufferedLen),
310
- _GcsUploadSink.WRITE_OPTS
311
- );
647
+ await this.sendSimple(concatBytes(this.buffered, this.bufferedLen));
312
648
  return { size: this.total, sha256 };
313
649
  }
314
650
  try {
@@ -320,6 +656,34 @@ var GcsUploadSink = class _GcsUploadSink {
320
656
  }
321
657
  return { size: this.total, sha256 };
322
658
  }
659
+ /**
660
+ * Send the simple upload once, and again after a `429` or `503`, up to {@link THROTTLE_RESENDS} times, waiting a
661
+ * full-jitter backoff before each. Any other failure is thrown at once. Once it has been sent again, a `412` can be
662
+ * an earlier send that landed after all, so the object's write id decides: this write's own is a success, and any
663
+ * other object, or one with no id, is the conflict. A failed read-back throws, and is neither. Exhausted, it throws
664
+ * {@link TransientError}; nothing is deleted.
665
+ */
666
+ async sendSimple(body) {
667
+ for (let resent = 0; ; resent++) {
668
+ try {
669
+ await saveOnce(this.file, body, this.tagged());
670
+ return;
671
+ } catch (err) {
672
+ if (resent > 0 && isPreconditionFailed(err)) {
673
+ if (await this.storedWriteId() === this.writeId) return;
674
+ throw err;
675
+ }
676
+ if (!isThrottle(err)) throw err;
677
+ if (resent >= THROTTLE_RESENDS) {
678
+ throw new TransientError(
679
+ `GCS upload was throttled on each of its ${resent + 1} sends; the object may or may not exist`,
680
+ { cause: err }
681
+ );
682
+ }
683
+ await this.clock.sleep(Math.floor(Math.random() * THROTTLE_BASE_DELAY_MS * 2 ** resent));
684
+ }
685
+ }
686
+ }
323
687
  /** The id the stored object carries in its custom metadata, or `undefined` when it carries none or is gone. */
324
688
  async storedWriteId() {
325
689
  try {
@@ -340,44 +704,61 @@ import {
340
704
  IntegrityError,
341
705
  MAX_ROW_BYTES,
342
706
  ObjectStoreRegistry,
343
- ObjectVersionRaced,
344
707
  TransientError as TransientError2,
708
+ ValidationError as ValidationError4,
345
709
  WriteConflictError as WriteConflictError2,
346
710
  normalizeObjectPrefix
347
711
  } from "@cloudbitmaps/core/driver-kit";
348
- var GcsStore = class {
349
- constructor(storage, bucket) {
712
+ var GcsRegistryStore = class {
713
+ constructor(storage, readStorage, bucket, readTimeoutMs, conditionalDelete) {
350
714
  this.storage = storage;
715
+ this.readStorage = readStorage;
351
716
  this.bucket = bucket;
717
+ this.readTimeoutMs = readTimeoutMs;
718
+ this.conditionalDelete = conditionalDelete;
352
719
  }
353
720
  storage;
721
+ readStorage;
354
722
  bucket;
723
+ readTimeoutMs;
724
+ conditionalDelete;
355
725
  label = "GCS";
356
- /** A file handle, optionally pinned to one object generation (GCS's version fence). */
357
- file(name, generation) {
358
- return generation === void 0 ? this.storage.bucket(this.bucket).file(name) : this.storage.bucket(this.bucket).file(name, { generation });
726
+ /** A handle on one registry object. */
727
+ file(name) {
728
+ return this.storage.bucket(this.bucket).file(name);
729
+ }
730
+ /** The same file handle on the download client, off the SDK's shared agent ({@link downloadFile}). */
731
+ downloadable(name) {
732
+ return downloadFile(this.readStorage, this.bucket, name);
359
733
  }
360
734
  async read(key) {
361
- let generation;
362
- let size;
735
+ let res;
363
736
  try {
364
- const [meta] = await this.file(key).getMetadata();
365
- generation = String(meta.generation ?? "");
366
- size = Number(meta.size ?? 0);
737
+ const deadline = startDeadline(this.readTimeoutMs, `registry read of ${key}`);
738
+ res = await retryDownload(
739
+ () => readOnce(
740
+ this.downloadable(key),
741
+ {},
742
+ MAX_ROW_BYTES,
743
+ (size) => new IntegrityError(
744
+ size === void 0 ? `registry object exceeds cap ${MAX_ROW_BYTES}B` : `registry object ${size}B exceeds cap ${MAX_ROW_BYTES}B`
745
+ ),
746
+ deadline
747
+ ),
748
+ deadline
749
+ );
367
750
  } catch (err) {
368
751
  if (isNotFound(err)) return null;
369
- throw mapError(err);
752
+ throw mapReadError(err);
370
753
  }
371
- if (size > MAX_ROW_BYTES) {
372
- throw new IntegrityError(`registry object ${size}B exceeds cap ${MAX_ROW_BYTES}B`);
754
+ if (res.status !== 200) {
755
+ throw new IntegrityError(`registry read answered HTTP ${res.status}, not 200: ${key}`);
373
756
  }
374
- try {
375
- const [buf] = await this.file(key, generation).download();
376
- return { bytes: new Uint8Array(buf), version: generation };
377
- } catch (err) {
378
- if (isNotFound(err)) throw new ObjectVersionRaced(key);
379
- throw mapError(err);
757
+ const generation = singleHeader(res.headers, "x-goog-generation");
758
+ if (generation === void 0 || !/^[1-9]\d*$/.test(generation) || !Number.isSafeInteger(Number(generation))) {
759
+ throw new IntegrityError(`registry read carries no usable x-goog-generation: ${key}`);
380
760
  }
761
+ return { bytes: res.bytes, version: generation };
381
762
  }
382
763
  async write(key, body, expect) {
383
764
  try {
@@ -394,6 +775,16 @@ var GcsStore = class {
394
775
  throw mapError(err);
395
776
  }
396
777
  }
778
+ async delete(key, expect) {
779
+ try {
780
+ await this.file(key).delete({ ifGenerationMatch: generationFence(expect.version, key) });
781
+ } catch (err) {
782
+ if (isPreconditionFailed(err) || isNotFound(err)) {
783
+ throw new WriteConflictError2(`registry OCC conflict deleting ${key}`);
784
+ }
785
+ throw mapError(err);
786
+ }
787
+ }
397
788
  async *listKeys(prefix) {
398
789
  let pageToken;
399
790
  do {
@@ -416,19 +807,43 @@ function generationFence(version, key) {
416
807
  }
417
808
  return generation;
418
809
  }
810
+ function mapReadError(err) {
811
+ if (err instanceof ReadTimedOut) return new TransientError2(err.message, { cause: err });
812
+ if (isTransportFault(err)) {
813
+ return new TransientError2(`transient GCS fault: ${String(err.code)}`, {
814
+ cause: err
815
+ });
816
+ }
817
+ return mapError(err);
818
+ }
419
819
  function mapError(err) {
420
820
  if (isTransient(err)) {
421
821
  return new TransientError2(
422
- `transient GCS fault: ${err?.name ?? "unknown"}`,
822
+ `transient GCS fault: ${err?.code ?? err?.name ?? "unknown"}`,
423
823
  { cause: err }
424
824
  );
425
825
  }
426
826
  return err;
427
827
  }
828
+ function resolveConditionalDelete(value) {
829
+ if (value === void 0) {
830
+ return false;
831
+ }
832
+ if (typeof value !== "boolean") {
833
+ throw new ValidationError4(`conditionalDelete must be a boolean; got ${String(value)}`);
834
+ }
835
+ return value;
836
+ }
428
837
  var GcsRegistryDriver = class extends ObjectStoreRegistry {
429
838
  constructor(options) {
430
839
  super(
431
- new GcsStore(options.storage, options.bucket),
840
+ new GcsRegistryStore(
841
+ options.storage,
842
+ options.readStorage ?? options.storage,
843
+ options.bucket,
844
+ resolveReadTimeoutMs(options.readTimeoutMs),
845
+ resolveConditionalDelete(options.conditionalDelete)
846
+ ),
432
847
  normalizeObjectPrefix(options.prefix),
433
848
  options.now ?? (() => Date.now())
434
849
  );
@@ -444,19 +859,21 @@ var GCS_STORAGE_OPTION_KEYS = [
444
859
  "apiEndpoint",
445
860
  "maxObjectBytes",
446
861
  "simpleUploadThresholdBytes",
447
- "now"
862
+ "now",
863
+ "readTimeoutMs",
864
+ "conditionalDelete"
448
865
  ];
449
866
  var CLIENT_SETTINGS = ["projectId", "apiEndpoint"];
450
867
  function refuseUnknown(name, options, keys, hint) {
451
868
  if (options === null || typeof options !== "object") {
452
- throw new ValidationError3(
869
+ throw new ValidationError5(
453
870
  `${name} needs an options object \u2014 got ${options === null ? "null" : typeof options}`
454
871
  );
455
872
  }
456
873
  const unknown = Object.keys(options).filter((k) => !keys.includes(k));
457
874
  if (unknown.length > 0) {
458
875
  const list = (ks) => ks.map((k) => `\`${k}\``).join(", ");
459
- throw new ValidationError3(
876
+ throw new ValidationError5(
460
877
  `${name} does not take ${list(unknown)}. It takes ${list(keys)}; ${hint}.`
461
878
  );
462
879
  }
@@ -467,6 +884,7 @@ var GcsStorage = class {
467
884
  /** The client both halves share — built here unless one was supplied. */
468
885
  client;
469
886
  constructor(options) {
887
+ let readClient;
470
888
  refuseUnknown(
471
889
  "GcsStorage",
472
890
  options,
@@ -476,21 +894,26 @@ var GcsStorage = class {
476
894
  if (options.client !== void 0 && options.client !== null) {
477
895
  const ignored = CLIENT_SETTINGS.filter((k) => options[k] !== void 0);
478
896
  if (ignored.length > 0) {
479
- throw new ValidationError3(
897
+ throw new ValidationError5(
480
898
  `GcsStorage takes \`client\` OR ${CLIENT_SETTINGS.map((k) => `\`${k}\``).join(" / ")}, not both \u2014 got \`client\` with ${ignored.map((k) => `\`${k}\``).join(", ")}; the \`client\` already carries them, so configure them on the client, or drop \`client\``
481
899
  );
482
900
  }
483
901
  this.client = options.client;
902
+ readClient = options.client;
484
903
  } else {
485
- this.client = new GcsClient({
904
+ const settings = {
486
905
  ...options.projectId === void 0 ? {} : { projectId: options.projectId },
487
906
  ...options.apiEndpoint === void 0 ? {} : { apiEndpoint: options.apiEndpoint }
488
- });
907
+ };
908
+ this.client = new GcsClient(settings);
909
+ readClient = new GcsClient({ ...settings, retryOptions: { autoRetry: false } });
489
910
  }
490
911
  const shared = {
491
912
  storage: this.client,
913
+ readStorage: readClient,
492
914
  bucket: options.bucket,
493
- ...options.prefix === void 0 ? {} : { prefix: options.prefix }
915
+ ...options.prefix === void 0 ? {} : { prefix: options.prefix },
916
+ ...options.readTimeoutMs === void 0 ? {} : { readTimeoutMs: options.readTimeoutMs }
494
917
  };
495
918
  this.storage = new GcsStorageDriver({
496
919
  ...shared,
@@ -499,7 +922,8 @@ var GcsStorage = class {
499
922
  });
500
923
  this.registry = new GcsRegistryDriver({
501
924
  ...shared,
502
- ...options.now === void 0 ? {} : { now: options.now }
925
+ ...options.now === void 0 ? {} : { now: options.now },
926
+ ...options.conditionalDelete === void 0 ? {} : { conditionalDelete: options.conditionalDelete }
503
927
  });
504
928
  brandAsBackend(this);
505
929
  }