Files
suaveplan/openspec/archive/2026-05-12-storage-file/proposal.md
2026-05-27 23:29:53 +01:00

94 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Add @suaveplan/storage-file — Node.js filesystem AbstractStorage adapter
**Package**: `@suaveplan/storage-file`
**Status**: PROPOSED
**Type**: new-package
## Why
Genesis has `@suaveplan/storage` (AbstractStorage core with in-memory adapter) and `@suaveplan/storage-s3` (object-store adapter), but no filesystem-backed adapter for server-tier consumers (drive plugin, vault snapshots, i18n filesystem backends, integration test fixtures). The upstream `../suaveplan/packages/server/storage-file` exists at 90% coverage against the old `StorageLike` shim and ships with no atomic writes, no AbortSignal, no retry on transient errors, no path-traversal guard, and no streaming. The genesis rebuild extends `AbstractStorage`, adds POSIX-atomic temp-rename writes, hardens key validation, threads AbortSignal everywhere, and wires `@suaveplan/retry` for `EBUSY`/`EMFILE`/`ENFILE`. This unblocks several downstream packages (drive-template hydration, vault encrypted snapshots, server-side i18n backends, integration test storage fixtures).
## What changes
- ADDED: `NodeFsStorage` class extending `AbstractStorage` — full key-value storage rooted at a configurable `rootDir` with `.data`/`.meta` file pair per entry.
- ADDED: `createNodeFsStorage(options)` factory.
- ADDED: `NodeFsStorageOptions` interface + `NodeFsStorageOptionsSchema` Zod schema (Zod-validated boundary; throws `STORAGE_FILE.INVALID_OPTIONS` on malformed input).
- ADDED: `NodeFsLike` structural interface + `defaultNodeFs()` adapter wrapping `node:fs/promises` (`mkdir`, `readFile`, `writeFile`, `rename`, `unlink`, `readdir`, `stat`, `access`, `createReadStream`, `createWriteStream`). Injectable for tests.
- ADDED: `sanitizeKey(key)` strict key boundary — rejects empty, `..`, absolute paths (`/`, drive-prefixed), null bytes, control chars (0x00–0x1F, 0x7F), `KEY_TOO_LONG` over 1024 chars; normalises Windows `\` separators to POSIX `/`.
- ADDED: `STORAGE_FILE_CODES` registry via `defineCodes("STORAGE_FILE", [...])` — 8 codes: `NOT_FOUND`, `PERMISSION_DENIED`, `IO_FAILED`, `INVALID_KEY`, `ATOMIC_WRITE_FAILED`, `INVALID_OPTIONS`, `ABORTED`, `KEY_TOO_LONG`.
- ADDED: `StorageFileError extends SuaveplanError<StorageFileErrorCode>` with `key` discriminant.
- ADDED: Module-augmentation registry — `declare module "@suaveplan/storage"` adds `file: NodeFsStorage` to `StorageProviderRegistry` and `file: NodeFsStorageOptions` to `StorageProviderConfig`; `registerNodeFsProvider()` registers the constructor with the factory.
- ADDED: Atomic write helper — temp-file-then-`fs.rename` for `set` and metadata writes; rollback unlinks temp on failure.
- ADDED: `@suaveplan/retry` integration on transient filesystem errors (`EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN`) — exponential backoff with injectable `TimersLike`.
- ADDED: Native Web Streams via `createReadStream`/`createWriteStream` consuming `node:fs.createReadStream`/`createWriteStream` (declared `capabilities.nativeStreams: true`).
- ADDED: `getPartial(offset, length)` via `fs.read` with a positioned `FileHandle` — declared `capabilities.partialReads: true`.
- ADDED: OTel surface — `storage_file.op_duration_ms{op,outcome}` + `storage_file.io_bytes{op,direction}` histograms; `storage_file.{reads,writes,deletes,errors}_total{outcome}` counters; per-op span on `@suaveplan/storage-file` tracer.
## Out of scope
- `setPartial` random-access writes — falls through to `AbstractStorage.defaultSetPartial` read-modify-write reference path. Reason: POSIX `pwrite` with sparse-file extension semantics is a follow-up; current callers don't require it and the reference fallback is correct.
- Watch/notify mode — `fs.watch` integration belongs in a separate `storage-file-watcher` package; the i18n filesystem backend already covers its own watch needs (`@suaveplan/i18n-backend-fs-multifile`).
- Encryption-at-rest — orthogonal concern handled by `@suaveplan/drive-plugin-chunks` (AEAD per chunk) and `@suaveplan/crypto-vault` (sealed container). Storage layer stores bytes verbatim.
- Quota enforcement beyond `AbstractStorage.maxValueSize`/`maxTotalSize` — caller-facing concern; OS-level `ulimit` and filesystem quotas are out of scope.
## Source mapping
This change derives from `../suaveplan/enterprise/storage-file.md` (118 lines) and ports `../suaveplan/packages/server/storage-file/src/filesystem.ts` (259 lines) to extend `AbstractStorage` rather than the legacy `StorageLike` shim.
| Requirement | Source lines | Notes |
|---|---|---|
| Node.js filesystem storage purpose | L10-L12 | "Node.js filesystem storage" — preserved as the package's single responsibility. |
| Zod request/response schemas | L39 | Reworded as Zod option boundary (`NodeFsStorageOptionsSchema`); the upstream phrasing references `@suaveplan/api-schemas` which is API-tier and not in scope here. |
| OpenTelemetry observability | L44 | Implemented as per-op spans, counters, and histograms emitted from production paths. |
| Retries via `@suaveplan/retry` | L45 | Mapped to transient `EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN` retry with exponential backoff and injectable `TimersLike`. |
| Test coverage 100% | L52 | Adopted at the genesis-wide Istanbul 100/100/100/100 threshold. |
| File-size budget | L54 | Adopted: ≤300 LOC hard / ≤500 LOC test. |
| No mocks — real services | L55 | Tests use real `fs.mkdtemp` temp dirs and a real injectable `NodeFsLike` shim; no `vi.mock`. |
| `sideEffects: false` | L57 | Set in `package.json`. |
| Workspace deps + Bun catalog | L58 | All deps use `workspace:^` or `catalog:` refs. |
| Bun overrides exact | L59 | Inherits root override policy. |
| Biome clean | L60 | Enforced by `bun run check`. |
| TypeScript strict, no `any` | L61 | Adopted; no `any`, no `ts-ignore`. |
| Public API stability via Zod | L62 | `NodeFsStorageOptionsSchema` published; runtime validation at constructor boundary. |
| Error hierarchy via `@suaveplan/error` | L63 | `StorageFileError extends SuaveplanError<StorageFileErrorCode>`. |
| Performance benchmarks | L74 | Mitata benchmarks under `src/__benchmarks__/`. |
| Resilience — retries, cancellation | L75 | Retry on transient errors; AbortSignal threaded through every public async method. |
| Security — input validation at boundary | L76 | `sanitizeKey` strict boundary; `rootDir` isolation rejects traversal before any fs call. |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Extend `AbstractStorage` not `StorageLike` | The genesis storage core (`@suaveplan/storage`) replaces the legacy `StorageLike` shim with a richer `AbstractStorage` base class. Every adapter (memory, S3) extends it; this adapter MUST conform to the same contract for module-augmentation registry compatibility. |
| AbortSignal on every public async method | Required by `AbstractStorage`'s abstract signatures. Upstream had no AbortSignal support. |
| Atomic writes via temp-file-then-rename | Required for crash safety on the filesystem tier. Concurrent readers must never observe a torn write. POSIX `rename(2)` is atomic on the same filesystem; this is the standard primitive (e.g. `git`, `sqlite`). Upstream wrote directly to the destination path. |
| Path-traversal guard (`..`, absolute paths) | Upstream had no key sanitisation. Without it, a caller-controlled key (`../../etc/passwd`) would escape `rootDir`. Required by genesis security model. |
| Module-augmentation registry hook | The genesis `@suaveplan/storage` factory uses module augmentation (`StorageProviderRegistry`) for typed adapter dispatch. New adapters must hook in via `declare module` + `registerStorageProvider`. |
| Native streaming declared in `capabilities` | `AbstractStorage.capabilities` discriminates backends that support native streams from those that fall through to the buffered reference implementation. `node:fs.createReadStream`/`createWriteStream` are native; consumers (drive-plugin-chunks) read this flag to decide whether to chain or to pre-buffer. |
| Partial reads via positioned FileHandle | `capabilities.partialReads: true` is honoured by drive-plugin-chunks for range reads. Implementing it natively avoids `O(file-size)` memory for large objects. |
## Verification approach
Every requirement in the spec delta carries a `**Verified by:**` line referencing concrete `it(...)` titles in these files:
- `src/node-fs-storage/node-fs-storage.test.ts` — CRUD, metadata, list, clear, getSize, getPartial, native streams, AbortSignal, key sanitisation integration, atomic write under crash, retry exhaustion, OTel happy paths.
- `src/node-fs-storage/node-fs-storage.spec-conformance.test.ts` — type-level conformance against `AbstractStorage`, capabilities matrix, OTel instruments observed via real in-memory `MeterProvider`, idempotent registry registration, error-code registry exhaustion.
- `src/key-sanitizer/key-sanitizer.test.ts` — empty, traversal, absolute, null bytes, control chars, overlong, separator normalisation, valid paths.
- `src/node-fs/node-fs.test.ts` — `defaultNodeFs()` adapter forwards every method to `node:fs/promises`, against a real `fs.mkdtemp` directory.
- `src/retry-helper/retry-helper.test.ts` — transient classification (`EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN` retry; `ENOENT`/`EACCES` not), exponential backoff timing under FakeClock + injectable timers, exhaustion → `IO_FAILED`.
- `src/otel/otel.test.ts` — `recordOp` emits histogram + counter with the documented attribute set against a real `InMemoryMetricExporter`.
- `src/streams/streams.test.ts` — large-file round-trip via Web Streams, AbortSignal mid-stream, structured error mapping.
- `src/registry/registry.test.ts` — registers `file` provider, `createStorage({ type: "file", config })` returns `NodeFsStorage`, idempotent re-registration.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/storage-file` to enumerate. The package is new — no dependents currently. No action needed.
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/storage-file` returning the package itself).
### Affected upstreams
- `@suaveplan/storage` — `StorageProviderRegistry` module-augmented to add `file: NodeFsStorage` slot. Additive only; no breaking change. [x] no action needed (purely additive type slot).