Skip to content

prepend api_version to schema#7480

Open
lopert wants to merge 1 commit into
mainfrom
lopert.prepend-api-version
Open

prepend api_version to schema#7480
lopert wants to merge 1 commit into
mainfrom
lopert.prepend-api-version

Conversation

@lopert
Copy link
Copy Markdown
Contributor

@lopert lopert commented May 6, 2026

WHY are these changes introduced?

Part of https://github.com/shop/issues-shopifyvm/issues/945

When a developer bumps api_version in a function's shopify.extension.toml, the on-disk schema.graphql becomes stale. The next shopify app function build then fails with cryptic graphql-code-generator errors like:

Cannot query field "parentRelationship" on type "CartLine"

…with no indication that the underlying problem is a stale schema that just needs to be re-fetched. Today the build path is fully local — it never consults api_version and schema.graphql carries no version metadata, so staleness is undetectable.

The error surfaced gives them no actionable hint pointing at shopify app function schema.

WHAT is this pull request doing?

Stamps the api_version into schema.graphql when shopify app function schema writes it, then validates that marker against the extension's TOML at the start of every shopify app function build. When they disagree, the build aborts up-front with an actionable message instead of letting codegen fail mysteriously.

New file

  • packages/app/src/cli/services/function/schema-version.ts — self-contained helpers:
    • prependSchemaVersionHeader(definition, version) — adds a # api_version: <ver> marker line to the top of the SDL.
    • readSchemaApiVersion(path) — scans the leading comment block for the marker; returns undefined for missing files or legacy schemas (no marker).
    • validateSchemaApiVersion({directory, localIdentifier, apiVersion}) — throws an AbortError with remediation when the on-disk marker disagrees with the configured api_version.

Modified files

  • services/generate-schema.tsshopify app function schema now writes the marker as the first line of the SDL (both file and stdout output).
  • services/build/extension.tsbuildFunctionExtension invokes validateSchemaApiVersion once, up-front, before any function build work (typegen, esbuild, javy, wasm-opt, trampoline).

Behavior

  • Mismatched marker → fails fast with:

    The schema.graphql file for <extension> was generated for api_version 2025-07 but your function is now on api_version 2025-10. Run shopify app function schema to refresh it.

  • Missing schema file → no-op (out of scope for this change; codegen handles its own missing-file error).
  • Legacy schema with no marker → no-op (don't break existing setups; the marker self-heals on the next shopify app function schema).

The schema header is a single line:

# api_version: 2025-10

<SDL>

How to test your changes?

  • Checkout this branch
  • In a Shopify app with a function (e.g. discount function), set api_version = "2025-07" in the function's shopify.extension.toml.
  • Run the schema command locally, and confirm schema.graphql now starts with # api_version: 2025-07.
pnpm shopify app function schema --path /Users/lopert/code/functions/schema-sync
  • Bump api_version to "2025-10" in the TOML.
  • Run the build command locally, it should abort immediately with a message naming both versions and pointing at shopify app function schema.
pnpm shopify app function build --path /Users/lopert/code/functions/schema-sync
  • Run the schema command again to refresh, then the build command — it should now succeed.
  • Sanity check the legacy path: delete the marker line from schema.graphql (simulate an older fetched file). The build command should behave exactly as it did before this change (no false-positive abort).

Post-release steps

N/A.

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've considered possible documentation changes
  • I've considered analytics changes to measure impact
  • The change is user-facing — I've identified the correct bump type (patch for bug fixes · minor for new features · major for breaking changes) and added a changeset with pnpm changeset add

@github-actions github-actions Bot added the no-changelog This PR doesn't include a changeset entry. Is an internal only change not relevant to end users. label May 6, 2026
@lopert lopert marked this pull request as ready for review May 6, 2026 20:52
@lopert lopert requested a review from a team as a code owner May 6, 2026 20:52
@lopert lopert requested review from adampetro and davejcameron May 7, 2026 12:00
test('prepends a comment block with the version marker', () => {
const result = prependSchemaVersionHeader('type Query { id: ID }', '2025-10')

expect(result.startsWith(`${SCHEMA_VERSION_MARKER_PREFIX}2025-10`)).toBe(true)
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.

nit: can we assert there's a newline at the end here?

Suggested change
expect(result.startsWith(`${SCHEMA_VERSION_MARKER_PREFIX}2025-10`)).toBe(true)
expect(result.startsWith(`${SCHEMA_VERSION_MARKER_PREFIX}2025-10\n`)).toBe(true)

})
})

