/** * S3-compatible blob store (SPEC.md §5.9.2 durable objects, §5.9.5 delegated * presign) — AWS S3, Cloudflare R2, MinIO. The blob twin of * `s3-segment-store.ts`: same hand-rolled SigV4 over `fetch` (sigv4.ts), same * content-addressed key layout, same LIST-free-on-the-hot-path CAS stats * accumulator. Zero dependencies. * * Key layout (deterministic — every point lookup is a GET/HEAD, never a LIST): * * {keyPrefix}blob/sha256/{hex} * The blob bytes, VERBATIM (the object body MUST be exactly the * content-addressed bytes so a presigned GET serves them directly and the * client's §5.9.1 hash check passes — blobs are never re-compressed at * rest, §5.8/§5.9.5). The `byteLength` + optional `mediaType` ride along * as object user metadata `x-amz-meta-syncular-blob` = base64url(JSON), so * `get` is a single GET. `createdAtMs` is stored there too so the orphan * sweep (§5.9.2) can read the upload age without a separate index. * * DURABILITY vs SEGMENT TTL — the honest interface difference (§5.9.2): a * blob referenced by a live row must stay downloadable INDEFINITELY. So, * unlike `S3SegmentStore`, this store writes **no `expiresAtMs`, no S3 * lifecycle-expiration mapping, and no TTL config**. Expiry is * REFERENCE-driven, not time-driven: reclamation is the host-scheduled * `sweepOrphanBlobs` (blob-store.ts) deleting only objects no live row * references after a grace period — never a bucket lifecycle rule. Do NOT * put a lifecycle expiration on the `blob/` prefix; it would delete * still-referenced attachments. * * The orphan sweep is the ONLY operation that LISTs: `sweepOrphans` pages * `ListObjectsV2` over the `blob/` prefix (an admin/GC path, off the hot * path) to find candidates, reads each object's `createdAtMs` from its * metadata for the grace check, and DELETEs the unreferenced-and-old ones. */ import type { BlobRecord, BlobStore, BlobStoreStats } from './blob-store.js'; import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlIssue } from './signed-url.js'; export interface S3BlobStoreConfig { /** * Endpoint origin, no bucket, no trailing slash: * AWS `https://s3..amazonaws.com`, * R2 `https://.r2.cloudflarestorage.com`, * MinIO `http://127.0.0.1:9000`. Requests are path-style * (`{endpoint}/{bucket}/{key}`), which all three accept. */ readonly endpoint: string; /** AWS region; R2 uses `auto`. */ readonly region: string; readonly bucket: string; readonly accessKeyId: string; readonly secretAccessKey: string; /** Optional STS session token. */ readonly sessionToken?: string; /** Key namespace inside the bucket, e.g. `syncular/`. Default: none. */ readonly keyPrefix?: string; } export declare class S3BlobStore implements BlobStore { #private; constructor(config: S3BlobStoreConfig); /** * `{keyPrefix}blob/{partition}/sha256/{hex}` — the §5.9.5 "signed key embeds * the blobId". Blobs are content-addressed, but download authorization and * the reference index are per-partition, so the same bytes uploaded under * two partitions are two objects (a partition MUST NOT read another's * attachment by guessing a content address). The partition is folded into * the key as a path segment. */ objectKeyFor(partition: string, blobId: string): string; put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise; has(partition: string, blobId: string): Promise; get(partition: string, blobId: string): Promise<{ record: BlobRecord; bytes: Uint8Array; } | undefined>; /** * §5.9.2 orphan sweep — the ONLY LISTing operation. Page `ListObjectsV2` * over `{prefix}blob/{partition}/`, HEAD each object for its `createdAtMs`, * and DELETE the ones both older than `olderThanMs` AND absent from * `referencedBlobIds`. Returns the deleted blobIds. Never deletes a * referenced blob (§5.9.2). Off the hot path — a host-scheduled GC job. */ sweepOrphans(partition: string, olderThanMs: number, referencedBlobIds: ReadonlySet): Promise; /** * Store-wide counters from the pointer-object accumulator (never a LIST on * the read path). `approximate: true` for the same reasons `S3SegmentStore` * documents: best-effort ETag-CAS maintenance and sweep deletes that may * miss the decrement. Exact within a single writer; a health gauge under * concurrency. Note this is store-WIDE, not partition-scoped, so a shared * bucket reports the whole bucket — the parameter is accepted for interface * parity but the accumulator is one object per store. */ stats(_partition: string): Promise; /** * SigV4 presigned GET for a blob object (§5.9.5 delegated presign). The * signed key embeds the `blobId`; `urlExpiresAtMs` is the provider-enforced * expiry (`X-Amz-Date + X-Amz-Expires`). Issued ONLY after the row-derived * authorization check (§5.9.5) — the caller (blob-handlers) resolves scopes * and tests referencing rows first; the URL is then a short-TTL bearer * grant to exactly those immutable bytes. TTL SHOULD be ≤ 15 minutes; * default 900 s. */ presignBlobGet(partition: string, blobId: string, options?: { readonly ttlSeconds?: number; readonly nowMs?: number; }): Promise; /** * SigV4 presigned PUT for a blob object (§5.9.3 direct-to-storage upload). * The blob twin of `presignBlobGet`: the signed key embeds the `blobId`, so * the grant places bytes at exactly the content-addressed key. The store * does NOT recompute the SHA-256 (it is the object store, not the sync * server); integrity is enforced at REFERENCE time — the §5.9.6 push * existence check verifies the object exists (`has`) and every consumer * re-verifies the content address over the received bytes (§5.9.5/§5.1). A * client PUTting bytes that do not hash to `{blobId}` poisons only its own * upload; no honest reference resolves to it. Issued by the upload-grant * handler after host authentication; TTL SHOULD be ≤ 15 min (default 900). */ presignBlobPut(partition: string, blobId: string, options?: { readonly ttlSeconds?: number; readonly nowMs?: number; }): Promise; } /** * Wire an `S3BlobStore` into `SyncServerConfig.blobSignedUrls` as the §5.9.5 * delegated-presign scheme for blob downloads: * `blobSignedUrls: s3PresignedBlobUrls(blobStore, { ttlSeconds: 900 })`. * The presigned URL is issued only after the row-derived authorization check * (blob-handlers) — never as a bearer capability minted from the id alone. */ export declare function s3PresignedBlobUrls(store: S3BlobStore, options?: { readonly ttlSeconds?: number; }): BlobPresignConfig; /** * Wire an `S3BlobStore` into `SyncServerConfig.blobUploadUrls` as the §5.9.3 * presigned-upload (direct-to-storage) scheme: * `blobUploadUrls: s3PresignedBlobUploads(blobStore, { ttlSeconds: 900 })`. * The upload-grant handler issues the presigned PUT only after host * authentication + the size-cap check; the client PUTs bytes straight to the * object store with no host auth (§5.9.3). Integrity stays the content-address * check at reference/download time — never a store-side hash recompute. */ export declare function s3PresignedBlobUploads(store: S3BlobStore, options?: { readonly ttlSeconds?: number; }): BlobUploadPresignConfig;