Skip to content

Commit 97a18c2

Browse files
authored
Merge pull request #1561 from constructive-io/feat/correlated-schema-artifacts
fix(graphile-schema): correlate SDL and _meta metadata to one schema build
2 parents f73c89f + 5f409be commit 97a18c2

16 files changed

Lines changed: 329 additions & 71 deletions

File tree

.github/workflows/run-tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ jobs:
234234
- batch: pg-postgres
235235
packages: 'postgres/pgsql-test postgres/drizzle-orm-test postgres/introspectron graphile/graphile-test graphile/graphile-connection-filter graphile/graphile-postgis'
236236
- batch: pg-graphql
237-
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'
237+
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'
238238

239239
env:
240240
PGHOST: localhost

graphile/graphile-meta/__tests__/meta-schema.test.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import {
1414

1515
import {
1616
_buildFieldMeta,
17-
_cachedTablesMeta,
1817
_pgTypeToGqlType,
18+
getTablesMetaForSchema,
1919
MetaSchemaPlugin
2020
} from '../src';
2121
import { collectTablesMeta } from '../src/table-meta-builder';
@@ -136,14 +136,6 @@ function callGraphQLObjectTypeFieldsHook(
136136
});
137137
}
138138

139-
function callFinalizeHook(schema: GraphQLSchema, build: any): GraphQLSchema {
140-
const finalizeHook = MetaSchemaPlugin.schema!.hooks!.finalize as (
141-
schema: GraphQLSchema,
142-
build: any,
143-
) => GraphQLSchema;
144-
return finalizeHook(schema, build);
145-
}
146-
147139
function deepClone<T>(value: T): T {
148140
return JSON.parse(JSON.stringify(value)) as T;
149141
}
@@ -2024,9 +2016,6 @@ describe('MetaSchemaPlugin', () => {
20242016
}),
20252017
types: [userType]
20262018
});
2027-
callFinalizeHook(schema, build);
2028-
const seededTables = _cachedTablesMeta as any[];
2029-
20302019
const result = await graphql({
20312020
schema,
20322021
source: `
@@ -2047,6 +2036,7 @@ describe('MetaSchemaPlugin', () => {
20472036

20482037
expect(result.errors).toBeUndefined();
20492038
expect(result.data?.ping).toBe('pong');
2039+
const seededTables = getTablesMetaForSchema(schema) as any[];
20502040
expect((result.data as any)?._meta?.tables).toHaveLength(seededTables.length);
20512041
expect((result.data as any)?._meta?.tables?.[0]).toMatchObject({
20522042
name: 'User',
@@ -2068,7 +2058,7 @@ describe('MetaSchemaPlugin', () => {
20682058
]);
20692059
});
20702060

2071-
it('keeps resolver metadata scoped while replacing the legacy cache per build', async () => {
2061+
it('keeps resolver metadata scoped per executable schema', async () => {
20722062
const buildSchema = (resourceName: string, typeName: string) => {
20732063
const build = createMockBuild({
20742064
[resourceName]: {
@@ -2096,15 +2086,11 @@ describe('MetaSchemaPlugin', () => {
20962086
})
20972087
]
20982088
});
2099-
callFinalizeHook(schema, build);
21002089
return schema;
21012090
};
21022091

21032092
const userSchema = buildSchema('user', 'User');
2104-
expect(_cachedTablesMeta.map((table) => table.name)).toEqual(['User']);
2105-
21062093
const projectSchema = buildSchema('project', 'Project');
2107-
expect(_cachedTablesMeta.map((table) => table.name)).toEqual(['Project']);
21082094
const source = '{ _meta { tables { name } } }';
21092095

21102096
const [userResult, projectResult] = await Promise.all([
@@ -2118,6 +2104,12 @@ describe('MetaSchemaPlugin', () => {
21182104
expect((projectResult.data as any)._meta.tables).toEqual([
21192105
{ name: 'Project' }
21202106
]);
2107+
expect(
2108+
getTablesMetaForSchema(userSchema)!.map((table) => table.name)
2109+
).toEqual(['User']);
2110+
expect(
2111+
getTablesMetaForSchema(projectSchema)!.map((table) => table.name)
2112+
).toEqual(['Project']);
21212113
});
21222114

21232115
it('validates metadata against schema changes made by later finalizers', async () => {
@@ -2152,8 +2144,9 @@ describe('MetaSchemaPlugin', () => {
21522144
});
21532145
const schema = new GraphQLSchema({ query: queryType });
21542146

2155-
callFinalizeHook(schema, build);
2156-
expect(_cachedTablesMeta[0].query.all).toBe('users');
2147+
// Before later finalizers mutate the schema, the metadata resolves the
2148+
// list entry-point; the resolver must recompute from the final schema.
2149+
expect((collectTablesMeta(build, schema) as any[])[0].query.all).toBe('users');
21572150

21582151
delete queryType.getFields().users;
21592152
const result = await graphql({

graphile/graphile-meta/src/cache.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

graphile/graphile-meta/src/index.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@
77

88
import type { GraphileConfig } from 'graphile-config';
99

10-
import { cachedTablesMeta } from './cache';
1110
import { buildScalarEncoding } from './encoding-meta-builders';
12-
import { MetaSchemaPlugin } from './plugin';
11+
import { getTablesMetaForSchema, MetaSchemaPlugin } from './plugin';
1312
import { buildFieldMeta, pgTypeToGqlType } from './type-mappings';
1413

15-
export { MetaSchemaPlugin };
14+
export { getTablesMetaForSchema, MetaSchemaPlugin };
1615

1716
export const MetaSchemaPreset: GraphileConfig.Preset = {
1817
plugins: [MetaSchemaPlugin],
@@ -40,8 +39,6 @@ export { pgTypeToGqlType as _pgTypeToGqlType };
4039
/** @internal Exported for testing only */
4140
export { buildFieldMeta as _buildFieldMeta };
4241
/** @internal Exported for testing only */
43-
export { cachedTablesMeta as _cachedTablesMeta };
44-
/** @internal Exported for testing only */
4542
export { buildScalarEncoding as _buildScalarEncoding };
4643

4744
export default MetaSchemaPlugin;

graphile/graphile-meta/src/plugin.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import 'graphile-build';
44
import type { GraphileConfig } from 'graphile-config';
55
import type { GraphQLSchema } from 'graphql';
66

7-
import { setCachedTablesMeta } from './cache';
87
import { extendQueryWithMetaField } from './graphql-meta-field';
98
import { collectTablesMeta } from './table-meta-builder';
109
import type { MetaBuild, TableMeta } from './types';
@@ -19,11 +18,21 @@ function getRuntimeTablesMeta(
1918
if (!tables) {
2019
tables = collectTablesMeta(build, schema);
2120
runtimeTablesBySchema.set(schema, tables);
22-
setCachedTablesMeta(tables);
2321
}
2422
return tables;
2523
}
2624

25+
/**
26+
* Returns the table metadata memoized for the given executable schema, or
27+
* `undefined` if `_meta` has not been resolved against that schema (e.g. the
28+
* meta plugin is disabled or `_meta` was never executed).
29+
*/
30+
export function getTablesMetaForSchema(
31+
schema: GraphQLSchema
32+
): TableMeta[] | undefined {
33+
return runtimeTablesBySchema.get(schema);
34+
}
35+
2736
export const MetaSchemaPlugin: GraphileConfig.Plugin = {
2837
name: 'MetaSchemaPlugin',
2938
version: '1.0.0',
@@ -38,16 +47,6 @@ export const MetaSchemaPlugin: GraphileConfig.Plugin = {
3847
(schema) => getRuntimeTablesMeta(build, schema),
3948
) as typeof rawFields;
4049
},
41-
42-
finalize(schema, rawBuild) {
43-
// Populate the legacy module-level cache for consumers that read
44-
// `_cachedTablesMeta` without executing `_meta`. Deliberately does NOT
45-
// pre-warm the per-schema memo: later finalizers may still mutate the
46-
// schema, and the resolver must recompute from its final info.schema.
47-
const build = rawBuild as unknown as MetaBuild;
48-
setCachedTablesMeta(collectTablesMeta(build, schema));
49-
return schema;
50-
},
5150
},
5251
},
5352
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { pgCache } from 'pg-cache';
2+
import { getConnections, PgTestClient } from 'pgsql-test';
3+
4+
import { buildIntrospectionJSON } from '../src/build-introspection';
5+
import { buildSchemaArtifacts } from '../src/build-schema';
6+
7+
jest.setTimeout(60000);
8+
9+
let pg: PgTestClient;
10+
let teardown: () => Promise<void>;
11+
let database: string;
12+
13+
beforeAll(async () => {
14+
({ pg, teardown } = await getConnections());
15+
database = pg.config.database;
16+
17+
await pg.query(`
18+
CREATE SCHEMA schema_a;
19+
CREATE TABLE schema_a.alpha_items (
20+
id serial PRIMARY KEY,
21+
title text NOT NULL
22+
);
23+
24+
CREATE SCHEMA schema_b;
25+
CREATE TABLE schema_b.beta_widgets (
26+
id serial PRIMARY KEY,
27+
label text NOT NULL
28+
);
29+
`);
30+
});
31+
32+
afterAll(async () => {
33+
// Release the pg-cache pool created by buildSchemaArtifacts before the
34+
// ephemeral database is dropped.
35+
pgCache.delete(database);
36+
await pgCache.waitForDisposals();
37+
await teardown();
38+
});
39+
40+
function tableNames(tables: { tableName: string }[]): string[] {
41+
return tables.map((t) => t.tableName).sort();
42+
}
43+
44+
describe('buildSchemaArtifacts', () => {
45+
it('returns SDL and tablesMeta from the same schema build', async () => {
46+
const a = await buildSchemaArtifacts({ database, schemas: ['schema_a'] });
47+
expect(a.sdl).toContain('AlphaItem');
48+
expect(a.sdl).not.toContain('BetaWidget');
49+
expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']);
50+
51+
const b = await buildSchemaArtifacts({ database, schemas: ['schema_b'] });
52+
expect(b.sdl).toContain('BetaWidget');
53+
expect(tableNames(b.tablesMeta)).toEqual(['beta_widgets']);
54+
55+
// The earlier result must be unaffected by the later build.
56+
expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']);
57+
});
58+
59+
it('keeps results correlated under a forced A write -> B write -> A read schedule', async () => {
60+
// Deterministically reproduce the unsafe ordering from the legacy split
61+
// contract: caller A finishes collecting metadata (legacy global written),
62+
// caller B builds fully (legacy global overwritten), then caller A resumes
63+
// and returns. The correlated artifact API must still return A's metadata.
64+
let releaseA!: () => void;
65+
const gateA = new Promise<void>((resolve) => {
66+
releaseA = resolve;
67+
});
68+
let signalACollected!: () => void;
69+
const aCollected = new Promise<void>((resolve) => {
70+
signalACollected = resolve;
71+
});
72+
73+
const pendingA = buildSchemaArtifacts({
74+
database,
75+
schemas: ['schema_a'],
76+
_onMetaCollected: async () => {
77+
signalACollected();
78+
await gateA;
79+
}
80+
});
81+
82+
await aCollected;
83+
const b = await buildSchemaArtifacts({ database, schemas: ['schema_b'] });
84+
releaseA();
85+
const a = await pendingA;
86+
87+
expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']);
88+
expect(a.sdl).toContain('AlphaItem');
89+
expect(tableNames(b.tablesMeta)).toEqual(['beta_widgets']);
90+
expect(b.sdl).toContain('BetaWidget');
91+
});
92+
93+
it('keeps concurrent uncoordinated builds correlated', async () => {
94+
const [a, b] = await Promise.all([
95+
buildSchemaArtifacts({ database, schemas: ['schema_a'] }),
96+
buildSchemaArtifacts({ database, schemas: ['schema_b'] })
97+
]);
98+
99+
expect(tableNames(a.tablesMeta)).toEqual(['alpha_items']);
100+
expect(tableNames(b.tablesMeta)).toEqual(['beta_widgets']);
101+
});
102+
103+
it('returns explicit empty metadata when the meta plugin is disabled', async () => {
104+
const artifacts = await buildSchemaArtifacts({
105+
database,
106+
schemas: ['schema_a'],
107+
graphile: { disablePlugins: ['MetaSchemaPlugin'] }
108+
});
109+
110+
expect(artifacts.sdl).toContain('AlphaItem');
111+
expect(artifacts.sdl).not.toContain('_meta');
112+
expect(artifacts.tablesMeta).toEqual([]);
113+
});
114+
});
115+
116+
describe('buildIntrospectionJSON', () => {
117+
it('returns metadata correlated to its own build', async () => {
118+
let releaseA!: () => void;
119+
const gateA = new Promise<void>((resolve) => {
120+
releaseA = resolve;
121+
});
122+
let signalACollected!: () => void;
123+
const aCollected = new Promise<void>((resolve) => {
124+
signalACollected = resolve;
125+
});
126+
127+
const pendingA = buildIntrospectionJSON({
128+
database,
129+
schemas: ['schema_a'],
130+
_onMetaCollected: async () => {
131+
signalACollected();
132+
await gateA;
133+
}
134+
});
135+
136+
await aCollected;
137+
const b = await buildIntrospectionJSON({ database, schemas: ['schema_b'] });
138+
releaseA();
139+
const a = await pendingA;
140+
141+
expect(tableNames(a)).toEqual(['alpha_items']);
142+
expect(tableNames(b)).toEqual(['beta_widgets']);
143+
});
144+
});

graphile/graphile-schema/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
},
4040
"devDependencies": {
4141
"makage": "^0.3.0",
42+
"pgsql-test": "workspace:^",
4243
"ts-node": "^10.9.2"
4344
},
4445
"keywords": [

graphile/graphile-schema/src/build-introspection.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import type { TableMeta } from 'graphile-settings'
2-
import { _cachedTablesMeta } from 'graphile-settings'
3-
import { buildSchemaSDL } from './build-schema'
2+
import { buildSchemaArtifacts } from './build-schema'
43
import type { BuildSchemaOptions } from './build-schema'
54

65
export type { BuildSchemaOptions as BuildIntrospectionOptions }
76

87
/**
98
* Build introspection metadata for all tables visible in the given schemas.
109
*
11-
* Internally calls `buildSchemaSDL()` which triggers the MetaSchemaPlugin
12-
* finalization hook, populating `_cachedTablesMeta` as a side-effect. The cached
13-
* metadata is then returned as a plain array of `TableMeta` objects.
10+
* Internally calls `buildSchemaArtifacts()`, which returns SDL and `_meta`
11+
* metadata from one correlated build boundary — both derived from the same
12+
* final executable `GraphQLSchema` — so concurrent builds in one process
13+
* cannot return each other's metadata.
1414
*
1515
* The result includes every table's fields, types, constraints, indexes,
1616
* relations, inflection names, and query entry-points — the same data
@@ -31,6 +31,5 @@ export type { BuildSchemaOptions as BuildIntrospectionOptions }
3131
export async function buildIntrospectionJSON(
3232
opts: BuildSchemaOptions
3333
): Promise<TableMeta[]> {
34-
await buildSchemaSDL(opts)
35-
return [..._cachedTablesMeta]
34+
return (await buildSchemaArtifacts(opts)).tablesMeta
3635
}

0 commit comments

Comments
 (0)