23 KiB
23 KiB
Tasks: enterprise-storage
Change ID: enterprise-storage
Package: @suaveplan/storage
Path: universal/storage/storage
Work tasks in order. Each section is a logical gate — do not start the next section until all checkboxes in the current section are green and bunx turbo lint typecheck test --filter=@suaveplan/storage passes.
1. Scaffold
- Create
universal/storage/storage/directory tree:universal/storage/storage/ src/ abstract/ errors/ factory/ memory/ types/ __benchmarks__/ tests/ package.json tsconfig.json tsconfig.build.json vite.config.ts vitest.config.ts biome.json (extend root) - Write
package.json:name:@suaveplan/storage,version:0.1.0,private: false"sideEffects": falsetype:"module"main/module/typespointing atdist/output- Subpath exports:
"."→./dist/index.js"./abstract"→./dist/abstract/index.js"./errors"→./dist/errors/index.js"./factory"→./dist/factory/index.js"./memory"→./dist/memory/index.js"./types"→./dist/types/index.js
- Runtime deps:
@suaveplan/error workspace:^,@suaveplan/types workspace:^,@suaveplan/events workspace:^,zod catalog:forms,@opentelemetry/api catalog:observability,tslib catalog:build - Dev deps:
vitest catalog:testing,@vitest/coverage-istanbul catalog:testing,fast-check catalog:testing,mitata catalog:testing - Scripts:
build,test,test:coverage,test:bench,lint,typecheck,docs:check
- Write
tsconfig.jsonextending@suaveplan/typescript-config/library.json - Write
tsconfig.build.json(excludes test files and__benchmarks__) - Write
vite.config.tsfor library build (entrysrc/index.ts, formatses/cjs,dts: true) - Write
vitest.config.tsusingcreateTestConfig()from@suaveplan/testing/config; setprovider: 'istanbul',thresholdsat 100/100/100/100 - Add package to
turbo.jsonpipeline (inherits global tasks — confirm no extra config needed) - Add
@suaveplan/storageto rootbun.lockbworkspace list and runbun install - Write guard test
tests/tree-shake.test.ts:- Assert the built
dist/index.jscontains noimport ... from 'node:*'(universal runtime constraint) - Assert
dist/index.jsbyte size is tracked (soft limit alert at 50 KB)
- Assert the built
- Confirm
bunx turbo build --filter=@suaveplan/storageexits 0 (even with emptysrc/index.ts)
2. Types + Error Codes
2a. Error codes (src/errors/)
- Create
src/errors/errors.ts:- Import
SuaveplanError,defineCodesfrom@suaveplan/error - Define code registry with
defineCodes:STORAGE.GENERIC STORAGE.NOT_FOUND STORAGE.PERMISSION_DENIED STORAGE.QUOTA_EXCEEDED STORAGE.INVALID_KEY STORAGE.BATCH_ERROR STORAGE.ABORTED - Implement
StorageError extends SuaveplanError— constructor accepts(message, code, key?, cause?); preserveskeyas a typed public field; emits OTel span event on construction - Implement
StorageNotFoundError extends StorageError— constructor(key, cause?) - Implement
StoragePermissionError extends StorageError— constructor(key, operation, cause?) - Implement
StorageQuotaError extends StorageError— constructor(key, cause?) - Implement
StorageInvalidKeyError extends StorageError— constructor(key, reason, cause?) - Implement
StorageBatchError extends StorageError— constructor(message, cause?)
- Import
- Create
src/errors/errors.types.tswith any shared error-related type aliases - Create
src/errors/errors.md(purpose, features, usage examples, API reference, implementation notes — ≥ 200 words) - Create
src/errors/index.tsbarrel - Create
src/errors/errors.test.ts:- Instantiate each error class and assert
name,code,key,causefields - Assert each is
instanceof SuaveplanError - Assert
StorageErrorwith eachSTORAGE.*code round-trips through@suaveplan/errorwire format { timeout: 5000 }on all tests
- Instantiate each error class and assert
2b. Types and Zod schemas (src/types/)
- Create
src/types/types.ts— all interface definitions from the preserved surface (see proposal § Preserved public API):StorageOptions,StorageMetadata,StorageResult<T>,StorageListOptions,StorageListResult,StoragePatternOptions,StorageBatchOperation,StorageBatchResult,StorageStats,StorageEventType,StorageEventData,StorageEventHandler,StoragePreSetHook,StoragePostGetHook- All
metadatafields typedRecord<string, unknown>(notany)
- Create
src/types/schemas.ts— Zod schemas:StorageOptionsSchema,StorageMetadataSchema,StorageResultSchema(generic viaz.object+ type param helper),StorageListOptionsSchema,StorageListResultSchema,StoragePatternOptionsSchema,StorageBatchOperationSchema,StorageBatchResultSchema,StorageStatsSchema- Export inferred types alongside schemas (e.g.
export type StorageOptions = z.infer<typeof StorageOptionsSchema>)
- Create
src/types/types.mdandsrc/types/schemas.md(≥ 200 words each) - Create
src/types/index.tsbarrel - Create
src/types/types.test.ts:- Parse valid and invalid objects through every Zod schema
- Assert discriminated union
StorageBatchOperationSchemacorrectly narrows ontype { timeout: 5000 }on all tests
3. AbstractStorage Core
3a. Implementation (src/abstract/abstract.ts)
- Declare
AbstractStorage implements StorageLike(import from@suaveplan/types) - Constructor accepts optional
eventBus?: EventBusLike(from@suaveplan/types); defaults tonew EventBus()from@suaveplan/events - Internal
protected stats: StorageStatsinitialized with all counters at 0 - Internal
protected preSetHooks: StoragePreSetHook[]andprotected postGetHooks: StoragePostGetHook[] - Internal
protected maxValueSize: numberdefaulting to10 * 1024 * 1024 - Internal
protected maxTotalSize: number | undefined - Implement
protected validateKey(key: string): void— checks: non-empty, no..or\, no absolute path, length ≤ 1024, no<>"|*?; throwsStorageInvalidKeyError - Implement
protected async validateValueSize(key, value, signal?)— throwsStorageQuotaErroron single-value or total-quota breach - Declare all abstract methods with
signal?: AbortSignalon every async signature:get,set,delete,exists,list,clear(StorageLike conformance)getWithMetadata,getMetadata,getSizegetPartial,getItemSize,createReadStream,createWriteStream
- Provide default implementations for streaming methods that delegate to
get/set(subclasses may override for efficiency):getPartial: callsget, slices the resultingUint8ArraygetItemSize: callsgetMetadata, returnsmetadata.sizecreateReadStream: enqueues single chunk fromget()then closescreateWriteStream: accumulates all chunks, callsset()on close
- Implement concrete helpers:
batch,getdel,mdelete,listWithPattern— all accept optionalsignal, checksignal?.abortedat start and between async steps; throwStorageError(STORAGE.ABORTED)when aborted batch()rollback: on error or abort, reverse completedset/deleteops; use structuredconsole.warnonly if OTel is unavailable at that point, otherwise emit span event- Implement
setMaxValueSize,setMaxTotalSize - Implement
getStats(),resetStats() - Implement
on(event, handler)andoff(event, handler)delegating to the injectedEventBusLike - Implement
addPreSetHook,addPostGetHook,removeHookwith priority sort - Implement
protected async applyPreSetHooks(key, value, options?)andprotected async applyPostGetHooks(key, value) - Implement
protected emitEvent(event, data)— publishes toEventBusLike - Implement
namespace(prefix): NamespacedStorage
3b. OTel instrumentation (src/abstract/abstract.ts or extracted src/abstract/telemetry.ts)
- Import
metricsfrom@opentelemetry/api - Create meter
@suaveplan/storageonce (module-level singleton, lazy init) - Counter
storage.ops— attributes:operation(get/set/delete/exists/list/clear/batch/getdel/mdelete),provider(class name),status(success/error/aborted) - Histogram
storage.op.duration_ms— same attributes - Histogram
storage.value.bytes— attributes:operation(get/set),provider - Instrument all concrete helper methods and wrap the OTel recording around the abstract method calls within those helpers; individual subclasses do not need to add OTel (they inherit it)
3c. NamespacedStorage (src/abstract/namespace.ts)
class NamespacedStorage extends AbstractStorage- Constructor:
(storage: AbstractStorage, prefix: string)— validate prefix is non-empty, no: prefixKey(key)→${prefix}:${key};unprefixKey(key)strips prefix- Implement all abstract methods by delegating to underlying
storagewith prefixed key list()filters byprefix:prefix, strips prefix from returned keys, applies pagination correctly against filtered totalclear()lists all keys in this namespace and callsmdeleteon prefixed keys — scoped only, does not touch other namespacesgetSize()iterates only namespace keys and sumsmetadata.size— fixes the main-branch approximate-total buggetPartial,getItemSize,createReadStream,createWriteStreamdelegate viaprefixKey
3d. Docs and tests
- Create
src/abstract/abstract.md(≥ 200 words, both basic and advanced examples) - Create
src/abstract/namespace.md(≥ 200 words) - Create
src/abstract/index.tsbarrel - Create
src/abstract/abstract.test.ts— test via a minimalConcreteStorageimplementation (notMemoryStorage; keeps unit tests isolated):validateKey: empty key, path-traversal variants, absolute paths, over-1024-chars, forbidden chars, valid keyvalidateValueSize: single-value limit, total-quota limitbatch(): happy path, rollback on mid-batch failure, abort signal mid-batchgetdel(): value returned and deleted, null when absentmdelete(): returns count of actually-deleted keyslistWithPattern(): glob*,?, no-pattern passthrough, paginationon/off/emitEvent: handler called on emit, removed afteroffaddPreSetHook/addPostGetHook/removeHook: priority ordering, named removalsetMaxValueSize/setMaxTotalSize: rejects invalid valuesgetStats()/resetStats(): snapshot copy, reset to zerosnamespace(): returnsNamespacedStoragewith correct prefix- AbortSignal: each helper aborts before work starts
{ timeout: 5000 }on sync tests;{ timeout: 30000 }on async tests
- Create
src/abstract/namespace.test.ts:- Prefix isolation: keys in
ns('a')never visible vians('b') clear(): only removes namespace-scoped keysgetSize(): returns scoped bytes onlylist()pagination against namespace-filtered total{ timeout: 5000 }/{ timeout: 30000 }as appropriate
- Prefix isolation: keys in
4. MemoryStorage
4a. Implementation (src/memory/memory.ts)
class MemoryStorage extends AbstractStorage- Internal
Map<string, { value: Uint8Array; metadata: StorageMetadata }>store - Constructor accepts
MemoryStorageConfig(Zod-validated):maxSize?,cleanupIntervalMs?(default 60000; 0 disables timer) - Background TTL cleanup loop using
setInterval; call.unref()in Node/Bun to avoid blocking exit get(key, signal?):- Check
signal?.aborted; throwStorageError(STORAGE.ABORTED) - Return
undefined(notnull) for missing/expired keys — matchesStorageLike - Expire-on-read for TTL entries; update stats
- Apply
postGetHooks; emitgetevent
- Check
set(key, value, options?, signal?):- Check signal;
validateKey;validateValueSize; applypreSetHooks - Write entry with metadata (
createdAt,modifiedAt,expiresAt,size) - On error: roll back stats increments
- Check signal;
delete(key, signal?): returnsboolean(true if existed) — matchesStorageLike; update stats; emit eventexists(key, signal?): expire-on-read; return booleanlist(options?, signal?): filter expired, filter byprefix, paginate, returnStorageListResultgetWithMetadata(key, signal?): returnsStorageResult<Uint8Array> | nullgetMetadata(key, signal?): returnsStorageMetadata | nullwithout retrieving value bytesclear(signal?): clears map; resetskeyCount/totalSizestats; emitscleareventgetSize(signal?): sums live (non-expired) entry byte lengthsget size(): number— count of live (non-expired) entriesdestroy(): clears the cleanup interval; idempotent- Streaming overrides (zero-copy from internal
Uint8Array):getPartial(key, offset, length, signal?):entry.value.subarray(offset, offset + length)— handles out-of-range gracefully (clamp to actual size)getItemSize(key, signal?):entry.metadata.sizewithout extra allocationcreateReadStream(key, signal?):ReadableStreamthat enqueues the storedUint8Array(defensive copy so callers cannot mutate the store)createWriteStream(key, signal?):WritableStreamthat accumulates typed-array chunks then callsthis.set()on close; pipes abort signal throughWritableStreamDefaultController
4b. Types (src/memory/memory.types.ts)
MemoryEntry— internal store entry shapeMemoryStorageConfig— exported, used byMemoryStorageConfigSchemainsrc/types/schemas.ts
4c. Docs and tests
- Create
src/memory/memory.md(≥ 200 words, includes TTL and streaming examples) - Create
src/memory/index.tsbarrel - Create
src/memory/memory.test.ts(core operations):get/set/delete/exists/list/clear/getSizehappy paths- TTL: entry expires after
ttlms, returnsundefinedon late read getWithMetadata/getMetadatafield correctness- Stats accuracy across all operations including error paths
- Size limit (
maxSize) enforced onset destroy()stops the cleanup timerget sizeproperty reflects live key count- AbortSignal: each method aborts correctly
{ timeout: 5000 }/{ timeout: 30000 }as appropriate
- Create
src/memory/memory-streaming.test.ts:getPartial: byte-range slices, out-of-range clamping, missing key returns nullgetItemSize: matches stored byte lengthcreateReadStream: full round-trip matches original bytescreateWriteStream: multi-chunk write round-trips correctly viaget()- AbortSignal on streaming methods
{ timeout: 30000 }on all
- Create
src/memory/memory-ttl-cleanup.test.ts:- Expired entries removed by background cleanup (use
FakeClockvia injectable clock or mockDate.now) - Stats (
keyCount,totalSize) correctly decremented after cleanup cleanupIntervalMs: 0disables background timer{ timeout: 30000 }on all
- Expired entries removed by background cleanup (use
- Create
src/memory/memory-edge-cases.test.ts:- Overwrite:
modifiedAtupdates,createdAtunchanged - Large value exactly at
maxValueSizeaccepted; one byte over rejected - Empty
Uint8Arraystored and retrieved correctly clear()thengetSize()returns 0- Concurrent async
sets to the same key settle deterministically (last writer wins in single-threaded JS) { timeout: 30000 }on all
- Overwrite:
- Create
src/memory/memory-atomic.test.ts:getdel()is logically atomic: value returned and key absent after callmdelete()returns correct count for mix of existing and missing keys{ timeout: 5000 }/{ timeout: 30000 }as appropriate
5. NamespacedStorage (integration tests)
- Create
tests/namespace-integration.test.ts(intests/sibling, cross-cutting):- Full round-trip using
MemoryStorageas the backing store:set→get→getWithMetadata→delete→exists list()with prefix filter, pagination- Verify no key leakage:
ns('a').set('k', v)is not visible vians('b').get('k') getSize()returns bytes for the namespace only- Nested namespaces:
ns1.namespace('sub')works correctly { timeout: 30000 }on all
- Full round-trip using
6. StorageFactory + Provider Registry
6a. Implementation (src/factory/factory.ts)
- Module-level
providers = new Map<string, new (config?: unknown) => AbstractStorage>() - Auto-register
"memory"→MemoryStorageat module initialisation (this is the only side effect; it is isolated to the factory module and does not affect tree-shaking of other exports) - Implement all factory functions from the preserved surface (see proposal):
registerStorageProvider<T extends string>(type, implementation): voidcreateStorage<T extends StorageProviderType>(options): StorageProviderImplementation<T>— throwsStorageError(STORAGE.GENERIC)if provider not found (not rawError)createStorageWithConfig<T extends StorageProviderType>(type, config?): StorageProviderImplementation<T>hasStorageProvider(type): booleangetStorageProviders(): string[]unregisterStorageProvider(type): voidclearStorageProviders(): void— resets to memory-only
const StorageFactory = { register, create, createWithConfig, hasProvider, getProviders, unregister, clearProviders } as const
6b. Factory types (src/factory/factory.types.ts)
StorageProviderRegistryinterface withmemory: MemoryStoragetype InMemory = MemoryStorage— alias export for backwards compatStorageProviderType,StorageProviderImplementation<T>,StorageProviderConfig,MemoryStorageConfig(re-export frommemory.types.ts),StorageProviderConfigFor<T>,StorageFactoryConfig<T>
6c. Docs and tests
- Create
src/factory/factory.md(≥ 200 words, module-augmentation registration pattern example) - Create
src/factory/index.tsbarrel - Create
src/factory/factory.test.ts:createStorage({ type: 'memory' })returns aMemoryStorageinstancecreateStoragewith unregistered type throwsStorageErrorwith codeSTORAGE.GENERICregisterStorageProvider+createStoragewith custom provider workshasStorageProvider/getStorageProviders/unregisterStorageProvider/clearStorageProviderscorrectnessStorageFactoryfacade delegates to the same functionsInMemorytype alias is exported (type-level test usingsatisfies){ timeout: 5000 }on all
7. OTel Instrumentation Verification
- Create
tests/otel-instrumentation.test.ts:- Use a
TestSpanExporteror in-memory OTel SDK setup from@suaveplan/opentelemetry(if available) or a minimal no-op; verify that span event / metric recording code paths are exercised without throwing - Assert
storage.opscounter is incremented by 1 for each of:get,set,delete,exists,list,clear - Assert
storage.op.duration_mshistogram is recorded for each operation - Assert
storage.value.byteshistogram is recorded forgetandsetwith the correct byte count { timeout: 30000 }on all
- Use a
8. Property-Based Tests
- Create
tests/property-based.test.ts:- Key validation:
fc.string()—validateKeyaccepts all valid keys; rejects all strings containing..,\,/,<>"|*?, or longer than 1024 chars - Set/get roundtrip:
fc.tuple(fc.string({ minLength: 1, maxLength: 100 }).filter(k => !k.includes('..')), fc.uint8Array())—set(k, v)thenget(k)returns equalUint8Array - Namespace roundtrip:
fc.tuple(fc.string(...), fc.string(...), fc.uint8Array())—namespace(p).set(k, v)thennamespace(p).get(k)equalsv;namespace(p2).get(k)returnsundefinedforp2 !== p - listWithPattern consistency: for any store state and glob pattern,
listWithPattern({ pattern })result is a subset oflist()result and matches the pattern on every key - Batch rollback: inject a failure at a random operation index; assert all keys revert to pre-batch state
- TTL expiry:
fc.nat({ max: 100 })for ttl; advance time past ttl;getmust returnundefined { timeout: 30000 }on thedescribeblock
- Key validation:
9. Benchmarks
- Create
src/__benchmarks__/memory-ops.bench.ts:- Benchmark
MemoryStorageget,set,deleteindividually (1 000-key warm store) - Benchmark
list()on a 10 000-key store with and without prefix - Use
mitatabenchandgroupAPI
- Benchmark
- Create
src/__benchmarks__/batch-ops.bench.ts:- Benchmark
batch()of 100 mixed set/get/delete operations vs equivalent sequential calls - Measure rollback overhead by injecting a failure at index 50
- Benchmark
- Create
src/__benchmarks__/namespace-ops.bench.ts:- Compare
MemoryStorage.get/setdirect vs throughNamespacedStoragewrapper list()with namespace prefix vs without
- Compare
- Add
"test:bench": "vitest bench"script topackage.json - Confirm
bun run test:bench --filter=@suaveplan/storageruns without error and prints numbers
10. Docs Coverage Pass
- Ensure every
.tsimplementation file has a sibling.md(no co-located.types.ts,.test.ts, barrelindex.ts, or__benchmarks__files require.md):src/errors/errors.md✓ (Task 2a)src/types/types.md✓ (Task 2b)src/types/schemas.md✓ (Task 2b)src/abstract/abstract.md✓ (Task 3d)src/abstract/namespace.md✓ (Task 3d)src/abstract/telemetry.md(if telemetry extracted to its own file)src/memory/memory.md✓ (Task 4c)src/factory/factory.md✓ (Task 6c)
- Write root
README.mdatuniversal/storage/storage/README.md:- Package purpose (2+ sentences)
- Installation snippet
- Quick-start example (MemoryStorage + namespace + TTL)
- Streaming example (createReadStream / createWriteStream)
- Factory / custom provider registration example
- Error handling example (StorageNotFoundError, StorageQuotaError)
- Links to each co-located
.mdfile - Dependency table
- No category-level README inside
src/subdirectories
- Run
bun run docs:check --filter=@suaveplan/storageand fix any missing doc symbols
11. Verify and Publish
- Run the full pre-publish gate — all four commands must exit 0:
bunx turbo lint --filter=@suaveplan/storage bunx turbo typecheck --filter=@suaveplan/storage bunx turbo test --filter=@suaveplan/storage bunx turbo test:coverage --filter=@suaveplan/storage - Confirm coverage thresholds 100/100/100/100 are met on all touched modules (istanbul reporter)
- Confirm
bun run docs:check --filter=@suaveplan/storagereports 100% - Confirm
bun run test:bench --filter=@suaveplan/storageruns to completion and outputs benchmark numbers - Confirm
sideEffects: falseis set inpackage.jsonand tree-shake guard test passes - Review
src/for anyTODO,FIXME,@deprecated,any,ts-ignore, orit.skip/describe.skip— fix all before proceeding - Add
@suaveplan/storagetoCLAUDE.mdpackage listing (under Universal packages) with its full description and dep list - Publish:
bun scripts/publish.ts --package @suaveplan/storage - Archive this OpenSpec change: move
openspec/changes/enterprise-storage/toopenspec/archive/2026-04-23-enterprise-storage/and fold spec deltas intoopenspec/specs/storage/spec.md