From 4925629ba7e94cb2e0bf24e9cc7d0fc4ca2f336c Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Wed, 19 Aug 2026 16:58:25 -0700 Subject: [PATCH 1/3] WEBDEV-8920: Add fetchMetadataField for modeled single-field lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchMetadataValue hands back the raw API value, so callers that want a modeled field construct one themselves. Reading an item's primary collection meant `new StringField(raw).value`, and the reason it needs a field at all is easy to miss: collection is a list on an item in several collections but a bare string on an item in one, so reading [0] is wrong half the time. Takes a field name rather than a field class, and derives both the class and the return type from Metadata's own getter, so the mapping from field name to field type isn't duplicated here and a field added to Metadata is available without a change in this package. MetadataFieldKey is derived the same way, so only real fields are reachable — identifier and rawMetadata aren't. const result = await metadataService.fetchMetadataField('bra-bfr', 'collection'); result.success?.value; // 'kodi_archive' Rejects a value that isn't a scalar or array of scalars, which would otherwise construct a field whose String() is '[object Object]' inside a successful result. Note for consumers: this is a required member of MetadataServiceInterface, so anything implementing that interface has to add it. Offshoot's MockMetadataService does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017GxnijHsZmBrHkcuJ5XYf9 --- index.ts | 1 + src/metadata-field-key.ts | 20 +++++++ src/metadata-service-interface.ts | 56 ++++++++++++++++++ src/metadata-service.ts | 63 ++++++++++++++++++++ test/metadata-service.test.ts | 98 +++++++++++++++++++++++++++++++ 5 files changed, 238 insertions(+) create mode 100644 src/metadata-field-key.ts diff --git a/index.ts b/index.ts index a18f966..6cb0e37 100644 --- a/index.ts +++ b/index.ts @@ -9,6 +9,7 @@ export { Review, SpeechMusicASREntry, } from '@internetarchive/iaux-item-metadata'; +export type { MetadataFieldKey } from './src/metadata-field-key'; export { DefaultMetadataBackend } from './src/backend/default-metadata-backend'; export { MetadataService } from './src/metadata-service'; diff --git a/src/metadata-field-key.ts b/src/metadata-field-key.ts new file mode 100644 index 0000000..af62d80 --- /dev/null +++ b/src/metadata-field-key.ts @@ -0,0 +1,20 @@ +import type { + Metadata, + MetadataFieldInterface, +} from '@internetarchive/iaux-item-metadata'; + +/** + * The names of `Metadata`'s fields, i.e. its members that are a + * `MetadataField`. Excludes members like `identifier` and `rawMetadata`, which + * are a plain string and a plain record. + * + * Derived from `Metadata` rather than listed here, so a field added there is + * available without a matching change in this package. + */ +export type MetadataFieldKey = { + [K in keyof Metadata]-?: NonNullable< + Metadata[K] + > extends MetadataFieldInterface + ? K + : never; +}[keyof Metadata]; diff --git a/src/metadata-service-interface.ts b/src/metadata-service-interface.ts index 2605e69..13f786d 100644 --- a/src/metadata-service-interface.ts +++ b/src/metadata-service-interface.ts @@ -1,3 +1,5 @@ +import type { Metadata } from '@internetarchive/iaux-item-metadata'; +import type { MetadataFieldKey } from './metadata-field-key'; import type { Result } from '@internetarchive/result-type'; import type { MetadataServiceError } from './metadata-service-error'; import type { MetadataResponse } from './responses/metadata-response'; @@ -48,4 +50,58 @@ export interface MetadataServiceInterface { identifier: string, keypath: string, ): Promise>; + + /** + * Fetch a single item metadata field, modeled as its `MetadataField` type. + * + * Like {@link fetchMetadataValue}, but hands back a constructed field rather + * than the raw value, so callers get its parsing and normalization instead of + * reimplementing them. + * + * This matters most for fields the API sends inconsistently. `collection` is + * an array on an item in several collections but a bare string on an item in + * one, so reading `[0]` off the raw value is wrong half the time, while + * `value` is the first entry either way. + * + * ```ts + * const result = await metadataService.fetchMetadataField( + * 'bra-bfr', + * 'collection', + * ); + * result.success?.value; // 'kodi_archive' + * result.success?.values; // ['kodi_archive', 'community'] + * ``` + * + * The field name is any field `Metadata` declares, and the returned type + * follows from it, so a date comes back parsed: + * + * ```ts + * const added = await metadataService.fetchMetadataField( + * 'bra-bfr', + * 'addeddate', + * ); + * added.success?.value; // Date + * ``` + * + * A field `Metadata` doesn't declare isn't reachable here — add a typed + * getter there rather than working around it, so every caller shares one + * definition of a field's type. For anything that isn't an item metadata + * field (`files_count`, `server`, `files/0/name`), use + * {@link fetchMetadataValue}. + * + * A value the field's parser rejects leaves `value` undefined while + * `rawValue` keeps the original, and an empty array leaves `value` undefined + * too; `values.length` tells those apart. + * + * Errors match {@link fetchMetadataValue}. Note the API reports both an + * unknown identifier and an unknown field as a payload `error`, which + * surfaces as `searchEngineError` rather than `itemNotFound`. + * + * @param identifier + * @param field Name of a field declared on `Metadata` + */ + fetchMetadataField( + identifier: string, + field: K, + ): Promise, MetadataServiceError>>; } diff --git a/src/metadata-service.ts b/src/metadata-service.ts index cbd8f09..a5ad1f5 100644 --- a/src/metadata-service.ts +++ b/src/metadata-service.ts @@ -1,3 +1,5 @@ +import { Metadata } from '@internetarchive/iaux-item-metadata'; +import type { MetadataFieldKey } from './metadata-field-key'; import type { Result } from '@internetarchive/result-type'; import { DefaultMetadataBackend } from './backend/default-metadata-backend'; import { MetadataBackendInterface } from './backend/metadata-backend-interface'; @@ -60,4 +62,65 @@ export class MetadataService implements MetadataServiceInterface { return { success: result.success.result }; } + + /** @inheritdoc */ + async fetchMetadataField( + identifier: string, + field: K, + ): Promise, MetadataServiceError>> { + const result = await this.fetchMetadataValue( + identifier, + `metadata/${field}`, + ); + if (result.error) { + return { error: result.error }; + } + + // Required for narrowing. The backend maps a missing item or field to an + // error rather than an absent result, so this is defensive. + if (result.success === undefined) { + return { + error: new MetadataServiceError(MetadataServiceErrorType.itemNotFound), + }; + } + + if (!MetadataService.isFieldValue(result.success)) { + return { + error: new MetadataServiceError( + MetadataServiceErrorType.decodingError, + `Value of '${field}' for '${identifier}' is not a scalar or array of scalars`, + result.success, + ), + }; + } + + // Metadata's own getter supplies both the field class and its type, so the + // mapping from field name to field class lives in one place. + const modeled = new Metadata({ [field]: result.success })[field]; + if (!modeled) { + return { + error: new MetadataServiceError( + MetadataServiceErrorType.decodingError, + `Could not model '${field}' for '${identifier}'`, + result.success, + ), + }; + } + + return { success: modeled as NonNullable }; + } + + /** + * Whether a raw value is something a `MetadataField` can parse. + * + * Without this an object-valued field would still construct, and + * `String(value)` would quietly yield `'[object Object]'` inside an otherwise + * successful result. + */ + private static isFieldValue(value: unknown): boolean { + const isScalar = (v: unknown): boolean => + typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'; + if (Array.isArray(value)) return value.every(isScalar); + return isScalar(value); + } } diff --git a/test/metadata-service.test.ts b/test/metadata-service.test.ts index e66b26e..a1f001c 100644 --- a/test/metadata-service.test.ts +++ b/test/metadata-service.test.ts @@ -71,6 +71,104 @@ describe('MetadataService', () => { }); }); + describe('fetchMetadataField', () => { + class MockMetadataBackend implements MetadataBackendInterface { + response: any; + async fetchMetadata( + identifier: string, + keypath?: string, + ): Promise> { + return { success: { result: this.response } }; + } + } + + function serviceReturning(response: any): MetadataService { + const backend = new MockMetadataBackend(); + backend.response = response; + return new MetadataService(backend); + } + + it('models a field the API sent as an array', async () => { + const service = serviceReturning(['kodi_archive', 'community']); + + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success?.value).to.equal('kodi_archive'); + expect(result.success?.values).to.deep.equal([ + 'kodi_archive', + 'community', + ]); + }); + + it('models a field the API sent as a bare string', async () => { + // An item in a single collection gets a string rather than a list, which + // is the case that makes reading the raw value directly unsafe. + const service = serviceReturning('audio'); + + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success?.value).to.equal('audio'); + expect(result.success?.values).to.deep.equal(['audio']); + }); + + it('casts according to the field, without being told which', async () => { + const service = serviceReturning('2018-08-13 10:08:32'); + + const result = await service.fetchMetadataField('foo', 'addeddate'); + expect(result.success?.value).to.be.instanceOf(Date); + expect(result.success?.value?.getUTCFullYear()).to.equal(2018); + }); + + it('leaves an out-of-range enum value undefined but keeps the raw', async () => { + const service = serviceReturning('not-a-mediatype'); + + const result = await service.fetchMetadataField('foo', 'mediatype'); + expect(result.success?.value).to.equal(undefined); + expect(result.success?.rawValue).to.equal('not-a-mediatype'); + }); + + it('keeps an empty array distinguishable from a rejected value', async () => { + const service = serviceReturning([]); + + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success?.value).to.equal(undefined); + expect(result.success?.values).to.deep.equal([]); + }); + + it('rejects a value that is not a scalar or array of scalars', async () => { + // Without this the field would still construct and String() would yield + // '[object Object]' inside a successful result. + const service = serviceReturning([{ reviewer: 'someone' }]); + + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal( + MetadataServiceErrorType.decodingError, + ); + }); + + it('passes a backend error through', async () => { + // What an unknown identifier or field actually produces: the API returns + // a payload `error` key, which the backend maps to searchEngineError. + class FailingBackend implements MetadataBackendInterface { + async fetchMetadata(): Promise> { + return { + error: new MetadataServiceError( + MetadataServiceErrorType.searchEngineError, + "Couldn't get part '/nope' of 'metadata' for item foo", + ), + }; + } + } + + const service = new MetadataService(new FailingBackend()); + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal( + MetadataServiceErrorType.searchEngineError, + ); + expect(result.error?.message).to.contain("Couldn't get part"); + }); + }); + it('returns an error result if the item is not found', async () => { class MockSearchBackend implements MetadataBackendInterface { async fetchMetadata( From bfb1cf600a5989fb114a8860e8a92a516ddcde6e Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Wed, 19 Aug 2026 17:04:35 -0700 Subject: [PATCH 2/3] WEBDEV-8920: Keep MetadataFieldKey internal It's derived from Metadata and describes Metadata's shape, so it belongs in iaux-item-metadata rather than here. Leaving it unexported means moving it there is an internal refactor instead of a breaking removal. Calling fetchMetadataField with a field-name literal doesn't need the name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017GxnijHsZmBrHkcuJ5XYf9 --- index.ts | 1 - src/metadata-field-key.ts | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 6cb0e37..a18f966 100644 --- a/index.ts +++ b/index.ts @@ -9,7 +9,6 @@ export { Review, SpeechMusicASREntry, } from '@internetarchive/iaux-item-metadata'; -export type { MetadataFieldKey } from './src/metadata-field-key'; export { DefaultMetadataBackend } from './src/backend/default-metadata-backend'; export { MetadataService } from './src/metadata-service'; diff --git a/src/metadata-field-key.ts b/src/metadata-field-key.ts index af62d80..70e3a46 100644 --- a/src/metadata-field-key.ts +++ b/src/metadata-field-key.ts @@ -10,6 +10,11 @@ import type { * * Derived from `Metadata` rather than listed here, so a field added there is * available without a matching change in this package. + * + * Internal on purpose. It's a fact about `Metadata`, so it belongs alongside + * `Metadata` in iaux-item-metadata; keeping it unexported here means moving it + * there later isn't a breaking change. Callers don't need the name to call + * `fetchMetadataField` with a field-name literal. */ export type MetadataFieldKey = { [K in keyof Metadata]-?: NonNullable< From 00ac134b18d168e44d015edafda861c6e678a54b Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Thu, 20 Aug 2026 15:49:36 -0700 Subject: [PATCH 3/3] WEBDEV-8920: Import MetadataFieldKey from item-metadata 1.5.0 The type lives next to Metadata now (WEBDEV-8923), so drop the local copy and take it from the package. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019VzfA4RksuDWvNvUq5oQgm --- package-lock.json | 8 ++++---- package.json | 2 +- src/metadata-field-key.ts | 25 ------------------------- src/metadata-service-interface.ts | 6 ++++-- src/metadata-service.ts | 6 ++++-- 5 files changed, 13 insertions(+), 34 deletions(-) delete mode 100644 src/metadata-field-key.ts diff --git a/package-lock.json b/package-lock.json index d047a6b..e17e8f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@internetarchive/field-parsers": "^0.1.4", - "@internetarchive/iaux-item-metadata": "^1.3.0", + "@internetarchive/iaux-item-metadata": "^1.5.0", "@internetarchive/result-type": "^0.0.1", "typescript-memoize": "^1.1.1" }, @@ -655,9 +655,9 @@ "license": "AGPL-3.0-only" }, "node_modules/@internetarchive/iaux-item-metadata": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@internetarchive/iaux-item-metadata/-/iaux-item-metadata-1.3.0.tgz", - "integrity": "sha512-T6qYs8i8b6PXTQEEpGZQUsFcg/FxpP9Zk+LWNJ4MWLWe4piRioPE2jReNlOwLX6nAx6yGcJ9BZQMDzmHbjK3jg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@internetarchive/iaux-item-metadata/-/iaux-item-metadata-1.5.0.tgz", + "integrity": "sha512-p1wK181a2qrHsSaL+yc6WG3JNbNlD+8g3wFKIIKzbT9ipfmsYnqjOlc8JlQT5OcXXMq4EYYCw21Agi//n9gaTw==", "license": "AGPL-3.0-only", "dependencies": { "@internetarchive/field-parsers": "^1.2.0", diff --git a/package.json b/package.json index 13bfcfe..33312db 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@internetarchive/field-parsers": "^0.1.4", - "@internetarchive/iaux-item-metadata": "^1.3.0", + "@internetarchive/iaux-item-metadata": "^1.5.0", "@internetarchive/result-type": "^0.0.1", "typescript-memoize": "^1.1.1" }, diff --git a/src/metadata-field-key.ts b/src/metadata-field-key.ts deleted file mode 100644 index 70e3a46..0000000 --- a/src/metadata-field-key.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - Metadata, - MetadataFieldInterface, -} from '@internetarchive/iaux-item-metadata'; - -/** - * The names of `Metadata`'s fields, i.e. its members that are a - * `MetadataField`. Excludes members like `identifier` and `rawMetadata`, which - * are a plain string and a plain record. - * - * Derived from `Metadata` rather than listed here, so a field added there is - * available without a matching change in this package. - * - * Internal on purpose. It's a fact about `Metadata`, so it belongs alongside - * `Metadata` in iaux-item-metadata; keeping it unexported here means moving it - * there later isn't a breaking change. Callers don't need the name to call - * `fetchMetadataField` with a field-name literal. - */ -export type MetadataFieldKey = { - [K in keyof Metadata]-?: NonNullable< - Metadata[K] - > extends MetadataFieldInterface - ? K - : never; -}[keyof Metadata]; diff --git a/src/metadata-service-interface.ts b/src/metadata-service-interface.ts index 13f786d..db6ef42 100644 --- a/src/metadata-service-interface.ts +++ b/src/metadata-service-interface.ts @@ -1,5 +1,7 @@ -import type { Metadata } from '@internetarchive/iaux-item-metadata'; -import type { MetadataFieldKey } from './metadata-field-key'; +import type { + Metadata, + MetadataFieldKey, +} from '@internetarchive/iaux-item-metadata'; import type { Result } from '@internetarchive/result-type'; import type { MetadataServiceError } from './metadata-service-error'; import type { MetadataResponse } from './responses/metadata-response'; diff --git a/src/metadata-service.ts b/src/metadata-service.ts index a5ad1f5..9b7792d 100644 --- a/src/metadata-service.ts +++ b/src/metadata-service.ts @@ -1,5 +1,7 @@ -import { Metadata } from '@internetarchive/iaux-item-metadata'; -import type { MetadataFieldKey } from './metadata-field-key'; +import { + Metadata, + type MetadataFieldKey, +} from '@internetarchive/iaux-item-metadata'; import type { Result } from '@internetarchive/result-type'; import { DefaultMetadataBackend } from './backend/default-metadata-backend'; import { MetadataBackendInterface } from './backend/metadata-backend-interface';