Skip to content

Commit 95e9788

Browse files
authored
Merge pull request #7480 from Shopify/lopert.prepend-api-version
prepend api_version to schema
2 parents 4a110f1 + f80f6ea commit 95e9788

6 files changed

Lines changed: 225 additions & 5 deletions

File tree

packages/app/src/cli/services/build/extension.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {buildFunctionExtension} from './extension.js'
22
import {testFunctionExtension} from '../../models/app/app.test-data.js'
33
import {buildGraphqlTypes, buildJSFunction, runWasmOpt, runTrampoline} from '../function/build.js'
4+
import {validateSchemaApiVersion} from '../function/schema-version.js'
45
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
56
import {FunctionConfigType} from '../../models/extensions/specifications/function.js'
67
import {beforeEach, describe, expect, test, vi} from 'vitest'
@@ -12,6 +13,7 @@ import {joinPath} from '@shopify/cli-kit/node/path'
1213

1314
vi.mock('@shopify/cli-kit/node/system')
1415
vi.mock('../function/build.js')
16+
vi.mock('../function/schema-version.js')
1517
vi.mock('proper-lockfile')
1618
vi.mock('@shopify/cli-kit/node/fs')
1719

@@ -418,6 +420,26 @@ describe('buildFunctionExtension', () => {
418420
expect(runWasmOpt).toHaveBeenCalled()
419421
})
420422

