@syncular/server 0.32.1 → 0.34.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.
Files changed (43) hide show
  1. package/dist/blob-handlers.js +3 -3
  2. package/dist/bucket-blob-store.d.ts +77 -0
  3. package/dist/bucket-blob-store.js +266 -0
  4. package/dist/bucket-segment-store.d.ts +77 -0
  5. package/dist/bucket-segment-store.js +445 -0
  6. package/dist/context.d.ts +7 -0
  7. package/dist/d1-storage.js +2 -2
  8. package/dist/handler.js +4 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.js +4 -0
  11. package/dist/object-bucket.d.ts +78 -0
  12. package/dist/object-bucket.js +18 -0
  13. package/dist/operations-realtime.js +3 -0
  14. package/dist/operations.d.ts +20 -3
  15. package/dist/operations.js +23 -3
  16. package/dist/realtime.d.ts +11 -0
  17. package/dist/realtime.js +25 -58
  18. package/dist/relational-rows.d.ts +6 -3
  19. package/dist/relational-rows.js +8 -9
  20. package/dist/s3-blob-store.d.ts +9 -64
  21. package/dist/s3-blob-store.js +25 -359
  22. package/dist/s3-object-bucket.d.ts +39 -0
  23. package/dist/s3-object-bucket.js +164 -0
  24. package/dist/s3-segment-store.d.ts +9 -61
  25. package/dist/s3-segment-store.js +25 -503
  26. package/dist/sqlite-storage.js +3 -8
  27. package/package.json +2 -2
  28. package/src/blob-handlers.ts +4 -4
  29. package/src/bucket-blob-store.ts +351 -0
  30. package/src/bucket-segment-store.ts +582 -0
  31. package/src/context.ts +10 -0
  32. package/src/d1-storage.ts +3 -3
  33. package/src/handler.ts +11 -0
  34. package/src/index.ts +4 -0
  35. package/src/object-bucket.ts +83 -0
  36. package/src/operations-realtime.ts +7 -0
  37. package/src/operations.ts +52 -5
  38. package/src/realtime.ts +29 -58
  39. package/src/relational-rows.ts +8 -11
  40. package/src/s3-blob-store.ts +25 -466
  41. package/src/s3-object-bucket.ts +233 -0
  42. package/src/s3-segment-store.ts +25 -646
  43. package/src/sqlite-storage.ts +3 -10
@@ -1,49 +1,25 @@
1
1
  /**
2
2
  * S3-compatible blob store (SPEC.md §5.9.2 durable objects, §5.9.5 delegated
3
- * presign) — AWS S3, Cloudflare R2, MinIO. The blob twin of
4
- * `s3-segment-store.ts`: same hand-rolled SigV4 over `fetch` (sigv4.ts), same
5
- * content-addressed key layout, same LIST-free-on-the-hot-path CAS stats
6
- * accumulator. Zero dependencies.
3
+ * presign) — AWS S3, Cloudflare R2's S3 endpoint, MinIO. The blob twin of
4
+ * `s3-segment-store.ts`.
7
5
  *
8
- * Key layout (deterministic — every point lookup is a GET/HEAD, never a LIST):
9
- *
10
- * {keyPrefix}blob/sha256/{hex}
11
- * The blob bytes, VERBATIM (the object body MUST be exactly the
12
- * content-addressed bytes so a presigned GET serves them directly and the
13
- * client's §5.9.1 hash check passes — blobs are never re-compressed at
14
- * rest, §5.8/§5.9.5). The `byteLength` + optional `mediaType` ride along
15
- * as object user metadata `x-amz-meta-syncular-blob` = base64url(JSON), so
16
- * `get` is a single GET. `createdAtMs` is stored there too so the orphan
17
- * sweep (§5.9.2) can read the upload age without a separate index.
18
- *
19
- * DURABILITY vs SEGMENT TTL — the honest interface difference (§5.9.2): a
20
- * blob referenced by a live row must stay downloadable INDEFINITELY. So,
21
- * unlike `S3SegmentStore`, this store writes **no `expiresAtMs`, no S3
22
- * lifecycle-expiration mapping, and no TTL config**. Expiry is
23
- * REFERENCE-driven, not time-driven: reclamation is the host-scheduled
24
- * `sweepOrphanBlobs` (blob-store.ts) deleting only objects no live row
25
- * references after a grace period — never a bucket lifecycle rule. Do NOT
26
- * put a lifecycle expiration on the `blob/` prefix; it would delete
27
- * still-referenced attachments.
28
- *
29
- * The orphan sweep is the ONLY operation that LISTs: `sweepOrphans` pages
30
- * `ListObjectsV2` over the `blob/` prefix (an admin/GC path, off the hot
31
- * path) to find candidates, reads each object's `createdAtMs` from its
32
- * metadata for the grace check, and DELETEs the unreferenced-and-old ones.
6
+ * The store logic (content-addressed key layout, idempotent re-put, orphan
7
+ * sweep, CAS stats accumulator) is `BucketBlobStore`, shared with
8
+ * `R2BlobStore` in `@syncular/server-workers`; this class adds the S3 REST
9
+ * transport (`S3ObjectBucket`) and the SigV4 presign methods, which a bucket
10
+ * binding cannot mint.
33
11
  */
