diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 5a4b52fdf1..9b54a45a02 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -242,7 +242,7 @@ jobs: - batch: pg-core packages: 'pgpm/ast pgpm/traverse pgpm/bundle pgpm/core pgpm/cli pgpm/portability pgpm/export packages/client packages/safegres postgres/pg-codegen postgres/query-builder' - batch: pg-postgres - packages: 'postgres/pgsql-test postgres/drizzle-orm-test postgres/introspectron graphile/graphile-test graphile/graphile-connection-filter graphile/graphile-postgis' + packages: 'postgres/pgsql-test postgres/drizzle-orm-test postgres/introspectron graphile/graphile-test graphile/graphile-scoped-introspection graphile/graphile-connection-filter graphile/graphile-postgis' - batch: pg-graphql packages: 'graphile/graphile-search graphile/graphile-ltree graphile/graphile-bulk-mutations graphile/graphile-function-bindings graphile/graphile-history graphile/graphile-meta graphile/graphile-schema graphql/orm-test graphql/test graphql/playwright-test' - batch: pg-graphile-extras diff --git a/graphile/graphile-scoped-introspection/README.md b/graphile/graphile-scoped-introspection/README.md new file mode 100644 index 0000000000..2da3a4dfa5 --- /dev/null +++ b/graphile/graphile-scoped-introspection/README.md @@ -0,0 +1,67 @@ +# 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], + gather: { + pgScopedIntrospection: { + main: true, + }, + }, + pgServices: [ + { + // standard Graphile PgService fields + name: 'main', + schemas: ['app_public'], + }, + ], +}; +``` + +With `main: true`, the scoped query uses `catalogTypes: 'all'`: it retains the +dependency closure needed by the selected schemas and all `pg_catalog` types. +It does not expose every user schema or every catalog object. Services omitted +from `gather.pgScopedIntrospection`, or mapped to `false`, use stock +introspection. + +Use an options object when the dependency-only policy or extension capability +metadata is needed: + +```ts +gather: { + pgScopedIntrospection: { + main: { + catalogTypes: 'dependency-closure', + capabilityExtensions: ['pg_trgm'], + }, + }, +}, +``` + +The query follows real PostgreSQL dependencies across schemas automatically; +there is no dependency-schema allowlist. `capabilityExtensions` records the +requested extension capability metadata and does not add user schemas to the +scope. Unknown service names fail validation even when mapped to `false`. + +The package atomically replaces `PgIntrospectionPlugin` only when its preset +is installed. `true` enables scoped introspection with defaults; an options +object selects the catalog type policy and optional extension capability +metadata. + +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. + +Use `makeSchemaScopedIntrospectionPlan` when the query result will be parsed +and validated. It returns the normalized schema and option scope alongside the +parameterized query; `makeSchemaScopedIntrospectionQuery` remains available for +callers that only need the SQL. + +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__/fixtures/scoped-introspection.sql b/graphile/graphile-scoped-introspection/__tests__/fixtures/scoped-introspection.sql new file mode 100644 index 0000000000..7ca0dbd2c3 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/fixtures/scoped-introspection.sql @@ -0,0 +1,141 @@ +create schema scope_root; +create schema scope_dependency; +create schema scope_unrelated; +create schema scope_extension; +create schema scope_capability_root; + +create extension pg_trgm with schema scope_extension; + +create type scope_dependency.item_status as enum ( + 'draft', + 'active', + 'archived' +); + +create domain scope_dependency.positive_integer as integer + check (value > 0); + +create type scope_dependency.item_payload as ( + status scope_dependency.item_status, + score scope_dependency.positive_integer +); + +create type scope_dependency.integer_span as range ( + subtype = integer, + multirange_type_name = scope_dependency.integer_span_set +); + +create table scope_dependency.dependency_owners ( + id bigint generated always as identity primary key, + status scope_dependency.item_status not null +); + +create table scope_dependency.inherited_base ( + inherited_status scope_dependency.item_status not null +); + +create table scope_root.closure_items ( + id bigint generated always as identity primary key, + dependency_owner_id bigint not null + references scope_dependency.dependency_owners (id), + title text not null, + status scope_dependency.item_status not null, + score scope_dependency.positive_integer not null, + payload scope_dependency.item_payload not null, + active_span scope_dependency.integer_span +); + +create table scope_root.inherited_items ( + id bigint generated always as identity primary key +) inherits (scope_dependency.inherited_base); + +create table scope_root.inheritance_root ( + id bigint generated always as identity primary key, + root_note text not null +); + +create table scope_dependency.reverse_inherited_item ( + dependency_note text not null +) inherits (scope_root.inheritance_root); + +create index closure_items_status_idx + on scope_root.closure_items (status); + +create index closure_items_title_gin_trgm_idx + on scope_root.closure_items + using gin (title scope_extension.gin_trgm_ops); + +create index closure_items_title_gist_trgm_idx + on scope_root.closure_items + using gist (title scope_extension.gist_trgm_ops(siglen = 32)); + +create function scope_root.echo_dependency_status( + input_status scope_dependency.item_status +) +returns scope_dependency.item_status +language sql +immutable +strict +parallel safe +as $$ + select input_status; +$$; + +create function scope_root.make_dependency_payload( + input_status scope_dependency.item_status, + input_score scope_dependency.positive_integer +) +returns scope_dependency.item_payload +language sql +immutable +strict +parallel safe +as $$ + select row(input_status, input_score)::scope_dependency.item_payload; +$$; + +create type scope_unrelated.item_status as enum ( + 'draft', + 'active', + 'archived' +); + +create table scope_unrelated.closure_items ( + id bigint generated always as identity primary key, + status scope_unrelated.item_status not null +); + +create function scope_unrelated.echo_dependency_status( + input_status scope_unrelated.item_status +) +returns scope_unrelated.item_status +language sql +immutable +strict +parallel safe +as $$ + select input_status; +$$; + +create table scope_capability_root.capability_items ( + id bigint generated always as identity primary key, + title text not null +); + +insert into scope_dependency.dependency_owners (status) +values ('active'); + +insert into scope_root.closure_items ( + dependency_owner_id, + title, + status, + score, + payload +) +values ( + 1, + 'scoped fixture item', + 'active', + 7, + row('active', 7)::scope_dependency.item_payload +); 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..172bdff06d --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts @@ -0,0 +1,275 @@ +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('reuses clean raw text, refreshes dirty data, and fails closed on errors', async () => { + let cache: { + introspectionResultsPromise: Promise | null; + dirty: boolean; + } | null = null; + let triggerRegather: (() => void) | null = null; + let triggerDirtyRegather: (() => void) | null = null; + let queryError: Error | null = null; + let nextIntrospectionText = introspectionText; + const seenNamespaceNames: string[] = []; + let announcementCount = 0; + 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 announcementPlugin = { + name: 'ScopedIntrospectionAnnouncementObserverPlugin', + gather: { + namespace: 'scopedIntrospectionAnnouncementObserver', + hooks: { + pgIntrospection_introspection() { + announcementCount += 1; + }, + }, + }, + } 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; + triggerDirtyRegather = () => { + cache!.introspectionResultsPromise = null; + cache!.dirty = true; + callback(); + }; + return (): void => undefined; + }, + }, + } as unknown as GraphileConfig.Plugin; + const pgService = { + name: 'main', + schemas: [SCHEMA], + adaptor, + adaptorSettings: {}, + withPgClientKey: 'withPgClient', + pgSettingsKey: 'pgSettings', + }; + const results = makeResultQueue(); + + const stopWatching = await watchGather( + { + plugins: [ + capturingIntrospectionPlugin, + announcementPlugin, + observerPlugin, + ], + gather: { pgScopedIntrospection: { main: true } }, + 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).not.toBeNull(); + expect(announcementCount).toBe(1); + + triggerRegather!(); + const second = await results.next(); + expect(second.error).toBeUndefined(); + expect(second.input).toMatchObject({ namespaceName: SCHEMA }); + expect(query).toHaveBeenCalledTimes(1); + expect(seenNamespaceNames).toEqual([SCHEMA, SCHEMA]); + expect(cache!.introspectionResultsPromise).not.toBeNull(); + expect(announcementCount).toBe(2); + + nextIntrospectionText = unapprovedSchemaIntrospectionText; + triggerDirtyRegather!(); + const crossSchema = await results.next(); + expect(crossSchema.error).toBeUndefined(); + expect(crossSchema.input).toMatchObject({ namespaceName: SCHEMA }); + expect(query).toHaveBeenCalledTimes(2); + expect(cache!.introspectionResultsPromise).not.toBeNull(); + + nextIntrospectionText = missingSchemaIntrospectionText; + triggerDirtyRegather!(); + const missing = await results.next(); + expect(missing.input).toBeNull(); + expect(missing.error?.message).toContain( + `did not find required schema(s): ${SCHEMA}` + ); + expect(query).toHaveBeenCalledTimes(3); + expect(cache!.introspectionResultsPromise).not.toBeNull(); + + nextIntrospectionText = introspectionText; + triggerDirtyRegather!(); + const recovered = await results.next(); + expect(recovered.error).toBeUndefined(); + expect(recovered.input).toMatchObject({ namespaceName: SCHEMA }); + expect(query).toHaveBeenCalledTimes(4); + + const marker = new Error('scoped introspection re-query failed'); + queryError = marker; + triggerDirtyRegather!(); + const failed = await results.next(); + expect(failed.input).toBeNull(); + expect(failed.error).toBe(marker); + expect(query).toHaveBeenCalledTimes(5); + expect(cache!.introspectionResultsPromise).toBeNull(); + } finally { + stopWatching(); + } + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-integration.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-integration.test.ts new file mode 100644 index 0000000000..d432350d78 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-integration.test.ts @@ -0,0 +1,433 @@ +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; + +import { + defaultPreset as graphileBuildPreset, + makeSchema, +} from 'graphile-build'; +import { defaultPreset as graphileBuildPgPreset } from 'graphile-build-pg'; +import type { GraphileConfig } from 'graphile-config'; +import { + type GraphQLSchema, + lexicographicSortSchema, + parse, + printSchema, +} from 'graphql'; +import type { Introspection } from 'pg-introspection'; +import { getConnections, type PgTestClient,seed } from 'pgsql-test'; + +const { withPgClientFromPgService } = require('postgraphile/@dataplan/pg') as { + withPgClientFromPgService: (...args: readonly unknown[]) => unknown; +}; +type GrafastExecutionResult = { + data?: Record | null; + errors?: readonly unknown[]; +}; +type GrafastExecution = GrafastExecutionResult | AsyncIterable; +const { execute } = require('postgraphile/grafast') as { + execute(options: { + schema: GraphQLSchema; + document: ReturnType; + resolvedPreset: GraphileConfig.ResolvedPreset; + contextValue: Record; + }): GrafastExecution | Promise; +}; + +import { ScopedIntrospectionPreset } from '../src'; + +const { makePgService } = require('postgraphile/adaptors/pg') as { + makePgService( + options: Record + ): GraphileConfig.PgServiceConfiguration; +}; + +const ROOT_SCHEMA = 'scope_root'; +const DEPENDENCY_SCHEMA = 'scope_dependency'; +const UNRELATED_SCHEMA = 'scope_unrelated'; +const EXTENSION_SCHEMA = 'scope_extension'; +const CAPABILITY_ROOT_SCHEMA = 'scope_capability_root'; + +type ScopedConfig = + | true + | { + catalogTypes: 'dependency-closure'; + capabilityExtensions: readonly string[]; + }; + +interface SchemaBuild { + schema: GraphQLSchema; + resolvedPreset: GraphileConfig.ResolvedPreset; + service: GraphileConfig.PgServiceConfiguration; + introspection: Introspection; + hash: string; +} + +const fixture = (file: string): string => join(__dirname, 'fixtures', file); + +const makeCapturePlugin = ( + capture: (introspection: Introspection) => void +): GraphileConfig.Plugin => ({ + name: 'ScopedIntrospectionCapturePlugin', + gather: { + namespace: 'scopedIntrospectionCapture' as const, + hooks: { + pgIntrospection_introspection(_info: unknown, event: { introspection: Introspection }) { + capture(event.introspection); + }, + }, + }, +}) as unknown as GraphileConfig.Plugin; + +describe('schema-scoped PostgreSQL introspection', () => { + let teardown: (() => Promise) | undefined; + let pool: ReturnType>['manager']['getPool']>; + let pg: PgTestClient; + let db: PgTestClient; + const services: GraphileConfig.PgServiceConfiguration[] = []; + let stock: SchemaBuild; + let scopedDefault: SchemaBuild; + let scoped: SchemaBuild; + + const buildSchema = async ( + scopedConfig: false | ScopedConfig, + rootSchema = ROOT_SCHEMA + ): Promise => { + let introspection: Introspection | undefined; + const service = makePgService({ + pool, + schemas: [rootSchema], + pubsub: false, + }); + services.push(service); + const serviceName = String(service.name); + let built = false; + try { + const result = await makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + ...(scopedConfig !== false + ? { + gather: { + pgScopedIntrospection: { + [serviceName]: scopedConfig, + }, + }, + } + : {}), + plugins: [ + makeCapturePlugin((value) => { + introspection = value; + }), + ], + pgServices: [service as never], + }); + if (!introspection) { + throw new Error( + 'PostgreSQL introspection lifecycle event was not emitted' + ); + } + const sdl = printSchema(lexicographicSortSchema(result.schema)); + built = true; + return { + schema: result.schema, + resolvedPreset: result.resolvedPreset, + service, + introspection, + hash: createHash('sha256').update(sdl).digest('hex'), + }; + } finally { + if (!built) { + services.splice(services.indexOf(service), 1); + await service.release?.(); + } + } + }; + + const executeSchema = async ( + built: SchemaBuild, + document: ReturnType + ): Promise => { + const execution = await execute({ + schema: built.schema, + document, + resolvedPreset: built.resolvedPreset, + contextValue: { + [built.service.pgSettingsKey!]: {}, + [built.service.withPgClientKey!]: withPgClientFromPgService.bind( + null, + built.service + ), + }, + }); + if ( + execution !== null && + typeof execution === 'object' && + Symbol.asyncIterator in execution + ) { + throw new Error( + 'Integration query unexpectedly returned an async iterable' + ); + } + return execution as GrafastExecutionResult; + }; + + beforeAll(async () => { + const connections = await getConnections({}, [ + seed.sqlfile([fixture('scoped-introspection.sql')]), + ]); + pg = connections.pg; + db = connections.db; + teardown = connections.teardown; + pool = connections.manager.getPool(connections.pg.config); + stock = await buildSchema(false); + scopedDefault = await buildSchema(true); + scoped = await buildSchema({ + catalogTypes: 'dependency-closure', + capabilityExtensions: ['pg_trgm'], + }); + }, 120_000); + + afterAll(async () => { + for (const service of services) { + await service.release?.(); + } + await teardown?.(); + }); + + beforeEach(async () => { + await pg.beforeEach(); + await db.beforeEach(); + }); + + afterEach(async () => { + await db.afterEach(); + await pg.afterEach(); + }); + + it('builds the same schema and a working runtime', async () => { + expect(scopedDefault.hash).toBe(stock.hash); + + const document = parse('{ __typename }'); + const stockResult = await executeSchema(stock, document); + const scopedResult = await executeSchema(scopedDefault, document); + expect(scopedResult).toEqual(stockResult); + expect(scopedResult.errors).toBeUndefined(); + expect(scopedResult.data?.__typename).toBe('Query'); + }); + + it('executes a retained table and function through the scoped schema', async () => { + const queryFields = scopedDefault.schema.getQueryType()!.getFields(); + const tableFieldName = Object.keys(queryFields).find((name) => + name.toLowerCase().includes('closureitems') + ); + if (!tableFieldName) { + throw new Error( + `Scoped fixture table field was not generated; query fields: ${Object.keys(queryFields).join(', ')}` + ); + } + + const tableResult = await executeSchema( + scopedDefault, + parse(`{ ${tableFieldName} { nodes { title } } }`) + ); + expect(tableResult.errors).toBeUndefined(); + expect(tableResult.data?.[tableFieldName!]).toEqual({ + nodes: [{ title: 'scoped fixture item' }], + }); + + const functionFieldName = Object.keys(queryFields).find((name) => + name.toLowerCase().includes('echodependencystatus') + ); + if (!functionFieldName) { + throw new Error( + `Scoped fixture function field was not generated; query fields: ${Object.keys(queryFields).join(', ')}` + ); + } + const functionField = queryFields[functionFieldName!]; + expect(functionField.args).toHaveLength(1); + const argument = functionField.args[0]; + let argumentType = argument.type; + while ('ofType' in argumentType) argumentType = argumentType.ofType; + const enumValue = 'getValues' in argumentType + ? argumentType + .getValues() + .find( + (value: { name: string }) => + value.name.toLowerCase() === 'active' + ) + : undefined; + if (!enumValue) { + throw new Error( + `Scoped fixture function argument did not expose active; type: ${argumentType.toString()}; values: ${'getValues' in argumentType ? argumentType.getValues().map((value: { name: string }) => value.name).join(', ') : 'none'}` + ); + } + + const functionResult = await executeSchema( + scopedDefault, + parse(`{ ${functionFieldName}(${argument.name}: ${enumValue!.name}) }`) + ); + expect(functionResult.errors).toBeUndefined(); + expect(functionResult.data?.[functionFieldName!]).toBe(enumValue.name); + }); + + it('retains transitive table, function, and range type dependencies', () => { + const namespaceNames = scoped.introspection.namespaces.map( + (namespace) => namespace.nspname + ); + expect(namespaceNames).toEqual( + expect.arrayContaining([ + ROOT_SCHEMA, + DEPENDENCY_SCHEMA, + EXTENSION_SCHEMA, + 'pg_catalog', + ]) + ); + expect(namespaceNames).not.toContain(UNRELATED_SCHEMA); + + const rootTable = scoped.introspection.classes.find( + (entity) => + entity.relname === 'closure_items' && + entity.getNamespace()?.nspname === ROOT_SCHEMA + ); + expect(rootTable).toBeDefined(); + const attributeTypes = new Map( + rootTable! + .getAttributes() + .map((attribute) => [attribute.attname, attribute.getType()]) + ); + expect(attributeTypes.get('status')?.typname).toBe('item_status'); + expect(attributeTypes.get('score')?.typname).toBe('positive_integer'); + expect(attributeTypes.get('payload')?.typname).toBe('item_payload'); + expect(attributeTypes.get('active_span')?.typname).toBe('integer_span'); + + const statusType = attributeTypes.get('status'); + expect(statusType?.getEnumValues().map((value) => value.enumlabel)).toEqual([ + 'draft', + 'active', + 'archived', + ]); + expect(statusType?.getArrayType()?.typname).toBe('_item_status'); + + const payloadType = attributeTypes.get('payload'); + expect( + payloadType + ?.getClass() + ?.getAttributes() + .map((attribute) => attribute.getType()?.typname) + ).toEqual(['item_status', 'positive_integer']); + + const echoStatus = scoped.introspection.procs.find( + (proc) => + proc.proname === 'echo_dependency_status' && + proc.getNamespace()?.nspname === ROOT_SCHEMA + ); + expect(echoStatus?.getReturnType()?.typname).toBe('item_status'); + expect( + echoStatus?.getArguments().map((argument) => argument.type.typname) + ).toEqual(['item_status']); + + const makePayload = scoped.introspection.procs.find( + (proc) => + proc.proname === 'make_dependency_payload' && + proc.getNamespace()?.nspname === ROOT_SCHEMA + ); + expect(makePayload?.getReturnType()?.typname).toBe('item_payload'); + expect( + makePayload?.getArguments().map((argument) => argument.type.typname) + ).toEqual(['item_status', 'positive_integer']); + + const range = scoped.introspection.ranges.find( + (entity) => entity.getType()?.typname === 'integer_span' + ); + expect(range?.getSubType()?.typname).toBe('int4'); + expect( + scoped.introspection.types.find( + (type) => type._id === range?.rngmultitypid + )?.typname + ).toBe('integer_span_set'); + + const foreignKey = rootTable + ?.getConstraints() + .find((constraint) => constraint.contype === 'f'); + expect(foreignKey?.getForeignClass()?.relname).toBe('dependency_owners'); + expect(foreignKey?.getForeignClass()?.getNamespace()?.nspname).toBe( + DEPENDENCY_SCHEMA + ); + + const inheritedItems = scoped.introspection.classes.find( + (entity) => + entity.relname === 'inherited_items' && + entity.getNamespace()?.nspname === ROOT_SCHEMA + ); + const inherited = inheritedItems?.getInherited(); + expect(inherited).toHaveLength(1); + expect( + scoped.introspection.classes.find( + (entity) => entity._id === inherited?.[0]?.inhparent + )?.relname + ).toBe('inherited_base'); + expect( + scoped.introspection.classes.some( + (entity) => entity.relname === 'reverse_inherited_item' + ) + ).toBe(false); + }); + + it('retains indexes and identifies their owning extension', () => { + const indexNames = scoped.introspection.indexes.map( + (index) => index.getIndexClass()?.relname + ); + expect(indexNames).toEqual( + expect.arrayContaining([ + 'closure_items_status_idx', + 'closure_items_title_gin_trgm_idx', + 'closure_items_title_gist_trgm_idx', + ]) + ); + expect( + scoped.introspection.extensions.some( + (extension) => extension.extname === 'pg_trgm' + ) + ).toBe(true); + expect( + scoped.introspection.types.some( + (type) => type.getNamespace()?.nspname === UNRELATED_SCHEMA + ) + ).toBe(false); + expect( + scoped.introspection.procs.some( + (proc) => proc.getNamespace()?.nspname === UNRELATED_SCHEMA + ) + ).toBe(false); + }); + + it('retains explicitly requested extension capability metadata', async () => { + const capabilityOnly = await buildSchema( + { + catalogTypes: 'dependency-closure', + capabilityExtensions: ['pg_trgm'], + }, + CAPABILITY_ROOT_SCHEMA + ); + + expect( + capabilityOnly.introspection.extensions.some( + (extension) => extension.extname === 'pg_trgm' + ) + ).toBe(true); + expect( + capabilityOnly.introspection.indexes.some((index) => + index.getIndexClass()?.relname.includes('trgm') + ) + ).toBe(false); + }); + + it('fails fast when a configured root schema is missing', async () => { + await expect(buildSchema(true, 'scope_missing_root')).rejects.toThrow( + /validation failed.*did not find required schema\(s\): scope_missing_root/u + ); + }); +}); 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..97ac96145f --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts @@ -0,0 +1,238 @@ +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, + 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], + 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], + gather: { pgScopedIntrospection: { scoped: true } }, + pgServices: [ + makeService('stock', 'stock_schema', queries), + makeService('scoped', 'scoped_schema', 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', upstreamQueries), + ], + }; + const replacementPreset = { + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + plugins: [makeObserver('ReplacementStockObserverPlugin')], + pgServices: [ + makeService('main', 'stock_schema', 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', upstreamQueries), + ], + }), + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + pgServices: [ + makeService('main', 'stock_schema', 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..f8791eb5f6 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-plugin.test.ts @@ -0,0 +1,151 @@ +import { defaultPreset as graphileBuildPreset } from 'graphile-build'; +import { + defaultPreset as graphileBuildPgPreset, + PgIntrospectionPlugin, +} from 'graphile-build-pg'; +import { resolvePreset } from 'graphile-config'; + +import { peerDependencies } from '../package.json'; +import { + ConstructivePgIntrospectionPlugin, + ScopedIntrospectionPreset, + scopedIntrospectionUpstreamContract, +} from '../src'; + +describe('CNC introspection replacement contract', () => { + it('declares the exact upstream version enforced by the runtime guard', () => { + expect(peerDependencies['graphile-build-pg']).toBe( + scopedIntrospectionUpstreamContract.version + ); + }); + + 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..f0de750a2e --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts @@ -0,0 +1,107 @@ +import { + makeSchemaScopedIntrospectionPlan, + makeSchemaScopedIntrospectionQuery, + validateSchemaScopedIntrospection, +} 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('returns normalized scope with the query plan', () => { + const plan = makeSchemaScopedIntrospectionPlan( + ['tenant_a', 'tenant_a'], + { capabilityExtensions: ['pg_trgm', 'pg_trgm'] } + ); + + expect(plan.scope).toEqual({ + schemas: ['tenant_a'], + catalogTypes: 'all', + capabilityExtensions: ['pg_trgm'], + }); + expect(plan.query.values).toEqual([['tenant_a'], ['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'); + }); + + it('validates required root schemas without rejecting real dependencies', () => { + const plan = makeSchemaScopedIntrospectionPlan(['tenant_a']); + const introspection = { + namespaces: [{ nspname: 'tenant_a' }, { nspname: 'shared_dependency' }], + types: [], + classes: [], + attributes: [], + constraints: [], + procs: [], + enums: [], + ranges: [], + } as never; + + expect(() => validateSchemaScopedIntrospection(introspection, plan)).not.toThrow(); + }); +}); 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..6df7da8ce9 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts @@ -0,0 +1,163 @@ +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), + }; + const service = makePostGraphilePgService({ + pool: pool as never, + schemas: ['tenant_a'], + }); + const serviceName = service.name as string; + + await expect( + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + gather: { + pgScopedIntrospection: { + [serviceName]: { + capabilityExtensions: ['pg_trgm'], + ...(scopedCatalogTypes === undefined + ? {} + : { catalogTypes: scopedCatalogTypes }), + }, + }, + }, + pgServices: [service 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), + }; + const service = makePostGraphilePgService({ + pool: pool as never, + schemas: ['tenant_a'], + }); + const serviceName = service.name as string; + + const failure = await makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + gather: { + pgScopedIntrospection: { + [serviceName]: { catalogTypes: 'dependency-closure' }, + }, + }, + pgServices: [service as never], + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch( + /validation failed for PostgreSQL service '.+': Dependency-closure introspection retained pg_class 'broken_items \(200\)' field 'reltype' referencing missing pg_type OID '999'/ + ); + expect( + (failure as Error & { cause?: unknown }).cause + ).toBeInstanceOf(Error); + 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..a25c9df87b --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts @@ -0,0 +1,117 @@ +import '@dataplan/pg/adaptors/pg'; + +import { gather } from 'graphile-build'; +import type { GraphileConfig } from 'graphile-config'; +import { makeIntrospectionQuery } from 'pg-introspection'; + +import { ConstructivePgIntrospectionPlugin } from '../src'; + +const makeService = (overrides: Record = {}): never => + ({ + name: 'main', + schemas: ['tenant_a'], + 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], + gather: { + pgScopedIntrospection: Object.fromEntries( + [first, second].map((service: any) => [service.name, true]) + ), + }, + pgServices: [first, second], + }) + ).rejects.toThrow(message); + }); + + it('rejects unknown service names even when scoped introspection is false', async () => { + await expect( + gather({ + plugins: [ConstructivePgIntrospectionPlugin, consumerPlugin], + gather: { pgScopedIntrospection: { missing: false } }, + pgServices: [makeService()], + }) + ).rejects.toThrow( + 'unknown PostgreSQL service(s): missing' + ); + }); + + it('uses stock introspection when a known service is explicitly false', async () => { + const marker = new Error('stock query captured'); + let captured: { text: string; values?: unknown[] } | null = null; + const service = makeService({ + adaptor: { + createWithPgClient: jest.fn(async () => + Object.assign( + async ( + _settings: unknown, + callback: (client: { + query(query: { text: string; values?: unknown[] }): never; + }) => never + ) => + callback({ + query(query) { + captured = query; + throw marker; + }, + }), + { release: jest.fn() } + ) + ), + }, + }); + + await expect( + gather({ + plugins: [ConstructivePgIntrospectionPlugin, consumerPlugin], + gather: { pgScopedIntrospection: { main: false } }, + pgServices: [service], + }) + ).rejects.toBe(marker); + expect(captured).toEqual({ text: makeIntrospectionQuery() }); + }); +}); 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..7286305fb4 --- /dev/null +++ b/graphile/graphile-scoped-introspection/package.json @@ -0,0 +1,56 @@ +{ + "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.8.0", + "postgraphile": "5.1.4", + "pgsql-test": "workspace:^" + }, + "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..d70b941564 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/index.ts @@ -0,0 +1,21 @@ +export type { + PgScopedIntrospectionConfig, + PgScopedIntrospectionServiceConfig, + SchemaScopedIntrospectionOptions, + ScopedCatalogTypes, +} from './plugin'; +export { + ConstructivePgIntrospectionPlugin, + ScopedIntrospectionPreset, + scopedIntrospectionUpstreamContract, +} from './plugin'; +export type { + SchemaScopedIntrospectionPlan, + SchemaScopedIntrospectionQuery, + SchemaScopedIntrospectionScope, +} from './scoped-introspection-query'; +export { + makeSchemaScopedIntrospectionPlan, + makeSchemaScopedIntrospectionQuery, + validateSchemaScopedIntrospection, +} 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..728dc134de --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/plugin.ts @@ -0,0 +1,367 @@ +import 'graphile-build'; + +import type { + PgScopedIntrospectionConfig, + PgScopedIntrospectionServiceConfig, + SchemaScopedIntrospectionOptions, +} 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 { + makeSchemaScopedIntrospectionPlan, + type SchemaScopedIntrospectionPlan, + validateSchemaScopedIntrospection, +} from './scoped-introspection-query'; + +export type { + PgScopedIntrospectionConfig, + PgScopedIntrospectionServiceConfig, + SchemaScopedIntrospectionOptions, + ScopedCatalogTypes, +} from '@constructive-io/graphql-types'; + +type GatherInfo = { + cache: { + introspectionResultsPromise: Promise | null; + dirty: boolean; + }; + state: { + getIntrospectionPromise: + Promise | IntrospectionResult[] | null; + }; + options: GraphileBuild.GatherOptions; + resolvedPreset: GraphileConfig.ResolvedPreset; + process(eventName: string, event: Record): Promise; +}; +type IntrospectionResult = { + pgService: GraphileConfig.PgServiceConfiguration; + introspection: Introspection; +}; +type RawIntrospection = { + pgService: GraphileConfig.PgServiceConfiguration; + introspectionText: string; + scopedPlan: SchemaScopedIntrospectionPlan | 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 getIntrospectionQuery( + pgService: GraphileConfig.PgServiceConfiguration, + config?: PgScopedIntrospectionServiceConfig +): { + query: PgQuery; + scopedPlan: SchemaScopedIntrospectionPlan | null; +} { + if (config === undefined || config === false) { + return { + query: { text: makeIntrospectionQuery() }, + scopedPlan: null, + }; + } + + const options: SchemaScopedIntrospectionOptions = + config === true ? {} : config; + const scopedPlan = makeSchemaScopedIntrospectionPlan( + pgService.schemas ?? [], + options + ); + return { + query: scopedPlan.query, + scopedPlan, + }; +} + +function assertScopedIntrospectionServices( + pgServices: readonly GraphileConfig.PgServiceConfiguration[] | undefined, + options: PgScopedIntrospectionConfig | undefined +): void { + if (options === undefined) return; + if ( + options === null || + typeof options !== 'object' || + Array.isArray(options) + ) { + throw new Error( + 'pgScopedIntrospection must be an object keyed by PostgreSQL service name' + ); + } + + const serviceNames = new Set( + (pgServices ?? []).map((pgService) => pgService.name) + ); + const unknownServiceNames = Object.keys(options).filter( + (serviceName) => !serviceNames.has(serviceName) + ); + if (unknownServiceNames.length > 0) { + throw new Error( + `Schema-scoped introspection configured for unknown PostgreSQL service(s): ${unknownServiceNames.join(', ')}` + ); + } + + for (const [serviceName, config] of Object.entries(options)) { + if ( + config !== true && + config !== false && + (config === null || + typeof config !== 'object' || + Array.isArray(config)) + ) { + throw new Error( + `Schema-scoped introspection configuration for service '${serviceName}' must be true, false, or an options object` + ); + } + } +} + +// 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, + scopedIntrospection: PgScopedIntrospectionConfig | undefined +): Promise { + assertScopedIntrospectionServices(pgServices, scopedIntrospection); + 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, scopedPlan } = getIntrospectionQuery( + pgService, + scopedIntrospection?.[name] + ); + 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, + scopedPlan, + }; + }) + ); +} + +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 ?? []; + const scopedIntrospection = info.options.pgScopedIntrospection; + assertScopedIntrospectionServices(pgServices, scopedIntrospection); + const hasScopedService = Object.values(scopedIntrospection ?? {}).some( + (config) => config === true || (config !== false && config !== undefined) + ); + if (!hasScopedService) { + 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, scopedIntrospection)); + introspectionPromise.then(null, () => { + info.cache.introspectionResultsPromise = null; + }); + + const rawIntrospections = await introspectionPromise; + // Keep the raw result promise for clean gathers. Parsed introspection is + // intentionally rebuilt because gather plugins may mutate their copy. + const introspections = rawIntrospections.map( + ({ pgService, introspectionText, scopedPlan }) => { + const introspection = parseIntrospectionResults(introspectionText); + if (scopedPlan) { + try { + validateSchemaScopedIntrospection(introspection, scopedPlan); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + throw new Error( + `Schema-scoped introspection validation failed for PostgreSQL service '${pgService.name}': ${message}`, + { cause: error } + ); + } + } + 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..4dbf95750e --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/scoped-introspection-query.ts @@ -0,0 +1,843 @@ +/** + * Scoped catalog SQL adapted from Graphile Crystal's + * utils/pg-introspection/src/scopedIntrospection.ts at commit + * 441cef73b0a12ed24343b7f38241af28b6454280, which is MIT licensed + * (Copyright © 2023 Benjie Gillam). This is a static, CNC-owned query + * generator: it does not inspect or rewrite the upstream query at runtime. + */ + +import type { + SchemaScopedIntrospectionOptions, + ScopedCatalogTypes, +} from '@constructive-io/graphql-types'; +import type { Introspection } from 'pg-introspection'; + +export type { + SchemaScopedIntrospectionOptions, + ScopedCatalogTypes, +} from '@constructive-io/graphql-types'; + +export interface SchemaScopedIntrospectionQuery { + text: string; + values: [string[], string[]]; +} + +export interface SchemaScopedIntrospectionScope { + schemas: readonly string[]; + catalogTypes: ScopedCatalogTypes; + capabilityExtensions: readonly string[]; +} + +export interface SchemaScopedIntrospectionPlan { + query: SchemaScopedIntrospectionQuery; + scope: SchemaScopedIntrospectionScope; +} + +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 plan scoped to the requested schemas + * and the transitive object dependencies required by their objects. + */ +export const makeSchemaScopedIntrospectionPlan = ( + schemas: readonly string[], + options: SchemaScopedIntrospectionOptions = {} +): SchemaScopedIntrospectionPlan => { + 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))"; + const query: SchemaScopedIntrospectionQuery = { + 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], + }; + return { + query, + scope: { + schemas: normalized, + catalogTypes, + capabilityExtensions: normalizedCapabilityExtensions, + }, + }; +}; + +/** + * Builds only the query portion of a schema-scoped introspection plan. + * + * Prefer `makeSchemaScopedIntrospectionPlan()` when the results will be + * parsed and validated by this package. + */ +export const makeSchemaScopedIntrospectionQuery = ( + schemas: readonly string[], + options: SchemaScopedIntrospectionOptions = {} +): SchemaScopedIntrospectionQuery => + makeSchemaScopedIntrospectionPlan(schemas, options).query; + +function assertScopedNamespaces( + introspection: Introspection, + requiredSchemas: readonly string[] +): void { + 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 did not find required schema(s): ${missing.join(', ')}` + ); + } +} + +function assertDependencyClosureTypes(introspection: Introspection): void { + const retainedTypeOids = new Set( + introspection.types.map((type) => String(type._id)) + ); + const introspectionLookups = ( + introspection as Introspection & { + _lookups?: { typeById?: Map }; + } + )._lookups; + 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 lookup hydration; the lookup remains available. + const resolves = + retainedTypeOids.has(normalizedOid) || + introspectionLookups?.typeById?.has(normalizedOid) === true; + if (!resolves) { + throw new Error( + `Dependency-closure introspection 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'); + } +} + +/** Validates that an introspection result satisfies its scoped query plan. */ +export function validateSchemaScopedIntrospection( + introspection: Introspection, + plan: SchemaScopedIntrospectionPlan +): void { + assertScopedNamespaces(introspection, plan.scope.schemas); + if (plan.scope.catalogTypes === 'dependency-closure') { + assertDependencyClosureTypes(introspection); + } +} 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 bfaa27b293..1786d07f70 100644 --- a/graphile/graphile-settings/README.md +++ b/graphile/graphile-settings/README.md @@ -183,6 +183,56 @@ 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 schema-scoped +introspection add the independently owned replacement preset and configure the +service by name in the Graphile gather options: + +```typescript +import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection'; +import { ConstructivePreset, makePgService } from 'graphile-settings'; + +const preset = { + extends: [ConstructivePreset, ScopedIntrospectionPreset], + gather: { + pgScopedIntrospection: { + main: true, + }, + }, + pgServices: [ + makePgService({ + connectionString: 'postgres://user:pass@localhost/mydb', + schemas: ['app_public'], + }), + ], +}; +``` + +Use an options object when a service needs a specific catalog policy or +extension capability: + +```typescript +const preset = { + extends: [ConstructivePreset, ScopedIntrospectionPreset], + gather: { + pgScopedIntrospection: { + main: { + catalogTypes: 'dependency-closure', + capabilityExtensions: ['pg_trgm'], + }, + }, + }, +}; +``` + +The map is keyed by the final PostgreSQL service name. `true` enables scoped +defaults, `false` keeps stock introspection, and an omitted service keeps stock +introspection. The Constructive GraphQL server accepts the same gather options +under `graphile.preset.gather` and loads the scoped package only when the map is +configured, unless the replacement preset is already included in `graphile.extends`. + ## Smart Tags Reference Control schema generation with PostgreSQL comments: diff --git a/graphql/env/README.md b/graphql/env/README.md index af82fdf137..e139975ac8 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -44,6 +44,12 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag ### GraphQL Schema - `GRAPHILE_SCHEMA` - Comma-separated list of PostgreSQL schemas to expose +Schema-scoped introspection is configured through the Graphile preset in +`pgpm.json` or runtime options. The map is keyed by the final PostgreSQL +service name: omit a service or set it to `false` for stock introspection, set +it to `true` for scoped defaults, or provide `catalogTypes` and +`capabilityExtensions` explicitly. + ### Feature Flags - `FEATURES_SIMPLE_INFLECTION` - Enable simple inflection plugin - `FEATURES_OPPOSITE_BASE_NAMES` - Enable opposite base names @@ -65,7 +71,11 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`: ```typescript { - graphile: { schema: [] }, + graphile: { + schema: [], + extends: [], + preset: {} + }, features: { simpleInflection: true, oppositeBaseNames: true, @@ -82,6 +92,41 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`: } ``` +For example, this enables scoped introspection with the defaults for the +server's default `main` service: + +```json +{ + "graphile": { + "preset": { + "gather": { + "pgScopedIntrospection": { "main": true } + } + } + } +} +``` + +Advanced options can be supplied when a service needs a specific catalog +policy or extension capability: + +```json +{ + "graphile": { + "preset": { + "gather": { + "pgScopedIntrospection": { + "main": { + "catalogTypes": "dependency-closure", + "capabilityExtensions": ["pg_trgm"] + } + } + } + } + } +} +``` + ## When to Use - Use `@constructive-io/graphql-env` for Constructive applications that need GraphQL/Graphile configuration diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index fa7dd645e8..d5299a9d02 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -138,6 +138,53 @@ describe('getEnvOptions', () => { expect(result.api?.metaSchemas).toEqual(['env_meta', 'override_meta']); }); + it('defaults to the upstream Graphile preset configuration', () => { + const result = getEnvOptions({}, process.cwd(), {}); + + expect(result.graphile).toEqual({ + schema: [], + extends: [], + preset: {} + }); + }); + + it('forwards Graphile preset gather configuration with runtime precedence', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-introspection-') + ); + writeConfig(tempDir, { + graphile: { + preset: { + gather: { + pgScopedIntrospection: { main: true } + } + } + } + }); + + const configured = getEnvOptions({}, tempDir, {}); + expect(configured.graphile?.preset?.gather).toEqual({ + pgScopedIntrospection: { main: true } + }); + + const overridden = getEnvOptions( + { + graphile: { + preset: { + gather: { + pgScopedIntrospection: { main: false } + } + } + } + }, + tempDir, + {} + ); + expect(overridden.graphile?.preset?.gather).toEqual({ + pgScopedIntrospection: { main: false } + }); + }); + it('parses SMS environment variables into typed options', () => { const result = getGraphQLEnvVars({ SMS_PROVIDER: 'devsms', diff --git a/graphql/server/package.json b/graphql/server/package.json index 474c217238..3164b9e886 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..53a19abc85 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts @@ -0,0 +1,225 @@ +import type { GraphileConfig } from 'graphile-config'; +import type { Pool } from 'pg'; + +jest.mock('graphile-settings', () => { + const { makePgService } = jest.requireActual('postgraphile/adaptors/pg'); + return { makePgService }; +}); + +import { makeIntrospectionWiring } from '../graphile-introspection'; + +const pool = {} as Pool; + +describe('Graphile introspection 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).toMatchObject({ + name: 'main', + schemas: ['tenant_a'] + }); + expect(wiring.pgService.pgSettingsForIntrospection).toBeUndefined(); + }); + + it('loads scoped introspection for a resolved gather configuration', async () => { + const scopedPreset: GraphileConfig.Preset = { + disablePlugins: ['PgIntrospectionPlugin'] + }; + const loadScopedPreset = jest.fn(async () => scopedPreset); + + const wiring = await makeIntrospectionWiring( + pool, + ['tenant_a'], + { + preset: { + gather: { + pgScopedIntrospection: { + main: { + catalogTypes: 'dependency-closure', + capabilityExtensions: ['pg_trgm'] + } + } + } + } + }, + loadScopedPreset + ); + + expect(loadScopedPreset).toHaveBeenCalledTimes(1); + expect(wiring.presets).toEqual([scopedPreset]); + expect(wiring.pgService).toMatchObject({ + name: 'main', + schemas: ['tenant_a'] + }); + expect(wiring.pgService.pgSettingsForIntrospection).toBeUndefined(); + }); + + it('uses nested preset precedence before deciding whether to load scoped code', async () => { + const scopedPreset: GraphileConfig.Preset = { + disablePlugins: ['PgIntrospectionPlugin'] + }; + const loadScopedPreset = jest.fn(async () => scopedPreset); + const nestedPreset: GraphileConfig.Preset = { + extends: [ + { + gather: { + pgScopedIntrospection: { main: true } + } + } + ], + gather: { + pgScopedIntrospection: { main: false } + } + }; + + await makeIntrospectionWiring( + pool, + ['tenant_a'], + { + extends: [nestedPreset], + preset: { + gather: { + pgScopedIntrospection: { main: false } + } + } + }, + loadScopedPreset + ); + + // A false entry is still configuration. The replacement must be loaded so + // it can validate unknown service keys instead of silently using stock. + expect(loadScopedPreset).toHaveBeenCalledTimes(1); + }); + + it('loads the replacement for an unknown service entry even when disabled', async () => { + const scopedPreset: GraphileConfig.Preset = { + disablePlugins: ['PgIntrospectionPlugin'] + }; + const loadScopedPreset = jest.fn(async () => scopedPreset); + + await makeIntrospectionWiring( + pool, + ['tenant_a'], + { + preset: { + gather: { + pgScopedIntrospection: { unknown: false } + } + } + }, + loadScopedPreset + ); + + expect(loadScopedPreset).toHaveBeenCalledTimes(1); + }); + + it('loads the replacement so malformed explicit gather values are diagnosed', async () => { + for (const value of [[], null] as const) { + const scopedPreset: GraphileConfig.Preset = { + disablePlugins: ['PgIntrospectionPlugin'] + }; + const loadScopedPreset = jest.fn(async () => scopedPreset); + + await makeIntrospectionWiring( + pool, + ['tenant_a'], + { + preset: { + gather: { + pgScopedIntrospection: value as never + } + } + }, + loadScopedPreset + ); + + expect(loadScopedPreset).toHaveBeenCalledTimes(1); + } + }); + + it('keeps an explicitly supplied local replacement preset without importing it', async () => { + const loadScopedPreset = jest.fn(async () => { + throw new Error('scoped preset should not load'); + }); + const localPreset = { + plugins: [{ name: 'ConstructivePgIntrospectionPlugin' }], + disablePlugins: ['PgIntrospectionPlugin'], + gather: { + pgScopedIntrospection: { main: true } + } + } as GraphileConfig.Preset; + + const wiring = await makeIntrospectionWiring( + pool, + ['tenant_a'], + { extends: [localPreset] }, + loadScopedPreset + ); + + expect(loadScopedPreset).not.toHaveBeenCalled(); + expect(wiring.presets).toEqual([]); + }); + + it('disables stock introspection for a raw local replacement plugin', async () => { + const loadScopedPreset = jest.fn(async () => { + throw new Error('scoped preset should not load'); + }); + const localPlugin = { + // Include the upstream plugin to mirror the core preset that the server + // adds later; the local replacement must disable it atomically. + plugins: [ + { name: 'PgIntrospectionPlugin' }, + { name: 'ConstructivePgIntrospectionPlugin' } + ] + } as GraphileConfig.Preset; + + const wiring = await makeIntrospectionWiring( + pool, + ['tenant_a'], + { extends: [localPlugin] }, + loadScopedPreset + ); + + expect(loadScopedPreset).not.toHaveBeenCalled(); + expect(wiring.presets).toEqual([ + { disablePlugins: ['PgIntrospectionPlugin'] } + ]); + }); + + it.each([ + true, + false, + { catalogTypes: 'dependency-closure' as const } + ])( + 'preserves the configured introspection role for scoped setting %p', + async (setting) => { + const wiring = await makeIntrospectionWiring( + pool, + ['tenant_a'], + { + preset: { + gather: { + pgScopedIntrospection: { main: setting } + } + } + }, + async () => ({}), + 'tenant_introspector' + ); + expect(wiring.pgService.pgSettingsForIntrospection).toEqual({ + role: 'tenant_introspector' + }); + } + ); +}); diff --git a/graphql/server/src/middleware/__tests__/graphile-single-flight.test.ts b/graphql/server/src/middleware/__tests__/graphile-single-flight.test.ts new file mode 100644 index 0000000000..b4affffee9 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-single-flight.test.ts @@ -0,0 +1,110 @@ +const mockCacheGet = jest.fn(); +const mockCacheSet = jest.fn(); +const mockCreateGraphileInstance = jest.fn(); +const mockGetPgPool = jest.fn(); +const mockMakeIntrospectionWiring = jest.fn(); + +jest.mock('graphile-cache', () => ({ + createGraphileInstance: mockCreateGraphileInstance, + graphileCache: { + get: mockCacheGet, + set: mockCacheSet + } +})); + +jest.mock('graphile-settings', () => ({ + createConstructivePreset: jest.fn(() => ({})) +})); + +jest.mock('pg-cache', () => ({ + getPgPool: mockGetPgPool +})); + +jest.mock('../graphile-introspection', () => ({ + makeIntrospectionWiring: mockMakeIntrospectionWiring +})); + +import type { Request, Response } from 'express'; + +import { + clearInFlightMap, + getInFlightCount, + graphile +} from '../graphile'; + +const makeRequest = (): Request => + ({ + requestId: 'request-id', + svc_key: 'service-key', + api: { + dbname: 'database', + anonRole: 'anonymous', + roleName: 'authenticated', + schema: ['app_public'], + databaseId: 'database-id' + }, + get: jest.fn((): undefined => undefined) + }) as unknown as Request; + +describe('graphile single-flight handler creation', () => { + beforeEach(() => { + clearInFlightMap(); + mockCacheGet.mockReset().mockReturnValue(undefined); + mockCacheSet.mockReset(); + mockGetPgPool.mockReset().mockReturnValue({}); + mockCreateGraphileInstance.mockReset().mockResolvedValue({ + handler: jest.fn() + }); + mockMakeIntrospectionWiring.mockReset(); + }); + + it('coalesces requests while asynchronous preset wiring is pending', async () => { + let releaseWiring!: () => void; + const wiringReady = new Promise((resolve) => { + releaseWiring = resolve; + }); + mockMakeIntrospectionWiring.mockImplementation(async () => { + await wiringReady; + return { presets: [], pgService: {} }; + }); + + const middleware = graphile({ + graphile: { + extends: [ + { + gather: { + pgScopedIntrospection: { main: true } + } + } + ], + preset: { + gather: { + pgScopedIntrospection: { main: false } + } + } + } + } as any); + const response = { headersSent: false } as unknown as Response; + const next = jest.fn(); + const first = middleware(makeRequest(), response, next); + + // Let the first request enter the registered creation promise before the + // second request checks the in-flight map. + await Promise.resolve(); + expect(getInFlightCount()).toBe(1); + + const second = middleware(makeRequest(), response, next); + expect(getInFlightCount()).toBe(1); + expect(mockMakeIntrospectionWiring).toHaveBeenCalledTimes(1); + + releaseWiring(); + await Promise.all([first, second]); + + expect(mockCreateGraphileInstance).toHaveBeenCalledTimes(1); + expect(mockCacheSet).toHaveBeenCalledTimes(1); + expect(getInFlightCount()).toBe(0); + expect(mockCreateGraphileInstance.mock.calls[0][0].preset.gather).toEqual({ + pgScopedIntrospection: { main: false } + }); + }); +}); diff --git a/graphql/server/src/middleware/graphile-introspection.ts b/graphql/server/src/middleware/graphile-introspection.ts new file mode 100644 index 0000000000..12c073b846 --- /dev/null +++ b/graphql/server/src/middleware/graphile-introspection.ts @@ -0,0 +1,104 @@ +import type { GraphileOptions } from '@constructive-io/graphql-types'; +import { type GraphileConfig,resolvePreset } from 'graphile-config'; +import { makePgService } from 'graphile-settings'; +import type { Pool } from 'pg'; + +export interface IntrospectionWiring { + presets: GraphileConfig.Preset[]; + pgService: GraphileConfig.PgServiceConfiguration; +} + +export type ScopedIntrospectionPresetLoader = + () => Promise; + +const SCOPED_INTROSPECTION_PLUGIN = 'ConstructivePgIntrospectionPlugin'; + +let scopedIntrospectionPresetPromise: + Promise | undefined; + +const loadScopedIntrospectionPreset = (): Promise => { + scopedIntrospectionPresetPromise ??= + import('graphile-scoped-introspection').then( + ({ ScopedIntrospectionPreset }) => ScopedIntrospectionPreset + ); + return scopedIntrospectionPresetPromise; +}; + +type ResolvedCallerPreset = GraphileConfig.ResolvedPreset | undefined; + +/** + * Resolve caller-provided Graphile configuration before deciding whether the + * optional scoped introspection package is needed. Graphile config applies + * nested `extends` entries before the containing preset, so this preserves the + * same precedence used by the eventual schema build. + */ +export const resolveCallerPreset = ( + graphileOptions: GraphileOptions | undefined +): ResolvedCallerPreset => { + const preset = graphileOptions?.preset; + const presets: GraphileConfig.Preset[] = [ + ...(graphileOptions?.extends ?? []), + ...(preset ? [preset as GraphileConfig.Preset] : []) + ]; + return presets.length > 0 ? resolvePreset({ extends: presets }) : undefined; +}; + +const hasLocalScopedIntrospectionPreset = ( + preset: ResolvedCallerPreset +): boolean => + preset?.plugins.some((plugin) => plugin.name === SCOPED_INTROSPECTION_PLUGIN) ?? + false; + +const hasUpstreamIntrospectionDisabled = ( + preset: ResolvedCallerPreset +): boolean => + preset?.disablePlugins.includes('PgIntrospectionPlugin') ?? false; + +/** + * 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, + introspectionRole?: string +): Promise => { + const pgSettingsForIntrospection = introspectionRole ? { role: introspectionRole } : undefined; + const pgService = makePgService({ pool, schemas, pgSettingsForIntrospection }); + const callerPreset = resolveCallerPreset(graphileOptions); + const scopedConfig = callerPreset?.gather?.pgScopedIntrospection; + const hasScopedConfiguration = scopedConfig !== undefined; + + // An explicitly supplied local preset already installs the replacement + // plugin. Keep that path synchronous and avoid importing the optional + // package a second time. A raw plugin entry may omit the stock plugin's + // disablement, so add that narrow preset to keep the replacement atomic. + if (hasLocalScopedIntrospectionPreset(callerPreset)) { + return { + presets: hasUpstreamIntrospectionDisabled(callerPreset) + ? [] + : [{ disablePlugins: ['PgIntrospectionPlugin'] }], + pgService + }; + } + + if (!hasScopedConfiguration) { + return { + presets: [], + pgService + }; + } + + // Load the replacement whenever the map is supplied, including an empty map, + // entries set to false, or entries keyed by an unknown service. The + // replacement owns validation; leaving it out would cause Graphile's stock + // plugin to silently ignore the gather option in those cases. + const scopedPreset = await loadScopedPreset(); + return { + presets: [scopedPreset], + pgService + }; +}; diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index dca05e19c9..8cd730b31c 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -10,7 +10,7 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; 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 { AuthCookiePlugin } from '../plugins/auth-cookie-plugin'; import { createErrorEventsPlugin } from '../plugins/error-events-plugin'; import { RequestProtectionPlugin } from '../plugins/request-protection-plugin'; import type { DatabaseSettings } from '../types'; +import { makeIntrospectionWiring } from './graphile-introspection'; import { maskError } from './mask-error'; import { observeGraphileBuild } from './observability/graphile-build-stats'; @@ -71,19 +72,29 @@ 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, introspectionRole: string | undefined, + graphileOptions: ConstructiveOptions['graphile'], databaseSettings?: DatabaseSettings, apiId?: string, compute?: ComputeConfig -): GraphileConfig.Preset => { +): Promise => { + const introspection = await makeIntrospectionWiring(pool, schemas, graphileOptions, undefined, introspectionRole); + const configuredPreset = graphileOptions?.preset ?? {}; return { - extends: [createConstructivePreset(databaseSettings)], + ...configuredPreset, + extends: [ + createConstructivePreset(databaseSettings), + ...(graphileOptions?.extends ?? []), + ...(configuredPreset.extends ?? []), + ...introspection.presets + ], plugins: [ + ...(configuredPreset.plugins ?? []), AuthCookiePlugin, RequestProtectionPlugin, createErrorEventsPlugin(pool), @@ -107,20 +118,7 @@ const buildPreset = ( ] : []) ], - pgServices: [ - makePgService({ - pool, - schemas, - // Introspection runs outside any request, so it has no served role to - // inherit: unset, it reads the catalog as whatever role the pool - // connected as (a superuser in most deployments) and the schema - // advertises that role's reach. Naming the role keeps schema shape - // tied to a bounded role's grants. - ...(introspectionRole && { - pgSettingsForIntrospection: { role: introspectionRole } - }) - }) - ], + pgServices: [introspection.pgService], grafserv: { graphqlPath: '/graphql', graphiqlPath: '/graphiql', @@ -356,31 +354,35 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // properly, preventing leaked connections during database teardown. const pool = getPgPool(pgConfig); - // 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, - opts.api?.introspectionRole, - api.databaseSettings, - api.apiId, - compute - ); - const creationPromise = observeGraphileBuild( - { - cacheKey: key, - serviceKey: key, - databaseId: api.databaseId ?? null - }, - () => createGraphileInstance({ - preset, - cacheKey: key, - enableRealtime: api.databaseSettings?.enableRealtime - }), - { enabled: observabilityEnabled } - ); + // Register the creation promise before any asynchronous preset work so + // concurrent requests coalesce while optional plugin modules load. + const creationPromise = (async () => { + const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; + const preset = await buildPreset( + pool, + schema || [], + anonRole, + roleName, + opts.api?.introspectionRole, + opts.graphile, + api.databaseSettings, + api.apiId, + compute + ); + return observeGraphileBuild( + { + cacheKey: key, + serviceKey: key, + databaseId: api.databaseId ?? null + }, + () => createGraphileInstance({ + preset, + cacheKey: key, + enableRealtime: api.databaseSettings?.enableRealtime + }), + { enabled: observabilityEnabled } + ); + })(); creating.set(key, creationPromise); try { diff --git a/graphql/server/src/middleware/types.ts b/graphql/server/src/middleware/types.ts index 4eec6e8b60..2afecf348b 100644 --- a/graphql/server/src/middleware/types.ts +++ b/graphql/server/src/middleware/types.ts @@ -1,19 +1,6 @@ -import type { RequestProtection } from '@constructive-io/express-context'; +import type { ApiStructure, ConstructiveAPIToken, RequestProtection } 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; - root_session_id?: string; - parent_session_id?: string; - intent?: 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 cbbf6cea76..38ddd30b29 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -1,5 +1,48 @@ import type { GraphileConfig } from 'graphile-config'; +export type ScopedCatalogTypes = 'all' | 'dependency-closure'; + +/** Options for schema-scoped PostgreSQL catalog introspection. */ +export interface SchemaScopedIntrospectionOptions { + /** Retain all catalog types, or only the transitive dependency closure. */ + catalogTypes?: ScopedCatalogTypes; + /** Extensions whose optional capability metadata should be retained. */ + capabilityExtensions?: readonly string[]; +} + +/** Per-service schema-scoped introspection configuration. */ +export type PgScopedIntrospectionServiceConfig = + | boolean + | SchemaScopedIntrospectionOptions; + +/** Schema-scoped introspection configuration keyed by PostgreSQL service name. */ +export type PgScopedIntrospectionConfig = Readonly< + Record +>; + +declare global { + namespace GraphileBuild { + interface GatherOptions { + /** + * Schema-scoped introspection options keyed by PostgreSQL service name. + * `true` enables defaults, `false` keeps stock introspection, and an + * object customizes the scoped query. Services without an entry keep + * stock introspection. + */ + pgScopedIntrospection?: PgScopedIntrospectionConfig; + } + } + + // Keep the public preset type usable by graphql-types consumers that do not + // import graphile-build themselves. graphile-build declares the same field, + // so this merges with its richer preset declaration when it is present. + namespace GraphileConfig { + interface Preset { + gather?: GraphileBuild.GatherOptions; + } + } +} + /** * PostGraphile/Graphile v5 configuration */ diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index 895604e137..f6eead41db 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -5,7 +5,12 @@ export { graphileDefaults, graphileFeatureDefaults, GraphileFeatureOptions, - GraphileOptions} from './graphile'; + GraphileOptions, + PgScopedIntrospectionConfig, + PgScopedIntrospectionServiceConfig, + SchemaScopedIntrospectionOptions, + ScopedCatalogTypes, +} from './graphile'; // Export Constructive combined types export { diff --git a/packages/perf-harness/README.md b/packages/perf-harness/README.md index ff22327cee..3afedea3b3 100644 --- a/packages/perf-harness/README.md +++ b/packages/perf-harness/README.md @@ -80,3 +80,29 @@ may be visible to other local processes. The PostgreSQL fixture command only creates a previously absent schema whose name starts with `cperf_`; it never drops or replaces schemas. + +## Scoped introspection comparison + +`makeScopedIntrospectionSuite({ schemas })` compares stock introspection with +`gather.pgScopedIntrospection.main: true` using `scoped-introspection-worker.js`. +Both cases use the same upstream service factory and its default session settings. +The scoped case uses the copied CNC plugin and its default `catalogTypes: 'all'`; +there is no legacy mode, dependency-schema allowlist, or performance-only tuning. +The stock case does not load the scoped plugin. + +Pass an optional `runtimeCheck: { query, expectedData }` to the suite to verify +actual table, relation, or function results after each build. A result mismatch +fails the sample. Without this option, the worker performs the minimal +`{ __typename }` smoke check. Schema hash equivalence is checked separately by +the runner. Service release completes before the worker reports success. + +For performance comparisons, keep target schemas fixed while increasing unrelated +catalog objects, discard a warm-up pair, and use repeated fresh-process samples +with the runner's seeded case ordering. Fresh Node processes do not imply cold +PostgreSQL caches. Report medians and sample ranges together with PostgreSQL/Node +versions and catalog sizes; runtime validation and process startup are outside +`buildMs`. `processPeakRss` is the worker peak measured after runtime validation, +before service release. + +The [default scoped comparison](benchmarks/scoped-introspection.md) includes a +reproduction command, measured results, and the individual timing/memory samples. 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/__tests__/scoped-introspection-worker.test.ts b/packages/perf-harness/__tests__/scoped-introspection-worker.test.ts new file mode 100644 index 0000000000..7d71db63bb --- /dev/null +++ b/packages/perf-harness/__tests__/scoped-introspection-worker.test.ts @@ -0,0 +1,345 @@ +import { makeSchema } from 'graphile-build'; +import { buildSchema, type GraphQLSchema } from 'graphql'; +import { makePgService } from 'postgraphile/adaptors/pg'; +import { execute } from 'postgraphile/grafast'; + +import { WORKER_RESULT_PREFIX } from '../src/process'; +import { runScopedIntrospectionWorker as runWorker } from '../src/scoped-introspection-worker'; +import type { WorkerResult } from '../src/types'; + +jest.mock('graphile-build', () => ({ + defaultPreset: {}, + makeSchema: jest.fn(), +})); + +jest.mock('graphile-build-pg', () => ({ defaultPreset: {} })); +jest.mock('graphile-scoped-introspection', () => ({ + ScopedIntrospectionPreset: { plugins: [] }, +})); +jest.mock('postgraphile/@dataplan/pg', () => ({ + withPgClientFromPgService: jest.fn(), +})); +jest.mock('postgraphile/grafast', () => ({ execute: jest.fn() })); + +jest.mock('postgraphile/adaptors/pg', () => ({ + makePgService: jest.fn(), +})); + +const loadScopedPreset = jest.fn(async () => ({ plugins: [] })); +const runScopedIntrospectionWorker = (args: readonly string[]) => + runWorker(args, loadScopedPreset); + +const mockedMakeSchema = jest.mocked(makeSchema); +const mockedMakePgService = jest.mocked(makePgService); + +const databaseUrl = 'postgres://secret@example.test/benchmark'; +const encodedConfig = (workerConfig: unknown): string => + Buffer.from(JSON.stringify({ caseName: 'stock', workerConfig })).toString( + 'base64url' + ); +const workerArgs = (workerConfig: unknown): string[] => [ + '--database-url', + databaseUrl, + '--worker-config', + encodedConfig(workerConfig), +]; + +interface MockService { + release: jest.Mock, []>; +} + +const createService = (): MockService => ({ + release: jest.fn().mockResolvedValue(undefined), +}); + +const readResult = (write: jest.Mock): WorkerResult => { + const output = write.mock.calls[0]?.[0]; + expect(typeof output).toBe('string'); + expect(write).toHaveBeenCalledTimes(1); + return JSON.parse( + (output as string).slice(WORKER_RESULT_PREFIX.length) + ) as WorkerResult; +}; + +describe('scoped comparison worker lifecycle', () => { + let originalExitCode: typeof process.exitCode; + let originalGcDescriptor: PropertyDescriptor | undefined; + let output: jest.Mock; + let service: MockService; + + beforeEach(() => { + originalExitCode = process.exitCode; + originalGcDescriptor = Object.getOwnPropertyDescriptor(global, 'gc'); + Object.defineProperty(global, 'gc', { + configurable: true, + value: jest.fn(), + writable: true, + }); + output = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) as unknown as jest.Mock; + service = createService(); + jest.mocked(execute).mockResolvedValue({ data: { __typename: 'Query' } }); + // Schema construction is mocked separately; this service stub owns only + // the external release hook exercised by these lifecycle tests. + mockedMakePgService.mockReturnValue( + service as unknown as ReturnType + ); + mockedMakeSchema.mockResolvedValue({ + schema: buildSchema('type Query { stock: String }'), + resolvedPreset: {}, + } as { schema: GraphQLSchema; resolvedPreset: never }); + }); + + afterEach(() => { + process.exitCode = originalExitCode; + jest.restoreAllMocks(); + jest.clearAllMocks(); + if (originalGcDescriptor) { + Object.defineProperty(global, 'gc', originalGcDescriptor); + } else { + Reflect.deleteProperty(global, 'gc'); + } + }); + + test('writes exactly once after deferred cleanup completes', async () => { + let finishRelease: (() => void) | undefined; + service.release.mockImplementation( + () => + new Promise((resolve) => { + finishRelease = resolve; + }) + ); + + const running = runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + await new Promise((resolve) => setImmediate(resolve)); + + expect(service.release).toHaveBeenCalledTimes(1); + expect(output).not.toHaveBeenCalled(); + finishRelease?.(); + await running; + + expect(readResult(output)).toMatchObject({ + status: 'ok', + caseName: 'stock', + }); + }); + + test('reports one successful result after releasing the service', async () => { + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + + expect(service.release).toHaveBeenCalledTimes(1); + expect(readResult(output)).toMatchObject({ + status: 'ok', + caseName: 'stock', + runtimeVerified: true, + }); + expect(global.gc).toHaveBeenCalledTimes(6); + }); + + test('reports a release failure once', async () => { + service.release.mockRejectedValue(new Error('release failed')); + + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + + expect(readResult(output)).toMatchObject({ + status: 'error', + error: 'release failed', + }); + expect(process.exitCode).toBe(1); + }); + + test('reports a measurement failure after cleanup', async () => { + mockedMakeSchema.mockRejectedValue(new Error('measurement failed')); + + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + + expect(service.release).toHaveBeenCalledTimes(1); + expect(readResult(output)).toMatchObject({ + status: 'error', + error: 'measurement failed', + }); + }); + + test('keeps the primary diagnostic before a release failure', async () => { + mockedMakeSchema.mockRejectedValue( + new Error(`measurement failed: ${databaseUrl}`) + ); + service.release.mockRejectedValue( + new Error(`release failed: ${databaseUrl}`) + ); + + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + + const result = readResult(output); + expect(result).toMatchObject({ status: 'error' }); + expect((result as Extract).error).toBe( + 'measurement failed: ; release failed: ' + ); + expect(JSON.stringify(result)).not.toContain(databaseUrl); + }); + + test.each([ + ['undefined', undefined], + ['null', null], + ['false', false], + ['zero', 0], + ['empty string', ''], + ])('preserves a %s thrown value', async (_name, failure) => { + mockedMakeSchema.mockImplementation(async () => { + throw failure; + }); + + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + + const result = readResult(output); + expect(result.status).toBe('error'); + expect((result as Extract).error).toBe( + String(failure) + ); + }); + + test('does not call hostile object toString or leak it', async () => { + const hostile = { + get toString(): never { + throw new Error(databaseUrl); + }, + }; + mockedMakeSchema.mockImplementation(async () => { + throw hostile; + }); + + await expect( + runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ) + ).resolves.toBeUndefined(); + + const result = readResult(output); + expect(result).toMatchObject({ status: 'error', error: 'unknown error' }); + expect(JSON.stringify(result)).not.toContain(databaseUrl); + }); + + test('handles an Error with a throwing message getter', async () => { + const hostile = Object.create(Error.prototype) as Error; + Object.defineProperty(hostile, 'message', { + configurable: true, + get: () => { + throw new Error(databaseUrl); + }, + }); + mockedMakeSchema.mockImplementation(async () => { + throw hostile; + }); + + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + + expect(readResult(output)).toMatchObject({ + status: 'error', + error: 'unknown error', + }); + expect(output.mock.calls.join(' ')).not.toContain(databaseUrl); + }); + + test('preserves a falsy cleanup rejection', async () => { + service.release.mockRejectedValue(false); + + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + + expect(readResult(output)).toMatchObject({ + status: 'error', + error: 'false', + }); + }); + + test('redacts the full database URL in both failure diagnostics', async () => { + mockedMakeSchema.mockRejectedValue( + new Error(`could not connect to ${databaseUrl}`) + ); + service.release.mockRejectedValue( + new Error(`could not release ${databaseUrl}`) + ); + + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + + const result = readResult(output); + expect(result).toMatchObject({ + status: 'error', + error: + 'could not connect to ; could not release ', + }); + expect(JSON.stringify(result)).not.toContain(databaseUrl); + }); + + test('validates config before allocating a service', async () => { + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: [] }) + ); + + expect(mockedMakePgService).not.toHaveBeenCalled(); + expect(output).toHaveBeenCalledTimes(1); + expect(readResult(output)).toMatchObject({ + status: 'error', + error: 'scoped introspection worker requires a non-empty schemas array', + }); + }); + test('enables scoped defaults only through the named gather configuration', async () => { + await runScopedIntrospectionWorker( + workerArgs({ mode: 'scoped', schemas: ['public'] }) + ); + expect(mockedMakePgService).toHaveBeenCalledWith({ + name: 'main', + connectionString: databaseUrl, + schemas: ['public'], + pubsub: false, + }); + expect(mockedMakeSchema).toHaveBeenCalledWith( + expect.objectContaining({ + gather: { pgScopedIntrospection: { main: true } }, + }) + ); + expect(readResult(output).status).toBe('ok'); + }); + + test('stock does not enable the scoped gather option', async () => { + await runScopedIntrospectionWorker( + workerArgs({ mode: 'stock', schemas: ['public'] }) + ); + expect(mockedMakeSchema.mock.calls[0][0]).not.toHaveProperty('gather'); + expect(loadScopedPreset).not.toHaveBeenCalled(); + }); + + test('rejects incorrect data from the configured runtime query', async () => { + jest.mocked(execute).mockResolvedValue({ data: { account: null } }); + await runScopedIntrospectionWorker( + workerArgs({ + mode: 'scoped', + schemas: ['public'], + runtimeCheck: { + query: '{ account { id } }', + expectedData: { account: { id: '1' } }, + }, + }) + ); + expect(readResult(output)).toMatchObject({ status: 'error' }); + expect(service.release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/perf-harness/benchmarks/scoped-introspection-environment.json b/packages/perf-harness/benchmarks/scoped-introspection-environment.json new file mode 100644 index 0000000000..5741b80b1e --- /dev/null +++ b/packages/perf-harness/benchmarks/scoped-introspection-environment.json @@ -0,0 +1,76 @@ +{ + "capturedAt": "2026-09-10T03:08:22.678Z", + "node": "v24.20.0", + "platform": "linux", + "architecture": "x64", + "cpu": "Intel(R) Xeon(R) Gold 6152 CPU @ 2.10GHz", + "logicalCpus": 3, + "totalMemory": 4105740288, + "loadAverageBefore": [ + 6.24, + 5.67, + 5.14 + ], + "adaptorSessionSettings": [ + { + "name": "jit", + "setting": "on", + "unit": null + }, + { + "name": "jit_above_cost", + "setting": "100000", + "unit": null + }, + { + "name": "jit_inline_above_cost", + "setting": "500000", + "unit": null + }, + { + "name": "jit_optimize_above_cost", + "setting": "-1", + "unit": null + }, + { + "name": "max_parallel_workers_per_gather", + "setting": "2", + "unit": null + }, + { + "name": "statement_timeout", + "setting": "0", + "unit": "ms" + }, + { + "name": "work_mem", + "setting": "4096", + "unit": "kB" + } + ], + "postgresVersion": "18.6", + "mainBase": "e008e936", + "crystalSource": "441cef73b0a12ed24343b7f38241af28b6454280", + "loadAverageAfter": { + "0": [ + 5.57, + 5.6, + 5.16 + ], + "10": [ + 4.82, + 5.37, + 5.12 + ], + "50": [ + 5.25, + 5.3, + 5.12 + ] + }, + "compiledCodeSha256": { + "graphile/graphile-scoped-introspection/dist/plugin.js": "68bb93b9e373b916b5453d026f92fc9f239ac4a29c5fe4cbde05e4b57324fa0f", + "graphile/graphile-scoped-introspection/dist/scoped-introspection-query.js": "82f61359eec84444bd2d425274740385364a7de258ba7896d62114dbf48d81fe", + "packages/perf-harness/dist/scoped-introspection-worker.js": "929a29ab9d3b061867a976d45a35b59da9e5183923457c64ee74fb5551a9b999" + } +} diff --git a/packages/perf-harness/benchmarks/scoped-introspection-samples.csv b/packages/perf-harness/benchmarks/scoped-introspection-samples.csv new file mode 100644 index 0000000000..6916a38629 --- /dev/null +++ b/packages/perf-harness/benchmarks/scoped-introspection-samples.csv @@ -0,0 +1,43 @@ +unrelated_schemas,case,repetition,pid,build_ms,retained_heap_bytes,peak_rss_bytes,schema_sha256,validation_passed +0,stock,1,1314792,1117.40321,27647640,124674048,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,scoped,1,1314841,4339.743367,27753216,124731392,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,scoped,2,1314899,4495.784806000001,27752816,124575744,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,stock,2,1315037,1214.189011,27647016,124628992,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,stock,3,1315054,1204.0237589999997,27647000,124841984,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,scoped,3,1315088,4166.057228,27751072,124690432,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,stock,4,1315153,1322.88216,27646232,124727296,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,scoped,4,1315179,4199.1300599999995,27752768,124866560,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,scoped,5,1315291,4433.801736,27752200,124694528,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,stock,5,1315404,1195.751804,27648016,124907520,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,stock,6,1315442,1254.9538679999998,27648208,124628992,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,scoped,6,1315459,3745.3831609999997,27749712,125816832,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,scoped,7,1315519,4056.742956,27752600,125448192,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +0,stock,7,1315571,1120.6722579999998,27647560,125673472,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,stock,1,1315792,2543.90956,43804168,195346432,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,scoped,1,1315875,4541.515913,27750280,125022208,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,scoped,2,1315949,3940.2051029999993,27752320,124772352,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,stock,2,1315989,2275.408082,43825144,174792704,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,stock,3,1316054,2206.7710420000003,43828808,180027392,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,scoped,3,1316096,4009.055742,27748664,124977152,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,stock,4,1316182,2393.0753669999995,43824720,180187136,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,scoped,4,1316251,4023.3548249999994,27750816,125210624,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,scoped,5,1316294,4580.057837,27754144,125186048,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,stock,5,1316338,2394.544593,43800568,196788224,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,stock,6,1316418,2630.196468,43824760,196124672,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,scoped,6,1316436,3987.7613659999997,27753080,125018112,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,scoped,7,1316499,4745.523598,27752296,125063168,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +10,stock,7,1316628,2593.1881860000003,43823160,195518464,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,stock,1,1317266,7757.512533,109930952,303149056,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,scoped,1,1317382,4675.1935969999995,27752704,126042112,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,scoped,2,1317455,4206.750878,27754672,125587456,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,stock,2,1317536,6911.457863,108211872,341778432,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,stock,3,1317627,6630.055281999999,108211632,332468224,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,scoped,3,1317716,4127.204637,27752832,125562880,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,stock,4,1317739,6539.152844,108198952,337833984,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,scoped,4,1317880,4525.434472999999,27751136,126095360,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,scoped,5,1317957,4266.888,27752680,125861888,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,stock,5,1318017,6457.588834,109907680,305082368,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,stock,6,1318108,7062.589355000001,109909088,307884032,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,scoped,6,1318173,4152.237724,27753064,126033920,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,scoped,7,1318247,4548.305887,27754896,125759488,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True +50,stock,7,1318327,6383.482334,108250840,328863744,3b9dab5243611a57da1c0721ea3bc1aba455051124e43549e3c326178b3e97c1,True diff --git a/packages/perf-harness/benchmarks/scoped-introspection.cjs b/packages/perf-harness/benchmarks/scoped-introspection.cjs new file mode 100644 index 0000000000..b607771a93 --- /dev/null +++ b/packages/perf-harness/benchmarks/scoped-introspection.cjs @@ -0,0 +1,75 @@ +// Reproduce the default stock/scoped catalog-scaling comparison. +// Run after building perf-harness, graphile-scoped-introspection and pgsql-test. +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const root = process.argv[2]; +const output = process.argv[3]; +if (!root || !output) throw new Error('usage: node scoped-introspection.cjs ABSOLUTE_WORKTREE OUTPUT'); +const { createRequire } = require('node:module'); +const req = createRequire(path.join(root, 'packages/perf-harness/package.json')); +const { getConnections } = require(path.join(root, 'postgres/pgsql-test/dist')); +const { buildConnectionString } = require(path.join(root, 'postgres/pg-cache/dist')); +const { prepareFixture, makeScopedIntrospectionSuite, runBenchmarkSuite } = require(path.join(root, 'packages/perf-harness/dist')); +(async () => { + fs.mkdirSync(output, { recursive: true }); + const conn = await getConnections({}, []); + try { + const c = conn.pg.config; + const databaseUrl = buildConnectionString(c.user, c.password, c.host, c.port, c.database); + const fixture = await prepareFixture({ databaseUrl, schema: 'cperf_target', tables: 8 }); + await conn.pg.query(`insert into cperf_target.account (external_id, name) values ('11111111-1111-1111-1111-111111111111', 'benchmark account'); + insert into cperf_target.entity_1 (account_id, title) values (1, 'benchmark entity');`); + const suite = makeScopedIntrospectionSuite({ + schemas: ['cperf_target'], + runtimeCheck: { + query: '{ allAccounts { nodes { name } } allEntity1S { nodes { title accountByAccountId { name } } } entity1ByAccount(requestedAccountId: "1") { nodes { title } } }', + expectedData: { allAccounts: { nodes: [{ name: 'benchmark account' }] }, allEntity1S: { nodes: [{ title: 'benchmark entity', accountByAccountId: { name: 'benchmark account' } }] }, entity1ByAccount: { nodes: [{ title: 'benchmark entity' }] } }, + }, + }); + const worker = path.join(root, 'packages/perf-harness/dist/scoped-introspection-worker.js'); + const environment = { + capturedAt: new Date().toISOString(), + node: process.version, platform: process.platform, architecture: process.arch, + cpu: os.cpus()[0].model, logicalCpus: os.cpus().length, totalMemory: os.totalmem(), + fixture, loadAverageBefore: os.loadavg(), + postgresSettings: (await conn.pg.query("select name, setting, unit from pg_settings where name in ('jit','jit_above_cost','work_mem','statement_timeout','max_parallel_workers_per_gather','shared_buffers') order by name")).rows, + method: 'Fresh Node process per sample, default PostGraphile adaptor session settings for both cases, database caches warmed by discarded pair at each scale, seeded interleaving, no historical implementation or catalogTypes override.', + }; + fs.writeFileSync(path.join(output, 'environment.json'), JSON.stringify(environment, null, 2)); + let previous = 0; + for (const schemas of [0, 10, 50]) { + for (let i = previous; i < schemas; i++) await prepareFixture({ databaseUrl, schema: `cperf_noise_${i}`, tables: 20 }); + previous = schemas; + const counts = await conn.pg.query(`select (select count(*)::int from pg_namespace where nspname like 'cperf_%') as schemas, (select count(*)::int from pg_class c join pg_namespace n on n.oid=c.relnamespace where n.nspname like 'cperf_%' and c.relkind='r') as tables, (select count(*)::int from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname like 'cperf_%') as functions`); + const options = { databaseUrl, repetitions: 1, seed: 20260910, order: null, workerTimeoutMs: 300000 }; + const warm = await runBenchmarkSuite(suite, options, worker); + fs.writeFileSync(path.join(output, `warmup-${schemas}.json`), JSON.stringify(warm, null, 2)); + if (!warm.validation.allRunsSucceeded || !warm.validation.schemaGroupsEquivalent) throw new Error(JSON.stringify(warm.validation)); + const report = await runBenchmarkSuite(suite, { ...options, repetitions: 7 }, worker); + report.catalog = counts.rows[0]; + report.loadAverageAfter = os.loadavg(); + fs.writeFileSync(path.join(output, `report-${schemas}.json`), JSON.stringify(report, null, 2)); + if (!report.validation.allRunsSucceeded || !report.validation.schemaGroupsEquivalent) throw new Error(JSON.stringify(report.validation)); + console.log(JSON.stringify({ unrelatedSchemas: schemas, catalog: counts.rows[0], validation: report.validation, medians: Object.fromEntries(Object.entries(report.summaries).map(([k,v]) => [k, { buildMs: v.buildMs.median, heapMiB: v.heapUsedAfterBuild.median/1048576, peakRssMiB: v.processPeakRss.median/1048576 }])) })); + } + // SQL-only diagnostics after measured samples, through the same default + // PostGraphile adaptor as the workers (including its built-in JIT setting). + const stockQuery = req('postgraphile/graphile-build-pg/pg-introspection').makeIntrospectionQuery(); + const scopedQuery = req('graphile-scoped-introspection').makeSchemaScopedIntrospectionPlan(['cperf_target']).query; + const { makePgService } = req('postgraphile/adaptors/pg'); + const { withPgClientFromPgService } = req('postgraphile/@dataplan/pg'); + const service = makePgService({ name: 'main', connectionString: databaseUrl, schemas: ['cperf_target'], pubsub: false }); + try { + await withPgClientFromPgService(service, null, async client => { + environment.adaptorSessionSettings = (await client.query({ text: "select name, setting, unit from pg_settings where name in ('jit','jit_above_cost','jit_inline_above_cost','jit_optimize_above_cost','work_mem','statement_timeout','max_parallel_workers_per_gather') order by name" })).rows; + fs.writeFileSync(path.join(output, 'environment.json'), JSON.stringify(environment, null, 2)); + const plans = {}; + for (const [name, query] of [['stock', { text: stockQuery, values: [] }], ['scoped', scopedQuery]]) { + plans[name] = (await client.query({ text: 'explain (analyze, buffers, format json) ' + query.text, values: query.values })).rows[0]['QUERY PLAN']; + } + fs.writeFileSync(path.join(output, 'plans-default.json'), JSON.stringify(plans, null, 2)); + }); + } finally { await service.release(); } + } finally { await conn.teardown(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/packages/perf-harness/benchmarks/scoped-introspection.md b/packages/perf-harness/benchmarks/scoped-introspection.md new file mode 100644 index 0000000000..d198e2b3ab --- /dev/null +++ b/packages/perf-harness/benchmarks/scoped-introspection.md @@ -0,0 +1,55 @@ +# Default scoped introspection comparison + +Schema-scoped introspection reduces catalog work for a small API within a large database. This experiment compares stock with the copied CNC plugin enabled by `gather.pgScopedIntrospection.main: true` on the main-based implementation. + +## Method + +- The target schema stays fixed at 9 tables and 8 functions. Each unrelated schema adds 21 tables and 20 functions. +- Each scale discards one stock/scoped warm-up pair, then measures seven fresh Node processes per case, interleaved with seed `20260910`. Database caches are warm. +- Both cases use the upstream `makePgService` and its default session settings. No `catalogTypes` override or scoped-only GUC is applied. +- `buildMs` measures `makeSchema` only; module loading, process startup, runtime verification, and service release are outside that interval. +- Every sample must pass actual table, relation, and function queries and schema hash equivalence. Retained heap is measured after validation and forced GC; peak RSS is read at that point, before service release. +- This is a shared host with background load and a synthetic fixture. Medians and ranges describe these samples and do not establish a production speedup. + +Environment: Node v24.20.0, PostgreSQL 18.6, 3 logical CPUs, 3.82 GiB RAM. Full parameters are in [environment metadata](scoped-introspection-environment.json). + +## Results + +| Unrelated schemas / tables | Stock build | Scoped build | Build change | Stock / scoped retained heap | Stock / scoped peak RSS | +|---|---:|---:|---:|---:|---:| +| 0 / 0 | 1.204 s | 4.199 s | +248.8% | 26.4 / 26.5 MiB | 118.9 / 119.0 MiB | +| 10 / 210 | 2.395 s | 4.023 s | +68.0% | 41.8 / 26.5 MiB | 186.3 / 119.2 MiB | +| 50 / 1050 | 6.630 s | 4.267 s | -35.6% | 103.2 / 26.5 MiB | 313.6 / 120.0 MiB | + +All 42 measured samples passed validation and used distinct PIDs. Individual measurements are in [samples](scoped-introspection-samples.csv). + +| Unrelated schemas | Stock build range | Scoped build range | +|---|---:|---:| +| 0 | 1.117–1.323 s | 3.745–4.496 s | +| 10 | 2.207–2.630 s | 3.940–4.746 s | +| 50 | 6.383–7.758 s | 4.127–4.675 s | + +The default scoped query is slower in the two smaller catalogs and faster in the largest catalog. It is therefore an opt-in with workload-dependent benefits. This comparison contains no historical implementation and does not quantify a change relative to the previous PR. + +## SQL diagnostics + +After measurement, the script runs one `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` per query through the same default adaptor. These diagnostic timings are not included in the build medians. + +- stock: SQL execution 3.339 s; no JIT reported. +- scoped: SQL execution 3.102 s; JIT 815 functions, 2.812 s total. + +The adaptor sets `jit_optimize_above_cost = -1` for both cases. Scoped SQL still triggers JIT, providing an observable source of fixed overhead; this single diagnostic does not isolate every contributor to build time. + +## Reproduce + +Use the repository development instructions to load a test-admin PostgreSQL connection through the standard `PG*` environment. The runner creates a temporary database through `pgsql-test` and cleans it up. + +```sh +pnpm install --frozen-lockfile +pnpm -r --filter '@constructive-io/perf-harness...' --filter 'pgsql-test...' run build +node packages/perf-harness/benchmarks/scoped-introspection.cjs "$PWD" /tmp/cnc-scoped-results +``` + +The output directory includes full reports, discarded warm-ups, database/session metadata, and SQL plans. The committed CSV and JSON contain only selected non-secret measurements and environment data. + +The copied query comes from [Crystal #2](https://github.com/constructive-io/crystal/pull/2), commit `441cef73b0a12ed24343b7f38241af28b6454280`. These measurements were taken after integration onto Constructive main `e008e936`; compiled-code hashes are included in the environment metadata. diff --git a/packages/perf-harness/package.json b/packages/perf-harness/package.json index 094465062b..9c5f5c6795 100644 --- a/packages/perf-harness/package.json +++ b/packages/perf-harness/package.json @@ -21,6 +21,7 @@ "graphile-build": "5.1.1", "graphile-build-pg": "5.1.3", "graphile-config": "1.1.0", + "graphile-scoped-introspection": "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 3693bc592e..0a7fea3710 100644 --- a/packages/perf-harness/src/index.ts +++ b/packages/perf-harness/src/index.ts @@ -4,4 +4,5 @@ export * from './process'; export * from './report'; export * from './run'; export * from './schedule'; +export * from './scoped-introspection-suite'; export * from './types'; 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..593dab926e --- /dev/null +++ b/packages/perf-harness/src/scoped-introspection-suite.ts @@ -0,0 +1,33 @@ +import type { BenchmarkSuiteDefinition, JsonValue } from './types'; + +export interface ScopedIntrospectionSuiteOptions { + schemas: string[]; + runtimeCheck?: { query: string; expectedData: JsonValue }; +} + +/** 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, + ...(options.runtimeCheck ? { runtimeCheck: options.runtimeCheck } : {}), + }, + expectedSchemaGroup: 'introspection-equivalence', + }, + { + name: 'scoped', + workerConfig: { + mode: 'scoped', + schemas: options.schemas, + ...(options.runtimeCheck ? { runtimeCheck: options.runtimeCheck } : {}), + }, + 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..43b0d76b6d --- /dev/null +++ b/packages/perf-harness/src/scoped-introspection-worker.ts @@ -0,0 +1,197 @@ +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; + +import { + defaultPreset as graphileBuildPreset, + makeSchema, +} from 'graphile-build'; +import { defaultPreset as graphileBuildPgPreset } from 'graphile-build-pg'; +import type { GraphileConfig } from 'graphile-config'; +import { lexicographicSortSchema, parse, printSchema } from 'graphql'; +import { withPgClientFromPgService } from 'postgraphile/@dataplan/pg'; +import { makePgService } from 'postgraphile/adaptors/pg'; +import { execute } from 'postgraphile/grafast'; + +import { measureBenchmarkCase } from './metrics'; +import { + parseWorkerProcessArgs, + redactSecret, + writeWorkerResult, +} from './process'; +import type { JsonValue, SuccessfulWorkerResult, WorkerResult } from './types'; + +interface ScopedConfig { + mode: 'stock' | 'scoped'; + schemas: string[]; + runtimeCheck?: { query: string; expectedData: JsonValue }; +} + +const validateConfig = (value: unknown): ScopedConfig => { + const config = value as Partial | null; + if (config?.mode !== 'stock' && config?.mode !== 'scoped') { + throw new Error( + 'scoped introspection worker requires stock or scoped mode' + ); + } + const { schemas, runtimeCheck } = config; + if ( + !Array.isArray(schemas) || + schemas.length === 0 || + schemas.some((schema) => typeof schema !== 'string' || schema.length === 0) + ) { + throw new Error( + 'scoped introspection worker requires a non-empty schemas array' + ); + } + if ( + runtimeCheck !== undefined && + (runtimeCheck === null || + typeof runtimeCheck.query !== 'string' || + runtimeCheck.query.length === 0 || + runtimeCheck.expectedData === undefined) + ) { + throw new Error('runtimeCheck requires a query and expectedData'); + } + return { mode: config.mode, schemas, runtimeCheck }; +}; + +const failureToText = (failure: unknown): string => { + try { + if (failure instanceof Error) { + const message: unknown = failure.message; + return typeof message === 'string' ? message : 'unknown error'; + } + if ( + failure === null || + (typeof failure !== 'object' && typeof failure !== 'function') + ) { + return String(failure); + } + } catch { + // A hostile proxy or Error.message getter must not prevent reporting. + } + return 'unknown error'; +}; + +export const runScopedIntrospectionWorker = async ( + args: readonly string[] = process.argv.slice(2), + loadScopedPreset: () => Promise = async () => + (await import('graphile-scoped-introspection')).ScopedIntrospectionPreset +): Promise => { + let databaseUrl = ''; + let caseName = 'unknown'; + let service: ReturnType | undefined; + let primaryCaptured = false; + let primaryFailure: unknown; + let cleanupCaptured = false; + let cleanupFailure: unknown; + let successfulResult: SuccessfulWorkerResult | undefined; + + try { + const workerArgs = parseWorkerProcessArgs(args); + databaseUrl = workerArgs.databaseUrl; + const { envelope } = workerArgs; + caseName = envelope.caseName; + const config = validateConfig(envelope.workerConfig); + service = makePgService({ + name: 'main', + connectionString: databaseUrl, + schemas: config.schemas, + pubsub: false, + }); + const scopedPreset = + config.mode === 'scoped' ? await loadScopedPreset() : undefined; + const pgService = service; + successfulResult = await measureBenchmarkCase( + caseName, + async () => + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ...(scopedPreset ? [scopedPreset] : []), + ], + pgServices: [pgService], + ...(scopedPreset + ? { gather: { pgScopedIntrospection: { main: true } } } + : {}), + }), + async ({ schema, resolvedPreset }) => { + const execution = await execute({ + schema, + document: parse(config.runtimeCheck?.query ?? '{ __typename }'), + resolvedPreset, + contextValue: { + [pgService.pgSettingsKey!]: {}, + [pgService.withPgClientKey!]: withPgClientFromPgService.bind( + null, + pgService + ), + }, + }); + if ( + Symbol.asyncIterator in execution || + execution.errors?.length || + !isDeepStrictEqual( + JSON.parse(JSON.stringify(execution.data ?? null)), + config.runtimeCheck?.expectedData ?? { __typename: 'Query' } + ) + ) { + throw new Error( + `runtime verification query failed: ${JSON.stringify(execution)}` + ); + } + 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, + runtimeQuery: config.runtimeCheck?.query ?? '{ __typename }', + }, + }; + } + ); + } catch (error) { + primaryCaptured = true; + primaryFailure = error; + } + + if (service !== undefined && service !== null) { + try { + await service.release(); + } catch (error) { + cleanupCaptured = true; + cleanupFailure = error; + } + } + + let result: WorkerResult; + if (primaryCaptured || cleanupCaptured) { + const failures: string[] = []; + if (primaryCaptured) failures.push(failureToText(primaryFailure)); + if (cleanupCaptured) failures.push(failureToText(cleanupFailure)); + process.exitCode = 1; + result = { + status: 'error', + pid: process.pid, + caseName, + error: redactSecret(failures.join('; '), databaseUrl), + }; + } else { + result = successfulResult as SuccessfulWorkerResult; + } + + // Classification and cleanup are complete before this single terminal + // protocol write. A write failure must not enter another result path. + writeWorkerResult(result); +}; + +if ( + typeof require !== 'undefined' && + typeof module !== 'undefined' && + require.main === module +) { + void runScopedIntrospectionWorker(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca6ae59d08..85adda87b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1225,6 +1225,44 @@ 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.8.0 + version: 0.8.0 + postgraphile: + specifier: 5.1.4 + version: 5.1.4(f282a162d8bd20a217e08c60f5396af8) + pgsql-test: + specifier: workspace:^ + version: link:../../postgres/pgsql-test/dist + publishDirectory: dist + graphile/graphile-search: dependencies: '@dataplan/pg': @@ -2100,6 +2138,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 @@ -2722,6 +2763,9 @@ importers: graphile-config: specifier: 1.1.0 version: 1.1.0 + graphile-scoped-introspection: + specifier: workspace:^ + version: link:../../graphile/graphile-scoped-introspection/dist graphql: specifier: 16.13.0 version: 16.13.0