@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,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed blob store over the `ObjectBucket` seam (§5.9.2 durable
|
|
3
|
+
* objects) — the store logic shared by `S3BlobStore` (S3 REST + SigV4) and
|
|
4
|
+
* `R2BlobStore` (`@syncular/server-workers`, the R2 binding). The blob twin of
|
|
5
|
+
* `bucket-segment-store.ts`: same content-addressed key layout, same
|
|
6
|
+
* LIST-free-on-the-hot-path CAS stats accumulator.
|
|
7
|
+
*
|
|
8
|
+
* Key layout (deterministic — every point lookup is a GET/HEAD, never a LIST):
|
|
9
|
+
*
|
|
10
|
+
* {keyPrefix}blob/{partition}/sha256/{hex}
|
|
11
|
+
* The blob bytes, VERBATIM (the object body MUST be exactly the
|
|
12
|
+
* content-addressed bytes so a presigned GET or the inline download route
|
|
13
|
+
* serves them directly and the client's §5.9.1 hash check passes — blobs
|
|
14
|
+
* are never re-compressed at rest, §5.8/§5.9.5). The `byteLength` +
|
|
15
|
+
* optional `mediaType` ride along as user metadata `syncular-blob` =
|
|
16
|
+
* base64url(JSON), so `get` is a single GET. `createdAtMs` is stored there
|
|
17
|
+
* too so the orphan sweep (§5.9.2) can read the upload age without a
|
|
18
|
+
* separate index.
|
|
19
|
+
*
|
|
20
|
+
* DURABILITY vs SEGMENT TTL — the honest interface difference (§5.9.2): a
|
|
21
|
+
* blob referenced by a live row must stay downloadable INDEFINITELY. So,
|
|
22
|
+
* unlike the segment store, this store writes **no `expiresAtMs`, no bucket
|
|
23
|
+
* lifecycle-expiration mapping, and no TTL config**. Expiry is
|
|
24
|
+
* REFERENCE-driven, not time-driven: reclamation is the host-scheduled
|
|
25
|
+
* `sweepOrphanBlobs` (blob-store.ts) deleting only objects no live row
|
|
26
|
+
* references after a grace period — never a bucket lifecycle rule. Do NOT
|
|
27
|
+
* put a lifecycle expiration on the `blob/` prefix; it would delete
|
|
28
|
+
* still-referenced attachments.
|
|
29
|
+
*
|
|
30
|
+
* The orphan sweep is the ONLY operation that LISTs: `sweepOrphans` pages a
|
|
31
|
+
* prefix listing over the `blob/` prefix (an admin/GC path, off the hot path)
|
|
32
|
+
* to find candidates, reads each object's `createdAtMs` from its metadata for
|
|
33
|
+
* the grace check, and DELETEs the unreferenced-and-old ones.
|
|
34
|
+
*/
|
|
35
|
+
import type { BlobRecord, BlobStore, BlobStoreStats } from './blob-store';
|
|
36
|
+
import type { ObjectBucket } from './object-bucket';
|
|
37
|
+
|
|
38
|
+
export interface BucketBlobStoreOptions {
|
|
39
|
+
/** Key namespace inside the bucket, e.g. `syncular/`. Default: none. */
|
|
40
|
+
readonly keyPrefix?: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const BLOB_ID_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
44
|
+
const BLOB_META_KEY = 'syncular-blob';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Stats accumulator (mirrors the segment store): a small counter object under
|
|
48
|
+
* a fixed key, updated read-modify-write on `put` under an ETag
|
|
49
|
+
* compare-and-swap. A LIST-free point-lookup store cannot count objects on
|
|
50
|
+
* demand cheaply, so `stats()` reads the accumulator. The counters are
|
|
51
|
+
* APPROXIMATE — a crash between the blob PUT and the accumulator CAS, or a
|
|
52
|
+
* sweep DELETE the accumulator does not decrement, drifts them — so the
|
|
53
|
+
* admin surface labels these stats `approximate: true`. A health gauge,
|
|
54
|
+
* not an invoice.
|
|
55
|
+
*/
|
|
56
|
+
const STATS_KEY_SUFFIX = 'stats/blobs.json';
|
|
57
|
+
const STATS_CAS_ATTEMPTS = 16;
|
|
58
|
+
|
|
59
|
+
interface StatsAccumulator {
|
|
60
|
+
count: number;
|
|
61
|
+
bytes: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function emptyStats(): StatsAccumulator {
|
|
65
|
+
return { count: 0, bytes: 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseStatsJson(json: string): StatsAccumulator {
|
|
69
|
+
const parsed = JSON.parse(json) as Partial<StatsAccumulator>;
|
|
70
|
+
return {
|
|
71
|
+
count: Number(parsed.count ?? 0),
|
|
72
|
+
bytes: Number(parsed.bytes ?? 0),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Runtime-neutral base64url: `Buffer` is not present on
|
|
77
|
+
// Cloudflare Workers without `nodejs_compat`, so the metadata header is
|
|
78
|
+
// (de)coded with `btoa`/`atob`, available in every runtime.
|
|
79
|
+
function utf8ToBase64url(text: string): string {
|
|
80
|
+
let binary = '';
|
|
81
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
82
|
+
binary += String.fromCharCode(byte);
|
|
83
|
+
}
|
|
84
|
+
return btoa(binary)
|
|
85
|
+
.replaceAll('+', '-')
|
|
86
|
+
.replaceAll('/', '_')
|
|
87
|
+
.replace(/=+$/, '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function base64urlToUtf8(value: string): string {
|
|
91
|
+
const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
|
|
92
|
+
const bytes = new Uint8Array(binary.length);
|
|
93
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
94
|
+
return new TextDecoder().decode(bytes);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface BlobMeta {
|
|
98
|
+
readonly byteLength: number;
|
|
99
|
+
readonly mediaType?: string;
|
|
100
|
+
readonly createdAtMs: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function metaToJson(meta: BlobMeta): string {
|
|
104
|
+
return JSON.stringify(meta);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseMetaJson(json: string, source: string, label: string): BlobMeta {
|
|
108
|
+
let parsed: unknown;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(json);
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error(`${label}: corrupt blob metadata in ${source}`);
|
|
113
|
+
}
|
|
114
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
115
|
+
throw new Error(`${label}: corrupt blob metadata in ${source}`);
|
|
116
|
+
}
|
|
117
|
+
const r = parsed as Record<string, unknown>;
|
|
118
|
+
if (typeof r.byteLength !== 'number' || typeof r.createdAtMs !== 'number') {
|
|
119
|
+
throw new Error(`${label}: bad blob metadata in ${source}`);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
byteLength: r.byteLength,
|
|
123
|
+
...(typeof r.mediaType === 'string' ? { mediaType: r.mediaType } : {}),
|
|
124
|
+
createdAtMs: r.createdAtMs,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class BucketBlobStore implements BlobStore {
|
|
129
|
+
readonly #bucket: ObjectBucket;
|
|
130
|
+
readonly #prefix: string;
|
|
131
|
+
/** Error prefix: the concrete store class the host constructed. */
|
|
132
|
+
readonly #label: string;
|
|
133
|
+
|
|
134
|
+
constructor(bucket: ObjectBucket, options?: BucketBlobStoreOptions) {
|
|
135
|
+
this.#bucket = bucket;
|
|
136
|
+
this.#prefix = options?.keyPrefix ?? '';
|
|
137
|
+
this.#label = this.constructor.name;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `{keyPrefix}blob/{partition}/sha256/{hex}` — the §5.9.5 "signed key embeds
|
|
142
|
+
* the blobId". Blobs are content-addressed, but download authorization and
|
|
143
|
+
* the reference index are per-partition, so the same bytes uploaded under
|
|
144
|
+
* two partitions are two objects (a partition MUST NOT read another's
|
|
145
|
+
* attachment by guessing a content address). The partition is folded into
|
|
146
|
+
* the key as a path segment.
|
|
147
|
+
*/
|
|
148
|
+
objectKeyFor(partition: string, blobId: string): string {
|
|
149
|
+
return `${this.#prefix}blob/${partition}/${blobId.replace(':', '/')}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#statsKey(): string {
|
|
153
|
+
return `${this.#prefix}${STATS_KEY_SUFFIX}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #readStats(): Promise<{
|
|
157
|
+
stats: StatsAccumulator;
|
|
158
|
+
etag: string | undefined;
|
|
159
|
+
}> {
|
|
160
|
+
const object = await this.#bucket.get(this.#statsKey());
|
|
161
|
+
if (object === undefined) {
|
|
162
|
+
return { stats: emptyStats(), etag: undefined };
|
|
163
|
+
}
|
|
164
|
+
return { stats: parseStatsJson(await object.text()), etag: object.etag };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Conditionally write the stats accumulator. `etag` present ⇒ `ifMatch`
|
|
169
|
+
* (update the object we read); absent ⇒ `createOnly` (create only). Returns
|
|
170
|
+
* `false` on a failed precondition so the caller retries.
|
|
171
|
+
*/
|
|
172
|
+
async #writeStatsCas(
|
|
173
|
+
stats: StatsAccumulator,
|
|
174
|
+
etag: string | undefined,
|
|
175
|
+
): Promise<boolean> {
|
|
176
|
+
return this.#bucket.put(this.#statsKey(), {
|
|
177
|
+
body: new TextEncoder().encode(JSON.stringify(stats)),
|
|
178
|
+
contentType: 'application/json',
|
|
179
|
+
...(etag === undefined ? { createOnly: true } : { ifMatch: etag }),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Fold `delta` into the stats accumulator under an ETag CAS retry loop.
|
|
185
|
+
* Best-effort: exhausting the budget leaves the counters drifted (they are
|
|
186
|
+
* documented APPROXIMATE) rather than failing an op that already touched
|
|
187
|
+
* the bytes.
|
|
188
|
+
*/
|
|
189
|
+
async #bumpStats(delta: StatsAccumulator): Promise<void> {
|
|
190
|
+
for (let attempt = 0; attempt < STATS_CAS_ATTEMPTS; attempt++) {
|
|
191
|
+
const { stats, etag } = await this.#readStats();
|
|
192
|
+
const next: StatsAccumulator = {
|
|
193
|
+
count: stats.count + delta.count,
|
|
194
|
+
bytes: stats.bytes + delta.bytes,
|
|
195
|
+
};
|
|
196
|
+
if (await this.#writeStatsCas(next, etag)) return;
|
|
197
|
+
}
|
|
198
|
+
// Lost the CAS race `STATS_CAS_ATTEMPTS` times — leave counters as-is;
|
|
199
|
+
// `stats()` stays APPROXIMATE by contract.
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async put(
|
|
203
|
+
partition: string,
|
|
204
|
+
blobId: string,
|
|
205
|
+
bytes: Uint8Array,
|
|
206
|
+
nowMs: number,
|
|
207
|
+
mediaType?: string,
|
|
208
|
+
): Promise<BlobRecord> {
|
|
209
|
+
const record: BlobRecord = {
|
|
210
|
+
blobId,
|
|
211
|
+
partition,
|
|
212
|
+
byteLength: bytes.length,
|
|
213
|
+
...(mediaType !== undefined ? { mediaType } : {}),
|
|
214
|
+
createdAtMs: nowMs,
|
|
215
|
+
};
|
|
216
|
+
const objectKey = this.objectKeyFor(partition, blobId);
|
|
217
|
+
// Idempotent re-put (same content-address ⇒ same key): count each distinct
|
|
218
|
+
// blob once, and preserve the FIRST createdAtMs so the sweep's grace clock
|
|
219
|
+
// is not reset by a re-upload (§5.9.2 upload age).
|
|
220
|
+
const preexisting = await this.#bucket.head(objectKey);
|
|
221
|
+
if (preexisting !== undefined) {
|
|
222
|
+
const meta = preexisting.metadata[BLOB_META_KEY];
|
|
223
|
+
if (meta !== undefined) {
|
|
224
|
+
const existing = parseMetaJson(
|
|
225
|
+
base64urlToUtf8(meta),
|
|
226
|
+
blobId,
|
|
227
|
+
this.#label,
|
|
228
|
+
);
|
|
229
|
+
return {
|
|
230
|
+
blobId,
|
|
231
|
+
partition,
|
|
232
|
+
byteLength: existing.byteLength,
|
|
233
|
+
...(existing.mediaType !== undefined
|
|
234
|
+
? { mediaType: existing.mediaType }
|
|
235
|
+
: {}),
|
|
236
|
+
createdAtMs: existing.createdAtMs,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const meta: BlobMeta = {
|
|
241
|
+
byteLength: bytes.length,
|
|
242
|
+
...(mediaType !== undefined ? { mediaType } : {}),
|
|
243
|
+
createdAtMs: nowMs,
|
|
244
|
+
};
|
|
245
|
+
// Conditional write: of two concurrent uploads of the same blob exactly
|
|
246
|
+
// one lands, so the first createdAtMs survives and the blob counts once.
|
|
247
|
+
// The loser re-reads and returns the winner's record.
|
|
248
|
+
const written = await this.#bucket.put(objectKey, {
|
|
249
|
+
body: bytes,
|
|
250
|
+
contentType: mediaType ?? 'application/octet-stream',
|
|
251
|
+
metadata: { [BLOB_META_KEY]: utf8ToBase64url(metaToJson(meta)) },
|
|
252
|
+
...(preexisting === undefined
|
|
253
|
+
? { createOnly: true }
|
|
254
|
+
: { ifMatch: preexisting.etag }),
|
|
255
|
+
});
|
|
256
|
+
if (!written) return this.put(partition, blobId, bytes, nowMs, mediaType);
|
|
257
|
+
if (preexisting === undefined)
|
|
258
|
+
await this.#bumpStats({ count: 1, bytes: bytes.length });
|
|
259
|
+
return record;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async has(partition: string, blobId: string): Promise<boolean> {
|
|
263
|
+
if (!BLOB_ID_PATTERN.test(blobId)) return false;
|
|
264
|
+
const head = await this.#bucket.head(this.objectKeyFor(partition, blobId));
|
|
265
|
+
return head !== undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async get(
|
|
269
|
+
partition: string,
|
|
270
|
+
blobId: string,
|
|
271
|
+
): Promise<{ record: BlobRecord; bytes: Uint8Array } | undefined> {
|
|
272
|
+
if (!BLOB_ID_PATTERN.test(blobId)) return undefined;
|
|
273
|
+
const object = await this.#bucket.get(this.objectKeyFor(partition, blobId));
|
|
274
|
+
if (object === undefined) return undefined;
|
|
275
|
+
const bytes = new Uint8Array(await object.arrayBuffer());
|
|
276
|
+
const rawMeta = object.metadata[BLOB_META_KEY];
|
|
277
|
+
const meta =
|
|
278
|
+
rawMeta !== undefined
|
|
279
|
+
? parseMetaJson(base64urlToUtf8(rawMeta), blobId, this.#label)
|
|
280
|
+
: { byteLength: bytes.length, createdAtMs: 0 };
|
|
281
|
+
return {
|
|
282
|
+
record: {
|
|
283
|
+
blobId,
|
|
284
|
+
partition,
|
|
285
|
+
byteLength: meta.byteLength,
|
|
286
|
+
...(meta.mediaType !== undefined ? { mediaType: meta.mediaType } : {}),
|
|
287
|
+
createdAtMs: meta.createdAtMs,
|
|
288
|
+
},
|
|
289
|
+
bytes,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* §5.9.2 orphan sweep — the ONLY LISTing operation. Page the `{prefix}blob/
|
|
295
|
+
* {partition}/` prefix, HEAD each object for its `createdAtMs`, and DELETE
|
|
296
|
+
* the ones both older than `olderThanMs` AND absent from
|
|
297
|
+
* `referencedBlobIds`. Returns the deleted blobIds. Never deletes a
|
|
298
|
+
* referenced blob (§5.9.2). Off the hot path — a host-scheduled GC job.
|
|
299
|
+
*/
|
|
300
|
+
async sweepOrphans(
|
|
301
|
+
partition: string,
|
|
302
|
+
olderThanMs: number,
|
|
303
|
+
referencedBlobIds: ReadonlySet<string>,
|
|
304
|
+
): Promise<string[]> {
|
|
305
|
+
const listPrefix = `${this.#prefix}blob/${partition}/`;
|
|
306
|
+
const swept: string[] = [];
|
|
307
|
+
let deletedBytes = 0;
|
|
308
|
+
let cursor: string | undefined;
|
|
309
|
+
do {
|
|
310
|
+
const page = await this.#bucket.list(listPrefix, cursor);
|
|
311
|
+
cursor = page.cursor;
|
|
312
|
+
for (const key of page.keys) {
|
|
313
|
+
// `{listPrefix}sha256/{hex}` → `sha256:{hex}`.
|
|
314
|
+
const rest = key.slice(listPrefix.length);
|
|
315
|
+
if (!rest.startsWith('sha256/')) continue;
|
|
316
|
+
const blobId = `sha256:${rest.slice('sha256/'.length)}`;
|
|
317
|
+
if (!BLOB_ID_PATTERN.test(blobId)) continue;
|
|
318
|
+
if (referencedBlobIds.has(blobId)) continue;
|
|
319
|
+
const head = await this.#bucket.head(key);
|
|
320
|
+
if (head === undefined) continue;
|
|
321
|
+
const rawMeta = head.metadata[BLOB_META_KEY];
|
|
322
|
+
const meta =
|
|
323
|
+
rawMeta !== undefined
|
|
324
|
+
? parseMetaJson(base64urlToUtf8(rawMeta), blobId, this.#label)
|
|
325
|
+
: { byteLength: head.size ?? 0, createdAtMs: 0 };
|
|
326
|
+
if (meta.createdAtMs >= olderThanMs) continue;
|
|
327
|
+
await this.#bucket.delete(key);
|
|
328
|
+
swept.push(blobId);
|
|
329
|
+
deletedBytes += meta.byteLength;
|
|
330
|
+
}
|
|
331
|
+
} while (cursor !== undefined);
|
|
332
|
+
if (swept.length > 0) {
|
|
333
|
+
await this.#bumpStats({ count: -swept.length, bytes: -deletedBytes });
|
|
334
|
+
}
|
|
335
|
+
return swept;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Store-wide counters from the pointer-object accumulator (never a LIST on
|
|
340
|
+
* the read path). `approximate: true` for the same reasons the segment
|
|
341
|
+
* store documents: best-effort ETag-CAS maintenance and sweep deletes that
|
|
342
|
+
* may miss the decrement. Exact within a single writer; a health gauge
|
|
343
|
+
* under concurrency. Note this is store-WIDE, not partition-scoped, so a
|
|
344
|
+
* shared bucket reports the whole bucket — the parameter is accepted for
|
|
345
|
+
* interface parity but the accumulator is one object per store.
|
|
346
|
+
*/
|
|
347
|
+
async stats(_partition: string): Promise<BlobStoreStats> {
|
|
348
|
+
const { stats } = await this.#readStats();
|
|
349
|
+
return { count: stats.count, bytes: stats.bytes, approximate: true };
|
|
350
|
+
}
|
|
351
|
+
}
|