@syncular/server 0.32.0 → 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.
@@ -180,8 +180,7 @@ async function downloadBlob(
180
180
  // egress path — it only needs to know the object EXISTS (a HEAD/`has`), not
181
181
  // the bytes. Inline delivery loads the bytes; presigned delivery does not.
182
182
  const presignConfigured = ctx.blobSignedUrls !== undefined;
183
- let entry: { record: { mediaType?: string }; bytes: Uint8Array } | undefined;
184
- let mediaType: string | undefined;
183
+ let entry: { bytes: Uint8Array } | undefined;
185
184
  if (presignConfigured) {
186
185
  if (!(await store.has(ctx.partition, blobId))) {
187
186
  throw syncError('blob.not_found', 'unknown blob (§5.9.5)');
@@ -191,7 +190,6 @@ async function downloadBlob(
191
190
  if (entry === undefined) {
192
191
  throw syncError('blob.not_found', 'unknown blob (§5.9.5)');
193
192
  }
194
- mediaType = entry.record.mediaType;
195
193
  }
196
194
 
197
195
  // §5.9.5 authorization: resolve once, then test referencing rows.
@@ -279,10 +277,12 @@ async function downloadBlob(
279
277
  // Unreachable: presign not configured ⇒ `entry` was loaded above.
280
278
  throw syncError('blob.not_found', 'blob bytes unavailable (§5.9.5)');
281
279
  }
280
+ // §5.9.5: inline bytes are always octet-stream. The stored media type
281
+ // would make a JSON blob indistinguishable from the signed-URL envelope.
282
282
  return {
283
283
  bytes,
284
284
  headers: {
285
- 'Content-Type': mediaType ?? 'application/octet-stream',
285
+ 'Content-Type': 'application/octet-stream',
286
286
  ETag: `"${blobId}"`,
287
287
  'Cache-Control': 'private, max-age=0',
288
288
  Vary: 'Authorization, Accept-Encoding',
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Content-addressed blob store over the `ObjectBucket` seam (§5.9.2 durable
3
+ * objects) — the store logic shared by `S3BlobStore` (S3 REST + SigV4) and
4
+ * `R2BlobStore` (`@syncular/server-workers`, the R2 binding). The blob twin of
5
+ * `bucket-segment-store.ts`: same content-addressed key layout, same
6
+ * LIST-free-on-the-hot-path CAS stats accumulator.
7
+ *
8
+ * Key layout (deterministic — every point lookup is a GET/HEAD, never a LIST):
9
+ *
10
+ * {keyPrefix}blob/{partition}/sha256/{hex}
11
+ * The blob bytes, VERBATIM (the object body MUST be exactly the
12
+ * content-addressed bytes so a presigned GET or the inline download route
13
+ * serves them directly and the client's §5.9.1 hash check passes — blobs
14
+ * are never re-compressed at rest, §5.8/§5.9.5). The `byteLength` +
15
+ * optional `mediaType` ride along as user metadata `syncular-blob` =
16
+ * base64url(JSON), so `get` is a single GET. `createdAtMs` is stored there
17
+ * too so the orphan sweep (§5.9.2) can read the upload age without a
18
+ * separate index.
19
+ *
20
+ * DURABILITY vs SEGMENT TTL — the honest interface difference (§5.9.2): a
21
+ * blob referenced by a live row must stay downloadable INDEFINITELY. So,
22
+ * unlike the segment store, this store writes **no `expiresAtMs`, no bucket
23
+ * lifecycle-expiration mapping, and no TTL config**. Expiry is
24
+ * REFERENCE-driven, not time-driven: reclamation is the host-scheduled
25
+ * `sweepOrphanBlobs` (blob-store.ts) deleting only objects no live row
26
+ * references after a grace period — never a bucket lifecycle rule. Do NOT
27
+ * put a lifecycle expiration on the `blob/` prefix; it would delete
28
+ * still-referenced attachments.
29
+ *
30
+ * The orphan sweep is the ONLY operation that LISTs: `sweepOrphans` pages a
31
+ * prefix listing over the `blob/` prefix (an admin/GC path, off the hot path)
32
+ * to find candidates, reads each object's `createdAtMs` from its metadata for
33
+ * the grace check, and DELETEs the unreferenced-and-old ones.
34
+ */
35
+ import type { BlobRecord, BlobStore, BlobStoreStats } from './blob-store';
36
+ import type { ObjectBucket } from './object-bucket';
37
+
38
+ export interface BucketBlobStoreOptions {
39
+ /** Key namespace inside the bucket, e.g. `syncular/`. Default: none. */
40
+ readonly keyPrefix?: string | undefined;
41
+ }
42
+
43
+ const BLOB_ID_PATTERN = /^sha256:[0-9a-f]{64}$/;
44
+ const BLOB_META_KEY = 'syncular-blob';
45
+
46
+ /**
47
+ * Stats accumulator (mirrors the segment store): a small counter object under
48
+ * a fixed key, updated read-modify-write on `put` under an ETag
49
+ * compare-and-swap. A LIST-free point-lookup store cannot count objects on
50
+ * demand cheaply, so `stats()` reads the accumulator. The counters are
51
+ * APPROXIMATE — a crash between the blob PUT and the accumulator CAS, or a
52
+ * sweep DELETE the accumulator does not decrement, drifts them — so the
53
+ * admin surface labels these stats `approximate: true`. A health gauge,
54
+ * not an invoice.
55
+ */
56
+ const STATS_KEY_SUFFIX = 'stats/blobs.json';
57
+ const STATS_CAS_ATTEMPTS = 16;
58
+
59
+ interface StatsAccumulator {
60
+ count: number;
61
+ bytes: number;
62
+ }
63
+
64
+ function emptyStats(): StatsAccumulator {
65
+ return { count: 0, bytes: 0 };
66
+ }
67
+
68
+ function parseStatsJson(json: string): StatsAccumulator {
69
+ const parsed = JSON.parse(json) as Partial<StatsAccumulator>;
70
+ return {
71
+ count: Number(parsed.count ?? 0),
72
+ bytes: Number(parsed.bytes ?? 0),
73
+ };
74
+ }
75
+
76
+ // Runtime-neutral base64url: `Buffer` is not present on
77
+ // Cloudflare Workers without `nodejs_compat`, so the metadata header is
78
+ // (de)coded with `btoa`/`atob`, available in every runtime.
79
+ function utf8ToBase64url(text: string): string {
80
+ let binary = '';
81
+ for (const byte of new TextEncoder().encode(text)) {
82
+ binary += String.fromCharCode(byte);
83
+ }
84
+ return btoa(binary)
85
+ .replaceAll('+', '-')
86
+ .replaceAll('/', '_')
87
+ .replace(/=+$/, '');
88
+ }
89
+
90
+ function base64urlToUtf8(value: string): string {
91
+ const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
92
+ const bytes = new Uint8Array(binary.length);
93
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
94
+ return new TextDecoder().decode(bytes);
95
+ }
96
+
97
+ interface BlobMeta {
98
+ readonly byteLength: number;
99
+ readonly mediaType?: string;
100
+ readonly createdAtMs: number;
101
+ }
102
+
103
+ function metaToJson(meta: BlobMeta): string {
104
+ return JSON.stringify(meta);
105
+ }
106
+
107
+ function parseMetaJson(json: string, source: string, label: string): BlobMeta {
108
+ let parsed: unknown;
109
+ try {
110
+ parsed = JSON.parse(json);
111
+ } catch {
112
+ throw new Error(`${label}: corrupt blob metadata in ${source}`);
113
+ }
114
+ if (typeof parsed !== 'object' || parsed === null) {
115
+ throw new Error(`${label}: corrupt blob metadata in ${source}`);
116
+ }
117
+ const r = parsed as Record<string, unknown>;
118
+ if (typeof r.byteLength !== 'number' || typeof r.createdAtMs !== 'number') {
119
+ throw new Error(`${label}: bad blob metadata in ${source}`);
120
+ }
121
+ return {
122
+ byteLength: r.byteLength,
123
+ ...(typeof r.mediaType === 'string' ? { mediaType: r.mediaType } : {}),
124
+ createdAtMs: r.createdAtMs,
125
+ };
126
+ }
127
+
128
+ export class BucketBlobStore implements BlobStore {
129
+ readonly #bucket: ObjectBucket;
130
+ readonly #prefix: string;
131
+ /** Error prefix: the concrete store class the host constructed. */
132
+ readonly #label: string;
133
+
134
+ constructor(bucket: ObjectBucket, options?: BucketBlobStoreOptions) {
135
+ this.#bucket = bucket;
136
+ this.#prefix = options?.keyPrefix ?? '';
137
+ this.#label = this.constructor.name;
138
+ }
139
+
140
+ /**
141
+ * `{keyPrefix}blob/{partition}/sha256/{hex}` — the §5.9.5 "signed key embeds
142
+ * the blobId". Blobs are content-addressed, but download authorization and
143
+ * the reference index are per-partition, so the same bytes uploaded under
144
+ * two partitions are two objects (a partition MUST NOT read another's
145
+ * attachment by guessing a content address). The partition is folded into
146
+ * the key as a path segment.
147
+ */
148
+ objectKeyFor(partition: string, blobId: string): string {
149
+ return `${this.#prefix}blob/${partition}/${blobId.replace(':', '/')}`;
150
+ }
151
+
152
+ #statsKey(): string {
153
+ return `${this.#prefix}${STATS_KEY_SUFFIX}`;
154
+ }
155
+
156
+ async #readStats(): Promise<{
157
+ stats: StatsAccumulator;
158
+ etag: string | undefined;
159
+ }> {
160
+ const object = await this.#bucket.get(this.#statsKey());
161
+ if (object === undefined) {
162
+ return { stats: emptyStats(), etag: undefined };
163
+ }
164
+ return { stats: parseStatsJson(await object.text()), etag: object.etag };
165
+ }
166
+
167
+ /**
168
+ * Conditionally write the stats accumulator. `etag` present ⇒ `ifMatch`
169
+ * (update the object we read); absent ⇒ `createOnly` (create only). Returns
170
+ * `false` on a failed precondition so the caller retries.
171
+ */
172
+ async #writeStatsCas(
173
+ stats: StatsAccumulator,
174
+ etag: string | undefined,
175
+ ): Promise<boolean> {
176
+ return this.#bucket.put(this.#statsKey(), {
177
+ body: new TextEncoder().encode(JSON.stringify(stats)),
178
+ contentType: 'application/json',
179
+ ...(etag === undefined ? { createOnly: true } : { ifMatch: etag }),
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Fold `delta` into the stats accumulator under an ETag CAS retry loop.
185
+ * Best-effort: exhausting the budget leaves the counters drifted (they are
186
+ * documented APPROXIMATE) rather than failing an op that already touched
187
+ * the bytes.
188
+ */
189
+ async #bumpStats(delta: StatsAccumulator): Promise<void> {
190
+ for (let attempt = 0; attempt < STATS_CAS_ATTEMPTS; attempt++) {
191
+ const { stats, etag } = await this.#readStats();
192
+ const next: StatsAccumulator = {
193
+ count: stats.count + delta.count,
194
+ bytes: stats.bytes + delta.bytes,
195
+ };
196
+ if (await this.#writeStatsCas(next, etag)) return;
197
+ }
198
+ // Lost the CAS race `STATS_CAS_ATTEMPTS` times — leave counters as-is;
199
+ // `stats()` stays APPROXIMATE by contract.
200
+ }
201
+
202
+ async put(
203
+ partition: string,
204
+ blobId: string,
205
+ bytes: Uint8Array,
206
+ nowMs: number,
207
+ mediaType?: string,
208
+ ): Promise<BlobRecord> {
209
+ const record: BlobRecord = {
210
+ blobId,
211
+ partition,
212
+ byteLength: bytes.length,
213
+ ...(mediaType !== undefined ? { mediaType } : {}),
214
+ createdAtMs: nowMs,
215
+ };
216
+ const objectKey = this.objectKeyFor(partition, blobId);
217
+ // Idempotent re-put (same content-address ⇒ same key): count each distinct
218
+ // blob once, and preserve the FIRST createdAtMs so the sweep's grace clock
219
+ // is not reset by a re-upload (§5.9.2 upload age).
220
+ const preexisting = await this.#bucket.head(objectKey);
221
+ if (preexisting !== undefined) {
222
+ const meta = preexisting.metadata[BLOB_META_KEY];
223
+ if (meta !== undefined) {
224
+ const existing = parseMetaJson(
225
+ base64urlToUtf8(meta),
226
+ blobId,
227
+ this.#label,
228
+ );
229
+ return {
230
+ blobId,
231
+ partition,
232
+ byteLength: existing.byteLength,
233
+ ...(existing.mediaType !== undefined
234
+ ? { mediaType: existing.mediaType }
235
+ : {}),
236
+ createdAtMs: existing.createdAtMs,
237
+ };
238
+ }
239
+ }
240
+ const meta: BlobMeta = {
241
+ byteLength: bytes.length,
242
+ ...(mediaType !== undefined ? { mediaType } : {}),
243
+ createdAtMs: nowMs,
244
+ };
245
+ // Conditional write: of two concurrent uploads of the same blob exactly
246
+ // one lands, so the first createdAtMs survives and the blob counts once.
247
+ // The loser re-reads and returns the winner's record.
248
+ const written = await this.#bucket.put(objectKey, {
249
+ body: bytes,
250
+ contentType: mediaType ?? 'application/octet-stream',
251
+ metadata: { [BLOB_META_KEY]: utf8ToBase64url(metaToJson(meta)) },
252
+ ...(preexisting === undefined
253
+ ? { createOnly: true }
254
+ : { ifMatch: preexisting.etag }),
255
+ });
256
+ if (!written) return this.put(partition, blobId, bytes, nowMs, mediaType);
257
+ if (preexisting === undefined)
258
+ await this.#bumpStats({ count: 1, bytes: bytes.length });
259
+ return record;
260
+ }
261
+
262
+ async has(partition: string, blobId: string): Promise<boolean> {
263
+ if (!BLOB_ID_PATTERN.test(blobId)) return false;
264
+ const head = await this.#bucket.head(this.objectKeyFor(partition, blobId));
265
+ return head !== undefined;
266
+ }
267
+
268
+ async get(
269
+ partition: string,
270
+ blobId: string,
271
+ ): Promise<{ record: BlobRecord; bytes: Uint8Array } | undefined> {
272
+ if (!BLOB_ID_PATTERN.test(blobId)) return undefined;
273
+ const object = await this.#bucket.get(this.objectKeyFor(partition, blobId));
274
+ if (object === undefined) return undefined;
275
+ const bytes = new Uint8Array(await object.arrayBuffer());
276
+ const rawMeta = object.metadata[BLOB_META_KEY];
277
+ const meta =
278
+ rawMeta !== undefined
279
+ ? parseMetaJson(base64urlToUtf8(rawMeta), blobId, this.#label)
280
+ : { byteLength: bytes.length, createdAtMs: 0 };
281
+ return {
282
+ record: {
283
+ blobId,
284
+ partition,
285
+ byteLength: meta.byteLength,
286
+ ...(meta.mediaType !== undefined ? { mediaType: meta.mediaType } : {}),
287
+ createdAtMs: meta.createdAtMs,
288
+ },
289
+ bytes,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * §5.9.2 orphan sweep — the ONLY LISTing operation. Page the `{prefix}blob/
295
+ * {partition}/` prefix, HEAD each object for its `createdAtMs`, and DELETE
296
+ * the ones both older than `olderThanMs` AND absent from
297
+ * `referencedBlobIds`. Returns the deleted blobIds. Never deletes a
298
+ * referenced blob (§5.9.2). Off the hot path — a host-scheduled GC job.
299
+ */
300
+ async sweepOrphans(
301
+ partition: string,
302
+ olderThanMs: number,
303
+ referencedBlobIds: ReadonlySet<string>,
304
+ ): Promise<string[]> {
305
+ const listPrefix = `${this.#prefix}blob/${partition}/`;
306
+ const swept: string[] = [];
307
+ let deletedBytes = 0;
308
+ let cursor: string | undefined;
309
+ do {
310
+ const page = await this.#bucket.list(listPrefix, cursor);
311
+ cursor = page.cursor;
312
+ for (const key of page.keys) {
313
+ // `{listPrefix}sha256/{hex}` → `sha256:{hex}`.
314
+ const rest = key.slice(listPrefix.length);
315
+ if (!rest.startsWith('sha256/')) continue;
316
+ const blobId = `sha256:${rest.slice('sha256/'.length)}`;
317
+ if (!BLOB_ID_PATTERN.test(blobId)) continue;
318
+ if (referencedBlobIds.has(blobId)) continue;
319
+ const head = await this.#bucket.head(key);
320
+ if (head === undefined) continue;
321
+ const rawMeta = head.metadata[BLOB_META_KEY];
322
+ const meta =
323
+ rawMeta !== undefined
324
+ ? parseMetaJson(base64urlToUtf8(rawMeta), blobId, this.#label)
325
+ : { byteLength: head.size ?? 0, createdAtMs: 0 };
326
+ if (meta.createdAtMs >= olderThanMs) continue;
327
+ await this.#bucket.delete(key);
328
+ swept.push(blobId);
329
+ deletedBytes += meta.byteLength;
330
+ }
331
+ } while (cursor !== undefined);
332
+ if (swept.length > 0) {
333
+ await this.#bumpStats({ count: -swept.length, bytes: -deletedBytes });
334
+ }
335
+ return swept;
336
+ }
337
+
338
+ /**
339
+ * Store-wide counters from the pointer-object accumulator (never a LIST on
340
+ * the read path). `approximate: true` for the same reasons the segment
341
+ * store documents: best-effort ETag-CAS maintenance and sweep deletes that
342
+ * may miss the decrement. Exact within a single writer; a health gauge
343
+ * under concurrency. Note this is store-WIDE, not partition-scoped, so a
344
+ * shared bucket reports the whole bucket — the parameter is accepted for
345
+ * interface parity but the accumulator is one object per store.
346
+ */
347
+ async stats(_partition: string): Promise<BlobStoreStats> {
348
+ const { stats } = await this.#readStats();
349
+ return { count: stats.count, bytes: stats.bytes, approximate: true };
350
+ }
351
+ }