14 KiB
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/)
// 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/)
// 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/)
// 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:
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/)
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/)
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/)
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/)
defineCodesfrom@suaveplan/errorfor stableSTORAGE.*code registry.- All error classes extend
SuaveplanError(cause-chain, OTel span-event, wire format, redaction). - Added:
StorageInvalidKeyError(was an inlineStorageErrorwith 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 byoperation,provider,status). - Histograms:
storage.op.duration_ms(per operation latency),storage.value.bytes(read/write sizes). - Applied at the
AbstractStorageconcrete-method layer, not inside subclasses.
EventBusLike integration
AbstractStorageconstructor accepts an optionalEventBusLike(from@suaveplan/types).- Default: an internal minimal bus backed by
@suaveplan/eventsEventBus. on/offdelegate to the bus; existingStorageEventType/StorageEventHandlersurface preserved.
AbortSignal propagation
- All async abstract methods and concrete helpers accept an optional trailing
signal?: AbortSignal. MemoryStoragecheckssignal.abortedat the top of each method and throwsStorageErrorwith codeSTORAGE.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 byget().createWriteStream(key, signal?)—WritableStream<Uint8Array>that accumulates chunks then callsset().- Default implementations on
AbstractStorageusingget/setfor subclasses that don't override. MemoryStorageoverrides all four with zero-copy slices from the internalUint8Array.
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)thennamespace(p).get(k)equalsv. - TTL expiry: for any
ttl > 0, a read afterttlms must returnundefined. listWithPatternresult consistency against brute-forcelist()+ JS filter.
Benchmarks (src/__benchmarks__/)
memory-ops.bench.ts— throughput forget/set/delete/listonMemoryStorage.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(needsgetPartial/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
EventEmitterre-export removal and theget()return type narrowing fromnulltoundefined(to matchStorageLike). sideEffects: falseconfirmed — provider registration happens lazily insidefactory.ts, not at module scope before the factory module is imported.