@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.
- package/dist/blob-handlers.js +3 -3
- package/dist/bucket-blob-store.d.ts +77 -0
- package/dist/bucket-blob-store.js +266 -0
- package/dist/bucket-segment-store.d.ts +77 -0
- package/dist/bucket-segment-store.js +445 -0
- package/dist/context.d.ts +7 -0
- package/dist/handler.js +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/object-bucket.d.ts +78 -0
- package/dist/object-bucket.js +18 -0
- package/dist/operations-realtime.js +3 -0
- package/dist/realtime.d.ts +11 -0
- package/dist/realtime.js +25 -58
- package/dist/s3-blob-store.d.ts +9 -64
- package/dist/s3-blob-store.js +25 -359
- package/dist/s3-object-bucket.d.ts +39 -0
- package/dist/s3-object-bucket.js +164 -0
- package/dist/s3-segment-store.d.ts +9 -61
- package/dist/s3-segment-store.js +25 -503
- package/package.json +2 -2
- package/src/blob-handlers.ts +4 -4
- package/src/bucket-blob-store.ts +351 -0
- package/src/bucket-segment-store.ts +582 -0
- package/src/context.ts +10 -0
- package/src/handler.ts +11 -0
- package/src/index.ts +4 -0
- package/src/object-bucket.ts +83 -0
- package/src/operations-realtime.ts +7 -0
- package/src/realtime.ts +29 -58
- package/src/s3-blob-store.ts +25 -466
- package/src/s3-object-bucket.ts +233 -0
- package/src/s3-segment-store.ts +25 -646
|
@@ -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('<', '<')
|
|
41
|
+
.replaceAll('>', '>')
|
|
42
|
+
.replaceAll('"', '"')
|
|
43
|
+
.replaceAll(''', "'")
|
|
44
|
+
.replaceAll('&', '&');
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
|
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
|