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

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" 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