# Capability Spec: streams-metadata **Package**: `@suaveplan/streams-metadata` **Status**: ACTIVE ## 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"` and `mime.source === "magic-bytes"`, and a `mime:detected` listener 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"` and `mime.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"`, and `mime.priority === 0` #### Scenario: passthrough preserves the input reference - **WHEN** the caller constructs `new MimeTransform({ mode: "passthrough" })` and invokes `await transform.process(payload)` - **THEN** the returned value is `===` the input `payload` and `mime:detected` still 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 an `exif:detected` listener observed the same `ImageMetadata` #### Scenario: unknown bytes raise a typed error - **WHEN** the caller invokes `await transform.process({ bytes: gibberishBytes })` - **THEN** the call rejects with a `StreamsMetadataError` whose `code === STREAMS_METADATA.EXIF_FAILED` and whose `cause` is 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.exif` is `undefined` #### Scenario: passthrough preserves the input reference - **WHEN** the caller constructs `new ExifTransform({ mode: "passthrough" })` and invokes `await transform.process(payload)` against valid JPEG bytes - **THEN** the returned value is `===` the input `payload` and `exif:detected` still 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 `mime` and `exif`, and listeners observed `mime:detected`, `exif:detected`, and `metadata:complete` in 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:error` listener observed the wrapped `StreamsMetadataError`, and no `metadata:complete` listener fired #### Scenario: child events flow through the wrapper - **WHEN** the caller subscribes `on('mime:detected', m)` and `on('exif:detected', e)` on the wrapper and invokes `process` against 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_CODES` is the `.code` of an error class exposed and exercised by the test suite #### Scenario: error cause chain is preserved - **WHEN** an `ExifFailedError` is raised by `ExifTransform` after `readMetadata` throws an `@suaveplan/exif::ExifError` - **THEN** `error.cause` is the original `ExifError` instance and `error.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 a `MimeTransform` - **THEN** TypeScript types `r` as `MimeDetectionResult` and rejects accesses to non-existent fields #### Scenario: listener errors are caught - **WHEN** a registered listener for `mime:detected` throws - **THEN** `process` still 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.ts` for `export *` - **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/**/*.ts` for `from "node:"`, `globalThis.document`, or `from "fs"` - **THEN** the result is empty **Verified by:** Static audit of `src/**/*.ts` import statements