@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.
@@ -1,196 +1,31 @@
1
- import { mergeSegmentRecord, publicationRecord, publicationsForRecord, segmentBytesEqual, segmentIdFor, selectSegmentPublication, } from './segment-store.js';
2
- import { EMPTY_PAYLOAD_SHA256, presignUrl, sha256Hex, signRequest, } from './sigv4.js';
3
- const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
4
- const SEGMENT_ID_PATTERN = /^sha256:[0-9a-f]{64}$/;
5
- const RECORD_META_HEADER = 'x-amz-meta-syncular-record';
6
1
  /**
7
- * Stats accumulator (§ "S3 stats" in the README). The store keeps a small
8
- * counter object under a fixed key, updated read-modify-write on `put`. It
9
- * exists because a LIST-free store (every lookup is a GET/HEAD) has no cheap
10
- * way to count objects on demand — so `stats()` reads the accumulator instead
11
- * of enumerating the bucket.
2
+ * S3-compatible segment store (SPEC.md §5.1 cache semantics, §5.4
3
+ * delegated presign) — AWS S3, Cloudflare R2's S3 endpoint, MinIO. Zero
4
+ * dependencies: hand-rolled SigV4 over `fetch` (sigv4.ts).
12
5
  *
13
- * The write is guarded by an ETag compare-and-swap (`If-Match`, or
14
- * `If-None-Match: *` to create): the loop re-reads and retries on a `412`
15
- * precondition failure, so concurrent writers do not silently clobber each
16
- * other's increment. The counters are still APPROXIMATE — a crash between the
17
- * segment PUT and the accumulator CAS, or a writer against a backend that
18
- * ignores conditional headers, can drift them — so the admin surface labels
19
- * S3 stats `approximate: true`. They are a health gauge, not an invoice.
6
+ * The store logic (key layout, record CAS, reuse pointers, stats
7
+ * accumulator) is `BucketSegmentStore`, shared with `R2SegmentStore` in
8
+ * `@syncular/server-workers`; this class adds the S3 REST transport
9
+ * (`S3ObjectBucket`) and the SigV4 presign methods, which a bucket binding
10
+ * cannot mint.
20
11
  */
