fix: skip portal-created 13-digit schema IDs on spec import to prevent duplicate schemas - #280
Conversation
Newer APIM API versions (e.g. 2025-09-01-preview) assign epoch-millis IDs (e.g. 1786466527403) to schemas created on spec import, but isAutoGeneratedId() only matched 24-char hex IDs. The CLI therefore re-published such schemas after the spec import, creating a duplicate schema on the destination API. Extend the auto-generated ID detection with a 13-digit numeric pattern so these schemas are skipped on publish, like their 24-hex predecessors. Closes Azure#274
…lish test Live verification against a Developer-tier instance showed that ARM spec import generates 24-hex schema IDs even on api-version 2025-09-01-preview. The Date.now()-style 13-digit IDs come from schemas created outside spec import (e.g. the portal's OpenAPI spec editor), so reword comments accordingly. Behavior is unchanged: such IDs are machine-generated and re-publishing them after spec import duplicates the schema (Azure#274). Also add the publish-path regression test that skips a 13-digit schema on spec import.
…a path Widening isAutoGeneratedId() globally was too risky: the predicate also gates named values, subscriptions, and operation reconciliation, where a 13-digit-named resource would be silently skipped and its data lost. Move the pattern to a dedicated isPortalGeneratedSchemaId() helper applied only when filtering explicit ApiSchema re-publishes during spec import -- the one place where skipping is safe because the import recreates schema content. Also verified live: the portal generates these Date.now() IDs when adding a definition to an API with no schemas (operation Frontend editor), while ARM spec import produces 24-hex IDs on all api-versions 2021-08-01 through 2025-09-01-preview.
There was a problem hiding this comment.
🟡 Changes recommended
The import filter can silently lose legitimate explicitly named 13-digit schemas without additional provenance safeguards.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR prevents duplicate destination schemas during specification imports by excluding portal-created 13-digit epoch-millisecond schema IDs.
Changes:
- Adds dedicated portal-generated schema ID detection.
- Applies filtering only during specification-import planning.
- Adds unit and regression test coverage.
File summaries
| File | Changes | Review notes |
|---|---|---|
tests/unit/services/api-publisher.test.ts |
Adds publish-path regression coverage. | Nit: the test comment should reference the operation Frontend editor’s “New definition” flow, not the OpenAPI specification editor. |
tests/unit/lib/auto-generated.test.ts |
Tests portal-generated ID classification. | — |
src/services/api-publisher.ts |
Skips matching schemas during specification imports. | Critical: the name-only heuristic may discard legitimate explicitly named 13-digit schemas; add provenance/evidence gating and numeric-but-explicit coverage. |
src/lib/auto-generated.ts |
Adds 13-digit portal schema ID detection. | — |
Review details
Suppressed comments (1)
tests/unit/services/api-publisher.test.ts:1900
- The OpenAPI specification editor is not the path that creates these IDs; the verified portal path is the operation Frontend editor's "New definition" flow. This comment is misleading and could cause future changes to apply the workaround to the wrong portal behavior.
// The portal spec editor assigns Date.now()-style schema IDs; the spec
// import recreates schema content, so re-PUTs create a duplicate (#274).
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…mponents Address PR review: a 13-digit name alone is a weak provenance signal and would silently drop an explicitly named numeric schema that the spec import does not recreate. The skip now additionally requires that every component defined in the artifact schema document (components.schemas / definitions) is declared in the imported specification. When the spec is not parseable OpenAPI, declares no components, misses any of the schema's components, or the artifact document defines none, the schema is published like any other explicit schema. Tests: skip when spec covers the components; re-publish when components are absent from the spec and when the spec declares no components at all.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in the publication filtering logic.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
src/services/api-publisher.ts:340
- This eagerly reads every 13-digit candidate before applying the configured filter or
allowedDescriptors. A filtered-out schema can therefore still throw fromreadResource(for example, a malformed or inaccessible artifact) and abort the entire API plan even though it would never be published. Apply the filter/allowed-set tocandidateSchemasbefore thisPromise.alland retain the later filtering only if needed for the existing semantics.
candidateSchemas.map(async (descriptor) => {
if (!isPortalGeneratedSchemaId(getNamePart(descriptor.nameParts, 1))) {
return descriptor;
}
if (!importSpecSchemaNames || importSpecSchemaNames.size === 0) {
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
… re-publish Address PR review (two comments): - Compare full component definitions, not just names: a 13-digit-named schema may define a component with the same name but different shape than the spec; name-only matching would drop it on a clean destination. The skip now requires every artifact component to be structurally identical (deep, key-order-insensitive) to the spec's declaration. - Gate artifact component extraction on OpenAPI/Swagger content types: in standalone JSON Schema documents (schemaType: json), 'definitions' has a different meaning and spec import does not recreate the resource; such schemas are always retained. New tests: same-named component with different shape is re-published; standalone JSON Schema with overlapping definition names is re-published.
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate issues remain involving YAML scalar normalization and preservation of schema-level metadata.
Review details
Suppressed comments (2)
src/services/api-publisher.ts:1108
js-yaml's default schema coerces unquoted YAML timestamps (for example, a validformat: date-timeexample) intoDateobjects, while the extractedschemaInformation.jsoncontains the same value as a string.deepEqualUnorderedwill therefore report an otherwise identical component as different and re-PUT the 13-digit schema, so YAML specs with date-like schema values can still produce the duplicate this change is intended to prevent. Parse the spec with a JSON-like schema (or otherwise normalize YAML scalar types) before comparing it with the JSON artifact.
const doc = yaml.load(content) as Record<string, unknown> | undefined;
src/services/api-publisher.ts:359
- The skip decision only compares
document.components.schemasentries.ApiSchemaartifacts can also carry schema-level properties such asproperties.description; a user-created schema whose component definition happens to match the imported spec would be omitted here and lose that metadata on a clean destination. Since the 13-digit pattern is explicitly treated as a weak heuristic, require all non-import-recreated schema metadata to be absent/unchanged before skipping, or conservatively keep publishing when such properties are present.
const recreatedByImport = entries.every(
([name, definition]) =>
Object.hasOwn(specComponents, name) &&
deepEqualUnordered(specComponents[name], definition)
);
return recreatedByImport ? undefined : descriptor;
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved schema-read error handling and YAML value comparison issues may still block publication or recreate duplicates.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
src/services/api-publisher.ts:1108
yaml.loaduses js-yaml's default schema, which resolves unquoted YAML timestamp scalars toDateobjects. The artifact'sproperties.documentis JSON and keeps the same OpenAPI example/default as a string, so an otherwise identical schema can faildeepEqualUnorderedand still be re-PUT (recreating the duplicate this change is meant to prevent). Parse with a JSON-compatible YAML schema or normalize YAML scalar values before comparing, and add a regression case with an unquoted date-time.
const doc = yaml.load(content) as Record<string, unknown> | undefined;
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
Address PR review: readResource can throw for malformed or unreadable schemaInformation.json, and the call runs inside Promise.all -- a single bad 13-digit schema aborted planApiPublication before the root API was published, contradicting the 'any ambiguity -> publish' fallback. Catch the read per descriptor and return the descriptor so the normal child publication path reports the failure. Test: root API publishes and the schema is retained when its artifact read throws.
There was a problem hiding this comment.
🔵 Needs a closer look
Default YAML scalar coercion can prevent equivalent schemas from matching, leaving duplicates for valid YAML specifications.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/services/api-publisher.ts:1113
js-yaml's default schema converts unquoted ISO dates/timestamps in YAML OpenAPI examples, defaults, or enums intoDateobjects. The extractedschemaInformation.jsonstores those values as strings, sodeepEqualUnorderedwill treat an otherwise identical schema as different and re-PUT the 13-digit schema, preserving the duplicate this change is intended to remove. Parse using JSON-compatible scalar semantics so valid OpenAPI YAML values compare with their JSON artifact representation.
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
End-to-end verification on a live APIM instanceReproduced the exact #274 scenario on a Developer-tier instance and verified the fix. Source setup (mirrors the reporter's case):
Publish to a clean destination (API deleted first), same artifacts:
Post-fix integrity checks:
The structural comparison held up against APIM's real spec export (the extracted specification's components matched the schema document byte-for-byte semantically), so the skip triggers for genuine portal-created schemas while the ambiguity fallbacks (different shape, standalone JSON Schema, unreadable artifact, no components in spec) keep explicit schemas publishable. |
Summary
Fixes duplicate schemas on the destination API when publishing an API whose artifacts contain a portal-created schema with a 13-digit epoch-millis name (#274).
Root Cause
The Azure portal generates schema IDs client-side as
Date.now()(13 digits) when a definition is added to an API that has no schema resources yet (operation Frontend editor → "New definition"). Verified live: ARM spec import generates 24-hex IDs on every api-version from 2021-08-01 to 2025-09-01-preview, so 13-digit IDs only enter artifacts via portal edits on the source instance.On publish, the spec import creates its own 24-hex schema on the destination, and the CLI additionally re-published the 13-digit artifact schema because
isAutoGeneratedId()only recognized 24-hex names — yielding exactly two schemas, stable across repeated publishes, and breaking the portal's OpenAPI editor on the destination (x-ms-export-notes: "references multiple schemas").Changes
isPortalGeneratedSchemaId()(^\d{13}$) insrc/lib/auto-generated.tsplanApiPublication()'s explicit-schema filter when a specification is imported — the one place where skipping is safe because the import recreates schema contentisAutoGeneratedId()predicate: that predicate also gates named values, subscriptions, and operation reconciliation, where a 13-digit-named resource would be silently skipped and its data lostDate.now()vs server-side ObjectId)Related Issue(s)
Closes #274
Verification
isPortalGeneratedSchemaIdand a publish-path regression test (13-digit schema excluded from childPuts on spec import); 215 tests across all predicate call sites passtsc --noEmitcleanNotes for Reviewers
Code review required per repo policy for
src/changes.After this fix, destinations keep a single import-created (24-hex) schema; a stale 13-digit schema already present from earlier publishes needs one-time manual deletion (noted in the issue).