@syncular/server 0.32.1 → 0.34.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/d1-storage.js +2 -2
- 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/operations.d.ts +20 -3
- package/dist/operations.js +23 -3
- package/dist/realtime.d.ts +11 -0
- package/dist/realtime.js +25 -58
- package/dist/relational-rows.d.ts +6 -3
- package/dist/relational-rows.js +8 -9
- 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/dist/sqlite-storage.js +3 -8
- 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/d1-storage.ts +3 -3
- 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/operations.ts +52 -5
- package/src/realtime.ts +29 -58
- package/src/relational-rows.ts +8 -11
- package/src/s3-blob-store.ts +25 -466
- package/src/s3-object-bucket.ts +233 -0
- package/src/s3-segment-store.ts +25 -646
- package/src/sqlite-storage.ts +3 -10
package/dist/blob-handlers.js
CHANGED
|
@@ -100,7 +100,6 @@ async function downloadBlob(ctx, blobId) {
|
|
|
100
100
|
// the bytes. Inline delivery loads the bytes; presigned delivery does not.
|
|
101
101
|
const presignConfigured = ctx.blobSignedUrls !== undefined;
|
|
102
102
|
let entry;
|
|
103
|
-
let mediaType;
|
|
104
103
|
if (presignConfigured) {
|
|
105
104
|
if (!(await store.has(ctx.partition, blobId))) {
|
|
106
105
|
throw syncError('blob.not_found', 'unknown blob (§5.9.5)');
|
|
@@ -111,7 +110,6 @@ async function downloadBlob(ctx, blobId) {
|
|
|
111
110
|
if (entry === undefined) {
|
|
112
111
|
throw syncError('blob.not_found', 'unknown blob (§5.9.5)');
|
|
113
112
|
}
|
|
114
|
-
mediaType = entry.record.mediaType;
|
|
115
113
|
}
|
|
116
114
|
// §5.9.5 authorization: resolve once, then test referencing rows.
|
|
117
115
|
const listRows = ctx.storage.listRowsReferencingBlob;
|
|
@@ -191,10 +189,12 @@ async function downloadBlob(ctx, blobId) {
|
|
|
191
189
|
// Unreachable: presign not configured ⇒ `entry` was loaded above.
|
|
192
190
|
throw syncError('blob.not_found', 'blob bytes unavailable (§5.9.5)');
|
|
193
191
|
}
|
|
192
|
+
// §5.9.5: inline bytes are always octet-stream. The stored media type
|
|
193
|
+
// would make a JSON blob indistinguishable from the signed-URL envelope.
|
|
194
194
|
return {
|
|
195
195
|
bytes,
|
|
196
196
|
headers: {
|
|
197
|
-
'Content-Type':
|
|
197
|
+
'Content-Type': 'application/octet-stream',
|
|
198
198
|
ETag: `"${blobId}"`,
|
|
199
199
|
'Cache-Control': 'private, max-age=0',
|
|
200
200
|
Vary: 'Authorization, Accept-Encoding',
|
|
@@ -0,0 +1,77 @@
|
|
|
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.js';
|
|
36
|
+
import type { ObjectBucket } from './object-bucket.js';
|
|
37
|
+
export interface BucketBlobStoreOptions {
|
|
38
|
+
/** Key namespace inside the bucket, e.g. `syncular/`. Default: none. */
|
|
39
|
+
readonly keyPrefix?: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
export declare class BucketBlobStore implements BlobStore {
|
|
42
|
+
#private;
|
|
43
|
+
constructor(bucket: ObjectBucket, options?: BucketBlobStoreOptions);
|
|
44
|
+
/**
|
|
45
|
+
* `{keyPrefix}blob/{partition}/sha256/{hex}` — the §5.9.5 "signed key embeds
|
|
46
|
+
* the blobId". Blobs are content-addressed, but download authorization and
|
|
47
|
+
* the reference index are per-partition, so the same bytes uploaded under
|
|
48
|
+
* two partitions are two objects (a partition MUST NOT read another's
|
|
49
|
+
* attachment by guessing a content address). The partition is folded into
|
|
50
|
+
* the key as a path segment.
|
|
51
|
+
*/
|
|
52
|
+
objectKeyFor(partition: string, blobId: string): string;
|
|
53
|
+
put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise<BlobRecord>;
|
|
54
|
+
has(partition: string, blobId: string): Promise<boolean>;
|
|
55
|
+
get(partition: string, blobId: string): Promise<{
|
|
56
|
+
record: BlobRecord;
|
|
57
|
+
bytes: Uint8Array;
|
|
58
|
+
} | undefined>;
|
|
59
|
+
/**
|
|
60
|
+
* §5.9.2 orphan sweep — the ONLY LISTing operation. Page the `{prefix}blob/
|
|
61
|
+
* {partition}/` prefix, HEAD each object for its `createdAtMs`, and DELETE
|
|
62
|
+
* the ones both older than `olderThanMs` AND absent from
|
|
63
|
+
* `referencedBlobIds`. Returns the deleted blobIds. Never deletes a
|
|
64
|
+
* referenced blob (§5.9.2). Off the hot path — a host-scheduled GC job.
|
|
65
|
+
*/
|
|
66
|
+
sweepOrphans(partition: string, olderThanMs: number, referencedBlobIds: ReadonlySet<string>): Promise<string[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Store-wide counters from the pointer-object accumulator (never a LIST on
|
|
69
|
+
* the read path). `approximate: true` for the same reasons the segment
|
|
70
|
+
* store documents: best-effort ETag-CAS maintenance and sweep deletes that
|
|
71
|
+
* may miss the decrement. Exact within a single writer; a health gauge
|
|
72
|
+
* under concurrency. Note this is store-WIDE, not partition-scoped, so a
|
|
73
|
+
* shared bucket reports the whole bucket — the parameter is accepted for
|
|
74
|
+
* interface parity but the accumulator is one object per store.
|
|
75
|
+
*/
|
|
76
|
+
stats(_partition: string): Promise<BlobStoreStats>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
const BLOB_ID_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
2
|
+
const BLOB_META_KEY = 'syncular-blob';
|
|
3
|
+
/**
|
|
4
|
+
* Stats accumulator (mirrors the segment store): a small counter object under
|
|
5
|
+
* a fixed key, updated read-modify-write on `put` under an ETag
|
|
6
|
+
* compare-and-swap. A LIST-free point-lookup store cannot count objects on
|
|
7
|
+
* demand cheaply, so `stats()` reads the accumulator. The counters are
|
|
8
|
+
* APPROXIMATE — a crash between the blob PUT and the accumulator CAS, or a
|
|
9
|
+
* sweep DELETE the accumulator does not decrement, drifts them — so the
|
|
10
|
+
* admin surface labels these stats `approximate: true`. A health gauge,
|
|
11
|
+
* not an invoice.
|
|
12
|
+
*/
|
|
13
|
+
const STATS_KEY_SUFFIX = 'stats/blobs.json';
|
|
14
|
+
const STATS_CAS_ATTEMPTS = 16;
|
|
15
|
+
function emptyStats() {
|
|
16
|
+
return { count: 0, bytes: 0 };
|
|
17
|
+
}
|
|
18
|
+
function parseStatsJson(json) {
|
|
19
|
+
const parsed = JSON.parse(json);
|
|
20
|
+
return {
|
|
21
|
+
count: Number(parsed.count ?? 0),
|
|
22
|
+
bytes: Number(parsed.bytes ?? 0),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Runtime-neutral base64url: `Buffer` is not present on
|
|
26
|
+
// Cloudflare Workers without `nodejs_compat`, so the metadata header is
|
|
27
|
+
// (de)coded with `btoa`/`atob`, available in every runtime.
|
|
28
|
+
function utf8ToBase64url(text) {
|
|
29
|
+
let binary = '';
|
|
30
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
31
|
+
binary += String.fromCharCode(byte);
|
|
32
|
+
}
|
|
33
|
+
return btoa(binary)
|
|
34
|
+
.replaceAll('+', '-')
|
|
35
|
+
.replaceAll('/', '_')
|
|
36
|
+
.replace(/=+$/, '');
|
|
37
|
+
}
|
|
38
|
+
function base64urlToUtf8(value) {
|
|
39
|
+
const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
|
|
40
|
+
const bytes = new Uint8Array(binary.length);
|
|
41
|
+
for (let i = 0; i < binary.length; i++)
|
|
42
|
+
bytes[i] = binary.charCodeAt(i);
|
|
43
|
+
return new TextDecoder().decode(bytes);
|
|
44
|
+
}
|
|
45
|
+
function metaToJson(meta) {
|
|
46
|
+
return JSON.stringify(meta);
|
|
47
|
+
}
|
|
48
|
+
function parseMetaJson(json, source, label) {
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(json);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error(`${label}: corrupt blob metadata in ${source}`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
57
|
+
throw new Error(`${label}: corrupt blob metadata in ${source}`);
|
|
58
|
+
}
|
|
59
|
+
const r = parsed;
|
|
60
|
+
if (typeof r.byteLength !== 'number' || typeof r.createdAtMs !== 'number') {
|
|
61
|
+
throw new Error(`${label}: bad blob metadata in ${source}`);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
byteLength: r.byteLength,
|
|
65
|
+
...(typeof r.mediaType === 'string' ? { mediaType: r.mediaType } : {}),
|
|
66
|
+
createdAtMs: r.createdAtMs,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export class BucketBlobStore {
|
|
70
|
+
#bucket;
|
|
71
|
+
#prefix;
|
|
72
|
+
/** Error prefix: the concrete store class the host constructed. */
|
|
73
|
+
#label;
|
|
74
|
+
constructor(bucket, options) {
|
|
75
|
+
this.#bucket = bucket;
|
|
76
|
+
this.#prefix = options?.keyPrefix ?? '';
|
|
77
|
+
this.#label = this.constructor.name;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `{keyPrefix}blob/{partition}/sha256/{hex}` — the §5.9.5 "signed key embeds
|
|
81
|
+
* the blobId". Blobs are content-addressed, but download authorization and
|
|
82
|
+
* the reference index are per-partition, so the same bytes uploaded under
|
|
83
|
+
* two partitions are two objects (a partition MUST NOT read another's
|
|
84
|
+
* attachment by guessing a content address). The partition is folded into
|
|
85
|
+
* the key as a path segment.
|
|
86
|
+
*/
|
|
87
|
+
objectKeyFor(partition, blobId) {
|
|
88
|
+
return `${this.#prefix}blob/${partition}/${blobId.replace(':', '/')}`;
|
|
89
|
+
}
|
|
90
|
+
#statsKey() {
|
|
91
|
+
return `${this.#prefix}${STATS_KEY_SUFFIX}`;
|
|
92
|
+
}
|
|
93
|
+
async #readStats() {
|
|
94
|
+
const object = await this.#bucket.get(this.#statsKey());
|
|
95
|
+
if (object === undefined) {
|
|
96
|
+
return { stats: emptyStats(), etag: undefined };
|
|
97
|
+
}
|
|
98
|
+
return { stats: parseStatsJson(await object.text()), etag: object.etag };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Conditionally write the stats accumulator. `etag` present ⇒ `ifMatch`
|
|
102
|
+
* (update the object we read); absent ⇒ `createOnly` (create only). Returns
|
|
103
|
+
* `false` on a failed precondition so the caller retries.
|
|
104
|
+
*/
|
|
105
|
+
async #writeStatsCas(stats, etag) {
|
|
106
|
+
return this.#bucket.put(this.#statsKey(), {
|
|
107
|
+
body: new TextEncoder().encode(JSON.stringify(stats)),
|
|
108
|
+
contentType: 'application/json',
|
|
109
|
+
...(etag === undefined ? { createOnly: true } : { ifMatch: etag }),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Fold `delta` into the stats accumulator under an ETag CAS retry loop.
|
|
114
|
+
* Best-effort: exhausting the budget leaves the counters drifted (they are
|
|
115
|
+
* documented APPROXIMATE) rather than failing an op that already touched
|
|
116
|
+
* the bytes.
|
|
117
|
+
*/
|
|
118
|
+
async #bumpStats(delta) {
|
|
119
|
+
for (let attempt = 0; attempt < STATS_CAS_ATTEMPTS; attempt++) {
|
|
120
|
+
const { stats, etag } = await this.#readStats();
|
|
121
|
+
const next = {
|
|
122
|
+
count: stats.count + delta.count,
|
|
123
|
+
bytes: stats.bytes + delta.bytes,
|
|
124
|
+
};
|
|
125
|
+
if (await this.#writeStatsCas(next, etag))
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Lost the CAS race `STATS_CAS_ATTEMPTS` times — leave counters as-is;
|
|
129
|
+
// `stats()` stays APPROXIMATE by contract.
|
|
130
|
+
}
|
|
131
|
+
async put(partition, blobId, bytes, nowMs, mediaType) {
|
|
132
|
+
const record = {
|
|
133
|
+
blobId,
|
|
134
|
+
partition,
|
|
135
|
+
byteLength: bytes.length,
|
|
136
|
+
...(mediaType !== undefined ? { mediaType } : {}),
|
|
137
|
+
createdAtMs: nowMs,
|
|
138
|
+
};
|
|
139
|
+
const objectKey = this.objectKeyFor(partition, blobId);
|
|
140
|
+
// Idempotent re-put (same content-address ⇒ same key): count each distinct
|
|
141
|
+
// blob once, and preserve the FIRST createdAtMs so the sweep's grace clock
|
|
142
|
+
// is not reset by a re-upload (§5.9.2 upload age).
|
|
143
|
+
const preexisting = await this.#bucket.head(objectKey);
|
|
144
|
+
if (preexisting !== undefined) {
|
|
145
|
+
const meta = preexisting.metadata[BLOB_META_KEY];
|
|
146
|
+
if (meta !== undefined) {
|
|
147
|
+
const existing = parseMetaJson(base64urlToUtf8(meta), blobId, this.#label);
|
|
148
|
+
return {
|
|
149
|
+
blobId,
|
|
150
|
+
partition,
|
|
151
|
+
byteLength: existing.byteLength,
|
|
152
|
+
...(existing.mediaType !== undefined
|
|
153
|
+
? { mediaType: existing.mediaType }
|
|
154
|
+
: {}),
|
|
155
|
+
createdAtMs: existing.createdAtMs,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const meta = {
|
|
160
|
+
byteLength: bytes.length,
|
|
161
|
+
...(mediaType !== undefined ? { mediaType } : {}),
|
|
162
|
+
createdAtMs: nowMs,
|
|
163
|
+
};
|
|
164
|
+
// Conditional write: of two concurrent uploads of the same blob exactly
|
|
165
|
+
// one lands, so the first createdAtMs survives and the blob counts once.
|
|
166
|
+
// The loser re-reads and returns the winner's record.
|
|
167
|
+
const written = await this.#bucket.put(objectKey, {
|
|
168
|
+
body: bytes,
|
|
169
|
+
contentType: mediaType ?? 'application/octet-stream',
|
|
170
|
+
metadata: { [BLOB_META_KEY]: utf8ToBase64url(metaToJson(meta)) },
|
|
171
|
+
...(preexisting === undefined
|
|
172
|
+
? { createOnly: true }
|
|
173
|
+
: { ifMatch: preexisting.etag }),
|
|
174
|
+
});
|
|
175
|
+
if (!written)
|
|
176
|
+
return this.put(partition, blobId, bytes, nowMs, mediaType);
|
|
177
|
+
if (preexisting === undefined)
|
|
178
|
+
await this.#bumpStats({ count: 1, bytes: bytes.length });
|
|
179
|
+
return record;
|
|
180
|
+
}
|
|
181
|
+
async has(partition, blobId) {
|
|
182
|
+
if (!BLOB_ID_PATTERN.test(blobId))
|
|
183
|
+
return false;
|
|
184
|
+
const head = await this.#bucket.head(this.objectKeyFor(partition, blobId));
|
|
185
|
+
return head !== undefined;
|
|
186
|
+
}
|
|
187
|
+
async get(partition, blobId) {
|
|
188
|
+
if (!BLOB_ID_PATTERN.test(blobId))
|
|
189
|
+
return undefined;
|
|
190
|
+
const object = await this.#bucket.get(this.objectKeyFor(partition, blobId));
|
|
191
|
+
if (object === undefined)
|
|
192
|
+
return undefined;
|
|
193
|
+
const bytes = new Uint8Array(await object.arrayBuffer());
|
|
194
|
+
const rawMeta = object.metadata[BLOB_META_KEY];
|
|
195
|
+
const meta = rawMeta !== undefined
|
|
196
|
+
? parseMetaJson(base64urlToUtf8(rawMeta), blobId, this.#label)
|
|
197
|
+
: { byteLength: bytes.length, createdAtMs: 0 };
|
|
198
|
+
return {
|
|
199
|
+
record: {
|
|
200
|
+
blobId,
|
|
201
|
+
partition,
|
|
202
|
+
byteLength: meta.byteLength,
|
|
203
|
+
...(meta.mediaType !== undefined ? { mediaType: meta.mediaType } : {}),
|
|
204
|
+
createdAtMs: meta.createdAtMs,
|
|
205
|
+
},
|
|
206
|
+
bytes,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* §5.9.2 orphan sweep — the ONLY LISTing operation. Page the `{prefix}blob/
|
|
211
|
+
* {partition}/` prefix, HEAD each object for its `createdAtMs`, and DELETE
|
|
212
|
+
* the ones both older than `olderThanMs` AND absent from
|
|
213
|
+
* `referencedBlobIds`. Returns the deleted blobIds. Never deletes a
|
|
214
|
+
* referenced blob (§5.9.2). Off the hot path — a host-scheduled GC job.
|
|
215
|
+
*/
|
|
216
|
+
async sweepOrphans(partition, olderThanMs, referencedBlobIds) {
|
|
217
|
+
const listPrefix = `${this.#prefix}blob/${partition}/`;
|
|
218
|
+
const swept = [];
|
|
219
|
+
let deletedBytes = 0;
|
|
220
|
+
let cursor;
|
|
221
|
+
do {
|
|
222
|
+
const page = await this.#bucket.list(listPrefix, cursor);
|
|
223
|
+
cursor = page.cursor;
|
|
224
|
+
for (const key of page.keys) {
|
|
225
|
+
// `{listPrefix}sha256/{hex}` → `sha256:{hex}`.
|
|
226
|
+
const rest = key.slice(listPrefix.length);
|
|
227
|
+
if (!rest.startsWith('sha256/'))
|
|
228
|
+
continue;
|
|
229
|
+
const blobId = `sha256:${rest.slice('sha256/'.length)}`;
|
|
230
|
+
if (!BLOB_ID_PATTERN.test(blobId))
|
|
231
|
+
continue;
|
|
232
|
+
if (referencedBlobIds.has(blobId))
|
|
233
|
+
continue;
|
|
234
|
+
const head = await this.#bucket.head(key);
|
|
235
|
+
if (head === undefined)
|
|
236
|
+
continue;
|
|
237
|
+
const rawMeta = head.metadata[BLOB_META_KEY];
|
|
238
|
+
const meta = rawMeta !== undefined
|
|
239
|
+
? parseMetaJson(base64urlToUtf8(rawMeta), blobId, this.#label)
|
|
240
|
+
: { byteLength: head.size ?? 0, createdAtMs: 0 };
|
|
241
|
+
if (meta.createdAtMs >= olderThanMs)
|
|
242
|
+
continue;
|
|
243
|
+
await this.#bucket.delete(key);
|
|
244
|
+
swept.push(blobId);
|
|
245
|
+
deletedBytes += meta.byteLength;
|
|
246
|
+
}
|
|
247
|
+
} while (cursor !== undefined);
|
|
248
|
+
if (swept.length > 0) {
|
|
249
|
+
await this.#bumpStats({ count: -swept.length, bytes: -deletedBytes });
|
|
250
|
+
}
|
|
251
|
+
return swept;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Store-wide counters from the pointer-object accumulator (never a LIST on
|
|
255
|
+
* the read path). `approximate: true` for the same reasons the segment
|
|
256
|
+
* store documents: best-effort ETag-CAS maintenance and sweep deletes that
|
|
257
|
+
* may miss the decrement. Exact within a single writer; a health gauge
|
|
258
|
+
* under concurrency. Note this is store-WIDE, not partition-scoped, so a
|
|
259
|
+
* shared bucket reports the whole bucket — the parameter is accepted for
|
|
260
|
+
* interface parity but the accumulator is one object per store.
|
|
261
|
+
*/
|
|
262
|
+
async stats(_partition) {
|
|
263
|
+
const { stats } = await this.#readStats();
|
|
264
|
+
return { count: stats.count, bytes: stats.bytes, approximate: true };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed segment store over the `ObjectBucket` seam (§5.1 cache
|
|
3
|
+
* semantics) — the store logic shared by `S3SegmentStore` (S3 REST + SigV4)
|
|
4
|
+
* and `R2SegmentStore` (`@syncular/server-workers`, the R2 binding).
|
|
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 a presigned GET or the inline
|
|
11
|
+
* download route serves them directly and the client's §5.1 hash check
|
|
12
|
+
* passes).
|
|
13
|
+
*
|
|
14
|
+
* {keyPrefix}rec/sha256/{hex}.json
|
|
15
|
+
* The `SegmentRecord` (§5.1). It is the one MUTABLE object of the two:
|
|
16
|
+
* the scope-digest set is a union that every publication extends, so
|
|
17
|
+
* the merge is a read-modify-write. Keeping the record in the body (not
|
|
18
|
+
* in the bytes object's user metadata) is what makes that write
|
|
19
|
+
* conditional — a body ETag changes when the union changes, while the
|
|
20
|
+
* bytes ETag is a content hash and is identical for identical bytes. A
|
|
21
|
+
* failed precondition re-reads and merges again, so no concurrent
|
|
22
|
+
* publication can drop another one's digest. `get` therefore reads two
|
|
23
|
+
* objects.
|
|
24
|
+
*
|
|
25
|
+
* {keyPrefix}find/{sha256Hex(canonical reuse key)}.json
|
|
26
|
+
* Whole-table reuse pointer (§5.3): written only when
|
|
27
|
+
* `rowCursor === null`, body = the record JSON. The reuse key is the
|
|
28
|
+
* canonical JSON array `[partition, logEpoch, table, schemaVersion,
|
|
29
|
+
* mediaType, scopeDigest, asOfCommitSeq]`, so `find` is one GET (plus a
|
|
30
|
+
* HEAD to confirm the segment object itself still exists — lifecycle GC
|
|
31
|
+
* may remove objects independently of pointers).
|
|
32
|
+
*
|
|
33
|
+
* Records written before the split kept the record in the bytes object's
|
|
34
|
+
* user metadata `syncular-record`; `get` still reads that shape and the next
|
|
35
|
+
* `put` materializes the record object from it, so an upgrade loses no cache
|
|
36
|
+
* entry.
|
|
37
|
+
*
|
|
38
|
+
* TTL mapping: expiry is **store-side and authoritative** — `expiresAtMs`
|
|
39
|
+
* (put-time + `ttlMs`, default 24 h) is recorded in the record and the
|
|
40
|
+
* pointer; `get` returns expired records so §5.5 can answer
|
|
41
|
+
* `sync.segment_expired`, and `find` filters them out itself. Bucket
|
|
42
|
+
* lifecycle expiration is garbage collection only: configure it comfortably
|
|
43
|
+
* ABOVE `ttlMs` (e.g. 2 days for the 24 h default) so clients normally see
|
|
44
|
+
* the precise, retryable `sync.segment_expired` and hit `sync.not_found` only
|
|
45
|
+
* long after. Never set lifecycle below `ttlMs`.
|
|
46
|
+
*/
|
|
47
|
+
import type { ObjectBucket } from './object-bucket.js';
|
|
48
|
+
import type { SegmentFindKey, SegmentMetadata, SegmentRecord, SegmentStore, SegmentStoreStats, SegmentStream } from './segment-store.js';
|
|
49
|
+
export interface BucketSegmentStoreOptions {
|
|
50
|
+
/** Key namespace inside the bucket, e.g. `syncular/`. Default: none. */
|
|
51
|
+
readonly keyPrefix?: string | undefined;
|
|
52
|
+
/** Segment TTL (§5.1 cache semantics). Default 24 h. */
|
|
53
|
+
readonly ttlMs?: number | undefined;
|
|
54
|
+
}
|
|
55
|
+
export declare class BucketSegmentStore implements SegmentStore {
|
|
56
|
+
#private;
|
|
57
|
+
constructor(bucket: ObjectBucket, options?: BucketSegmentStoreOptions);
|
|
58
|
+
/** `{keyPrefix}seg/sha256/{hex}` — the §5.4 "key embeds the segmentId". */
|
|
59
|
+
objectKeyFor(segmentId: string): string;
|
|
60
|
+
put(metadata: SegmentMetadata, bytes: Uint8Array, nowMs: number): Promise<SegmentRecord>;
|
|
61
|
+
get(segmentId: string): Promise<{
|
|
62
|
+
record: SegmentRecord;
|
|
63
|
+
bytes: Uint8Array;
|
|
64
|
+
} | undefined>;
|
|
65
|
+
/** The record plus the object GET's body, never buffered (see `get`). */
|
|
66
|
+
open(segmentId: string): Promise<SegmentStream | undefined>;
|
|
67
|
+
find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
|
|
68
|
+
/**
|
|
69
|
+
* Store-wide counters from the pointer-object accumulator (never a LIST).
|
|
70
|
+
* Marked `approximate: true`: the counters are maintained by a best-effort
|
|
71
|
+
* ETag-CAS read-modify-write on `put`, so a crash between the segment PUT
|
|
72
|
+
* and the accumulator write, or lifecycle GC that deletes objects the
|
|
73
|
+
* accumulator still counts, can drift them. Exact within a single writer;
|
|
74
|
+
* a health gauge under concurrency. See the README "S3 stats".
|
|
75
|
+
*/
|
|
76
|
+
stats(): Promise<SegmentStoreStats>;
|
|
77
|
+
}
|