Files
2026-05-27 23:29:53 +01:00

362 lines
14 KiB
Markdown

# OpenSpec Change: enterprise-storage
- **Change ID**: enterprise-storage
- **Package**: `@suaveplan/storage`
- **Category**: universal
- **Target path**: `universal/storage/storage`
- **Status**: IN PROGRESS
- **Date**: 2026-04-23
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/storage.md`
## Summary
Bootstrap `@suaveplan/storage` on genesis from scratch. This package provides the core key-value storage abstractions used by all downstream packages that need persistent or in-process state: `@suaveplan/drive`, `@suaveplan/idempotency`, `@suaveplan/logger` (StorageLogger), `@suaveplan/crypto-keys` (Keychain serialized-JSON adapter), and others.
The genesis rewrite preserves the full public API surface from the main branch and adds enterprise-grade enhancements: `StorageLike` implementation on `AbstractStorage`, streaming primitives (`createReadStream`/`createWriteStream`/`getPartial`/`getItemSize`) required by the drive layer, structured errors extending `@suaveplan/error` with stable `STORAGE.*` codes, Zod schemas for every config and options shape, OTel counters and histograms on all public operations, `EventBusLike` integration replacing the old `EventEmitter` re-export, `AbortSignal` on every async method, property-based tests (fast-check), and a mitata benchmark suite.
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
## Available workspace deps on genesis
| Dependency | Catalog / pin | Available |
|---|---|---|
| `@suaveplan/error` | `workspace:^` | ✅ |
| `@suaveplan/types` | `workspace:^` | ✅ (includes `StorageLike`) |
| `@suaveplan/events` | `workspace:^` | ✅ |
| `zod` | `catalog:forms` | ✅ |
| `@opentelemetry/api` | `catalog:observability` | ✅ |
| `tslib` | `catalog:build` | ✅ |
| `vitest` | `catalog:testing` (devDep) | ✅ |
| `@vitest/coverage-istanbul` | `catalog:testing` (devDep) | ✅ |
| `fast-check` | `catalog:testing` (devDep) | ✅ |
| `mitata` | `catalog:testing` (devDep) | ✅ |
## Preserved public API surface (from main branch)
### Errors (`src/errors/`)
```ts
// Extend @suaveplan/error via defineCodes — not ad-hoc subclasses
class StorageError extends SuaveplanError // code: STORAGE.GENERIC
class StorageNotFoundError extends StorageError // code: STORAGE.NOT_FOUND
class StoragePermissionError extends StorageError // code: STORAGE.PERMISSION_DENIED
class StorageQuotaError extends StorageError // code: STORAGE.QUOTA_EXCEEDED
// New on genesis:
class StorageInvalidKeyError extends StorageError // code: STORAGE.INVALID_KEY
class StorageBatchError extends StorageError // code: STORAGE.BATCH_ERROR
```
### Types (`src/types/`)
```ts
// All from main branch — Zod-validated on genesis
interface StorageOptions {
ttl?: number;
metadata?: Record<string, unknown>;
compress?: boolean;
encrypt?: boolean;
}
interface StorageMetadata {
createdAt: number;
modifiedAt: number;
expiresAt?: number;
size: number;
metadata?: Record<string, unknown>;
}
interface StorageResult<T> {
value: T;
metadata: StorageMetadata;
}
interface StorageListOptions {
limit?: number;
offset?: number;
prefix?: string;
continuationToken?: string;
}
interface StorageListResult {
keys: string[];
total: number;
hasMore: boolean;
continuationToken?: string;
}
interface StoragePatternOptions extends StorageListOptions {
pattern?: string;
}
type StorageBatchOperation =
| { type: 'get'; key: string }
| { type: 'set'; key: string; value: Uint8Array; options?: StorageOptions }
| { type: 'delete'; key: string };
interface StorageBatchResult {
operation: StorageBatchOperation;
success: boolean;
value?: Uint8Array | null;
error?: Error;
}
interface StorageStats {
reads: number;
writes: number;
deletes: number;
hits: number;
misses: number;
bytesRead: number;
bytesWritten: number;
keyCount?: number;
totalGets?: number;
totalSets?: number;
totalDeletes?: number;
totalErrors?: number;
totalSize?: number;
}
// Event types — replaces EventEmitter-based surface with EventBusLike
type StorageEventType = 'set' | 'get' | 'delete' | 'clear' | 'error';
interface StorageEventData {
key?: string;
value?: Uint8Array | null;
error?: Error;
}
type StorageEventHandler = (data: StorageEventData) => void;
// Hook types (unchanged from main)
interface StoragePreSetHook {
name?: string;
priority?: number;
transform(key: string, value: Uint8Array, options?: StorageOptions):
Promise<{ key: string; value: Uint8Array; options?: StorageOptions }>;
}
interface StoragePostGetHook {
name?: string;
priority?: number;
transform(key: string, value: Uint8Array): Promise<Uint8Array>;
}
```
### Factory types (`src/factory/`)
```ts
// Module-augmentation pattern preserved
interface StorageProviderRegistry {
memory: MemoryStorage;
// downstream packages augment this
}
type StorageProviderType = keyof StorageProviderRegistry;
type StorageProviderImplementation<T extends StorageProviderType> = StorageProviderRegistry[T];
interface StorageProviderConfig {
memory?: MemoryStorageConfig;
}
interface MemoryStorageConfig {
maxSize?: number;
cleanupIntervalMs?: number; // surfaced from MemoryStorage constructor
}
type StorageProviderConfigFor<T extends StorageProviderType> = ...;
interface StorageFactoryConfig<T extends StorageProviderType> {
type: T;
config?: StorageProviderConfigFor<T>;
}
// Alias export (backwards compat)
type InMemory = MemoryStorage;
```
### AbstractStorage (`src/abstract/`)
All existing public methods preserved, with `AbortSignal` added and `StorageLike` conformance:
```ts
abstract class AbstractStorage implements StorageLike {
// StorageLike contract (adapted: get returns Uint8Array | undefined, delete returns boolean)
abstract get(key: string, signal?: AbortSignal): Promise<Uint8Array | undefined>;
abstract set(key: string, value: Uint8Array, options?: StorageOptions, signal?: AbortSignal): Promise<void>;
abstract delete(key: string, signal?: AbortSignal): Promise<boolean>;
abstract exists(key: string, signal?: AbortSignal): Promise<boolean>;
abstract list(options?: StorageListOptions, signal?: AbortSignal): Promise<StorageListResult>;
abstract clear(signal?: AbortSignal): Promise<void>;
// Extended beyond StorageLike
abstract getWithMetadata(key: string, signal?: AbortSignal): Promise<StorageResult<Uint8Array> | null>;
abstract getMetadata(key: string, signal?: AbortSignal): Promise<StorageMetadata | null>;
abstract getSize(signal?: AbortSignal): Promise<number>;
// Streaming (new on genesis — required by @suaveplan/drive)
abstract getPartial(key: string, offset: number, length: number, signal?: AbortSignal): Promise<Uint8Array | null>;
abstract getItemSize(key: string, signal?: AbortSignal): Promise<number | null>;
abstract createReadStream(key: string, signal?: AbortSignal): ReadableStream<Uint8Array>;
abstract createWriteStream(key: string, signal?: AbortSignal): WritableStream<Uint8Array>;
// Concrete helpers on AbstractStorage (all accept optional AbortSignal)
batch(operations: StorageBatchOperation[], signal?: AbortSignal): Promise<StorageBatchResult[]>;
getdel(key: string, signal?: AbortSignal): Promise<Uint8Array | null>;
mdelete(keys: string[], signal?: AbortSignal): Promise<number>;
listWithPattern(options?: StoragePatternOptions, signal?: AbortSignal): Promise<StorageListResult>;
namespace(prefix: string): NamespacedStorage;
// Size limits
setMaxValueSize(bytes: number): void;
setMaxTotalSize(bytes?: number): void;
// Stats
getStats(): StorageStats;
resetStats(): void;
// Event hooks (EventBusLike-backed on genesis)
on(event: StorageEventType, handler: StorageEventHandler): void;
off(event: StorageEventType, handler: StorageEventHandler): void;
// Transform hooks
addPreSetHook(hook: StoragePreSetHook): void;
addPostGetHook(hook: StoragePostGetHook): void;
removeHook(name?: string): void;
}
```
### NamespacedStorage (`src/abstract/`)
```ts
class NamespacedStorage extends AbstractStorage {
constructor(storage: AbstractStorage, prefix: string);
// Delegates all operations to underlying storage with prefix: key → `${prefix}:${key}`
// clear() only removes keys in this namespace
// getSize() returns scoped byte count (not total — fixed from main branch approximation)
}
```
### MemoryStorage (`src/memory/`)
```ts
class MemoryStorage extends AbstractStorage {
constructor(options?: MemoryStorageConfig);
// Implements all AbstractStorage abstracts
get size(): number; // number of live (non-expired) keys
destroy(): void; // stops cleanup interval
// New on genesis
getPartial(key, offset, length, signal?): Promise<Uint8Array | null>;
getItemSize(key, signal?): Promise<number | null>;
createReadStream(key, signal?): ReadableStream<Uint8Array>;
createWriteStream(key, signal?): WritableStream<Uint8Array>;
}
```
### Factory functions (`src/factory/`)
```ts
function registerStorageProvider<T extends string>(
type: T,
implementation: new (config?: unknown) => AbstractStorage
): void;
function createStorage<T extends StorageProviderType>(
options: StorageFactoryConfig<T>
): StorageProviderImplementation<T>;
function createStorageWithConfig<T extends StorageProviderType>(
type: T,
config?: StorageProviderConfigFor<T>
): StorageProviderImplementation<T>;
function hasStorageProvider(type: string): boolean;
function getStorageProviders(): string[];
function unregisterStorageProvider(type: string): void;
function clearStorageProviders(): void; // resets to memory-only
// Object facade (legacy compat — preserved, not deprecated on genesis)
const StorageFactory: {
register: typeof registerStorageProvider;
create: typeof createStorage;
createWithConfig: typeof createStorageWithConfig;
hasProvider: typeof hasStorageProvider;
getProviders: typeof getStorageProviders;
unregister: typeof unregisterStorageProvider;
clearProviders: typeof clearStorageProviders;
};
```
## Dropped from main branch
| Symbol | Reason |
|---|---|
| `export { EventEmitter } from './utils/event-emitter.js'` | `EventEmitter` was already just a re-export of `@suaveplan/events` on main. Genesis drops the pass-through; callers import directly from `@suaveplan/events`. `AbstractStorage` now accepts an optional injected `EventBusLike`. |
## New additions for genesis
### Structured errors (`src/errors/`)
- `defineCodes` from `@suaveplan/error` for stable `STORAGE.*` code registry.
- All error classes extend `SuaveplanError` (cause-chain, OTel span-event, wire format, redaction).
- Added: `StorageInvalidKeyError` (was an inline `StorageError` with code `"INVALID_KEY"` on main), `StorageBatchError`.
### Zod schemas (`src/types/`)
- `StorageOptionsSchema`, `StorageMetadataSchema`, `StorageResultSchema`, `StorageListOptionsSchema`, `StorageListResultSchema`, `StoragePatternOptionsSchema`, `StorageBatchOperationSchema`, `StorageBatchResultSchema`, `StorageStatsSchema`, `MemoryStorageConfigSchema`.
- Used for runtime boundary validation (factory config, hook inputs) and wire-format safety.
### OTel instrumentation
- Counters: `storage.ops` (labelled by `operation`, `provider`, `status`).
- Histograms: `storage.op.duration_ms` (per operation latency), `storage.value.bytes` (read/write sizes).
- Applied at the `AbstractStorage` concrete-method layer, not inside subclasses.
### EventBusLike integration
- `AbstractStorage` constructor accepts an optional `EventBusLike` (from `@suaveplan/types`).
- Default: an internal minimal bus backed by `@suaveplan/events` `EventBus`.
- `on`/`off` delegate to the bus; existing `StorageEventType`/`StorageEventHandler` surface preserved.
### AbortSignal propagation
- All async abstract methods and concrete helpers accept an optional trailing `signal?: AbortSignal`.
- `MemoryStorage` checks `signal.aborted` at the top of each method and throws `StorageError` with code `STORAGE.ABORTED`.
- `batch()` checks signal between operations and rolls back on abort.
### Streaming primitives (required by `@suaveplan/drive`)
- `getPartial(key, offset, length, signal?)` — byte-range read.
- `getItemSize(key, signal?)` — size probe without data transfer.
- `createReadStream(key, signal?)` — `ReadableStream<Uint8Array>` backed by `get()`.
- `createWriteStream(key, signal?)` — `WritableStream<Uint8Array>` that accumulates chunks then calls `set()`.
- Default implementations on `AbstractStorage` using `get`/`set` for subclasses that don't override.
- `MemoryStorage` overrides all four with zero-copy slices from the internal `Uint8Array`.
### Property-based tests (fast-check)
- Key-validation invariants (arbitrary strings, path-traversal, length boundaries).
- `batch()` rollback correctness under arbitrary failure injection.
- Namespace roundtrip: `namespace(p).set(k, v)` then `namespace(p).get(k)` equals `v`.
- TTL expiry: for any `ttl > 0`, a read after `ttl` ms must return `undefined`.
- `listWithPattern` result consistency against brute-force `list()` + JS filter.
### Benchmarks (`src/__benchmarks__/`)
- `memory-ops.bench.ts` — throughput for `get`/`set`/`delete`/`list` on `MemoryStorage`.
- `batch-ops.bench.ts` — batch vs sequential write throughput.
- `namespace-ops.bench.ts` — namespace overhead vs direct access.
### NamespacedStorage `getSize()` fix
Main branch `getSize()` returned the total storage size rather than the scoped namespace size. Genesis implements a correct scoped byte count by iterating only keys under `this.prefix`.
## Impact
- **Unblocks**: `@suaveplan/drive` (needs `getPartial`/`createReadStream`/`createWriteStream`), `@suaveplan/idempotency` (MemoryStore adapter), `@suaveplan/crypto-keys` (Keychain serialized-JSON), `@suaveplan/logger` (StorageLogger sink).
- **No breaking changes** to the main-branch public API surface beyond the `EventEmitter` re-export removal and the `get()` return type narrowing from `null` to `undefined` (to match `StorageLike`).
- **`sideEffects: false`** confirmed — provider registration happens lazily inside `factory.ts`, not at module scope before the factory module is imported.