Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/assembly-response-readers.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 11 additions & 11 deletions docs/fingerprint/transloadit-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -108,8 +108,8 @@
},
{
"path": "dist/alphalib/types/assemblyStatus.js",
"sizeBytes": 41658,
"sha256": "f41f1c3ddf09ecc725e91c54679db640eec26abf71c97426bb075f2be6da64fa"
"sizeBytes": 42153,
"sha256": "6352ea88a66e33fd9f878435df3b7a6506db56eb1c5ebe62fd46fe3d0dd82263"
},
{
"path": "dist/alphalib/types/assemblyUrls.js",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions docs/monorepo-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
36 changes: 22 additions & 14 deletions packages/node/src/alphalib/types/assemblyStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,14 +401,17 @@ export const assemblyStatusErrCodeSchema = z.enum([
export type AssemblyStatusErrCode = z.infer<typeof assemblyStatusErrCodeSchema>

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({
thumbhash: storedAssetSchema.shape.thumbhash,
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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -998,12 +1001,13 @@ export type AssemblyStatus = z.infer<typeof assemblyStatusSchema>
*/
export function hasError(
assembly: AssemblyStatus | undefined | null,
particularErrorCode?: z.infer<typeof assemblyStatusErrCodeSchema>,
// 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
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
)
}

/**
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion packages/node/test/unit/alphalib-lean-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [{}] },
Expand Down
160 changes: 160 additions & 0 deletions packages/node/test/unit/assembly-status-responses.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading