199 lines
10 KiB
Markdown
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
|