10 KiB
Capability Spec Delta: streams-metadata (streams-metadata)
Package: @suaveplan/streams-metadata
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: MimeTransform detects MIME type and enriches the payload
The package SHALL export a MimeTransform class extending
BaseTransform<{}, StreamsMetadataEvents> from @suaveplan/streams. Its
process(input) SHALL detect the MIME type by calling
@suaveplan/mime::detectFromMagicBytes(input.bytes); when magic bytes match,
the result SHALL carry source: "magic-bytes" and the magic-bytes
priority; when no magic match exists but input.name is a string, the
result SHALL be derived from @suaveplan/mime::detectFromExtension(input.name)
with source: "extension"; when neither path matches, the result SHALL be
{ mimeType: "application/octet-stream", priority: 0, source: "fallback" }.
The transform SHALL emit mime:detected with the result before returning. In
enrich mode (default) process SHALL return a shallow-cloned payload with
mime set; in passthrough mode it SHALL return the original input reference
unchanged.
Scenario: magic-bytes detection wins over extension
- WHEN a caller invokes
await transform.process({ bytes: pngBytes, name: "x.jpg" }) - THEN the returned payload carries
mime.mimeType === "image/png"andmime.source === "magic-bytes", and amime:detectedlistener observed the same result
Scenario: extension fallback when magic bytes are unknown
- WHEN the caller invokes
await transform.process({ bytes: gibberishBytes, name: "song.mp3" }) - THEN the returned payload carries
mime.mimeType === "audio/mpeg"andmime.source === "extension"
Scenario: octet-stream fallback when nothing matches
- WHEN the caller invokes
await transform.process({ bytes: gibberishBytes }) - THEN the returned payload carries
mime.mimeType === "application/octet-stream",mime.source === "fallback", andmime.priority === 0
Scenario: passthrough preserves the input reference
- WHEN the caller constructs
new MimeTransform({ mode: "passthrough" })and invokesawait transform.process(payload) - THEN the returned value is
===the inputpayloadandmime:detectedstill fires with the detection result
Verified by: src/mime/mime.test.ts::"prefers magic bytes over extension" AND src/mime/mime.test.ts::"falls back to extension when magic returns null" AND src/mime/mime.test.ts::"falls back to application/octet-stream when nothing matches" AND src/mime/mime.test.ts::"passthrough mode returns the original input reference"
Requirement: ExifTransform extracts ImageMetadata and enriches the payload
The package SHALL export an ExifTransform class extending
BaseTransform<{}, StreamsMetadataEvents>. Its process(input) SHALL invoke
@suaveplan/exif::readMetadata(input.bytes, options.read) and SHALL emit
exif:detected with the resulting ImageMetadata. In enrich mode (default)
the returned payload SHALL be a shallow clone carrying exif; in
passthrough mode it SHALL be the original input reference. When
readMetadata throws, ExifTransform SHALL re-raise as
ExifFailedError(STREAMS_METADATA.EXIF_FAILED) with cause set to the
original error.
Scenario: JPEG round trip
- WHEN the caller invokes
await transform.process({ bytes: minimalJpegBytes }) - THEN the returned payload carries
exif.format === "jpeg"and anexif:detectedlistener observed the sameImageMetadata
Scenario: unknown bytes raise a typed error
- WHEN the caller invokes
await transform.process({ bytes: gibberishBytes }) - THEN the call rejects with a
StreamsMetadataErrorwhosecode === STREAMS_METADATA.EXIF_FAILEDand whosecauseis the original@suaveplan/exif::ExifError
Scenario: read options pass through to readMetadata
- WHEN the caller constructs
new ExifTransform({ read: { skipExif: true } })and processes a JPEG - THEN the resulting
ImageMetadata.exifisundefined
Scenario: passthrough preserves the input reference
- WHEN the caller constructs
new ExifTransform({ mode: "passthrough" })and invokesawait transform.process(payload)against valid JPEG bytes - THEN the returned value is
===the inputpayloadandexif:detectedstill fires
Verified by: src/exif/exif.test.ts::"extracts metadata from a JPEG payload" AND src/exif/exif.test.ts::"wraps ExifError as StreamsMetadataError on unsupported bytes" AND src/exif/exif.test.ts::"forwards read options to @suaveplan/exif" AND src/exif/exif.test.ts::"passthrough mode returns the original input reference"
Requirement: MetadataTransform composes mime and exif with event forwarding
The package SHALL export a MetadataTransform class extending
BaseTransform<{}, StreamsMetadataEvents>. The constructor SHALL build
MimeTransform and ExifTransform children. Listeners on the children for
mime:detected and exif:detected SHALL re-emit through the wrapper so a
single on('mime:detected', fn) on the wrapper observes the child event.
process(input) SHALL run mime then exif in sequence; on success it SHALL
emit metadata:complete and return the enriched payload; if either child
throws, it SHALL emit metadata:error with the thrown value and return the
original input unchanged.
Scenario: happy path composes both children
- WHEN the caller invokes
await transform.process({ bytes: jpegBytes, name: "photo.jpg" }) - THEN the returned payload carries both
mimeandexif, and listeners observedmime:detected,exif:detected, andmetadata:completein that order
Scenario: child failure emits metadata:error and yields the original input
- WHEN the caller invokes
await transform.process({ bytes: gibberishBytes }) - THEN the call resolves to the original input reference, a
metadata:errorlistener observed the wrappedStreamsMetadataError, and nometadata:completelistener fired
Scenario: child events flow through the wrapper
- WHEN the caller subscribes
on('mime:detected', m)andon('exif:detected', e)on the wrapper and invokesprocessagainst a JPEG - THEN both listeners fire with the payloads produced by the underlying child transforms
Verified by: src/metadata/metadata.test.ts::"composes mime and exif into an enriched payload" AND src/metadata/metadata.test.ts::"emits metadata:error and returns the original input when a child throws" AND src/metadata/metadata.test.ts::"forwards child mime:detected and exif:detected events"
Requirement: Stable error codes via defineCodes registry
The package SHALL declare a STREAMS_METADATA_CODES registry via
defineCodes("STREAMS_METADATA", [...]) covering every failure surface
(EXIF_FAILED, MIME_FAILED, INVALID_PAYLOAD, ABORTED). Every code SHALL
be reachable from real call sites (either thrown directly by a transform or
carried on the cause chain of a metadata:error listener payload). The
package SHALL export a StreamsMetadataError base class and one factory
subclass per code.
Scenario: every code is wired into a call site
- WHEN a static auditor inspects the registry against
src/ - THEN every code in
STREAMS_METADATA_CODESis the.codeof an error class exposed and exercised by the test suite
Scenario: error cause chain is preserved
- WHEN an
ExifFailedErroris raised byExifTransformafterreadMetadatathrows an@suaveplan/exif::ExifError - THEN
error.causeis the originalExifErrorinstance anderror.code === "STREAMS_METADATA.EXIF_FAILED"
Verified by: src/errors/errors.test.ts::"registers every code via defineCodes" AND src/errors/errors.test.ts::"ExifFailedError preserves Error cause" AND src/exif/exif.test.ts::"wraps ExifError as StreamsMetadataError on unsupported bytes"
Requirement: Typed event surface via BaseTransform.on/emit
The package SHALL declare a StreamsMetadataEvents map mapping each event
name to its payload type and SHALL parameterise every transform with
BaseTransform<{}, StreamsMetadataEvents>. Listener errors SHALL NOT
propagate to process callers.
Scenario: listener type-safety at the call site
- WHEN a caller writes
transform.on('mime:detected', (r) => r.mimeType)against aMimeTransform - THEN TypeScript types
rasMimeDetectionResultand rejects accesses to non-existent fields
Scenario: listener errors are caught
- WHEN a registered listener for
mime:detectedthrows - THEN
processstill resolves with the enriched payload
Verified by: src/mime/mime.test.ts::"a throwing mime:detected listener does not break process"
Requirement: Subpath exports per feature directory
The package SHALL declare subpath exports for every public feature
directory: ./mime/mime, ./exif/exif, ./metadata/metadata,
./events/events, ./errors/errors, ./types/payload. Every feature barrel
SHALL use explicit named re-exports per CLAUDE.md §10.
Scenario: each feature is importable via its subpath
- WHEN a consumer writes
import { MimeTransform } from "@suaveplan/streams-metadata/mime/mime" - THEN the import resolves and includes only
MimeTransform's graph
Scenario: no export * leakage in any non-root barrel
- WHEN a static auditor greps every
src/**/index.tsforexport * - THEN the result is empty
Verified by: package.json::"declares subpath exports for every feature directory" AND inspection of src/**/index.ts
Requirement: Universal-tier purity
The package SHALL run unmodified in Node, Bun, and browser tiers. The source
SHALL NOT import node:* modules, the DOM, window, or process. Runtime
deps SHALL be limited to @suaveplan/error, @suaveplan/events,
@suaveplan/exif, @suaveplan/mime, @suaveplan/streams, @suaveplan/types,
and tslib. sideEffects SHALL be false.
Scenario: no node:* or browser-only imports in src
- WHEN a static auditor greps
src/**/*.tsforfrom "node:",globalThis.document, orfrom "fs" - THEN the result is empty
Verified by: Static audit of src/**/*.ts import statements