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
21 changes: 21 additions & 0 deletions .changeset/format-asset-slot-codegen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@adcp/sdk': minor
---

feat(types): tighten `Format.assets` typing and emit named slot unions + Zod schemas

Closes adcontextprotocol/adcp-client#1652.

The codegen post-processor that restored the `asset_type` discriminator on `Individual*Asset` slot types (#1498) now also:

- Imports the `*AssetRequirements` types into `tools.generated.ts` from `core.generated.ts`, so the per-slot `requirements?:` field is preserved on every `IndividualImageAsset` / `IndividualVideoAsset` / … exported from the tools surface (previously missing — a downstream cast pattern).
- Restores the same `asset_type` + `requirements` discriminator on the 12 `Group*Asset` shapes inside `RepeatableGroupAsset.assets[]`.
- Emits named `IndividualAssetSlot`, `GroupAssetSlot`, and `FormatAssetSlot` unions in both `core.generated.ts` and `tools.generated.ts`, and tightens `Format.assets?: FormatAssetSlot[]` and `RepeatableGroupAsset.assets: GroupAssetSlot[]` to reference them.
- ts-to-zod picks up the named unions, so the generated schemas now include `IndividualAssetSlotSchema`, `GroupAssetSlotSchema`, `FormatAssetSlotSchema`, and per-type `IndividualImageAssetSchema { asset_type, requirements }` / `GroupImageAssetSchema { asset_type, requirements }` carrying the requirements branch.

Consumer impact:

- `Format.assets[i]` narrows correctly: `slot.asset_type === 'image'` now gives `slot.requirements: ImageAssetRequirements | undefined` for free. The cast pattern from #1652's worked example goes away.
- New runtime-validation entry points: import `IndividualAssetSlotSchema`, `GroupAssetSlotSchema`, or `FormatAssetSlotSchema` from `@adcp/sdk` instead of forking a local `z.union([...])` over the per-type schemas.
- The hand-authored `src/lib/types/format-asset-slots.ts` shim is reduced to thin `*Slot` aliases over the codegen names. Consumers importing `IndividualImageAssetSlot` etc. continue to work; the underlying type is now identical to the spec-derived `IndividualImageAsset`. **Optionality note:** the prior hand-authored shim modeled `requirements` as required on the `*Slot` types; it is now optional (`?:`) to match the spec, where the field appears in `properties` but never in `required`. Adopters that destructured `slot.requirements` and treated the value as defined will need to handle `undefined` or assert.
- Two no-op group builders (`briefGroupAsset`, `catalogGroupAsset`) are removed from `@adcp/sdk`'s public exports. The spec doesn't include `brief` or `catalog` in `RepeatableGroupAsset.assets[].oneOf`, so calling these always produced an object that would fail wire-schema validation. Treated as a bug-fix removal under the minor bump rather than a breaking contract change. The 12 valid `*GroupAsset` builders (and their `FormatAsset.group*` namespace entries) are unchanged.
116 changes: 109 additions & 7 deletions scripts/generate-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1481,24 +1481,126 @@ const INDIVIDUAL_ASSET_DISCRIMINATORS: Array<{ name: string; assetType: string;
{ name: 'IndividualCatalogAsset', assetType: 'catalog' },
];

const GROUP_ASSET_DISCRIMINATORS: Array<{ name: string; assetType: string; requirementsType: string }> = [
{ name: 'GroupImageAsset', assetType: 'image', requirementsType: 'ImageAssetRequirements' },
{ name: 'GroupVideoAsset', assetType: 'video', requirementsType: 'VideoAssetRequirements' },
{ name: 'GroupAudioAsset', assetType: 'audio', requirementsType: 'AudioAssetRequirements' },
{ name: 'GroupTextAsset', assetType: 'text', requirementsType: 'TextAssetRequirements' },
{ name: 'GroupMarkdownAsset', assetType: 'markdown', requirementsType: 'MarkdownAssetRequirements' },
{ name: 'GroupHtmlAsset', assetType: 'html', requirementsType: 'HTMLAssetRequirements' },
{ name: 'GroupCssAsset', assetType: 'css', requirementsType: 'CSSAssetRequirements' },
{ name: 'GroupJavaScriptAsset', assetType: 'javascript', requirementsType: 'JavaScriptAssetRequirements' },
{ name: 'GroupVastAsset', assetType: 'vast', requirementsType: 'VASTAssetRequirements' },
{ name: 'GroupDaastAsset', assetType: 'daast', requirementsType: 'DAASTAssetRequirements' },
{ name: 'GroupUrlAsset', assetType: 'url', requirementsType: 'URLAssetRequirements' },
{ name: 'GroupWebhookAsset', assetType: 'webhook', requirementsType: 'WebhookAssetRequirements' },
];

export function applyIndividualAssetDiscriminators(typeDefinitions: string): string {
let result = typeDefinitions;
let rewritten = 0;
// The *AssetRequirements interfaces are declared in core.generated.ts. When this
// post-processor runs against tools.generated.ts, those interfaces aren't local —
// we inject an import so `requirements?: ImageAssetRequirements` references resolve
// and TS + ts-to-zod (and downstream Format.assets[] consumers) see the full shape.
const requirementsLocallyDeclared = /^export (interface|type) ImageAssetRequirements\b/m.test(result);
const importedRequirementsTypes: string[] = [];

for (const entry of INDIVIDUAL_ASSET_DISCRIMINATORS) {
const aliasPattern = new RegExp(`^export type ${entry.name} = BaseIndividualAsset;$`, 'm');
if (!aliasPattern.test(result)) continue;
// Only emit `requirements?:` if the requirements type is declared in this
// same file — the tools.generated.ts artifact reuses BaseIndividualAsset
// but does not redeclare the *AssetRequirements interfaces, so referencing
// them there triggers TS2304.
const reqsAvailable =
entry.requirementsType !== undefined &&
new RegExp(`^export interface ${entry.requirementsType}\\b`, 'm').test(result);
const reqsAvailable = entry.requirementsType !== undefined;
const reqsField = reqsAvailable ? `\n requirements?: ${entry.requirementsType};` : '';
const replacement = `export type ${entry.name} = BaseIndividualAsset & {\n asset_type: '${entry.assetType}';${reqsField}\n};`;
result = result.replace(aliasPattern, replacement);
rewritten++;
if (reqsAvailable && !requirementsLocallyDeclared && entry.requirementsType) {
importedRequirementsTypes.push(entry.requirementsType);
}
}

for (const entry of GROUP_ASSET_DISCRIMINATORS) {
const aliasPattern = new RegExp(`^export type ${entry.name} = BaseGroupAsset;$`, 'm');
if (!aliasPattern.test(result)) continue;
const replacement = `export type ${entry.name} = BaseGroupAsset & {\n asset_type: '${entry.assetType}';\n requirements?: ${entry.requirementsType};\n};`;
result = result.replace(aliasPattern, replacement);
rewritten++;
if (!requirementsLocallyDeclared) {
importedRequirementsTypes.push(entry.requirementsType);
}
}

// tools.generated.ts has no existing `import` statements — we only inject one when
// we actually emitted a reference to a non-local requirements type.
if (importedRequirementsTypes.length > 0) {
const sortedImports = [...new Set(importedRequirementsTypes)].sort();
const importBlock = `import type {\n${sortedImports.map(name => ` ${name},`).join('\n')}\n} from './core.generated';\n\n`;
result = importBlock + result;
}

// Emit named union aliases for the slot shapes so ts-to-zod produces named
// schemas (IndividualAssetSlotSchema, FormatAssetSlotSchema) and consumers
// can import the unions directly. Only emit when the constituent types are
// present in this file — keeps the post-processor a no-op on unrelated files.
const allIndividualPresent = INDIVIDUAL_ASSET_DISCRIMINATORS.every(entry =>
new RegExp(`^export type ${entry.name} = BaseIndividualAsset & \\{`, 'm').test(result)
);
const repeatableGroupPresent = /^export interface RepeatableGroupAsset \{/m.test(result);

if (allIndividualPresent && repeatableGroupPresent && !/^export type IndividualAssetSlot\b/m.test(result)) {
const slotUnion = [
`export type IndividualAssetSlot =`,
...INDIVIDUAL_ASSET_DISCRIMINATORS.map((entry, i) => {
const tail = i === INDIVIDUAL_ASSET_DISCRIMINATORS.length - 1 ? ';' : '';
return ` | ${entry.name}${tail}`;
}),
``,
`export type GroupAssetSlot =`,
...GROUP_ASSET_DISCRIMINATORS.map((entry, i) => {
const tail = i === GROUP_ASSET_DISCRIMINATORS.length - 1 ? ';' : '';
return ` | ${entry.name}${tail}`;
}),
``,
`export type FormatAssetSlot = IndividualAssetSlot | RepeatableGroupAsset;`,
``,
].join('\n');
result = result.trimEnd() + '\n\n' + slotUnion;

// Tighten Format.assets[] from the inline anonymous union to the named
// FormatAssetSlot[]. The codegen emits the inline union right after the
// 'Array of all assets supported for this format.' comment. If jsts ever
// changes its emitted indentation/wrapping the regex silently no-ops, so
// we count replacements and warn loudly — Format.assets[] would otherwise
// fall back to the loose anonymous union without TS surfacing it.
const formatAssetsPattern = new RegExp(
`( assets\\?: )\\(\\s*(?:\\|\\s*Individual\\w+Asset\\s*)+\\|\\s*RepeatableGroupAsset\\s*\\)\\[\\];`,
'g'
);
let formatAssetsReplaced = 0;
result = result.replace(formatAssetsPattern, (_match, prefix) => {
formatAssetsReplaced++;
return `${prefix}FormatAssetSlot[];`;
});
if (formatAssetsReplaced === 0) {
console.warn(
'⚠️ applyIndividualAssetDiscriminators: Format.assets[] inline union not rewritten — jsts output layout may have changed'
);
}

// Same for RepeatableGroupAsset.assets[] — replace the inline group union with GroupAssetSlot[].
const groupAssetsPattern = new RegExp(`( assets: )\\(\\s*(?:\\|\\s*Group\\w+Asset\\s*)+\\)\\[\\];`, 'g');
let groupAssetsReplaced = 0;
result = result.replace(groupAssetsPattern, (_match, prefix) => {
groupAssetsReplaced++;
return `${prefix}GroupAssetSlot[];`;
});
if (groupAssetsReplaced === 0) {
console.warn(
'⚠️ applyIndividualAssetDiscriminators: RepeatableGroupAsset.assets[] inline union not rewritten — jsts output layout may have changed'
);
}
}

if (rewritten > 0) {
console.log(`🔀 Restored asset_type discriminator on ${rewritten} Individual*Asset alias(es)`);
}
Expand Down
2 changes: 0 additions & 2 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,8 +938,6 @@ export {
daastGroupAsset,
urlGroupAsset,
webhookGroupAsset,
briefGroupAsset,
catalogGroupAsset,
} from './utils/format-asset-slot-builders';

// ====== PREVIEW RENDER BUILDERS ======
Expand Down
127 changes: 83 additions & 44 deletions src/lib/types/core.generated.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated AdCP core types from official schemas v3.0.9
// Generated at: 2026-05-10T10:33:36.437Z
// Generated at: 2026-05-10T17:46:01.057Z

// MEDIA-BUY SCHEMA
/**
Expand Down Expand Up @@ -15155,51 +15155,87 @@ export type IndividualCatalogAsset = BaseIndividualAsset & {
/**
* Image asset in group
*/
export type GroupImageAsset = BaseGroupAsset;
export type GroupImageAsset = BaseGroupAsset & {
asset_type: 'image';
requirements?: ImageAssetRequirements;
};
/**
* Video asset in group
*/
export type GroupVideoAsset = BaseGroupAsset;
export type GroupVideoAsset = BaseGroupAsset & {
asset_type: 'video';
requirements?: VideoAssetRequirements;
};
/**
* Audio asset in group
*/
export type GroupAudioAsset = BaseGroupAsset;
export type GroupAudioAsset = BaseGroupAsset & {
asset_type: 'audio';
requirements?: AudioAssetRequirements;
};
/**
* Text asset in group
*/
export type GroupTextAsset = BaseGroupAsset;
export type GroupTextAsset = BaseGroupAsset & {
asset_type: 'text';
requirements?: TextAssetRequirements;
};
/**
* Markdown asset in group
*/
export type GroupMarkdownAsset = BaseGroupAsset;
export type GroupMarkdownAsset = BaseGroupAsset & {
asset_type: 'markdown';
requirements?: MarkdownAssetRequirements;
};
/**
* HTML asset in group
*/
export type GroupHtmlAsset = BaseGroupAsset;
export type GroupHtmlAsset = BaseGroupAsset & {
asset_type: 'html';
requirements?: HTMLAssetRequirements;
};
/**
* CSS asset in group
*/
export type GroupCssAsset = BaseGroupAsset;
export type GroupCssAsset = BaseGroupAsset & {
asset_type: 'css';
requirements?: CSSAssetRequirements;
};
/**
* JavaScript asset in group
*/
export type GroupJavaScriptAsset = BaseGroupAsset;
export type GroupJavaScriptAsset = BaseGroupAsset & {
asset_type: 'javascript';
requirements?: JavaScriptAssetRequirements;
};
/**
* VAST asset in group
*/
export type GroupVastAsset = BaseGroupAsset;
export type GroupVastAsset = BaseGroupAsset & {
asset_type: 'vast';
requirements?: VASTAssetRequirements;
};
/**
* DAAST asset in group
*/
export type GroupDaastAsset = BaseGroupAsset;
export type GroupDaastAsset = BaseGroupAsset & {
asset_type: 'daast';
requirements?: DAASTAssetRequirements;
};
/**
* URL asset in group
*/
export type GroupUrlAsset = BaseGroupAsset;
export type GroupUrlAsset = BaseGroupAsset & {
asset_type: 'url';
requirements?: URLAssetRequirements;
};
/**
* Webhook asset in group
*/
export type GroupWebhookAsset = BaseGroupAsset;
export type GroupWebhookAsset = BaseGroupAsset & {
asset_type: 'webhook';
requirements?: WebhookAssetRequirements;
};
/**
* Represents a creative format with its requirements
*/
Expand Down Expand Up @@ -15283,23 +15319,7 @@ export interface Format {
/**
* Array of all assets supported for this format. Each asset is identified by its asset_id, which must be used as the key in creative manifests. Use the 'required' boolean on each asset to indicate whether it's mandatory.
*/
assets?: (
| IndividualImageAsset
| IndividualVideoAsset
| IndividualAudioAsset
| IndividualTextAsset
| IndividualMarkdownAsset
| IndividualHtmlAsset
| IndividualCssAsset
| IndividualJavaScriptAsset
| IndividualVastAsset
| IndividualDaastAsset
| IndividualUrlAsset
| IndividualWebhookAsset
| IndividualBriefAsset
| IndividualCatalogAsset
| RepeatableGroupAsset
)[];
assets?: FormatAssetSlot[];
/**
* Delivery method specifications (e.g., hosted, VAST, third-party tags)
*/
Expand Down Expand Up @@ -15477,20 +15497,7 @@ export interface RepeatableGroupAsset {
/**
* Assets within each repetition of this group
*/
assets: (
| GroupImageAsset
| GroupVideoAsset
| GroupAudioAsset
| GroupTextAsset
| GroupMarkdownAsset
| GroupHtmlAsset
| GroupCssAsset
| GroupJavaScriptAsset
| GroupVastAsset
| GroupDaastAsset
| GroupUrlAsset
| GroupWebhookAsset
)[];
assets: GroupAssetSlot[];
}
export interface BaseGroupAsset {
/**
Expand Down Expand Up @@ -17770,3 +17777,35 @@ export interface PropertyListChangedWebhook {
signature: string;
ext?: ExtensionObject;
}

export type IndividualAssetSlot =
| IndividualImageAsset
| IndividualVideoAsset
| IndividualAudioAsset
| IndividualTextAsset
| IndividualMarkdownAsset
| IndividualHtmlAsset
| IndividualCssAsset
| IndividualJavaScriptAsset
| IndividualVastAsset
| IndividualDaastAsset
| IndividualUrlAsset
| IndividualWebhookAsset
| IndividualBriefAsset
| IndividualCatalogAsset;

export type GroupAssetSlot =
| GroupImageAsset
| GroupVideoAsset
| GroupAudioAsset
| GroupTextAsset
| GroupMarkdownAsset
| GroupHtmlAsset
| GroupCssAsset
| GroupJavaScriptAsset
| GroupVastAsset
| GroupDaastAsset
| GroupUrlAsset
| GroupWebhookAsset;

export type FormatAssetSlot = IndividualAssetSlot | RepeatableGroupAsset;
Loading
Loading