test('no-ops when the schema file has no version marker (legacy)', async () => {
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.

nit: can we remove legacy, I think it's something good to have in perpetuity, but legacy implies to me that at some point this case might go away

Comment on lines +106 to +107
await expect(result).rejects.toThrow(/2025-07/)
await expect(result).rejects.toThrow(/2025-10/)
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.

can we combine these into one regex so we know that the ordering is correct?

Comment on lines +32 to +39
// Only inspect the leading comment block — bail out as soon as we see a
// non-comment, non-empty line so we don't scan the whole SDL.
for (const line of contents.split(/\r?\n/)) {
if (line.startsWith(SCHEMA_VERSION_MARKER_PREFIX)) {
return line.slice(SCHEMA_VERSION_MARKER_PREFIX.length)
}
if (!line.startsWith('#')) break
}
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.

do we really need to inspect the whole comment block and not just the first line?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

My concern here was trying to not rely on being the first line. Stuff like:

  • Someone adds a # DO NOT EDIT / license / TODO comment above it
  • A future change prepends another metadata line (e.g. # generated_at: …)
  • Some tool reformats the file

I guess for now, since I'm the only one adding a special metadata comment, it's fine to just look at the first line.

localIdentifier,
apiVersion,
}: ValidateSchemaApiVersionOptions): Promise<void> {
const schemaPath = joinPath(directory, 'schema.graphql')
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.

hmm, not ideal that we're hardcoding the file name but I don't think we have this in the config anywhere 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Like we discussed on slack, we've got this hardcoded in a few places.
It's actually just the function build command that ends up looking at the package.json/codegen/schema property, so there's already a disconnect today if someone changes that file.

I've created an issue for this, but for this PR, I'll keep things consistent with the rest of the code.

// non-comment, non-empty line so we don't scan the whole SDL.
for (const line of contents.split(/\r?\n/)) {
if (line.startsWith(SCHEMA_VERSION_MARKER_PREFIX)) {
return line.slice(SCHEMA_VERSION_MARKER_PREFIX.length)
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.

should we trim too in case there is leading or trailing whitespace?

expect(runWasmOpt).toHaveBeenCalled()
})

test('validates the schema api_version with the values from the extension config', async () => {
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.

The title of this test makes me think that there will be some validation assertion but only thing we do is assert that we called the validate function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair enough. The actual logic is covered in the schema-version code so this is in fact just making sure we're invoking it with the right params


extension.outputPath = joinPath(extension.directory, relativeBuildPath)

if (functionConfiguration.api_version) {
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.

Why do we need this condition, why wouldn't there be an api_version in the function_configuration?

directory,
localIdentifier,
apiVersion,
}: ValidateSchemaApiVersionOptions): Promise<void> {
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.

Should the validate function just return a pure True or False type validation (with it defaulting to True if it cant verify because the schema file doesn't have the version) and then the extension file is responsible for throwing the AbortError like it does for other cases? So this method is pure validation, not responsible for responding to the user, that happens at the caller?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think so? I'm not sure what the other cases are here.
In extensions.ts, we're catching errors from other things (such as trying to acquire the lock) and re-throwing an AbortError. I think this is different than validators, such as validateShopifyFunctionPackageVersion, which do throw their own errors.

The validator also knows what it's looking for, and the error provided can include more detail (i.e. the different versions).

const {api_version: version, type, targeting} = extension.configuration
const usingTargets = Boolean(targeting?.length)
const definition = await (usingTargets
const fetchedDefinition = await (usingTargets
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.

I dont understand the difference between definition and fetched_definition, what if we keep this definition and call the other definition_with_version or something?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

definition is essentially what was here before, the contents we're about to write to the local schema.

I introduced fetchedDefinition to name the intermediate value: the raw schema we just grabbed from the server, before the prepending step.

Reading top-down: fetch -> add version header -> write.
Whereas before it was just fetch -> write.
Open to renaming if it still reads confusingly, but it felt clearer to me separated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog This PR doesn't include a changeset entry. Is an internal only change not relevant to end users.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants