-
Notifications
You must be signed in to change notification settings - Fork 254
prepend api_version to schema #7480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { | ||
| prependSchemaVersionHeader, | ||
| readSchemaApiVersion, | ||
| validateSchemaApiVersion, | ||
| SCHEMA_VERSION_MARKER_PREFIX, | ||
| } from './schema-version.js' | ||
| import {describe, expect, test} from 'vitest' | ||
| import {AbortError} from '@shopify/cli-kit/node/error' | ||
| import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' | ||
| import {joinPath} from '@shopify/cli-kit/node/path' | ||
|
|
||
| function options(directory: string, apiVersion: string) { | ||
| return {directory, localIdentifier: 'my-function', apiVersion} | ||
| } | ||
|
|
||
| describe('prependSchemaVersionHeader', () => { | ||
| 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\n`)).toBe(true) | ||
| expect(result.endsWith('type Query { id: ID }')).toBe(true) | ||
| }) | ||
| }) | ||
|
|
||
| describe('readSchemaApiVersion', () => { | ||
| test('returns the version when the marker is present', async () => { | ||
| await inTemporaryDirectory(async (tmpDir) => { | ||
| const path = joinPath(tmpDir, 'schema.graphql') | ||
| await writeFile(path, prependSchemaVersionHeader('type Query { id: ID }', '2025-10')) | ||
|
|
||
| await expect(readSchemaApiVersion(path)).resolves.toEqual('2025-10') | ||
| }) | ||
| }) | ||
|
|
||
| test('returns undefined when the file has no marker', async () => { | ||
| await inTemporaryDirectory(async (tmpDir) => { | ||
| const path = joinPath(tmpDir, 'schema.graphql') | ||
| await writeFile(path, '# some other comment\ntype Query { id: ID }') | ||
|
|
||
| await expect(readSchemaApiVersion(path)).resolves.toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| test('returns undefined when the file does not exist', async () => { | ||
| await inTemporaryDirectory(async (tmpDir) => { | ||
| await expect(readSchemaApiVersion(joinPath(tmpDir, 'missing.graphql'))).resolves.toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| test('does not match the marker once SDL content has started', async () => { | ||
| await inTemporaryDirectory(async (tmpDir) => { | ||
| const path = joinPath(tmpDir, 'schema.graphql') | ||
| // Marker buried after SDL content should be ignored. | ||
| await writeFile(path, `type Query { id: ID }\n${SCHEMA_VERSION_MARKER_PREFIX}2025-10\n`) | ||
|
|
||
| await expect(readSchemaApiVersion(path)).resolves.toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| test('trims surrounding whitespace from the marker value', async () => { | ||
| await inTemporaryDirectory(async (tmpDir) => { | ||
| const path = joinPath(tmpDir, 'schema.graphql') | ||
| await writeFile(path, `${SCHEMA_VERSION_MARKER_PREFIX} 2025-10 \ntype Query { id: ID }`) | ||
|
|
||
| await expect(readSchemaApiVersion(path)).resolves.toEqual('2025-10') | ||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| describe('validateSchemaApiVersion', () => { | ||
| test('no-ops when the schema file does not exist', async () => { | ||
| await inTemporaryDirectory(async (tmpDir) => { | ||
| await expect(validateSchemaApiVersion(options(tmpDir, '2025-10'))).resolves.toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| test('no-ops when the schema file has no version marker', async () => { | ||
| await inTemporaryDirectory(async (tmpDir) => { | ||
| await writeFile(joinPath(tmpDir, 'schema.graphql'), 'type Query { id: ID }') | ||
|
|
||
| await expect(validateSchemaApiVersion(options(tmpDir, '2025-10'))).resolves.toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| test('no-ops when the marker matches the configured api_version', async () => { | ||
| await inTemporaryDirectory(async (tmpDir) => { | ||
| await writeFile( | ||
| joinPath(tmpDir, 'schema.graphql'), | ||
| prependSchemaVersionHeader('type Query { id: ID }', '2025-10'), | ||
| ) | ||
|
|
||
| await expect(validateSchemaApiVersion(options(tmpDir, '2025-10'))).resolves.toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| test('throws an AbortError with remediation when the marker is stale', async () => { | ||
| await inTemporaryDirectory(async (tmpDir) => { | ||
| await writeFile( | ||
| joinPath(tmpDir, 'schema.graphql'), | ||
| prependSchemaVersionHeader('type Query { id: ID }', '2025-07'), | ||
| ) | ||
|
|
||
| const result = validateSchemaApiVersion(options(tmpDir, '2025-10')) | ||
|
|
||
| await expect(result).rejects.toThrow(AbortError) | ||
| await expect(result).rejects.toThrow(/2025-07[\s\S]*2025-10/) | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import {AbortError} from '@shopify/cli-kit/node/error' | ||
| import {fileExists, readFile} from '@shopify/cli-kit/node/fs' | ||
| import {joinPath} from '@shopify/cli-kit/node/path' | ||
| import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' | ||
|
|
||
| /** | ||
| * Marker used in the leading comments of `schema.graphql` to record the | ||
| * `api_version` the schema was fetched for. Format: `# api_version: <version>`. | ||
| */ | ||
| export const SCHEMA_VERSION_MARKER_PREFIX = '# api_version: ' | ||
|
|
||
| /** | ||
| * Prepends a versioned header to a schema definition. The header documents | ||
| * which `api_version` the schema was generated for so subsequent builds can | ||
| * detect when the on-disk schema is stale. | ||
| */ | ||
| export function prependSchemaVersionHeader(definition: string, apiVersion: string): string { | ||
| return `${SCHEMA_VERSION_MARKER_PREFIX}${apiVersion}\n\n${definition}` | ||
| } | ||
|
|
||
| /** | ||
| * Reads the `api_version` recorded in the leading comments of a schema file. | ||
| * Returns `undefined` if the file does not have the marker (e.g. hand-authored | ||
| * schemas, or schemas generated before this header existed). | ||
| */ | ||
| export async function readSchemaApiVersion(filePath: string): Promise<string | undefined> { | ||
| if (!(await fileExists(filePath))) { | ||
| outputDebug(`Could not determine api_version: schema file not found at ${filePath}.`) | ||
| return undefined | ||
| } | ||
|
|
||
| const contents = await readFile(filePath) | ||
| // The marker is always written as the first line by `prependSchemaVersionHeader`. | ||
| const firstLine = contents.split('\n', 1)[0]! | ||
| if (firstLine.startsWith(SCHEMA_VERSION_MARKER_PREFIX)) { | ||
| return firstLine.slice(SCHEMA_VERSION_MARKER_PREFIX.length).trim() | ||
| } | ||
|
|
||
| outputDebug( | ||
| `Could not determine api_version from ${filePath}: missing '${SCHEMA_VERSION_MARKER_PREFIX}' marker on the first line.`, | ||
| ) | ||
| return undefined | ||
| } | ||
|
|
||
| /** | ||
| * Validates that `<extension>/schema.graphql` matches the `api_version` | ||
| * declared in the extension TOML. Throws an `AbortError` with a remediation | ||
| * pointing at `shopify app function schema` when the on-disk schema is stale. | ||
| * | ||
| * Silently no-ops when: | ||
| * - the schema file does not exist (handled by codegen / out of scope here) | ||
| * - the schema file has no version marker (hand-authored schema, or one | ||
| * generated before this header existed) | ||
| */ | ||
| interface ValidateSchemaApiVersionOptions { | ||
| directory: string | ||
| localIdentifier: string | ||
| apiVersion: string | ||
| } | ||
|
|
||
| export async function validateSchemaApiVersion({ | ||
| directory, | ||
| localIdentifier, | ||
| apiVersion, | ||
| }: ValidateSchemaApiVersionOptions): Promise<void> { | ||
|
lopert marked this conversation as resolved.
|
||
| const schemaPath = joinPath(directory, 'schema.graphql') | ||
|
lopert marked this conversation as resolved.
|
||
| const versionFromSchema = await readSchemaApiVersion(schemaPath) | ||
| if (versionFromSchema === undefined) return | ||
| if (versionFromSchema === apiVersion) return | ||
|
|
||
| throw new AbortError( | ||
| outputContent`The ${outputToken.cyan( | ||
| 'schema.graphql', | ||
| )} file for ${outputToken.cyan(localIdentifier)} was generated for api_version ${outputToken.yellow( | ||
| versionFromSchema, | ||
| )} but your function is now on api_version ${outputToken.yellow(apiVersion)}.`, | ||
|
Comment on lines
+72
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe it's something we can ask to UX, but I think it's better to just use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmmmm, I went back and forth on this.
That said, your comment made me notice the message had three concepts but only two colors. |
||
| outputContent`Run ${outputToken.genericShellCommand('shopify app function schema')} to refresh it.`, | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import {prependSchemaVersionHeader} from './function/schema-version.js' | ||
| import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' | ||
| import {SchemaDefinitionByApiTypeQueryVariables} from '../api/graphql/functions/generated/schema-definition-by-api-type.js' | ||
| import {SchemaDefinitionByTargetQueryVariables} from '../api/graphql/functions/generated/schema-definition-by-target.js' | ||
|
|
@@ -22,7 +23,7 @@ export async function generateSchemaService(options: GenerateSchemaOptions) { | |
| const apiKey = app.configuration.client_id | ||
| const {api_version: version, type, targeting} = extension.configuration | ||
| const usingTargets = Boolean(targeting?.length) | ||
| const definition = await (usingTargets | ||
| const fetchedDefinition = await (usingTargets | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I dont understand the difference between
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I introduced Reading top-down: fetch -> add version header -> write.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I guess fetched_definition doesn't immediately make sense to me when I read the code but its okay, its just a variable name, I won't let it block you from shipping the PR. |
||
| ? generateSchemaFromTarget({ | ||
| localIdentifier: extension.localIdentifier, | ||
| developerPlatformClient, | ||
|
|
@@ -41,6 +42,8 @@ export async function generateSchemaService(options: GenerateSchemaOptions) { | |
| orgId, | ||
| })) | ||
|
|
||
| const definition = prependSchemaVersionHeader(fetchedDefinition, version) | ||
|
|
||
| if (stdout) { | ||
| outputResult(definition) | ||
| } else { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.