@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.
@@ -0,0 +1,164 @@
1
+ import { EMPTY_PAYLOAD_SHA256, sha256Hex, signRequest, } from './sigv4.js';
2
+ const META_HEADER_PREFIX = 'x-amz-meta-';
3
+ /** User metadata carried as `x-amz-meta-*` headers (lowercased by `Headers`). */
4
+ function metadataOf(headers) {
5
+ const metadata = {};
6
+ for (const [name, value] of headers) {
7
+ if (name.startsWith(META_HEADER_PREFIX)) {
8
+ metadata[name.slice(META_HEADER_PREFIX.length)] = value;
9
+ }
10
+ }
11
+ return metadata;
12
+ }
13
+ function sizeOf(headers) {
14
+ const value = headers.get('content-length');
15
+ if (value === null)
16
+ return undefined;
17
+ const size = Number(value);
18
+ return Number.isFinite(size) ? size : undefined;
19
+ }
20
+ /**
21
+ * Extract `<Key>` values (and the `<NextContinuationToken>`, if the listing
22
+ * is truncated) from a `ListObjectsV2` XML body. A tiny regex reader; the
23
+ * only XML the stores ever parse, and only on the GC path. `ListObjectsV2`
24
+ * returns keys XML-escaped for `& < >`; we unescape those.
25
+ */
26
+ function parseListObjectsV2(xml) {
27
+ const objects = [];
28
+ for (const m of xml.matchAll(/<Key>([\s\S]*?)<\/Key>/g)) {
29
+ objects.push({ key: unescapeXml(m[1] ?? '') });
30
+ }
31
+ const truncated = /<IsTruncated>\s*true\s*<\/IsTruncated>/i.test(xml);
32
+ const tokenMatch = /<NextContinuationToken>([\s\S]*?)<\/NextContinuationToken>/.exec(xml);
33
+ const nextToken = truncated && tokenMatch !== null
34
+ ? unescapeXml(tokenMatch[1] ?? '')
35
+ : undefined;
36
+ return { objects, nextToken };
37
+ }
38
+ function unescapeXml(value) {
39
+ return value
40
+ .replaceAll('&lt;', '<')
41
+ .replaceAll('&gt;', '>')
42
+ .replaceAll('&quot;', '"')
43
+ .replaceAll('&apos;', "'")
44
+ .replaceAll('&amp;', '&');
45
+ }
46
+ export class S3ObjectBucket {
47
+ #config;
48
+ #credentials;
49
+ constructor(config) {
50
+ this.#config = config;
51
+ this.#credentials = {
52
+ accessKeyId: config.accessKeyId,
53
+ secretAccessKey: config.secretAccessKey,
54
+ ...(config.sessionToken !== undefined
55
+ ? { sessionToken: config.sessionToken }
56
+ : {}),
57
+ };
58
+ }
59
+ /** The object URL the presign helpers sign (`{endpoint}/{bucket}/{key}`). */
60
+ urlFor(key) {
61
+ const endpoint = this.#config.endpoint.replace(/\/+$/, '');
62
+ return new URL(`${endpoint}/${this.#config.bucket}/${key}`);
63
+ }
64
+ async #request(method, key, options) {
65
+ const url = this.urlFor(key);
66
+ if (options?.query !== undefined) {
67
+ for (const [name, value] of options.query) {
68
+ url.searchParams.set(name, value);
69
+ }
70
+ }
71
+ const body = options?.body;
72
+ const headers = await signRequest({
73
+ method,
74
+ url,
75
+ region: this.#config.region,
76
+ credentials: this.#credentials,
77
+ nowMs: Date.now(),
78
+ payloadHash: body === undefined ? EMPTY_PAYLOAD_SHA256 : await sha256Hex(body),
79
+ ...(options?.headers !== undefined ? { headers: options.headers } : {}),
80
+ });
81
+ const response = await fetch(url, {
82
+ method,
83
+ headers,
84
+ ...(body !== undefined ? { body: body } : {}),
85
+ });
86
+ if (response.status === 404) {
87
+ await response.arrayBuffer();
88
+ return undefined;
89
+ }
90
+ if (options?.allow412 === true && response.status === 412)
91
+ return response;
92
+ if (!response.ok) {
93
+ const detail = (await response.text()).slice(0, 300);
94
+ throw new Error(`S3ObjectBucket: ${method} ${key} failed with ${response.status}: ${detail}`);
95
+ }
96
+ return response;
97
+ }
98
+ async get(key) {
99
+ const response = await this.#request('GET', key);
100
+ if (response === undefined)
101
+ return undefined;
102
+ return {
103
+ body: response.body,
104
+ etag: response.headers.get('etag') ?? undefined,
105
+ size: sizeOf(response.headers),
106
+ metadata: metadataOf(response.headers),
107
+ arrayBuffer: () => response.arrayBuffer(),
108
+ text: () => response.text(),
109
+ };
110
+ }
111
+ async head(key) {
112
+ const response = await this.#request('HEAD', key);
113
+ if (response === undefined)
114
+ return undefined;
115
+ await response.arrayBuffer();
116
+ return {
117
+ etag: response.headers.get('etag') ?? undefined,
118
+ size: sizeOf(response.headers),
119
+ metadata: metadataOf(response.headers),
120
+ };
121
+ }
122
+ async put(key, options) {
123
+ const headers = {
124
+ 'content-type': options.contentType,
125
+ };
126
+ if (options.createOnly === true)
127
+ headers['if-none-match'] = '*';
128
+ if (options.ifMatch !== undefined)
129
+ headers['if-match'] = options.ifMatch;
130
+ for (const [name, value] of Object.entries(options.metadata ?? {})) {
131
+ headers[`${META_HEADER_PREFIX}${name}`] = value;
132
+ }
133
+ const response = await this.#request('PUT', key, {
134
+ body: options.body,
135
+ headers,
136
+ allow412: true,
137
+ });
138
+ if (response === undefined) {
139
+ throw new Error(`S3ObjectBucket: PUT ${key} failed with 404`);
140
+ }
141
+ await response.arrayBuffer();
142
+ // A failed `If-Match` / `If-None-Match: *` is a `412`: the caller's CAS
143
+ // read superseded, so it re-reads instead of overwriting.
144
+ return response.status !== 412;
145
+ }
146
+ async delete(key) {
147
+ const response = await this.#request('DELETE', key);
148
+ await response?.arrayBuffer();
149
+ }
150
+ async list(prefix, cursor) {
151
+ const query = [
152
+ ['list-type', '2'],
153
+ ['prefix', prefix],
154
+ ];
155
+ if (cursor !== undefined)
156
+ query.push(['continuation-token', cursor]);
157
+ // ListObjectsV2 is a bucket-level GET (`GET /{bucket}?list-type=2`).
158
+ const response = await this.#request('GET', '', { query });
159
+ if (response === undefined)
160
+ return { keys: [], cursor: undefined };
161
+ const { objects, nextToken } = parseListObjectsV2(await response.text());
162
+ return { keys: objects.map((object) => object.key), cursor: nextToken };
163
+ }
164
+ }
@@ -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