Skip to content

fix: skip portal-created 13-digit schema IDs on spec import to prevent duplicate schemas - #280

Merged
Alexander Zaslonov (azaslonov) merged 6 commits into
Azure:mainfrom
Alexey-Zheltov:fix/timestamp-schema-ids
Sep 12, 2026
Merged

fix: skip portal-created 13-digit schema IDs on spec import to prevent duplicate schemas#280
Alexander Zaslonov (azaslonov) merged 6 commits into
Azure:mainfrom
Alexey-Zheltov:fix/timestamp-schema-ids

Conversation

@Alexey-Zheltov

Copy link
Copy Markdown
Collaborator

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

  • New isPortalGeneratedSchemaId() (^\d{13}$) in src/lib/auto-generated.ts
  • Applied only in planApiPublication()'s explicit-schema filter when a specification is imported — the one place where skipping is safe because the import recreates schema content
  • Deliberately NOT widened into the general isAutoGeneratedId() predicate: that predicate also gates named values, subscriptions, and operation reconciliation, where a 13-digit-named resource would be silently skipped and its data lost
  • Comments document the verified ID provenance (portal Date.now() vs server-side ObjectId)

Related Issue(s)

Closes #274

Verification

  • Live reproduction of the full scenario (portal definition creation → extract → publish → duplicate), details in Publish API creates 2 schemas on destination API #274 (comment)
  • Reporter confirmed the artifact schema is the 13-digit one and duplication is idempotent at 2
  • New unit tests for isPortalGeneratedSchemaId and a publish-path regression test (13-digit schema excluded from childPuts on spec import); 215 tests across all predicate call sites pass
  • tsc --noEmit clean

Notes 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).

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.
Copilot AI balanced review requested due to automatic review settings September 11, 2026 17:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/services/api-publisher.ts Outdated
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 from readResource (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 to candidateSchemas before this Promise.all and 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.

Comment thread src/services/api-publisher.ts Outdated
Comment thread src/services/api-publisher.ts
… 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 valid format: date-time example) into Date objects, while the extracted schemaInformation.json contains the same value as a string. deepEqualUnordered will 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.schemas entries. ApiSchema artifacts can also carry schema-level properties such as properties.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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.load uses js-yaml's default schema, which resolves unquoted YAML timestamp scalars to Date objects. The artifact's properties.document is JSON and keeps the same OpenAPI example/default as a string, so an otherwise identical schema can fail deepEqualUnordered and 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.

Comment thread src/services/api-publisher.ts Outdated
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 into Date objects. The extracted schemaInformation.json stores those values as strings, so deepEqualUnordered will 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

@Alexey-Zheltov

Copy link
Copy Markdown
Collaborator Author

End-to-end verification on a live APIM instance

Reproduced the exact #274 scenario on a Developer-tier instance and verified the fix.

Source setup (mirrors the reporter's case):

  1. API created manually (no spec import)
  2. Portal-style schema created via PUT .../schemas/{Date.now()} — 13-digit name, components.schemas.IngestionRequest with a required list
  3. Operation referencing the schema (schemaId + typeName in request representations)
  4. Extracted with the CLI → artifacts contain specification.yaml + schemas/<13-digit>/schemaInformation.json + the operation

Publish to a clean destination (API deleted first), same artifacts:

Build Schemas on destination after publish
main (bug) 2 — the 13-digit re-PUT by the CLI + a 24-hex one created by the spec import
this PR 1 — only the import-created 24-hex schema

Post-fix integrity checks:

  • Operation binding: request.representations[0].schemaId points at the import-created schema, typeName preserved — no dangling references to the skipped 13-digit schema
  • Schema content intact: the required: ["type", "object"] list survived the round trip — the structural-equality gate correctly recognized the artifact schema as recreated by the import
  • Idempotency: a second publish keeps the schema count at 1

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.

@azaslonov
Alexander Zaslonov (azaslonov) merged commit 7b6f6c9 into Azure:main Sep 12, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish API creates 2 schemas on destination API

3 participants