diff --git a/.changeset/assembly-response-readers.md b/.changeset/assembly-response-readers.md new file mode 100644 index 00000000..8610d519 --- /dev/null +++ b/.changeset/assembly-response-readers.md @@ -0,0 +1,12 @@ +--- +'@transloadit/node': major +'transloadit': major +'@transloadit/types': major +'@transloadit/zod': major +'@transloadit/mcp-server': minor +'@transloadit/notify-url-relay': minor +--- + +Correct Assembly response readers to preserve numeric State/XPSubject metadata, repeated date tags and null uploaded-file extensions already emitted by the API. Read nonempty future Assembly error codes without confusing the closed known-code inventory with the public response contract; terminal-error helpers now recognize those responses. + +These corrections widen exported response types. Consumers must handle numeric metadata, arrays of date values, nullable uploaded-file extensions and unknown error strings. The MCP server and notification relay follow the corrected response types in pre-1.0 minor releases; the relay publicly exports its AssemblyResponse alias. See the [Assembly response migration guide](https://github.com/transloadit/node-sdk/blob/main/docs/monorepo-architecture.md#assembly-response-migration) for the affected fields and narrowing rules. diff --git a/docs/fingerprint/transloadit-baseline.json b/docs/fingerprint/transloadit-baseline.json index c5ec07cd..dc65251c 100644 --- a/docs/fingerprint/transloadit-baseline.json +++ b/docs/fingerprint/transloadit-baseline.json @@ -2,8 +2,8 @@ "packageDir": "packages/transloadit", "tarball": { "filename": "transloadit-5.2.0.tgz", - "sizeBytes": 1223961, - "sha256": "0fa25ca8a3eb974bcfef882656e04519ebeacbd1656233c7fc5a8ee911bbd428" + "sizeBytes": 1219673, + "sha256": "0634ee4d41291f69f18a6ff8ef7e9dc1014d7863873f3bb41001c7ad33daecb5" }, "packageJson": { "name": "transloadit", @@ -108,8 +108,8 @@ }, { "path": "dist/alphalib/types/assemblyStatus.js", - "sizeBytes": 41658, - "sha256": "f41f1c3ddf09ecc725e91c54679db640eec26abf71c97426bb075f2be6da64fa" + "sizeBytes": 42153, + "sha256": "6352ea88a66e33fd9f878435df3b7a6506db56eb1c5ebe62fd46fe3d0dd82263" }, { "path": "dist/alphalib/types/assemblyUrls.js", @@ -1169,12 +1169,12 @@ { "path": "dist/alphalib/types/assemblyStatus.d.ts.map", "sizeBytes": 31203, - "sha256": "0ae2a156fd26169a8201afec4490adc9d7d1ead0b7e79f3fb79162705ff3c5a3" + "sha256": "c1d257cbdb846f803ac201e705dcce5a8695e424a54b22b380577ea9113b2ec8" }, { "path": "dist/alphalib/types/assemblyStatus.js.map", - "sizeBytes": 39324, - "sha256": "6c47d5bf9deae9d91a09f730f9f8bb21e1c905297f8a507dc56856a60ab135bc" + "sizeBytes": 39445, + "sha256": "b7ae92551756159b26a1089a248a51c942946ae403db5a653da88d30bbd68927" }, { "path": "dist/alphalib/types/assemblyUrls.d.ts.map", @@ -3113,13 +3113,13 @@ }, { "path": "dist/alphalib/types/assemblyStatus.d.ts", - "sizeBytes": 1703569, - "sha256": "340371dcb36f98a2d1693f494d3a62447a2aba5bc6789285326d78a09a6302a5" + "sizeBytes": 1649923, + "sha256": "28231711c7d17de26d50bb175fe0c9ab61e09e4425420b0a6e37a9faa25a9523" }, { "path": "src/alphalib/types/assemblyStatus.ts", - "sizeBytes": 43747, - "sha256": "69b682839cb50fd585938db500ffb4eea0322f4d9ac4b927c6b19d5897072880" + "sizeBytes": 44237, + "sha256": "6518832c25c9261c6064f5ded38b25e2c14eaf7285c78f8d244e921ac757977f" }, { "path": "dist/alphalib/types/assemblyUrls.d.ts", diff --git a/docs/monorepo-architecture.md b/docs/monorepo-architecture.md index 4a612e47..6418b1f4 100644 --- a/docs/monorepo-architecture.md +++ b/docs/monorepo-architecture.md @@ -200,3 +200,28 @@ This is now a manual/release-time tool for investigating publish drift or intent - [x] Add a sync-v4 regression fixture to verify `.passthrough()` → `.catchall(z.unknown())` and `z.record(...)` key injection - [x] Add runtime parity coverage for `assemblyStatus` (ok/error/busy) once stable samples are selected - [x] Add a CI guard that `@transloadit/zod` exports list stays in sync with `packages/node/src/alphalib/types` + +## Assembly response migration + +The response-reader correction is a breaking type release for `@transloadit/node`, `transloadit`, +`@transloadit/types` and `@transloadit/zod`; the pre-1.0 MCP server and notification relay follow in minor releases. The relay publicly +exports its `AssemblyResponse` alias, so its consumers see the same type widening. +The API already emits these values, and response validation now preserves them without coercion: + +- `meta.state` and `meta.xp_subject` can be finite numbers as well as strings, null or omitted. + Narrow with `typeof value === 'number'` before using string methods. +- `date_file_modified`, `date_recorded`, `date_file_created`, `create_date` and `modify_date` can + contain a string, a finite number, an array of those values, null or no value. Use `Array.isArray` + before treating a repeated metadata tag as a scalar; numeric values are preserved rather than + converted into an assumed timestamp format. +- A completed uploaded file can have `ext: null`. Check for null before calling extension string + methods; null does not mean an empty extension. +- Public Assembly status and list response errors are nonempty strings, including future server + codes. Exhaustive switches need a default branch. `hasError(status, code)` accepts an arbitrary + string, and terminal-error helpers recognize future errors. `assemblyStatusErrCodeSchema`, + `AssemblyStatusErrCode` and `isAssemblyErrorStatus` still describe only the closed known-code + inventory; use them when an application specifically needs to narrow to a known code. + +Empty errors, nonfinite metadata numbers, booleans and nested date arrays remain invalid. The HTTP +status and paginated-list regression fixtures protect the same contract as the generated Zod v3/v4 +schemas and public TypeScript declarations. This change does not publish a package automatically. diff --git a/packages/node/src/alphalib/types/assemblyStatus.ts b/packages/node/src/alphalib/types/assemblyStatus.ts index 09d9ab23..25a4e8c8 100644 --- a/packages/node/src/alphalib/types/assemblyStatus.ts +++ b/packages/node/src/alphalib/types/assemblyStatus.ts @@ -401,6 +401,9 @@ export const assemblyStatusErrCodeSchema = z.enum([ export type AssemblyStatusErrCode = z.infer const metadataTextSchema = z.union([z.string(), z.number().finite()]) +const metadataDateSchema = z.union([metadataTextSchema, z.array(metadataTextSchema)]) +// Readers must survive new server errors; the exported known-code enum remains closed. +const publicAssemblyErrorCodeSchema = z.string().min(1) const assemblyStatusMetaSchema = z .object({ @@ -408,7 +411,7 @@ const assemblyStatusMetaSchema = z has_alpha: storedAssetSchema.shape.has_alpha, width: z.union([z.number(), z.null()]).optional(), height: z.union([z.number(), z.null()]).optional(), - date_file_modified: z.string().nullable().optional(), + date_file_modified: metadataDateSchema.nullable().optional(), aspect_ratio: z.union([z.number(), z.string(), z.null()]).optional(), has_clipping_path: z.boolean().optional(), frame_count: z.union([z.number(), z.null()]).optional(), @@ -417,14 +420,14 @@ const assemblyStatusMetaSchema = z average_color: z.string().nullable().optional(), svgViewBoxWidth: z.union([z.number(), z.null()]).optional(), svgViewBoxHeight: z.union([z.number(), z.null()]).optional(), - date_recorded: z.union([z.string(), z.number()]).nullable().optional(), - date_file_created: metadataTextSchema.nullable().optional(), + date_recorded: metadataDateSchema.nullable().optional(), + date_file_created: metadataDateSchema.nullable().optional(), title: z.union([z.string(), z.number()]).nullable().optional(), description: metadataTextSchema.nullable().optional(), duration: z.union([z.number(), z.null()]).optional(), location: z.string().nullable().optional(), city: z.string().nullable().optional(), - state: z.string().nullable().optional(), + state: metadataTextSchema.nullable().optional(), rights: z.union([z.string(), z.number()]).nullable().optional(), country: z.string().nullable().optional(), country_code: z.string().nullable().optional(), @@ -471,7 +474,7 @@ const assemblyStatusMetaSchema = z xp_title: metadataTextSchema.nullable().optional(), xp_comment: z.string().nullable().optional(), xp_keywords: metadataTextSchema.nullable().optional(), - xp_subject: z.string().nullable().optional(), + xp_subject: metadataTextSchema.nullable().optional(), recognized_text: z .union([ z.array(z.string()), @@ -562,8 +565,8 @@ const assemblyStatusMetaSchema = z page_count: z.union([z.number(), z.null()]).optional(), page_size: z.string().nullable().optional(), producer: z.string().nullable().optional(), - create_date: metadataTextSchema.nullable().optional(), - modify_date: z.union([z.string(), z.number()]).nullable().optional(), + create_date: metadataDateSchema.nullable().optional(), + modify_date: metadataDateSchema.nullable().optional(), colortransfer: z.string().nullable().optional(), colorprimaries: z.string().nullable().optional(), archive_directory: z.string().nullable().optional(), @@ -657,7 +660,7 @@ export const assemblyStatusUploadSchema = z has_alpha: storedAssetSchema.shape.has_alpha, name: z.string().nullable(), basename: z.string().nullable(), - ext: z.string(), + ext: z.string().nullable(), size: z.number(), mime: z.string().nullable(), type: z.string().nullable(), @@ -920,7 +923,7 @@ export const assemblyStatusOkSchema: AssemblyStatusVariant<{ .passthrough() const assemblyStatusErrorFields = z.object({ - error: assemblyStatusErrCodeSchema, + error: publicAssemblyErrorCodeSchema, ok: z.null().optional(), retries: z.number().optional(), numRetries: z.number().optional(), @@ -998,12 +1001,13 @@ export type AssemblyStatus = z.infer */ export function hasError( assembly: AssemblyStatus | undefined | null, - particularErrorCode?: z.infer, + // Keep known-code suggestions while matching future public response codes too. + particularErrorCode?: AssemblyStatusErrCode | (string & {}), ): assembly is AssemblyStatusError { const errorExists = Boolean(assembly) && assembly != null && typeof assembly === 'object' && 'error' in assembly - if (particularErrorCode) { + if (particularErrorCode !== undefined) { return errorExists && assembly.error === particularErrorCode } @@ -1081,7 +1085,7 @@ export function isAssemblyOkStatus( } /** - * Type guard to check if a status string is an error state. + * Type guard to check if a status string is a known error code. */ export function isAssemblyErrorStatus( status: string | undefined | null, @@ -1125,7 +1129,11 @@ export function isAssemblyTerminalOk(assembly: AssemblyStatus | undefined | null * Returns true if the assembly has a terminal error state. */ export function isAssemblyTerminalError(assembly: AssemblyStatus | undefined | null): boolean { - return isAssemblyErrorStatus(getError(assembly)) || isAssemblySysError(assembly) + // getError() historically stringifies explicit undefined; classification needs the raw value. + return ( + (hasError(assembly) && publicAssemblyErrorCodeSchema.safeParse(assembly.error).success) || + isAssemblySysError(assembly) + ) } /** @@ -1187,7 +1195,7 @@ export const assemblyIndexItemSchema = z bytes_received: assemblyStatusBaseSchema.shape.bytes_received.optional(), upload_duration: assemblyStatusBaseSchema.shape.upload_duration.optional(), ok: assemblyStatusOkCodeSchema.nullable().optional(), - error: assemblyStatusErrCodeSchema.nullable().optional(), + error: publicAssemblyErrorCodeSchema.nullable().optional(), created: z.string(), created_ts: z.number().optional(), template_name: z.string().nullable().optional(), diff --git a/packages/node/test/unit/alphalib-lean-sync.test.ts b/packages/node/test/unit/alphalib-lean-sync.test.ts index c419aad1..063e2161 100644 --- a/packages/node/test/unit/alphalib-lean-sync.test.ts +++ b/packages/node/test/unit/alphalib-lean-sync.test.ts @@ -281,7 +281,7 @@ describe('lean Assembly Status compatibility', () => { }) it.each([ - { error: 'NOT_AN_ASSEMBLY_ERROR' }, + { error: '' }, { ok: 'ASSEMBLY_COMPLETED', error: 'HTTP_REQUEST_FAILURE' }, { ok: 'ASSEMBLY_COMPLETED', uploads: {} }, { ok: 'ASSEMBLY_COMPLETED', uploads: [{}] }, diff --git a/packages/node/test/unit/assembly-status-responses.test.ts b/packages/node/test/unit/assembly-status-responses.test.ts new file mode 100644 index 00000000..5bd05b67 --- /dev/null +++ b/packages/node/test/unit/assembly-status-responses.test.ts @@ -0,0 +1,160 @@ +import nock from 'nock' +import { afterEach, describe, expect, it } from 'vitest' + +import { + assemblyIndexSchema, + assemblyStatusErrCodeSchema, + assemblyStatusSchema, + getError, + hasError, + isAssemblyTerminal, + isAssemblyTerminalError, +} from '../../src/alphalib/types/assemblyStatus.ts' +import { Transloadit } from '../../src/Transloadit.ts' + +const upload = { + id: 'upload-1', + original_id: 'upload-1', + name: 'fixture.jpg', + basename: 'fixture', + ext: 'jpg', + size: 100, + mime: 'image/jpeg', + type: 'image', + field: null, + url: null, + meta: {}, +} +const dates = ['2026:01:02 03:04:05', 1767312245] +const metadata = { + state: 42, + xp_subject: 1984, + date_file_modified: dates, + date_recorded: dates, + date_file_created: dates, + create_date: dates, + modify_date: dates, +} + +afterEach(() => nock.cleanAll()) + +describe('Assembly response compatibility', () => { + it.each([ + { state: 0 }, + { state: -1.5 }, + { xp_subject: 0 }, + { xp_subject: 123 }, + { date_file_modified: dates }, + { date_recorded: dates }, + { date_file_created: dates }, + { create_date: dates }, + { modify_date: dates }, + ])('preserves emitted metadata %j in uploads and results', (meta) => { + const status = { + ok: 'ASSEMBLY_COMPLETED', + uploads: [{ ...upload, meta }], + results: { output: [{ meta }] }, + } + expect(assemblyStatusSchema.parse(status)).toStrictEqual(status) + }) + + it.each([ + 'ASSEMBLY_UPLOADING', + 'ASSEMBLY_EXECUTING', + 'ASSEMBLY_REPLAYING', + ])('keeps %s responses with an explicit undefined error busy', (ok) => { + const status = assemblyStatusSchema.parse({ ok, error: undefined }) + expect(isAssemblyTerminalError(status)).toBe(false) + expect(isAssemblyTerminal(status)).toBe(false) + }) + + it('keeps a completed response with an undefined error successful', () => { + const status = assemblyStatusSchema.parse({ ok: 'ASSEMBLY_COMPLETED', error: undefined }) + expect(isAssemblyTerminalError(status)).toBe(false) + expect(isAssemblyTerminal(status)).toBe(true) + }) + + it('preserves null extensions in completed uploaded files', () => { + const status = { ok: 'ASSEMBLY_COMPLETED', uploads: [{ ...upload, ext: null }] } + expect(assemblyStatusSchema.parse(status)).toStrictEqual(status) + }) + + it('retains a closed known-code inventory while reading future nonempty errors', () => { + const error = 'FUTURE_ASSEMBLY_ERROR' + const status = assemblyStatusSchema.parse({ error, assembly_id: 'assembly-1' }) + expect(getError(status)).toBe(error) + expect(hasError(status)).toBe(true) + expect(hasError(status, error)).toBe(true) + expect(hasError(status, '')).toBe(false) + expect(isAssemblyTerminalError(status)).toBe(true) + expect(assemblyIndexSchema.parse([{ id: 'assembly-1', created: '2026-10-08', error }])).toEqual( + [{ id: 'assembly-1', created: '2026-10-08', error }], + ) + expect(assemblyStatusErrCodeSchema.safeParse(error).success).toBe(false) + expect(assemblyStatusErrCodeSchema.options).toHaveLength(371) + }) + + it.each([ + { state: true }, + { state: Number.POSITIVE_INFINITY }, + { xp_subject: [] }, + { xp_subject: Number.NaN }, + { create_date: [true] }, + { modify_date: [{}] }, + { date_recorded: [Number.POSITIVE_INFINITY] }, + { date_file_modified: [[dates]] }, + ])('rejects malformed emitted metadata %j', (meta) => { + expect( + assemblyStatusSchema.safeParse({ ok: 'ASSEMBLY_COMPLETED', results: { output: [{ meta }] } }) + .success, + ).toBe(false) + }) + + it.each(['', 42])('rejects invalid public errors %j in statuses and lists', (error) => { + expect(assemblyStatusSchema.safeParse({ error }).success).toBe(false) + expect( + assemblyIndexSchema.safeParse([{ id: 'assembly-1', created: '2026-10-08', error }]).success, + ).toBe(false) + }) +}) + +describe('SDK response validation over HTTP', () => { + const origin = 'http://127.0.0.1:9' + const client = new Transloadit({ + endpoint: origin, + authKey: 'test-key', + authSecret: 'test-secret', + validateResponses: true, + }) + + it('reads current metadata and null extensions without coercing the response', async () => { + const status = { + assembly_id: 'assembly-1', + assembly_url: `${origin}/assemblies/assembly-1`, + assembly_ssl_url: `${origin}/assemblies/assembly-1`, + ok: 'ASSEMBLY_COMPLETED', + uploads: [{ ...upload, ext: null, meta: metadata }], + results: { output: [{ meta: metadata }] }, + } + const request = nock(origin).get('/assemblies/assembly-1').query(true).reply(200, status) + await expect(client.getAssembly('assembly-1')).resolves.toStrictEqual(status) + expect(request.isDone()).toBe(true) + }) + + it('reads future Assembly errors through both status and paginated list APIs', async () => { + const error = 'FUTURE_ASSEMBLY_ERROR' + const status = { + error, + assembly_id: 'assembly-1', + assembly_url: `${origin}/assemblies/assembly-1`, + assembly_ssl_url: `${origin}/assemblies/assembly-1`, + } + const index = { items: [{ id: 'assembly-1', created: '2026-10-08', error }], count: 1 } + const statusRequest = nock(origin).get('/assemblies/assembly-1').query(true).reply(200, status) + const listRequest = nock(origin).get('/assemblies').query(true).reply(200, index) + await expect(client.getAssembly('assembly-1')).resolves.toStrictEqual(status) + await expect(client.listAssemblies()).resolves.toStrictEqual(index) + expect(statusRequest.isDone()).toBe(true) + expect(listRequest.isDone()).toBe(true) + }) +}) diff --git a/packages/zod/test/runtime-parity.test.ts b/packages/zod/test/runtime-parity.test.ts index 73e05124..0a4b4e97 100644 --- a/packages/zod/test/runtime-parity.test.ts +++ b/packages/zod/test/runtime-parity.test.ts @@ -64,6 +64,40 @@ for (const { version, schema } of [ { version: 'v3', schema: v3AssemblyStatus }, { version: 'v4', schema: v4AssemblyStatus }, ]) { + test(`${version} response metadata, nullable extensions and future errors preserve values`, () => { + const dates = ['2026:01:02 03:04:05', 1767312245] + const meta = { + state: 0, + xp_subject: 1984, + date_file_modified: dates, + date_recorded: dates, + date_file_created: dates, + create_date: dates, + modify_date: dates, + } + const status = { + ok: 'ASSEMBLY_COMPLETED', + uploads: [{ original_id: 'upload-1', ext: null, meta }], + results: { output: [{ meta }] }, + } + assert.deepEqual(schema.parse(status), status) + const failure = { error: 'FUTURE_ASSEMBLY_ERROR' } + assert.deepEqual(schema.parse(failure), failure) + assert.equal(schema.safeParse({ error: '' }).success, false) + for (const invalid of [ + { state: true }, + { xp_subject: Number.NaN }, + { date_recorded: [Number.POSITIVE_INFINITY] }, + { modify_date: [[dates]] }, + ]) { + assert.equal( + schema.safeParse({ ok: 'ASSEMBLY_COMPLETED', results: { output: [{ meta: invalid }] } }) + .success, + false, + ) + } + }) + test(`${version} XPKeywords preserves strings, finite numbers and absent values`, () => { for (const meta of [ {}, diff --git a/packages/zod/test/type-equality-v3.ts b/packages/zod/test/type-equality-v3.ts index e60ea615..c9b07b60 100644 --- a/packages/zod/test/type-equality-v3.ts +++ b/packages/zod/test/type-equality-v3.ts @@ -1,8 +1,18 @@ -import type { AssemblyStatus, AssemblyStatusMeta } from '@transloadit/types/assemblyStatus' +import type { + AssemblyIndexItem, + AssemblyStatus, + AssemblyStatusError, + AssemblyStatusMeta, + AssemblyStatusUpload, +} from '@transloadit/types/assemblyStatus' import type { AssemblyInstructions } from '@transloadit/types/template' import type { z } from 'zod/v3' -import type { assemblyStatusMetaSchema, assemblyStatusSchema } from '../src/v3/assemblyStatus.ts' +import type { + assemblyIndexItemSchema, + assemblyStatusMetaSchema, + assemblyStatusSchema, +} from '../src/v3/assemblyStatus.ts' import type { assemblyInstructionsSchema } from '../src/v3/template.ts' type Equal = @@ -26,3 +36,29 @@ export type _AssemblyStatusCheck = Assert< export type _AssemblyInstructionsCheck = Assert< Equal> > + +export type _StateTypeCheck = Assert< + Equal +> +export type _XPSubjectTypeCheck = Assert< + Equal +> +export type _DateTypeCheck = Assert< + Equal< + AssemblyStatusMeta[ + | 'date_file_modified' + | 'date_recorded' + | 'date_file_created' + | 'create_date' + | 'modify_date'], + string | number | (string | number)[] | null | undefined + > +> +export type _ExtensionTypeCheck = Assert> +export type _ErrorTypeCheck = Assert> +export type _ListErrorTypeCheck = Assert< + Equal +> +export type _AssemblyIndexCheck = Assert< + Equal> +> diff --git a/packages/zod/test/type-equality-v4.ts b/packages/zod/test/type-equality-v4.ts index da48b8dc..43152eae 100644 --- a/packages/zod/test/type-equality-v4.ts +++ b/packages/zod/test/type-equality-v4.ts @@ -1,8 +1,18 @@ -import type { AssemblyStatus, AssemblyStatusMeta } from '@transloadit/types/assemblyStatus' +import type { + AssemblyIndexItem, + AssemblyStatus, + AssemblyStatusError, + AssemblyStatusMeta, + AssemblyStatusUpload, +} from '@transloadit/types/assemblyStatus' import type { AssemblyInstructions } from '@transloadit/types/template' import type { z } from 'zod/v4' -import type { assemblyStatusMetaSchema, assemblyStatusSchema } from '../src/v4/assemblyStatus.ts' +import type { + assemblyIndexItemSchema, + assemblyStatusMetaSchema, + assemblyStatusSchema, +} from '../src/v4/assemblyStatus.ts' import type { assemblyInstructionsSchema } from '../src/v4/template.ts' type Equal = [A] extends [B] ? ([B] extends [A] ? true : false) : false @@ -25,3 +35,29 @@ export type _AssemblyStatusCheck = Assert< export type _AssemblyInstructionsCheck = Assert< Equal> > + +export type _StateTypeCheck = Assert< + Equal +> +export type _XPSubjectTypeCheck = Assert< + Equal +> +export type _DateTypeCheck = Assert< + Equal< + AssemblyStatusMeta[ + | 'date_file_modified' + | 'date_recorded' + | 'date_file_created' + | 'create_date' + | 'modify_date'], + string | number | (string | number)[] | null | undefined + > +> +export type _ExtensionTypeCheck = Assert> +export type _ErrorTypeCheck = Assert> +export type _ListErrorTypeCheck = Assert< + Equal +> +export type _AssemblyIndexCheck = Assert< + Equal> +>