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

199 lines
10 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"` 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