diff --git a/graphile/graphile-scoped-introspection/README.md b/graphile/graphile-scoped-introspection/README.md new file mode 100644 index 0000000000..2cffd9cc35 --- /dev/null +++ b/graphile/graphile-scoped-introspection/README.md @@ -0,0 +1,31 @@ +# graphile-scoped-introspection + +An opt-in Graphile plugin that scopes PostgreSQL catalog introspection to the +configured service schemas and their required dependency closure. + +```ts +import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection'; + +const preset = { + extends: [ScopedIntrospectionPreset], + pgServices: [ + { + // standard Graphile PgService fields + introspectionMode: 'scoped-required', + introspectionScopedCatalogTypes: 'dependency-closure', + }, + ], +}; +``` + +The package atomically replaces `PgIntrospectionPlugin` only when its preset +is installed. Stock-only services delegate to the upstream helper unchanged; +mixed stock/scoped services select their catalog query independently. + +The scoped SQL is CNC-owned and parameterized. It is adapted from the MIT +licensed `pg-introspection@1.0.1` query and does not patch, import private +subpaths from, or rewrite the installed upstream package. + +Database clients use the normal `@dataplan/pg` checkout lifecycle and return +to the pool after each query. Applications remain responsible for calling +`PgService.release()` during final shutdown. diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts new file mode 100644 index 0000000000..3231130adc --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts @@ -0,0 +1,252 @@ +import { watchGather } from 'graphile-build'; +import type { GraphileConfig } from 'graphile-config'; + +import { ConstructivePgIntrospectionPlugin } from '../src'; + +const SCHEMA = 'tenant_a'; + +const introspectionText = JSON.stringify({ + database: { datdba: '10', datacl: null }, + namespaces: [ + { + _id: '2200', + oid: '2200', + nspname: SCHEMA, + nspowner: '10', + nspacl: null, + }, + ], + classes: [], + attributes: [], + constraints: [], + procs: [], + roles: [ + { + _id: '10', + oid: '10', + rolname: 'postgres', + rolsuper: true, + rolinherit: true, + rolcreaterole: true, + rolcreatedb: true, + rolcanlogin: true, + rolreplication: true, + rolconnlimit: -1, + rolpassword: null, + rolvaliduntil: null, + rolbypassrls: true, + rolconfig: null, + }, + ], + auth_members: [], + types: [], + enums: [], + extensions: [], + indexes: [], + languages: [], + ranges: [], + depends: [], + descriptions: [], + inherits: [], + am: [], + catalog_by_oid: { + 2615: 'pg_namespace', + 1259: 'pg_class', + 1255: 'pg_proc', + 1247: 'pg_type', + 2606: 'pg_constraint', + 3079: 'pg_extension', + }, + current_user: 'postgres', + server_version_num: 180004, +}); +const missingSchemaIntrospectionText = JSON.stringify({ + ...JSON.parse(introspectionText), + namespaces: [], +}); +const unapprovedSchemaIntrospectionText = JSON.stringify({ + ...JSON.parse(introspectionText), + namespaces: [ + ...JSON.parse(introspectionText).namespaces, + { + _id: '2201', + oid: '2201', + nspname: 'unexpected_dependency', + nspowner: '10', + nspacl: null, + }, + ], +}); + +interface GatherResult { + input: Record | null; + error?: Error; +} + +function makeResultQueue() { + const queued: GatherResult[] = []; + const waiters: Array<(result: GatherResult) => void> = []; + + return { + push(result: GatherResult) { + const waiter = waiters.shift(); + if (waiter) waiter(result); + else queued.push(result); + }, + next(): Promise { + const result = queued.shift(); + if (result) return Promise.resolve(result); + return new Promise((resolve) => waiters.push(resolve)); + }, + }; +} + +describe('scoped introspection raw-text lifecycle', () => { + it('releases raw text, re-queries fresh data, and fails closed on regather errors', async () => { + let cache: { introspectionResultsPromise: Promise | null } | null = + null; + let triggerRegather: (() => void) | null = null; + let queryError: Error | null = null; + let nextIntrospectionText = introspectionText; + const seenNamespaceNames: string[] = []; + const query = jest.fn(async () => { + if (queryError) { + const error = queryError; + queryError = null; + throw error; + } + return { rows: [{ introspection: nextIntrospectionText }] }; + }); + const withPgClient = Object.assign( + async ( + _settings: Record | null, + callback: (client: { query: typeof query }) => unknown + ) => callback({ query }), + { release: jest.fn() } + ); + const adaptor = { + createWithPgClient: jest.fn(async () => withPgClient), + }; + + const originalGather = ConstructivePgIntrospectionPlugin.gather!; + const capturingIntrospectionPlugin = { + ...ConstructivePgIntrospectionPlugin, + gather: { + ...originalGather, + initialCache(info: never) { + cache = originalGather.initialCache!(info) as typeof cache; + return cache; + }, + // A deterministic test trigger drives the same persistent gather cache + // without needing a live LISTEN/NOTIFY subscriber. + watch: undefined, + }, + } as unknown as GraphileConfig.Plugin; + const observerPlugin = { + name: 'ScopedIntrospectionCacheObserverPlugin', + gather: { + namespace: 'scopedIntrospectionCacheObserver', + async main(output: Record, info: any) { + const first = info.helpers.pgIntrospection.getIntrospection(); + const second = info.helpers.pgIntrospection.getIntrospection(); + expect(second).toBe(first); + const [firstResults, secondResults] = await Promise.all([ + first, + second, + ]); + expect(secondResults).toBe(firstResults); + const [result] = firstResults; + const namespace = result.introspection.namespaces[0]; + seenNamespaceNames.push(namespace.nspname); + output.namespaceName = namespace.nspname; + // Graphile plugins may mutate their gather-local parsed graph. A later + // gather must never observe this mutation. + namespace.nspname = 'mutated_by_plugin'; + }, + watch(_info: never, callback: () => void) { + triggerRegather = callback; + return (): void => undefined; + }, + }, + } as unknown as GraphileConfig.Plugin; + const pgService = { + name: 'main', + schemas: [SCHEMA], + introspectionMode: 'scoped-required', + introspectionAllowedDependencySchemas: [] as readonly string[], + adaptor, + adaptorSettings: {}, + withPgClientKey: 'withPgClient', + pgSettingsKey: 'pgSettings', + }; + const results = makeResultQueue(); + + const stopWatching = await watchGather( + { + plugins: [capturingIntrospectionPlugin, observerPlugin], + pgServices: [pgService as never], + }, + undefined, + (input, error) => { + results.push({ + input: input as unknown as Record | null, + error: error as Error | undefined, + }); + } + ); + + try { + const first = await results.next(); + expect(first.error).toBeUndefined(); + expect(first.input).toMatchObject({ namespaceName: SCHEMA }); + expect(query).toHaveBeenCalledTimes(1); + expect(cache!.introspectionResultsPromise).toBeNull(); + + triggerRegather!(); + const second = await results.next(); + expect(second.error).toBeUndefined(); + expect(second.input).toMatchObject({ namespaceName: SCHEMA }); + expect(query).toHaveBeenCalledTimes(2); + expect(seenNamespaceNames).toEqual([SCHEMA, SCHEMA]); + expect(cache!.introspectionResultsPromise).toBeNull(); + + nextIntrospectionText = unapprovedSchemaIntrospectionText; + triggerRegather!(); + const invalid = await results.next(); + expect(invalid.input).toBeNull(); + expect(invalid.error?.message).toContain( + 'crossed into unapproved dependency schema(s): unexpected_dependency' + ); + expect(query).toHaveBeenCalledTimes(3); + expect(cache!.introspectionResultsPromise).toBeNull(); + + nextIntrospectionText = missingSchemaIntrospectionText; + triggerRegather!(); + const missing = await results.next(); + expect(missing.input).toBeNull(); + expect(missing.error?.message).toContain( + `did not find required schema(s): ${SCHEMA}` + ); + expect(query).toHaveBeenCalledTimes(4); + expect(cache!.introspectionResultsPromise).toBeNull(); + + nextIntrospectionText = introspectionText; + triggerRegather!(); + const recovered = await results.next(); + expect(recovered.error).toBeUndefined(); + expect(recovered.input).toMatchObject({ namespaceName: SCHEMA }); + expect(query).toHaveBeenCalledTimes(5); + + const marker = new Error('scoped introspection re-query failed'); + queryError = marker; + triggerRegather!(); + const failed = await results.next(); + expect(failed.input).toBeNull(); + expect(failed.error).toBe(marker); + expect(query).toHaveBeenCalledTimes(6); + expect(cache!.introspectionResultsPromise).toBeNull(); + } finally { + stopWatching(); + } + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts new file mode 100644 index 0000000000..f0c99bdd2d --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts @@ -0,0 +1,240 @@ +import '@dataplan/pg/adaptors/pg'; + +import { + defaultPreset as graphileBuildPreset, + gather, + makeSchema, +} from 'graphile-build'; +import { + defaultPreset as graphileBuildPgPreset, + PgIntrospectionPlugin, +} from 'graphile-build-pg'; +import type { GraphileConfig } from 'graphile-config'; +import { execute, lexicographicSortSchema, parse, printSchema } from 'graphql'; + +import { + ConstructivePgIntrospectionPlugin, + ScopedIntrospectionPreset, +} from '../src'; + +const introspectionText = (schema: string): string => + JSON.stringify({ + database: { datdba: '10', datacl: null }, + namespaces: [ + { + _id: schema === 'stock_schema' ? '2200' : '2201', + oid: schema === 'stock_schema' ? '2200' : '2201', + nspname: schema, + nspowner: '10', + nspacl: null, + }, + ], + classes: [], + attributes: [], + constraints: [], + procs: [], + roles: [ + { + _id: '10', + oid: '10', + rolname: 'postgres', + rolsuper: true, + rolinherit: true, + rolcreaterole: true, + rolcreatedb: true, + rolcanlogin: true, + rolreplication: true, + rolconnlimit: -1, + rolpassword: null, + rolvaliduntil: null, + rolbypassrls: true, + rolconfig: null, + }, + ], + auth_members: [], + types: [], + enums: [], + extensions: [], + indexes: [], + languages: [], + ranges: [], + depends: [], + descriptions: [], + inherits: [], + am: [], + catalog_by_oid: { + 2615: 'pg_namespace', + 1259: 'pg_class', + 1255: 'pg_proc', + 1247: 'pg_type', + 2606: 'pg_constraint', + 3079: 'pg_extension', + }, + current_user: 'postgres', + server_version_num: 180004, + }); + +const makeService = ( + name: string, + schema: string, + mode: 'stock' | 'scoped-required', + queries: Array<{ text: string; values?: unknown[] }> +): never => { + const query = jest.fn(async (input: { text: string; values?: unknown[] }) => { + queries.push(input); + return { rows: [{ introspection: introspectionText(schema) }] }; + }); + const withPgClient = Object.assign( + async ( + _settings: Record | null, + callback: (client: { query: typeof query }) => unknown + ) => callback({ query }), + { release: jest.fn() } + ); + return { + name, + schemas: [schema], + introspectionMode: mode, + introspectionAllowedDependencySchemas: [], + adaptor: { + createWithPgClient: jest.fn(async () => withPgClient), + }, + adaptorSettings: {}, + withPgClientKey: `${name}WithPgClient`, + pgSettingsKey: `${name}PgSettings`, + } as never; +}; + +describe('mixed stock/scoped introspection services', () => { + it('selects each service query independently and announces each once', async () => { + const queries: Array<{ text: string; values?: unknown[] }> = []; + const observer = { + name: 'MixedIntrospectionObserverPlugin', + gather: { + namespace: 'mixedIntrospectionObserver', + async main(output: Record, info: any) { + const first = info.helpers.pgIntrospection.getIntrospection(); + const second = info.helpers.pgIntrospection.getIntrospection(); + expect(second).toBe(first); + const [results, sharedResults] = await Promise.all([first, second]); + expect(sharedResults).toBe(results); + output.services = results.map((result: any) => ({ + name: result.pgService.name, + namespaces: result.introspection.namespaces.map( + (namespace: any) => namespace.nspname + ), + })); + }, + }, + } as unknown as GraphileConfig.Plugin; + + const output = await gather({ + plugins: [ConstructivePgIntrospectionPlugin, observer], + pgServices: [ + makeService('stock', 'stock_schema', 'stock', queries), + makeService('scoped', 'scoped_schema', 'scoped-required', queries), + ], + }); + + expect(output).toMatchObject({ + services: [ + { name: 'stock', namespaces: ['stock_schema'] }, + { name: 'scoped', namespaces: ['scoped_schema'] }, + ], + }); + expect(queries).toHaveLength(2); + const stock = queries.find( + (query) => !query.text.includes('requested_schema_names') + ); + const scoped = queries.find((query) => + query.text.includes('requested_schema_names') + ); + expect(stock).toBeDefined(); + expect(stock?.values).toBeUndefined(); + expect(scoped?.values).toEqual([['scoped_schema'], []]); + }); + + it('keeps replacement stock gather, schema, and runtime equivalent to upstream', async () => { + const makeObserver = (name: string) => + ({ + name, + gather: { + namespace: `${name}Namespace`, + async main(output: Record, info: any) { + const [result] = + await info.helpers.pgIntrospection.getIntrospection(); + output.entityCounts = Object.fromEntries( + [ + 'namespaces', + 'classes', + 'attributes', + 'constraints', + 'procs', + 'roles', + 'types', + 'ranges', + ].map((key) => [key, result.introspection[key].length]) + ); + }, + }, + }) as unknown as GraphileConfig.Plugin; + const upstreamQueries: Array<{ text: string; values?: unknown[] }> = []; + const replacementQueries: Array<{ text: string; values?: unknown[] }> = []; + const upstreamPreset = { + extends: [graphileBuildPreset, graphileBuildPgPreset], + plugins: [makeObserver('UpstreamStockObserverPlugin')], + pgServices: [ + makeService('main', 'stock_schema', 'stock', upstreamQueries), + ], + }; + const replacementPreset = { + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + plugins: [makeObserver('ReplacementStockObserverPlugin')], + pgServices: [ + makeService('main', 'stock_schema', 'stock', replacementQueries), + ], + }; + + const [upstreamGather, replacementGather] = await Promise.all([ + gather(upstreamPreset), + gather(replacementPreset), + ]); + expect((replacementGather as any).entityCounts).toEqual( + (upstreamGather as any).entityCounts + ); + expect(upstreamQueries).toHaveLength(1); + expect(replacementQueries).toHaveLength(1); + expect(replacementQueries[0].text).toBe(upstreamQueries[0].text); + + const [upstream, replacement] = await Promise.all([ + makeSchema({ + extends: [graphileBuildPreset, graphileBuildPgPreset], + pgServices: [ + makeService('main', 'stock_schema', 'stock', upstreamQueries), + ], + }), + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + pgServices: [ + makeService('main', 'stock_schema', 'stock', replacementQueries), + ], + }), + ]); + expect(printSchema(lexicographicSortSchema(replacement.schema))).toEqual( + printSchema(lexicographicSortSchema(upstream.schema)) + ); + const query = parse('{ __typename }'); + expect( + await execute({ schema: replacement.schema, document: query }) + ).toEqual(await execute({ schema: upstream.schema, document: query })); + expect(PgIntrospectionPlugin.name).toBe('PgIntrospectionPlugin'); + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-plugin.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-plugin.test.ts new file mode 100644 index 0000000000..dc971a8424 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-plugin.test.ts @@ -0,0 +1,144 @@ +import { defaultPreset as graphileBuildPreset } from 'graphile-build'; +import { + defaultPreset as graphileBuildPgPreset, + PgIntrospectionPlugin, +} from 'graphile-build-pg'; +import { resolvePreset } from 'graphile-config'; + +import { + ConstructivePgIntrospectionPlugin, + ScopedIntrospectionPreset, + scopedIntrospectionUpstreamContract, +} from '../src'; + +describe('CNC introspection replacement contract', () => { + it('atomically replaces the upstream namespace owner exactly once', () => { + const stock = resolvePreset({ + extends: [graphileBuildPreset, graphileBuildPgPreset], + }); + const scoped = resolvePreset({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + }); + + expect( + stock.plugins.filter((plugin) => plugin.name === 'PgIntrospectionPlugin') + ).toEqual([PgIntrospectionPlugin]); + expect( + scoped.plugins.filter( + (plugin) => + plugin.name === 'PgIntrospectionPlugin' || + plugin.name === 'ConstructivePgIntrospectionPlugin' + ) + ).toEqual([ConstructivePgIntrospectionPlugin]); + expect(scoped.disablePlugins).toContain('PgIntrospectionPlugin'); + expect(ConstructivePgIntrospectionPlugin.provides).toContain( + 'PgIntrospectionPlugin' + ); + expect(ConstructivePgIntrospectionPlugin.before).toContain( + 'PgRegistryPlugin' + ); + }); + + it('creates new plugin, gather, and helper objects without mutating upstream', () => { + expect(ConstructivePgIntrospectionPlugin).not.toBe(PgIntrospectionPlugin); + expect(ConstructivePgIntrospectionPlugin.gather).not.toBe( + PgIntrospectionPlugin.gather + ); + expect(ConstructivePgIntrospectionPlugin.gather!.helpers).not.toBe( + PgIntrospectionPlugin.gather!.helpers + ); + expect(PgIntrospectionPlugin.name).toBe('PgIntrospectionPlugin'); + expect(PgIntrospectionPlugin.provides).toBeUndefined(); + }); + + it('reuses every upstream lifecycle seam and all unchanged helpers', () => { + const upstream = PgIntrospectionPlugin.gather!; + const replacement = ConstructivePgIntrospectionPlugin.gather!; + const replacementHelpers = replacement.helpers as Record; + + expect(replacement.initialCache).toBe(upstream.initialCache); + expect(replacement.initialState).toBe(upstream.initialState); + expect(replacement.watch).toBe(upstream.watch); + expect(replacement.hooks).toBe(upstream.hooks); + for (const [name, helper] of Object.entries(upstream.helpers!)) { + if (name === 'getIntrospection' || name === 'getRangeByType') continue; + expect(replacementHelpers[name]).toBe(helper); + } + }); + + it('looks up scoped ranges directly from the parsed range collection', async () => { + const helpers = ConstructivePgIntrospectionPlugin.gather!.helpers as Record< + string, + unknown + >; + const getRangeByType = helpers.getRangeByType as ( + info: unknown, + serviceName: string, + typeId: string + ) => Promise; + const range = { rngtypid: '100', rngmultitypid: '101' }; + const info = { + helpers: { + pgIntrospection: { + getIntrospection: () => [ + { + pgService: { name: 'main' }, + introspection: { ranges: [range] }, + }, + ], + }, + }, + }; + + await expect(getRangeByType(info, 'main', '101')).resolves.toBe(range); + }); + + it('detects upstream contract drift at the pinned version', () => { + expect(scopedIntrospectionUpstreamContract).toEqual({ + package: 'graphile-build-pg', + version: '5.1.3', + pluginName: 'PgIntrospectionPlugin', + namespace: 'pgIntrospection', + hasInitialCache: true, + hasInitialState: true, + hasWatch: true, + helperNames: [ + 'getAttribute', + 'getAttributesForClass', + 'getClass', + 'getClassByName', + 'getClasses', + 'getConstraint', + 'getConstraintsForClass', + 'getEnum', + 'getEnumsForType', + 'getExecutorForService', + 'getExtension', + 'getExtensionByName', + 'getForeignConstraintsForClass', + 'getIndex', + 'getInheritanceChildrenForClass', + 'getInheritedForClass', + 'getIntrospection', + 'getLanguage', + 'getNamespace', + 'getNamespaceByName', + 'getProc', + 'getRangeByType', + 'getRoles', + 'getService', + 'getType', + 'getTypeByArray', + 'getTypeByName', + ], + hookNames: [ + 'pgRegistry_PgRegistryBuilder_init', + 'pgRegistry_PgRegistryBuilder_pgExecutors', + ], + }); + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts new file mode 100644 index 0000000000..29ebb04de2 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts @@ -0,0 +1,73 @@ +import { makeSchemaScopedIntrospectionQuery } from '../src/scoped-introspection-query'; + +describe('CNC-owned scoped introspection SQL', () => { + it('keeps schema and capability input in parameters', () => { + const schema = "tenant_a'); drop schema public; --"; + const capability = "pg_trgm'); select pg_sleep(10); --"; + const query = makeSchemaScopedIntrospectionQuery( + [schema, 'tenant_a', schema], + { capabilityExtensions: [capability, 'pg_trgm', capability] } + ); + + expect(query.text).toContain('pg_catalog.unnest($1::text[])'); + expect(query.text).toContain('pg_catalog.unnest($2::text[])'); + expect(query.text).not.toContain(schema); + expect(query.text).not.toContain(capability); + expect(query.values).toEqual([ + [schema, 'tenant_a'], + [capability, 'pg_trgm'], + ]); + }); + + it('rejects empty, system, NUL, and malformed capability inputs', () => { + expect(() => makeSchemaScopedIntrospectionQuery([])).toThrow( + 'requires at least one schema' + ); + expect(() => makeSchemaScopedIntrospectionQuery(['pg_catalog'])).toThrow( + "cannot expose system schema 'pg_catalog'" + ); + expect(() => + makeSchemaScopedIntrospectionQuery(['information_schema']) + ).toThrow("cannot expose system schema 'information_schema'"); + expect(() => makeSchemaScopedIntrospectionQuery(['tenant\0a'])).toThrow( + 'must not contain NUL bytes' + ); + expect(() => + makeSchemaScopedIntrospectionQuery(['tenant_a'], { + capabilityExtensions: [' pg_trgm'], + }) + ).toThrow('must contain exact non-empty extension names'); + }); + + it('keeps recursive dependency closure and both catalog type policies', () => { + const all = makeSchemaScopedIntrospectionQuery(['tenant_a']); + const closure = makeSchemaScopedIntrospectionQuery(['tenant_a'], { + catalogTypes: 'dependency-closure', + }); + + for (const query of [all, closure]) { + expect(query.text).toContain('with\nrecursive'); + expect(query.text).toContain( + 'object_closure(object_class, object_id) as' + ); + expect(query.text).toContain('retained_index_support_objects'); + expect(query.text).toContain('installed_extensions'); + expect(query.text).toContain('select pg_language.oid as _id'); + expect(query.text).toContain('select pg_am.oid as _id'); + } + expect(all.text).toContain( + "or pg_type.typnamespace = 'pg_catalog'::regnamespace" + ); + expect(closure.text).not.toContain( + "or pg_type.typnamespace = 'pg_catalog'::regnamespace" + ); + }); + + it('rejects unknown options at the runtime boundary', () => { + expect(() => + makeSchemaScopedIntrospectionQuery(['tenant_a'], { + unexpected: true, + } as never) + ).toThrow('Unsupported schema-scoped introspection option(s): unexpected'); + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts new file mode 100644 index 0000000000..264533b736 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts @@ -0,0 +1,160 @@ +import { + defaultPreset as graphileBuildPreset, + makeSchema, +} from 'graphile-build'; +import { defaultPreset as graphileBuildPgPreset } from 'graphile-build-pg'; + +import { ScopedIntrospectionPreset } from '../src'; + +const { makePgService: makePostGraphilePgService } = + require('postgraphile/adaptors/pg') as { + makePgService(options: Record): Record; + }; + +describe('schema-scoped introspection runtime integration', () => { + it.each([ + ['all catalog types by default', undefined, true], + ['dependency-closure catalog types', 'dependency-closure', false], + ] as const)( + 'executes the parameterized scoped query with %s', + async (_label, scopedCatalogTypes, retainsAllCatalogTypes) => { + const marker = new Error('captured introspection query'); + let captured: { text: string; values?: unknown[] } | null = null; + const client = { + query: jest.fn( + async (query: string | { text: string; values?: unknown[] }) => { + if (typeof query === 'string') return { rows: [] as unknown[] }; + captured = query; + throw marker; + } + ), + release: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + }; + const pool = { + connect: jest.fn().mockResolvedValue(client), + }; + + await expect( + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + pgServices: [ + Object.assign( + makePostGraphilePgService({ + pool: pool as never, + schemas: ['tenant_a'], + }), + { + introspectionMode: 'scoped-required', + introspectionCapabilityExtensions: ['pg_trgm'], + ...(scopedCatalogTypes === undefined + ? {} + : { introspectionScopedCatalogTypes: scopedCatalogTypes }), + } + ) as never, + ], + }) + ).rejects.toBe(marker); + + expect(captured).not.toBeNull(); + expect(captured!.text).toContain('requested_schema_names'); + expect(captured!.text).not.toBe('select introspection'); + expect(captured!.values).toEqual([['tenant_a'], ['pg_trgm']]); + expect( + captured!.text.includes( + "or pg_type.typnamespace = 'pg_catalog'::regnamespace" + ) + ).toBe(retainsAllCatalogTypes); + expect(client.release).toHaveBeenCalledTimes(1); + } + ); + + it('fails closed when a retained entity references a missing type', async () => { + const introspection = JSON.stringify({ + database: {}, + namespaces: [ + { + _id: '100', + nspname: 'tenant_a', + nspowner: '10', + nspacl: null, + }, + ], + classes: [ + { + _id: '200', + relname: 'broken_items', + relnamespace: '100', + reltype: '999', + reloftype: null, + }, + ], + attributes: [], + constraints: [], + procs: [], + roles: [], + auth_members: [], + types: [], + enums: [], + extensions: [], + indexes: [], + inherits: [], + languages: [], + policies: [], + ranges: [], + depends: [], + descriptions: [], + am: [], + catalog_by_oid: { + 1255: 'pg_proc', + 1247: 'pg_type', + 1259: 'pg_class', + 2606: 'pg_constraint', + 2615: 'pg_namespace', + 3079: 'pg_extension', + }, + current_user: 'runtime_role', + pg_version: 'PostgreSQL test fixture', + introspection_version: 1, + }); + const client = { + query: jest.fn().mockResolvedValue({ rows: [{ introspection }] }), + release: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + }; + const pool = { + connect: jest.fn().mockResolvedValue(client), + }; + + await expect( + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + pgServices: [ + Object.assign( + makePostGraphilePgService({ + pool: pool as never, + schemas: ['tenant_a'], + }), + { + introspectionMode: 'scoped-required', + introspectionScopedCatalogTypes: 'dependency-closure', + } + ) as never, + ], + }) + ).rejects.toThrow( + /service '.+' retained pg_class 'broken_items \(200\)' field 'reltype' referencing missing pg_type OID '999'/ + ); + expect(client.release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts new file mode 100644 index 0000000000..09111a9794 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts @@ -0,0 +1,66 @@ +import '@dataplan/pg/adaptors/pg'; + +import { gather } from 'graphile-build'; +import type { GraphileConfig } from 'graphile-config'; + +import { ConstructivePgIntrospectionPlugin } from '../src'; + +const makeService = (overrides: Record = {}): never => + ({ + name: 'main', + schemas: ['tenant_a'], + introspectionMode: 'scoped-required', + introspectionAllowedDependencySchemas: [], + adaptor: { + createWithPgClient: jest.fn(() => { + throw new Error('query should not be reached'); + }), + }, + adaptorSettings: {}, + withPgClientKey: 'withPgClient', + pgSettingsKey: 'pgSettings', + ...overrides, + }) as never; + +describe('scoped introspection service identity contract', () => { + const consumerPlugin = { + name: 'ScopedIntrospectionIdentityConsumerPlugin', + gather: { + namespace: 'scopedIntrospectionIdentityConsumer', + async main(_output: Record, info: any) { + await info.helpers.pgIntrospection.getIntrospection(); + }, + }, + } as unknown as GraphileConfig.Plugin; + + it.each([ + [ + 'name', + makeService(), + makeService({ + withPgClientKey: 'secondWithPgClient', + pgSettingsKey: 'secondPgSettings', + }), + 'same name', + ], + [ + 'withPgClientKey', + makeService(), + makeService({ name: 'second', pgSettingsKey: 'secondPgSettings' }), + 'same withPgClientKey', + ], + [ + 'pgSettingsKey', + makeService(), + makeService({ name: 'second', withPgClientKey: 'secondWithPgClient' }), + 'same pgSettingsKey', + ], + ])('rejects duplicate %s values', async (_field, first, second, message) => { + await expect( + gather({ + plugins: [ConstructivePgIntrospectionPlugin, consumerPlugin], + pgServices: [first, second], + }) + ).rejects.toThrow(message); + }); +}); diff --git a/graphile/graphile-scoped-introspection/jest.config.js b/graphile/graphile-scoped-introspection/jest.config.js new file mode 100644 index 0000000000..bcc983c7cd --- /dev/null +++ b/graphile/graphile-scoped-introspection/jest.config.js @@ -0,0 +1,19 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testTimeout: 60000, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'], +}; diff --git a/graphile/graphile-scoped-introspection/package.json b/graphile/graphile-scoped-introspection/package.json new file mode 100644 index 0000000000..b63f8461df --- /dev/null +++ b/graphile/graphile-scoped-introspection/package.json @@ -0,0 +1,55 @@ +{ + "name": "graphile-scoped-introspection", + "version": "0.1.0", + "description": "Opt-in schema-scoped PostgreSQL introspection for Graphile", + "author": "Constructive ", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest", + "test:watch": "jest --watch" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "dependencies": { + "@constructive-io/graphql-types": "workspace:^" + }, + "devDependencies": { + "@types/node": "^22.19.11", + "graphql": "16.13.0", + "makage": "^0.3.0", + "postgraphile": "5.1.4" + }, + "peerDependencies": { + "@dataplan/pg": "^1.1.1", + "graphile-build": "^5.1.1", + "graphile-build-pg": "^5.1.3", + "graphile-config": "^1.1.0", + "pg-introspection": "^1.0.1" + }, + "keywords": [ + "postgraphile", + "graphile", + "constructive", + "plugin", + "postgres", + "introspection" + ] +} diff --git a/graphile/graphile-scoped-introspection/src/index.ts b/graphile/graphile-scoped-introspection/src/index.ts new file mode 100644 index 0000000000..c7c6b7eaa2 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/index.ts @@ -0,0 +1,13 @@ +export type { + GraphileIntrospectionMode, + ScopedIntrospectionServiceOptions, +} from './plugin'; +export { + ConstructivePgIntrospectionPlugin, + ScopedIntrospectionPreset, + scopedIntrospectionUpstreamContract, +} from './plugin'; +export type { + SchemaScopedIntrospectionOptions, + ScopedCatalogTypes, +} from './scoped-introspection-query'; diff --git a/graphile/graphile-scoped-introspection/src/plugin.ts b/graphile/graphile-scoped-introspection/src/plugin.ts new file mode 100644 index 0000000000..6d7cd0784b --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/plugin.ts @@ -0,0 +1,483 @@ +import 'graphile-build'; + +import type { ScopedIntrospectionServiceOptions } from '@constructive-io/graphql-types'; +import { withPgClientFromPgService } from '@dataplan/pg'; +import { + PgIntrospectionPlugin, + version as graphileBuildPgVersion, +} from 'graphile-build-pg'; +import type { GraphileConfig } from 'graphile-config'; +import type { Introspection } from 'pg-introspection'; +import { + makeIntrospectionQuery, + parseIntrospectionResults, +} from 'pg-introspection'; + +import { + makeSchemaScopedIntrospectionQuery, + type ScopedCatalogTypes, +} from './scoped-introspection-query'; + +export type { + GraphileIntrospectionMode, + ScopedIntrospectionServiceOptions, +} from '@constructive-io/graphql-types'; + +declare global { + namespace GraphileConfig { + interface PgServiceConfiguration + extends ScopedIntrospectionServiceOptions {} + } +} + +type GatherInfo = { + cache: { + introspectionResultsPromise: Promise | null; + dirty: boolean; + }; + state: { + getIntrospectionPromise: + Promise | IntrospectionResult[] | null; + }; + resolvedPreset: GraphileConfig.ResolvedPreset; + process(eventName: string, event: Record): Promise; +}; +type IntrospectionResult = { + pgService: GraphileConfig.PgServiceConfiguration; + introspection: Introspection; +}; +type RawIntrospection = { + pgService: GraphileConfig.PgServiceConfiguration; + introspectionText: string; + requiredSchemas: readonly string[] | null; + allowedSchemas: readonly string[] | null; + scopedCatalogTypes: ScopedCatalogTypes | null; +}; +type PgQuery = { text: string; values?: unknown[] }; + +const upstreamGather = PgIntrospectionPlugin.gather; +const upstreamHelpers = upstreamGather?.helpers as + Record | undefined; +const upstreamGetIntrospection = upstreamHelpers?.getIntrospection as + ((info: never) => unknown) | undefined; +const SUPPORTED_GRAPHILE_BUILD_PG_VERSION = '5.1.3'; + +if (graphileBuildPgVersion !== SUPPORTED_GRAPHILE_BUILD_PG_VERSION) { + throw new Error( + `Unsupported graphile-build-pg introspection contract: expected ${SUPPORTED_GRAPHILE_BUILD_PG_VERSION}, received ${graphileBuildPgVersion}` + ); +} + +if (!upstreamGather || !upstreamHelpers || !upstreamGetIntrospection) { + throw new Error( + 'graphile-build-pg PgIntrospectionPlugin no longer exposes the expected gather contract' + ); +} + +function isScopedService( + pgService: GraphileConfig.PgServiceConfiguration +): boolean { + return pgService.introspectionMode === 'scoped-required'; +} + +function getIntrospectionQuery( + pgService: GraphileConfig.PgServiceConfiguration +): Omit & { + query: PgQuery; +} { + const mode = pgService.introspectionMode ?? 'stock'; + const configuredCatalogTypes = pgService.introspectionScopedCatalogTypes; + const configuredCapabilityExtensions = + pgService.introspectionCapabilityExtensions; + const scopedCatalogTypes = configuredCatalogTypes ?? 'all'; + + if ( + scopedCatalogTypes !== 'all' && + scopedCatalogTypes !== 'dependency-closure' + ) { + throw new Error( + `Unsupported scoped catalog type policy '${scopedCatalogTypes}' for service '${pgService.name}'` + ); + } + if (mode === 'stock') { + if (configuredCatalogTypes !== undefined) { + throw new Error( + `Scoped catalog type policy is only valid with scoped-required introspection for service '${pgService.name}'` + ); + } + if (configuredCapabilityExtensions !== undefined) { + throw new Error( + `Scoped extension capabilities are only valid with scoped-required introspection for service '${pgService.name}'` + ); + } + return { + query: { text: makeIntrospectionQuery() }, + requiredSchemas: null, + allowedSchemas: null, + scopedCatalogTypes: null, + }; + } + if (mode === 'scoped-required') { + const requiredSchemas = pgService.schemas ?? []; + const dependencySchemas = + pgService.introspectionAllowedDependencySchemas ?? []; + return { + query: makeSchemaScopedIntrospectionQuery(requiredSchemas, { + catalogTypes: scopedCatalogTypes, + capabilityExtensions: configuredCapabilityExtensions ?? [], + }), + requiredSchemas, + allowedSchemas: [ + ...new Set([...requiredSchemas, ...dependencySchemas, 'pg_catalog']), + ], + scopedCatalogTypes, + }; + } + throw new Error( + `Unsupported PostgreSQL introspection mode '${mode}' for service '${pgService.name}'` + ); +} + +function assertScopedNamespaces( + introspection: Introspection, + requiredSchemas: readonly string[] | null, + allowedSchemas: readonly string[] | null, + serviceName: string +): void { + if (requiredSchemas === null || allowedSchemas === null) return; + + const found = new Set( + introspection.namespaces.map((namespace) => namespace.nspname) + ); + const missing = requiredSchemas.filter((schema) => !found.has(schema)); + if (missing.length > 0) { + throw new Error( + `Schema-scoped introspection for service '${serviceName}' did not find required schema(s): ${missing.join(', ')}` + ); + } + const allowed = new Set(allowedSchemas); + const unexpected = [...found].filter((schema) => !allowed.has(schema)); + if (unexpected.length > 0) { + throw new Error( + `Schema-scoped introspection for service '${serviceName}' crossed into unapproved dependency schema(s): ${unexpected.join(', ')}` + ); + } +} + +function assertDependencyClosureTypes( + introspection: Introspection, + scopedCatalogTypes: ScopedCatalogTypes | null, + serviceName: string +): void { + if (scopedCatalogTypes !== 'dependency-closure') return; + + const retainedTypeOids = new Set( + introspection.types.map((type) => String(type._id)) + ); + const requireType = ( + oid: unknown, + objectKind: string, + objectContext: string, + field: string + ): void => { + if (oid === null || oid === undefined || String(oid) === '0') return; + const normalizedOid = String(oid); + // pg-introspection removes extension-owned composite resources from its + // public arrays after building lookups. Validate the runtime lookup too. + const introspectionLookups = ( + introspection as Introspection & { + _lookups?: { typeById?: Map }; + } + )._lookups; + const resolves = + retainedTypeOids.has(normalizedOid) || + introspectionLookups?.typeById?.has(normalizedOid) === true; + if (!resolves) { + throw new Error( + `Dependency-closure introspection for service '${serviceName}' retained ${objectKind} '${objectContext}' field '${field}' referencing missing pg_type OID '${normalizedOid}'` + ); + } + }; + const requireTypes = ( + oids: readonly unknown[] | null | undefined, + objectKind: string, + objectContext: string, + field: string + ): void => { + for (const oid of oids ?? []) { + requireType(oid, objectKind, objectContext, field); + } + }; + + for (const entity of introspection.classes) { + const context = `${entity.relname} (${entity._id})`; + requireType(entity.reltype, 'pg_class', context, 'reltype'); + requireType(entity.reloftype, 'pg_class', context, 'reloftype'); + } + for (const entity of introspection.attributes) { + requireType( + entity.atttypid, + 'pg_attribute', + `${entity.attrelid}.${entity.attname}`, + 'atttypid' + ); + } + for (const entity of introspection.constraints) { + requireType( + entity.contypid, + 'pg_constraint', + `${entity.conname} (${entity._id})`, + 'contypid' + ); + } + for (const entity of introspection.procs) { + const context = `${entity.proname} (${entity._id})`; + requireType(entity.prorettype, 'pg_proc', context, 'prorettype'); + requireTypes(entity.proargtypes, 'pg_proc', context, 'proargtypes'); + requireTypes(entity.proallargtypes, 'pg_proc', context, 'proallargtypes'); + } + for (const entity of introspection.types) { + const context = `${entity.typname} (${entity._id})`; + requireType(entity.typbasetype, 'pg_type', context, 'typbasetype'); + requireType(entity.typelem, 'pg_type', context, 'typelem'); + requireType(entity.typarray, 'pg_type', context, 'typarray'); + } + for (const entity of introspection.enums) { + requireType( + entity.enumtypid, + 'pg_enum', + `${entity.enumlabel} (${entity._id})`, + 'enumtypid' + ); + } + for (const entity of introspection.ranges) { + const context = `range ${entity.rngtypid ?? 'unknown'}`; + requireType(entity.rngtypid, 'pg_range', context, 'rngtypid'); + requireType(entity.rngsubtype, 'pg_range', context, 'rngsubtype'); + requireType(entity.rngmultitypid, 'pg_range', context, 'rngmultitypid'); + } +} + +// Adapted from graphile-build-pg@5.1.3 +// dist/plugins/PgIntrospectionPlugin.js. The upstream function is private, so +// mixed/scoped services must retain this service validation/query seam locally. +async function introspectPgServices( + pgServices: readonly GraphileConfig.PgServiceConfiguration[] | undefined +): Promise { + if (!pgServices) return []; + + const seenNames = new Map(); + const seenPgSettingsKeys = new Map(); + const seenWithPgClientKeys = new Map(); + + return Promise.all( + pgServices.map(async (pgService, i) => { + const { name, pgSettingsKey, withPgClientKey } = pgService; + if (!name) throw new Error(`pgServices[${i}] has no name`); + if (!withPgClientKey) { + throw new Error(`pgServices[${i}] has no withPgClientKey`); + } + const duplicateName = seenNames.get(name); + if (duplicateName !== undefined) { + throw new Error( + `pgServices[${i}] has the same name as pgServices[${duplicateName}] (${JSON.stringify(name)})` + ); + } + seenNames.set(name, i); + const duplicateClientKey = seenWithPgClientKeys.get(withPgClientKey); + if (duplicateClientKey !== undefined) { + throw new Error( + `pgServices[${i}] has the same withPgClientKey as pgServices[${duplicateClientKey}] (${JSON.stringify(withPgClientKey)})` + ); + } + seenWithPgClientKeys.set(withPgClientKey, i); + if (pgSettingsKey) { + const duplicateSettingsKey = seenPgSettingsKeys.get(pgSettingsKey); + if (duplicateSettingsKey !== undefined) { + throw new Error( + `pgServices[${i}] has the same pgSettingsKey as pgServices[${duplicateSettingsKey}] (${JSON.stringify(pgSettingsKey)})` + ); + } + seenPgSettingsKeys.set(pgSettingsKey, i); + } + + const { query, requiredSchemas, allowedSchemas, scopedCatalogTypes } = + getIntrospectionQuery(pgService); + const result = await withPgClientFromPgService( + pgService, + pgService.pgSettingsForIntrospection ?? null, + (client) => client.query<{ introspection: string }>(query) + ); + const [row] = result.rows; + if (!row) throw new Error('Introspection failed'); + return { + pgService, + introspectionText: row.introspection, + requiredSchemas, + allowedSchemas, + scopedCatalogTypes, + }; + }) + ); +} + +async function announceIntrospection( + info: GatherInfo, + introspections: IntrospectionResult[] +): Promise { + await Promise.all( + introspections.map(async ({ introspection, pgService }) => { + const announce = async ( + eventName: string, + entities: readonly unknown[] + ): Promise => { + await Promise.all( + entities.map((entity) => + info.process(eventName, { entity, serviceName: pgService.name }) + ) + ); + }; + + await info.process('pgIntrospection_introspection', { + introspection, + serviceName: pgService.name, + }); + await announce('pgIntrospection_namespace', introspection.namespaces); + await announce('pgIntrospection_class', introspection.classes); + await announce('pgIntrospection_attribute', introspection.attributes); + await announce('pgIntrospection_constraint', introspection.constraints); + await announce('pgIntrospection_proc', introspection.procs); + await announce('pgIntrospection_role', introspection.roles); + await announce('pgIntrospection_auth_member', introspection.auth_members); + await announce('pgIntrospection_type', introspection.types); + await announce('pgIntrospection_enum', introspection.enums); + await announce('pgIntrospection_extension', introspection.extensions); + await announce('pgIntrospection_index', introspection.indexes); + await announce('pgIntrospection_language', introspection.languages); + await announce('pgIntrospection_range', introspection.ranges); + await announce('pgIntrospection_depend', introspection.depends); + await announce('pgIntrospection_description', introspection.descriptions); + }) + ); +} + +// Adapted from graphile-build-pg@5.1.3 +// dist/plugins/PgIntrospectionPlugin.js. Upstream does not expose its +// cache/parse/announcement flow independently from the stock query. +function getConstructiveIntrospection( + info: GatherInfo +): Promise | IntrospectionResult[] { + const pgServices: readonly GraphileConfig.PgServiceConfiguration[] = + info.resolvedPreset.pgServices ?? []; + if (!pgServices.some(isScopedService)) { + return upstreamGetIntrospection(info as never) as + Promise | IntrospectionResult[]; + } + + return ( + info.state.getIntrospectionPromise ?? + (info.state.getIntrospectionPromise = (async () => { + if (info.cache.dirty) { + info.cache.introspectionResultsPromise = null; + info.cache.dirty = false; + } + const introspectionPromise = + info.cache.introspectionResultsPromise ?? + (info.cache.introspectionResultsPromise = + introspectPgServices(pgServices)); + introspectionPromise.then(null, () => { + info.cache.introspectionResultsPromise = null; + }); + + const rawIntrospections = await introspectionPromise; + if (info.cache.introspectionResultsPromise === introspectionPromise) { + info.cache.introspectionResultsPromise = null; + } + const introspections = rawIntrospections.map( + ({ + pgService, + introspectionText, + requiredSchemas, + allowedSchemas, + scopedCatalogTypes, + }) => { + const introspection = parseIntrospectionResults(introspectionText); + assertScopedNamespaces( + introspection, + requiredSchemas, + allowedSchemas, + pgService.name + ); + assertDependencyClosureTypes( + introspection, + scopedCatalogTypes, + pgService.name + ); + return { pgService, introspection }; + } + ); + + // Announcements may call back into getIntrospection, so expose the + // resolved gather-local value before broadcasting entities. + info.state.getIntrospectionPromise = introspections; + await announceIntrospection(info, introspections); + return introspections; + })()) + ); +} + +async function getRangeByType( + info: GatherInfo & { + helpers: GraphileConfig.GatherHelpers; + }, + serviceName: string, + typeId: string +) { + const introspections = await info.helpers.pgIntrospection.getIntrospection(); + const relevant = introspections.find( + (result) => result.pgService.name === serviceName + ); + if (!relevant) throw new Error(`Could not find database '${serviceName}'`); + return relevant.introspection.ranges.find( + (range) => range.rngtypid === typeId || range.rngmultitypid === typeId + ); +} + +/** + * CNC-owned atomic replacement for graphile-build-pg's introspection plugin. + * Stock-only configurations delegate to the upstream helper unchanged. + */ +export const ConstructivePgIntrospectionPlugin: GraphileConfig.Plugin = { + name: 'ConstructivePgIntrospectionPlugin', + description: + 'Adds opt-in schema-scoped PostgreSQL introspection for Constructive', + version: PgIntrospectionPlugin.version, + provides: ['PgIntrospectionPlugin'], + before: ['PgRegistryPlugin'], + gather: { + ...upstreamGather, + helpers: { + ...upstreamHelpers, + getIntrospection: getConstructiveIntrospection, + getRangeByType, + }, + } as never, +}; + +/** Disable upstream atomically before installing the CNC replacement. */ +export const ScopedIntrospectionPreset: GraphileConfig.Preset = { + disablePlugins: ['PgIntrospectionPlugin'], + plugins: [ConstructivePgIntrospectionPlugin], +}; + +export const scopedIntrospectionUpstreamContract = Object.freeze({ + package: 'graphile-build-pg', + version: SUPPORTED_GRAPHILE_BUILD_PG_VERSION, + pluginName: PgIntrospectionPlugin.name, + namespace: upstreamGather.namespace, + hasInitialCache: typeof upstreamGather.initialCache === 'function', + hasInitialState: typeof upstreamGather.initialState === 'function', + hasWatch: typeof upstreamGather.watch === 'function', + helperNames: Object.keys(upstreamHelpers).sort(), + hookNames: Object.keys(upstreamGather.hooks ?? {}).sort(), +}); diff --git a/graphile/graphile-scoped-introspection/src/scoped-introspection-query.ts b/graphile/graphile-scoped-introspection/src/scoped-introspection-query.ts new file mode 100644 index 0000000000..90dcb5a9e6 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/scoped-introspection-query.ts @@ -0,0 +1,695 @@ +/** + * Scoped catalog SQL adapted from pg-introspection@1.0.1 + * src/introspection.ts (MIT) and the previous CNC patch implementation. + * This is a static, CNC-owned query generator: it does not inspect or rewrite + * the upstream query at runtime. + */ + +import type { ScopedCatalogTypes } from '@constructive-io/graphql-types'; + +export type { ScopedCatalogTypes } from '@constructive-io/graphql-types'; + +export interface SchemaScopedIntrospectionOptions { + catalogTypes?: ScopedCatalogTypes; + capabilityExtensions?: readonly string[]; +} + +export interface SchemaScopedIntrospectionQuery { + text: string; + values: [string[], string[]]; +} + +interface IntrospectionQueryScope { + ctes: string; + namespacePredicate: string; + classPredicate: string; + constraintPredicate: string; + procPredicate: string; + typePredicate: string; + extensionPredicate: string; + languagePredicate: string; + accessMethodPredicate: string; + rolePredicate?: string; + authMemberPredicate?: string; +} + +const SCOPED_CTES = `recursive + requested_schema_names(schema_name) as ( + select distinct requested.schema_name + from pg_catalog.unnest($1::text[]) as requested(schema_name) + ), + + capability_extension_names(extension_name) as ( + select distinct capability.extension_name + from pg_catalog.unnest($2::text[]) as capability(extension_name) + ), + + requested_namespaces as ( + select pg_namespace.oid as _id, pg_namespace.nspname + from pg_catalog.pg_namespace + inner join requested_schema_names + on requested_schema_names.schema_name = pg_namespace.nspname + ), + + root_objects(object_class, object_id) as ( + select 'pg_catalog.pg_class'::regclass::oid, pg_class.oid + from pg_catalog.pg_class + where pg_class.relnamespace in (select requested_namespaces._id from requested_namespaces) + + union + + select 'pg_catalog.pg_constraint'::regclass::oid, pg_constraint.oid + from pg_catalog.pg_constraint + where pg_constraint.connamespace in (select requested_namespaces._id from requested_namespaces) + + union + + select 'pg_catalog.pg_proc'::regclass::oid, pg_proc.oid + from pg_catalog.pg_proc + where pg_proc.pronamespace in (select requested_namespaces._id from requested_namespaces) + and pg_proc.prorettype operator(pg_catalog.<>) 2279 + + union + + select 'pg_catalog.pg_type'::regclass::oid, pg_type.oid + from pg_catalog.pg_type + where pg_type.typnamespace in (select requested_namespaces._id from requested_namespaces) + ), + + object_closure(object_class, object_id) as ( + select root_objects.object_class, root_objects.object_id + from root_objects + + union + + select dependency.object_class, dependency.object_id + from object_closure + cross join lateral ( + select + 'pg_catalog.pg_type'::regclass::oid as object_class, + pg_class.reltype as object_id + from pg_catalog.pg_class + where object_closure.object_class = 'pg_catalog.pg_class'::regclass + and pg_class.oid = object_closure.object_id + + union all + + select 'pg_catalog.pg_type'::regclass::oid, pg_class.reloftype + from pg_catalog.pg_class + where object_closure.object_class = 'pg_catalog.pg_class'::regclass + and pg_class.oid = object_closure.object_id + + union all + + select 'pg_catalog.pg_type'::regclass::oid, pg_attribute.atttypid + from pg_catalog.pg_attribute + where object_closure.object_class = 'pg_catalog.pg_class'::regclass + and pg_attribute.attrelid = object_closure.object_id + + union all + + select 'pg_catalog.pg_constraint'::regclass::oid, pg_constraint.oid + from pg_catalog.pg_constraint + where object_closure.object_class = 'pg_catalog.pg_class'::regclass + and pg_constraint.conrelid = object_closure.object_id + + union all + + select 'pg_catalog.pg_class'::regclass::oid, pg_index.indexrelid + from pg_catalog.pg_index + where object_closure.object_class = 'pg_catalog.pg_class'::regclass + and pg_index.indrelid = object_closure.object_id + + union all + + select 'pg_catalog.pg_class'::regclass::oid, pg_inherits.inhparent + from pg_catalog.pg_inherits + where object_closure.object_class = 'pg_catalog.pg_class'::regclass + and pg_inherits.inhrelid = object_closure.object_id + + union all + + select 'pg_catalog.pg_class'::regclass::oid, constraint_class.oid + from pg_catalog.pg_constraint + cross join lateral pg_catalog.unnest( + array[ + pg_constraint.conrelid, + pg_constraint.confrelid, + pg_constraint.conindid + ]::oid[] + ) as constraint_class(oid) + where object_closure.object_class = 'pg_catalog.pg_constraint'::regclass + and pg_constraint.oid = object_closure.object_id + + union all + + select 'pg_catalog.pg_type'::regclass::oid, pg_constraint.contypid + from pg_catalog.pg_constraint + where object_closure.object_class = 'pg_catalog.pg_constraint'::regclass + and pg_constraint.oid = object_closure.object_id + + union all + + select 'pg_catalog.pg_constraint'::regclass::oid, pg_constraint.conparentid + from pg_catalog.pg_constraint + where object_closure.object_class = 'pg_catalog.pg_constraint'::regclass + and pg_constraint.oid = object_closure.object_id + + union all + + select 'pg_catalog.pg_type'::regclass::oid, procedure_type.oid + from pg_catalog.pg_proc + cross join lateral pg_catalog.unnest( + coalesce(pg_proc.proallargtypes, pg_proc.proargtypes::oid[]) + || array[pg_proc.prorettype]::oid[] + ) as procedure_type(oid) + where object_closure.object_class = 'pg_catalog.pg_proc'::regclass + and pg_proc.oid = object_closure.object_id + + union all + + select 'pg_catalog.pg_type'::regclass::oid, dependency_type.oid + from pg_catalog.pg_type + cross join lateral pg_catalog.unnest( + array[ + pg_type.typbasetype, + pg_type.typelem, + pg_type.typarray + ]::oid[] + ) as dependency_type(oid) + where object_closure.object_class = 'pg_catalog.pg_type'::regclass + and pg_type.oid = object_closure.object_id + + union all + + select 'pg_catalog.pg_class'::regclass::oid, pg_type.typrelid + from pg_catalog.pg_type + where object_closure.object_class = 'pg_catalog.pg_type'::regclass + and pg_type.oid = object_closure.object_id + + union all + + select 'pg_catalog.pg_constraint'::regclass::oid, pg_constraint.oid + from pg_catalog.pg_constraint + where object_closure.object_class = 'pg_catalog.pg_type'::regclass + and pg_constraint.contypid = object_closure.object_id + + union all + + select 'pg_catalog.pg_type'::regclass::oid, range_type.oid + from pg_catalog.pg_range + cross join lateral pg_catalog.unnest( + array[ + pg_range.rngtypid, + pg_range.rngsubtype, + pg_range.rngmultitypid + ]::oid[] + ) as range_type(oid) + where object_closure.object_class = 'pg_catalog.pg_type'::regclass + and object_closure.object_id in (pg_range.rngtypid, pg_range.rngmultitypid) + ) as dependency + where dependency.object_id operator(pg_catalog.<>) 0 + ), + + retained_index_metadata(indexrelid, indclass, indcollation) as ( + select pg_index.indexrelid, pg_index.indclass, pg_index.indcollation + from object_closure + inner join pg_catalog.pg_class retained_index + on object_closure.object_class = 'pg_catalog.pg_class'::regclass + and retained_index.oid = object_closure.object_id + and retained_index.relkind in ('i', 'I') + inner join pg_catalog.pg_index + on pg_index.indexrelid = retained_index.oid + ), + + retained_index_opclasses(_id, opcfamily) as ( + select pg_opclass.oid, pg_opclass.opcfamily + from retained_index_metadata + cross join lateral pg_catalog.unnest( + retained_index_metadata.indclass::oid[] + ) as index_opclass(_id) + inner join pg_catalog.pg_opclass + on pg_opclass.oid = index_opclass._id + ), + + retained_index_support_objects(object_class, object_id) as ( + select 'pg_catalog.pg_opclass'::regclass::oid, retained_index_opclasses._id + from retained_index_opclasses + + union + + select 'pg_catalog.pg_opfamily'::regclass::oid, retained_index_opclasses.opcfamily + from retained_index_opclasses + + union + + select 'pg_catalog.pg_operator'::regclass::oid, pg_amop.amopopr + from retained_index_opclasses + inner join pg_catalog.pg_amop + on pg_amop.amopfamily = retained_index_opclasses.opcfamily + + union + + select 'pg_catalog.pg_proc'::regclass::oid, pg_amproc.amproc + from retained_index_opclasses + inner join pg_catalog.pg_amproc + on pg_amproc.amprocfamily = retained_index_opclasses.opcfamily + + union + + select 'pg_catalog.pg_collation'::regclass::oid, index_collation._id + from retained_index_metadata + cross join lateral pg_catalog.unnest( + retained_index_metadata.indcollation::oid[] + ) as index_collation(_id) + where index_collation._id operator(pg_catalog.<>) 0 + ), + + installed_extensions(_id, extnamespace) as ( + select pg_extension.oid, pg_extension.extnamespace + from pg_catalog.pg_extension + where pg_extension.extname in ( + select capability_extension_names.extension_name + from capability_extension_names + ) + or exists ( + select 1 + from object_closure + inner join pg_catalog.pg_depend + on pg_depend.classid = object_closure.object_class + and pg_depend.objid = object_closure.object_id + and pg_depend.refclassid = 'pg_catalog.pg_extension'::regclass + and pg_depend.refobjid = pg_extension.oid + and pg_depend.deptype = 'e' + ) + or exists ( + select 1 + from retained_index_support_objects + inner join pg_catalog.pg_depend + on pg_depend.classid = retained_index_support_objects.object_class + and pg_depend.objid = retained_index_support_objects.object_id + and pg_depend.refclassid = 'pg_catalog.pg_extension'::regclass + and pg_depend.refobjid = pg_extension.oid + and pg_depend.deptype = 'e' + ) + or exists ( + select 1 + from object_closure + inner join pg_catalog.pg_class retained_index + on object_closure.object_class = 'pg_catalog.pg_class'::regclass + and retained_index.oid = object_closure.object_id + and retained_index.relkind = 'i' + inner join pg_catalog.pg_depend + on pg_depend.classid = 'pg_catalog.pg_am'::regclass + and pg_depend.objid = retained_index.relam + and pg_depend.refclassid = 'pg_catalog.pg_extension'::regclass + and pg_depend.refobjid = pg_extension.oid + and pg_depend.deptype = 'e' + ) + ), + + scoped_namespaces(_id) as ( + select requested_namespaces._id + from requested_namespaces + + union + + select pg_class.relnamespace + from object_closure + inner join pg_catalog.pg_class + on object_closure.object_class = 'pg_catalog.pg_class'::regclass + and pg_class.oid = object_closure.object_id + + union + + select pg_constraint.connamespace + from object_closure + inner join pg_catalog.pg_constraint + on object_closure.object_class = 'pg_catalog.pg_constraint'::regclass + and pg_constraint.oid = object_closure.object_id + + union + + select pg_proc.pronamespace + from object_closure + inner join pg_catalog.pg_proc + on object_closure.object_class = 'pg_catalog.pg_proc'::regclass + and pg_proc.oid = object_closure.object_id + + union + + select pg_type.typnamespace + from object_closure + inner join pg_catalog.pg_type + on object_closure.object_class = 'pg_catalog.pg_type'::regclass + and pg_type.oid = object_closure.object_id + + union + + select installed_extensions.extnamespace + from installed_extensions + where installed_extensions.extnamespace operator(pg_catalog.<>) 0 + + union + + select pg_namespace.oid + from pg_catalog.pg_namespace + where pg_namespace.nspname = 'pg_catalog' + ), + +`; +// We might want this to take options in future, so we've made it a function. +/** + * Builds a PostgreSQL introspection SQL query to return an object with the same shape as `Introspection` above. + */ +const buildIntrospectionQuery = (scope: IntrospectionQueryScope): string => `\ +with +${scope?.ctes ?? ''}\ + database as ( + select pg_database.oid as _id, * + from pg_catalog.pg_database + where datname = current_database() + ), + + namespaces as ( + select pg_namespace.oid as _id, * + from pg_catalog.pg_namespace + where ${scope.namespacePredicate} + ), + + classes as ( + select pg_class.oid as _id, *, + pg_catalog.pg_relation_is_updatable(oid, true)::bit(8)::int4 as "updatable_mask" + from pg_catalog.pg_class + where ${scope.classPredicate} + ), + + attributes as ( + select * + from pg_catalog.pg_attribute + where attrelid in (select classes._id from classes) AND attnum > 0 + ), + + constraints as ( + select pg_constraint.oid as _id, * + from pg_catalog.pg_constraint + where ${scope.constraintPredicate} + ), + + procs as ( + select pg_proc.oid as _id, * + from pg_catalog.pg_proc + where ${scope.procPredicate} + and prorettype operator(pg_catalog.<>) 2279 + ), + + roles as ( + select pg_roles.oid as _id, * + from pg_catalog.pg_roles +${ + scope?.rolePredicate + ? ` where ${scope.rolePredicate} +` + : '' +}\ + ), + + auth_members as ( + select * + from pg_catalog.pg_auth_members + where ${scope.authMemberPredicate ?? 'roleid in (select roles._id from roles)'} + ), + + types as ( + select pg_type.oid as _id, * + from pg_catalog.pg_type + where ${scope.typePredicate} + ), + + enums as ( + select pg_enum.oid as _id, * + from pg_catalog.pg_enum + where enumtypid in (select types._id from types) + ), + + extensions as ( + select pg_extension.oid as _id, * + from pg_catalog.pg_extension +${ + scope?.extensionPredicate + ? ` where ${scope.extensionPredicate} +` + : '' +}\ + ), + + indexes as ( + select * + from pg_catalog.pg_index + where indrelid in (select classes._id from classes) + ), + + inherits as ( + select * + from pg_catalog.pg_inherits + where inhrelid in (select classes._id from classes) + ), + + languages as ( + select pg_language.oid as _id, * + from pg_catalog.pg_language +${ + scope?.languagePredicate + ? ` where ${scope.languagePredicate} +` + : '' +}\ + ), + + policies as ( + select * + from pg_catalog.pg_policy + where polrelid in (select classes._id from classes) + ), + + ranges as ( + select * + from pg_catalog.pg_range + where rngtypid in (select types._id from types) + ), + + depends as ( + select * + from pg_catalog.pg_depend + where deptype IN ('a', 'e') and ( + (classid = 'pg_catalog.pg_namespace'::regclass and objid in (select namespaces._id from namespaces)) + or (classid = 'pg_catalog.pg_class'::regclass and objid in (select classes._id from classes)) + or (classid = 'pg_catalog.pg_attribute'::regclass and objid in (select classes._id from classes) and objsubid > 0) + or (classid = 'pg_catalog.pg_constraint'::regclass and objid in (select constraints._id from constraints)) + or (classid = 'pg_catalog.pg_proc'::regclass and objid in (select procs._id from procs)) + or (classid = 'pg_catalog.pg_type'::regclass and objid in (select types._id from types)) + or (classid = 'pg_catalog.pg_enum'::regclass and objid in (select enums._id from enums)) + or (classid = 'pg_catalog.pg_extension'::regclass and objid in (select extensions._id from extensions)) + ) + ), + + descriptions as ( + select * + from pg_catalog.pg_description + where ( + (classoid = 'pg_catalog.pg_namespace'::regclass and objoid in (select namespaces._id from namespaces)) + or (classoid = 'pg_catalog.pg_class'::regclass and objoid in (select classes._id from classes)) + or (classoid = 'pg_catalog.pg_attribute'::regclass and objoid in (select classes._id from classes) and objsubid > 0) + or (classoid = 'pg_catalog.pg_constraint'::regclass and objoid in (select constraints._id from constraints)) + or (classoid = 'pg_catalog.pg_proc'::regclass and objoid in (select procs._id from procs)) + or (classoid = 'pg_catalog.pg_type'::regclass and objoid in (select types._id from types)) + or (classoid = 'pg_catalog.pg_enum'::regclass and objoid in (select enums._id from enums)) + or (classoid = 'pg_catalog.pg_extension'::regclass and objoid in (select extensions._id from extensions)) + ) + ), + + am as ( + select pg_am.oid as _id, * + from pg_catalog.pg_am + where ${scope.accessMethodPredicate} + ) +select json_build_object( + 'database', + (select row_to_json(database) from database), + + 'namespaces', + (select coalesce((select json_agg(row_to_json(namespaces) order by nspname) from namespaces), '[]'::json)), + + 'classes', + (select coalesce((select json_agg(row_to_json(classes) order by relnamespace, relname) from classes), '[]'::json)), + + 'attributes', + (select coalesce((select json_agg(row_to_json(attributes) order by attrelid, attnum) from attributes), '[]'::json)), + + 'constraints', + (select coalesce((select json_agg(row_to_json(constraints) order by connamespace, conrelid, conname) from constraints), '[]'::json)), + + 'procs', + (select coalesce((select json_agg(row_to_json(procs) order by pronamespace, proname, pg_get_function_identity_arguments(procs._id)) from procs), '[]'::json)), + + 'roles', + (select coalesce((select json_agg(row_to_json(roles) order by rolname) from roles), '[]'::json)), + + 'auth_members', + (select coalesce((select json_agg(row_to_json(auth_members) order by roleid, member, grantor) from auth_members), '[]'::json)), + + 'types', + (select coalesce((select json_agg(row_to_json(types) order by typnamespace, typname) from types), '[]'::json)), + + 'enums', + (select coalesce((select json_agg(row_to_json(enums) order by enumtypid, enumsortorder) from enums), '[]'::json)), + + 'extensions', + (select coalesce((select json_agg(row_to_json(extensions) order by extname) from extensions), '[]'::json)), + + 'indexes', + (select coalesce((select json_agg(row_to_json(indexes) order by indrelid, indexrelid) from indexes), '[]'::json)), + + 'inherits', + (select coalesce((select json_agg(row_to_json(inherits) order by inhrelid, inhseqno) from inherits), '[]'::json)), + + 'languages', + (select coalesce((select json_agg(row_to_json(languages) order by lanname) from languages), '[]'::json)), + + 'policies', + (select coalesce((select json_agg(row_to_json(policies) order by polrelid, polname) from policies), '[]'::json)), + + 'ranges', + (select coalesce((select json_agg(row_to_json(ranges) order by rngtypid) from ranges), '[]'::json)), + + 'depends', + (select coalesce((select json_agg(row_to_json(depends) order by classid, objid, objsubid, refclassid, refobjid, refobjsubid) from depends), '[]'::json)), + + 'descriptions', + (select coalesce((select json_agg(row_to_json(descriptions) order by objoid, classoid, objsubid) from descriptions), '[]'::json)), + + 'am', + (select coalesce((select json_agg(row_to_json(am) order by amname) from am), '[]'::json)), + + 'catalog_by_oid', + ( + select json_object_agg(oid::text, relname order by relname asc) + from pg_class + where relnamespace = ( + select oid + from pg_namespace + where nspname = 'pg_catalog' + ) + and relkind = 'r' + ), + + 'current_user', + current_user, + 'pg_version', + version(), + 'introspection_version', + 1 +)::text as introspection +`; +/** + * Builds a parameterized introspection query scoped to the requested schemas + * and the transitive object dependencies required by their objects. + */ +export const makeSchemaScopedIntrospectionQuery = ( + schemas: readonly string[], + options: SchemaScopedIntrospectionOptions = {} +): SchemaScopedIntrospectionQuery => { + if (!Array.isArray(schemas) || schemas.length === 0) { + throw new Error('Schema-scoped introspection requires at least one schema'); + } + if ( + options === null || + typeof options !== 'object' || + Array.isArray(options) + ) { + throw new Error('Schema-scoped introspection options must be an object'); + } + const unsupportedOptions = Object.keys(options).filter( + (key) => key !== 'catalogTypes' && key !== 'capabilityExtensions' + ); + if (unsupportedOptions.length > 0) { + throw new Error( + `Unsupported schema-scoped introspection option(s): ${unsupportedOptions.join(', ')}` + ); + } + const catalogTypes = options.catalogTypes ?? 'all'; + if (catalogTypes !== 'all' && catalogTypes !== 'dependency-closure') { + throw new Error( + `Unsupported schema-scoped catalog type policy '${catalogTypes}'` + ); + } + const capabilityExtensions = options.capabilityExtensions ?? []; + if (!Array.isArray(capabilityExtensions)) { + throw new Error( + 'Schema-scoped introspection capabilityExtensions must be an array' + ); + } + const normalizedCapabilityExtensions = Array.from( + new Set( + capabilityExtensions.map((extension) => { + if ( + typeof extension !== 'string' || + extension.length === 0 || + extension.trim() !== extension || + extension.includes('\0') + ) { + throw new Error( + 'Schema-scoped introspection capabilityExtensions must contain exact non-empty extension names' + ); + } + return extension; + }) + ) + ); + const normalized = Array.from( + new Set( + schemas.map((schema) => { + if (typeof schema !== 'string' || schema.length === 0) { + throw new Error( + 'Schema-scoped introspection schemas must be non-empty strings' + ); + } + if (schema.includes('\0')) { + throw new Error( + 'Schema-scoped introspection schemas must not contain NUL bytes' + ); + } + if (schema === 'information_schema' || schema.startsWith('pg_')) { + throw new Error( + `Schema-scoped introspection cannot expose system schema '${schema}'` + ); + } + return schema; + }) + ) + ); + const dependencyClosureTypePredicate = + "pg_type.oid = any (array(select object_id from object_closure where object_class = 'pg_catalog.pg_type'::regclass))"; + return { + text: buildIntrospectionQuery({ + ctes: SCOPED_CTES, + namespacePredicate: + 'pg_namespace.oid = any (array(select scoped_namespaces._id from scoped_namespaces))', + classPredicate: + "pg_class.oid = any (array(select object_id from object_closure where object_class = 'pg_catalog.pg_class'::regclass))", + constraintPredicate: + "pg_constraint.oid = any (array(select object_id from object_closure where object_class = 'pg_catalog.pg_constraint'::regclass))", + procPredicate: + "pg_proc.oid = any (array(select object_id from object_closure where object_class = 'pg_catalog.pg_proc'::regclass))", + typePredicate: + catalogTypes === 'all' + ? `${dependencyClosureTypePredicate} or pg_type.typnamespace = 'pg_catalog'::regnamespace` + : dependencyClosureTypePredicate, + extensionPredicate: + 'pg_extension.oid = any (array(select installed_extensions._id from installed_extensions))', + languagePredicate: 'true', + accessMethodPredicate: 'true', + }), + values: [normalized, normalizedCapabilityExtensions], + }; +}; diff --git a/graphile/graphile-scoped-introspection/tsconfig.esm.json b/graphile/graphile-scoped-introspection/tsconfig.esm.json new file mode 100644 index 0000000000..f624f96708 --- /dev/null +++ b/graphile/graphile-scoped-introspection/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "ESNext" + } +} diff --git a/graphile/graphile-scoped-introspection/tsconfig.json b/graphile/graphile-scoped-introspection/tsconfig.json new file mode 100644 index 0000000000..63ca6be40b --- /dev/null +++ b/graphile/graphile-scoped-introspection/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/graphile/graphile-settings/README.md b/graphile/graphile-settings/README.md index 84a4156058..683d93e9e7 100644 --- a/graphile/graphile-settings/README.md +++ b/graphile/graphile-settings/README.md @@ -183,6 +183,34 @@ const { schema } = await makeSchema(preset); const sdl = printSchema(schema); ``` +## Opt-in Scoped Introspection + +`ConstructivePreset` and `makePgService` retain PostGraphile's upstream +introspection behavior. Applications that explicitly opt into CNC scoped +introspection should install the independently owned preset and use the scoped +service factory together: + +```typescript +import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection'; +import { ConstructivePreset, makeScopedPgService } from 'graphile-settings'; + +const preset = { + extends: [ConstructivePreset, ScopedIntrospectionPreset], + pgServices: [ + makeScopedPgService({ + connectionString: 'postgres://user:pass@localhost/mydb', + schemas: ['app_public'], + introspectionAllowedDependencySchemas: ['shared'], + introspectionCapabilityExtensions: ['pg_trgm'], + }), + ], +}; +``` + +The Constructive GraphQL server performs this pairing when +`GRAPHILE_INTROSPECTION_MODE=scoped-required`; when unset or set to `stock`, it +does not load the scoped package. + ## Smart Tags Reference Control schema generation with PostgreSQL comments: diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts new file mode 100644 index 0000000000..6f7c62a031 --- /dev/null +++ b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts @@ -0,0 +1,96 @@ +import type { ScopedIntrospectionServiceOptions } from '@constructive-io/graphql-types'; +import { PgIntrospectionPlugin } from 'graphile-build-pg'; +import { resolvePreset } from 'graphile-config'; + +import { ConstructivePreset } from '../src/presets/constructive-preset'; +import { makeConfiguredPgService } from '../src/scoped-introspection-service'; + +type TestUpstreamOptions = { + pubsub?: boolean; + schemas?: string[]; + pgSettingsForIntrospection?: + Record | null | undefined; +}; + +const makeUpstreamPgService = jest.fn((options: TestUpstreamOptions) => ({ + ...options, + upstream: true, +})); +const makeScopedPgService = ( + options: TestUpstreamOptions & + Omit +) => makeConfiguredPgService(makeUpstreamPgService, options); + +describe('scoped introspection settings wiring', () => { + beforeEach(() => { + makeUpstreamPgService.mockClear(); + }); + + it('normalizes scoped service configuration without forwarding CNC fields upstream', () => { + const service = makeScopedPgService({ + pubsub: false, + schemas: ['tenant_a'], + introspectionScopedCatalogTypes: 'dependency-closure', + introspectionAllowedDependencySchemas: ['shared', 'shared'], + introspectionCapabilityExtensions: ['pg_trgm', 'pg_trgm'], + pgSettingsForIntrospection: { statement_timeout: '30s' }, + }); + + expect(service).toMatchObject({ + schemas: ['tenant_a'], + introspectionMode: 'scoped-required', + introspectionScopedCatalogTypes: 'dependency-closure', + introspectionAllowedDependencySchemas: ['shared'], + introspectionCapabilityExtensions: ['pg_trgm'], + pgSettingsForIntrospection: { + statement_timeout: '30s', + jit: 'off', + work_mem: '512kB', + }, + }); + expect(makeUpstreamPgService).toHaveBeenCalledWith({ + pubsub: false, + schemas: ['tenant_a'], + pgSettingsForIntrospection: { + statement_timeout: '30s', + jit: 'off', + work_mem: '512kB', + }, + }); + }); + + it('fails deterministically on invalid scoped configuration', () => { + expect(() => + makeScopedPgService({ + pubsub: false, + introspectionScopedCatalogTypes: 'unsupported' as never, + }) + ).toThrow("Unsupported scoped catalog type policy 'unsupported'"); + expect(() => + makeScopedPgService({ + pubsub: false, + introspectionCapabilityExtensions: [' pg_trgm'], + }) + ).toThrow( + 'introspectionCapabilityExtensions must contain exact non-empty extension names' + ); + expect(() => + makeScopedPgService({ + pubsub: false, + introspectionAllowedDependencySchemas: ['pg_catalog'], + }) + ).toThrow('must not be a system schema'); + }); + + it('keeps ConstructivePreset on the upstream introspection plugin', () => { + const constructive = resolvePreset(ConstructivePreset); + + expect(constructive.plugins).toContain(PgIntrospectionPlugin); + expect(constructive.plugins.map((plugin) => plugin.name)).not.toContain( + 'ConstructivePgIntrospectionPlugin' + ); + expect(constructive.disablePlugins ?? []).not.toContain( + 'PgIntrospectionPlugin' + ); + }); +}); diff --git a/graphile/graphile-settings/package.json b/graphile/graphile-settings/package.json index 409a7e679b..2b6400f3cb 100644 --- a/graphile/graphile-settings/package.json +++ b/graphile/graphile-settings/package.json @@ -70,6 +70,7 @@ "mime-bytes": "workspace:^", "pg": "^8.21.0", "pg-query-context": "workspace:^", + "pg-introspection": "1.0.1", "pg-sql2": "5.0.1", "postgraphile": "5.1.4", "request-ip": "^3.3.0", diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index afa9154a82..758fef9e1a 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -37,6 +37,11 @@ import 'graphile-build'; import { makePgService } from 'postgraphile/adaptors/pg'; +import { + makeConfiguredPgService, + type ScopedIntrospectionOptions +} from './scoped-introspection-service'; + // ============================================================================ // Re-export all plugins and presets // ============================================================================ @@ -55,9 +60,21 @@ export * from './presets/index'; // Utilities // ============================================================================ -// Re-export makePgService for convenience +export type ScopedPgServiceOptions = Parameters[0] & + ScopedIntrospectionOptions; + +// Keep the default service factory as the untouched upstream implementation. export { makePgService }; +/** Construct a PG service configured for CNC's opt-in scoped introspection. */ +export const makeScopedPgService = (options: ScopedPgServiceOptions) => + makeConfiguredPgService(makePgService, options); + +export { + normalizeIntrospectionDependencySchemas, + resolveIntrospectionSettings +} from './introspection-settings'; + // Presigned URL utilities export { getPresignedUrlS3Config } from './presigned-url-resolver'; diff --git a/graphile/graphile-settings/src/introspection-settings.ts b/graphile/graphile-settings/src/introspection-settings.ts new file mode 100644 index 0000000000..a5fc9eda9a --- /dev/null +++ b/graphile/graphile-settings/src/introspection-settings.ts @@ -0,0 +1,49 @@ +import type { GraphileIntrospectionMode } from '@constructive-io/graphql-types'; + +export type { GraphileIntrospectionMode } from '@constructive-io/graphql-types'; + +export const DEFAULT_INTROSPECTION_STATEMENT_TIMEOUT = '120s'; + +export const normalizeIntrospectionDependencySchemas = ( + schemas: readonly string[] | null | undefined +): string[] => [ + ...new Set( + (schemas ?? []).map((schema) => { + if (typeof schema !== 'string' || schema.trim().length === 0) { + throw new Error( + 'Introspection dependency schemas must be non-empty strings' + ); + } + const normalized = schema.trim(); + if (normalized === 'information_schema' || normalized.startsWith('pg_')) { + throw new Error( + `Introspection dependency schema '${normalized}' must not be a system schema` + ); + } + if (normalized.includes('\0')) { + throw new Error( + 'Introspection dependency schemas must not contain NUL bytes' + ); + } + return normalized; + }) + ), +]; + +export const resolveIntrospectionSettings = ( + mode: GraphileIntrospectionMode, + settings: Record | null | undefined +): Record => { + const boundedSettings = { ...settings }; + if (!boundedSettings.statement_timeout) { + boundedSettings.statement_timeout = DEFAULT_INTROSPECTION_STATEMENT_TIMEOUT; + } + if (mode === 'scoped-required') { + return { + ...boundedSettings, + jit: 'off', + work_mem: '512kB', + }; + } + return boundedSettings; +}; diff --git a/graphile/graphile-settings/src/scoped-introspection-service.ts b/graphile/graphile-settings/src/scoped-introspection-service.ts new file mode 100644 index 0000000000..20cbf695d4 --- /dev/null +++ b/graphile/graphile-settings/src/scoped-introspection-service.ts @@ -0,0 +1,92 @@ +import type { ScopedIntrospectionServiceOptions } from '@constructive-io/graphql-types'; + +import { + normalizeIntrospectionDependencySchemas, + resolveIntrospectionSettings, +} from './introspection-settings'; + +const normalizeIntrospectionCapabilityExtensions = ( + extensions: readonly string[] | undefined +): readonly string[] => { + if (extensions === undefined) return []; + if (!Array.isArray(extensions)) { + throw new Error('introspectionCapabilityExtensions must be an array'); + } + return [ + ...new Set( + extensions.map((extension) => { + if ( + typeof extension !== 'string' || + extension.length === 0 || + extension.trim() !== extension || + extension.includes('\0') + ) { + throw new Error( + 'introspectionCapabilityExtensions must contain exact non-empty extension names' + ); + } + return extension; + }) + ), + ]; +}; + +type UpstreamPgServiceOptions = { + pgSettingsForIntrospection?: + Record | null | undefined; +}; + +export type ScopedIntrospectionOptions = Omit< + ScopedIntrospectionServiceOptions, + 'introspectionMode' +>; + +/** + * Apply CNC's scoped-introspection settings around an upstream PgService + * factory. The injected binding keeps configuration behavior independently + * testable without duplicating or mocking the Graphile adaptor. + */ +export function makeConfiguredPgService< + TOptions extends UpstreamPgServiceOptions, + TService extends object, +>( + makeUpstreamPgService: (options: TOptions) => TService, + options: TOptions & ScopedIntrospectionOptions +) { + const { + introspectionScopedCatalogTypes, + introspectionAllowedDependencySchemas: configuredDependencySchemas, + introspectionCapabilityExtensions: configuredCapabilityExtensions, + ...upstreamOptions + } = options; + const introspectionCapabilityExtensions = + normalizeIntrospectionCapabilityExtensions(configuredCapabilityExtensions); + + if ( + introspectionScopedCatalogTypes !== undefined && + introspectionScopedCatalogTypes !== 'all' && + introspectionScopedCatalogTypes !== 'dependency-closure' + ) { + throw new Error( + `Unsupported scoped catalog type policy '${introspectionScopedCatalogTypes}'` + ); + } + const introspectionAllowedDependencySchemas = + normalizeIntrospectionDependencySchemas(configuredDependencySchemas); + const pgSettingsForIntrospection = resolveIntrospectionSettings( + 'scoped-required', + options.pgSettingsForIntrospection + ); + const service = makeUpstreamPgService({ + ...upstreamOptions, + pgSettingsForIntrospection, + } as TOptions); + + return Object.assign(service, { + introspectionMode: 'scoped-required' as const, + introspectionScopedCatalogTypes: + introspectionScopedCatalogTypes ?? 'dependency-closure', + introspectionAllowedDependencySchemas, + introspectionCapabilityExtensions, + }); +} diff --git a/graphql/env/README.md b/graphql/env/README.md index e5084a59d8..0ecdf458d8 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -43,6 +43,8 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag ### GraphQL Schema - `GRAPHILE_SCHEMA` - Comma-separated list of PostgreSQL schemas to expose +- `GRAPHILE_INTROSPECTION_MODE` - `stock` (default) or `scoped-required`; + invalid values fail during option resolution ### Feature Flags - `FEATURES_SIMPLE_INFLECTION` - Enable simple inflection plugin @@ -63,7 +65,12 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`: ```typescript { - graphile: { schema: [] }, + graphile: { + schema: [], + introspectionMode: 'stock', + introspectionDependencySchemas: [], + introspectionCapabilityExtensions: [] + }, features: { simpleInflection: true, oppositeBaseNames: true, diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 6383de2044..89998b6a15 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -70,6 +70,9 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and }, "graphile": { "extends": [], + "introspectionCapabilityExtensions": [], + "introspectionDependencySchemas": [], + "introspectionMode": "stock", "preset": {}, "schema": [ "override_schema", diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index fa7dd645e8..f856dba16d 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -138,6 +138,66 @@ describe('getEnvOptions', () => { expect(result.api?.metaSchemas).toEqual(['env_meta', 'override_meta']); }); + it('defaults to untouched stock introspection', () => { + const result = getEnvOptions({}, process.cwd(), {}); + + expect(result.graphile?.introspectionMode).toBe('stock'); + }); + + it('accepts stock and scoped-required introspection environment modes', () => { + expect( + getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: 'stock' }).graphile + ?.introspectionMode + ).toBe('stock'); + expect( + getGraphQLEnvVars({ + GRAPHILE_INTROSPECTION_MODE: 'scoped-required' + }).graphile?.introspectionMode + ).toBe('scoped-required'); + }); + + it('rejects malformed explicit introspection modes', () => { + expect(() => + getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: 'scpoed' }) + ).toThrow(/GRAPHILE_INTROSPECTION_MODE/); + expect(() => + getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: '' }) + ).toThrow(/GRAPHILE_INTROSPECTION_MODE/); + }); + + it('honors config, env, and runtime priority for introspection mode', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-introspection-') + ); + writeConfig(tempDir, { + graphile: { introspectionMode: 'stock' } + }); + + expect( + getEnvOptions({}, tempDir, { + GRAPHILE_INTROSPECTION_MODE: 'scoped-required' + }).graphile?.introspectionMode + ).toBe('scoped-required'); + expect( + getEnvOptions({ graphile: { introspectionMode: 'stock' } }, tempDir, { + GRAPHILE_INTROSPECTION_MODE: 'scoped-required' + }).graphile?.introspectionMode + ).toBe('stock'); + }); + + it('rejects malformed config-file introspection modes', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-invalid-introspection-') + ); + writeConfig(tempDir, { + graphile: { introspectionMode: 'scpoed' } + }); + + expect(() => getEnvOptions({}, tempDir, {})).toThrow( + /Unsupported Graphile introspection mode/ + ); + }); + it('parses SMS environment variables into typed options', () => { const result = getGraphQLEnvVars({ SMS_PROVIDER: 'devsms', diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 014924ef24..2d531232a5 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,5 +1,20 @@ -import { ConstructiveOptions } from '@constructive-io/graphql-types'; -import { parseEnvBoolean, parseEnvNumber } from '12factor-env'; +import { ConstructiveOptions, type GraphileIntrospectionMode, graphileIntrospectionModes } from '@constructive-io/graphql-types'; +import { env as validateEnv, parseEnvBoolean, parseEnvNumber, str } from '12factor-env'; + +const parseGraphileIntrospectionMode = ( + value: string | undefined +): GraphileIntrospectionMode | undefined => { + if (value === undefined) return undefined; + return validateEnv( + { GRAPHILE_INTROSPECTION_MODE: value }, + {}, + { + GRAPHILE_INTROSPECTION_MODE: str({ + choices: [...graphileIntrospectionModes] + }) + } + ).GRAPHILE_INTROSPECTION_MODE as GraphileIntrospectionMode; +}; /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) @@ -7,6 +22,7 @@ import { parseEnvBoolean, parseEnvNumber } from '12factor-env'; export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial => { const { GRAPHILE_SCHEMA, + GRAPHILE_INTROSPECTION_MODE, FEATURES_SIMPLE_INFLECTION, FEATURES_OPPOSITE_BASE_NAMES, @@ -38,6 +54,9 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial // let an absent env var overwrite pgpm.json or consumer-specific values. const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); + const introspectionMode = parseGraphileIntrospectionMode( + GRAPHILE_INTROSPECTION_MODE + ); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || SMS_SENDER_ID || @@ -52,7 +71,8 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial schema: GRAPHILE_SCHEMA.includes(',') ? GRAPHILE_SCHEMA.split(',').map(s => s.trim()) : GRAPHILE_SCHEMA - }) + }), + ...(introspectionMode !== undefined && { introspectionMode }) }, features: { ...(FEATURES_SIMPLE_INFLECTION && { simpleInflection: parseEnvBoolean(FEATURES_SIMPLE_INFLECTION) }), diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 15f1402c53..76b77b1995 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -1,4 +1,4 @@ -import { constructiveGraphqlDefaults,ConstructiveOptions } from '@constructive-io/graphql-types'; +import { constructiveGraphqlDefaults,ConstructiveOptions,graphileIntrospectionModes } from '@constructive-io/graphql-types'; import { getEnvOptions as getPgpmEnvOptions, loadConfigSync, replaceArrays } from '@pgpmjs/env'; import deepmerge from 'deepmerge'; @@ -36,7 +36,7 @@ export const getEnvOptions = ( const configOptions = loadConfigSync(cwd) as Partial; // Merge in order: core -> graphql defaults -> config (for graphql keys) -> graphql env -> overrides - return deepmerge.all([ + const options = deepmerge.all([ coreOptions, constructiveGraphqlDefaults, // Only merge graphql-related keys from config (if present) @@ -51,6 +51,18 @@ export const getEnvOptions = ( ], { arrayMerge: replaceArrays }) as ConstructiveOptions; + + const introspectionMode = options.graphile?.introspectionMode; + if ( + introspectionMode !== undefined && + !graphileIntrospectionModes.includes(introspectionMode) + ) { + throw new Error( + `Unsupported Graphile introspection mode '${String(introspectionMode)}'; expected one of: ${graphileIntrospectionModes.join(', ')}` + ); + } + + return options; }; /** diff --git a/graphql/server/README.md b/graphql/server/README.md index f874d18e0f..74fe2953ac 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -123,6 +123,7 @@ Configuration is merged from defaults, config files, and env vars via `@construc | `PGPASSWORD` | Postgres password | `password` | | `PGDATABASE` | Postgres database | `postgres` | | `GRAPHILE_SCHEMA` | Comma-separated schemas to expose | empty | +| `GRAPHILE_INTROSPECTION_MODE` | `stock` or opt-in `scoped-required` | `stock` | | `FEATURES_SIMPLE_INFLECTION` | Enable simple inflection | `true` | | `FEATURES_OPPOSITE_BASE_NAMES` | Enable opposite base names | `true` | | `FEATURES_POSTGIS` | Enable PostGIS support | `true` | diff --git a/graphql/server/package.json b/graphql/server/package.json index 784404c3a2..5a59cbc380 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -67,6 +67,7 @@ "graphile-cache": "workspace:^", "graphile-config": "1.1.0", "graphile-function-bindings": "workspace:^", + "graphile-scoped-introspection": "workspace:^", "graphile-settings": "workspace:^", "graphile-utils": "5.0.3", "graphql": "16.13.0", diff --git a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts new file mode 100644 index 0000000000..104b43d7b2 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts @@ -0,0 +1,64 @@ +import type { GraphileConfig } from 'graphile-config'; +import type { Pool } from 'pg'; + +import { makeIntrospectionWiring } from '../graphile-introspection'; + +const pool = {} as Pool; + +describe('Graphile introspection mode wiring', () => { + it('uses untouched upstream service wiring without loading scoped code by default', async () => { + const loadScopedPreset = jest.fn(async () => { + throw new Error('scoped preset should not load'); + }); + + const wiring = await makeIntrospectionWiring( + pool, + ['tenant_a'], + undefined, + loadScopedPreset + ); + + expect(loadScopedPreset).not.toHaveBeenCalled(); + expect(wiring.presets).toEqual([]); + expect(wiring.pgService).not.toHaveProperty('introspectionMode'); + expect(wiring.pgService).not.toHaveProperty( + 'introspectionAllowedDependencySchemas' + ); + expect(wiring.pgService).not.toHaveProperty( + 'introspectionCapabilityExtensions' + ); + expect(wiring.pgService.pgSettingsForIntrospection).toBeUndefined(); + }); + + it('loads and configures scoped introspection only when explicitly enabled', async () => { + const scopedPreset: GraphileConfig.Preset = { + disablePlugins: ['PgIntrospectionPlugin'], + }; + const loadScopedPreset = jest.fn(async () => scopedPreset); + + const wiring = await makeIntrospectionWiring( + pool, + ['tenant_a'], + { + introspectionMode: 'scoped-required', + introspectionDependencySchemas: ['shared'], + introspectionCapabilityExtensions: ['pg_trgm'], + }, + loadScopedPreset + ); + + expect(loadScopedPreset).toHaveBeenCalledTimes(1); + expect(wiring.presets).toEqual([scopedPreset]); + expect(wiring.pgService).toMatchObject({ + introspectionMode: 'scoped-required', + introspectionScopedCatalogTypes: 'dependency-closure', + introspectionAllowedDependencySchemas: ['shared'], + introspectionCapabilityExtensions: ['pg_trgm'], + pgSettingsForIntrospection: { + statement_timeout: '120s', + jit: 'off', + work_mem: '512kB', + }, + }); + }); +}); diff --git a/graphql/server/src/middleware/graphile-introspection.ts b/graphql/server/src/middleware/graphile-introspection.ts new file mode 100644 index 0000000000..175693841b --- /dev/null +++ b/graphql/server/src/middleware/graphile-introspection.ts @@ -0,0 +1,70 @@ +import type { + GraphileIntrospectionMode, + GraphileOptions, +} from '@constructive-io/graphql-types'; +import type { GraphileConfig } from 'graphile-config'; +import { makePgService, makeScopedPgService } from 'graphile-settings'; +import type { Pool } from 'pg'; + +export interface IntrospectionWiring { + presets: GraphileConfig.Preset[]; + pgService: GraphileConfig.PgServiceConfiguration; +} + +export type ScopedIntrospectionPresetLoader = + () => Promise; + +let scopedIntrospectionPresetPromise: + Promise | undefined; + +const loadScopedIntrospectionPreset = (): Promise => { + scopedIntrospectionPresetPromise ??= + import('graphile-scoped-introspection').then( + ({ ScopedIntrospectionPreset }) => ScopedIntrospectionPreset + ); + return scopedIntrospectionPresetPromise; +}; + +const assertNever = (mode: never): never => { + throw new Error(`Unsupported Graphile introspection mode '${String(mode)}'`); +}; + +/** + * Select the stock or scoped introspection wiring once, while constructing a + * server-owned schema handler. The stock branch returns before the scoped + * package (and its upstream contract sentinel) is loaded. + */ +export const makeIntrospectionWiring = async ( + pool: Pool, + schemas: string[], + graphileOptions: GraphileOptions | undefined, + loadScopedPreset: ScopedIntrospectionPresetLoader = loadScopedIntrospectionPreset +): Promise => { + const mode: GraphileIntrospectionMode = + graphileOptions?.introspectionMode ?? 'stock'; + + if (mode === 'stock') { + return { + presets: [], + pgService: makePgService({ pool, schemas }), + }; + } + + if (mode === 'scoped-required') { + const scopedPreset = await loadScopedPreset(); + return { + presets: [scopedPreset], + pgService: makeScopedPgService({ + pool, + schemas, + introspectionScopedCatalogTypes: 'dependency-closure', + introspectionAllowedDependencySchemas: + graphileOptions?.introspectionDependencySchemas, + introspectionCapabilityExtensions: + graphileOptions?.introspectionCapabilityExtensions, + }), + }; + } + + return assertNever(mode); +}; diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6de98f7ad..c4dce14582 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -12,7 +12,7 @@ import type { GraphQLError, GraphQLFormattedError } from 'grafast/graphql'; import { createGraphileInstance, graphileCache,type GraphileCacheEntry } from 'graphile-cache'; import type { GraphileConfig } from 'graphile-config'; import { createFunctionBindingsPlugin } from 'graphile-function-bindings'; -import { createConstructivePreset, makePgService } from 'graphile-settings'; +import { createConstructivePreset } from 'graphile-settings'; import { getPgPool } from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; @@ -21,6 +21,7 @@ import { HandlerCreationError } from '../errors/api-errors'; import { respondWithGraphQLError } from '../errors/graphql-response'; import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin'; import type { DatabaseSettings } from '../types'; +import { makeIntrospectionWiring } from './graphile-introspection'; import { observeGraphileBuild } from './observability/graphile-build-stats'; const maskErrorLog = new Logger('graphile:maskError'); @@ -160,17 +161,26 @@ const reqLabel = (req: Request): string => (req.requestId ? `[${req.requestId}]` * plugin preset. Without settings the default preset is used * (everything on except aggregates). */ -const buildPreset = ( +const buildPreset = async ( pool: import('pg').Pool, schemas: string[], anonRole: string, roleName: string, + graphileOptions: ConstructiveOptions['graphile'], databaseSettings?: DatabaseSettings, apiId?: string, compute?: ComputeConfig -): GraphileConfig.Preset => { +): Promise => { + const introspection = await makeIntrospectionWiring( + pool, + schemas, + graphileOptions + ); return { - extends: [createConstructivePreset(databaseSettings)], + extends: [ + createConstructivePreset(databaseSettings), + ...introspection.presets + ], plugins: [ AuthCookiePlugin, // Only registered when the compute module is provisioned for this @@ -193,12 +203,7 @@ const buildPreset = ( ] : []) ], - pgServices: [ - makePgService({ - pool, - schemas - }) - ], + pgServices: [introspection.pgService], grafserv: { graphqlPath: '/graphql', graphiqlPath: '/graphiql', @@ -403,7 +408,16 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // Create promise and store in in-flight map BEFORE try block const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; - const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute); + const preset = await buildPreset( + pool, + schema || [], + anonRole, + roleName, + opts.graphile, + api.databaseSettings, + api.apiId, + compute + ); const creationPromise = observeGraphileBuild( { cacheKey: key, diff --git a/graphql/server/src/middleware/types.ts b/graphql/server/src/middleware/types.ts index 5b0868f764..d5b8904c0f 100644 --- a/graphql/server/src/middleware/types.ts +++ b/graphql/server/src/middleware/types.ts @@ -1,14 +1,8 @@ +import type { ConstructiveAPIToken } from '@constructive-io/express-context'; + import type { ApiStructure } from '../types'; -export type ConstructiveAPIToken = { - id?: string; - user_id?: string; - principal_id?: string; - session_id?: string; - access_level?: string; - kind?: string; - [key: string]: unknown; -}; +export type { ConstructiveAPIToken } from '@constructive-io/express-context'; declare global { namespace Express { diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index 72fff4c739..a4e60cc49e 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -1,11 +1,35 @@ import type { GraphileConfig } from 'graphile-config'; +export const graphileIntrospectionModes = ['stock', 'scoped-required'] as const; + +export type GraphileIntrospectionMode = + (typeof graphileIntrospectionModes)[number]; + +export type ScopedCatalogTypes = 'all' | 'dependency-closure'; + +export interface ScopedIntrospectionServiceOptions { + /** Selects the catalog query used during this service's gather phase. */ + introspectionMode?: GraphileIntrospectionMode; + /** Catalog types retained by scoped introspection; defaults to all. */ + introspectionScopedCatalogTypes?: ScopedCatalogTypes; + /** Non-root schemas that scoped dependency closure may retain. */ + introspectionAllowedDependencySchemas?: readonly string[]; + /** Installed extensions whose optional capability metadata is required. */ + introspectionCapabilityExtensions?: readonly string[]; +} + /** * PostGraphile/Graphile v5 configuration */ export interface GraphileOptions { /** Database schema(s) to expose through GraphQL */ schema?: string | string[]; + /** PostgreSQL catalog introspection implementation selected at startup. */ + introspectionMode?: GraphileIntrospectionMode; + /** Additional schemas that scoped dependency closure may retain. */ + introspectionDependencySchemas?: string[]; + /** Installed extensions whose optional capability metadata must be retained. */ + introspectionCapabilityExtensions?: string[]; /** Additional presets to extend */ extends?: GraphileConfig.Preset[]; /** Preset overrides */ @@ -51,6 +75,9 @@ export interface ApiOptions { */ export const graphileDefaults: GraphileOptions = { schema: [], + introspectionMode: 'stock', + introspectionDependencySchemas: [], + introspectionCapabilityExtensions: [], extends: [], preset: {} }; diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index 895604e137..20e684cdef 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -5,7 +5,11 @@ export { graphileDefaults, graphileFeatureDefaults, GraphileFeatureOptions, - GraphileOptions} from './graphile'; + GraphileIntrospectionMode, + graphileIntrospectionModes, + GraphileOptions, + ScopedCatalogTypes, + ScopedIntrospectionServiceOptions} from './graphile'; // Export Constructive combined types export { diff --git a/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts b/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts new file mode 100644 index 0000000000..c1efcd88f7 --- /dev/null +++ b/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts @@ -0,0 +1,23 @@ +import { makeScopedIntrospectionSuite } from '../src/scoped-introspection-suite'; + +describe('scoped introspection benchmark registration', () => { + it('adds two schema-equivalent cases through the generic suite API', () => { + expect( + makeScopedIntrospectionSuite({ schemas: ['cperf_example'] }) + ).toEqual({ + name: 'scoped-introspection', + cases: [ + { + name: 'stock', + workerConfig: { mode: 'stock', schemas: ['cperf_example'] }, + expectedSchemaGroup: 'introspection-equivalence', + }, + { + name: 'scoped', + workerConfig: { mode: 'scoped', schemas: ['cperf_example'] }, + expectedSchemaGroup: 'introspection-equivalence', + }, + ], + }); + }); +}); diff --git a/packages/perf-harness/package.json b/packages/perf-harness/package.json index 8d23b8bc10..f5c235a485 100644 --- a/packages/perf-harness/package.json +++ b/packages/perf-harness/package.json @@ -20,6 +20,8 @@ "graphile-build": "5.1.1", "graphile-build-pg": "5.1.3", "graphile-config": "1.1.0", + "graphile-scoped-introspection": "workspace:^", + "graphile-settings": "workspace:^", "graphql": "16.13.0", "pg": "^8.21.0", "postgraphile": "5.1.4" diff --git a/packages/perf-harness/src/index.ts b/packages/perf-harness/src/index.ts index 7829d7dc66..379752f582 100644 --- a/packages/perf-harness/src/index.ts +++ b/packages/perf-harness/src/index.ts @@ -6,6 +6,7 @@ export * from './process'; export * from './report'; export * from './run'; export * from './schedule'; +export * from './scoped-introspection-suite'; export * from './types'; import { cliMain } from './run'; diff --git a/packages/perf-harness/src/scoped-introspection-suite.ts b/packages/perf-harness/src/scoped-introspection-suite.ts new file mode 100644 index 0000000000..5c59d9f825 --- /dev/null +++ b/packages/perf-harness/src/scoped-introspection-suite.ts @@ -0,0 +1,24 @@ +import type { BenchmarkSuiteDefinition } from './types'; + +export interface ScopedIntrospectionSuiteOptions { + schemas: string[]; +} + +/** Register the stock/scoped cases without teaching the core runner their names. */ +export const makeScopedIntrospectionSuite = ( + options: ScopedIntrospectionSuiteOptions +): BenchmarkSuiteDefinition => ({ + name: 'scoped-introspection', + cases: [ + { + name: 'stock', + workerConfig: { mode: 'stock', schemas: options.schemas }, + expectedSchemaGroup: 'introspection-equivalence', + }, + { + name: 'scoped', + workerConfig: { mode: 'scoped', schemas: options.schemas }, + expectedSchemaGroup: 'introspection-equivalence', + }, + ], +}); diff --git a/packages/perf-harness/src/scoped-introspection-worker.ts b/packages/perf-harness/src/scoped-introspection-worker.ts new file mode 100644 index 0000000000..733331b39f --- /dev/null +++ b/packages/perf-harness/src/scoped-introspection-worker.ts @@ -0,0 +1,121 @@ +import { createHash } from 'node:crypto'; + +import { + defaultPreset as graphileBuildPreset, + makeSchema, +} from 'graphile-build'; +import { defaultPreset as graphileBuildPgPreset } from 'graphile-build-pg'; +import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection'; +import { makeScopedPgService } from 'graphile-settings'; +import { execute, lexicographicSortSchema, parse, printSchema } from 'graphql'; +import { makePgService as makePostGraphilePgService } from 'postgraphile/adaptors/pg'; + +import { measureBenchmarkCase } from './metrics'; +import { + parseWorkerProcessArgs, + redactSecret, + writeWorkerResult, +} from './process'; + +interface ScopedWorkerConfig { + mode: 'stock' | 'scoped'; + schemas: string[]; +} + +const validateConfig = (value: unknown): ScopedWorkerConfig => { + const config = value as Partial; + if (config.mode !== 'stock' && config.mode !== 'scoped') { + throw new Error( + 'scoped introspection worker requires stock or scoped mode' + ); + } + if ( + !Array.isArray(config.schemas) || + config.schemas.length === 0 || + config.schemas.some( + (schema) => typeof schema !== 'string' || schema.length === 0 + ) + ) { + throw new Error( + 'scoped introspection worker requires a non-empty schemas array' + ); + } + return { mode: config.mode, schemas: config.schemas }; +}; + +const main = async (): Promise => { + let databaseUrl = ''; + let caseName = 'unknown'; + let release: (() => Promise) | null = null; + try { + const workerArgs = parseWorkerProcessArgs(process.argv.slice(2)); + databaseUrl = workerArgs.databaseUrl; + const { envelope } = workerArgs; + caseName = envelope.caseName; + const config = validateConfig(envelope.workerConfig); + const serviceOptions = { + connectionString: databaseUrl, + schemas: config.schemas, + pubsub: false, + }; + const scopedServiceOptions = { + ...serviceOptions, + introspectionScopedCatalogTypes: 'dependency-closure' as const, + }; + const service = + config.mode === 'stock' + ? makePostGraphilePgService(serviceOptions) + : makeScopedPgService(scopedServiceOptions); + release = async () => { + await service.release(); + }; + + const result = await measureBenchmarkCase( + caseName, + async () => + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ...(config.mode === 'scoped' ? [ScopedIntrospectionPreset] : []), + ], + pgServices: [service], + }), + async ({ schema }) => { + const execution = await execute({ + schema, + document: parse('{ __typename }'), + }); + if ( + execution.errors?.length || + execution.data?.__typename !== 'Query' + ) { + throw new Error('runtime verification query failed'); + } + const schemaText = printSchema(lexicographicSortSchema(schema)); + return { + schemaHash: createHash('sha256').update(schemaText).digest('hex'), + schemaTypeCount: Object.keys(schema.getTypeMap()).length, + runtimeVerified: true as const, + metadata: { introspectionMode: config.mode }, + }; + } + ); + writeWorkerResult(result); + } catch (error) { + writeWorkerResult({ + status: 'error', + pid: process.pid, + caseName, + error: redactSecret( + error instanceof Error ? error.message : String(error), + databaseUrl + ), + }); + process.exitCode = 1; + } finally { + await release?.(); + } +}; + +if (require.main === module) void main(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ba0972089..144b4b3fd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1152,6 +1152,41 @@ importers: version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist + graphile/graphile-scoped-introspection: + dependencies: + '@constructive-io/graphql-types': + specifier: workspace:^ + version: link:../../graphql/types/dist + '@dataplan/pg': + specifier: 1.1.1 + version: 1.1.1(@dataplan/json@1.0.1(grafast@1.1.2(graphql@16.13.0)))(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + graphile-build: + specifier: 5.1.1 + version: 5.1.1(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0) + graphile-build-pg: + specifier: 5.1.3 + version: 5.1.3(@dataplan/pg@1.1.1(@dataplan/json@1.0.1(grafast@1.1.2(graphql@16.13.0)))(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.1.2(graphql@16.13.0))(graphile-build@5.1.1(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + graphile-config: + specifier: 1.1.0 + version: 1.1.0 + pg-introspection: + specifier: 1.0.1 + version: 1.0.1 + devDependencies: + '@types/node': + specifier: ^22.19.11 + version: 22.19.19 + graphql: + specifier: 16.13.0 + version: 16.13.0 + makage: + specifier: ^0.3.0 + version: 0.3.0 + postgraphile: + specifier: 5.1.4 + version: 5.1.4(f282a162d8bd20a217e08c60f5396af8) + publishDirectory: dist + graphile/graphile-search: dependencies: '@dataplan/pg': @@ -1324,6 +1359,9 @@ importers: pg: specifier: ^8.21.0 version: 8.21.0 + pg-introspection: + specifier: 1.0.1 + version: 1.0.1 pg-query-context: specifier: workspace:^ version: link:../../postgres/pg-query-context/dist @@ -2017,6 +2055,9 @@ importers: graphile-function-bindings: specifier: workspace:^ version: link:../../graphile/graphile-function-bindings/dist + graphile-scoped-introspection: + specifier: workspace:^ + version: link:../../graphile/graphile-scoped-introspection/dist graphile-settings: specifier: workspace:^ version: link:../../graphile/graphile-settings/dist @@ -2626,6 +2667,12 @@ importers: graphile-config: specifier: 1.1.0 version: 1.1.0 + graphile-scoped-introspection: + specifier: workspace:^ + version: link:../../graphile/graphile-scoped-introspection/dist + graphile-settings: + specifier: workspace:^ + version: link:../../graphile/graphile-settings/dist graphql: specifier: 16.13.0 version: 16.13.0