Files
suaveplan/docs/storage/architecture.md
2026-05-27 23:29:53 +01:00

99 lines
6.4 KiB
Markdown

# Storage Architecture
Canonical capability spec: [`openspec/specs/storage/spec.md`](../../openspec/specs/storage/spec.md) (`STORAGE-01`..`STORAGE-04`). This document explains the contract; the spec defines it.
## The structural contract: `StorageLike`
The minimum any storage adapter must satisfy lives in `@suaveplan/types/storage-like` so that consumers (drive, plugins, tests) can accept any conformant implementation without taking a runtime dependency on `@suaveplan/storage`. This is the wire that breaks the circular-dep risk between drive and storage.
```ts
interface StorageLike {
get(key: string): Promise<Uint8Array | undefined>;
set(key: string, value: Uint8Array): Promise<void>;
delete(key: string): Promise<boolean>;
exists(key: string): Promise<boolean>;
list(opts?: { prefix?: string; limit?: number }): Promise<{
keys: string[]; total: number; hasMore: boolean; continuationToken?: string;
}>;
clear(): Promise<void>;
// optional batch surface
getMany?(keys: readonly string[]): Promise<ReadonlyMap<string, Uint8Array | undefined>>;
setMany?(entries: readonly (readonly [string, Uint8Array])[]): Promise<void>;
deleteMany?(keys: readonly string[]): Promise<number>;
}
```
## The concrete base class: `AbstractStorage`
`AbstractStorage` (in `@suaveplan/storage`) extends `StorageLike` with the rest of the platform surface. Every concrete backend extends `AbstractStorage`.
| Method | Purpose |
| --- | --- |
| `get(key)` / `set(key, value, options?)` | Whole-blob read/write. `options` carries TTL and metadata. |
| `setPartial(key, offset, bytes, signal?)` | **Sparse byte-range write.** See `STORAGE-01..03` below. |
| `getPartial(key, start, end)` | Byte-range read for resume-friendly downloads. |
| `delete(key)` / `has(key)` / `clear(pattern?)` | Lifecycle. |
| `list(pattern?, options?)` | Glob-prefixed enumeration. |
| `batch(operations)` | Multi-op grouping (atomic only when the backend says so). |
| `createReadStream(key)` / `createWriteStream(key)` | WHATWG streams; falls back to whole-blob when `nativeStreams=false`. |
| `namespace(prefix)` | Returns a `NamespacedStorage` view. |
| `capabilities` | Readonly record describing what the backend can do natively. |
## The capability matrix (`STORAGE-04`)
Every backend declares a `capabilities` record so the drive layer can pick the right strategy without runtime probing.
```ts
interface StorageCapabilities {
partialReads: boolean; // getPartial(key, start, end) is native
partialWrites: boolean; // setPartial(key, offset, bytes) is native (no RMW)
nativeStreams: boolean; // createReadStream/createWriteStream are native (no buffering)
atomicWrites: boolean; // set is atomic across crash
contentAddressable: boolean; // key is the content hash; same-content writes coalesce
}
```
Defaults are all `false` (`DEFAULT_STORAGE_CAPABILITIES`). Subclasses opt in only for what they truly support.
The drive-plugin-chunks layer reads these flags directly to choose its layout (see `CHUNKS-LAYOUT-AUTO-01`):
- `partialWrites === true` → use the **sparse-file** layout (one key per file, byte ranges).
- `partialWrites === false` → use the **chunk-keys** layout (one key per chunk).
## `setPartial` semantics (`STORAGE-01..03`)
`setPartial` is the load-bearing method that lets large files survive a backend that does not support random-access writes. The contract is:
1. **Always available** (`STORAGE-01`). `AbstractStorage.setPartial` is abstract; every concrete subclass must provide one. `defaultSetPartial` is a protected RMW fallback that subclasses can call when `capabilities.partialWrites === false`.
2. **Sparse extension** (`STORAGE-02`). Writing to an offset past the current end zero-fills the gap on subsequent reads. This lets the chunks plugin pre-allocate a sparse file and fill chunks in arbitrary order.
3. **In-place overwrite** (`STORAGE-03`). Writing into an existing range overwrites only the addressed bytes; everything outside `[offset, offset+bytes.length)` is preserved exactly. Zero-length writes are a no-op.
When a backend genuinely cannot support partial writes (`S3`), `setPartial` falls back to `defaultSetPartial`, which downloads the full object, splices the new bytes in, and re-uploads — correct but expensive. The drive layer reads `partialWrites` and routes around this when it can.
## `NamespacedStorage` (scoping)
`storage.namespace("drive/users/alice/")` returns a `NamespacedStorage` that prefixes every key with `"drive/users/alice/"` before delegating to the inner storage. The wrapper inherits the inner storage's `capabilities` exactly — namespace scoping is purely a key transform. This is how `drive`, `drive-plugin-chunks`, and `drive-sharing` give each tenant/file/partition its own keyspace without dragging tenancy concerns down into every backend.
## What lives above storage, not in it
| Concern | Where it lives | Why |
| --- | --- | --- |
| Paths, directories, `..` resolution | `@suaveplan/drive` | Storage keys are opaque strings. |
| File metadata (size, mtime, owner) | `@suaveplan/drive` | Stat goes through the drive plugin chain. |
| Chunk headers, signatures, AEAD tags | `@suaveplan/drive-plugin-chunks` | Storage stores ciphertext bytes, never plaintext. |
| Manifests, BLAKE3/SHA-256 integrity | `@suaveplan/drive-plugin-chunks` | Storage does not verify content. |
| Replication, redundancy, peer fetch | `@suaveplan/drive-mesh` + `@suaveplan/network-plugin-content-delivery` | Storage is single-replica by definition. |
| Encryption keys, vault, KDF | `@suaveplan/crypto-vault`, `@suaveplan/keys` | Storage never sees a key. |
This separation is what lets the storage layer accept **any** backend — local fs, S3, OPFS, IndexedDB, an embedded FAT image, a Garage cluster — and have the drive layer treat them all the same.
## OTel and errors
Every concrete subclass instruments `storage.ops` (counter), `storage.op.duration_ms` (histogram), and `storage.value.bytes` (histogram). Errors are `StorageError` (and the four typed subclasses `StorageNotFoundError`, `StorageQuotaError`, `StorageInvalidKeyError`, `StorageBatchError`) with codes in the frozen `STORAGE_CODES` registry. Backends MAY add their own namespaced registries (`S3.S3_CODES`) but MUST extend the base error class.
## Reading further
- [`backends.md`](./backends.md) — every concrete backend.
- [`implementation-status.md`](./implementation-status.md) — what's shipped, what's pending.
- [`../drive/architecture.md`](../drive/architecture.md) — the consumer.