@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.34.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/blob-handlers.ts
CHANGED
|
@@ -180,8 +180,7 @@ async function downloadBlob(
|
|
|
180
180
|
// egress path — it only needs to know the object EXISTS (a HEAD/`has`), not
|
|
181
181
|
// the bytes. Inline delivery loads the bytes; presigned delivery does not.
|
|
182
182
|
const presignConfigured = ctx.blobSignedUrls !== undefined;
|
|
183
|
-
let entry: {
|
|
184
|
-
let mediaType: string | undefined;
|
|
183
|
+
let entry: { bytes: Uint8Array } | undefined;
|
|
185
184
|
if (presignConfigured) {
|
|
186
185
|
if (!(await store.has(ctx.partition, blobId))) {
|
|
187
186
|
throw syncError('blob.not_found', 'unknown blob (§5.9.5)');
|
|
@@ -191,7 +190,6 @@ async function downloadBlob(
|
|
|
191
190
|
if (entry === undefined) {
|
|
192
191
|
throw syncError('blob.not_found', 'unknown blob (§5.9.5)');
|
|
193
192
|
}
|
|
194
|
-
mediaType = entry.record.mediaType;
|
|
195
193
|
}
|
|
196
194
|
|
|
197
195
|
// §5.9.5 authorization: resolve once, then test referencing rows.
|
|
@@ -279,10 +277,12 @@ async function downloadBlob(
|
|
|
279
277
|
// Unreachable: presign not configured ⇒ `entry` was loaded above.
|
|
280
278
|
throw syncError('blob.not_found', 'blob bytes unavailable (§5.9.5)');
|
|
281
279
|
}
|
|
280
|
+
// §5.9.5: inline bytes are always octet-stream. The stored media type
|
|
281
|
+
// would make a JSON blob indistinguishable from the signed-URL envelope.
|
|
282
282
|
return {
|
|
283
283
|
bytes,
|
|
284
284
|
headers: {
|
|
285
|
-
'Content-Type':
|
|
285
|
+
'Content-Type': 'application/octet-stream',
|
|
286
286
|
ETag: `"${blobId}"`,
|
|
287
287
|
'Cache-Control': 'private, max-age=0',
|
|
288
288
|
Vary: 'Authorization, Accept-Encoding',
|
|
@@ -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
|
+
}
|