21
- const STATS_KEY_SUFFIX = 'stats/segments.json';
22
- const CAS_ATTEMPTS = 16;
23
- function emptyStats() {
24
- return { count: 0, bytes: 0, rowsSegments: 0, sqliteSegments: 0 };
25
- }
26
- function parseStatsJson(json) {
27
- const parsed = JSON.parse(json);
28
- return {
29
- count: Number(parsed.count ?? 0),
30
- bytes: Number(parsed.bytes ?? 0),
31
- rowsSegments: Number(parsed.rowsSegments ?? 0),
32
- sqliteSegments: Number(parsed.sqliteSegments ?? 0),
33
- };
34
- }
35
- // Runtime-neutral base64url: `Buffer` is not present on
36
- // Cloudflare Workers without `nodejs_compat`, so a legacy object-metadata
37
- // record header is decoded with `atob`, available in every runtime.
38
- function base64urlToUtf8(value) {
39
- const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
40
- const bytes = new Uint8Array(binary.length);
41
- for (let i = 0; i < binary.length; i++)
42
- bytes[i] = binary.charCodeAt(i);
43
- return new TextDecoder().decode(bytes);
44
- }
45
- function recordToJson(record) {
46
- return JSON.stringify(record);
47
- }
48
- function isMediaType(value) {
49
- return value === 'rows' || value === 'sqlite';
50
- }
51
- /** One publication inside a stored record (or a legacy materialized one). */
52
- function parsePublication(value, source) {
53
- if (typeof value !== 'object' || value === null) {
54
- throw new Error(`S3SegmentStore: corrupt publication in ${source}`);
55
- }
56
- const p = value;
57
- const strings = ['partition', 'logEpoch', 'table', 'scopeDigest'];
58
- const numbers = [
59
- 'schemaVersion',
60
- 'asOfCommitSeq',
61
- 'rowCount',
62
- 'byteLength',
63
- 'createdAtMs',
64
- 'expiresAtMs',
65
- ];
66
- for (const field of strings) {
67
- if (typeof p[field] !== 'string') {
68
- throw new Error(`S3SegmentStore: bad publication field ${field} in ${source}`);
69
- }
70
- }
71
- for (const field of numbers) {
72
- if (typeof p[field] !== 'number') {
73
- throw new Error(`S3SegmentStore: bad publication field ${field} in ${source}`);
74
- }
75
- }
76
- if (!isMediaType(p.mediaType)) {
77
- throw new Error(`S3SegmentStore: bad publication mediaType in ${source}`);
78
- }
79
- const cursorOk = (v) => v === null || typeof v === 'string';
80
- if (!cursorOk(p.rowCursor) || !cursorOk(p.nextRowCursor)) {
81
- throw new Error(`S3SegmentStore: bad publication cursor in ${source}`);
82
- }
83
- return {
84
- partition: p.partition,
85
- logEpoch: p.logEpoch,
86
- table: p.table,
87
- schemaVersion: p.schemaVersion,
88
- mediaType: p.mediaType,
89
- scopeDigest: p.scopeDigest,
90
- asOfCommitSeq: p.asOfCommitSeq,
91
- rowCount: p.rowCount,
92
- rowCursor: p.rowCursor,
93
- nextRowCursor: p.nextRowCursor,
94
- byteLength: p.byteLength,
95
- createdAtMs: p.createdAtMs,
96
- expiresAtMs: p.expiresAtMs,
97
- };
98
- }
99
- function parseRecordJson(json, source) {
100
- let parsed;
101
- try {
102
- parsed = JSON.parse(json);
103
- }
104
- catch {
105
- throw new Error(`S3SegmentStore: corrupt record JSON in ${source}`);
106
- }
107
- if (typeof parsed !== 'object' || parsed === null) {
108
- throw new Error(`S3SegmentStore: corrupt record in ${source}`);
109
- }
110
- const r = parsed;
111
- const strings = [
112
- 'segmentId',
113
- 'partition',
114
- 'logEpoch',
115
- 'table',
116
- 'scopeDigest',
117
- ];
118
- const numbers = [
119
- 'schemaVersion',
120
- 'asOfCommitSeq',
121
- 'rowCount',
122
- 'byteLength',
123
- 'createdAtMs',
124
- 'expiresAtMs',
125
- ];
126
- for (const field of strings) {
127
- if (typeof r[field] !== 'string') {
128
- throw new Error(`S3SegmentStore: bad record field ${field} in ${source}`);
129
- }
130
- }
131
- for (const field of numbers) {
132
- if (typeof r[field] !== 'number') {
133
- throw new Error(`S3SegmentStore: bad record field ${field} in ${source}`);
134
- }
135
- }
136
- if (!isMediaType(r.mediaType)) {
137
- throw new Error(`S3SegmentStore: bad record mediaType in ${source}`);
138
- }
139
- const cursorOk = (v) => v === null || typeof v === 'string';
140
- if (!cursorOk(r.rowCursor) || !cursorOk(r.nextRowCursor)) {
141
- throw new Error(`S3SegmentStore: bad record cursor in ${source}`);
142
- }
143
- const scopeDigest = r.scopeDigest;
144
- // Records written before §5.1 recorded the digest set carry only the
145
- // primary digest; the object is a cache entry with a TTL, so reading it
146
- // back as a one-digest record is correct, not a degraded path.
147
- const rawDigests = r.scopeDigests;
148
- if (rawDigests !== undefined &&
149
- (!Array.isArray(rawDigests) ||
150
- rawDigests.some((digest) => typeof digest !== 'string'))) {
151
- throw new Error(`S3SegmentStore: bad record scopeDigests in ${source}`);
152
- }
153
- const rawPublications = r.publications;
154
- if (rawPublications !== undefined && !Array.isArray(rawPublications)) {
155
- throw new Error(`S3SegmentStore: bad record publications in ${source}`);
156
- }
157
- const top = {
158
- segmentId: r.segmentId,
159
- partition: r.partition,
160
- logEpoch: r.logEpoch,
161
- table: r.table,
162
- schemaVersion: r.schemaVersion,
163
- mediaType: r.mediaType,
164
- scopeDigest,
165
- scopeDigests: [
166
- scopeDigest,
167
- ...(rawDigests ?? []).filter((digest) => digest !== scopeDigest),
168
- ],
169
- asOfCommitSeq: r.asOfCommitSeq,
170
- rowCount: r.rowCount,
171
- rowCursor: r.rowCursor,
172
- nextRowCursor: r.nextRowCursor,
173
- byteLength: r.byteLength,
174
- createdAtMs: r.createdAtMs,
175
- expiresAtMs: r.expiresAtMs,
176
- };
177
- return {
178
- ...top,
179
- // A record from before per-publication provenance, or a legacy record
180
- // whose union came from object metadata, materializes its publications
181
- // from the merged top-level context — the old single-record read.
182
- publications: rawPublications === undefined
183
- ? publicationsForRecord(top)
184
- : rawPublications.map((value) => parsePublication(value, source)),
185
- };
186
- }
187
- export class S3SegmentStore {
188
- #config;
12
+ import { BucketSegmentStore, } from './bucket-segment-store.js';
13
+ import { S3ObjectBucket } from './s3-object-bucket.js';
14
+ import { presignUrl } from './sigv4.js';
15
+ const SEGMENT_ID_PATTERN = /^sha256:[0-9a-f]{64}$/;
16
+ export class S3SegmentStore extends BucketSegmentStore {
17
+ #bucket;
18
+ #region;
189
19
  #credentials;
190
- #ttlMs;
191
- #prefix;
192
20
  constructor(config) {
193
- this.#config = config;
21
+ const options = {
22
+ keyPrefix: config.keyPrefix,
23
+ ttlMs: config.ttlMs,
24
+ };
25
+ const bucket = new S3ObjectBucket(config);
26
+ super(bucket, options);
27
+ this.#bucket = bucket;
28
+ this.#region = config.region;
194
29
  this.#credentials = {
195
30
  accessKeyId: config.accessKeyId,
196
31
  secretAccessKey: config.secretAccessKey,
@@ -198,319 +33,6 @@ export class S3SegmentStore {
198
33
  ? { sessionToken: config.sessionToken }
199
34
  : {}),
200
35
  };
201
- this.#ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
202
- this.#prefix = config.keyPrefix ?? '';
203
- }
204
- /** `{keyPrefix}seg/sha256/{hex}` — the §5.4 "key embeds the segmentId". */
205
- objectKeyFor(segmentId) {
206
- return `${this.#prefix}seg/${segmentId.replace(':', '/')}`;
207
- }
208
- /** `{keyPrefix}rec/sha256/{hex}.json` — the §5.1 record (§5.1 union). */
209
- #recordKeyFor(segmentId) {
210
- return `${this.#prefix}rec/${segmentId.replace(':', '/')}.json`;
211
- }
212
- async #findKeyFor(key) {
213
- const canonical = JSON.stringify([
214
- key.partition,
215
- key.logEpoch,
216
- key.table,
217
- key.schemaVersion,
218
- key.mediaType,
219
- key.scopeDigest,
220
- key.asOfCommitSeq,
221
- ]);
222
- return `${this.#prefix}find/${await sha256Hex(canonical)}.json`;
223
- }
224
- #statsKey() {
225
- return `${this.#prefix}${STATS_KEY_SUFFIX}`;
226
- }
227
- #urlFor(key) {
228
- const endpoint = this.#config.endpoint.replace(/\/+$/, '');
229
- return new URL(`${endpoint}/${this.#config.bucket}/${key}`);
230
- }
231
- async #request(method, key, options) {
232
- const url = this.#urlFor(key);
233
- const body = options?.body;
234
- const headers = await signRequest({
235
- method,
236
- url,
237
- region: this.#config.region,
238
- credentials: this.#credentials,
239
- nowMs: Date.now(),
240
- payloadHash: body === undefined ? EMPTY_PAYLOAD_SHA256 : await sha256Hex(body),
241
- ...(options?.headers !== undefined ? { headers: options.headers } : {}),
242
- });
243
- const response = await fetch(url, {
244
- method,
245
- headers,
246
- ...(body !== undefined ? { body: body } : {}),
247
- });
248
- if (response.status === 404) {
249
- await response.arrayBuffer();
250
- return undefined;
251
- }
252
- if (options?.allow412 === true && response.status === 412)
253
- return response;
254
- if (!response.ok) {
255
- const detail = (await response.text()).slice(0, 300);
256
- throw new Error(`S3SegmentStore: ${method} ${key} failed with ${response.status}: ${detail}`);
257
- }
258
- return response;
259
- }
260
- /** Read the stats accumulator + its ETag (for the CAS write), or none. */
261
- async #readStats() {
262
- const response = await this.#request('GET', this.#statsKey());
263
- if (response === undefined) {
264
- return { stats: emptyStats(), etag: undefined };
265
- }
266
- const etag = response.headers.get('etag') ?? undefined;
267
- const stats = parseStatsJson(await response.text());
268
- return { stats, etag };
269
- }
270
- /**
271
- * Conditionally write the stats accumulator. `etag` present ⇒ `If-Match`
272
- * (update the object we read); absent ⇒ `If-None-Match: *` (create only).
273
- * Returns `false` on a `412` precondition failure so the caller retries.
274
- */
275
- async #writeStatsCas(stats, etag) {
276
- const url = this.#urlFor(this.#statsKey());
277
- const body = new TextEncoder().encode(JSON.stringify(stats));
278
- const conditional = etag === undefined ? { 'if-none-match': '*' } : { 'if-match': etag };
279
- const headers = await signRequest({
280
- method: 'PUT',
281
- url,
282
- region: this.#config.region,
283
- credentials: this.#credentials,
284
- nowMs: Date.now(),
285
- payloadHash: await sha256Hex(body),
286
- headers: { 'content-type': 'application/json', ...conditional },
287
- });
288
- const response = await fetch(url, {
289
- method: 'PUT',
290
- headers,
291
- body: body,
292
- });
293
- if (response.status === 412) {
294
- await response.arrayBuffer();
295
- return false;
296
- }
297
- if (!response.ok) {
298
- const detail = (await response.text()).slice(0, 300);
299
- throw new Error(`S3SegmentStore: stats PUT failed with ${response.status}: ${detail}`);
300
- }
301
- await response.arrayBuffer();
302
- return true;
303
- }
304
- /**
305
- * Fold `delta` into the stats accumulator under an ETag compare-and-swap
306
- * retry loop. Best-effort: exhausting the retry budget leaves the counters
307
- * drifted (they are documented APPROXIMATE) rather than failing the `put`
308
- * that already stored the bytes.
309
- */
310
- async #bumpStats(delta) {
311
- for (let attempt = 0; attempt < CAS_ATTEMPTS; attempt++) {
312
- const { stats, etag } = await this.#readStats();
313
- const next = {
314
- count: stats.count + delta.count,
315
- bytes: stats.bytes + delta.bytes,
316
- rowsSegments: stats.rowsSegments + delta.rowsSegments,
317
- sqliteSegments: stats.sqliteSegments + delta.sqliteSegments,
318
- };
319
- if (await this.#writeStatsCas(next, etag))
320
- return;
321
- }
322
- // Lost the CAS race `CAS_ATTEMPTS` times running — leave the
323
- // counters as-is. `stats()` stays APPROXIMATE by contract.
324
- }
325
- async put(metadata, bytes, nowMs) {
326
- const segmentId = await segmentIdFor(bytes);
327
- await this.#putBytes(segmentId, bytes);
328
- for (let attempt = 0; attempt < CAS_ATTEMPTS; attempt++) {
329
- // The id IS the content address, so an existing record means a second
330
- // scope published the same bytes: read it back and merge the digest set
331
- // (§5.1).
332
- const preexisting = await this.#readRecord(segmentId);
333
- const record = mergeSegmentRecord(preexisting?.record, metadata, segmentId, bytes.length, nowMs, this.#ttlMs);
334
- const recordJson = recordToJson(record);
335
- // The digest set is monotone (§5.1) and this is a read-modify-write: a
336
- // concurrent publisher of the same bytes computed its own union from the
337
- // same pre-state, so an unconditional PUT would drop one of the digests
338
- // and make that scope's download `sync.forbidden`. The write is
339
- // conditional on the record body that was read — the record ETag is the
340
- // body hash, so it changes whenever the union does — and a `412`
341
- // re-reads and merges again. In-process dedupe cannot cover this: two
342
- // scopes whose rows are byte-identical are two builds, and two server
343
- // instances share one bucket.
344
- const conditional = preexisting?.etag === undefined
345
- ? { 'if-none-match': '*' }
346
- : { 'if-match': preexisting.etag };
347
- const response = await this.#request('PUT', this.#recordKeyFor(segmentId), {
348
- body: new TextEncoder().encode(recordJson),
349
- headers: { 'content-type': 'application/json', ...conditional },
350
- allow412: true,
351
- });
352
- if (response !== undefined && response.status === 412) {
353
- await response.arrayBuffer();
354
- continue;
355
- }
356
- await response?.arrayBuffer();
357
- if (preexisting === undefined) {
358
- await this.#bumpStats({
359
- count: 1,
360
- bytes: bytes.length,
361
- rowsSegments: metadata.mediaType === 'rows' ? 1 : 0,
362
- sqliteSegments: metadata.mediaType === 'sqlite' ? 1 : 0,
363
- });
364
- }
365
- if (metadata.rowCursor === null) {
366
- const pointer = await this.#request('PUT', await this.#findKeyFor(metadata), {
367
- body: new TextEncoder().encode(recordJson),
368
- headers: { 'content-type': 'application/json' },
369
- });
370
- await pointer?.arrayBuffer();
371
- }
372
- return record;
373
- }
374
- throw new Error(`S3SegmentStore: ${CAS_ATTEMPTS} conditional writes in a row lost the race for ${segmentId} — the stored digest set would have dropped a scope`);
375
- }
376
- /**
377
- * Write the immutable bytes object. Create-only, so a second publisher of
378
- * identical bytes is a no-op and a second publisher of DIFFERENT bytes under
379
- * the same content address is the hash collision §5.1 requires to fail
380
- * loudly instead of overwriting.
381
- */
382
- async #putBytes(segmentId, bytes) {
383
- const key = this.objectKeyFor(segmentId);
384
- const response = await this.#request('PUT', key, {
385
- body: bytes,
386
- headers: {
387
- 'content-type': 'application/octet-stream',
388
- 'if-none-match': '*',
389
- },
390
- allow412: true,
391
- });
392
- if (response === undefined || response.status !== 412) {
393
- await response?.arrayBuffer();
394
- return;
395
- }
396
- await response.arrayBuffer();
397
- const current = await this.#request('GET', key);
398
- if (current === undefined)
399
- return;
400
- const stored = new Uint8Array(await current.arrayBuffer());
401
- if (!segmentBytesEqual(stored, bytes)) {
402
- throw new Error(`S3SegmentStore: content-address collision at ${segmentId} (§5.1)`);
403
- }
404
- }
405
- /**
406
- * Read the record object plus the ETag of the body that was read, so the
407
- * union write can be made conditional on exactly that pre-state (§5.1).
408
- *
409
- * A record written before the record/bytes split rides in the bytes object's
410
- * user metadata; it is read here and its union is materialized into a record
411
- * object by the next `put`. That shape carries no usable ETag for the union
412
- * (the bytes ETag is a content hash), which is exactly why it was replaced.
413
- * A bytes object with neither a record object nor the legacy metadata is an
414
- * entry whose record is missing — a cache miss, not corruption, so `get`
415
- * reports it as absent and the client re-pulls.
416
- */
417
- async #readRecord(segmentId) {
418
- if (!SEGMENT_ID_PATTERN.test(segmentId))
419
- return undefined;
420
- const response = await this.#request('GET', this.#recordKeyFor(segmentId));
421
- if (response !== undefined) {
422
- return {
423
- record: parseRecordJson(await response.text(), 'segment record'),
424
- etag: response.headers.get('etag') ?? undefined,
425
- };
426
- }
427
- const legacy = await this.#request('GET', this.objectKeyFor(segmentId));
428
- if (legacy === undefined)
429
- return undefined;
430
- const meta = legacy.headers.get(RECORD_META_HEADER);
431
- await legacy.arrayBuffer();
432
- if (meta === null)
433
- return undefined;
434
- return {
435
- record: parseRecordJson(base64urlToUtf8(meta), segmentId),
436
- etag: undefined,
437
- };
438
- }
439
- async get(segmentId) {
440
- if (!SEGMENT_ID_PATTERN.test(segmentId))
441
- return undefined;
442
- const record = await this.#readRecord(segmentId);
443
- if (record === undefined)
444
- return undefined;
445
- const response = await this.#request('GET', this.objectKeyFor(segmentId));
446
- if (response === undefined)
447
- return undefined;
448
- return {
449
- record: record.record,
450
- bytes: new Uint8Array(await response.arrayBuffer()),
451
- };
452
- }
453
- /** The record plus the object GET's body, never buffered (see `get`). */
454
- async open(segmentId) {
455
- if (!SEGMENT_ID_PATTERN.test(segmentId))
456
- return undefined;
457
- const record = await this.#readRecord(segmentId);
458
- if (record === undefined)
459
- return undefined;
460
- const response = await this.#request('GET', this.objectKeyFor(segmentId));
461
- if (response === undefined || response.body === null)
462
- return undefined;
463
- return {
464
- record: record.record,
465
- body: response.body,
466
- byteLength: record.record.byteLength,
467
- };
468
- }
469
- async find(key, nowMs) {
470
- const response = await this.#request('GET', await this.#findKeyFor(key));
471
- if (response === undefined)
472
- return undefined;
473
- const record = parseRecordJson(await response.text(), 'reuse pointer');
474
- // The pointer is keyed by the full context, but the record body is the
475
- // whole merged entry: select the publication the key names, never the
476
- // top-level fields or the digest union (§5.1).
477
- const publication = selectSegmentPublication(record, {
478
- partition: key.partition,
479
- logEpoch: key.logEpoch,
480
- table: key.table,
481
- schemaVersion: key.schemaVersion,
482
- mediaType: key.mediaType,
483
- scopeDigest: key.scopeDigest,
484
- asOfCommitSeq: key.asOfCommitSeq,
485
- rowCursor: null,
486
- nowMs,
487
- });
488
- if (publication === undefined)
489
- return undefined;
490
- // Lifecycle GC may have removed the object while the pointer survived.
491
- const head = await this.#request('HEAD', this.objectKeyFor(record.segmentId));
492
- if (head === undefined)
493
- return undefined;
494
- await head.arrayBuffer();
495
- return publicationRecord(record, publication);
496
- }
497
- /**
498
- * Store-wide counters from the pointer-object accumulator (never a LIST).
499
- * Marked `approximate: true`: the counters are maintained by a best-effort
500
- * ETag-CAS read-modify-write on `put`, so a crash between the segment PUT
501
- * and the accumulator write, or lifecycle GC that deletes objects the
502
- * accumulator still counts, can drift them. Exact within a single writer;
503
- * a health gauge under concurrency. See the README "S3 stats".
504
- */
505
- async stats() {
506
- const { stats } = await this.#readStats();
507
- return {
508
- count: stats.count,
509
- bytes: stats.bytes,
510
- rowsSegments: stats.rowsSegments,
511
- sqliteSegments: stats.sqliteSegments,
512
- approximate: true,
513
- };
514
36
  }
515
37
  /**
516
38
  * SigV4 presigned GET for a segment object (§5.4 delegated presign).
@@ -526,8 +48,8 @@ export class S3SegmentStore {
526
48
  const ttlSeconds = options?.ttlSeconds ?? 900;
527
49
  const nowMs = options?.nowMs ?? Date.now();
528
50
  const url = await presignUrl({
529
- url: this.#urlFor(this.objectKeyFor(segmentId)),
530
- region: this.#config.region,
51
+ url: this.#bucket.urlFor(this.objectKeyFor(segmentId)),
52
+ region: this.#region,
531
53
  credentials: this.#credentials,
532
54
  nowMs,
533
55
  expiresSeconds: ttlSeconds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.32.1"
71
+ "@syncular/core": "0.33.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
@@ -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',