34
- import type { BlobRecord, BlobStore, BlobStoreStats } from './blob-store';
12
+ import {
13
+ BucketBlobStore,
14
+ type BucketBlobStoreOptions,
15
+ } from './bucket-blob-store';
16
+ import { S3ObjectBucket } from './s3-object-bucket';
35
17
  import type {
36
18
  BlobPresignConfig,
37
19
  BlobUploadPresignConfig,
38
20
  SegmentUrlIssue,
39
21
  } from './signed-url';
40
- import {
41
- EMPTY_PAYLOAD_SHA256,
42
- presignUrl,
43
- type SigV4Credentials,
44
- sha256Hex,
45
- signRequest,
46
- } from './sigv4';
22
+ import { presignUrl, type SigV4Credentials } from './sigv4';
47
23
 
48
24
  export interface S3BlobStoreConfig {
49
25
  /**
@@ -66,135 +42,18 @@ export interface S3BlobStoreConfig {
66
42
  }
67
43
 
68
44
  const BLOB_ID_PATTERN = /^sha256:[0-9a-f]{64}$/;
69
- const BLOB_META_HEADER = 'x-amz-meta-syncular-blob';
70
-
71
- /**
72
- * Stats accumulator (mirrors `S3SegmentStore`): a small counter object under
73
- * a fixed key, updated read-modify-write on `put` under an ETag
74
- * compare-and-swap. A LIST-free point-lookup store cannot count objects on
75
- * demand cheaply, so `stats()` reads the accumulator. The counters are
76
- * APPROXIMATE — a crash between the blob PUT and the accumulator CAS, or a
77
- * sweep DELETE the accumulator does not decrement, drifts them — so the
78
- * admin surface labels S3 blob stats `approximate: true`. A health gauge,
79
- * not an invoice.
80
- */
81
- const STATS_KEY_SUFFIX = 'stats/blobs.json';
82
- const STATS_CAS_ATTEMPTS = 16;
83
-
84
- interface StatsAccumulator {
85
- count: number;
86
- bytes: number;
87
- }
88
-
89
- function emptyStats(): StatsAccumulator {
90
- return { count: 0, bytes: 0 };
91
- }
92
-
93
- function parseStatsJson(json: string): StatsAccumulator {
94
- const parsed = JSON.parse(json) as Partial<StatsAccumulator>;
95
- return {
96
- count: Number(parsed.count ?? 0),
97
- bytes: Number(parsed.bytes ?? 0),
98
- };
99
- }
100
-
101
- // Runtime-neutral base64url: `Buffer` is not present on
102
- // Cloudflare Workers without `nodejs_compat`, so the metadata header is
103
- // (de)coded with `btoa`/`atob`, available in every runtime.
104
- function utf8ToBase64url(text: string): string {
105
- let binary = '';
106
- for (const byte of new TextEncoder().encode(text)) {
107
- binary += String.fromCharCode(byte);
108
- }
109
- return btoa(binary)
110
- .replaceAll('+', '-')
111
- .replaceAll('/', '_')
112
- .replace(/=+$/, '');
113
- }
114
-
115
- function base64urlToUtf8(value: string): string {
116
- const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
117
- const bytes = new Uint8Array(binary.length);
118
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
119
- return new TextDecoder().decode(bytes);
120
- }
121
-
122
- interface BlobMeta {
123
- readonly byteLength: number;
124
- readonly mediaType?: string;
125
- readonly createdAtMs: number;
126
- }
127
-
128
- function metaToJson(meta: BlobMeta): string {
129
- return JSON.stringify(meta);
130
- }
131
45
 
132
- function parseMetaJson(json: string, source: string): BlobMeta {
133
- let parsed: unknown;
134
- try {
135
- parsed = JSON.parse(json);
136
- } catch {
137
- throw new Error(`S3BlobStore: corrupt blob metadata in ${source}`);
138
- }
139
- if (typeof parsed !== 'object' || parsed === null) {
140
- throw new Error(`S3BlobStore: corrupt blob metadata in ${source}`);
141
- }
142
- const r = parsed as Record<string, unknown>;
143
- if (typeof r.byteLength !== 'number' || typeof r.createdAtMs !== 'number') {
144
- throw new Error(`S3BlobStore: bad blob metadata in ${source}`);
145
- }
146
- return {
147
- byteLength: r.byteLength,
148
- ...(typeof r.mediaType === 'string' ? { mediaType: r.mediaType } : {}),
149
- createdAtMs: r.createdAtMs,
150
- };
151
- }
152
-
153
- /** One `<Contents>` entry from a `ListObjectsV2` page. */
154
- interface ListedObject {
155
- readonly key: string;
156
- }
157
-
158
- /**
159
- * Extract `<Key>` values (and the `<NextContinuationToken>`, if the listing
160
- * is truncated) from a `ListObjectsV2` XML body. A tiny regex reader — the
161
- * only XML the store ever parses, and only on the GC path. `ListObjectsV2`
162
- * returns keys XML-escaped for `& < >`; we unescape those three.
163
- */
164
- function parseListObjectsV2(xml: string): {
165
- objects: ListedObject[];
166
- nextToken: string | undefined;
167
- } {
168
- const objects: ListedObject[] = [];
169
- for (const m of xml.matchAll(/<Key>([\s\S]*?)<\/Key>/g)) {
170
- objects.push({ key: unescapeXml(m[1] ?? '') });
171
- }
172
- const truncated = /<IsTruncated>\s*true\s*<\/IsTruncated>/i.test(xml);
173
- const tokenMatch =
174
- /<NextContinuationToken>([\s\S]*?)<\/NextContinuationToken>/.exec(xml);
175
- const nextToken =
176
- truncated && tokenMatch !== null
177
- ? unescapeXml(tokenMatch[1] ?? '')
178
- : undefined;
179
- return { objects, nextToken };
180
- }
181
-
182
- function unescapeXml(value: string): string {
183
- return value
184
- .replaceAll('&lt;', '<')
185
- .replaceAll('&gt;', '>')
186
- .replaceAll('&quot;', '"')
187
- .replaceAll('&apos;', "'")
188
- .replaceAll('&amp;', '&');
189
- }
190
-
191
- export class S3BlobStore implements BlobStore {
192
- readonly #config: S3BlobStoreConfig;
46
+ export class S3BlobStore extends BucketBlobStore {
47
+ readonly #bucket: S3ObjectBucket;
48
+ readonly #region: string;
193
49
  readonly #credentials: SigV4Credentials;
194
- readonly #prefix: string;
195
50
 
196
51
  constructor(config: S3BlobStoreConfig) {
197
- this.#config = config;
52
+ const options: BucketBlobStoreOptions = { keyPrefix: config.keyPrefix };
53
+ const bucket = new S3ObjectBucket(config);
54
+ super(bucket, options);
55
+ this.#bucket = bucket;
56
+ this.#region = config.region;
198
57
  this.#credentials = {
199
58
  accessKeyId: config.accessKeyId,
200
59
  secretAccessKey: config.secretAccessKey,
@@ -202,306 +61,6 @@ export class S3BlobStore implements BlobStore {
202
61
  ? { sessionToken: config.sessionToken }
203
62
  : {}),
204
63
  };
205
- this.#prefix = config.keyPrefix ?? '';
206
- }
207
-
208
- /**
209
- * `{keyPrefix}blob/{partition}/sha256/{hex}` — the §5.9.5 "signed key embeds
210
- * the blobId". Blobs are content-addressed, but download authorization and
211
- * the reference index are per-partition, so the same bytes uploaded under
212
- * two partitions are two objects (a partition MUST NOT read another's
213
- * attachment by guessing a content address). The partition is folded into
214
- * the key as a path segment.
215
- */
216
- objectKeyFor(partition: string, blobId: string): string {
217
- return `${this.#prefix}blob/${partition}/${blobId.replace(':', '/')}`;
218
- }
219
-
220
- #statsKey(): string {
221
- return `${this.#prefix}${STATS_KEY_SUFFIX}`;
222
- }
223
-
224
- #urlFor(key: string): URL {
225
- const endpoint = this.#config.endpoint.replace(/\/+$/, '');
226
- return new URL(`${endpoint}/${this.#config.bucket}/${key}`);
227
- }
228
-
229
- async #request(
230
- method: 'GET' | 'HEAD' | 'PUT' | 'DELETE',
231
- key: string,
232
- options?: {
233
- readonly body?: Uint8Array;
234
- readonly headers?: Readonly<Record<string, string>>;
235
- readonly query?: Iterable<readonly [string, string]>;
236
- },
237
- ): Promise<Response | undefined> {
238
- const url = this.#urlFor(key);
239
- if (options?.query !== undefined) {
240
- for (const [name, value] of options.query) {
241
- url.searchParams.set(name, value);
242
- }
243
- }
244
- const body = options?.body;
245
- const headers = await signRequest({
246
- method,
247
- url,
248
- region: this.#config.region,
249
- credentials: this.#credentials,
250
- nowMs: Date.now(),
251
- payloadHash:
252
- body === undefined ? EMPTY_PAYLOAD_SHA256 : await sha256Hex(body),
253
- ...(options?.headers !== undefined ? { headers: options.headers } : {}),
254
- });
255
- const response = await fetch(url, {
256
- method,
257
- headers,
258
- ...(body !== undefined ? { body: body as BodyInit } : {}),
259
- });
260
- if (response.status === 404) {
261
- await response.arrayBuffer();
262
- return undefined;
263
- }
264
- if (!response.ok) {
265
- const detail = (await response.text()).slice(0, 300);
266
- throw new Error(
267
- `S3BlobStore: ${method} ${key} failed with ${response.status}: ${detail}`,
268
- );
269
- }
270
- return response;
271
- }
272
-
273
- async #readStats(): Promise<{
274
- stats: StatsAccumulator;
275
- etag: string | undefined;
276
- }> {
277
- const response = await this.#request('GET', this.#statsKey());
278
- if (response === undefined) {
279
- return { stats: emptyStats(), etag: undefined };
280
- }
281
- const etag = response.headers.get('etag') ?? undefined;
282
- const stats = parseStatsJson(await response.text());
283
- return { stats, etag };
284
- }
285
-
286
- async #writeStatsCas(
287
- stats: StatsAccumulator,
288
- etag: string | undefined,
289
- ): Promise<boolean> {
290
- const url = this.#urlFor(this.#statsKey());
291
- const body = new TextEncoder().encode(JSON.stringify(stats));
292
- const conditional: Record<string, string> =
293
- etag === undefined ? { 'if-none-match': '*' } : { 'if-match': etag };
294
- const headers = await signRequest({
295
- method: 'PUT',
296
- url,
297
- region: this.#config.region,
298
- credentials: this.#credentials,
299
- nowMs: Date.now(),
300
- payloadHash: await sha256Hex(body),
301
- headers: { 'content-type': 'application/json', ...conditional },
302
- });
303
- const response = await fetch(url, {
304
- method: 'PUT',
305
- headers,
306
- body: body as BodyInit,
307
- });
308
- if (response.status === 412) {
309
- await response.arrayBuffer();
310
- return false;
311
- }
312
- if (!response.ok) {
313
- const detail = (await response.text()).slice(0, 300);
314
- throw new Error(
315
- `S3BlobStore: stats PUT failed with ${response.status}: ${detail}`,
316
- );
317
- }
318
- await response.arrayBuffer();
319
- return true;
320
- }
321
-
322
- /**
323
- * Fold `delta` into the stats accumulator under an ETag CAS retry loop.
324
- * Best-effort: exhausting the budget leaves the counters drifted (they are
325
- * documented APPROXIMATE) rather than failing an op that already touched
326
- * the bytes.
327
- */
328
- async #bumpStats(delta: StatsAccumulator): Promise<void> {
329
- for (let attempt = 0; attempt < STATS_CAS_ATTEMPTS; attempt++) {
330
- const { stats, etag } = await this.#readStats();
331
- const next: StatsAccumulator = {
332
- count: stats.count + delta.count,
333
- bytes: stats.bytes + delta.bytes,
334
- };
335
- if (await this.#writeStatsCas(next, etag)) return;
336
- }
337
- // Lost the CAS race `STATS_CAS_ATTEMPTS` times — leave counters as-is;
338
- // `stats()` stays APPROXIMATE by contract.
339
- }
340
-
341
- async put(
342
- partition: string,
343
- blobId: string,
344
- bytes: Uint8Array,
345
- nowMs: number,
346
- mediaType?: string,
347
- ): Promise<BlobRecord> {
348
- const record: BlobRecord = {
349
- blobId,
350
- partition,
351
- byteLength: bytes.length,
352
- ...(mediaType !== undefined ? { mediaType } : {}),
353
- createdAtMs: nowMs,
354
- };
355
- const objectKey = this.objectKeyFor(partition, blobId);
356
- // Idempotent re-put (same content-address ⇒ same key): count each distinct
357
- // blob once, and preserve the FIRST createdAtMs so the sweep's grace clock
358
- // is not reset by a re-upload (§5.9.2 upload age).
359
- const preexisting = await this.#request('HEAD', objectKey);
360
- if (preexisting !== undefined) {
361
- const meta = preexisting.headers.get(BLOB_META_HEADER);
362
- await preexisting.arrayBuffer();
363
- if (meta !== null) {
364
- const existing = parseMetaJson(base64urlToUtf8(meta), blobId);
365
- return {
366
- blobId,
367
- partition,
368
- byteLength: existing.byteLength,
369
- ...(existing.mediaType !== undefined
370
- ? { mediaType: existing.mediaType }
371
- : {}),
372
- createdAtMs: existing.createdAtMs,
373
- };
374
- }
375
- }
376
- const meta: BlobMeta = {
377
- byteLength: bytes.length,
378
- ...(mediaType !== undefined ? { mediaType } : {}),
379
- createdAtMs: nowMs,
380
- };
381
- const response = await this.#request('PUT', objectKey, {
382
- body: bytes,
383
- headers: {
384
- 'content-type': mediaType ?? 'application/octet-stream',
385
- [BLOB_META_HEADER]: utf8ToBase64url(metaToJson(meta)),
386
- },
387
- });
388
- await response?.arrayBuffer();
389
- await this.#bumpStats({ count: 1, bytes: bytes.length });
390
- return record;
391
- }
392
-
393
- async has(partition: string, blobId: string): Promise<boolean> {
394
- if (!BLOB_ID_PATTERN.test(blobId)) return false;
395
- const response = await this.#request(
396
- 'HEAD',
397
- this.objectKeyFor(partition, blobId),
398
- );
399
- if (response === undefined) return false;
400
- await response.arrayBuffer();
401
- return true;
402
- }
403
-
404
- async get(
405
- partition: string,
406
- blobId: string,
407
- ): Promise<{ record: BlobRecord; bytes: Uint8Array } | undefined> {
408
- if (!BLOB_ID_PATTERN.test(blobId)) return undefined;
409
- const response = await this.#request(
410
- 'GET',
411
- this.objectKeyFor(partition, blobId),
412
- );
413
- if (response === undefined) return undefined;
414
- const bytes = new Uint8Array(await response.arrayBuffer());
415
- const rawMeta = response.headers.get(BLOB_META_HEADER);
416
- const meta =
417
- rawMeta !== null
418
- ? parseMetaJson(base64urlToUtf8(rawMeta), blobId)
419
- : { byteLength: bytes.length, createdAtMs: 0 };
420
- return {
421
- record: {
422
- blobId,
423
- partition,
424
- byteLength: meta.byteLength,
425
- ...(meta.mediaType !== undefined ? { mediaType: meta.mediaType } : {}),
426
- createdAtMs: meta.createdAtMs,
427
- },
428
- bytes,
429
- };
430
- }
431
-
432
- /**
433
- * §5.9.2 orphan sweep — the ONLY LISTing operation. Page `ListObjectsV2`
434
- * over `{prefix}blob/{partition}/`, HEAD each object for its `createdAtMs`,
435
- * and DELETE the ones both older than `olderThanMs` AND absent from
436
- * `referencedBlobIds`. Returns the deleted blobIds. Never deletes a
437
- * referenced blob (§5.9.2). Off the hot path — a host-scheduled GC job.
438
- */
439
- async sweepOrphans(
440
- partition: string,
441
- olderThanMs: number,
442
- referencedBlobIds: ReadonlySet<string>,
443
- ): Promise<string[]> {
444
- const listPrefix = `${this.#prefix}blob/${partition}/`;
445
- const swept: string[] = [];
446
- let deletedBytes = 0;
447
- let continuationToken: string | undefined;
448
- do {
449
- const query: Array<readonly [string, string]> = [
450
- ['list-type', '2'],
451
- ['prefix', listPrefix],
452
- ];
453
- if (continuationToken !== undefined) {
454
- query.push(['continuation-token', continuationToken]);
455
- }
456
- // ListObjectsV2 is a bucket-level GET (`GET /{bucket}?list-type=2`).
457
- const response = await this.#request('GET', '', { query });
458
- if (response === undefined) break;
459
- const { objects, nextToken } = parseListObjectsV2(await response.text());
460
- continuationToken = nextToken;
461
- for (const { key } of objects) {
462
- // `{listPrefix}sha256/{hex}` → `sha256:{hex}`.
463
- const rest = key.slice(listPrefix.length);
464
- if (!rest.startsWith('sha256/')) continue;
465
- const blobId = `sha256:${rest.slice('sha256/'.length)}`;
466
- if (!BLOB_ID_PATTERN.test(blobId)) continue;
467
- if (referencedBlobIds.has(blobId)) continue;
468
- const head = await this.#request('HEAD', key);
469
- if (head === undefined) continue;
470
- const rawMeta = head.headers.get(BLOB_META_HEADER);
471
- const lengthHeader = head.headers.get('content-length');
472
- await head.arrayBuffer();
473
- const meta =
474
- rawMeta !== null
475
- ? parseMetaJson(base64urlToUtf8(rawMeta), blobId)
476
- : {
477
- byteLength: Number(lengthHeader ?? 0),
478
- createdAtMs: 0,
479
- };
480
- if (meta.createdAtMs >= olderThanMs) continue;
481
- const del = await this.#request('DELETE', key);
482
- await del?.arrayBuffer();
483
- swept.push(blobId);
484
- deletedBytes += meta.byteLength;
485
- }
486
- } while (continuationToken !== undefined);
487
- if (swept.length > 0) {
488
- await this.#bumpStats({ count: -swept.length, bytes: -deletedBytes });
489
- }
490
- return swept;
491
- }
492
-
493
- /**
494
- * Store-wide counters from the pointer-object accumulator (never a LIST on
495
- * the read path). `approximate: true` for the same reasons `S3SegmentStore`
496
- * documents: best-effort ETag-CAS maintenance and sweep deletes that may
497
- * miss the decrement. Exact within a single writer; a health gauge under
498
- * concurrency. Note this is store-WIDE, not partition-scoped, so a shared
499
- * bucket reports the whole bucket — the parameter is accepted for interface
500
- * parity but the accumulator is one object per store.
501
- */
502
- async stats(_partition: string): Promise<BlobStoreStats> {
503
- const { stats } = await this.#readStats();
504
- return { count: stats.count, bytes: stats.bytes, approximate: true };
505
64
  }
506
65
 
507
66
  /**
@@ -524,8 +83,8 @@ export class S3BlobStore implements BlobStore {
524
83
  const ttlSeconds = options?.ttlSeconds ?? 900;
525
84
  const nowMs = options?.nowMs ?? Date.now();
526
85
  const url = await presignUrl({
527
- url: this.#urlFor(this.objectKeyFor(partition, blobId)),
528
- region: this.#config.region,
86
+ url: this.#bucket.urlFor(this.objectKeyFor(partition, blobId)),
87
+ region: this.#region,
529
88
  credentials: this.#credentials,
530
89
  nowMs,
531
90
  expiresSeconds: ttlSeconds,
@@ -560,8 +119,8 @@ export class S3BlobStore implements BlobStore {
560
119
  const nowMs = options?.nowMs ?? Date.now();
561
120
  const url = await presignUrl({
562
121
  method: 'PUT',
563
- url: this.#urlFor(this.objectKeyFor(partition, blobId)),
564
- region: this.#config.region,
122
+ url: this.#bucket.urlFor(this.objectKeyFor(partition, blobId)),
123
+ region: this.#region,
565
124
  credentials: this.#credentials,
566
125
  nowMs,
567
126
  expiresSeconds: ttlSeconds,