@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.
@@ -1,48 +1,15 @@
1
1
  /**
2
2
  * S3-compatible segment store (SPEC.md §5.1 cache semantics, §5.4
3
- * delegated presign) — AWS S3, Cloudflare R2, MinIO. Zero dependencies:
4
- * hand-rolled SigV4 over `fetch` (sigv4.ts).
3
+ * delegated presign) — AWS S3, Cloudflare R2's S3 endpoint, MinIO. Zero
4
+ * dependencies: hand-rolled SigV4 over `fetch` (sigv4.ts).
5
5
  *
6
- * Key layout (deterministic — every lookup is a GET/HEAD, never a LIST):
7
- *
8
- * {keyPrefix}seg/sha256/{hex}
9
- * The segment bytes, verbatim and immutable (the object body MUST be
10
- * exactly the content-addressed bytes so presigned GETs serve them
11
- * directly and the client's §5.1 hash check passes).
12
- *
13
- * {keyPrefix}rec/sha256/{hex}.json
14
- * The `SegmentRecord` (§5.1). It is the one MUTABLE object of the two:
15
- * the scope-digest set is a union that every publication extends, so
16
- * the merge is a read-modify-write. Keeping the record in the body (not
17
- * in the bytes object's user metadata) is what makes that write
18
- * conditional — a body ETag changes when the union changes, while the
19
- * bytes ETag is a content hash and is identical for identical bytes. A
20
- * `412` re-reads and merges again, so no concurrent publication can drop
21
- * another one's digest. `get` therefore reads two objects.
22
- *
23
- * {keyPrefix}find/{sha256Hex(canonical reuse key)}.json
24
- * Whole-table reuse pointer (§5.3): written only when
25
- * `rowCursor === null`, body = the record JSON. The reuse key is the
26
- * canonical JSON array `[partition, table, schemaVersion, mediaType,
27
- * scopeDigest, asOfCommitSeq]`, so `find` is one GET (plus a HEAD to
28
- * confirm the segment object itself still exists — lifecycle GC may
29
- * remove objects independently of pointers).
30
- *
31
- * Records written before the split kept the record in the bytes object's
32
- * user metadata `x-amz-meta-syncular-record`; `get` still reads that shape
33
- * and the next `put` materializes the record object from it, so an upgrade
34
- * loses no cache entry.
35
- *
36
- * TTL mapping: expiry is **store-side and authoritative** — `expiresAtMs`
37
- * (put-time + `ttlMs`, default 24 h) is recorded in the record and the
38
- * pointer; `get` returns expired records so §5.5 can answer
39
- * `sync.segment_expired`, and `find` filters them out itself. S3 lifecycle
40
- * expiration is garbage collection only: configure it comfortably ABOVE
41
- * `ttlMs` (e.g. 2 days for the 24 h default) so clients normally see the
42
- * precise, retryable `sync.segment_expired` and hit `sync.not_found` only
43
- * long after. Never set lifecycle below `ttlMs`.
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.
44
11
  */
45
- import type { SegmentFindKey, SegmentMetadata, SegmentRecord, SegmentStore, SegmentStoreStats, SegmentStream } from './segment-store.js';
12
+ import { BucketSegmentStore } from './bucket-segment-store.js';
46
13
  import type { DelegatedPresignConfig, SegmentUrlIssue } from './signed-url.js';
47
14
  export interface S3SegmentStoreConfig {
48
15
  /**
@@ -65,28 +32,9 @@ export interface S3SegmentStoreConfig {
65
32
  /** Segment TTL (§5.1 cache semantics). Default 24 h. */
66
33
  readonly ttlMs?: number;
67
34
  }
68
- export declare class S3SegmentStore implements SegmentStore {
35
+ export declare class S3SegmentStore extends BucketSegmentStore {
69
36
  #private;
70
37
  constructor(config: S3SegmentStoreConfig);
71
- /** `{keyPrefix}seg/sha256/{hex}` — the §5.4 "key embeds the segmentId". */
72
- objectKeyFor(segmentId: string): string;
73
- put(metadata: SegmentMetadata, bytes: Uint8Array, nowMs: number): Promise<SegmentRecord>;
74
- get(segmentId: string): Promise<{
75
- record: SegmentRecord;
76
- bytes: Uint8Array;
77
- } | undefined>;
78
- /** The record plus the object GET's body, never buffered (see `get`). */
79
- open(segmentId: string): Promise<SegmentStream | undefined>;
80
- find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
81
- /**
82
- * Store-wide counters from the pointer-object accumulator (never a LIST).
83
- * Marked `approximate: true`: the counters are maintained by a best-effort
84
- * ETag-CAS read-modify-write on `put`, so a crash between the segment PUT
85
- * and the accumulator write, or lifecycle GC that deletes objects the
86
- * accumulator still counts, can drift them. Exact within a single writer;
87
- * a health gauge under concurrency. See the README "S3 stats".
88
- */
89
- stats(): Promise<SegmentStoreStats>;
90
38
  /**
91
39
  * SigV4 presigned GET for a segment object (§5.4 delegated presign).
92
40
  * The signed key embeds the `segmentId` (equivalence rule) and
@@ -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/dist/seed.js CHANGED
@@ -13,6 +13,7 @@ import { decodeMessage, encodeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, }
13
13
  import { SyncError } from './errors.js';
14
14
  import { composeEvents } from './events-ring.js';
15
15
  import { handleSyncRequest } from './handler.js';
16
+ import { renderScopeValue } from './scopes.js';
16
17
  /** Structured terminal failure from the real push path used by a seed. */
17
18
  export class SeedMutationError extends Error {
18
19
  name = 'SeedMutationError';
@@ -94,9 +95,9 @@ export async function seedMutations(config, target, mutations) {
94
95
  }
95
96
  const values = table.columns.map((column) => (byColumn.get(column.name) ?? null));
96
97
  const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
97
- const rowId = values[pkIndex];
98
- if (typeof rowId !== 'string' || rowId.length === 0) {
99
- throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty string primary key`);
98
+ const rowId = renderScopeValue(values[pkIndex]);
99
+ if (rowId === undefined) {
100
+ throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty primary key`);
100
101
  }
101
102
  return {
102
103
  table: mutation.table,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.32.0",
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.0"
71
+ "@syncular/core": "0.33.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"