423+
test('calls validateSchemaApiVersion with the values from the extension config', async () => {
424+
// When
425+
await expect(
426+
buildFunctionExtension(extension, {
427+
stdout,
428+
stderr,
429+
signal,
430+
app,
431+
environment: 'production',
432+
}),
433+
).resolves.toBeUndefined()
434+
435+
// Then
436+
expect(validateSchemaApiVersion).toHaveBeenCalledWith({
437+
directory: extension.directory,
438+
localIdentifier: extension.localIdentifier,
439+
apiVersion: extension.configuration.api_version,
440+
})
441+
})
442+
421443
test('does not rebundle when build.path stays in the default output directory', async () => {
422444
// Given
423445
extension.configuration.build!.path = 'dist/custom.wasm'

packages/app/src/cli/services/build/extension.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {formatBundleSize} from './bundle-size.js'
22
import {AppInterface} from '../../models/app/app.js'
33
import {bundleExtension} from '../extensions/bundle.js'
44
import {buildGraphqlTypes, buildJSFunction, runTrampoline, runWasmOpt} from '../function/build.js'
5+
import {validateSchemaApiVersion} from '../function/schema-version.js'
56
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
67
import {FunctionConfigType} from '../../models/extensions/specifications/function.js'
78
import {exec} from '@shopify/cli-kit/node/system'
@@ -156,12 +157,18 @@ export async function buildFunctionExtension(
156157
}
157158

158159
try {
160+
const functionConfiguration = (extension as ExtensionInstance<FunctionConfigType>).configuration
159161
const bundlePath = extension.outputPath
160-
const relativeBuildPath =
161-
(extension as ExtensionInstance<FunctionConfigType>).configuration.build?.path ?? extension.outputRelativePath
162+
const relativeBuildPath = functionConfiguration.build?.path ?? extension.outputRelativePath
162163

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

166+
await validateSchemaApiVersion({
167+
directory: extension.directory,
168+
localIdentifier: extension.localIdentifier,
169+
apiVersion: functionConfiguration.api_version,
170+
})
171+
165172
if (extension.isJavaScript) {
166173
await runCommandOrBuildJSFunction(extension, options)
167174
} else {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
prependSchemaVersionHeader,
3+
readSchemaApiVersion,
4+
validateSchemaApiVersion,
5+
SCHEMA_VERSION_MARKER_PREFIX,
6+
} from './schema-version.js'
7+
import {describe, expect, test} from 'vitest'
8+
import {AbortError} from '@shopify/cli-kit/node/error'
9+
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
10+
import {joinPath} from '@shopify/cli-kit/node/path'
11+
12+
function options(directory: string, apiVersion: string) {
13+
return {directory, localIdentifier: 'my-function', apiVersion}
14+
}
15+
16+
describe('prependSchemaVersionHeader', () => {
17+
test('prepends a comment block with the version marker', () => {
18+
const result = prependSchemaVersionHeader('type Query { id: ID }', '2025-10')
19+
20+
expect(result.startsWith(`${SCHEMA_VERSION_MARKER_PREFIX}2025-10\n`)).toBe(true)
21+
expect(result.endsWith('type Query { id: ID }')).toBe(true)
22+
})
23+
})
24+
25+
describe('readSchemaApiVersion', () => {
26+
test('returns the version when the marker is present', async () => {
27+
await inTemporaryDirectory(async (tmpDir) => {
28+
const path = joinPath(tmpDir, 'schema.graphql')
29+
await writeFile(path, prependSchemaVersionHeader('type Query { id: ID }', '2025-10'))
30+
31+
await expect(readSchemaApiVersion(path)).resolves.toEqual('2025-10')
32+
})
33+
})
34+
35+
test('returns undefined when the file has no marker', async () => {
36+
await inTemporaryDirectory(async (tmpDir) => {
37+
const path = joinPath(tmpDir, 'schema.graphql')
38+
await writeFile(path, '# some other comment\ntype Query { id: ID }')
39+
40+
await expect(readSchemaApiVersion(path)).resolves.toBeUndefined()
41+
})
42+
})
43+
44+
test('returns undefined when the file does not exist', async () => {
45+
await inTemporaryDirectory(async (tmpDir) => {
46+
await expect(readSchemaApiVersion(joinPath(tmpDir, 'missing.graphql'))).resolves.toBeUndefined()
47+
})
48+
})
49+
50+
test('does not match the marker once SDL content has started', async () => {
51+
await inTemporaryDirectory(async (tmpDir) => {
52+
const path = joinPath(tmpDir, 'schema.graphql')
53+
// Marker buried after SDL content should be ignored.
54+
await writeFile(path, `type Query { id: ID }\n${SCHEMA_VERSION_MARKER_PREFIX}2025-10\n`)
55+
56+
await expect(readSchemaApiVersion(path)).resolves.toBeUndefined()
57+
})
58+
})
59+
60+
test('trims surrounding whitespace from the marker value', async () => {
61+
await inTemporaryDirectory(async (tmpDir) => {
62+
const path = joinPath(tmpDir, 'schema.graphql')
63+
await writeFile(path, `${SCHEMA_VERSION_MARKER_PREFIX} 2025-10 \ntype Query { id: ID }`)
64+
65+
await expect(readSchemaApiVersion(path)).resolves.toEqual('2025-10')
66+
})
67+
})
68+
})
69+
70+
describe('validateSchemaApiVersion', () => {
71+
test('no-ops when the schema file does not exist', async () => {
72+
await inTemporaryDirectory(async (tmpDir) => {
73+
await expect(validateSchemaApiVersion(options(tmpDir, '2025-10'))).resolves.toBeUndefined()
74+
})
75+
})
76+
77+
test('no-ops when the schema file has no version marker', async () => {
78+
await inTemporaryDirectory(async (tmpDir) => {
79+
await writeFile(joinPath(tmpDir, 'schema.graphql'), 'type Query { id: ID }')
80+
81+
await expect(validateSchemaApiVersion(options(tmpDir, '2025-10'))).resolves.toBeUndefined()
82+
})
83+
})
84+
85+
test('no-ops when the marker matches the configured api_version', async () => {
86+
await inTemporaryDirectory(async (tmpDir) => {
87+
await writeFile(
88+
joinPath(tmpDir, 'schema.graphql'),
89+
prependSchemaVersionHeader('type Query { id: ID }', '2025-10'),
90+
)
91+
92+
await expect(validateSchemaApiVersion(options(tmpDir, '2025-10'))).resolves.toBeUndefined()
93+
})
94+
})
95+
96+
test('throws an AbortError with remediation when the marker is stale', async () => {
97+
await inTemporaryDirectory(async (tmpDir) => {
98+
await writeFile(
99+
joinPath(tmpDir, 'schema.graphql'),
100+
prependSchemaVersionHeader('type Query { id: ID }', '2025-07'),
101+
)
102+
103+
const result = validateSchemaApiVersion(options(tmpDir, '2025-10'))
104+
105+
await expect(result).rejects.toThrow(AbortError)
106+
await expect(result).rejects.toThrow(/2025-07[\s\S]*2025-10/)
107+
})
108+
})
109+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {AbortError} from '@shopify/cli-kit/node/error'
2+
import {fileExists, readFile} from '@shopify/cli-kit/node/fs'
3+
import {joinPath} from '@shopify/cli-kit/node/path'
4+
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
5+
6+
/**
7+
* Marker used in the leading comments of `schema.graphql` to record the
8+
* `api_version` the schema was fetched for. Format: `# api_version: <version>`.
9+
*/
10+
export const SCHEMA_VERSION_MARKER_PREFIX = '# api_version: '
11+
12+
/**
13+
* Prepends a versioned header to a schema definition. The header documents
14+
* which `api_version` the schema was generated for so subsequent builds can
15+
* detect when the on-disk schema is stale.
16+
*/
17+
export function prependSchemaVersionHeader(definition: string, apiVersion: string): string {
18+
return `${SCHEMA_VERSION_MARKER_PREFIX}${apiVersion}\n\n${definition}`
19+
}
20+
21+
/**
22+
* Reads the `api_version` recorded in the leading comments of a schema file.
23+
* Returns `undefined` if the file does not have the marker (e.g. hand-authored
24+
* schemas, or schemas generated before this header existed).
25+
*/
26+
export async function readSchemaApiVersion(filePath: string): Promise<string | undefined> {
27+
if (!(await fileExists(filePath))) {
28+
outputDebug(`Could not determine api_version: schema file not found at ${filePath}.`)
29+
return undefined
30+
}
31+
32+
const contents = await readFile(filePath)
33+
// The marker is always written as the first line by `prependSchemaVersionHeader`.
34+
const firstLine = contents.split('\n', 1)[0]!
35+
if (firstLine.startsWith(SCHEMA_VERSION_MARKER_PREFIX)) {
36+
return firstLine.slice(SCHEMA_VERSION_MARKER_PREFIX.length).trim()
37+
}
38+
39+
outputDebug(
40+
`Could not determine api_version from ${filePath}: missing '${SCHEMA_VERSION_MARKER_PREFIX}' marker on the first line.`,
41+
)
42+
return undefined
43+
}
44+
45+
/**
46+
* Validates that `<extension>/schema.graphql` matches the `api_version`
47+
* declared in the extension TOML. Throws an `AbortError` with a remediation
48+
* pointing at `shopify app function schema` when the on-disk schema is stale.
49+
*
50+
* Silently no-ops when:
51+
* - the schema file does not exist (handled by codegen / out of scope here)
52+
* - the schema file has no version marker (hand-authored schema, or one
53+
* generated before this header existed)
54+
*/
55+
interface ValidateSchemaApiVersionOptions {
56+
directory: string
57+
localIdentifier: string
58+
apiVersion: string
59+
}
60+
61+
export async function validateSchemaApiVersion({
62+
directory,
63+
localIdentifier,
64+
apiVersion,
65+
}: ValidateSchemaApiVersionOptions): Promise<void> {
66+
const schemaPath = joinPath(directory, 'schema.graphql')
67+
const versionFromSchema = await readSchemaApiVersion(schemaPath)
68+
if (versionFromSchema === undefined) return
69+
if (versionFromSchema === apiVersion) return
70+
71+
throw new AbortError(
72+
outputContent`The ${outputToken.cyan(
73+
'schema.graphql',
74+
)} file for ${outputToken.cyan(localIdentifier)} was generated for api_version ${outputToken.yellow(
75+
versionFromSchema,
76+
)} but your function is now on api_version ${outputToken.yellow(apiVersion)}.`,
77+
outputContent`Run ${outputToken.genericShellCommand('shopify app function schema')} to refresh it.`,
78+
)
79+
}

packages/app/src/cli/services/generate-schema.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe('generateSchemaService', () => {
4747

4848
// Then
4949
const outputFile = await readFile(joinPath(extension.directory, 'schema.graphql'))
50-
expect(outputFile).toEqual('schema')
50+
expect(outputFile).toEqual(`# api_version: ${extension.configuration.api_version}\n\nschema`)
5151
})
5252
})
5353

@@ -72,7 +72,7 @@ describe('generateSchemaService', () => {
7272
})
7373

7474
// Then
75-
expect(mockOutput).toHaveBeenCalledWith('schema')
75+
expect(mockOutput).toHaveBeenCalledWith(`# api_version: ${extension.configuration.api_version}\n\nschema`)
7676
})
7777
})
7878

packages/app/src/cli/services/generate-schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {prependSchemaVersionHeader} from './function/schema-version.js'
12
import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js'
23
import {SchemaDefinitionByApiTypeQueryVariables} from '../api/graphql/functions/generated/schema-definition-by-api-type.js'
34
import {SchemaDefinitionByTargetQueryVariables} from '../api/graphql/functions/generated/schema-definition-by-target.js'
@@ -22,7 +23,7 @@ export async function generateSchemaService(options: GenerateSchemaOptions) {
2223
const apiKey = app.configuration.client_id
2324
const {api_version: version, type, targeting} = extension.configuration
2425
const usingTargets = Boolean(targeting?.length)
25-
const definition = await (usingTargets
26+
const fetchedDefinition = await (usingTargets
2627
? generateSchemaFromTarget({
2728
localIdentifier: extension.localIdentifier,
2829
developerPlatformClient,
@@ -41,6 +42,8 @@ export async function generateSchemaService(options: GenerateSchemaOptions) {
4142
orgId,
4243
}))
4344

45+
const definition = prependSchemaVersionHeader(fetchedDefinition, version)
46+
4447
if (stdout) {
4548
outputResult(definition)
4649
} else {

0 commit comments

Comments
 (0)