@syncular/server 0.32.1 → 0.33.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/realtime.js CHANGED
@@ -510,6 +510,15 @@ export class RealtimeSession {
510
510
  * deltas immediately; it also survives a newer round-end refresh that
511
511
  * failed, because that round applied nothing.
512
512
  */
513
+ /**
514
+ * A pull outside this connection changed the client's subscriptions. A
515
+ * round in flight on the connection reloads them at its end anyway.
516
+ */
517
+ async refreshAfterPull() {
518
+ if (this.#activeRound !== undefined)
519
+ return;
520
+ await this.refreshRegistrations(false);
521
+ }
513
522
  async refreshRegistrations(failClosed) {
514
523
  const generation = ++this.#registrationGeneration;
515
524
  let registrations;
@@ -845,67 +854,13 @@ export class RealtimeHub {
845
854
  // segment store the HTTP binding uses — never a degraded round.
846
855
  throw syncError('sync.invalid_request', 'socket sync rounds require a segment store on the realtime hub (§8.7)');
847
856
  }
857
+ // The whole config, so a new `SyncServerConfig` field reaches socket and
858
+ // hub-served HTTP rounds without being listed here.
848
859
  return {
860
+ ...this.#config,
861
+ segments,
849
862
  partition: identity.partition,
850
863
  actorId: identity.actorId,
851
- schema: this.#config.schema,
852
- ...(this.#config.schemaWindow !== undefined
853
- ? { schemaWindow: this.#config.schemaWindow }
854
- : {}),
855
- storage: this.#config.storage,
856
- segments,
857
- resolveScopes: this.#config.resolveScopes,
858
- ...(this.#config.blobs !== undefined
859
- ? { blobs: this.#config.blobs }
860
- : {}),
861
- ...(this.#config.maxBlobBytes !== undefined
862
- ? { maxBlobBytes: this.#config.maxBlobBytes }
863
- : {}),
864
- ...(this.#config.crdtMergers !== undefined
865
- ? { crdtMergers: this.#config.crdtMergers }
866
- : {}),
867
- ...(this.#config.validators !== undefined
868
- ? { validators: this.#config.validators }
869
- : {}),
870
- ...(this.#config.commitValidator !== undefined
871
- ? { commitValidator: this.#config.commitValidator }
872
- : {}),
873
- ...(this.#config.reactionPlanner !== undefined
874
- ? { reactionPlanner: this.#config.reactionPlanner }
875
- : {}),
876
- ...(this.#config.checkpoints !== undefined
877
- ? { checkpoints: this.#config.checkpoints }
878
- : {}),
879
- ...(this.#config.clock !== undefined
880
- ? { clock: this.#config.clock }
881
- : {}),
882
- ...(this.#config.limits !== undefined
883
- ? { limits: this.#config.limits }
884
- : {}),
885
- ...(this.#config.signedUrls !== undefined
886
- ? { signedUrls: this.#config.signedUrls }
887
- : {}),
888
- ...(this.#config.blobSignedUrls !== undefined
889
- ? { blobSignedUrls: this.#config.blobSignedUrls }
890
- : {}),
891
- ...(this.#config.blobUploadUrls !== undefined
892
- ? { blobUploadUrls: this.#config.blobUploadUrls }
893
- : {}),
894
- ...(this.#config.sqliteImageBuilder !== undefined
895
- ? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
896
- : {}),
897
- ...(this.#config.leases !== undefined
898
- ? { leases: this.#config.leases }
899
- : {}),
900
- ...(this.#config.events !== undefined
901
- ? { events: this.#config.events }
902
- : {}),
903
- ...(this.#config.onError !== undefined
904
- ? { onError: this.#config.onError }
905
- : {}),
906
- ...(this.#config.mapError !== undefined
907
- ? { mapError: this.#config.mapError }
908
- : {}),
909
864
  realtime: this,
910
865
  };
911
866
  }
@@ -1012,6 +967,18 @@ export class RealtimeHub {
1012
967
  session.deliverCommit(commit);
1013
968
  }
1014
969
  }
970
+ /**
971
+ * RealtimeNotifier: a pull changed a client's subscription list (§8.7).
972
+ * Its sockets reload their registrations with the round-end semantics, so
973
+ * an unreadable record keeps the previous registrations.
974
+ */
975
+ async notifySubscriptions(partition, clientId) {
976
+ for (const session of [...this.#sessions]) {
977
+ if (session.partition !== partition || session.clientId !== clientId)
978
+ continue;
979
+ await session.refreshAfterPull();
980
+ }
981
+ }
1015
982
  /**
1016
983
  * Host-initiated, fail-closed registration refresh (§8.7). A host calls this
1017
984
  * after it changes an actor's membership or connection state, so the
@@ -1,37 +1,15 @@
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.js';
12
+ import { BucketBlobStore } from './bucket-blob-store.js';
35
13
  import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlIssue } from './signed-url.js';
36
14
  export interface S3BlobStoreConfig {
37
15
  /**
@@ -52,42 +30,9 @@ export interface S3BlobStoreConfig {
52
30
  /** Key namespace inside the bucket, e.g. `syncular/`. Default: none. */
53
31
  readonly keyPrefix?: string;
54
32
  }
55
- export declare class S3BlobStore implements BlobStore {
33
+ export declare class S3BlobStore extends BucketBlobStore {
56
34
  #private;
57
35
  constructor(config: S3BlobStoreConfig);
58
- /**
59
- * `{keyPrefix}blob/{partition}/sha256/{hex}` — the §5.9.5 "signed key embeds
60
- * the blobId". Blobs are content-addressed, but download authorization and
61
- * the reference index are per-partition, so the same bytes uploaded under
62
- * two partitions are two objects (a partition MUST NOT read another's
63
- * attachment by guessing a content address). The partition is folded into
64
- * the key as a path segment.
65
- */
66
- objectKeyFor(partition: string, blobId: string): string;
67
- put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise<BlobRecord>;
68
- has(partition: string, blobId: string): Promise<boolean>;
69
- get(partition: string, blobId: string): Promise<{
70
- record: BlobRecord;
71
- bytes: Uint8Array;
72
- } | undefined>;
73
- /**
74
- * §5.9.2 orphan sweep — the ONLY LISTing operation. Page `ListObjectsV2`
75
- * over `{prefix}blob/{partition}/`, HEAD each object for its `createdAtMs`,
76
- * and DELETE the ones both older than `olderThanMs` AND absent from
77
- * `referencedBlobIds`. Returns the deleted blobIds. Never deletes a
78
- * referenced blob (§5.9.2). Off the hot path — a host-scheduled GC job.
79
- */
80
- sweepOrphans(partition: string, olderThanMs: number, referencedBlobIds: ReadonlySet<string>): Promise<string[]>;
81
- /**
82
- * Store-wide counters from the pointer-object accumulator (never a LIST on
83
- * the read path). `approximate: true` for the same reasons `S3SegmentStore`
84
- * documents: best-effort ETag-CAS maintenance and sweep deletes that may
85
- * miss the decrement. Exact within a single writer; a health gauge under
86
- * concurrency. Note this is store-WIDE, not partition-scoped, so a shared
87
- * bucket reports the whole bucket — the parameter is accepted for interface
88
- * parity but the accumulator is one object per store.
89
- */
90
- stats(_partition: string): Promise<BlobStoreStats>;
91
36
  /**
92
37
  * SigV4 presigned GET for a blob object (§5.9.5 delegated presign). The
93
38
  * signed key embeds the `blobId`; `urlExpiresAtMs` is the provider-enforced
@@ -1,104 +1,28 @@
1
- import { EMPTY_PAYLOAD_SHA256, presignUrl, sha256Hex, signRequest, } from './sigv4.js';
2
- const BLOB_ID_PATTERN = /^sha256:[0-9a-f]{64}$/;
3
- const BLOB_META_HEADER = 'x-amz-meta-syncular-blob';
4
- /**
5
- * Stats accumulator (mirrors `S3SegmentStore`): a small counter object under
6
- * a fixed key, updated read-modify-write on `put` under an ETag
7
- * compare-and-swap. A LIST-free point-lookup store cannot count objects on
8
- * demand cheaply, so `stats()` reads the accumulator. The counters are
9
- * APPROXIMATE — a crash between the blob PUT and the accumulator CAS, or a
10
- * sweep DELETE the accumulator does not decrement, drifts them — so the
11
- * admin surface labels S3 blob stats `approximate: true`. A health gauge,
12
- * not an invoice.
13
- */
14
- const STATS_KEY_SUFFIX = 'stats/blobs.json';
15
- const STATS_CAS_ATTEMPTS = 16;
16
- function emptyStats() {
17
- return { count: 0, bytes: 0 };
18
- }
19
- function parseStatsJson(json) {
20
- const parsed = JSON.parse(json);
21
- return {
22
- count: Number(parsed.count ?? 0),
23
- bytes: Number(parsed.bytes ?? 0),
24
- };
25
- }
26
- // Runtime-neutral base64url: `Buffer` is not present on
27
- // Cloudflare Workers without `nodejs_compat`, so the metadata header is
28
- // (de)coded with `btoa`/`atob`, available in every runtime.
29
- function utf8ToBase64url(text) {
30
- let binary = '';
31
- for (const byte of new TextEncoder().encode(text)) {
32
- binary += String.fromCharCode(byte);
33
- }
34
- return btoa(binary)
35
- .replaceAll('+', '-')
36
- .replaceAll('/', '_')
37
- .replace(/=+$/, '');
38
- }
39
- function base64urlToUtf8(value) {
40
- const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
41
- const bytes = new Uint8Array(binary.length);
42
- for (let i = 0; i < binary.length; i++)
43
- bytes[i] = binary.charCodeAt(i);
44
- return new TextDecoder().decode(bytes);
45
- }
46
- function metaToJson(meta) {
47
- return JSON.stringify(meta);
48
- }
49
- function parseMetaJson(json, source) {
50
- let parsed;
51
- try {
52
- parsed = JSON.parse(json);
53
- }
54
- catch {
55
- throw new Error(`S3BlobStore: corrupt blob metadata in ${source}`);
56
- }
57
- if (typeof parsed !== 'object' || parsed === null) {
58
- throw new Error(`S3BlobStore: corrupt blob metadata in ${source}`);
59
- }
60
- const r = parsed;
61
- if (typeof r.byteLength !== 'number' || typeof r.createdAtMs !== 'number') {
62
- throw new Error(`S3BlobStore: bad blob metadata in ${source}`);
63
- }
64
- return {
65
- byteLength: r.byteLength,
66
- ...(typeof r.mediaType === 'string' ? { mediaType: r.mediaType } : {}),
67
- createdAtMs: r.createdAtMs,
68
- };
69
- }
70
1
  /**
71
- * Extract `<Key>` values (and the `<NextContinuationToken>`, if the listing
72
- * is truncated) from a `ListObjectsV2` XML body. A tiny regex reader — the
73
- * only XML the store ever parses, and only on the GC path. `ListObjectsV2`
74
- * returns keys XML-escaped for `& < >`; we unescape those three.
2
+ * S3-compatible blob store (SPEC.md §5.9.2 durable objects, §5.9.5 delegated
3
+ * presign) — AWS S3, Cloudflare R2's S3 endpoint, MinIO. The blob twin of
4
+ * `s3-segment-store.ts`.
5
+ *
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.
75
11
  */
76
- function parseListObjectsV2(xml) {
77
- const objects = [];
78
- for (const m of xml.matchAll(/<Key>([\s\S]*?)<\/Key>/g)) {
79
- objects.push({ key: unescapeXml(m[1] ?? '') });
80
- }
81
- const truncated = /<IsTruncated>\s*true\s*<\/IsTruncated>/i.test(xml);
82
- const tokenMatch = /<NextContinuationToken>([\s\S]*?)<\/NextContinuationToken>/.exec(xml);
83
- const nextToken = truncated && tokenMatch !== null
84
- ? unescapeXml(tokenMatch[1] ?? '')
85
- : undefined;
86
- return { objects, nextToken };
87
- }
88
- function unescapeXml(value) {
89
- return value
90
- .replaceAll('&lt;', '<')
91
- .replaceAll('&gt;', '>')
92
- .replaceAll('&quot;', '"')
93
- .replaceAll('&apos;', "'")
94
- .replaceAll('&amp;', '&');
95
- }
96
- export class S3BlobStore {
97
- #config;
12
+ import { BucketBlobStore, } from './bucket-blob-store.js';
13
+ import { S3ObjectBucket } from './s3-object-bucket.js';
14
+ import { presignUrl } from './sigv4.js';
15
+ const BLOB_ID_PATTERN = /^sha256:[0-9a-f]{64}$/;
16
+ export class S3BlobStore extends BucketBlobStore {
17
+ #bucket;
18
+ #region;
98
19
  #credentials;
99
- #prefix;
100
20
  constructor(config) {
101
- this.#config = config;
21
+ const options = { keyPrefix: config.keyPrefix };
22
+ const bucket = new S3ObjectBucket(config);
23
+ super(bucket, options);
24
+ this.#bucket = bucket;
25
+ this.#region = config.region;
102
26
  this.#credentials = {
103
27
  accessKeyId: config.accessKeyId,
104
28
  secretAccessKey: config.secretAccessKey,
@@ -106,264 +30,6 @@ export class S3BlobStore {
106
30
  ? { sessionToken: config.sessionToken }
107
31
  : {}),
108
32
  };
109
- this.#prefix = config.keyPrefix ?? '';
110
- }
111
- /**
112
- * `{keyPrefix}blob/{partition}/sha256/{hex}` — the §5.9.5 "signed key embeds
113
- * the blobId". Blobs are content-addressed, but download authorization and
114
- * the reference index are per-partition, so the same bytes uploaded under
115
- * two partitions are two objects (a partition MUST NOT read another's
116
- * attachment by guessing a content address). The partition is folded into
117
- * the key as a path segment.
118
- */
119
- objectKeyFor(partition, blobId) {
120
- return `${this.#prefix}blob/${partition}/${blobId.replace(':', '/')}`;
121
- }
122
- #statsKey() {
123
- return `${this.#prefix}${STATS_KEY_SUFFIX}`;
124
- }
125
- #urlFor(key) {
126
- const endpoint = this.#config.endpoint.replace(/\/+$/, '');
127
- return new URL(`${endpoint}/${this.#config.bucket}/${key}`);
128
- }
129
- async #request(method, key, options) {
130
- const url = this.#urlFor(key);
131
- if (options?.query !== undefined) {
132
- for (const [name, value] of options.query) {
133
- url.searchParams.set(name, value);
134
- }
135
- }
136
- const body = options?.body;
137
- const headers = await signRequest({
138
- method,
139
- url,
140
- region: this.#config.region,
141
- credentials: this.#credentials,
142
- nowMs: Date.now(),
143
- payloadHash: body === undefined ? EMPTY_PAYLOAD_SHA256 : await sha256Hex(body),
144
- ...(options?.headers !== undefined ? { headers: options.headers } : {}),
145
- });
146
- const response = await fetch(url, {
147
- method,
148
- headers,
149
- ...(body !== undefined ? { body: body } : {}),
150
- });
151
- if (response.status === 404) {
152
- await response.arrayBuffer();
153
- return undefined;
154
- }
155
- if (!response.ok) {
156
- const detail = (await response.text()).slice(0, 300);
157
- throw new Error(`S3BlobStore: ${method} ${key} failed with ${response.status}: ${detail}`);
158
- }
159
- return response;
160
- }
161
- async #readStats() {
162
- const response = await this.#request('GET', this.#statsKey());
163
- if (response === undefined) {
164
- return { stats: emptyStats(), etag: undefined };
165
- }
166
- const etag = response.headers.get('etag') ?? undefined;
167
- const stats = parseStatsJson(await response.text());
168
- return { stats, etag };
169
- }
170
- async #writeStatsCas(stats, etag) {
171
- const url = this.#urlFor(this.#statsKey());
172
- const body = new TextEncoder().encode(JSON.stringify(stats));
173
- const conditional = etag === undefined ? { 'if-none-match': '*' } : { 'if-match': etag };
174
- const headers = await signRequest({
175
- method: 'PUT',
176
- url,
177
- region: this.#config.region,
178
- credentials: this.#credentials,
179
- nowMs: Date.now(),
180
- payloadHash: await sha256Hex(body),
181
- headers: { 'content-type': 'application/json', ...conditional },
182
- });
183
- const response = await fetch(url, {
184
- method: 'PUT',
185
- headers,
186
- body: body,
187
- });
188
- if (response.status === 412) {
189
- await response.arrayBuffer();
190
- return false;
191
- }
192
- if (!response.ok) {
193
- const detail = (await response.text()).slice(0, 300);
194
- throw new Error(`S3BlobStore: stats PUT failed with ${response.status}: ${detail}`);
195
- }
196
- await response.arrayBuffer();
197
- return true;
198
- }
199
- /**
200
- * Fold `delta` into the stats accumulator under an ETag CAS retry loop.
201
- * Best-effort: exhausting the budget leaves the counters drifted (they are
202
- * documented APPROXIMATE) rather than failing an op that already touched
203
- * the bytes.
204
- */
205
- async #bumpStats(delta) {
206
- for (let attempt = 0; attempt < STATS_CAS_ATTEMPTS; attempt++) {
207
- const { stats, etag } = await this.#readStats();
208
- const next = {
209
- count: stats.count + delta.count,
210
- bytes: stats.bytes + delta.bytes,
211
- };
212
- if (await this.#writeStatsCas(next, etag))
213
- return;
214
- }
215
- // Lost the CAS race `STATS_CAS_ATTEMPTS` times — leave counters as-is;
216
- // `stats()` stays APPROXIMATE by contract.
217
- }
218
- async put(partition, blobId, bytes, nowMs, mediaType) {
219
- const record = {
220
- blobId,
221
- partition,
222
- byteLength: bytes.length,
223
- ...(mediaType !== undefined ? { mediaType } : {}),
224
- createdAtMs: nowMs,
225
- };
226
- const objectKey = this.objectKeyFor(partition, blobId);
227
- // Idempotent re-put (same content-address ⇒ same key): count each distinct
228
- // blob once, and preserve the FIRST createdAtMs so the sweep's grace clock
229
- // is not reset by a re-upload (§5.9.2 upload age).
230
- const preexisting = await this.#request('HEAD', objectKey);
231
- if (preexisting !== undefined) {
232
- const meta = preexisting.headers.get(BLOB_META_HEADER);
233
- await preexisting.arrayBuffer();
234
- if (meta !== null) {
235
- const existing = parseMetaJson(base64urlToUtf8(meta), blobId);
236
- return {
237
- blobId,
238
- partition,
239
- byteLength: existing.byteLength,
240
- ...(existing.mediaType !== undefined
241
- ? { mediaType: existing.mediaType }
242
- : {}),
243
- createdAtMs: existing.createdAtMs,
244
- };
245
- }
246
- }
247
- const meta = {
248
- byteLength: bytes.length,
249
- ...(mediaType !== undefined ? { mediaType } : {}),
250
- createdAtMs: nowMs,
251
- };
252
- const response = await this.#request('PUT', objectKey, {
253
- body: bytes,
254
- headers: {
255
- 'content-type': mediaType ?? 'application/octet-stream',
256
- [BLOB_META_HEADER]: utf8ToBase64url(metaToJson(meta)),
257
- },
258
- });
259
- await response?.arrayBuffer();
260
- await this.#bumpStats({ count: 1, bytes: bytes.length });
261
- return record;
262
- }
263
- async has(partition, blobId) {
264
- if (!BLOB_ID_PATTERN.test(blobId))
265
- return false;
266
- const response = await this.#request('HEAD', this.objectKeyFor(partition, blobId));
267
- if (response === undefined)
268
- return false;
269
- await response.arrayBuffer();
270
- return true;
271
- }
272
- async get(partition, blobId) {
273
- if (!BLOB_ID_PATTERN.test(blobId))
274
- return undefined;
275
- const response = await this.#request('GET', this.objectKeyFor(partition, blobId));
276
- if (response === undefined)
277
- return undefined;
278
- const bytes = new Uint8Array(await response.arrayBuffer());
279
- const rawMeta = response.headers.get(BLOB_META_HEADER);
280
- const meta = rawMeta !== null
281
- ? parseMetaJson(base64urlToUtf8(rawMeta), blobId)
282
- : { byteLength: bytes.length, createdAtMs: 0 };
283
- return {
284
- record: {
285
- blobId,
286
- partition,
287
- byteLength: meta.byteLength,
288
- ...(meta.mediaType !== undefined ? { mediaType: meta.mediaType } : {}),
289
- createdAtMs: meta.createdAtMs,
290
- },
291
- bytes,
292
- };
293
- }
294
- /**
295
- * §5.9.2 orphan sweep — the ONLY LISTing operation. Page `ListObjectsV2`
296
- * over `{prefix}blob/{partition}/`, HEAD each object for its `createdAtMs`,
297
- * and DELETE the ones both older than `olderThanMs` AND absent from
298
- * `referencedBlobIds`. Returns the deleted blobIds. Never deletes a
299
- * referenced blob (§5.9.2). Off the hot path — a host-scheduled GC job.
300
- */
301
- async sweepOrphans(partition, olderThanMs, referencedBlobIds) {
302
- const listPrefix = `${this.#prefix}blob/${partition}/`;
303
- const swept = [];
304
- let deletedBytes = 0;
305
- let continuationToken;
306
- do {
307
- const query = [
308
- ['list-type', '2'],
309
- ['prefix', listPrefix],
310
- ];
311
- if (continuationToken !== undefined) {
312
- query.push(['continuation-token', continuationToken]);
313
- }
314
- // ListObjectsV2 is a bucket-level GET (`GET /{bucket}?list-type=2`).
315
- const response = await this.#request('GET', '', { query });
316
- if (response === undefined)
317
- break;
318
- const { objects, nextToken } = parseListObjectsV2(await response.text());
319
- continuationToken = nextToken;
320
- for (const { key } of objects) {
321
- // `{listPrefix}sha256/{hex}` → `sha256:{hex}`.
322
- const rest = key.slice(listPrefix.length);
323
- if (!rest.startsWith('sha256/'))
324
- continue;
325
- const blobId = `sha256:${rest.slice('sha256/'.length)}`;
326
- if (!BLOB_ID_PATTERN.test(blobId))
327
- continue;
328
- if (referencedBlobIds.has(blobId))
329
- continue;
330
- const head = await this.#request('HEAD', key);
331
- if (head === undefined)
332
- continue;
333
- const rawMeta = head.headers.get(BLOB_META_HEADER);
334
- const lengthHeader = head.headers.get('content-length');
335
- await head.arrayBuffer();
336
- const meta = rawMeta !== null
337
- ? parseMetaJson(base64urlToUtf8(rawMeta), blobId)
338
- : {
339
- byteLength: Number(lengthHeader ?? 0),
340
- createdAtMs: 0,
341
- };
342
- if (meta.createdAtMs >= olderThanMs)
343
- continue;
344
- const del = await this.#request('DELETE', key);
345
- await del?.arrayBuffer();
346
- swept.push(blobId);
347
- deletedBytes += meta.byteLength;
348
- }
349
- } while (continuationToken !== undefined);
350
- if (swept.length > 0) {
351
- await this.#bumpStats({ count: -swept.length, bytes: -deletedBytes });
352
- }
353
- return swept;
354
- }
355
- /**
356
- * Store-wide counters from the pointer-object accumulator (never a LIST on
357
- * the read path). `approximate: true` for the same reasons `S3SegmentStore`
358
- * documents: best-effort ETag-CAS maintenance and sweep deletes that may
359
- * miss the decrement. Exact within a single writer; a health gauge under
360
- * concurrency. Note this is store-WIDE, not partition-scoped, so a shared
361
- * bucket reports the whole bucket — the parameter is accepted for interface
362
- * parity but the accumulator is one object per store.
363
- */
364
- async stats(_partition) {
365
- const { stats } = await this.#readStats();
366
- return { count: stats.count, bytes: stats.bytes, approximate: true };
367
33
  }
368
34
  /**
369
35
  * SigV4 presigned GET for a blob object (§5.9.5 delegated presign). The
@@ -381,8 +47,8 @@ export class S3BlobStore {
381
47
  const ttlSeconds = options?.ttlSeconds ?? 900;
382
48
  const nowMs = options?.nowMs ?? Date.now();
383
49
  const url = await presignUrl({
384
- url: this.#urlFor(this.objectKeyFor(partition, blobId)),
385
- region: this.#config.region,
50
+ url: this.#bucket.urlFor(this.objectKeyFor(partition, blobId)),
51
+ region: this.#region,
386
52
  credentials: this.#credentials,
387
53
  nowMs,
388
54
  expiresSeconds: ttlSeconds,
@@ -412,8 +78,8 @@ export class S3BlobStore {
412
78
  const nowMs = options?.nowMs ?? Date.now();
413
79
  const url = await presignUrl({
414
80
  method: 'PUT',
415
- url: this.#urlFor(this.objectKeyFor(partition, blobId)),
416
- region: this.#config.region,
81
+ url: this.#bucket.urlFor(this.objectKeyFor(partition, blobId)),
82
+ region: this.#region,
417
83
  credentials: this.#credentials,
418
84
  nowMs,
419
85
  expiresSeconds: ttlSeconds,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `ObjectBucket` over the S3 REST API: hand-rolled SigV4 over `fetch`
3
+ * (sigv4.ts), zero dependencies, path-style requests
4
+ * (`{endpoint}/{bucket}/{key}`) that AWS S3, Cloudflare R2's S3 endpoint, and
5
+ * MinIO all accept.
6
+ *
7
+ * This module owns the transport only. The segment and blob store logic lives
8
+ * in `bucket-segment-store.ts` / `bucket-blob-store.ts` and runs over any
9
+ * `ObjectBucket`, so the same objects are reachable through an R2 binding
10
+ * (`R2SegmentStore` in `@syncular/server-workers`) with no copy between them.
11
+ */
12
+ import type { BucketListPage, BucketObject, BucketObjectHead, BucketWriteOptions, ObjectBucket } from './object-bucket.js';
13
+ export interface S3ObjectBucketConfig {
14
+ /**
15
+ * Endpoint origin, no bucket, no trailing slash:
16
+ * AWS `https://s3.<region>.amazonaws.com`,
17
+ * R2 `https://<account-id>.r2.cloudflarestorage.com`,
18
+ * MinIO `http://127.0.0.1:9000`.
19
+ */
20
+ readonly endpoint: string;
21
+ /** AWS region; R2 uses `auto`. */
22
+ readonly region: string;
23
+ readonly bucket: string;
24
+ readonly accessKeyId: string;
25
+ readonly secretAccessKey: string;
26
+ /** Optional STS session token. */
27
+ readonly sessionToken?: string;
28
+ }
29
+ export declare class S3ObjectBucket implements ObjectBucket {
30
+ #private;
31
+ constructor(config: S3ObjectBucketConfig);
32
+ /** The object URL the presign helpers sign (`{endpoint}/{bucket}/{key}`). */
33
+ urlFor(key: string): URL;
34
+ get(key: string): Promise<BucketObject | undefined>;
35
+ head(key: string): Promise<BucketObjectHead | undefined>;
36
+ put(key: string, options: BucketWriteOptions): Promise<boolean>;
37
+ delete(key: string): Promise<void>;
38
+ list(prefix: string, cursor?: string): Promise<BucketListPage>;
39
+ }