diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 9338d16fde..3ab63a92bb 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -234,7 +234,7 @@ jobs: - batch: pg-postgres packages: 'postgres/pgsql-test postgres/drizzle-orm-test postgres/introspectron graphile/graphile-test graphile/graphile-connection-filter graphile/graphile-postgis' - batch: pg-graphql - packages: 'graphile/graphile-search graphile/graphile-ltree graphile/graphile-bulk-mutations graphile/graphile-function-bindings graphile/graphile-history graphql/orm-test graphql/test graphql/playwright-test' + packages: 'graphile/graphile-search graphile/graphile-ltree graphile/graphile-bulk-mutations graphile/graphile-function-bindings graphile/graphile-history graphile/graphile-meta graphile/graphile-schema graphql/orm-test graphql/test graphql/playwright-test' env: PGHOST: localhost diff --git a/graphile/graphile-meta/__tests__/meta-schema.test.ts b/graphile/graphile-meta/__tests__/meta-schema.test.ts index 0e6ce4be40..45c277e136 100644 --- a/graphile/graphile-meta/__tests__/meta-schema.test.ts +++ b/graphile/graphile-meta/__tests__/meta-schema.test.ts @@ -14,8 +14,8 @@ import { import { _buildFieldMeta, - _cachedTablesMeta, _pgTypeToGqlType, + getTablesMetaForSchema, MetaSchemaPlugin } from '../src'; import { collectTablesMeta } from '../src/table-meta-builder'; @@ -136,14 +136,6 @@ function callGraphQLObjectTypeFieldsHook( }); } -function callFinalizeHook(schema: GraphQLSchema, build: any): GraphQLSchema { - const finalizeHook = MetaSchemaPlugin.schema!.hooks!.finalize as ( - schema: GraphQLSchema, - build: any, - ) => GraphQLSchema; - return finalizeHook(schema, build); -} - function deepClone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } @@ -2024,9 +2016,6 @@ describe('MetaSchemaPlugin', () => { }), types: [userType] }); - callFinalizeHook(schema, build); - const seededTables = _cachedTablesMeta as any[]; - const result = await graphql({ schema, source: ` @@ -2047,6 +2036,7 @@ describe('MetaSchemaPlugin', () => { expect(result.errors).toBeUndefined(); expect(result.data?.ping).toBe('pong'); + const seededTables = getTablesMetaForSchema(schema) as any[]; expect((result.data as any)?._meta?.tables).toHaveLength(seededTables.length); expect((result.data as any)?._meta?.tables?.[0]).toMatchObject({ name: 'User', @@ -2068,7 +2058,7 @@ describe('MetaSchemaPlugin', () => { ]); }); - it('keeps resolver metadata scoped while replacing the legacy cache per build', async () => { + it('keeps resolver metadata scoped per executable schema', async () => { const buildSchema = (resourceName: string, typeName: string) => { const build = createMockBuild({ [resourceName]: { @@ -2096,15 +2086,11 @@ describe('MetaSchemaPlugin', () => { }) ] }); - callFinalizeHook(schema, build); return schema; }; const userSchema = buildSchema('user', 'User'); - expect(_cachedTablesMeta.map((table) => table.name)).toEqual(['User']); - const projectSchema = buildSchema('project', 'Project'); - expect(_cachedTablesMeta.map((table) => table.name)).toEqual(['Project']); const source = '{ _meta { tables { name } } }'; const [userResult, projectResult] = await Promise.all([ @@ -2118,6 +2104,12 @@ describe('MetaSchemaPlugin', () => { expect((projectResult.data as any)._meta.tables).toEqual([ { name: 'Project' } ]); + expect( + getTablesMetaForSchema(userSchema)!.map((table) => table.name) + ).toEqual(['User']); + expect( + getTablesMetaForSchema(projectSchema)!.map((table) => table.name) + ).toEqual(['Project']); }); it('validates metadata against schema changes made by later finalizers', async () => { @@ -2152,8 +2144,9 @@ describe('MetaSchemaPlugin', () => { }); const schema = new GraphQLSchema({ query: queryType }); - callFinalizeHook(schema, build); - expect(_cachedTablesMeta[0].query.all).toBe('users'); + // Before later finalizers mutate the schema, the metadata resolves the + // list entry-point; the resolver must recompute from the final schema. + expect((collectTablesMeta(build, schema) as any[])[0].query.all).toBe('users'); delete queryType.getFields().users; const result = await graphql({ diff --git a/graphile/graphile-meta/src/cache.ts b/graphile/graphile-meta/src/cache.ts deleted file mode 100644 index 3e9f909b7a..0000000000 --- a/graphile/graphile-meta/src/cache.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { TableMeta } from './types'; - -export let cachedTablesMeta: TableMeta[] = []; - -export function getCachedTablesMeta(): TableMeta[] { - return cachedTablesMeta; -} - -export function setCachedTablesMeta(tablesMeta: TableMeta[]): void { - cachedTablesMeta = tablesMeta; -} diff --git a/graphile/graphile-meta/src/index.ts b/graphile/graphile-meta/src/index.ts index 68c217c2cf..4d5159189e 100644 --- a/graphile/graphile-meta/src/index.ts +++ b/graphile/graphile-meta/src/index.ts @@ -7,12 +7,11 @@ import type { GraphileConfig } from 'graphile-config'; -import { cachedTablesMeta } from './cache'; import { buildScalarEncoding } from './encoding-meta-builders'; -import { MetaSchemaPlugin } from './plugin'; +import { getTablesMetaForSchema, MetaSchemaPlugin } from './plugin'; import { buildFieldMeta, pgTypeToGqlType } from './type-mappings'; -export { MetaSchemaPlugin }; +export { getTablesMetaForSchema, MetaSchemaPlugin }; export const MetaSchemaPreset: GraphileConfig.Preset = { plugins: [MetaSchemaPlugin], @@ -40,8 +39,6 @@ export { pgTypeToGqlType as _pgTypeToGqlType }; /** @internal Exported for testing only */ export { buildFieldMeta as _buildFieldMeta }; /** @internal Exported for testing only */ -export { cachedTablesMeta as _cachedTablesMeta }; -/** @internal Exported for testing only */ export { buildScalarEncoding as _buildScalarEncoding }; export default MetaSchemaPlugin; diff --git a/graphile/graphile-meta/src/plugin.ts b/graphile/graphile-meta/src/plugin.ts index 65fe9f3704..b87b7b1dbc 100644 --- a/graphile/graphile-meta/src/plugin.ts +++ b/graphile/graphile-meta/src/plugin.ts @@ -4,7 +4,6 @@ import 'graphile-build'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLSchema } from 'graphql'; -import { setCachedTablesMeta } from './cache'; import { extendQueryWithMetaField } from './graphql-meta-field'; import { collectTablesMeta } from './table-meta-builder'; import type { MetaBuild, TableMeta } from './types'; @@ -19,11 +18,21 @@ function getRuntimeTablesMeta( if (!tables) { tables = collectTablesMeta(build, schema); runtimeTablesBySchema.set(schema, tables); - setCachedTablesMeta(tables); } return tables; } +/** + * Returns the table metadata memoized for the given executable schema, or + * `undefined` if `_meta` has not been resolved against that schema (e.g. the + * meta plugin is disabled or `_meta` was never executed). + */ +export function getTablesMetaForSchema( + schema: GraphQLSchema +): TableMeta[] | undefined { + return runtimeTablesBySchema.get(schema); +} + export const MetaSchemaPlugin: GraphileConfig.Plugin = { name: 'MetaSchemaPlugin', version: '1.0.0', @@ -38,16 +47,6 @@ export const MetaSchemaPlugin: GraphileConfig.Plugin = { (schema) => getRuntimeTablesMeta(build, schema), ) as typeof rawFields; }, - - finalize(schema, rawBuild) { - // Populate the legacy module-level cache for consumers that read - // `_cachedTablesMeta` without executing `_meta`. Deliberately does NOT - // pre-warm the per-schema memo: later finalizers may still mutate the - // schema, and the resolver must recompute from its final info.schema. - const build = rawBuild as unknown as MetaBuild; - setCachedTablesMeta(collectTablesMeta(build, schema)); - return schema; - }, }, }, }; diff --git a/graphile/graphile-schema/__tests__/build-schema-artifacts.test.ts b/graphile/graphile-schema/__tests__/build-schema-artifacts.test.ts new file mode 100644 index 0000000000..50b9445591 --- /dev/null +++ b/graphile/graphile-schema/__tests__/build-schema-artifacts.test.ts @@ -0,0 +1,144 @@ +import { pgCache } from 'pg-cache'; +import { getConnections, PgTestClient } from 'pgsql-test'; + +import { buildIntrospectionJSON } from '../src/build-introspection'; +import { buildSchemaArtifacts } from '../src/build-schema'; + +jest.setTimeout(60000); + +let pg: PgTestClient; +let teardown: () => Promise; +let database: string; + +beforeAll(async () => { + ({ pg, teardown } = await getConnections()); + database = pg.config.database; + + await pg.query(` + CREATE SCHEMA schema_a; + CREATE TABLE schema_a.alpha_items ( + id serial PRIMARY KEY, + title text NOT NULL + ); + + CREATE SCHEMA schema_b; + CREATE TABLE schema_b.beta_widgets ( + id serial PRIMARY KEY, + label text NOT NULL + ); + `); +}); + +afterAll(async () => { + // Release the pg-cache pool created by buildSchemaArtifacts before the + // ephemeral database is dropped. + pgCache.delete(database); + await pgCache.waitForDisposals(); + await teardown(); +}); + +function tableNames(tables: { tableName: string }[]): string[] { + return tables.map((t) => t.tableName).sort(); +} + +describe('buildSchemaArtifacts', () => { + it('returns SDL and tablesMeta from the same schema build', async () => { + const a = await buildSchemaArtifacts({ database, schemas: ['schema_a'] }); + expect(a.sdl).toContain('AlphaItem'); + expect(a.sdl).not.toContain('BetaWidget'); + expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']); + + const b = await buildSchemaArtifacts({ database, schemas: ['schema_b'] }); + expect(b.sdl).toContain('BetaWidget'); + expect(tableNames(b.tablesMeta)).toEqual(['beta_widgets']); + + // The earlier result must be unaffected by the later build. + expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']); + }); + + it('keeps results correlated under a forced A write -> B write -> A read schedule', async () => { + // Deterministically reproduce the unsafe ordering from the legacy split + // contract: caller A finishes collecting metadata (legacy global written), + // caller B builds fully (legacy global overwritten), then caller A resumes + // and returns. The correlated artifact API must still return A's metadata. + let releaseA!: () => void; + const gateA = new Promise((resolve) => { + releaseA = resolve; + }); + let signalACollected!: () => void; + const aCollected = new Promise((resolve) => { + signalACollected = resolve; + }); + + const pendingA = buildSchemaArtifacts({ + database, + schemas: ['schema_a'], + _onMetaCollected: async () => { + signalACollected(); + await gateA; + } + }); + + await aCollected; + const b = await buildSchemaArtifacts({ database, schemas: ['schema_b'] }); + releaseA(); + const a = await pendingA; + + expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']); + expect(a.sdl).toContain('AlphaItem'); + expect(tableNames(b.tablesMeta)).toEqual(['beta_widgets']); + expect(b.sdl).toContain('BetaWidget'); + }); + + it('keeps concurrent uncoordinated builds correlated', async () => { + const [a, b] = await Promise.all([ + buildSchemaArtifacts({ database, schemas: ['schema_a'] }), + buildSchemaArtifacts({ database, schemas: ['schema_b'] }) + ]); + + expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']); + expect(tableNames(b.tablesMeta)).toEqual(['beta_widgets']); + }); + + it('returns explicit empty metadata when the meta plugin is disabled', async () => { + const artifacts = await buildSchemaArtifacts({ + database, + schemas: ['schema_a'], + graphile: { disablePlugins: ['MetaSchemaPlugin'] } + }); + + expect(artifacts.sdl).toContain('AlphaItem'); + expect(artifacts.sdl).not.toContain('_meta'); + expect(artifacts.tablesMeta).toEqual([]); + }); +}); + +describe('buildIntrospectionJSON', () => { + it('returns metadata correlated to its own build', async () => { + let releaseA!: () => void; + const gateA = new Promise((resolve) => { + releaseA = resolve; + }); + let signalACollected!: () => void; + const aCollected = new Promise((resolve) => { + signalACollected = resolve; + }); + + const pendingA = buildIntrospectionJSON({ + database, + schemas: ['schema_a'], + _onMetaCollected: async () => { + signalACollected(); + await gateA; + } + }); + + await aCollected; + const b = await buildIntrospectionJSON({ database, schemas: ['schema_b'] }); + releaseA(); + const a = await pendingA; + + expect(tableNames(a)).toEqual(['alpha_items']); + expect(tableNames(b)).toEqual(['beta_widgets']); + }); +}); diff --git a/graphile/graphile-schema/package.json b/graphile/graphile-schema/package.json index 05cd49d038..a08bd93539 100644 --- a/graphile/graphile-schema/package.json +++ b/graphile/graphile-schema/package.json @@ -39,6 +39,7 @@ }, "devDependencies": { "makage": "^0.3.0", + "pgsql-test": "workspace:^", "ts-node": "^10.9.2" }, "keywords": [ diff --git a/graphile/graphile-schema/src/build-introspection.ts b/graphile/graphile-schema/src/build-introspection.ts index c4607d10fd..1f535a06cd 100644 --- a/graphile/graphile-schema/src/build-introspection.ts +++ b/graphile/graphile-schema/src/build-introspection.ts @@ -1,6 +1,5 @@ import type { TableMeta } from 'graphile-settings' -import { _cachedTablesMeta } from 'graphile-settings' -import { buildSchemaSDL } from './build-schema' +import { buildSchemaArtifacts } from './build-schema' import type { BuildSchemaOptions } from './build-schema' export type { BuildSchemaOptions as BuildIntrospectionOptions } @@ -8,9 +7,10 @@ export type { BuildSchemaOptions as BuildIntrospectionOptions } /** * Build introspection metadata for all tables visible in the given schemas. * - * Internally calls `buildSchemaSDL()` which triggers the MetaSchemaPlugin - * finalization hook, populating `_cachedTablesMeta` as a side-effect. The cached - * metadata is then returned as a plain array of `TableMeta` objects. + * Internally calls `buildSchemaArtifacts()`, which returns SDL and `_meta` + * metadata from one correlated build boundary — both derived from the same + * final executable `GraphQLSchema` — so concurrent builds in one process + * cannot return each other's metadata. * * The result includes every table's fields, types, constraints, indexes, * relations, inflection names, and query entry-points — the same data @@ -31,6 +31,5 @@ export type { BuildSchemaOptions as BuildIntrospectionOptions } export async function buildIntrospectionJSON( opts: BuildSchemaOptions ): Promise { - await buildSchemaSDL(opts) - return [..._cachedTablesMeta] + return (await buildSchemaArtifacts(opts)).tablesMeta } diff --git a/graphile/graphile-schema/src/build-schema.ts b/graphile/graphile-schema/src/build-schema.ts index af3e25c5f1..744fe09d00 100644 --- a/graphile/graphile-schema/src/build-schema.ts +++ b/graphile/graphile-schema/src/build-schema.ts @@ -1,18 +1,41 @@ import deepmerge from 'deepmerge' import { graphql, lexicographicSortSchema, printSchema } from 'graphql' -import { ConstructivePreset, makePgService } from 'graphile-settings' +import { ConstructivePreset, getTablesMetaForSchema, makePgService } from 'graphile-settings' import { makeSchema } from 'graphile-build' import { getPgPool } from 'pg-cache' import { getPgEnvOptions } from 'pg-env' import type { GraphileConfig } from 'graphile-config' +import type { TableMeta } from 'graphile-settings' export type BuildSchemaOptions = { database?: string; schemas: string[]; graphile?: Partial; + /** + * @internal Test-only. Awaited after the schema's `_meta` metadata has been + * collected and before artifacts are returned, so regression tests can + * deterministically interleave concurrent builds. + */ + _onMetaCollected?: () => Promise; }; -export async function buildSchemaSDL(opts: BuildSchemaOptions): Promise { +export type BuildSchemaArtifacts = { + /** SDL printed from the final executable schema. */ + sdl: string; + /** + * `_meta` table metadata belonging to the same `GraphQLSchema` the SDL was + * printed from. Empty when the meta plugin is disabled (no `_meta` field). + */ + tablesMeta: TableMeta[]; +}; + +/** + * Build the GraphQL schema for a database and return its SDL together with + * the `_meta` table metadata from one correlated build boundary. Both values + * are derived from the same final executable `GraphQLSchema` instance, so + * concurrent builds in one process can never cross-contaminate results. + */ +export async function buildSchemaArtifacts(opts: BuildSchemaOptions): Promise { const database = opts.database ?? 'constructive' const schemas = Array.isArray(opts.schemas) ? opts.schemas : [] @@ -52,7 +75,8 @@ export async function buildSchemaSDL(opts: BuildSchemaOptions): Promise // MetaSchemaPlugin validates executable names lazily against the schema that // will actually be executed. Trigger that resolver after every finalizer has - // run so legacy `_cachedTablesMeta` consumers receive the same final metadata. + // run, then read the metadata memoized for this exact GraphQLSchema instance. + let tablesMeta: TableMeta[] = [] if (schema.getQueryType()?.getFields()._meta) { const result = await graphql({ schema, @@ -61,7 +85,19 @@ export async function buildSchemaSDL(opts: BuildSchemaOptions): Promise if (result.errors?.length) { throw new AggregateError(result.errors, 'Failed to build schema metadata') } + tablesMeta = getTablesMetaForSchema(schema) ?? [] + } + + if (opts._onMetaCollected) { + await opts._onMetaCollected() } - return printSchema(lexicographicSortSchema(schema)) + return { + sdl: printSchema(lexicographicSortSchema(schema)), + tablesMeta + } +} + +export async function buildSchemaSDL(opts: BuildSchemaOptions): Promise { + return (await buildSchemaArtifacts(opts)).sdl } diff --git a/graphile/graphile-schema/src/index.ts b/graphile/graphile-schema/src/index.ts index c05a296166..77c3f4aac2 100644 --- a/graphile/graphile-schema/src/index.ts +++ b/graphile/graphile-schema/src/index.ts @@ -1,7 +1,6 @@ -export { buildSchemaSDL } from './build-schema'; -export type { BuildSchemaOptions } from './build-schema'; +export { buildSchemaArtifacts, buildSchemaSDL } from './build-schema'; +export type { BuildSchemaArtifacts, BuildSchemaOptions } from './build-schema'; export { buildIntrospectionJSON } from './build-introspection'; -export { _cachedTablesMeta } from 'graphile-settings'; export type { TableMeta, FieldMeta, diff --git a/graphile/graphile-settings/src/plugins/index.ts b/graphile/graphile-settings/src/plugins/index.ts index fdd1d8302a..a46d08ddb0 100644 --- a/graphile/graphile-settings/src/plugins/index.ts +++ b/graphile/graphile-settings/src/plugins/index.ts @@ -49,6 +49,7 @@ export type { UniqueLookupOptions } from './primary-key-only'; // Meta schema plugin for introspection (tables, fields, indexes, constraints) export { + getTablesMetaForSchema, MetaSchemaPlugin, MetaSchemaPreset, } from './meta-schema'; @@ -81,7 +82,7 @@ export { PublicKeySignature } from './PublicKeySignature'; export type { PublicKeyChallengeConfig } from './PublicKeySignature'; // Internal exports for testing -export { _pgTypeToGqlType, _buildFieldMeta, _cachedTablesMeta } from './meta-schema'; +export { _pgTypeToGqlType, _buildFieldMeta } from './meta-schema'; // Required input plugin - makes @requiredInput tagged fields non-nullable in mutation inputs export { diff --git a/graphile/graphile-settings/src/plugins/meta-schema.ts b/graphile/graphile-settings/src/plugins/meta-schema.ts index 72cd44d1f3..ffe2636c9f 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema.ts @@ -7,8 +7,8 @@ export { _buildFieldMeta, _buildScalarEncoding, - _cachedTablesMeta, _pgTypeToGqlType, + getTablesMetaForSchema, MetaSchemaPlugin, MetaSchemaPreset, } from 'graphile-meta'; diff --git a/graphql/codegen/src/__tests__/introspect/database-source.test.ts b/graphql/codegen/src/__tests__/introspect/database-source.test.ts new file mode 100644 index 0000000000..cf8680abeb --- /dev/null +++ b/graphql/codegen/src/__tests__/introspect/database-source.test.ts @@ -0,0 +1,86 @@ +/** + * Regression: DatabaseSchemaSource must return GraphQL introspection and + * tablesMeta from the same correlated build result, never from the legacy + * process-global `_cachedTablesMeta` channel — which another build in the + * same process can overwrite between builds. + */ +import { DatabaseSchemaSource } from '../../core/introspect/source/database'; + +const SDL_A = ` +type Query { + alphaItems: [AlphaItem!] +} + +type AlphaItem { + id: Int! + title: String! +} +`; + +const SDL_B = ` +type Query { + betaWidgets: [BetaWidget!] +} + +type BetaWidget { + id: Int! + label: String! +} +`; + +const tableMetaA = { name: 'AlphaItem', tableName: 'alpha_items' }; +const tableMetaB = { name: 'BetaWidget', tableName: 'beta_widgets' }; + +// Simulates the legacy process-global: the *last* build in the process wins. +let lastBuildGlobal: unknown[] = []; + +jest.mock('graphile-schema', () => ({ + buildSchemaArtifacts: jest.fn(async (opts: { schemas: string[]; _onMetaCollected?: () => Promise }) => { + const isA = opts.schemas.includes('schema_a'); + const tablesMeta = isA ? [tableMetaA] : [tableMetaB]; + lastBuildGlobal = tablesMeta; + if (opts._onMetaCollected) await opts._onMetaCollected(); + return { sdl: isA ? SDL_A : SDL_B, tablesMeta }; + }), +})); + +function typeNames(introspection: { __schema: { types: { name: string }[] } }): string[] { + return introspection.__schema.types.map((t) => t.name); +} + +describe('DatabaseSchemaSource result correlation', () => { + it('returns introspection and tablesMeta belonging to the same schema', async () => { + const source = new DatabaseSchemaSource({ + database: 'test_db', + schemas: ['schema_a'], + }); + + const result = await source.fetch(); + + expect(typeNames(result.introspection)).toContain('AlphaItem'); + expect(result.tablesMeta).toEqual([tableMetaA]); + }); + + it('is unaffected by another schema build completing mid-fetch (A write -> B write -> A read)', async () => { + const sourceA = new DatabaseSchemaSource({ + database: 'test_db', + schemas: ['schema_a'], + // Pause A after its metadata is collected so B can build and overwrite + // the simulated process-global before A's fetch resumes. + _onMetaCollected: async () => { + const sourceB = new DatabaseSchemaSource({ + database: 'test_db', + schemas: ['schema_b'], + }); + await sourceB.fetch(); + expect(lastBuildGlobal).toEqual([tableMetaB]); + }, + }); + + const resultA = await sourceA.fetch(); + + // A must return its own metadata even though B's build wrote last. + expect(typeNames(resultA.introspection)).toContain('AlphaItem'); + expect(resultA.tablesMeta).toEqual([tableMetaA]); + }); +}); diff --git a/graphql/codegen/src/core/introspect/enrich-relations.ts b/graphql/codegen/src/core/introspect/enrich-relations.ts index 0cbbe164c4..1369e3f8f1 100644 --- a/graphql/codegen/src/core/introspect/enrich-relations.ts +++ b/graphql/codegen/src/core/introspect/enrich-relations.ts @@ -2,7 +2,7 @@ * M:N Relation Enrichment * * After table inference from introspection, enriches ManyToManyRelation objects - * with junction key field metadata from _cachedTablesMeta (MetaSchemaPlugin). + * with junction key field metadata from the schema-correlated tablesMeta (MetaSchemaPlugin). */ import type { Table } from '../../types/schema'; import type { MetaTableInfo } from './source/types'; diff --git a/graphql/codegen/src/core/introspect/source/database.ts b/graphql/codegen/src/core/introspect/source/database.ts index 69e2f15cee..2435948b66 100644 --- a/graphql/codegen/src/core/introspect/source/database.ts +++ b/graphql/codegen/src/core/introspect/source/database.ts @@ -3,11 +3,13 @@ * * Loads GraphQL schema directly from a PostgreSQL database using PostGraphile * introspection and converts it to introspection format. - * Also returns _meta table metadata when available (via MetaSchemaPlugin cache). + * Also returns _meta table metadata correlated to the same schema build + * (via buildSchemaArtifacts). */ import { buildSchema, introspectionFromSchema } from 'graphql'; -import { buildSchemaSDL, _cachedTablesMeta } from 'graphile-schema'; +import { buildSchemaArtifacts } from 'graphile-schema'; +import type { TableMeta } from 'graphile-schema'; import type { IntrospectionQueryResponse } from '../../../types/introspection'; import { @@ -38,6 +40,12 @@ export interface DatabaseSchemaSourceOptions { * Mutually exclusive with schemas */ apiNames?: string[]; + + /** + * @internal Test-only. Forwarded to `buildSchemaArtifacts` so regression + * tests can deterministically interleave concurrent builds. + */ + _onMetaCollected?: () => Promise; } /** @@ -79,13 +87,16 @@ export class DatabaseSchemaSource implements SchemaSource { schemas = this.options.schemas ?? ['public']; } - // Build SDL from database (MetaSchemaPlugin populates _cachedTablesMeta as a side-effect) + // Build SDL and _meta metadata from one correlated build boundary so the + // returned pair always describes the same GraphQLSchema. let sdl: string; + let tablesMeta: TableMeta[]; try { - sdl = await buildSchemaSDL({ + ({ sdl, tablesMeta } = await buildSchemaArtifacts({ database, schemas, - }); + _onMetaCollected: this.options._onMetaCollected, + })); } catch (err) { throw new SchemaSourceError( `Failed to introspect database: ${err instanceof Error ? err.message : 'Unknown error'}`, @@ -133,7 +144,7 @@ export class DatabaseSchemaSource implements SchemaSource { return { introspection, - tablesMeta: [..._cachedTablesMeta] as MetaTableInfo[], + tablesMeta: tablesMeta as unknown as MetaTableInfo[], }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1e019e648..dac447d269 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1122,6 +1122,9 @@ importers: makage: specifier: ^0.3.0 version: 0.3.0 + pgsql-test: + specifier: workspace:^ + version: link:../../postgres/pgsql-test/dist ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3)