@syncular/server 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +7 -1
- package/dist/blob-handlers.js +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +11 -3
- package/dist/d1-storage.js +84 -2
- package/dist/errors.js +10 -0
- package/dist/handler.js +50 -4
- package/dist/operations.js +3 -3
- package/dist/pg-executor.d.ts +11 -1
- package/dist/postgres-storage.d.ts +20 -5
- package/dist/postgres-storage.js +345 -9
- package/dist/pull.js +9 -5
- package/dist/push.js +18 -0
- package/dist/readiness.d.ts +9 -2
- package/dist/readiness.js +27 -2
- package/dist/realtime.d.ts +40 -1
- package/dist/realtime.js +116 -19
- package/dist/relational-rows.d.ts +20 -0
- package/dist/relational-rows.js +29 -0
- package/dist/s3-segment-store.d.ts +20 -8
- package/dist/s3-segment-store.js +216 -68
- package/dist/schema.js +11 -0
- package/dist/segment-download.d.ts +1 -1
- package/dist/segment-download.js +50 -17
- package/dist/segment-store.d.ts +86 -0
- package/dist/segment-store.js +156 -17
- package/dist/signed-url.d.ts +7 -1
- package/dist/signed-url.js +10 -1
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +23 -0
- package/dist/sqlite-segment-store.js +153 -42
- package/dist/sqlite-storage.d.ts +11 -3
- package/dist/sqlite-storage.js +346 -34
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +4 -0
- package/dist/storage.d.ts +152 -2
- package/dist/storage.js +40 -1
- package/dist/validate.d.ts +20 -1
- package/package.json +2 -2
- package/src/admin.ts +13 -1
- package/src/blob-handlers.ts +1 -1
- package/src/context.ts +8 -0
- package/src/d1-storage.ts +143 -1
- package/src/errors.ts +10 -0
- package/src/handler.ts +62 -4
- package/src/operations.ts +17 -13
- package/src/pg-executor.ts +11 -1
- package/src/postgres-storage.ts +520 -5
- package/src/pull.ts +9 -1
- package/src/push.ts +18 -0
- package/src/readiness.ts +39 -4
- package/src/realtime.ts +117 -18
- package/src/relational-rows.ts +36 -0
- package/src/s3-segment-store.ts +275 -84
- package/src/schema.ts +15 -0
- package/src/segment-download.ts +79 -28
- package/src/segment-store.ts +250 -18
- package/src/signed-url.ts +12 -2
- package/src/sqlite-dialect.ts +23 -0
- package/src/sqlite-segment-store.ts +245 -65
- package/src/sqlite-storage.ts +494 -36
- package/src/storage-errors.ts +13 -1
- package/src/storage.ts +243 -1
- package/src/validate.ts +20 -1
package/src/s3-segment-store.ts
CHANGED
|
@@ -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
|
|
10
|
-
* content-addressed bytes so presigned GETs serve them
|
|
11
|
-
* the client's §5.1 hash check passes).
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
26
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
117
|
-
// header is
|
|
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
|
-
|
|
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
|
|
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 <
|
|
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 `
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
555
|
+
'if-none-match': '*',
|
|
391
556
|
},
|
|
557
|
+
allow412: true,
|
|
392
558
|
});
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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)`,
|
package/src/segment-download.ts
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
* or resolution failure
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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,74 @@ 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
|
-
|
|
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]!;
|
|
198
|
+
|
|
199
|
+
// RFC 0007: a descriptor minted before this partition declared a backfill
|
|
200
|
+
// checkpoint is not a complete window once the projection becomes
|
|
201
|
+
// checkpointed. SPEC §9 mints segments per schema version, so refuse and
|
|
202
|
+
// let the client re-pull for a fresh descriptor.
|
|
203
|
+
const checkpoints = await ctx.storage.readCheckpoints(ctx.partition);
|
|
204
|
+
if (
|
|
205
|
+
checkpoints.some(
|
|
206
|
+
(checkpoint) => checkpoint.schemaVersion > publication.schemaVersion,
|
|
207
|
+
)
|
|
208
|
+
) {
|
|
209
|
+
throw syncError(
|
|
210
|
+
'sync.segment_expired',
|
|
211
|
+
'segment predates the partition backfill checkpoint — re-pull (§5.1)',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
166
214
|
|
|
167
215
|
return {
|
|
168
|
-
record
|
|
216
|
+
// The returned record is projected onto the selected publication, so its
|
|
217
|
+
// `scopeDigests`/`publications` compatibility view cannot union a grant
|
|
218
|
+
// from another partition (§5.1).
|
|
219
|
+
record: publicationRecord(entry.record, publication),
|
|
169
220
|
bytes: entry.bytes,
|
|
170
221
|
headers: {
|
|
171
222
|
'Content-Type': 'application/octet-stream',
|