From 56898bdf08e502d385489be1ec2f99883d5babe4 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Wed, 25 Mar 2026 11:05:45 -0400 Subject: [PATCH 1/5] Warn when schema.graphql api_version mismatches extension TOML --- .../app/src/cli/services/build/extension.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/services/build/extension.ts b/packages/app/src/cli/services/build/extension.ts index a8a4c665b8..c6dab1a3d1 100644 --- a/packages/app/src/cli/services/build/extension.ts +++ b/packages/app/src/cli/services/build/extension.ts @@ -9,9 +9,10 @@ import {AbortSignal} from '@shopify/cli-kit/node/abort' import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' import lockfile from 'proper-lockfile' import {dirname, joinPath} from '@shopify/cli-kit/node/path' -import {outputDebug} from '@shopify/cli-kit/node/output' +import {outputDebug, outputWarn} from '@shopify/cli-kit/node/output' import {readFile, touchFile, writeFile, fileExistsSync} from '@shopify/cli-kit/node/fs' import {Writable} from 'stream' +import {open} from 'fs/promises' export interface ExtensionBuildOptions { /** @@ -135,6 +136,8 @@ export async function buildFunctionExtension( extension: ExtensionInstance, options: BuildFunctionExtensionOptions, ): Promise { + await warnIfSchemaMismatch(extension as ExtensionInstance) + const lockfilePath = joinPath(extension.directory, '.build-lock') let releaseLock try { @@ -232,6 +235,37 @@ async function buildOtherFunction(extension: ExtensionInstance, options: BuildFu return runCommand(extension.buildCommand, extension, options) } +const SCHEMA_VERSION_PREFIX = '# shopify:api_version=' + +async function warnIfSchemaMismatch(extension: ExtensionInstance) { + const schemaPath = joinPath(extension.directory, 'schema.graphql') + let handle + try { + handle = await open(schemaPath, 'r') + } catch { + return + } + try { + // Read just enough bytes for the version comment line + const buf = Buffer.alloc(128) + const {bytesRead} = await handle.read(buf, 0, 128, 0) + if (bytesRead === 0) return + + const firstLine = buf.toString('utf8', 0, bytesRead).split('\n')[0] ?? '' + if (!firstLine.startsWith(SCHEMA_VERSION_PREFIX)) return + + const schemaVersion = firstLine.slice(SCHEMA_VERSION_PREFIX.length).trim() + const tomlVersion = extension.configuration.api_version + if (schemaVersion !== tomlVersion) { + outputWarn( + `schema.graphql was generated for API version ${schemaVersion} but your extension targets ${tomlVersion}. Run \`shopify app function schema\` to update.`, + ) + } + } finally { + await handle.close() + } +} + async function runCommand(buildCommand: string, extension: ExtensionInstance, options: BuildFunctionExtensionOptions) { const buildCommandComponents = buildCommand.split(' ') options.stdout.write(`Building function ${extension.localIdentifier}...`) From c0b9211108ad9cc1180452a4a77b2518853b3771 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Wed, 25 Mar 2026 11:10:32 -0400 Subject: [PATCH 2/5] Rethrow unexpected errors in schema mismatch check --- packages/app/src/cli/services/build/extension.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/services/build/extension.ts b/packages/app/src/cli/services/build/extension.ts index c6dab1a3d1..87e91afa53 100644 --- a/packages/app/src/cli/services/build/extension.ts +++ b/packages/app/src/cli/services/build/extension.ts @@ -242,8 +242,9 @@ async function warnIfSchemaMismatch(extension: ExtensionInstance Date: Wed, 25 Mar 2026 11:28:29 -0400 Subject: [PATCH 3/5] Add tests for schema version mismatch warning --- .../src/cli/services/build/extension.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/app/src/cli/services/build/extension.test.ts b/packages/app/src/cli/services/build/extension.test.ts index adf92867b8..c311e4f677 100644 --- a/packages/app/src/cli/services/build/extension.test.ts +++ b/packages/app/src/cli/services/build/extension.test.ts @@ -8,6 +8,13 @@ import {exec} from '@shopify/cli-kit/node/system' import lockfile from 'proper-lockfile' import {AbortError} from '@shopify/cli-kit/node/error' import {fileExistsSync} from '@shopify/cli-kit/node/fs' +import * as outputModule from '@shopify/cli-kit/node/output' +import {open} from 'fs/promises' + +vi.mock('fs/promises', async () => { + const actual: any = await vi.importActual('fs/promises') + return {...actual, open: vi.fn(actual.open)} +}) vi.mock('@shopify/cli-kit/node/system') vi.mock('../function/build.js') @@ -416,4 +423,64 @@ describe('buildFunctionExtension', () => { expect(releaseLock).toHaveBeenCalled() expect(runWasmOpt).toHaveBeenCalled() }) + + describe('schema version mismatch warning', () => { + function mockSchemaFile(content: string) { + const buf = Buffer.from(content) + const handle = { + read: vi.fn().mockImplementation(async (target: Buffer, _offset: number, length: number) => { + const bytesRead = Math.min(buf.length, length) + buf.copy(target, 0, 0, bytesRead) + return {bytesRead, buffer: target} + }), + close: vi.fn().mockResolvedValue(undefined), + } + vi.mocked(open).mockResolvedValue(handle as any) + return handle + } + + function mockSchemaNotFound() { + const err = new Error('ENOENT') as NodeJS.ErrnoException + err.code = 'ENOENT' + vi.mocked(open).mockRejectedValue(err) + } + + test('warns when schema version does not match toml version', async () => { + const warnSpy = vi.spyOn(outputModule, 'outputWarn').mockImplementation(() => {}) + mockSchemaFile('# shopify:api_version=2025-01\ntype Query { shop: Shop }') + + await buildFunctionExtension(extension, {stdout, stderr, signal, app, environment: 'production'}) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('schema.graphql was generated for API version 2025-01 but your extension targets 2022-07'), + ) + }) + + test('does not warn when schema version matches toml version', async () => { + const warnSpy = vi.spyOn(outputModule, 'outputWarn').mockImplementation(() => {}) + mockSchemaFile('# shopify:api_version=2022-07\ntype Query { shop: Shop }') + + await buildFunctionExtension(extension, {stdout, stderr, signal, app, environment: 'production'}) + + expect(warnSpy).not.toHaveBeenCalled() + }) + + test('does not warn when schema file has no version comment', async () => { + const warnSpy = vi.spyOn(outputModule, 'outputWarn').mockImplementation(() => {}) + mockSchemaFile('type Query { shop: Shop }') + + await buildFunctionExtension(extension, {stdout, stderr, signal, app, environment: 'production'}) + + expect(warnSpy).not.toHaveBeenCalled() + }) + + test('does not warn when schema file does not exist', async () => { + const warnSpy = vi.spyOn(outputModule, 'outputWarn').mockImplementation(() => {}) + mockSchemaNotFound() + + await buildFunctionExtension(extension, {stdout, stderr, signal, app, environment: 'production'}) + + expect(warnSpy).not.toHaveBeenCalled() + }) + }) }) From a2ec196d5899bd7cef64ddfbd6ded774b1bc5c79 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Wed, 25 Mar 2026 12:13:55 -0400 Subject: [PATCH 4/5] Parse @apiVersion directive instead of comment for schema mismatch check --- .../app/src/cli/services/build/extension.test.ts | 6 +++--- packages/app/src/cli/services/build/extension.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/app/src/cli/services/build/extension.test.ts b/packages/app/src/cli/services/build/extension.test.ts index c311e4f677..f41757f736 100644 --- a/packages/app/src/cli/services/build/extension.test.ts +++ b/packages/app/src/cli/services/build/extension.test.ts @@ -447,7 +447,7 @@ describe('buildFunctionExtension', () => { test('warns when schema version does not match toml version', async () => { const warnSpy = vi.spyOn(outputModule, 'outputWarn').mockImplementation(() => {}) - mockSchemaFile('# shopify:api_version=2025-01\ntype Query { shop: Shop }') + mockSchemaFile('schema @apiVersion(version: "2025-01") {\n query: QueryRoot\n}') await buildFunctionExtension(extension, {stdout, stderr, signal, app, environment: 'production'}) @@ -458,14 +458,14 @@ describe('buildFunctionExtension', () => { test('does not warn when schema version matches toml version', async () => { const warnSpy = vi.spyOn(outputModule, 'outputWarn').mockImplementation(() => {}) - mockSchemaFile('# shopify:api_version=2022-07\ntype Query { shop: Shop }') + mockSchemaFile('schema @apiVersion(version: "2022-07") {\n query: QueryRoot\n}') await buildFunctionExtension(extension, {stdout, stderr, signal, app, environment: 'production'}) expect(warnSpy).not.toHaveBeenCalled() }) - test('does not warn when schema file has no version comment', async () => { + test('does not warn when schema has no apiVersion directive', async () => { const warnSpy = vi.spyOn(outputModule, 'outputWarn').mockImplementation(() => {}) mockSchemaFile('type Query { shop: Shop }') diff --git a/packages/app/src/cli/services/build/extension.ts b/packages/app/src/cli/services/build/extension.ts index 87e91afa53..8f1baa94d6 100644 --- a/packages/app/src/cli/services/build/extension.ts +++ b/packages/app/src/cli/services/build/extension.ts @@ -235,7 +235,7 @@ async function buildOtherFunction(extension: ExtensionInstance, options: BuildFu return runCommand(extension.buildCommand, extension, options) } -const SCHEMA_VERSION_PREFIX = '# shopify:api_version=' +const API_VERSION_DIRECTIVE_RE = /@apiVersion\(version:\s*"([^"]+)"\)/ async function warnIfSchemaMismatch(extension: ExtensionInstance) { const schemaPath = joinPath(extension.directory, 'schema.graphql') @@ -247,15 +247,15 @@ async function warnIfSchemaMismatch(extension: ExtensionInstance Date: Wed, 25 Mar 2026 12:20:14 -0400 Subject: [PATCH 5/5] Simplify schema mismatch check to use readFile --- .../src/cli/services/build/extension.test.ts | 22 ++---------- .../app/src/cli/services/build/extension.ts | 35 +++++++------------ 2 files changed, 16 insertions(+), 41 deletions(-) diff --git a/packages/app/src/cli/services/build/extension.test.ts b/packages/app/src/cli/services/build/extension.test.ts index f41757f736..0dfb73eb9b 100644 --- a/packages/app/src/cli/services/build/extension.test.ts +++ b/packages/app/src/cli/services/build/extension.test.ts @@ -7,14 +7,8 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {exec} from '@shopify/cli-kit/node/system' import lockfile from 'proper-lockfile' import {AbortError} from '@shopify/cli-kit/node/error' -import {fileExistsSync} from '@shopify/cli-kit/node/fs' +import {fileExistsSync, readFile} from '@shopify/cli-kit/node/fs' import * as outputModule from '@shopify/cli-kit/node/output' -import {open} from 'fs/promises' - -vi.mock('fs/promises', async () => { - const actual: any = await vi.importActual('fs/promises') - return {...actual, open: vi.fn(actual.open)} -}) vi.mock('@shopify/cli-kit/node/system') vi.mock('../function/build.js') @@ -426,23 +420,13 @@ describe('buildFunctionExtension', () => { describe('schema version mismatch warning', () => { function mockSchemaFile(content: string) { - const buf = Buffer.from(content) - const handle = { - read: vi.fn().mockImplementation(async (target: Buffer, _offset: number, length: number) => { - const bytesRead = Math.min(buf.length, length) - buf.copy(target, 0, 0, bytesRead) - return {bytesRead, buffer: target} - }), - close: vi.fn().mockResolvedValue(undefined), - } - vi.mocked(open).mockResolvedValue(handle as any) - return handle + vi.mocked(readFile).mockResolvedValueOnce(content) } function mockSchemaNotFound() { const err = new Error('ENOENT') as NodeJS.ErrnoException err.code = 'ENOENT' - vi.mocked(open).mockRejectedValue(err) + vi.mocked(readFile).mockRejectedValueOnce(err) } test('warns when schema version does not match toml version', async () => { diff --git a/packages/app/src/cli/services/build/extension.ts b/packages/app/src/cli/services/build/extension.ts index 8f1baa94d6..b0f26be41b 100644 --- a/packages/app/src/cli/services/build/extension.ts +++ b/packages/app/src/cli/services/build/extension.ts @@ -12,7 +12,6 @@ import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {outputDebug, outputWarn} from '@shopify/cli-kit/node/output' import {readFile, touchFile, writeFile, fileExistsSync} from '@shopify/cli-kit/node/fs' import {Writable} from 'stream' -import {open} from 'fs/promises' export interface ExtensionBuildOptions { /** @@ -239,31 +238,23 @@ const API_VERSION_DIRECTIVE_RE = /@apiVersion\(version:\s*"([^"]+)"\)/ async function warnIfSchemaMismatch(extension: ExtensionInstance) { const schemaPath = joinPath(extension.directory, 'schema.graphql') - let handle + let content: string try { - handle = await open(schemaPath, 'r') + content = await readFile(schemaPath) } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return + if (error instanceof Error && 'code' in error) return throw error } - try { - const buf = Buffer.alloc(512) - const {bytesRead} = await handle.read(buf, 0, 512, 0) - if (bytesRead === 0) return - - const head = buf.toString('utf8', 0, bytesRead) - const match = API_VERSION_DIRECTIVE_RE.exec(head) - if (!match) return - - const schemaVersion = match[1]! - const tomlVersion = extension.configuration.api_version - if (schemaVersion !== tomlVersion) { - outputWarn( - `schema.graphql was generated for API version ${schemaVersion} but your extension targets ${tomlVersion}. Run \`shopify app function schema\` to update.`, - ) - } - } finally { - await handle.close() + + const match = API_VERSION_DIRECTIVE_RE.exec(content) + if (!match) return + + const schemaVersion = match[1]! + const tomlVersion = extension.configuration.api_version + if (schemaVersion !== tomlVersion) { + outputWarn( + `schema.graphql was generated for API version ${schemaVersion} but your extension targets ${tomlVersion}. Run \`shopify app function schema\` to update.`, + ) } }