@syncular/server 0.21.0 → 0.22.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.
@@ -6,12 +6,19 @@
6
6
  * Key layout (deterministic — every lookup is a GET/HEAD, never a LIST):
7
7
  *
8
8
  * {keyPrefix}seg/sha256/{hex}
9
- * The segment bytes, verbatim (the object body MUST be exactly the
10
- * content-addressed bytes so presigned GETs serve them directly and
11
- * the client's §5.1 hash check passes). The full `SegmentRecord`
12
- * (minus bytes) rides along as object user metadata
13
- * `x-amz-meta-syncular-record` = base64url(JSON), so `get` is a
14
- * single GET.
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.
15
22
  *
16
23
  * {keyPrefix}find/{sha256Hex(canonical reuse key)}.json
17
24
  * Whole-table reuse pointer (§5.3): written only when
@@ -21,9 +28,14 @@
21
28
  * confirm the segment object itself still exists — lifecycle GC may
22
29
  * remove objects independently of pointers).
23
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
+ *
24
36
  * TTL mapping: expiry is **store-side and authoritative** — `expiresAtMs`
25
- * (put-time + `ttlMs`, default 24 h) is recorded in the object metadata
26
- * and the pointer; `get` returns expired records so §5.5 can answer
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
27
39
  * `sync.segment_expired`, and `find` filters them out itself. S3 lifecycle
28
40
  * expiration is garbage collection only: configure it comfortably ABOVE
29
41
  * `ttlMs` (e.g. 2 days for the 24 h default) so clients normally see the
@@ -33,11 +45,19 @@
33
45
  import type {
34
46
  SegmentFindKey,
35
47
  SegmentMetadata,
48
+ SegmentPublication,
36
49
  SegmentRecord,
37
50
  SegmentStore,
38
51
  SegmentStoreStats,
39
52
  } from './segment-store';
40
- import { segmentIdFor } from './segment-store';
53
+ import {
54
+ mergeSegmentRecord,
55
+ publicationRecord,
56
+ publicationsForRecord,
57
+ segmentBytesEqual,
58
+ segmentIdFor,
59
+ selectSegmentPublication,
60
+ } from './segment-store';
41
61
  import type { DelegatedPresignConfig, SegmentUrlIssue } from './signed-url';
42
62
  import {
43
63
  EMPTY_PAYLOAD_SHA256,
@@ -89,7 +109,7 @@ const RECORD_META_HEADER = 'x-amz-meta-syncular-record';
89
109
  * S3 stats `approximate: true`. They are a health gauge, not an invoice.
90
110
  */
91
111
  const STATS_KEY_SUFFIX = 'stats/segments.json';
92
- const STATS_CAS_ATTEMPTS = 16;
112
+ const CAS_ATTEMPTS = 16;
93
113
 
94
114
  interface StatsAccumulator {
95
115
  count: number;
@@ -113,19 +133,8 @@ function parseStatsJson(json: string): StatsAccumulator {
113
133
  }
114
134
 
115
135
  // Runtime-neutral base64url: `Buffer` is not present on
116
- // Cloudflare Workers without `nodejs_compat`, so the object-metadata record
117
- // header is (de)coded with `btoa`/`atob`, available in every runtime.
118
- function utf8ToBase64url(text: string): string {
119
- let binary = '';
120
- for (const byte of new TextEncoder().encode(text)) {
121
- binary += String.fromCharCode(byte);
122
- }
123
- return btoa(binary)
124
- .replaceAll('+', '-')
125
- .replaceAll('/', '_')
126
- .replace(/=+$/, '');
127
- }
128
-
136
+ // Cloudflare Workers without `nodejs_compat`, so a legacy object-metadata
137
+ // record header is decoded with `atob`, available in every runtime.
129
138
  function base64urlToUtf8(value: string): string {
130
139
  const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
131
140
  const bytes = new Uint8Array(binary.length);
@@ -141,6 +150,60 @@ function isMediaType(value: unknown): value is 'rows' | 'sqlite' {
141
150
  return value === 'rows' || value === 'sqlite';
142
151
  }
143
152
 
153
+ /** One publication inside a stored record (or a legacy materialized one). */
154
+ function parsePublication(value: unknown, source: string): SegmentPublication {
155
+ if (typeof value !== 'object' || value === null) {
156
+ throw new Error(`S3SegmentStore: corrupt publication in ${source}`);
157
+ }
158
+ const p = value as Record<string, unknown>;
159
+ const strings = ['partition', 'logEpoch', 'table', 'scopeDigest'] as const;
160
+ const numbers = [
161
+ 'schemaVersion',
162
+ 'asOfCommitSeq',
163
+ 'rowCount',
164
+ 'byteLength',
165
+ 'createdAtMs',
166
+ 'expiresAtMs',
167
+ ] as const;
168
+ for (const field of strings) {
169
+ if (typeof p[field] !== 'string') {
170
+ throw new Error(
171
+ `S3SegmentStore: bad publication field ${field} in ${source}`,
172
+ );
173
+ }
174
+ }
175
+ for (const field of numbers) {
176
+ if (typeof p[field] !== 'number') {
177
+ throw new Error(
178
+ `S3SegmentStore: bad publication field ${field} in ${source}`,
179
+ );
180
+ }
181
+ }
182
+ if (!isMediaType(p.mediaType)) {
183
+ throw new Error(`S3SegmentStore: bad publication mediaType in ${source}`);
184
+ }
185
+ const cursorOk = (v: unknown): v is string | null =>
186
+ v === null || typeof v === 'string';
187
+ if (!cursorOk(p.rowCursor) || !cursorOk(p.nextRowCursor)) {
188
+ throw new Error(`S3SegmentStore: bad publication cursor in ${source}`);
189
+ }
190
+ return {
191
+ partition: p.partition as string,
192
+ logEpoch: p.logEpoch as string,
193
+ table: p.table as string,
194
+ schemaVersion: p.schemaVersion as number,
195
+ mediaType: p.mediaType,
196
+ scopeDigest: p.scopeDigest as string,
197
+ asOfCommitSeq: p.asOfCommitSeq as number,
198
+ rowCount: p.rowCount as number,
199
+ rowCursor: p.rowCursor,
200
+ nextRowCursor: p.nextRowCursor,
201
+ byteLength: p.byteLength as number,
202
+ createdAtMs: p.createdAtMs as number,
203
+ expiresAtMs: p.expiresAtMs as number,
204
+ };
205
+ }
206
+
144
207
  function parseRecordJson(json: string, source: string): SegmentRecord {
145
208
  let parsed: unknown;
146
209
  try {
@@ -185,14 +248,36 @@ function parseRecordJson(json: string, source: string): SegmentRecord {
185
248
  if (!cursorOk(r.rowCursor) || !cursorOk(r.nextRowCursor)) {
186
249
  throw new Error(`S3SegmentStore: bad record cursor in ${source}`);
187
250
  }
188
- return {
251
+ const scopeDigest = r.scopeDigest as string;
252
+ // Records written before §5.1 recorded the digest set carry only the
253
+ // primary digest; the object is a cache entry with a TTL, so reading it
254
+ // back as a one-digest record is correct, not a degraded path.
255
+ const rawDigests = r.scopeDigests;
256
+ if (
257
+ rawDigests !== undefined &&
258
+ (!Array.isArray(rawDigests) ||
259
+ rawDigests.some((digest) => typeof digest !== 'string'))
260
+ ) {
261
+ throw new Error(`S3SegmentStore: bad record scopeDigests in ${source}`);
262
+ }
263
+ const rawPublications = r.publications;
264
+ if (rawPublications !== undefined && !Array.isArray(rawPublications)) {
265
+ throw new Error(`S3SegmentStore: bad record publications in ${source}`);
266
+ }
267
+ const top = {
189
268
  segmentId: r.segmentId as string,
190
269
  partition: r.partition as string,
191
270
  logEpoch: r.logEpoch as string,
192
271
  table: r.table as string,
193
272
  schemaVersion: r.schemaVersion as number,
194
273
  mediaType: r.mediaType,
195
- scopeDigest: r.scopeDigest as string,
274
+ scopeDigest,
275
+ scopeDigests: [
276
+ scopeDigest,
277
+ ...((rawDigests ?? []) as string[]).filter(
278
+ (digest) => digest !== scopeDigest,
279
+ ),
280
+ ],
196
281
  asOfCommitSeq: r.asOfCommitSeq as number,
197
282
  rowCount: r.rowCount as number,
198
283
  rowCursor: r.rowCursor,
@@ -201,6 +286,16 @@ function parseRecordJson(json: string, source: string): SegmentRecord {
201
286
  createdAtMs: r.createdAtMs as number,
202
287
  expiresAtMs: r.expiresAtMs as number,
203
288
  };
289
+ return {
290
+ ...top,
291
+ // A record from before per-publication provenance, or a legacy record
292
+ // whose union came from object metadata, materializes its publications
293
+ // from the merged top-level context — the old single-record read.
294
+ publications:
295
+ rawPublications === undefined
296
+ ? publicationsForRecord(top)
297
+ : rawPublications.map((value) => parsePublication(value, source)),
298
+ };
204
299
  }
205
300
 
206
301
  export class S3SegmentStore implements SegmentStore {
@@ -227,6 +322,11 @@ export class S3SegmentStore implements SegmentStore {
227
322
  return `${this.#prefix}seg/${segmentId.replace(':', '/')}`;
228
323
  }
229
324
 
325
+ /** `{keyPrefix}rec/sha256/{hex}.json` — the §5.1 record (§5.1 union). */
326
+ #recordKeyFor(segmentId: string): string {
327
+ return `${this.#prefix}rec/${segmentId.replace(':', '/')}.json`;
328
+ }
329
+
230
330
  async #findKeyFor(key: SegmentFindKey): Promise<string> {
231
331
  const canonical = JSON.stringify([
232
332
  key.partition,
@@ -255,6 +355,8 @@ export class S3SegmentStore implements SegmentStore {
255
355
  options?: {
256
356
  readonly body?: Uint8Array;
257
357
  readonly headers?: Readonly<Record<string, string>>;
358
+ /** Return a `412` response instead of throwing (conditional writes). */
359
+ readonly allow412?: boolean;
258
360
  },
259
361
  ): Promise<Response | undefined> {
260
362
  const url = this.#urlFor(key);
@@ -278,6 +380,7 @@ export class S3SegmentStore implements SegmentStore {
278
380
  await response.arrayBuffer();
279
381
  return undefined;
280
382
  }
383
+ if (options?.allow412 === true && response.status === 412) return response;
281
384
  if (!response.ok) {
282
385
  const detail = (await response.text()).slice(0, 300);
283
386
  throw new Error(
@@ -349,7 +452,7 @@ export class S3SegmentStore implements SegmentStore {
349
452
  * that already stored the bytes.
350
453
  */
351
454
  async #bumpStats(delta: StatsAccumulator): Promise<void> {
352
- for (let attempt = 0; attempt < STATS_CAS_ATTEMPTS; attempt++) {
455
+ for (let attempt = 0; attempt < CAS_ATTEMPTS; attempt++) {
353
456
  const { stats, etag } = await this.#readStats();
354
457
  const next: StatsAccumulator = {
355
458
  count: stats.count + delta.count,
@@ -359,7 +462,7 @@ export class S3SegmentStore implements SegmentStore {
359
462
  };
360
463
  if (await this.#writeStatsCas(next, etag)) return;
361
464
  }
362
- // Lost the CAS race `STATS_CAS_ATTEMPTS` times running — leave the
465
+ // Lost the CAS race `CAS_ATTEMPTS` times running — leave the
363
466
  // counters as-is. `stats()` stays APPROXIMATE by contract.
364
467
  }
365
468
 
@@ -369,65 +472,151 @@ export class S3SegmentStore implements SegmentStore {
369
472
  nowMs: number,
370
473
  ): Promise<SegmentRecord> {
371
474
  const segmentId = await segmentIdFor(bytes);
372
- const record: SegmentRecord = {
373
- ...metadata,
374
- segmentId,
375
- byteLength: bytes.length,
376
- createdAtMs: nowMs,
377
- expiresAtMs: nowMs + this.#ttlMs,
378
- };
379
- const recordJson = recordToJson(record);
380
- // Detect an idempotent re-put (same content-address ⇒ same key) so the
381
- // stats accumulator counts each distinct segment once, not once per PUT.
382
- const objectKey = this.objectKeyFor(segmentId);
383
- const preexisting = await this.#request('HEAD', objectKey);
384
- await preexisting?.arrayBuffer();
385
- const isNew = preexisting === undefined;
386
- const response = await this.#request('PUT', objectKey, {
475
+ await this.#putBytes(segmentId, bytes);
476
+ for (let attempt = 0; attempt < CAS_ATTEMPTS; attempt++) {
477
+ // The id IS the content address, so an existing record means a second
478
+ // scope published the same bytes: read it back and merge the digest set
479
+ // (§5.1).
480
+ const preexisting = await this.#readRecord(segmentId);
481
+ const record = mergeSegmentRecord(
482
+ preexisting?.record,
483
+ metadata,
484
+ segmentId,
485
+ bytes.length,
486
+ nowMs,
487
+ this.#ttlMs,
488
+ );
489
+ const recordJson = recordToJson(record);
490
+ // The digest set is monotone (§5.1) and this is a read-modify-write: a
491
+ // concurrent publisher of the same bytes computed its own union from the
492
+ // same pre-state, so an unconditional PUT would drop one of the digests
493
+ // and make that scope's download `sync.forbidden`. The write is
494
+ // conditional on the record body that was read — the record ETag is the
495
+ // body hash, so it changes whenever the union does — and a `412`
496
+ // re-reads and merges again. In-process dedupe cannot cover this: two
497
+ // scopes whose rows are byte-identical are two builds, and two server
498
+ // instances share one bucket.
499
+ const conditional: Record<string, string> =
500
+ preexisting?.etag === undefined
501
+ ? { 'if-none-match': '*' }
502
+ : { 'if-match': preexisting.etag };
503
+ const response = await this.#request(
504
+ 'PUT',
505
+ this.#recordKeyFor(segmentId),
506
+ {
507
+ body: new TextEncoder().encode(recordJson),
508
+ headers: { 'content-type': 'application/json', ...conditional },
509
+ allow412: true,
510
+ },
511
+ );
512
+ if (response !== undefined && response.status === 412) {
513
+ await response.arrayBuffer();
514
+ continue;
515
+ }
516
+ await response?.arrayBuffer();
517
+ if (preexisting === undefined) {
518
+ await this.#bumpStats({
519
+ count: 1,
520
+ bytes: bytes.length,
521
+ rowsSegments: metadata.mediaType === 'rows' ? 1 : 0,
522
+ sqliteSegments: metadata.mediaType === 'sqlite' ? 1 : 0,
523
+ });
524
+ }
525
+ if (metadata.rowCursor === null) {
526
+ const pointer = await this.#request(
527
+ 'PUT',
528
+ await this.#findKeyFor(metadata),
529
+ {
530
+ body: new TextEncoder().encode(recordJson),
531
+ headers: { 'content-type': 'application/json' },
532
+ },
533
+ );
534
+ await pointer?.arrayBuffer();
535
+ }
536
+ return record;
537
+ }
538
+ throw new Error(
539
+ `S3SegmentStore: ${CAS_ATTEMPTS} conditional writes in a row lost the race for ${segmentId} — the stored digest set would have dropped a scope`,
540
+ );
541
+ }
542
+
543
+ /**
544
+ * Write the immutable bytes object. Create-only, so a second publisher of
545
+ * identical bytes is a no-op and a second publisher of DIFFERENT bytes under
546
+ * the same content address is the hash collision §5.1 requires to fail
547
+ * loudly instead of overwriting.
548
+ */
549
+ async #putBytes(segmentId: string, bytes: Uint8Array): Promise<void> {
550
+ const key = this.objectKeyFor(segmentId);
551
+ const response = await this.#request('PUT', key, {
387
552
  body: bytes,
388
553
  headers: {
389
554
  'content-type': 'application/octet-stream',
390
- [RECORD_META_HEADER]: utf8ToBase64url(recordJson),
555
+ 'if-none-match': '*',
391
556
  },
557
+ allow412: true,
392
558
  });
393
- await response?.arrayBuffer();
394
- if (isNew) {
395
- await this.#bumpStats({
396
- count: 1,
397
- bytes: bytes.length,
398
- rowsSegments: metadata.mediaType === 'rows' ? 1 : 0,
399
- sqliteSegments: metadata.mediaType === 'sqlite' ? 1 : 0,
400
- });
559
+ if (response === undefined || response.status !== 412) {
560
+ await response?.arrayBuffer();
561
+ return;
401
562
  }
402
- if (metadata.rowCursor === null) {
403
- const pointer = await this.#request(
404
- 'PUT',
405
- await this.#findKeyFor(metadata),
406
- {
407
- body: new TextEncoder().encode(recordJson),
408
- headers: { 'content-type': 'application/json' },
409
- },
563
+ await response.arrayBuffer();
564
+ const current = await this.#request('GET', key);
565
+ if (current === undefined) return;
566
+ const stored = new Uint8Array(await current.arrayBuffer());
567
+ if (!segmentBytesEqual(stored, bytes)) {
568
+ throw new Error(
569
+ `S3SegmentStore: content-address collision at ${segmentId} (§5.1)`,
410
570
  );
411
- await pointer?.arrayBuffer();
412
571
  }
413
- return record;
572
+ }
573
+
574
+ /**
575
+ * Read the record object plus the ETag of the body that was read, so the
576
+ * union write can be made conditional on exactly that pre-state (§5.1).
577
+ *
578
+ * A record written before the record/bytes split rides in the bytes object's
579
+ * user metadata; it is read here and its union is materialized into a record
580
+ * object by the next `put`. That shape carries no usable ETag for the union
581
+ * (the bytes ETag is a content hash), which is exactly why it was replaced.
582
+ * A bytes object with neither a record object nor the legacy metadata is an
583
+ * entry whose record is missing — a cache miss, not corruption, so `get`
584
+ * reports it as absent and the client re-pulls.
585
+ */
586
+ async #readRecord(
587
+ segmentId: string,
588
+ ): Promise<{ record: SegmentRecord; etag: string | undefined } | undefined> {
589
+ if (!SEGMENT_ID_PATTERN.test(segmentId)) return undefined;
590
+ const response = await this.#request('GET', this.#recordKeyFor(segmentId));
591
+ if (response !== undefined) {
592
+ return {
593
+ record: parseRecordJson(await response.text(), 'segment record'),
594
+ etag: response.headers.get('etag') ?? undefined,
595
+ };
596
+ }
597
+ const legacy = await this.#request('GET', this.objectKeyFor(segmentId));
598
+ if (legacy === undefined) return undefined;
599
+ const meta = legacy.headers.get(RECORD_META_HEADER);
600
+ await legacy.arrayBuffer();
601
+ if (meta === null) return undefined;
602
+ return {
603
+ record: parseRecordJson(base64urlToUtf8(meta), segmentId),
604
+ etag: undefined,
605
+ };
414
606
  }
415
607
 
416
608
  async get(
417
609
  segmentId: string,
418
610
  ): Promise<{ record: SegmentRecord; bytes: Uint8Array } | undefined> {
419
611
  if (!SEGMENT_ID_PATTERN.test(segmentId)) return undefined;
612
+ const record = await this.#readRecord(segmentId);
613
+ if (record === undefined) return undefined;
420
614
  const response = await this.#request('GET', this.objectKeyFor(segmentId));
421
615
  if (response === undefined) return undefined;
422
- const bytes = new Uint8Array(await response.arrayBuffer());
423
- const meta = response.headers.get(RECORD_META_HEADER);
424
- if (meta === null) {
425
- throw new Error(
426
- `S3SegmentStore: object for ${segmentId} lacks ${RECORD_META_HEADER}`,
427
- );
428
- }
429
- const record = parseRecordJson(base64urlToUtf8(meta), segmentId);
430
- return { record, bytes };
616
+ return {
617
+ record: record.record,
618
+ bytes: new Uint8Array(await response.arrayBuffer()),
619
+ };
431
620
  }
432
621
 
433
622
  async find(
@@ -437,19 +626,21 @@ export class S3SegmentStore implements SegmentStore {
437
626
  const response = await this.#request('GET', await this.#findKeyFor(key));
438
627
  if (response === undefined) return undefined;
439
628
  const record = parseRecordJson(await response.text(), 'reuse pointer');
440
- if (
441
- record.partition !== key.partition ||
442
- record.logEpoch !== key.logEpoch ||
443
- record.table !== key.table ||
444
- record.schemaVersion !== key.schemaVersion ||
445
- record.mediaType !== key.mediaType ||
446
- record.scopeDigest !== key.scopeDigest ||
447
- record.asOfCommitSeq !== key.asOfCommitSeq ||
448
- record.rowCursor !== null
449
- ) {
450
- return undefined;
451
- }
452
- if (record.expiresAtMs <= nowMs) return undefined;
629
+ // The pointer is keyed by the full context, but the record body is the
630
+ // whole merged entry: select the publication the key names, never the
631
+ // top-level fields or the digest union (§5.1).
632
+ const publication = selectSegmentPublication(record, {
633
+ partition: key.partition,
634
+ logEpoch: key.logEpoch,
635
+ table: key.table,
636
+ schemaVersion: key.schemaVersion,
637
+ mediaType: key.mediaType,
638
+ scopeDigest: key.scopeDigest,
639
+ asOfCommitSeq: key.asOfCommitSeq,
640
+ rowCursor: null,
641
+ nowMs,
642
+ });
643
+ if (publication === undefined) return undefined;
453
644
  // Lifecycle GC may have removed the object while the pointer survived.
454
645
  const head = await this.#request(
455
646
  'HEAD',
@@ -457,7 +648,7 @@ export class S3SegmentStore implements SegmentStore {
457
648
  );
458
649
  if (head === undefined) return undefined;
459
650
  await head.arrayBuffer();
460
- return record;
651
+ return publicationRecord(record, publication);
461
652
  }
462
653
 
463
654
  /**
package/src/schema.ts CHANGED
@@ -228,6 +228,21 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
228
228
  `table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
229
229
  );
230
230
  }
231
+ // §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
232
+ // string every implementation produces identically and local storage
233
+ // resolves without deferring to a SQLite build.
234
+ const primaryKeyColumn = table.columns[primaryKeyIndex];
235
+ if (
236
+ primaryKeyColumn !== undefined &&
237
+ primaryKeyColumn.type !== 'string' &&
238
+ primaryKeyColumn.type !== 'integer' &&
239
+ primaryKeyColumn.type !== 'boolean' &&
240
+ primaryKeyColumn.type !== 'json'
241
+ ) {
242
+ throw new Error(
243
+ `table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`,
244
+ );
245
+ }
231
246
  if (table.scopes.length === 0) {
232
247
  throw new Error(
233
248
  `table ${table.name}: every synced table declares at least one scope pattern (§3.1)`,
@@ -4,8 +4,9 @@
4
4
  * A segment reference is not a bearer capability: every download re-runs
5
5
  * `resolveScopes`, recomputes the effective scopes from the supplied
6
6
  * `X-Syncular-Scopes` requested map, recomputes the scope digest, and
7
- * compares it with the segment's stored digest. Mismatch, revoked status,
8
- * or resolution failure ⇒ `sync.forbidden`.
7
+ * requires that digest to be one of the digests the stored content was
8
+ * published under (§5.5). Mismatch, revoked status, or resolution failure
9
+ * ⇒ `sync.forbidden`.
9
10
  */
10
11
  import type { ScopeMap } from '@syncular/core';
11
12
  import type { SyncRequestContext } from './context';
@@ -18,7 +19,7 @@ import { SyncError, syncError } from './errors';
18
19
  import { emitEvent } from './events';
19
20
  import { compileSchema } from './schema';
20
21
  import { computeEffective, type ResolvedScopes, scopeDigest } from './scopes';
21
- import type { SegmentRecord } from './segment-store';
22
+ import { publicationRecord, type SegmentRecord } from './segment-store';
22
23
 
23
24
  export interface SegmentDownloadRequest {
24
25
  readonly segmentId: string;
@@ -102,34 +103,22 @@ async function downloadSegment(
102
103
  ): Promise<SegmentDownloadResult> {
103
104
  const registry = await touchAuthenticatedPartition(ctx);
104
105
  const entry = await ctx.segments.get(request.segmentId);
105
- if (
106
- entry === undefined ||
107
- entry.record.partition !== ctx.partition ||
108
- entry.record.logEpoch !== registry.logEpoch
109
- ) {
106
+ if (entry === undefined) {
110
107
  throw syncError('sync.not_found', 'unknown segment (§5.5)');
111
108
  }
112
- if (entry.record.expiresAtMs <= clockOf(ctx)()) {
113
- throw syncError(
114
- 'sync.segment_expired',
115
- 'segment TTL elapsed — re-pull to mint fresh descriptors (§5.1)',
116
- );
109
+ // Select the caller's own publication context. A publication under a
110
+ // different partition or a stale log epoch is not this caller's grant, and
111
+ // the entry's existence must not leak across that boundary (§5.5).
112
+ const inContext = entry.record.publications.filter(
113
+ (publication) =>
114
+ publication.partition === ctx.partition &&
115
+ publication.logEpoch === registry.logEpoch,
116
+ );
117
+ if (inContext.length === 0) {
118
+ throw syncError('sync.not_found', 'unknown segment (§5.5)');
117
119
  }
118
120
 
119
- const schema = compileSchema(ctx.schema);
120
- const table = schema.tables.get(entry.record.table);
121
- if (table === undefined) {
122
- throw syncError('sync.not_found', 'segment table no longer served');
123
- }
124
121
  const requested = parseScopesHeader(request.scopesHeader);
125
- for (const [key, values] of Object.entries(requested)) {
126
- if (!table.declaredVariables.has(key) || values.includes('*')) {
127
- throw syncError(
128
- 'sync.invalid_subscription',
129
- 'invalid requested scopes (§3.2)',
130
- );
131
- }
132
- }
133
122
 
134
123
  let resolved: ResolvedScopes;
135
124
  try {
@@ -160,12 +149,58 @@ async function downloadSegment(
160
149
  throw syncError('sync.forbidden', 'segment scopes not held (§5.5)');
161
150
  }
162
151
  const digest = await scopeDigest(outcome.effective);
163
- if (digest !== entry.record.scopeDigest) {
152
+ // The digest is the grant, but only inside the caller's partition and live
153
+ // epoch: identical bytes published elsewhere never authorize this caller.
154
+ const forDigest = inContext.filter(
155
+ (publication) => publication.scopeDigest === digest,
156
+ );
157
+ if (forDigest.length === 0) {
164
158
  throw syncError('sync.forbidden', 'scope digest mismatch (§3.5, §5.5)');
165
159
  }
160
+ // TTL is per publication: an unrelated refresh of another publication must
161
+ // not extend this grant, and an expired old pin must not shadow a live one.
162
+ const now = clockOf(ctx)();
163
+ const live = forDigest.filter((publication) => publication.expiresAtMs > now);
164
+ if (live.length === 0) {
165
+ throw syncError(
166
+ 'sync.segment_expired',
167
+ 'segment TTL elapsed — re-pull to mint fresh descriptors (§5.1)',
168
+ );
169
+ }
170
+ // Choose among the live candidates that the compiled schema serves and that
171
+ // declare every requested scope. A malformed or custom record whose newer
172
+ // publication names another table must not fail the download while an
173
+ // earlier valid publication of the same bytes exists.
174
+ const schema = compileSchema(ctx.schema);
175
+ const candidates = live.filter((publication) => {
176
+ const table = schema.tables.get(publication.table);
177
+ return (
178
+ table !== undefined &&
179
+ Object.entries(requested).every(
180
+ ([key, values]) =>
181
+ table.declaredVariables.has(key) && !values.includes('*'),
182
+ )
183
+ );
184
+ });
185
+ if (candidates.length === 0) {
186
+ const served = live.some((publication) =>
187
+ schema.tables.has(publication.table),
188
+ );
189
+ if (!served) {
190
+ throw syncError('sync.not_found', 'segment table no longer served');
191
+ }
192
+ throw syncError(
193
+ 'sync.invalid_subscription',
194
+ 'invalid requested scopes (§3.2)',
195
+ );
196
+ }
197
+ const publication = candidates[candidates.length - 1]!;
166
198
 
167
199
  return {
168
- record: entry.record,
200
+ // The returned record is projected onto the selected publication, so its
201
+ // `scopeDigests`/`publications` compatibility view cannot union a grant
202
+ // from another partition (§5.1).
203
+ record: publicationRecord(entry.record, publication),
169
204
  bytes: entry.bytes,
170
205
  headers: {
171
206
  'Content-Type': 'application/octet-stream',