From e71c00e97e6dc44e8bdda31364492986541cab4e Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 06:18:08 +0800 Subject: [PATCH 01/12] Add upstream-shaped introspection plugin baseline --- eslint.config.mjs | 2 + .../graphile-scoped-introspection/README.md | 11 + .../graphile-scoped-introspection/UPSTREAM.md | 31 + .../UPSTREAM_LICENSE.md | 21 + .../__tests__/upstream-baseline.test.ts | 28 + .../jest.config.js | 19 + .../package.json | 53 + .../src/index.ts | 2 + .../PgIntrospectionPlugin.ts | 852 ++++++++ .../src/upstream/graphile-build-pg/version.ts | 2 + .../graphile-build-pg/watchFixtures.ts | 128 ++ .../src/upstream/pg-introspection/acl.ts | 663 ++++++ .../pg-introspection/augmentIntrospection.ts | 743 +++++++ .../src/upstream/pg-introspection/index.ts | 277 +++ .../pg-introspection/introspection.ts | 1787 +++++++++++++++++ .../pg-introspection/reservedWords.ts | 386 ++++ .../pg-introspection/smartComments.ts | 55 + .../tsconfig.esm.json | 8 + .../tsconfig.json | 13 + pnpm-lock.yaml | 32 + 20 files changed, 5113 insertions(+) create mode 100644 graphile/graphile-scoped-introspection/README.md create mode 100644 graphile/graphile-scoped-introspection/UPSTREAM.md create mode 100644 graphile/graphile-scoped-introspection/UPSTREAM_LICENSE.md create mode 100644 graphile/graphile-scoped-introspection/__tests__/upstream-baseline.test.ts create mode 100644 graphile/graphile-scoped-introspection/jest.config.js create mode 100644 graphile/graphile-scoped-introspection/package.json create mode 100644 graphile/graphile-scoped-introspection/src/index.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/version.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/watchFixtures.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/pg-introspection/acl.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/pg-introspection/augmentIntrospection.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/pg-introspection/index.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/pg-introspection/introspection.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/pg-introspection/reservedWords.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/pg-introspection/smartComments.ts create mode 100644 graphile/graphile-scoped-introspection/tsconfig.esm.json create mode 100644 graphile/graphile-scoped-introspection/tsconfig.json diff --git a/eslint.config.mjs b/eslint.config.mjs index 10770023e9..da2ed29c41 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,6 +20,8 @@ export default [ '**/*.generated.ts', 'postgres/pg-ast/src/asts.ts', 'postgres/pg-ast/src/wrapped.ts', + // upstream-derived source; preserve Crystal formatting for reviewability + 'graphile/graphile-scoped-introspection/src/upstream/**', // read verbatim at codegen time and written into generated clients 'graphql/codegen/src/core/codegen/templates/**', // codegen output snapshots and standalone sqitch/pgpm fixture projects diff --git a/graphile/graphile-scoped-introspection/README.md b/graphile/graphile-scoped-introspection/README.md new file mode 100644 index 0000000000..2c82fc9370 --- /dev/null +++ b/graphile/graphile-scoped-introspection/README.md @@ -0,0 +1,11 @@ +# graphile-scoped-introspection + +CNC-owned, upstream-shaped PostgreSQL introspection plugin. The package starts +from the published Graphile implementations that own the two relevant seams: + +- `pg-introspection@1.0.1` query generation and result augmentation; +- `graphile-build-pg@5.1.3` `PgIntrospectionPlugin` orchestration. + +The copied source is kept together so scoped changes remain reviewable against +the upstream package boundaries. See `UPSTREAM.md` and `UPSTREAM_LICENSE.md` for +provenance and licensing. diff --git a/graphile/graphile-scoped-introspection/UPSTREAM.md b/graphile/graphile-scoped-introspection/UPSTREAM.md new file mode 100644 index 0000000000..bbf8db663e --- /dev/null +++ b/graphile/graphile-scoped-introspection/UPSTREAM.md @@ -0,0 +1,31 @@ +# Upstream provenance + +The files under `src/upstream/pg-introspection` and +`src/upstream/graphile-build-pg` are derived from the MIT-licensed Graphile +Crystal repository at commit: + +`fb9792dd4e64601530026d1ec4079652a4f33c1c` + +That commit publishes `pg-introspection@1.0.1` and +`graphile-build-pg@5.1.3`, matching the versions pinned by CNC. + +Copied paths: + +- `utils/pg-introspection/src/*.ts` +- `graphile-build/graphile-build-pg/src/plugins/PgIntrospectionPlugin.ts` +- `graphile-build/graphile-build-pg/src/version.ts` +- `graphile-build/graphile-build-pg/src/watchFixtures.ts` + +The baseline copy has only the integration adaptations needed to compile the +published sources as an isolated CNC package: + +- relative imports and TypeScript extension syntax match the CNC package layout; +- the published `graphile-build-pg` declarations are loaded for registry and + service types that the original monorepo supplied through its compilation + unit (including the standard PG adaptor), and its public + `pg-introspection` types remain the plugin contract; +- Crystal's `useUnknownInCatchVariables: false` setting is retained; and +- one otherwise-contextual `description` value has an explicit type annotation. + +Functional changes are layered in separate commits. The copied source keeps its +upstream formatting so future comparisons remain useful. diff --git a/graphile/graphile-scoped-introspection/UPSTREAM_LICENSE.md b/graphile/graphile-scoped-introspection/UPSTREAM_LICENSE.md new file mode 100644 index 0000000000..5b78958e73 --- /dev/null +++ b/graphile/graphile-scoped-introspection/UPSTREAM_LICENSE.md @@ -0,0 +1,21 @@ +## The MIT License (MIT) + +Copyright © `2025` Benjie Gillam + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/graphile/graphile-scoped-introspection/__tests__/upstream-baseline.test.ts b/graphile/graphile-scoped-introspection/__tests__/upstream-baseline.test.ts new file mode 100644 index 0000000000..8858a45286 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/upstream-baseline.test.ts @@ -0,0 +1,28 @@ +import { PgIntrospectionPlugin as publishedPlugin } from 'graphile-build-pg'; +import { makeIntrospectionQuery as publishedQuery } from 'pg-introspection'; + +import { + makeIntrospectionQuery, + PgIntrospectionPlugin, +} from '../src'; + +describe('upstream introspection baseline', () => { + it('starts with the published pg-introspection query unchanged', () => { + expect(makeIntrospectionQuery()).toBe(publishedQuery()); + }); + + it('preserves the published gather lifecycle contract', () => { + expect(PgIntrospectionPlugin).not.toBe(publishedPlugin); + expect(PgIntrospectionPlugin.name).toBe(publishedPlugin.name); + expect(PgIntrospectionPlugin.before).toEqual(publishedPlugin.before); + expect(PgIntrospectionPlugin.gather?.namespace).toBe( + publishedPlugin.gather?.namespace + ); + expect(Object.keys(PgIntrospectionPlugin.gather?.helpers ?? {}).sort()).toEqual( + Object.keys(publishedPlugin.gather?.helpers ?? {}).sort() + ); + expect(Object.keys(PgIntrospectionPlugin.gather?.hooks ?? {}).sort()).toEqual( + Object.keys(publishedPlugin.gather?.hooks ?? {}).sort() + ); + }); +}); 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..004c84df65 --- /dev/null +++ b/graphile/graphile-scoped-introspection/package.json @@ -0,0 +1,53 @@ +{ + "name": "graphile-scoped-introspection", + "version": "0.1.0", + "description": "CNC-owned upstream-shaped PostgreSQL introspection plugin", + "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" + }, + "devDependencies": { + "@types/node": "^22.19.11", + "graphql": "16.13.0", + "makage": "^0.3.0", + "pg-introspection": "^1.0.1" + }, + "peerDependencies": { + "@dataplan/pg": "^1.1.1", + "grafast": "^1.1.2", + "graphile-build": "^5.1.1", + "graphile-build-pg": "^5.1.3", + "graphile-config": "^1.1.0", + "graphql": "^16.13.0" + }, + "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..79e4edda57 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/index.ts @@ -0,0 +1,2 @@ +export { PgIntrospectionPlugin } from './upstream/graphile-build-pg/PgIntrospectionPlugin'; +export * from './upstream/pg-introspection'; diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts new file mode 100644 index 0000000000..118b1af810 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts @@ -0,0 +1,852 @@ +import type { KeysOfType, PgExecutorContext } from "@dataplan/pg"; +import { + PgExecutor, + withPgClientFromPgService, + withSuperuserPgClientFromPgService, +} from "@dataplan/pg"; +import type {} from "@dataplan/pg/adaptors/pg"; +import type { PromiseOrDirect, Step } from "grafast"; +import { constant, context, noop, object, promiseWithResolve } from "grafast"; +import type { GatherPluginContext } from "graphile-build"; +import { EXPORTABLE, gatherConfig } from "graphile-build"; +import type {} from "graphile-build-pg"; +import type { + Introspection, + PgAttribute, + PgAuthMembers, + PgClass, + PgConstraint, + PgDepend, + PgDescription, + PgEnum, + PgExtension, + PgIndex, + PgInherits, + PgLanguage, + PgNamespace, + PgProc, + PgRange, + PgRoles, + PgType, +} from "pg-introspection"; +import { + makeIntrospectionQuery, + parseIntrospectionResults, +} from "../pg-introspection"; + +import { version } from "./version"; +import { watchFixtures } from "./watchFixtures"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Someone else created */ +const CLASH_CODES = ["23505", "42P06", "42P07", "42710"]; + +export type PgEntityWithId = + | PgNamespace + | PgClass + | PgConstraint + | PgProc + | PgRoles + | PgType + | PgEnum + | PgExtension + | PgExtension + | PgIndex + | PgLanguage; + +declare global { + namespace GraphileBuild { + interface GatherOptions { + /** + * Should we attempt to install the watch fixtures into the database? + * + * Default: true + */ + installWatchFixtures?: boolean; + } + } + + namespace GraphileConfig { + interface Plugins { + PgIntrospectionPlugin: true; + } + + interface GatherHelpers { + pgIntrospection: { + getIntrospection(): PromiseOrDirect; + getService(serviceName: string): Promise<{ + introspection: Introspection; + pgService: GraphileConfig.PgServiceConfiguration; + }>; + getExecutorForService(serviceName: string): PgExecutor; + + getNamespace( + serviceName: string, + id: string, + ): Promise; + getClasses(serviceName: string): Promise; + getClass(serviceName: string, id: string): Promise; + getConstraint( + serviceName: string, + id: string, + ): Promise; + getProc(serviceName: string, id: string): Promise; + getRoles(serviceName: string, id: string): Promise; + getType(serviceName: string, id: string): Promise; + getEnum(serviceName: string, id: string): Promise; + getExtension( + serviceName: string, + id: string, + ): Promise; + getIndex(serviceName: string, id: string): Promise; + getLanguage( + serviceName: string, + id: string, + ): Promise; + + getAttribute( + serviceName: string, + classId: string, + attributeNumber: number, + ): Promise; + // getAuthMembers( + // serviceName: string, + // id: string, + // ): Promise; + // getRange(serviceName: string, id: string): Promise; + // getDepend( + // serviceName: string, + // id: string, + // ): Promise; + // getDescription( + // serviceName: string, + // id: string, + // ): Promise; + + getAttributesForClass( + serviceName: string, + classId: string, + ): Promise; + getConstraintsForClass( + serviceName: string, + classId: string, + ): Promise; + getForeignConstraintsForClass( + serviceName: string, + classId: string, + ): Promise; + getInheritedForClass( + serviceName: string, + classId: string, + ): Promise; + getInheritanceChildrenForClass( + serviceName: string, + classId: string, + ): Promise; + getNamespaceByName( + serviceName: string, + namespaceName: string, + ): Promise; + getClassByName( + serviceName: string, + namespaceName: string, + tableName: string, + ): Promise; + getTypeByName( + serviceName: string, + namespaceName: string, + typeName: string, + ): Promise; + getTypeByArray( + serviceName: string, + arrayId: string, + ): Promise; + getEnumsForType(serviceName: string, typeId: string): Promise; + getRangeByType( + serviceName: string, + typeId: string, + ): Promise; + getExtensionByName( + serviceName: string, + extensionName: string, + ): Promise; + }; + } + + interface GatherHooks { + pgIntrospection_introspection(event: { + introspection: Introspection; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_namespace(event: { + entity: PgNamespace; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_class(event: { + entity: PgClass; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_attribute(event: { + entity: PgAttribute; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_constraint(event: { + entity: PgConstraint; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_proc(event: { + entity: PgProc; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_role(event: { + entity: PgRoles; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_auth_member(event: { + entity: PgAuthMembers; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_type(event: { + entity: PgType; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_enum(event: { + entity: PgEnum; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_extension(event: { + entity: PgExtension; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_index(event: { + entity: PgIndex; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_language(event: { + entity: PgLanguage; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_range(event: { + entity: PgRange; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_depend(event: { + entity: PgDepend; + serviceName: string; + }): PromiseOrDirect; + pgIntrospection_description(event: { + entity: PgDescription; + serviceName: string; + }): PromiseOrDirect; + } + } +} + +type RawIntrospectionResults = Array<{ + pgService: GraphileConfig.PgServiceConfiguration; + introspectionText: string; +}>; +type IntrospectionResults = Array<{ + pgService: GraphileConfig.PgServiceConfiguration; + introspection: Introspection; +}>; + +interface Cache { + introspectionResultsPromise: null | Promise; + dirty: boolean; +} + +interface State { + getIntrospectionPromise: null | PromiseOrDirect; + executors: { + [key: string]: PgExecutor; + }; +} + +async function getDb( + info: GatherPluginContext, + serviceName: string, +) { + const introspections = await info.helpers.pgIntrospection.getIntrospection(); + const relevant = introspections.find( + (intro) => intro.pgService.name === serviceName, + ); + if (!relevant) { + throw new Error(`Could not find database '${serviceName}'`); + } + return relevant; +} + +function makeGetEntity< + TKey extends KeysOfType>, +>(loc: TKey) { + return async ( + info: GatherPluginContext, + serviceName: string, + id: string, + ): Promise => { + const relevant = await getDb(info, serviceName); + const list = relevant.introspection[loc]; + if (!list) { + throw new Error( + `Could not find database '${serviceName}''s introspection results for '${loc}'`, + ); + } + return (list as PgEntityWithId[]).find((entity: PgEntityWithId) => + "_id" in entity + ? entity._id === id + : "indexrelid" in entity + ? entity.indexrelid + : false, + ); + }; +} + +function makeGetEntities< + TKey extends KeysOfType>, +>(loc: TKey) { + return async ( + info: GatherPluginContext, + serviceName: string, + ): Promise => { + const relevant = await getDb(info, serviceName); + const list = relevant.introspection[loc]; + if (!list) { + throw new Error( + `Could not find database '${serviceName}''s introspection results for '${loc}'`, + ); + } + return list as any[]; + }; +} + +export const PgIntrospectionPlugin: GraphileConfig.Plugin = { + name: "PgIntrospectionPlugin", + description: + "Introspects PostgreSQL databases and makes the results available to other plugins", + version: version, + + // Run before PgRegistryPlugin because we want all the introspection to be + // triggered/announced before the registryBuilder is built. + before: ["PgRegistryPlugin"], + + gather: gatherConfig({ + namespace: "pgIntrospection", + initialCache: (): Cache => ({ + introspectionResultsPromise: null, + dirty: false, + }), + initialState: (): State => ({ + getIntrospectionPromise: null, + executors: Object.create(null), + }), + helpers: { + getExecutorForService(info, serviceName) { + if (info.state.executors[serviceName]) { + return info.state.executors[serviceName]; + } + const pgService = info.resolvedPreset.pgServices?.find( + (db) => db.name === serviceName, + ); + if (!pgService) { + throw new Error(`Database '${serviceName}' not found`); + } + const { pgSettingsKey, withPgClientKey } = pgService; + /* TODO: consider replacing the `withPgClient` with: + ``` + withPgClient: assertNotNull( + ctx.get(withPgClientKey), + `Server is misconfigured; unable to find '${withPgClientKey}' in context.`, + ), + ``` + */ + const contextCallback = + pgSettingsKey != null + ? EXPORTABLE( + (context, object, pgSettingsKey, withPgClientKey) => () => { + const ctx = context(); + return object({ + pgSettings: ctx.get(pgSettingsKey), + withPgClient: ctx.get(withPgClientKey), + }) as Step>; + }, + [context, object, pgSettingsKey, withPgClientKey], + "contextCallback", + ) + : EXPORTABLE( + (constant, context, object, withPgClientKey) => () => { + const ctx = context(); + return object({ + pgSettings: constant(null), + withPgClient: ctx.get(withPgClientKey), + }) as Step>; + }, + [constant, context, object, withPgClientKey], + "contextCallback", + ); + const executor = EXPORTABLE( + (PgExecutor, contextCallback, serviceName) => + new PgExecutor({ name: serviceName, context: contextCallback }), + [PgExecutor, contextCallback, serviceName], + serviceName === "main" ? `executor` : `${serviceName}Executor`, + ); + + info.state.executors[serviceName] = executor; + return executor; + }, + + getNamespace: makeGetEntity("namespaces"), + getClasses: makeGetEntities("classes"), + getClass: makeGetEntity("classes"), + getConstraint: makeGetEntity("constraints"), + getProc: makeGetEntity("procs"), + getRoles: makeGetEntity("roles"), + getType: makeGetEntity("types"), + getEnum: makeGetEntity("enums"), + getExtension: makeGetEntity("extensions"), + getIndex: makeGetEntity("indexes"), + getLanguage: makeGetEntity("languages"), + + // ENHANCE: we need getters for these + // getAuthMembers: makeGetEntity("authMembers"), + // getRange: makeGetEntity("ranges"), + // getDepend: makeGetEntity("depends"), + // getDescription: makeGetEntity("descriptions"), + // + + async getAttribute(info, serviceName, classId, attributeNumber) { + const pgClass = await info.helpers.pgIntrospection.getClass( + serviceName, + classId, + ); + return pgClass?.getAttribute({ number: attributeNumber }); + }, + + async getAttributesForClass(info, serviceName, classId) { + const pgClass = await info.helpers.pgIntrospection.getClass( + serviceName, + classId, + ); + return pgClass?.getAttributes() ?? []; + }, + + async getConstraintsForClass(info, serviceName, classId) { + const pgClass = await info.helpers.pgIntrospection.getClass( + serviceName, + classId, + ); + return pgClass?.getConstraints() ?? []; + }, + + async getForeignConstraintsForClass(info, serviceName, classId) { + const pgClass = await info.helpers.pgIntrospection.getClass( + serviceName, + classId, + ); + return pgClass?.getForeignConstraints() ?? []; + }, + + async getInheritedForClass(info, serviceName, classId) { + // const pgClass = await info.helpers.pgIntrospection.getClass(serviceName, classId); + const relevant = await getDb(info, serviceName); + const list = relevant.introspection.inherits; + // PERF: cache + return list.filter((entity) => entity.inhrelid === classId); + }, + + async getInheritanceChildrenForClass(info, serviceName, classId) { + const relevant = await getDb(info, serviceName); + const list = relevant.introspection.inherits; + // PERF: cache + return list.filter((entity) => entity.inhparent === classId); + }, + + async getNamespaceByName(info, serviceName, name) { + const relevant = await getDb(info, serviceName); + const list = relevant.introspection.namespaces; + return list.find((nsp) => nsp.nspname === name); + }, + + async getClassByName(info, serviceName, schemaName, tableName) { + const relevant = await getDb(info, serviceName); + const list = relevant.introspection.classes; + return list.find( + (rel) => + rel.getNamespace()!.nspname === schemaName && + rel.relname === tableName, + ); + }, + + async getTypeByName(info, serviceName, schemaName, typeName) { + const relevant = await getDb(info, serviceName); + const list = relevant.introspection.types; + return list.find( + (typ) => + typ.getNamespace()!.nspname === schemaName && + typ.typname === typeName, + ); + }, + + // ENHANCE: we should maybe use pg_type.typelem and look up by ID directy + // instead of having this function + async getTypeByArray(info, serviceName, arrayId) { + const relevant = await getDb(info, serviceName); + const list = relevant.introspection.types; + return list.find((type) => type.typarray === arrayId); + }, + + async getEnumsForType(info, serviceName, typeId) { + const type = await info.helpers.pgIntrospection.getType( + serviceName, + typeId, + ); + return type?.getEnumValues() ?? []; + }, + + async getRangeByType(info, serviceName, typeId) { + const type = await info.helpers.pgIntrospection.getType( + serviceName, + typeId, + ); + return type?.getRange(); + }, + + async getExtensionByName(info, serviceName, extensionName) { + const relevant = await getDb(info, serviceName); + const list = relevant.introspection.extensions; + // PERF: cache + return list.find((entity) => entity.extname === extensionName); + }, + + getIntrospection(info) { + // IMPORTANT: introspection shouldn't change within a single run (even + // if the cache does), thus we add it to state. + return ( + info.state.getIntrospectionPromise ?? + (info.state.getIntrospectionPromise = (async () => { + // If the cache is dirty, clear it now we're about to replace it. + if (info.cache.dirty) { + info.cache.introspectionResultsPromise = null; + info.cache.dirty = false; + } + // Introspect the database (or read it from CLEAN cache) + const introspectionPromise = + info.cache.introspectionResultsPromise ?? + (info.cache.introspectionResultsPromise = introspectPgServices( + info.resolvedPreset.pgServices, + )); + + // Don't cache errors + introspectionPromise.then(null, () => { + info.cache.introspectionResultsPromise = null; + }); + + const rawIntrospections = await introspectionPromise; + + const introspections: IntrospectionResults = rawIntrospections.map( + ({ pgService, introspectionText }) => ({ + pgService, + // IMPORTANT: parseIntrospectionResults must NOT be cached, because other plugins mutate it. + introspection: parseIntrospectionResults(introspectionText), + }), + ); + + // Store the resolved state, so access during announcements doesn't cause the system to hang + info.state.getIntrospectionPromise = introspections; + + // Announce the introspection results. + // NOTE: we must not *cache* this, because it needs to run on every + // gather. We only do it once per gather though, so writing to state + // is fine. + await Promise.all( + introspections.map(async (result) => { + const { introspection, pgService } = result; + + const { + namespaces, + classes, + attributes, + constraints, + procs, + roles, + auth_members, + types, + enums, + extensions, + indexes, + languages, + ranges, + depends, + descriptions, + } = introspection; + + function announce< + TEvent extends keyof GraphileConfig.GatherHooks, + >( + eventName: TEvent, + entities: GraphileConfig.GatherHooks[TEvent] extends ( + firstArg: { entity: infer V; serviceName: string }, + ...rest: any[] + ) => any + ? V[] + : never, + ) { + const promises: Promise[] = []; + for (const entity of entities) { + promises.push( + (info.process as any)(eventName, { + entity: entity, + serviceName: pgService.name, + }), + ); + } + return Promise.all(promises); + } + await info.process("pgIntrospection_introspection", { + introspection, + serviceName: pgService.name, + }); + await announce("pgIntrospection_namespace", namespaces); + await announce("pgIntrospection_class", classes); + await announce("pgIntrospection_attribute", attributes); + await announce("pgIntrospection_constraint", constraints); + await announce("pgIntrospection_proc", procs); + await announce("pgIntrospection_role", roles); + await announce("pgIntrospection_auth_member", auth_members); + await announce("pgIntrospection_type", types); + await announce("pgIntrospection_enum", enums); + await announce("pgIntrospection_extension", extensions); + await announce("pgIntrospection_index", indexes); + await announce("pgIntrospection_language", languages); + await announce("pgIntrospection_range", ranges); + await announce("pgIntrospection_depend", depends); + await announce("pgIntrospection_description", descriptions); + }), + ); + + return introspections; + })()) + ); + }, + async getService(info, serviceName) { + const all = await info.helpers.pgIntrospection.getIntrospection(); + const match = all.find((n) => n.pgService.name === serviceName); + if (!match) { + throw new Error( + `Could not find results for database '${serviceName}'`, + ); + } + return match; + }, + }, + + hooks: { + async pgRegistry_PgRegistryBuilder_init(info, _event) { + await info.helpers.pgIntrospection.getIntrospection(); + }, + pgRegistry_PgRegistryBuilder_pgExecutors(info, event) { + for (const pgService of info.resolvedPreset.pgServices ?? []) { + const executor = info.helpers.pgIntrospection.getExecutorForService( + pgService.name, + ); + event.registryBuilder.addExecutor(executor); + } + }, + }, + + async watch(info, callback) { + const unlistens: Array<() => void> = []; + for (const pgService of info.resolvedPreset.pgServices ?? []) { + if (!pgService.pgSubscriber) { + console.warn( + `pgService '${pgService.name}' does not have a pgSubscriber, and thus cannot be used for watch mode`, + ); + continue; + } + // install the watch fixtures + if (info.options.installWatchFixtures ?? true) { + try { + await withSuperuserPgClientFromPgService( + pgService, + null, + (client) => client.query({ text: watchFixtures }), + ); + } catch (e) { + const code = e?.code; + if (CLASH_CODES.includes(code)) { + // Ignore; most likely someone else is installing it currently + } else { + console.warn( + `Failed to install watch fixtures into '${pgService.name}'.\nInstalling watch fixtures requires superuser privileges; have you correctly configured a 'superuserConnectionString'?\nYou may also opt to configure 'installWatchFixtures: false' and install them yourself.\n\nPostgres says: ${e}`, + ); + } + } + } + try { + let eventStream = + await pgService.pgSubscriber.subscribe("postgraphile_watch"); + const $$stop = Symbol("stop"); + const { resolve, promise: abort } = + promiseWithResolve(); + unlistens.push(() => resolve($$stop)); + const regather = () => { + // Delete the introspection results + info.cache.introspectionResultsPromise = null; + // Deleting the introspection results is not sufficient since they might be replaced before gather runs again + info.cache.dirty = true; + // Trigger re-gather + callback(); + }; + const waitNext = () => { + const next = Promise.race([abort, eventStream.next()]); + next.then( + (event) => { + if (event === $$stop) { + // Terminate the stream + if (eventStream.return) { + const result = eventStream.return(); + result.then(null, noop); + } else if (eventStream.throw) { + const result = eventStream.throw( + new Error("Please stop streaming events now."), + ); + result.then(null, noop); + } + } else { + if (event.done) { + // This should never happen + return restart(); + } else { + try { + regather(); + } finally { + // Wait for the next event + waitNext(); + } + } + } + }, + (e) => { + return restart(e); + }, + ); + }; + let attempts = 0; + const restart = async (e?: Error) => { + attempts++; + let delay = 50 * Math.pow(1.5, attempts - 1); + const MAX_DELAY = 30_000; + if (delay > MAX_DELAY) delay = MAX_DELAY * (Math.random() + 0.5); + console.error( + `postgraphile_watch subscription failed (${e}); waiting ${delay.toFixed(0)}ms then re-establishing`, + ); + const result = await Promise.race([sleep(delay), abort]); + if (result === $$stop) { + return; + } + try { + eventStream = + await pgService.pgSubscriber!.subscribe("postgraphile_watch"); + } catch (e) { + return restart(e); + } + regather(); + // Reset to success + attempts = 0; + waitNext(); + }; + waitNext(); + } catch (e) { + console.warn(`Failed to watch '${pgService.name}': ${e}`); + } + } + return () => { + for (const cb of unlistens) { + try { + cb(); + } catch { + /*nom nom nom*/ + } + } + }; + }, + }), +}; + +function introspectPgServices( + pgServices: ReadonlyArray | undefined, +): Promise { + if (!pgServices) { + return Promise.resolve([]); + } + + const seenNames = new Map(); + const seenPgSettingsKeys = new Map(); + const seenWithPgClientKeys = new Map(); + + // Resolve the promise ASAP so dependents can `getIntrospection()` and then + // `getClass` or whatever from the result. + return Promise.all( + pgServices.map(async (pgService, i) => { + // Validate there's no conflicts between pgServices + 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 existingIndex = seenNames.get(name); + if (existingIndex != null) { + throw new Error( + `pgServices[${i}] has the same name as pgServices[${existingIndex}] (${JSON.stringify( + name, + )})`, + ); + } + seenNames.set(name, i); + } + { + const existingIndex = seenWithPgClientKeys.get(withPgClientKey); + if (existingIndex != null) { + throw new Error( + `pgServices[${i}] has the same withPgClientKey as pgServices[${existingIndex}] (${JSON.stringify( + withPgClientKey, + )})`, + ); + } + seenWithPgClientKeys.set(withPgClientKey, i); + } + if (pgSettingsKey) { + const existingIndex = seenPgSettingsKeys.get(pgSettingsKey); + if (existingIndex != null) { + throw new Error( + `pgServices[${i}] has the same pgSettingsKey as pgServices[${existingIndex}] (${JSON.stringify( + pgSettingsKey, + )})`, + ); + } + seenPgSettingsKeys.set(pgSettingsKey, i); + } + + // Do the introspection + const introspectionQuery = makeIntrospectionQuery(); + const { + rows: [row], + } = await withPgClientFromPgService( + pgService, + pgService.pgSettingsForIntrospection ?? null, + (client) => + client.query<{ introspection: string }>({ + text: introspectionQuery, + }), + ); + if (!row) { + throw new Error("Introspection failed"); + } + return { pgService, introspectionText: row.introspection }; + }), + ); +} diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/version.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/version.ts new file mode 100644 index 0000000000..55a7e3a619 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/version.ts @@ -0,0 +1,2 @@ +// This file is autogenerated by /scripts/postversion.mjs +export const version = "5.1.3"; diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/watchFixtures.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/watchFixtures.ts new file mode 100644 index 0000000000..cc975cf501 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/watchFixtures.ts @@ -0,0 +1,128 @@ +/** + * The "watch" schema that enables PostGraphile to look for changes in the + * database. This used to be an SQL file in V4, but to make it more compatible + * with bundling/etc we've made it a TS file in V5. + */ +export const watchFixtures = /* SQL */ `\ +-- @see https://github.com/graphile/crystal/blob/main/graphile-build/graphile-build-pg/src/watchFixtures.ts + +-- Adds the functionality for PostGraphile to watch the database for schema +-- changes. This script is idempotent, you can run it as many times as you +-- would like. + +-- Drop the \`postgraphile_watch\` schema and all of its dependant objects +-- including the event trigger function and the event trigger itself. We will +-- recreate those objects in this script. +drop schema if exists postgraphile_watch cascade; + +-- Create a schema for the PostGraphile watch functionality. This schema will +-- hold things like trigger functions that are used to implement schema +-- watching. +create schema postgraphile_watch; + +create function postgraphile_watch.notify_watchers_ddl() returns event_trigger as $$ +declare + ddl_commands json; +begin + ddl_commands := ( + select json_agg( + json_build_object( + 'schema', ddlc.schema_name, + 'command', ddlc.command_tag + ) + ) + from pg_event_trigger_ddl_commands() ddlc + where ddlc.schema_name <> 'pg_temp' + ); + + if json_array_length(ddl_commands) > 0 then + perform pg_notify( + 'postgraphile_watch', + json_build_object( + 'type', 'ddl', + 'payload', ddl_commands + )::text + ); + end if; +end; +$$ language plpgsql; + +create function postgraphile_watch.notify_watchers_drop() returns event_trigger as $$ +declare + schemas json; +begin + schemas := ( + select json_agg(distinct dobjs.schema_name) + from pg_event_trigger_dropped_objects() dobjs + where dobjs.schema_name <> 'pg_temp' + ); + + if json_array_length(schemas) > 0 then + perform pg_notify( + 'postgraphile_watch', + json_build_object( + 'type', 'drop', + 'payload', schemas + )::text + ); + end if; +end; +$$ language plpgsql; + +-- Create an event trigger which will listen for the completion of all DDL +-- events and report that they happened to PostGraphile. Events are selected by +-- whether or not they modify the static definition of \`pg_catalog\` that +-- \`introspection-query.sql\` queries. +create event trigger postgraphile_watch_ddl + on ddl_command_end + when tag in ( + -- Ref: https://www.postgresql.org/docs/10/static/event-trigger-matrix.html + 'ALTER AGGREGATE', + 'ALTER DOMAIN', + 'ALTER EXTENSION', + 'ALTER FOREIGN TABLE', + 'ALTER FUNCTION', + 'ALTER POLICY', + 'ALTER SCHEMA', + 'ALTER TABLE', + 'ALTER TYPE', + 'ALTER VIEW', + 'COMMENT', + 'CREATE AGGREGATE', + 'CREATE DOMAIN', + 'CREATE EXTENSION', + 'CREATE FOREIGN TABLE', + 'CREATE FUNCTION', + 'CREATE INDEX', + 'CREATE POLICY', + 'CREATE RULE', + 'CREATE SCHEMA', + 'CREATE TABLE', + 'CREATE TABLE AS', + 'CREATE VIEW', + 'DROP AGGREGATE', + 'DROP DOMAIN', + 'DROP EXTENSION', + 'DROP FOREIGN TABLE', + 'DROP FUNCTION', + 'DROP INDEX', + 'DROP OWNED', + 'DROP POLICY', + 'DROP RULE', + 'DROP SCHEMA', + 'DROP TABLE', + 'DROP TYPE', + 'DROP VIEW', + 'GRANT', + 'REVOKE', + 'SELECT INTO' + ) + execute procedure postgraphile_watch.notify_watchers_ddl(); + +-- Create an event trigger which will listen for drop events because on drops +-- the DDL method seems to get nothing returned from +-- pg_event_trigger_ddl_commands() +create event trigger postgraphile_watch_drop + on sql_drop + execute procedure postgraphile_watch.notify_watchers_drop(); +` as string; /* otherwise the .d.ts includes this full string! */ diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/acl.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/acl.ts new file mode 100644 index 0000000000..6a61f973c0 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/acl.ts @@ -0,0 +1,663 @@ +import type { Introspection, PgEntity, PgRoles } from "./introspection"; + +export const OBJECT_COLUMN = "OBJECT_COLUMN"; +export const OBJECT_TABLE = "OBJECT_TABLE"; +export const OBJECT_SEQUENCE = "OBJECT_SEQUENCE"; +export const OBJECT_DATABASE = "OBJECT_DATABASE"; +export const OBJECT_FUNCTION = "OBJECT_FUNCTION"; +export const OBJECT_LANGUAGE = "OBJECT_LANGUAGE"; +export const OBJECT_LARGEOBJECT = "OBJECT_LARGEOBJECT"; +export const OBJECT_SCHEMA = "OBJECT_SCHEMA"; +export const OBJECT_TABLESPACE = "OBJECT_TABLESPACE"; +export const OBJECT_FDW = "OBJECT_FDW"; +export const OBJECT_FOREIGN_SERVER = "OBJECT_FOREIGN_SERVER"; +export const OBJECT_DOMAIN = "OBJECT_DOMAIN"; +export const OBJECT_TYPE = "OBJECT_TYPE"; + +// https://github.com/postgres/postgres/blob/4908c5872059c409aa647bcde758dfeffe07996e/src/include/nodes/parsenodes.h#L2094-L2148 +export type AclDefaultObjectType = + | typeof OBJECT_COLUMN + | typeof OBJECT_TABLE + | typeof OBJECT_SEQUENCE + | typeof OBJECT_DATABASE + | typeof OBJECT_FUNCTION + | typeof OBJECT_LANGUAGE + | typeof OBJECT_LARGEOBJECT + | typeof OBJECT_SCHEMA + | typeof OBJECT_TABLESPACE + | typeof OBJECT_FDW + | typeof OBJECT_FOREIGN_SERVER + | typeof OBJECT_DOMAIN + | typeof OBJECT_TYPE; + +// https://github.com/postgres/postgres/blob/4908c5872059c409aa647bcde758dfeffe07996e/src/include/nodes/parsenodes.h#L76-L89 +// https://www.postgresql.org/docs/current/ddl-priv.html#PRIVILEGE-ABBREVS-TABLE +const ACL_SELECT = "r"; +const ACL_INSERT = "a"; +const ACL_UPDATE = "w"; +const ACL_DELETE = "d"; +const ACL_TRUNCATE = "D"; +const ACL_REFERENCES = "x"; +const ACL_TRIGGER = "t"; +const ACL_CREATE = "C"; +const ACL_CONNECT = "c"; +const ACL_CREATE_TEMP = "T"; +const ACL_MAINTAIN = "m"; +const ACL_EXECUTE = "X"; +const ACL_USAGE = "U"; +// const ACL_SET = "s"; +// const ACL_ALTER_SYSTEM = "A"; + +/** @see {@link https://github.com/postgres/postgres/blob/4908c5872059c409aa647bcde758dfeffe07996e/src/include/nodes/parsenodes.h#L91} */ +const ACL_NO_RIGHTS = ""; + +/** @see {@link https://github.com/postgres/postgres/blob/4908c5872059c409aa647bcde758dfeffe07996e/src/include/utils/acl.h#L159} */ +const ACL_ALL_RIGHTS_RELATION = `${ACL_INSERT}${ACL_SELECT}${ACL_UPDATE}${ACL_DELETE}${ACL_TRUNCATE}${ACL_REFERENCES}${ACL_TRIGGER}${ACL_MAINTAIN}`; +const ACL_ALL_RIGHTS_SEQUENCE = `${ACL_USAGE}${ACL_SELECT}${ACL_UPDATE}`; +const ACL_ALL_RIGHTS_DATABASE = `${ACL_CREATE}${ACL_CREATE_TEMP}${ACL_CONNECT}`; +const ACL_ALL_RIGHTS_FDW = ACL_USAGE; +const ACL_ALL_RIGHTS_FOREIGN_SERVER = ACL_USAGE; +const ACL_ALL_RIGHTS_FUNCTION = ACL_EXECUTE; +const ACL_ALL_RIGHTS_LANGUAGE = ACL_USAGE; +const ACL_ALL_RIGHTS_LARGEOBJECT = `${ACL_SELECT}${ACL_UPDATE}`; +const ACL_ALL_RIGHTS_SCHEMA = `${ACL_USAGE}${ACL_CREATE}`; +const ACL_ALL_RIGHTS_TABLESPACE = ACL_CREATE; +const ACL_ALL_RIGHTS_TYPE = ACL_USAGE; + +/** + * Used for ordering permissions the same as Postgres + * @see {@link https://github.com/postgres/postgres/blob/a0e7e9799c71abdfdebf16219903e1e2d08687cd/src/include/utils/acl.h#L154} + */ +const ACL_ALL_RIGHTS_STR = `arwdDxtXUCTcsAm`; + +/** + * A fake 'pg_roles' record representing the 'public' meta-role. + */ +export const PUBLIC_ROLE: PgRoles = Object.freeze({ + rolname: "public", + rolsuper: false, + rolinherit: false, + rolcreaterole: false, + rolcreatedb: false, + rolcanlogin: false, + rolreplication: false, + rolconnlimit: null, + rolpassword: null, + rolbypassrls: false, + rolconfig: null, + rolvaliduntil: null, + _id: "0", +}); + +/** + * Gets a role given an OID; throws an error if the role is not found. + */ +function getRole(introspection: Introspection, oid: string): PgRoles { + if (oid === "0") { + return PUBLIC_ROLE; + } + const role = introspection._lookups.roleById.get(oid); + if (!role) { + throw new Error(`Could not find role with identifier '${oid}'`); + } + return role; +} + +/** + * Gets a role given its name; throws an error if the role is not found. + */ +function getRoleByName(introspection: Introspection, name: string): PgRoles { + if (name === "public") { + return PUBLIC_ROLE; + } + const role = introspection._lookups.roleByName[name]; + if (!role) { + throw new Error(`Could not find role with name '${name}'`); + } + return role; +} + +/** + * Represents a single ACL entry in an ACL string, such as + * `foo=arwdDxt/bar` + */ +export interface AclObject { + /** Who are these permissions granted to? */ + role: string; + /** Who granted these permissions? */ + granter: string; + + /** r */ + select?: boolean; + /** r* */ + selectGrant?: boolean; + + /** w */ + update?: boolean; + /** w* */ + updateGrant?: boolean; + + /** a */ + insert?: boolean; + /** a* */ + insertGrant?: boolean; + + /** d */ + delete?: boolean; + /** d* */ + deleteGrant?: boolean; + + /** D */ + truncate?: boolean; + /** D* */ + truncateGrant?: boolean; + + /** x */ + references?: boolean; + /** x* */ + referencesGrant?: boolean; + + /** t */ + trigger?: boolean; + /** t* */ + triggerGrant?: boolean; + + /** X */ + execute?: boolean; + /** X* */ + executeGrant?: boolean; + + /** U */ + usage?: boolean; + /** U* */ + usageGrant?: boolean; + + /** C */ + create?: boolean; + /** C* */ + createGrant?: boolean; + + /** c */ + connect?: boolean; + /** c* */ + connectGrant?: boolean; + + /** T */ + temporary?: boolean; + /** T* */ + temporaryGrant?: boolean; + + /** m */ + maintain?: boolean; + /** m* */ + maintainGrant?: boolean; +} + +export type ResolvedPermissions = Omit; + +/** + * Parses a role identifier from an ACL string. + * + * 'foo' becomes 'foo' + * '"foo""mcbrew"' becomes 'foo"mcbrew' + */ +const parseIdentifier = (str: string): string => { + if (str.startsWith('"')) { + if (!str.endsWith('"')) { + throw new Error( + `Invalid identifier - if it starts with '"' it must also end with '"'`, + ); + } + return str.substring(1, str.length - 1).replace(/""/g, '"'); + } else { + return str; + } +}; + +// https://www.postgresql.org/docs/current/ddl-priv.html#PRIVILEGE-ABBREVS-TABLE +const ACL_MAP = { + // This is the order defined in the Postgres docs; however on serialize it + // will be in the same order as ACL_ALL_RIGHTS_STR + [ACL_SELECT]: "select", + [ACL_UPDATE]: "update", + [ACL_INSERT]: "insert", + [ACL_DELETE]: "delete", + [ACL_TRUNCATE]: "truncate", + [ACL_REFERENCES]: "references", + [ACL_TRIGGER]: "trigger", + [ACL_EXECUTE]: "execute", + [ACL_USAGE]: "usage", + [ACL_CREATE]: "create", + [ACL_CONNECT]: "connect", + [ACL_CREATE_TEMP]: "temporary", + [ACL_MAINTAIN]: "maintain", +} as const; +Object.setPrototypeOf(ACL_MAP, null); + +type AclCharacter = keyof typeof ACL_MAP; + +const ACL_MAP_ENTRIES = Object.entries(ACL_MAP).sort((a, z) => { + // Sort them according to `ACL_ALL_RIGHTS_STR` + const ai = ACL_ALL_RIGHTS_STR.indexOf(a[0]); + if (ai < 0) throw new Error(`${a[0]} not found in ACL_ALL_RIGHTS_STR`); + const zi = ACL_ALL_RIGHTS_STR.indexOf(z[0]); + if (zi < 0) throw new Error(`${z[0]} not found in ACL_ALL_RIGHTS_STR`); + return ai - zi; +}) as ReadonlyArray< + { [K in AclCharacter]: [K, (typeof ACL_MAP)[K]] }[AclCharacter] +>; + +const NO_PERMISSIONS: AclObject = ACL_MAP_ENTRIES.reduce( + (acc, [_char, perm]) => { + acc[perm] = false; + acc[`${perm}Grant`] = false; + return acc; + }, + { role: "public", granter: "" } as Partial, +) as AclObject; + +/** + * Accepts an ACL string such as `foo=arwdDxt/bar` and converts it into + * a parsed AclObject. + */ +export function parseAcl(aclString: string): AclObject { + const aclLength = aclString.length; + if (aclLength < 3) { + // Shortest ACL string might be e.g. `=/a` + throw new Error("Invalid ACL string: too few characters"); + } + const acl: AclObject = { ...NO_PERMISSIONS }; + /** Where the name of the role ends */ + const equalsSignIndex = aclString.indexOf("="); + if (equalsSignIndex === -1) { + throw new Error( + `Could not parse ACL string '${aclString}' - no '=' symbol`, + ); + } else if (equalsSignIndex > 0) { + acl.role = parseIdentifier(aclString.substring(0, equalsSignIndex)); + } + const lastCharacterIndex = aclLength - 1; + let i = equalsSignIndex; // Start at the "=" + // Process the ACL tokens + while (++i < aclLength) { + const char = aclString[i]; + if (char === "/") { + // granter begins + // skip past the "/" delimiter + if (++i === aclLength) { + throw new Error(`ACL string should have a granter after the /`); + } + acl.granter = parseIdentifier(aclString.substring(i)); + // Success! + return acl; + } + const currentPerm = ACL_MAP[char as AclCharacter]; + if (currentPerm === undefined) { + throw new Error( + `Could not parse ACL string '${aclString}' - unsupported permission '${char}'`, + ); + } + acl[currentPerm] = true; + if (i < lastCharacterIndex && aclString[i + 1] === "*") { + // permission + grant + i++; // skip past the "*" character + acl[`${currentPerm}Grant`] = true; + } + } // end token processing + throw new Error( + `Invalid or unsupported ACL string '${aclString}' - no '/' character?`, + ); +} + +function escapeRole(role: string) { + if (role.indexOf('"') !== -1) { + return `"${role.replace(/"/g, '""')}"`; + } else { + return role; + } +} + +/** + * Takes an `AclObject` and converts it back into a Postgres ACL string such as + * `foo=arwdDxt/bar` + */ +export function serializeAcl(acl: AclObject) { + let permissions = (acl.role === "public" ? "" : escapeRole(acl.role)) + "="; + + for (const [char, perm] of ACL_MAP_ENTRIES) { + if (acl[`${perm}Grant`]) permissions += char + "*"; + else if (acl[perm]) permissions += char; + } + + permissions += `/${escapeRole(acl.granter)}`; + + return permissions; +} + +export const emptyAclObject = parseAcl("=/postgres"); + +/** + * Returns a list of AclObject by parsing the given input ACL strings. If no + * ACL strings are present then it will return the default (implied) ACL for + * the given `type` of entity owned by `ownerId`. + * + * See: + * + * https://github.com/postgres/postgres/blob/14aec03502302eff6c67981d8fd121175c436ce9/src/backend/utils/adt/acl.c#L748-L854 + * + * and: + * + * https://github.com/postgres/postgres/blob/fb3b098fe88441f9531a5169008ea17eac01301f/src/include/utils/acl.h#L153-L167 + */ +export function parseAcls( + introspection: Introspection, + inAcls: readonly string[] | null, + ownerId: string, + objtype: AclDefaultObjectType, +): AclObject[] { + const aclStrings: readonly string[] = + inAcls || + (() => { + const owner = getRole(introspection, ownerId); + let worldDefault: string; + let ownerDefault: string; + switch (objtype) { + case OBJECT_COLUMN: + worldDefault = ACL_NO_RIGHTS; + ownerDefault = ACL_NO_RIGHTS; + break; + case OBJECT_TABLE: + worldDefault = ACL_NO_RIGHTS; + ownerDefault = ACL_ALL_RIGHTS_RELATION; + break; + case OBJECT_SEQUENCE: + worldDefault = ACL_NO_RIGHTS; + ownerDefault = ACL_ALL_RIGHTS_SEQUENCE; + break; + case OBJECT_DATABASE: + worldDefault = `${ACL_CREATE_TEMP}${ACL_CONNECT}`; + ownerDefault = ACL_ALL_RIGHTS_DATABASE; + break; + case OBJECT_FUNCTION: + worldDefault = ACL_EXECUTE; + ownerDefault = ACL_ALL_RIGHTS_FUNCTION; + break; + case OBJECT_LANGUAGE: + worldDefault = ACL_USAGE; + ownerDefault = ACL_ALL_RIGHTS_LANGUAGE; + break; + case OBJECT_LARGEOBJECT: + worldDefault = ACL_NO_RIGHTS; + ownerDefault = ACL_ALL_RIGHTS_LARGEOBJECT; + break; + case OBJECT_SCHEMA: + worldDefault = ACL_NO_RIGHTS; + ownerDefault = ACL_ALL_RIGHTS_SCHEMA; + break; + case OBJECT_TABLESPACE: + worldDefault = ACL_NO_RIGHTS; + ownerDefault = ACL_ALL_RIGHTS_TABLESPACE; + break; + case OBJECT_FDW: + worldDefault = ACL_NO_RIGHTS; + ownerDefault = ACL_ALL_RIGHTS_FDW; + break; + case OBJECT_FOREIGN_SERVER: + worldDefault = ACL_NO_RIGHTS; + ownerDefault = ACL_ALL_RIGHTS_FOREIGN_SERVER; + break; + case OBJECT_DOMAIN: + case OBJECT_TYPE: + worldDefault = ACL_USAGE; + ownerDefault = ACL_ALL_RIGHTS_TYPE; + break; + default: + worldDefault = ACL_NO_RIGHTS; + ownerDefault = ACL_NO_RIGHTS; + break; + } + + const acl: string[] = []; + if (worldDefault !== ACL_NO_RIGHTS) { + acl.push(`=${worldDefault}/${owner.rolname}`); + } + if (ownerDefault !== ACL_NO_RIGHTS) { + acl.push(`${owner.rolname}=${ownerDefault}/${owner.rolname}`); + } + return acl; + })(); + + const acls = aclStrings.map(parseAcl); + return acls; +} + +// Forewarning: I hate TypeScript enums. Your PR to convert this to a +// TypeScript enum will be rejected. +export const Permission = { + select: "select", + selectGrant: "selectGrant", + update: "update", + updateGrant: "updateGrant", + insert: "insert", + insertGrant: "insertGrant", + delete: "delete", + deleteGrant: "deleteGrant", + truncate: "truncate", + truncateGrant: "truncateGrant", + references: "references", + referencesGrant: "referencesGrant", + trigger: "trigger", + triggerGrant: "triggerGrant", + execute: "execute", + executeGrant: "executeGrant", + usage: "usage", + usageGrant: "usageGrant", + create: "create", + createGrant: "createGrant", + connect: "connect", + connectGrant: "connectGrant", + temporary: "temporary", + temporaryGrant: "temporaryGrant", + maintain: "maintain", + maintainGrant: "maintainGrant", +} as const; + +/** + * Returns all the roles role has been granted (including PUBLIC), + * respecting `NOINHERIT`. + */ +export function expandRoles( + introspection: Introspection, + roles: PgRoles[], + includeNoInherit = false, +): PgRoles[] { + // To avoid potential memory exhaustion, we only cache the common case, where + // `roles` is an array of size 1. + if (roles.length === 1) { + const cacheKey = `${roles[0]._id}:${includeNoInherit ? "N" : "I"}`; + const cache = introspection._caches.expandRoles; + const cached = cache.get(cacheKey); + if (cached) { + return cached; + } + const result = _expandRolesUncached(introspection, roles, includeNoInherit); + cache.set(cacheKey, result); + return result; + } + return _expandRolesUncached(introspection, roles, includeNoInherit); +} + +function _expandRolesUncached( + introspection: Introspection, + roles: PgRoles[], + includeNoInherit: boolean, +): PgRoles[] { + const allRoles = new Set([PUBLIC_ROLE]); + const { authMembersByMemberId } = introspection._lookups; + + const addRole = (member: PgRoles) => { + if (!allRoles.has(member)) { + allRoles.add(member); + if (includeNoInherit || member.rolinherit !== false) { + const memberships = authMembersByMemberId.get(member._id); + if (memberships) { + for (const membership of memberships) { + const role = getRole(introspection, membership.roleid); + addRole(role); + } + } + } + } + }; + + for (const role of roles) { + addRole(role); + } + + return [...allRoles]; +} + +/** + * Returns true if ACL was applied to this role, or a role that this role + * inherits from (including public). + * + * i.e. does this ACL grant privileges to this role (directly or indirectly)? + * + * In Venn diagram terms, it asks if the 'role' is contained within (or equal to) + * the acl.role. + */ +export function aclContainsRole( + introspection: Introspection, + acl: AclObject, + role: PgRoles, + includeNoInherit = false, +): boolean { + const aclRole = getRoleByName(introspection, acl.role); + const expandedRoles = expandRoles(introspection, [role], includeNoInherit); + return expandedRoles.includes(aclRole); +} + +/** + * Filters the ACL objects to only those that apply to `role`, then calculates + * the `OR` of all the permissions to see what permissions the role has. + */ +export function resolvePermissions( + introspection: Introspection, + acls: AclObject[], + role: PgRoles, + includeNoInherit = false, + isOwnerAndHasNoExplicitACLs = false, +): ResolvedPermissions { + const expandedRoles = expandRoles(introspection, [role], includeNoInherit); + const isSuperuser = expandedRoles.some((role) => role.rolsuper); + + // Superusers have all permissions. An owner of an object has all permissions + // _unless_ there's a specific ACL for that owner. In all other cases, just as + // in life, you start with nothing... + const grantAll = isSuperuser || isOwnerAndHasNoExplicitACLs; + const permissions: ResolvedPermissions = { + select: grantAll, + selectGrant: grantAll, + update: grantAll, + updateGrant: grantAll, + insert: grantAll, + insertGrant: grantAll, + delete: grantAll, + deleteGrant: grantAll, + truncate: grantAll, + truncateGrant: grantAll, + references: grantAll, + referencesGrant: grantAll, + trigger: grantAll, + triggerGrant: grantAll, + execute: grantAll, + executeGrant: grantAll, + usage: grantAll, + usageGrant: grantAll, + create: grantAll, + createGrant: grantAll, + connect: grantAll, + connectGrant: grantAll, + temporary: grantAll, + temporaryGrant: grantAll, + maintain: grantAll, + maintainGrant: grantAll, + }; + + if (grantAll) { + return permissions; + } + + for (const acl of acls) { + const appliesToRole = aclContainsRole( + introspection, + acl, + role, + includeNoInherit, + ); + if (appliesToRole) { + permissions.select = permissions.select || acl.select; + permissions.selectGrant = permissions.selectGrant || acl.selectGrant; + permissions.update = permissions.update || acl.update; + permissions.updateGrant = permissions.updateGrant || acl.updateGrant; + permissions.insert = permissions.insert || acl.insert; + permissions.insertGrant = permissions.insertGrant || acl.insertGrant; + permissions.delete = permissions.delete || acl.delete; + permissions.deleteGrant = permissions.deleteGrant || acl.deleteGrant; + permissions.truncate = permissions.truncate || acl.truncate; + permissions.truncateGrant = + permissions.truncateGrant || acl.truncateGrant; + permissions.references = permissions.references || acl.references; + permissions.referencesGrant = + permissions.referencesGrant || acl.referencesGrant; + permissions.trigger = permissions.trigger || acl.trigger; + permissions.triggerGrant = permissions.triggerGrant || acl.triggerGrant; + permissions.execute = permissions.execute || acl.execute; + permissions.executeGrant = permissions.executeGrant || acl.executeGrant; + permissions.usage = permissions.usage || acl.usage; + permissions.usageGrant = permissions.usageGrant || acl.usageGrant; + permissions.create = permissions.create || acl.create; + permissions.createGrant = permissions.createGrant || acl.createGrant; + permissions.connect = permissions.connect || acl.connect; + permissions.connectGrant = permissions.connectGrant || acl.connectGrant; + permissions.temporary = permissions.temporary || acl.temporary; + permissions.temporaryGrant = + permissions.temporaryGrant || acl.temporaryGrant; + permissions.maintain = permissions.maintain || acl.maintain; + permissions.maintainGrant = + permissions.maintainGrant || acl.maintainGrant; + } + } + + return permissions; +} + +export function entityPermissions( + introspection: Introspection, + entity: Extract, + role: PgRoles, + includeNoInherit = false, +) { + const acls = entity.getACL(); + const owner = + entity._type === "PgAttribute" + ? entity.getClass()?.getOwner() + : entity.getOwner(); + // If the role is the owner, and no explicit ACLs have been granted to this role, then the owner has all privileges. + const isOwnerAndHasNoExplicitACLs = + owner && + owner === role && + !acls.some((acl) => acl.role === owner.rolname) && + (entity._type !== "PgAttribute" || + !entity + .getClass() + ?.getACL() + .some((acl) => acl.role === owner.rolname)); + return resolvePermissions( + introspection, + acls, + role, + includeNoInherit, + isOwnerAndHasNoExplicitACLs, + ); +} diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/augmentIntrospection.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/augmentIntrospection.ts new file mode 100644 index 0000000000..fc217c9b62 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/augmentIntrospection.ts @@ -0,0 +1,743 @@ +import { + OBJECT_COLUMN, + OBJECT_DATABASE, + OBJECT_FUNCTION, + OBJECT_SCHEMA, + OBJECT_SEQUENCE, + OBJECT_TABLE, + parseAcls, + PUBLIC_ROLE, +} from "./acl"; +import type { + Introspection, + PgAttribute, + PgClass, + PgConstraint, + PgDescription, + PgEnum, + PgIndex, + PgNamespace, + PgProcArgument, + PgRange, + PgRoles, + PgType, +} from "./introspection"; +import type { PgSmartTagsAndDescription } from "./smartComments"; +import { parseSmartComment } from "./smartComments"; + +/** + * Only suitable for functions that accept no arguments. + */ +function memo(fn: () => T): () => T { + let cache: T; + let called = false; + return () => { + if (!called) { + cache = fn(); + called = true; + } + return cache!; + }; +} + +function del, TKey extends keyof T>( + toDelete: Set, + collection: T[], + attr: TKey, +) { + for (let i = collection.length - 1; i >= 0; i--) { + const entry = collection[i]; + if (entry[attr] != null && toDelete.has(entry[attr])) { + collection.splice(i, 1); + } + } +} + +function descriptionKey( + spec: Pick, +) { + return `${spec.classoid}|${spec.objoid}|${spec.objsubid}`; +} + +function namespaceNameKey(namespaceId: string, name: string | null) { + return `${namespaceId}|${name}`; +} + +function attributeRelNumKey(relid: string, attnum: number) { + return `${relid}|${attnum}`; +} + +function createLookups(introspection: Omit) { + const lookups: Introspection["_lookups"] = { + oidByCatalog: Object.create(null), + roleById: new Map(), + roleByName: Object.create(null), + authMembersByMemberId: new Map(), + namespaceById: new Map(), + namespaceByName: new Map(), + typeById: new Map(), + classById: new Map(), + classByNamespaceName: new Map(), + rangeByTypid: new Map(), + attributesByRelId: new Map(), + attributeByRelIdAndNum: new Map(), + constraintById: new Map(), + constraintsByRelid: new Map(), + constraintByNamespaceName: new Map(), + foreignConstraintsByRelid: new Map(), + enumById: new Map(), + enumsByTypid: new Map(), + indexById: new Map(), + indexesByRelid: new Map(), + descriptionByDescriptionKey: new Map(), + procById: new Map(), + procsByNamespaceName: new Map(), + extensionById: new Map(), + languageById: new Map(), + accessMethodById: new Map(), + }; + + const { + oidByCatalog, + authMembersByMemberId, + roleById, + roleByName, + namespaceById, + namespaceByName, + typeById, + classById, + classByNamespaceName, + rangeByTypid, + attributesByRelId, + attributeByRelIdAndNum, + constraintById, + constraintsByRelid, + constraintByNamespaceName, + foreignConstraintsByRelid, + enumById, + enumsByTypid, + indexById, + indexesByRelid, + descriptionByDescriptionKey, + procById, + procsByNamespaceName, + extensionById, + languageById, + accessMethodById, + } = lookups; + + for (const [oid, catalog] of Object.entries(introspection.catalog_by_oid)) { + oidByCatalog[catalog] = oid; + } + for (const entity of introspection.namespaces) { + namespaceById.set(entity._id, entity); + namespaceByName.set(entity.nspname, entity); + } + for (const entity of introspection.types) { + typeById.set(entity._id, entity); + } + for (const entity of introspection.classes) { + classById.set(entity._id, entity); + classByNamespaceName.set( + namespaceNameKey(entity.relnamespace, entity.relname), + entity, + ); + } + for (const entity of introspection.procs) { + procById.set(entity._id, entity); + addListItem( + procsByNamespaceName, + namespaceNameKey(entity.pronamespace, entity.proname), + entity, + ); + } + for (const entity of introspection.extensions) { + extensionById.set(entity._id, entity); + } + for (const entity of introspection.languages) { + languageById.set(entity._id, entity); + } + for (const entity of introspection.am) { + accessMethodById.set(entity._id, entity); + } + for (const entity of introspection.ranges) { + if (entity.rngtypid == null) continue; + rangeByTypid.set(entity.rngtypid, entity); + } + for (const entity of introspection.descriptions) { + descriptionByDescriptionKey.set(descriptionKey(entity), entity); + } + + function addListItem( + cache: Map, + cacheKey: string, + entity: T, + ): void { + const list = cache.get(cacheKey); + if (list) { + list.push(entity); + } else { + cache.set(cacheKey, [entity]); + } + } + + // Constraints (sorted) + for (const entity of introspection.constraints) { + constraintById.set(entity._id, entity); + constraintByNamespaceName.set( + namespaceNameKey(entity.connamespace, entity.conname), + entity, + ); + if (entity.conrelid !== "0") { + addListItem(constraintsByRelid, entity.conrelid, entity); + } + if (entity.confrelid !== "0") { + addListItem(foreignConstraintsByRelid, entity.confrelid, entity); + } + } + for (const list of constraintsByRelid.values()) { + list.sort((a, z) => a.conname.localeCompare(z.conname, "en-US")); + } + + // Attributes (sorted) + for (const entity of introspection.attributes) { + addListItem(attributesByRelId, entity.attrelid, entity); + attributeByRelIdAndNum.set( + attributeRelNumKey(entity.attrelid, entity.attnum), + entity, + ); + } + for (const list of attributesByRelId.values()) { + list.sort((a, z) => a.attnum - z.attnum); + } + + // Enums (sorted) + for (const entity of introspection.enums) { + enumById.set(entity._id, entity); + addListItem(enumsByTypid, entity.enumtypid, entity); + } + for (const list of enumsByTypid.values()) { + list.sort((a, z) => a.enumsortorder - z.enumsortorder); + } + + for (const entity of introspection.indexes) { + indexById.set(entity.indexrelid, entity); + addListItem(indexesByRelid, entity.indrelid, entity); + } + + roleById.set(PUBLIC_ROLE._id, PUBLIC_ROLE); + roleByName[PUBLIC_ROLE.rolname] = PUBLIC_ROLE; + authMembersByMemberId.set(PUBLIC_ROLE._id, new Set()); + for (const role of introspection.roles) { + roleById.set(role._id, role); + roleByName[role.rolname] = role; + authMembersByMemberId.set(role._id, new Set()); + } + + for (const am of introspection.auth_members) { + const set = authMembersByMemberId.get(am.member); + if (!set) { + // This should never happen + console.warn( + `Introspection has membership for role with id '${am.member}', but there is no such role`, + ); + continue; + } + set.add(am); + } + + return lookups; +} + +function createCaches(): Introspection["_caches"] { + return { + expandRoles: new Map(), + }; +} + +/** + * Adds helpers to the introspection results. + */ +export function augmentIntrospection( + introspectionResultsString: string, + includeExtensionResources = false, +): Introspection { + const introspection = JSON.parse(introspectionResultsString) as Introspection; + return augmentIntrospectionParsed(introspection, includeExtensionResources); +} + +/** @internal */ +export function augmentIntrospectionParsed( + introspection: Introspection, + includeExtensionResources = false, +): Introspection { + introspection._lookups = createLookups(introspection); + introspection._caches = createCaches(); + + const { + oidByCatalog, + // authMembersByMemberId, + roleById, + // roleByName, + namespaceById, + namespaceByName, + typeById, + classById, + classByNamespaceName, + rangeByTypid, + attributesByRelId, + attributeByRelIdAndNum, + constraintById, + constraintsByRelid, + constraintByNamespaceName, + foreignConstraintsByRelid, + enumById, + enumsByTypid, + indexById, + indexesByRelid, + descriptionByDescriptionKey, + procById, + procsByNamespaceName, + extensionById, + languageById, + accessMethodById, + } = introspection._lookups; + + if (!includeExtensionResources) { + // Go through and delete things from the extensions + const extensionProcOids = new Set(); + const extensionClassOids = new Set(); + for (const pg_depend of introspection.depends) { + if ( + pg_depend.refclassid === oidByCatalog["pg_extension"] && + pg_depend.deptype === "e" && + pg_depend.classid === oidByCatalog["pg_proc"] + ) { + extensionProcOids.add(pg_depend.objid); + } + + if ( + pg_depend.refclassid === oidByCatalog["pg_extension"] && + pg_depend.deptype === "e" && + pg_depend.classid === oidByCatalog["pg_class"] + ) { + extensionClassOids.add(pg_depend.objid); + } + } + + del(extensionProcOids, introspection.procs, "_id"); + del(extensionClassOids, introspection.classes, "_id"); + del(extensionClassOids, introspection.attributes, "attrelid"); + del(extensionClassOids, introspection.constraints, "conrelid"); + del(extensionClassOids, introspection.constraints, "confrelid"); + del(extensionClassOids, introspection.types, "typrelid"); + } + + const getRole = (id: string | null): PgRoles | undefined => + id != null ? roleById.get(id) : undefined; + const getNamespace = (id: string | null): PgNamespace | undefined => + id != null ? namespaceById.get(id) : undefined; + const getType = (id: string | null): PgType | undefined => + id != null ? typeById.get(id) : undefined; + const getClass = (id: string | null): PgClass | undefined => + id != null ? classById.get(id) : undefined; + const getRange = (id: string | null): PgRange | undefined => + id != null ? rangeByTypid.get(id) : undefined; + const getAttributes = (id: string | null): PgAttribute[] => + id != null ? (attributesByRelId.get(id) ?? []) : []; + const getConstraints = (id: string | null): PgConstraint[] => + id != null ? (constraintsByRelid.get(id) ?? []) : []; + const getForeignConstraints = (id: string | null): PgConstraint[] => + id != null ? (foreignConstraintsByRelid.get(id) ?? []) : []; + const getEnums = (id: string | null): PgEnum[] => + id != null ? (enumsByTypid.get(id) ?? []) : []; + const getIndexes = (id: string | null): PgIndex[] => + id != null ? (indexesByRelid.get(id) ?? []) : []; + + const PG_NAMESPACE = oidByCatalog["pg_namespace"]; + const PG_CLASS = oidByCatalog["pg_class"]; + const PG_PROC = oidByCatalog["pg_proc"]; + const PG_TYPE = oidByCatalog["pg_type"]; + const PG_CONSTRAINT = oidByCatalog["pg_constraint"]; + const PG_EXTENSION = oidByCatalog["pg_extension"]; + + if ( + !PG_NAMESPACE || + !PG_CLASS || + !PG_PROC || + !PG_TYPE || + !PG_CONSTRAINT || + !PG_EXTENSION + ) { + throw new Error( + `Invalid introspection results; could not determine the ids of the system catalogs`, + ); + } + + const getDescription = ( + classoid: string, + objoid: string, + objsubid = 0, + ): string | undefined => + descriptionByDescriptionKey.get( + descriptionKey({ classoid, objoid, objsubid }), + )?.description; + + const getTagsAndDescription = ( + classoid: string, + objoid: string, + objsubid?: number, + fallback?: { + classoid: string; + objoid: string; + objsubid?: number; + }, + ): PgSmartTagsAndDescription => { + let description = getDescription(classoid, objoid, objsubid); + if (description == null && fallback) { + description = getDescription( + fallback.classoid, + fallback.objoid, + fallback.objsubid, + ); + } + return parseSmartComment(description); + }; + + const currentUser = + introspection._lookups.roleByName[introspection.current_user]; + introspection.getCurrentUser = () => currentUser; + + introspection.getNamespace = (by) => { + if ("id" in by && by.id) { + return namespaceById.get(by.id); + } else if ("name" in by && by.name) { + return namespaceByName.get(by.name); + } + }; + introspection.getClass = (by) => classById.get(by.id); + introspection.getConstraint = (by) => constraintById.get(by.id); + introspection.getProc = (by) => procById.get(by.id); + introspection.getRoles = (by) => roleById.get(by.id); + introspection.getType = (by) => typeById.get(by.id); + introspection.getEnum = (by) => enumById.get(by.id); + introspection.getExtension = (by) => extensionById.get(by.id); + introspection.getIndex = (by) => indexById.get(by.id); + introspection.getLanguage = (by) => languageById.get(by.id); + + introspection.database._type = "PgDatabase"; + const dba = roleById.get(introspection.database.datdba); + introspection.database.getOwner = () => dba; + introspection.database.getDba = introspection.database.getOwner; + introspection.database.getACL = memo(() => + parseAcls( + introspection, + introspection.database.datacl, + introspection.database.datdba, + OBJECT_DATABASE, + ), + ); + + introspection.namespaces.forEach((entity) => { + entity._type = "PgNamespace"; + entity.getOwner = memo(() => getRole(entity.nspowner)); + entity.getDescription = memo(() => + getDescription(PG_NAMESPACE, entity._id), + ); + entity.getTagsAndDescription = memo(() => + getTagsAndDescription(PG_NAMESPACE, entity._id), + ); + entity.getTags = memo(() => entity.getTagsAndDescription().tags); + entity.getACL = memo(() => + parseAcls(introspection, entity.nspacl, entity.nspowner, OBJECT_SCHEMA), + ); + + entity.getClass = (by) => + classByNamespaceName.get(namespaceNameKey(entity._id, by.name)); + entity.getConstraint = (by) => + constraintByNamespaceName.get(namespaceNameKey(entity._id, by.name)); + entity.getProcs = (by) => + procsByNamespaceName.get(namespaceNameKey(entity._id, by.name)) ?? []; + }); + + introspection.classes.forEach((entity) => { + entity._type = "PgClass"; + entity.getNamespace = memo(() => getNamespace(entity.relnamespace)); + entity.getType = memo(() => getType(entity.reltype)); + entity.getOfType = memo(() => getType(entity.reloftype)); + entity.getOwner = memo(() => getRole(entity.relowner)); + entity.getAttributes = memo(() => getAttributes(entity._id)); + entity.getConstraints = memo(() => getConstraints(entity._id)); + entity.getForeignConstraints = memo(() => + getForeignConstraints(entity._id), + ); + entity.getIndexes = memo(() => getIndexes(entity._id)); + entity.getDescription = memo(() => getDescription(PG_CLASS, entity._id, 0)); + entity.getTagsAndDescription = memo(() => + getTagsAndDescription(PG_CLASS, entity._id, 0, { + classoid: PG_TYPE, + objoid: entity.reltype, + }), + ); + entity.getTags = memo(() => entity.getTagsAndDescription().tags); + entity.getACL = memo(() => + parseAcls( + introspection, + entity.relacl, + entity.relowner, + entity.relkind === "S" ? OBJECT_SEQUENCE : OBJECT_TABLE, + ), + ); + + entity.getAttribute = (by) => { + const attributes = entity.getAttributes(); + return attributes.find((att) => + "number" in by && by.number + ? att.attnum === by.number + : "name" in by && by.name + ? att.attname === by.name + : false, + ); + }; + entity.getInherited = memo(() => + introspection.inherits.filter((inh) => inh.inhrelid === entity._id), + ); + entity.getAccessMethod = memo(() => + entity.relam != null ? accessMethodById.get(entity.relam) : undefined, + ); + }); + introspection.indexes.forEach((entity) => { + entity._type = "PgIndex"; + entity.getIndexClass = memo(() => getClass(entity.indexrelid)); + entity.getClass = memo(() => getClass(entity.indrelid)); + entity.getKeys = memo(() => { + const owner = getClass(entity.indrelid); + const keys = entity.indkey; + return keys.map((key) => + key === 0 + ? null + : attributeByRelIdAndNum.get(attributeRelNumKey(owner!._id, key))!, + ); + }); + entity.getDescription = memo(() => + entity.getIndexClass()?.getDescription(), + ); + entity.getTagsAndDescription = memo( + () => + entity.getIndexClass()?.getTagsAndDescription() ?? { + tags: Object.create(null), + description: undefined as string | undefined, + }, + ); + entity.getTags = memo(() => entity.getTagsAndDescription().tags); + }); + introspection.attributes.forEach((entity) => { + entity._type = "PgAttribute"; + entity.getClass = memo(() => getClass(entity.attrelid)); + entity.getType = memo(() => getType(entity.atttypid)); + entity.getDescription = memo(() => + getDescription(PG_CLASS, entity.attrelid, entity.attnum), + ); + entity.getTagsAndDescription = memo(() => + getTagsAndDescription(PG_CLASS, entity.attrelid, entity.attnum), + ); + entity.getTags = memo(() => entity.getTagsAndDescription().tags); + entity.getACL = memo(() => + parseAcls( + introspection, + entity.attacl, + entity.getClass()!.relowner, + OBJECT_COLUMN, + ), + ); + }); + introspection.constraints.forEach((entity) => { + entity._type = "PgConstraint"; + entity.getNamespace = memo(() => getNamespace(entity.connamespace)); + entity.getClass = memo(() => getClass(entity.conrelid)); + entity.getAttributes = memo(() => { + const klass = getClass(entity.conrelid); + if (!klass) { + console.error( + `getAttributes() called on constraint '${entity.conname}' (type = '${entity.contype}'), but we could not find the constraint's table (oid = '${entity.conrelid}') in the introspection results; returning empty array`, + ); + return []; + } + if (!entity.conkey) { + if (entity.contype === "f") { + console.error( + `getAttributes() called on constraint '${entity.conname}' (type = '${entity.contype}'), but that constraint has no 'conkey'; returning empty array`, + ); + return []; + } else { + return; + } + } + return entity.conkey.map( + (key) => + attributeByRelIdAndNum.get(attributeRelNumKey(klass._id, key))!, + ); + }); + entity.getType = memo(() => getType(entity.contypid)); + entity.getForeignClass = memo(() => getClass(entity.confrelid)); + entity.getForeignAttributes = memo(() => { + if (entity.confrelid == null) { + if (entity.contype === "f") { + console.error( + `getForeignAttributes() called on constraint '${entity.conname}' (type = '${entity.contype}'), but that constraint has no 'confrelid'; returning empty array`, + ); + return []; + } else { + return; + } + } + const klass = getClass(entity.confrelid); + if (!klass) { + console.error( + `getForeignAttributes() called on constraint '${entity.conname}' (type = '${entity.contype}'), but we could not find the constraint's foreign table (oid = '${entity.confrelid}') in the introspection results; returning empty array`, + ); + return []; + } + if (!entity.confkey) { + if (entity.contype === "f") { + console.error( + `getForeignAttributes() called on constraint '${entity.conname}' (type = '${entity.contype}'), but that constraint has no 'confkey'; returning empty array`, + ); + return []; + } else { + return; + } + } + return entity.confkey.map( + (key) => + attributeByRelIdAndNum.get(attributeRelNumKey(klass._id, key))!, + ); + }); + entity.getDescription = memo(() => + getDescription(PG_CONSTRAINT, entity._id), + ); + entity.getTagsAndDescription = memo(() => + getTagsAndDescription(PG_CONSTRAINT, entity._id), + ); + entity.getTags = memo(() => entity.getTagsAndDescription().tags); + }); + introspection.procs.forEach((entity) => { + entity._type = "PgProc"; + entity.getNamespace = memo(() => getNamespace(entity.pronamespace)); + entity.getOwner = memo(() => getRole(entity.proowner)); + entity.getReturnType = memo(() => getType(entity.prorettype)); + entity.getDescription = memo(() => getDescription(PG_PROC, entity._id)); + entity.getTagsAndDescription = memo(() => + getTagsAndDescription(PG_PROC, entity._id), + ); + entity.getTags = memo(() => entity.getTagsAndDescription().tags); + entity.getArguments = memo(() => { + const args: PgProcArgument[] = []; + const { + // Null if everything 'in'. i: IN, o: OUT, b: INOUT, v: VARIADIC, t: TABLE + proargmodes, + // Null if no names + proargnames, + // Only input types + proargtypes, + // Null if no arg defaults, length = pronargdefaults + // proargdefaults, + // Number of inpart args + // pronargs, + // All args, null if all args are input + proallargtypes, + // Number of arguments with defaults + pronargdefaults, + } = entity; + if (proallargtypes) { + for (let i = 0, l = proallargtypes.length; i < l; i++) { + const typeId = proallargtypes[i]; + const type = typeById.get(typeId); + if (!type) { + throw new Error("Corrupted introspection data"); + } + const mode = proargmodes?.[i] ?? "i"; + const isIn = mode === "i" || mode === "b" || mode === "v"; + const isOut = mode === "o" || mode === "b" || mode === "t"; + const isVariadic = mode === "v"; + const hasDefault = pronargdefaults ? i >= l - pronargdefaults : false; + const name = proargnames?.[i] ?? null; + + args.push({ + isIn, + isOut, + isVariadic, + hasDefault, + type, + name, + }); + } + } else if (proargtypes) { + for (let i = 0, l = proargtypes.length; i < l; i++) { + const typeId = proargtypes[i]; + const type = typeById.get(typeId); + if (!type) { + throw new Error("Corrupted introspection data"); + } + const mode = proargmodes?.[i] ?? "i"; + const isIn = mode === "i" || mode === "b" || mode === "v"; + const isOut = mode === "o" || mode === "b" || mode === "t"; + const isVariadic = mode === "v"; + const hasDefault = pronargdefaults ? i >= l - pronargdefaults : false; + const name = proargnames?.[i] ?? null; + + args.push({ + isIn, + isOut, + isVariadic, + hasDefault, + type, + name, + }); + } + } + + return args; + }); + entity.getACL = memo(() => + parseAcls(introspection, entity.proacl, entity.proowner, OBJECT_FUNCTION), + ); + }); + introspection.types.forEach((entity) => { + entity._type = "PgType"; + entity.getNamespace = memo(() => getNamespace(entity.typnamespace)); + entity.getOwner = memo(() => getRole(entity.typowner)); + entity.getClass = memo(() => getClass(entity.typrelid)); + entity.getElemType = memo(() => getType(entity.typelem)); + entity.getArrayType = memo(() => getType(entity.typarray)); + entity.getEnumValues = memo(() => getEnums(entity._id)); + entity.getRange = memo(() => getRange(entity._id)); + entity.getDescription = memo(() => getDescription(PG_TYPE, entity._id)); + entity.getTagsAndDescription = memo(() => + getTagsAndDescription(PG_TYPE, entity._id), + ); + entity.getTags = memo(() => entity.getTagsAndDescription().tags); + }); + introspection.enums.forEach((entity) => { + entity._type = "PgEnum"; + entity.getType = memo(() => getType(entity.enumtypid)); + // Postgres doesn't support comments on enum values right now, but we still + // want to be able to add tags/description so we fake it. + entity.getTagsAndDescription = memo(() => ({ + tags: Object.create(null), + description: "", + })); + entity.getTags = memo(() => entity.getTagsAndDescription().tags); + }); + introspection.ranges.forEach((entity) => { + entity._type = "PgRange"; + entity.getType = memo(() => getType(entity.rngtypid)); + entity.getSubType = memo(() => getType(entity.rngsubtype)); + }); + + introspection.inherits.forEach((entity) => { + entity.getParent = () => classById.get(entity.inhparent); + entity.getChild = () => classById.get(entity.inhrelid); + }); + return introspection; +} diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/index.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/index.ts new file mode 100644 index 0000000000..b93cc6f27e --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/index.ts @@ -0,0 +1,277 @@ +import type { + Introspection, + PgAm, + PgAttribute, + PgAuthMembers, + PgClass, + PgConstraint, + PgDatabase, + PgDepend, + PgDescription, + PgEntity, + PgEnum, + PgExtension, + PgIndex, + PgInherits, + PgLanguage, + PgNamespace, + PgProc, + PgProcArgument, + PgRange, + PgRoles, + PgType, +} from "./introspection"; +export { makeIntrospectionQuery } from "./introspection"; +import type { AclObject } from "./acl"; +import { + aclContainsRole, + entityPermissions, + expandRoles, + resolvePermissions, +} from "./acl"; +import { augmentIntrospection } from "./augmentIntrospection"; +import type { + PgSmartTagsAndDescription, + PgSmartTagsDict, +} from "./smartComments"; + +export { default as reservedWords } from "./reservedWords"; +export { parseSmartComment } from "./smartComments"; + +export type { + Introspection, + PgAttribute, + PgAuthMembers, + PgClass, + PgConstraint, + PgDatabase, + PgDepend, + PgDescription, + PgEntity, + PgEnum, + PgExtension, + PgIndex, + PgInherits, + PgLanguage, + PgNamespace, + PgProc, + PgProcArgument, + PgRange, + PgRoles, + PgType, +}; + +export type { AclObject }; +export { aclContainsRole, entityPermissions, expandRoles, resolvePermissions }; + +export function parseIntrospectionResults( + introspectionResults: string, + includeExtensionResources = false, +): Introspection { + return augmentIntrospection(introspectionResults, includeExtensionResources); +} + +export type { PgSmartTagsAndDescription, PgSmartTagsDict }; + +interface IntrospectionLookups { + oidByCatalog: Record; + roleById: Map; + roleByName: Record; + authMembersByMemberId: Map>; + namespaceById: Map; + namespaceByName: Map; + typeById: Map; + classById: Map; + classByNamespaceName: Map; + rangeByTypid: Map; + attributesByRelId: Map; + attributeByRelIdAndNum: Map; + constraintById: Map; + constraintsByRelid: Map; + constraintByNamespaceName: Map; + foreignConstraintsByRelid: Map; + enumById: Map; + enumsByTypid: Map; + indexById: Map; + indexesByRelid: Map; + descriptionByDescriptionKey: Map; + procById: Map; + procsByNamespaceName: Map; + extensionById: Map; + languageById: Map; + accessMethodById: Map; +} +interface IntrospectionCaches { + expandRoles: Map; +} + +declare module "./introspection.js" { + interface Introspection { + /** + * Use Record/Map to turn O(N) lookups into O(1) lookups. Populated at + * startup. + * + * @internal + */ + _lookups: IntrospectionLookups; + + /** + * For calculated values that should not be pre-computed (since they may + * not be needed), add caches here to avoid needing WeakMap. + * + * @internal + */ + _caches: IntrospectionCaches; + + getCurrentUser(): PgRoles | undefined; + + getNamespace( + by: { id: string } | { name: string }, + ): PgNamespace | undefined; + getClass(by: { id: string }): PgClass | undefined; + getConstraint(by: { id: string }): PgConstraint | undefined; + getProc(by: { id: string }): PgProc | undefined; + getRoles(by: { id: string }): PgRoles | undefined; + getType(by: { id: string }): PgType | undefined; + getEnum(by: { id: string }): PgEnum | undefined; + getExtension(by: { id: string }): PgExtension | undefined; + getIndex(by: { id: string }): PgIndex | undefined; + getLanguage(by: { id: string }): PgLanguage | undefined; + } + + interface PgProcArgument { + isIn: boolean; + isOut: boolean; + isVariadic: boolean; + hasDefault: boolean; + type: PgType; + name: string | null; + } + + interface PgDatabase { + _type: "PgDatabase"; + /** @deprecated Use getOwner instead */ + getDba(): PgRoles | undefined; + getOwner(): PgRoles | undefined; + getACL(): AclObject[]; + } + interface PgNamespace { + _type: "PgNamespace"; + getOwner(): PgRoles | undefined; + getDescription(): string | undefined; + getTagsAndDescription(): PgSmartTagsAndDescription; + /** Convenience method for getTagsAndDescription().tags */ + getTags(): PgSmartTagsDict; + getACL(): AclObject[]; + + getClass(by: { name: string }): PgClass | undefined; + getConstraint(by: { name: string }): PgConstraint | undefined; + getProcs(by: { name: string }): PgProc[] | undefined; + } + interface PgClass { + _type: "PgClass"; + getNamespace(): PgNamespace | undefined; + getType(): PgType | undefined; + getOfType(): PgType | undefined; + getOwner(): PgRoles | undefined; + getAttributes(): PgAttribute[]; + getAttribute( + by: { number: number } | { name: string }, + ): PgAttribute | undefined; + getConstraints(): PgConstraint[]; + getForeignConstraints(): PgConstraint[]; + getIndexes(): PgIndex[]; + getDescription(): string | undefined; + getTagsAndDescription(): PgSmartTagsAndDescription; + /** Convenience method for getTagsAndDescription().tags */ + getTags(): PgSmartTagsDict; + getACL(): AclObject[]; + getInherited(): PgInherits[]; + getAccessMethod(): PgAm | undefined; + } + interface PgIndex { + _type: "PgIndex"; + /** + * Get the class that represents this index. + */ + getIndexClass(): PgClass | undefined; + /** + * Get the class (typically table) this index is for. + */ + getClass(): PgClass | undefined; + /** + * The PgAttributes that are in this index; a null indicates that the entry + * is an expression rather than a column. + */ + getKeys(): Array; + getDescription(): string | undefined; + getTagsAndDescription(): PgSmartTagsAndDescription; + /** Convenience method for getTagsAndDescription().tags */ + getTags(): PgSmartTagsDict; + } + interface PgAttribute { + _type: "PgAttribute"; + getClass(): PgClass | undefined; + getType(): PgType | undefined; + getDescription(): string | undefined; + getTagsAndDescription(): PgSmartTagsAndDescription; + /** Convenience method for getTagsAndDescription().tags */ + getTags(): PgSmartTagsDict; + getACL(): AclObject[]; + } + interface PgConstraint { + _type: "PgConstraint"; + getNamespace(): PgNamespace | undefined; + getClass(): PgClass | undefined; + getAttributes(): PgAttribute[] | undefined; + getType(): PgType | undefined; + getForeignClass(): PgClass | undefined; + getForeignAttributes(): PgAttribute[] | undefined; + getDescription(): string | undefined; + getTagsAndDescription(): PgSmartTagsAndDescription; + /** Convenience method for getTagsAndDescription().tags */ + getTags(): PgSmartTagsDict; + } + interface PgProc { + _type: "PgProc"; + getNamespace(): PgNamespace | undefined; + getOwner(): PgRoles | undefined; + getReturnType(): PgType | undefined; + getDescription(): string | undefined; + getTagsAndDescription(): PgSmartTagsAndDescription; + /** Convenience method for getTagsAndDescription().tags */ + getTags(): PgSmartTagsDict; + getArguments(): PgProcArgument[]; + getACL(): AclObject[]; + } + interface PgType { + _type: "PgType"; + getNamespace(): PgNamespace | undefined; + getOwner(): PgRoles | undefined; + getClass(): PgClass | undefined; + getElemType(): PgType | undefined; + getArrayType(): PgType | undefined; + getEnumValues(): PgEnum[] | undefined; + getRange(): PgRange | undefined; + getDescription(): string | undefined; + getTagsAndDescription(): PgSmartTagsAndDescription; + /** Convenience method for getTagsAndDescription().tags */ + getTags(): PgSmartTagsDict; + } + interface PgEnum { + _type: "PgEnum"; + getType(): PgType | undefined; + getTagsAndDescription(): PgSmartTagsAndDescription; + /** Convenience method for getTagsAndDescription().tags */ + getTags(): PgSmartTagsDict; + } + interface PgRange { + _type: "PgRange"; + getType(): PgType | undefined; + getSubType(): PgType | undefined; + } + interface PgInherits { + getParent(): PgClass | undefined; + getChild(): PgClass | undefined; + } +} diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/introspection.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/introspection.ts new file mode 100644 index 0000000000..b1e6075a24 --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/introspection.ts @@ -0,0 +1,1787 @@ +/* + * THIS FILE IS AUTOGENERATED! + * + * DO NOT EDIT! + */ + +type PgOid = string; +type PgName = string; +type PgAclItem = string; +type PgXid = string; +type TimestampTZ = string; + +/** + * The catalog pg_database stores information about the available databases. Databases are created with the CREATE + * DATABASE command. Consult [managing-databases] for details about the meaning of some of the parameters. + */ +export interface PgDatabase { + /* COMMON FIELDS */ + + /** Row identifier */ + _id: PgOid; + + /** Database name */ + datname: PgName; + + /** Owner of the database, usually the user who created it */ + datdba: PgOid; + + /** Character encoding for this database (pg_encoding_to_char() can translate this number to the encoding name) */ + encoding: number | null; + + /** + * If true, then this database can be cloned by any user with CREATEDB privileges; if false, then only superusers or + * the owner of the database can clone it. + */ + datistemplate: boolean | null; + + /** + * If false then no one can connect to this database. This is used to protect the template0 database from being + * altered. + */ + datallowconn: boolean | null; + + /** + * Sets maximum number of concurrent connections that can be made to this database. -1 means no limit, -2 indicates the + * database is invalid. + */ + datconnlimit: number | null; + + /** + * All transaction IDs before this one have been replaced with a permanent (frozen) transaction ID in this database. + * This is used to track whether the database needs to be vacuumed in order to prevent transaction ID wraparound or to + * allow pg_xact to be shrunk. It is the minimum of the per-table pg_class.relfrozenxid values. + */ + datfrozenxid: PgXid | null; + + /** + * All multixact IDs before this one have been replaced with a transaction ID in this database. This is used to track + * whether the database needs to be vacuumed in order to prevent multixact ID wraparound or to allow pg_multixact to be + * shrunk. It is the minimum of the per-table pg_class.relminmxid values. + */ + datminmxid: PgXid | null; + + /** + * The default tablespace for the database. Within this database, all tables for which pg_class.reltablespace is zero + * will be stored in this tablespace; in particular, all the non-shared system catalogs will be there. + */ + dattablespace: PgOid | null; + + /** LC_COLLATE for this database */ + datcollate: string | null; + + /** LC_CTYPE for this database */ + datctype: string | null; + + /** Access privileges; see [ddl-priv] for details */ + datacl: ReadonlyArray | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * Locale provider for this database: + * - b = builtin, + * - c = libc, + * - i = icu + * + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x + */ + datlocprovider?: string | null | undefined; + + /** + * Indicates that there are login event triggers defined for this database. This flag is used to avoid extra lookups on + * the pg_event_trigger table during each backend startup. This flag is used internally by PostgreSQL and should not be + * manually altered or read for monitoring purposes. + * + * @remarks Only in 18.x, 17.x + */ + dathasloginevt?: boolean | null | undefined; + + /** + * Collation provider locale name for this database. If the provider is libc, datlocale is NULL; datcollate and + * datctype are used instead. + * + * @remarks Only in 18.x, 17.x + */ + datlocale?: string | null | undefined; + + /** + * ICU collation rules for this database + * + * @remarks Only in 18.x, 17.x, 16.x + */ + daticurules?: string | null | undefined; + + /** + * Provider-specific version of the collation. This is recorded when the database is created and then checked when it + * is used, to detect changes in the collation definition that could lead to data corruption. + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x + */ + datcollversion?: string | null | undefined; + + /** + * ICU locale ID for this database + * + * @remarks Only in 16.x, 15.x + */ + daticulocale?: string | null | undefined; + + /** + * Last system OID in the database; useful particularly to pg_dump + * + * @remarks Only in 14.x, 13.x, 12.x, 11.x, 10.x + */ + datlastsysoid?: PgOid | null | undefined; +} + +/** + * The catalog pg_namespace stores namespaces. A namespace is the structure underlying SQL schemas: each namespace can + * have a separate collection of relations, types, etc. without name conflicts. + */ +export interface PgNamespace { + /** Row identifier */ + _id: PgOid; + + /** Name of the namespace */ + nspname: PgName; + + /** Owner of the namespace */ + nspowner: PgOid; + + /** Access privileges; see [ddl-priv] for details */ + nspacl: ReadonlyArray | null; +} + +/** + * The catalog pg_class describes tables and other objects that have columns or are otherwise similar to a table. This + * includes indexes (but see also pg_index), sequences (but see also pg_sequence), views, materialized views, composite + * types, and TOAST tables; see relkind. Below, when we mean all of these kinds of objects we speak of relations. Not + * all of pg_class's columns are meaningful for all relation kinds. + */ +export interface PgClass { + /* COMMON FIELDS */ + + /** Row identifier */ + _id: PgOid; + + /** Name of the table, index, view, etc. */ + relname: PgName; + + /** The OID of the namespace that contains this relation */ + relnamespace: PgOid; + + /** + * The OID of the data type that corresponds to this table's row type, if any; zero for indexes, sequences, and toast + * tables, which have no pg_type entry + */ + reltype: PgOid; + + /** For typed tables, the OID of the underlying composite type; zero for all other relations */ + reloftype: PgOid | null; + + /** Owner of the relation */ + relowner: PgOid; + + /** + * The access method used to access this table or index. Not meaningful if the relation is a sequence or has no on-disk + * file, except for partitioned tables, where, if set, it takes precedence over default_table_access_method when + * determining the access method to use for partitions created when one is not specified in the creation command. + */ + relam: PgOid | null; + + /** + * Name of the on-disk file of this relation; zero means this is a mapped relation whose disk file name is determined + * by low-level state + */ + relfilenode: PgOid | null; + + /** + * The tablespace in which this relation is stored. If zero, the database's default tablespace is implied. Not + * meaningful if the relation has no on-disk file, except for partitioned tables, where this is the tablespace in which + * partitions will be created when one is not specified in the creation command. + */ + reltablespace: PgOid | null; + + /** + * Size of the on-disk representation of this table in pages (of size BLCKSZ). This is only an estimate used by the + * planner. It is updated by VACUUM, ANALYZE, and a few DDL commands such as CREATE INDEX. + */ + relpages: number | null; + + /** + * Number of live rows in the table. This is only an estimate used by the planner. It is updated by VACUUM, ANALYZE, + * and a few DDL commands such as CREATE INDEX. If the table has never yet been vacuumed or analyzed, reltuples + * contains -1 indicating that the row count is unknown. + */ + reltuples: number | null; + + /** + * Number of pages that are marked all-visible in the table's visibility map. This is only an estimate used by the + * planner. It is updated by VACUUM, ANALYZE, and a few DDL commands such as CREATE INDEX. + */ + relallvisible: number | null; + + /** + * OID of the TOAST table associated with this table, zero if none. The TOAST table stores large attributes out of line + * in a secondary table. + */ + reltoastrelid: PgOid | null; + + /** True if this is a table and it has (or recently had) any indexes */ + relhasindex: boolean | null; + + /** + * True if this table is shared across all databases in the cluster. Only certain system catalogs (such as pg_database) + * are shared. + */ + relisshared: boolean | null; + + /** p = permanent table/sequence, u = unlogged table/sequence, t = temporary table/sequence */ + relpersistence: string | null; + + /** + * - r = ordinary table, + * - i = index, + * - S = sequence, + * - t = TOAST table, + * - v = view, + * - m = materialized view, + * - c = composite type, + * - f = foreign table, + * - p = partitioned table, + * - I = partitioned index + */ + relkind: string; + + /** + * Number of user columns in the relation (system columns not counted). There must be this many corresponding entries + * in pg_attribute. See also pg_attribute.attnum. + */ + relnatts: number | null; + + /** Number of CHECK constraints on the table; see pg_constraint catalog */ + relchecks: number | null; + + /** True if table has (or once had) rules; see pg_rewrite catalog */ + relhasrules: boolean | null; + + /** True if table has (or once had) triggers; see pg_trigger catalog */ + relhastriggers: boolean | null; + + /** True if table or index has (or once had) any inheritance children or partitions */ + relhassubclass: boolean | null; + + /** True if table has row-level security enabled; see pg_policy catalog */ + relrowsecurity: boolean | null; + + /** True if row-level security (when enabled) will also apply to table owner; see pg_policy catalog */ + relforcerowsecurity: boolean | null; + + /** True if relation is populated (this is true for all relations other than some materialized views) */ + relispopulated: boolean | null; + + /** + * Columns used to form replica identity for rows: + * - d = default (primary key, if any), + * - n = nothing, + * - f = all columns, + * - i = index with indisreplident set + * (same as nothing if the index used has been dropped) + */ + relreplident: string | null; + + /** True if table or index is a partition */ + relispartition: boolean | null; + + /** + * All transaction IDs before this one have been replaced with a permanent (frozen) transaction ID in this table. This + * is used to track whether the table needs to be vacuumed in order to prevent transaction ID wraparound or to allow + * pg_xact to be shrunk. Zero (InvalidTransactionId) if the relation is not a table. + */ + relfrozenxid: PgXid | null; + + /** + * All multixact IDs before this one have been replaced by a transaction ID in this table. This is used to track + * whether the table needs to be vacuumed in order to prevent multixact ID wraparound or to allow pg_multixact to be + * shrunk. Zero (InvalidMultiXactId) if the relation is not a table. + */ + relminmxid: PgXid | null; + + /** Access privileges; see [ddl-priv] for details */ + relacl: ReadonlyArray | null; + + /** Access-method-specific options, as keyword=value strings */ + reloptions: ReadonlyArray | null; + + /** If table is a partition (see relispartition), internal representation of the partition bound */ + relpartbound: string | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * Number of pages that are marked all-frozen in the table's visibility map. This is only an estimate used for + * triggering autovacuums. It can also be used along with relallvisible for scheduling manual vacuums and tuning + * vacuum's freezing behavior. It is updated by VACUUM, ANALYZE, and a few DDL commands such as CREATE INDEX. + * + * @remarks Only in 18.x + */ + relallfrozen?: number | null | undefined; + + /** + * For new relations being written during a DDL operation that requires a table rewrite, this contains the OID of the + * original relation; otherwise zero. That state is only visible internally; this field should never contain anything + * other than zero for a user-visible relation. + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x, 13.x, 12.x, 11.x + */ + relrewrite?: PgOid | null | undefined; + + /** + * True if we generate an OID for each row of the relation + * + * @remarks Only in 11.x, 10.x + */ + relhasoids?: boolean | null | undefined; + + /** + * True if the table has (or once had) a primary key + * + * @remarks Only in 10.x + */ + relhaspkey?: boolean | null | undefined; + + /* EXTRA FIELDS */ + + updatable_mask?: number | null; +} + +/** + * The catalog pg_attribute stores information about table columns. There will be exactly one pg_attribute row for + * every column in every table in the database. (There will also be attribute entries for indexes, and indeed all + * objects that have pg_class entries.) + */ +export interface PgAttribute { + /* COMMON FIELDS */ + + /** The table this column belongs to */ + attrelid: PgOid; + + /** The column name */ + attname: PgName; + + /** The data type of this column (zero for a dropped column) */ + atttypid: PgOid; + + /** A copy of pg_type.typlen of this column's type */ + attlen: number | null; + + /** + * The number of the column. Ordinary columns are numbered from 1 up. System columns, such as ctid, have (arbitrary) + * negative numbers. + */ + attnum: number; + + /** + * atttypmod records type-specific data supplied at table creation time (for example, the maximum length of a varchar + * column). It is passed to type-specific input functions and length coercion functions. The value will generally be -1 + * for types that do not need atttypmod. + */ + atttypmod: number | null; + + /** + * Number of dimensions, if the column is an array type; otherwise 0. (Presently, the number of dimensions of an array + * is not enforced, so any nonzero value effectively means it's an array.) + */ + attndims: number | null; + + /** A copy of pg_type.typbyval of this column's type */ + attbyval: boolean | null; + + /** A copy of pg_type.typalign of this column's type */ + attalign: string | null; + + /** + * Normally a copy of pg_type.typstorage of this column's type. For TOAST-able data types, this can be altered after + * column creation to control storage policy. + */ + attstorage: string | null; + + /** This column has a (possibly invalid) not-null constraint. */ + attnotnull: boolean | null; + + /** + * This column has a default expression or generation expression, in which case there will be a corresponding entry in + * the pg_attrdef catalog that actually defines the expression. (Check attgenerated to determine whether this is a + * default or a generation expression.) + */ + atthasdef: boolean | null; + + /** If a zero byte (''), then not an identity column. Otherwise, a = generated always, d = generated by default. */ + attidentity: string | null; + + /** + * This column has been dropped and is no longer valid. A dropped column is still physically present in the table, but + * is ignored by the parser and so cannot be accessed via SQL. + */ + attisdropped: boolean | null; + + /** + * This column is defined locally in the relation. Note that a column can be locally defined and inherited + * simultaneously. + */ + attislocal: boolean | null; + + /** + * The number of direct ancestors this column has. A column with a nonzero number of ancestors cannot be dropped nor + * renamed. + */ + attinhcount: number | null; + + /** The defined collation of the column, or zero if the column is not of a collatable data type */ + attcollation: PgOid | null; + + /** + * attstattarget controls the level of detail of statistics accumulated for this column by ANALYZE. A zero value + * indicates that no statistics should be collected. A null value says to use the system default statistics target. The + * exact meaning of positive values is data type-dependent. For scalar data types, attstattarget is both the target + * number of most common values to collect, and the target number of histogram bins to create. + */ + attstattarget: number | null; + + /** Column-level access privileges, if any have been granted specifically on this column */ + attacl: ReadonlyArray | null; + + /** Attribute-level options, as keyword=value strings */ + attoptions: ReadonlyArray | null; + + /** Attribute-level foreign data wrapper options, as keyword=value strings */ + attfdwoptions: ReadonlyArray | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * The current compression method of the column. Typically this is `\0` to specify use of the current default setting + * (see [guc-default-toast-compression]). Otherwise, 'p' selects pglz compression, while 'l' selects LZ4 compression. + * However, this field is ignored whenever attstorage does not allow compression. + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x + */ + attcompression?: string | null | undefined; + + /** + * This column has a value which is used where the column is entirely missing from the row, as happens when a column is + * added with a non-volatile DEFAULT value after the row is created. The actual value used is stored in the + * attmissingval column. + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x, 13.x, 12.x, 11.x + */ + atthasmissing?: boolean | null | undefined; + + /** + * If a zero byte (''), then not a generated column. Otherwise, s = stored, v = virtual. A stored generated column is + * physically stored like a normal column. A virtual generated column is physically stored as a null value, with the + * actual value being computed at run time. + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x, 13.x, 12.x + */ + attgenerated?: string | null | undefined; + + /** + * Always -1 in storage, but when loaded into a row descriptor in memory this might be updated to cache the offset of + * the attribute within the row + * + * @remarks Only in 17.x, 16.x, 15.x, 14.x, 13.x, 12.x, 11.x, 10.x + */ + attcacheoff?: number | null | undefined; +} + +/** + * The catalog pg_constraint stores check, not-null, primary key, unique, foreign key, and exclusion constraints on + * tables. (Column constraints are not treated specially. Every column constraint is equivalent to some table + * constraint.) + */ +export interface PgConstraint { + /* COMMON FIELDS */ + + /** Row identifier */ + _id: PgOid; + + /** Constraint name (not necessarily unique!) */ + conname: PgName; + + /** The OID of the namespace that contains this constraint */ + connamespace: PgOid; + + /** + * - c = check constraint, + * - f = foreign key constraint, + * - n = not-null constraint, + * - p = primary key constraint, + * - u = unique constraint, + * - t = constraint trigger, + * - x = exclusion constraint + */ + contype: string; + + /** Is the constraint deferrable? */ + condeferrable: boolean | null; + + /** Is the constraint deferred by default? */ + condeferred: boolean | null; + + /** Has the constraint been validated? */ + convalidated: boolean | null; + + /** The table this constraint is on; zero if not a table constraint */ + conrelid: PgOid; + + /** The domain this constraint is on; zero if not a domain constraint */ + contypid: PgOid; + + /** + * The index supporting this constraint, if it's a unique, primary key, foreign key, or exclusion constraint; else + * zero + */ + conindid: PgOid; + + /** If a foreign key, the referenced table; else zero */ + confrelid: PgOid; + + /** + * Foreign key update action code: + * - a = no action, + * - r = restrict, + * - c = cascade, + * - n = set null, + * - d = set default + */ + confupdtype: string | null; + + /** + * Foreign key deletion action code: + * - a = no action, + * - r = restrict, + * - c = cascade, + * - n = set null, + * - d = set default + */ + confdeltype: string | null; + + /** + * Foreign key match type: + * - f = full, + * - p = partial, + * - s = simple + */ + confmatchtype: string | null; + + /** + * This constraint is defined locally for the relation. Note that a constraint can be locally defined and inherited + * simultaneously. + */ + conislocal: boolean | null; + + /** + * The number of direct inheritance ancestors this constraint has. A constraint with a nonzero number of ancestors + * cannot be dropped nor renamed. + */ + coninhcount: number | null; + + /** This constraint is defined locally for the relation. It is a non-inheritable constraint. */ + connoinherit: boolean | null; + + /** If a table constraint (including foreign keys, but not constraint triggers), list of the constrained columns */ + conkey: ReadonlyArray | null; + + /** If a foreign key, list of the referenced columns */ + confkey: ReadonlyArray | null; + + /** If a foreign key, list of the equality operators for PK = FK comparisons */ + conpfeqop: ReadonlyArray | null; + + /** If a foreign key, list of the equality operators for PK = PK comparisons */ + conppeqop: ReadonlyArray | null; + + /** If a foreign key, list of the equality operators for FK = FK comparisons */ + conffeqop: ReadonlyArray | null; + + /** + * If an exclusion constraint or WITHOUT OVERLAPS primary key/unique constraint, list of the per-column exclusion + * operators. + */ + conexclop: ReadonlyArray | null; + + /** + * If a check constraint, an internal representation of the expression. (It's recommended to use pg_get_constraintdef() + * to extract the definition of a check constraint.) + */ + conbin: string | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * Is the constraint enforced? + * + * @remarks Only in 18.x + */ + conenforced?: boolean | null | undefined; + + /** + * The corresponding constraint of the parent partitioned table, if this is a constraint on a partition; else zero + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x, 13.x, 12.x, 11.x + */ + conparentid?: PgOid | null | undefined; + + /** + * This constraint is defined with WITHOUT OVERLAPS (for primary keys and unique constraints) or PERIOD (for foreign + * keys). + * + * @remarks Only in 18.x + */ + conperiod?: boolean | null | undefined; + + /** + * If a foreign key with a SET NULL or SET DEFAULT delete action, the columns that will be updated. If null, all of the + * referencing columns will be updated. + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x + */ + confdelsetcols?: ReadonlyArray | null | undefined; + + /** + * If a check constraint, a human-readable representation of the expression + * + * @remarks Only in 11.x, 10.x + */ + consrc?: string | null | undefined; +} + +/** + * The catalog pg_proc stores information about functions, procedures, aggregate functions, and window functions + * (collectively also known as routines). See [sql-createfunction], [sql-createprocedure], and [xfunc] for more + * information. + */ +export interface PgProc { + /* COMMON FIELDS */ + + /** Row identifier */ + _id: PgOid; + + /** Name of the function */ + proname: PgName; + + /** The OID of the namespace that contains this function */ + pronamespace: PgOid; + + /** Owner of the function */ + proowner: PgOid; + + /** Implementation language or call interface of this function */ + prolang: PgOid | null; + + /** Estimated execution cost (in units of [guc-cpu-operator-cost]); if proretset, this is cost per row returned */ + procost: number | null; + + /** Estimated number of result rows (zero if not proretset) */ + prorows: number | null; + + /** Data type of the variadic array parameter's elements, or zero if the function does not have a variadic parameter */ + provariadic: PgOid | null; + + /** Function is a security definer (i.e., a setuid function) */ + prosecdef: boolean | null; + + /** + * The function has no side effects. No information about the arguments is conveyed except via the return value. Any + * function that might throw an error depending on the values of its arguments is not leakproof. + */ + proleakproof: boolean | null; + + /** + * Function returns null if any call argument is null. In that case the function won't actually be called at all. + * Functions that are not strict must be prepared to handle null inputs. + */ + proisstrict: boolean | null; + + /** Function returns a set (i.e., multiple values of the specified data type) */ + proretset: boolean; + + /** + * provolatile tells whether the function's result depends only on its input arguments, or is affected by outside + * factors. It is i for immutable functions, which always deliver the same result for the same inputs. It is s for + * stable functions, whose results (for fixed inputs) do not change within a scan. It is v for volatile functions, + * whose results might change at any time. (Use v also for functions with side-effects, so that calls to them cannot + * get optimized away.) + */ + provolatile: string | null; + + /** + * proparallel tells whether the function can be safely run in parallel mode. It is s for functions which are safe to + * run in parallel mode without restriction. It is r for functions which can be run in parallel mode, but their + * execution is restricted to the parallel group leader; parallel worker processes cannot invoke these functions. It is + * u for functions which are unsafe in parallel mode; the presence of such a function forces a serial execution plan. + */ + proparallel: string | null; + + /** Number of input arguments */ + pronargs: number | null; + + /** Number of arguments that have defaults */ + pronargdefaults: number | null; + + /** Data type of the return value */ + prorettype: PgOid; + + /** + * An array of the data types of the function arguments. This includes only input arguments (including INOUT and + * VARIADIC arguments), and thus represents the call signature of the function. + */ + proargtypes: ReadonlyArray | null; + + /** + * An array of the data types of the function arguments. This includes all arguments (including OUT and INOUT + * arguments); however, if all the arguments are IN arguments, this field will be null. Note that subscripting is + * 1-based, whereas for historical reasons proargtypes is subscripted from 0. + */ + proallargtypes: ReadonlyArray | null; + + /** + * An array of the modes of the function arguments, encoded as i for IN arguments, o for OUT arguments, b for INOUT + * arguments, v for VARIADIC arguments, t for TABLE arguments. If all the arguments are IN arguments, this field will + * be null. Note that subscripts correspond to positions of proallargtypes not proargtypes. + */ + proargmodes: ReadonlyArray | null; + + /** + * An array of the names of the function arguments. Arguments without a name are set to empty strings in the array. If + * none of the arguments have a name, this field will be null. Note that subscripts correspond to positions of + * proallargtypes not proargtypes. + */ + proargnames: ReadonlyArray | null; + + /** + * Expression trees (in nodeToString() representation) for default values. This is a list with pronargdefaults + * elements, corresponding to the last N input arguments (i.e., the last N proargtypes positions). If none of the + * arguments have defaults, this field will be null. + */ + proargdefaults: string | null; + + /** + * An array of the argument/result data type(s) for which to apply transforms (from the function's TRANSFORM clause). + * Null if none. + */ + protrftypes: ReadonlyArray | null; + + /** + * This tells the function handler how to invoke the function. It might be the actual source code of the function for + * interpreted languages, a link symbol, a file name, or just about anything else, depending on the implementation + * language/call convention. + */ + prosrc: string | null; + + /** Additional information about how to invoke the function. Again, the interpretation is language-specific. */ + probin: string | null; + + /** Function's local settings for run-time configuration variables */ + proconfig: ReadonlyArray | null; + + /** Access privileges; see [ddl-priv] for details */ + proacl: ReadonlyArray | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * Planner support function for this function (see [xfunc-optimization]), or zero if none + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x, 13.x, 12.x + */ + prosupport?: PgOid | null | undefined; + + /** + * f for a normal function, p for a procedure, a for an aggregate function, or w for a window function + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x, 13.x, 12.x, 11.x + */ + prokind?: string | null | undefined; + + /** + * Pre-parsed SQL function body. This is used for SQL-language functions when the body is given in SQL-standard + * notation rather than as a string literal. It's null in other cases. + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x + */ + prosqlbody?: string | null | undefined; + + /** + * Calls to this function can be simplified by this other function (see [xfunc-transform-functions]) + * + * @remarks Only in 11.x, 10.x + */ + protransform?: PgOid | null | undefined; + + /** + * Function is an aggregate function + * + * @remarks Only in 10.x + */ + proisagg?: boolean | null | undefined; + + /** + * Function is a window function + * + * @remarks Only in 10.x + */ + proiswindow?: boolean | null | undefined; +} + +/** + * The view pg_roles provides access to information about database roles. This is simply a publicly readable view of + * pg_authid that blanks out the password field. + */ +export interface PgRoles { + /** Role name */ + rolname: PgName; + + /** Role has superuser privileges */ + rolsuper: boolean | null; + + /** Role automatically inherits privileges of roles it is a member of */ + rolinherit: boolean | null; + + /** Role can create more roles */ + rolcreaterole: boolean | null; + + /** Role can create databases */ + rolcreatedb: boolean | null; + + /** Role can log in. That is, this role can be given as the initial session authorization identifier */ + rolcanlogin: boolean | null; + + /** + * Role is a replication role. A replication role can initiate replication connections and create and drop replication + * slots. + */ + rolreplication: boolean | null; + + /** + * For roles that can log in, this sets maximum number of concurrent connections this role can make. -1 means no + * limit. + */ + rolconnlimit: number | null; + + /** Not the password (always reads as ********) */ + rolpassword: string | null; + + /** Password expiry time (only used for password authentication); null if no expiration */ + rolvaliduntil: TimestampTZ | null; + + /** Role bypasses every row-level security policy, see [ddl-rowsecurity] for more information. */ + rolbypassrls: boolean | null; + + /** Role-specific defaults for run-time configuration variables */ + rolconfig: ReadonlyArray | null; + + /** ID of role */ + _id: PgOid; +} + +/** + * The catalog pg_auth_members shows the membership relations between roles. Any non-circular set of relationships is + * allowed. + */ +export interface PgAuthMembers { + /* COMMON FIELDS */ + + /** ID of a role that has a member */ + roleid: PgOid; + + /** ID of a role that is a member of roleid */ + member: PgOid; + + /** ID of the role that granted this membership */ + grantor: PgOid | null; + + /** True if member can grant membership in roleid to others */ + admin_option: boolean | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * Row identifier + * + * @remarks Only in 18.x, 17.x, 16.x + */ + _id?: PgOid | undefined; + + /** + * True if the member automatically inherits the privileges of the granted role + * + * @remarks Only in 18.x, 17.x, 16.x + */ + inherit_option?: boolean | null | undefined; + + /** + * True if the member can SET ROLE to the granted role + * + * @remarks Only in 18.x, 17.x, 16.x + */ + set_option?: boolean | null | undefined; +} + +/** + * The catalog pg_type stores information about data types. Base types and enum types (scalar types) are created with + * CREATE TYPE, and domains with CREATE DOMAIN. A composite type is automatically created for each table in the + * database, to represent the row structure of the table. It is also possible to create composite types with CREATE + * TYPE AS. + */ +export interface PgType { + /* COMMON FIELDS */ + + /** Row identifier */ + _id: PgOid; + + /** Data type name */ + typname: PgName; + + /** The OID of the namespace that contains this type */ + typnamespace: PgOid; + + /** Owner of the type */ + typowner: PgOid | null; + + /** + * For a fixed-size type, typlen is the number of bytes in the internal representation of the type. But for a + * variable-length type, typlen is negative. -1 indicates a varlena type (one that has a length word), -2 indicates a + * null-terminated C string. + */ + typlen: number | null; + + /** + * typbyval determines whether internal routines pass a value of this type by value or by reference. typbyval had + * better be false if typlen is not 1, 2, or 4 (or 8 on machines where Datum is 8 bytes). Variable-length types are + * always passed by reference. Note that typbyval can be false even if the length would allow pass-by-value. + */ + typbyval: boolean | null; + + /** + * typtype is b for a base type, c for a composite type (e.g., a table's row type), d for a domain, e for an enum type, + * p for a pseudo-type, r for a range type, or m for a multirange type. See also typrelid and typbasetype. + */ + typtype: string | null; + + /** + * typcategory is an arbitrary classification of data types that is used by the parser to determine which implicit + * casts should be preferred. See [catalog-typcategory-table]. + */ + typcategory: string | null; + + /** True if the type is a preferred cast target within its typcategory */ + typispreferred: boolean | null; + + /** + * True if the type is defined, false if this is a placeholder entry for a not-yet-defined type. When typisdefined is + * false, nothing except the type name, namespace, and OID can be relied on. + */ + typisdefined: boolean | null; + + /** + * Character that separates two values of this type when parsing array input. Note that the delimiter is associated + * with the array element data type, not the array data type. + */ + typdelim: string | null; + + /** + * If this is a composite type (see typtype), then this column points to the pg_class entry that defines the + * corresponding table. (For a free-standing composite type, the pg_class entry doesn't really represent a table, but + * it is needed anyway for the type's pg_attribute entries to link to.) Zero for non-composite types. + */ + typrelid: PgOid | null; + + /** + * If typelem is not zero then it identifies another row in pg_type, defining the type yielded by subscripting. This + * should be zero if typsubscript is zero. However, it can be zero when typsubscript isn't zero, if the handler doesn't + * need typelem to determine the subscripting result type. Note that a typelem dependency is considered to imply + * physical containment of the element type in this type; so DDL changes on the element type might be restricted by the + * presence of this type. + */ + typelem: PgOid | null; + + /** + * If typarray is not zero then it identifies another row in pg_type, which is the true array type having this type as + * element + */ + typarray: PgOid | null; + + /** Input conversion function (text format) */ + typinput: PgOid | null; + + /** Output conversion function (text format) */ + typoutput: PgOid | null; + + /** Input conversion function (binary format), or zero if none */ + typreceive: PgOid | null; + + /** Output conversion function (binary format), or zero if none */ + typsend: PgOid | null; + + /** Type modifier input function, or zero if type does not support modifiers */ + typmodin: PgOid | null; + + /** Type modifier output function, or zero to use the standard format */ + typmodout: PgOid | null; + + /** Custom [sql-analyze] function, or zero to use the standard function */ + typanalyze: PgOid | null; + + /** + * typalign is the alignment required when storing a value of this type. It applies to storage on disk as well as most + * representations of the value inside PostgreSQL. When multiple values are stored consecutively, such as in the + * representation of a complete row on disk, padding is inserted before a datum of this type so that it begins on the + * specified boundary. The alignment reference is the beginning of the first datum in the sequence. Possible values + * are: c = char alignment, i.e., no alignment needed. s = short alignment (2 bytes on most machines). i = int + * alignment (4 bytes on most machines). d = double alignment (8 bytes on many machines, but by no means all). + */ + typalign: string | null; + + /** + * typstorage tells for varlena types (those with typlen = -1) if the type is prepared for toasting and what the + * default strategy for attributes of this type should be. Possible values are: p (plain): Values must always be stored + * plain (non-varlena types always use this value). e (external): Values can be stored in a secondary TOAST relation + * (if relation has one, see pg_class.reltoastrelid). m (main): Values can be compressed and stored inline. x + * (extended): Values can be compressed and/or moved to a secondary relation. x is the usual choice for toast-able + * types. Note that m values can also be moved out to secondary storage, but only as a last resort (e and x values are + * moved first). + */ + typstorage: string | null; + + /** typnotnull represents a not-null constraint on a type. Used for domains only. */ + typnotnull: boolean | null; + + /** + * If this is a domain (see typtype), then typbasetype identifies the type that this one is based on. Zero if this type + * is not a domain. + */ + typbasetype: PgOid | null; + + /** + * Domains use typtypmod to record the typmod to be applied to their base type (-1 if base type does not use a typmod). + * -1 if this type is not a domain. + */ + typtypmod: number | null; + + /** + * typndims is the number of array dimensions for a domain over an array (that is, typbasetype is an array type). Zero + * for types other than domains over array types. + */ + typndims: number | null; + + /** + * typcollation specifies the collation of the type. If the type does not support collations, this will be zero. A base + * type that supports collations will have a nonzero value here, typically DEFAULT_COLLATION_OID. A domain over a + * collatable type can have a collation OID different from its base type's, if one was specified for the domain. + */ + typcollation: PgOid | null; + + /** + * If typdefaultbin is not null, it is the nodeToString() representation of a default expression for the type. This is + * only used for domains. + */ + typdefaultbin: string | null; + + /** + * typdefault is null if the type has no associated default value. If typdefaultbin is not null, typdefault must + * contain a human-readable version of the default expression represented by typdefaultbin. If typdefaultbin is null + * and typdefault is not, then typdefault is the external representation of the type's default value, which can be fed + * to the type's input converter to produce a constant. + */ + typdefault: string | null; + + /** Access privileges; see [ddl-priv] for details */ + typacl: ReadonlyArray | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * Subscripting handler function's OID, or zero if this type doesn't support subscripting. Types that are true array + * types have typsubscript = array_subscript_handler, but other types may have other handler functions to implement + * specialized subscripting behavior. + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x + */ + typsubscript?: PgOid | null | undefined; +} + +/** + * The pg_enum catalog contains entries showing the values and labels for each enum type. The internal representation + * of a given enum value is actually the OID of its associated row in pg_enum. + */ +export interface PgEnum { + /** Row identifier */ + _id: PgOid; + + /** The OID of the pg_type entry owning this enum value */ + enumtypid: PgOid; + + /** The sort position of this enum value within its enum type */ + enumsortorder: number; + + /** The textual label for this enum value */ + enumlabel: PgName; +} + +/** + * The catalog pg_extension stores information about the installed extensions. See [extend-extensions] for details + * about extensions. + */ +export interface PgExtension { + /** Row identifier */ + _id: PgOid; + + /** Name of the extension */ + extname: PgName; + + /** Owner of the extension */ + extowner: PgOid; + + /** Schema containing the extension's exported objects */ + extnamespace: PgOid | null; + + /** True if extension can be relocated to another schema */ + extrelocatable: boolean | null; + + /** Version name for the extension */ + extversion: string | null; + + /** Array of regclass OIDs for the extension's configuration table(s), or NULL if none */ + extconfig: ReadonlyArray | null; + + /** Array of WHERE-clause filter conditions for the extension's configuration table(s), or NULL if none */ + extcondition: ReadonlyArray | null; +} + +/** The catalog pg_index contains part of the information about indexes. The rest is mostly in pg_class. */ +export interface PgIndex { + /* COMMON FIELDS */ + + /** The OID of the pg_class entry for this index */ + indexrelid: PgOid; + + /** The OID of the pg_class entry for the table this index is for */ + indrelid: PgOid; + + /** + * The total number of columns in the index (duplicates pg_class.relnatts); this number includes both key and included + * attributes + */ + indnatts: number | null; + + /** If true, this is a unique index */ + indisunique: boolean | null; + + /** If true, this index represents the primary key of the table (indisunique should always be true when this is true) */ + indisprimary: boolean | null; + + /** If true, this index supports an exclusion constraint */ + indisexclusion: boolean | null; + + /** If true, the uniqueness check is enforced immediately on insertion (irrelevant if indisunique is not true) */ + indimmediate: boolean | null; + + /** If true, the table was last clustered on this index */ + indisclustered: boolean | null; + + /** + * If true, the index is currently valid for queries. False means the index is possibly incomplete: it must still be + * modified by INSERT/UPDATE operations, but it cannot safely be used for queries. If it is unique, the uniqueness + * property is not guaranteed true either. + */ + indisvalid: boolean | null; + + /** + * If true, queries must not use the index until the xmin of this pg_index row is below their TransactionXmin event + * horizon, because the table may contain broken HOT chains with incompatible rows that they can see + */ + indcheckxmin: boolean | null; + + /** + * If true, the index is currently ready for inserts. False means the index must be ignored by INSERT/UPDATE + * operations. + */ + indisready: boolean | null; + + /** + * If false, the index is in process of being dropped, and should be ignored for all purposes (including HOT-safety + * decisions) + */ + indislive: boolean | null; + + /** If true this index has been chosen as replica identity using ALTER TABLE ... REPLICA IDENTITY USING INDEX ... */ + indisreplident: boolean | null; + + /** + * This is an array of indnatts values that indicate which table columns this index indexes. For example, a value of 1 + * 3 would mean that the first and the third table columns make up the index entries. Key columns come before non-key + * (included) columns. A zero in this array indicates that the corresponding index attribute is an expression over the + * table columns, rather than a simple column reference. + */ + indkey: ReadonlyArray; + + /** + * For each column in the index key (indnkeyatts values), this contains the OID of the collation to use for the index, + * or zero if the column is not of a collatable data type. + */ + indcollation: ReadonlyArray | null; + + /** + * For each column in the index key (indnkeyatts values), this contains the OID of the operator class to use. See + * pg_opclass for details. + */ + indclass: ReadonlyArray | null; + + /** + * This is an array of indnkeyatts values that store per-column flag bits. The meaning of the bits is defined by the + * index's access method. + */ + indoption: ReadonlyArray | null; + + /** + * Expression trees (in nodeToString() representation) for index attributes that are not simple column references. This + * is a list with one element for each zero entry in indkey. Null if all index attributes are simple references. + */ + indexprs: string | null; + + /** Expression tree (in nodeToString() representation) for partial index predicate. Null if not a partial index. */ + indpred: string | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * The number of key columns in the index, not counting any included columns, which are merely stored and do not + * participate in the index semantics + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x, 13.x, 12.x, 11.x + */ + indnkeyatts?: number | null | undefined; + + /** + * This value is only used for unique indexes. If false, this unique index will consider null values distinct (so the + * index can contain multiple null values in a column, the default PostgreSQL behavior). If it is true, it will + * consider null values to be equal (so the index can only contain one null value in a column). + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x + */ + indnullsnotdistinct?: boolean | null | undefined; +} + +/** + * The catalog pg_inherits records information about table and index inheritance hierarchies. There is one entry for + * each direct parent-child table or index relationship in the database. (Indirect inheritance can be determined by + * following chains of entries.) + */ +export interface PgInherits { + /* COMMON FIELDS */ + + /** The OID of the child table or index */ + inhrelid: PgOid; + + /** The OID of the parent table or index */ + inhparent: PgOid; + + /** + * If there is more than one direct parent for a child table (multiple inheritance), this number tells the order in + * which the inherited columns are to be arranged. The count starts at 1. + */ + inhseqno: number | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * true for a partition that is in the process of being detached; false otherwise. + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x + */ + inhdetachpending?: boolean | null | undefined; +} + +/** + * The catalog pg_language registers languages in which you can write functions or stored procedures. See + * [sql-createlanguage] and [xplang] for more information about language handlers. + */ +export interface PgLanguage { + /** Row identifier */ + _id: PgOid; + + /** Name of the language */ + lanname: PgName | null; + + /** Owner of the language */ + lanowner: PgOid | null; + + /** + * This is false for internal languages (such as SQL) and true for user-defined languages. Currently, pg_dump still + * uses this to determine which languages need to be dumped, but this might be replaced by a different mechanism in the + * future. + */ + lanispl: boolean | null; + + /** + * True if this is a trusted language, which means that it is believed not to grant access to anything outside the + * normal SQL execution environment. Only superusers can create functions in untrusted languages. + */ + lanpltrusted: boolean | null; + + /** + * For noninternal languages this references the language handler, which is a special function that is responsible for + * executing all functions that are written in the particular language. Zero for internal languages. + */ + lanplcallfoid: PgOid | null; + + /** + * This references a function that is responsible for executing inline anonymous code blocks ([sql-do] blocks). Zero if + * inline blocks are not supported. + */ + laninline: PgOid | null; + + /** + * This references a language validator function that is responsible for checking the syntax and validity of new + * functions when they are created. Zero if no validator is provided. + */ + lanvalidator: PgOid | null; + + /** Access privileges; see [ddl-priv] for details */ + lanacl: ReadonlyArray | null; +} + +/** + * The catalog pg_policy stores row-level security policies for tables. A policy includes the kind of command that it + * applies to (possibly all commands), the roles that it applies to, the expression to be added as a security-barrier + * qualification to queries that include the table, and the expression to be added as a WITH CHECK option for queries + * that attempt to add new records to the table. + */ +export interface PgPolicy { + /* COMMON FIELDS */ + + /** The name of the policy */ + polname: PgName; + + /** The table to which the policy applies */ + polrelid: PgOid; + + /** + * The command type to which the policy is applied: r for [sql-select], a for [sql-insert], w for [sql-update], d for + * [sql-delete], or * for all + */ + polcmd: string | null; + + /** Is the policy permissive or restrictive? */ + polpermissive: boolean | null; + + /** The roles to which the policy is applied; zero means PUBLIC (and normally appears alone in the array) */ + polroles: ReadonlyArray | null; + + /** The expression tree to be added to the security barrier qualifications for queries that use the table */ + polqual: string | null; + + /** The expression tree to be added to the WITH CHECK qualifications for queries that attempt to add rows to the table */ + polwithcheck: string | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * Row identifier + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x, 13.x + */ + _id?: PgOid | undefined; +} + +/** The catalog pg_range stores information about range types. This is in addition to the types' entries in pg_type. */ +export interface PgRange { + /* COMMON FIELDS */ + + /** OID of the range type */ + rngtypid: PgOid | null; + + /** OID of the element type (subtype) of this range type */ + rngsubtype: PgOid | null; + + /** OID of the collation used for range comparisons, or zero if none */ + rngcollation: PgOid | null; + + /** OID of the subtype's operator class used for range comparisons */ + rngsubopc: PgOid | null; + + /** OID of the function to convert a range value into canonical form, or zero if none */ + rngcanonical: PgOid | null; + + /** OID of the function to return the difference between two element values as double precision, or zero if none */ + rngsubdiff: PgOid | null; + + /* FIELDS THAT AREN'T AVAILABLE IN ALL VERSIONS */ + + /** + * OID of the multirange type for this range type + * + * @remarks Only in 18.x, 17.x, 16.x, 15.x, 14.x + */ + rngmultitypid?: PgOid | null | undefined; +} + +/** + * The catalog pg_depend records the dependency relationships between database objects. This information allows DROP + * commands to find which other objects must be dropped by DROP CASCADE or prevent dropping in the DROP RESTRICT case. + */ +export interface PgDepend { + /** The OID of the system catalog the dependent object is in */ + classid: PgOid; + + /** The OID of the specific dependent object */ + objid: PgOid; + + /** + * For a table column, this is the column number (the objid and classid refer to the table itself). For all other + * object types, this column is zero. + */ + objsubid: number | null; + + /** The OID of the system catalog the referenced object is in */ + refclassid: PgOid; + + /** The OID of the specific referenced object */ + refobjid: PgOid; + + /** + * For a table column, this is the column number (the refobjid and refclassid refer to the table itself). For all other + * object types, this column is zero. + */ + refobjsubid: number | null; + + /** A code defining the specific semantics of this dependency relationship; see text */ + deptype: string; +} + +/** + * The catalog pg_description stores optional descriptions (comments) for each database object. Descriptions can be + * manipulated with the COMMENT command and viewed with psql's `\d` commands. Descriptions of many built-in system + * objects are provided in the initial contents of pg_description. + */ +export interface PgDescription { + /** The OID of the object this description pertains to */ + objoid: PgOid; + + /** The OID of the system catalog this object appears in */ + classoid: PgOid; + + /** + * For a comment on a table column, this is the column number (the objoid and classoid refer to the table itself). For + * all other object types, this column is zero. + */ + objsubid: number; + + /** Arbitrary text that serves as the description of this object */ + description: string; +} + +/** + * The catalog pg_am stores information about relation access methods. There is one row for each access method + * supported by the system. Currently, only tables and indexes have access methods. The requirements for table and + * index access methods are discussed in detail in [tableam] and [indexam] respectively. + */ +export interface PgAm { + /** Row identifier */ + _id: PgOid; + + /** Name of the access method */ + amname: PgName | null; + + /** OID of a handler function that is responsible for supplying information about the access method */ + amhandler: PgOid | null; + + /** t = table (including materialized views), i = index. */ + amtype: string | null; +} + +/** + * This type contains a description of everything we care about in the database. + */ +export interface Introspection { + database: PgDatabase; + namespaces: Array; + classes: Array; + attributes: Array; + constraints: Array; + procs: Array; + roles: Array; + auth_members: Array; + types: Array; + enums: Array; + extensions: Array; + indexes: Array; + inherits: Array; + languages: Array; + policies: Array; + ranges: Array; + depends: Array; + descriptions: Array; + am: Array; + + /** + * Catalogs such as pg_class, pg_attribute, etc have oids; this loopup lets us + * turn the OID back into the name of the underlying catalog. + */ + catalog_by_oid: { [oid: string]: string }; + + /** The user who performed the introspection */ + current_user: string; + /** + * The full PostgreSQL version string, e.g.: + * 'PostgreSQL 13.4 (Ubuntu 13.4-0ubuntu0.21.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 10.3.0-1ubuntu1) 10.3.0, 64-bit' + */ + pg_version: string; + /** In future we might use different introspection queries; we'll bump this whenever an incompatible change takes place. */ + introspection_version: 1; +} + +/** + * A PG entity can be any entity represented in a system catalog: a table, view, + * column, function, index, etc. + */ +export type PgEntity = + | PgDatabase + | PgNamespace + | PgClass + | PgAttribute + | PgConstraint + | PgProc + | PgRoles + | PgAuthMembers + | PgType + | PgEnum + | PgExtension + | PgIndex + | PgInherits + | PgLanguage + | PgPolicy + | PgRange + | PgDepend + | PgDescription + | PgAm; + +// 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. + */ +export const makeIntrospectionQuery = () => `\ +with + 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 nspname <> 'information_schema' + ), + + 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 relnamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%') + ), + + 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 connamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%') + ), + + procs as ( + select pg_proc.oid as _id, * + from pg_catalog.pg_proc + where pronamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%') + and prorettype operator(pg_catalog.<>) 2279 + ), + + roles as ( + select pg_roles.oid as _id, * + from pg_catalog.pg_roles + ), + + auth_members as ( + select * + from pg_catalog.pg_auth_members + where roleid in (select roles._id from roles) + ), + + types as ( + select pg_type.oid as _id, * + from pg_catalog.pg_type + where (typnamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%')) + or (typnamespace = 'pg_catalog'::regnamespace) + ), + + 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 + ), + + 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 + ), + + 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 true + ) +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 +`; diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/reservedWords.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/reservedWords.ts new file mode 100644 index 0000000000..5f5cd90a8d --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/reservedWords.ts @@ -0,0 +1,386 @@ +export default [ + "ABS", + "ABSENT", + "ACOS", + "ALL", + "ALLOCATE", + "ANALYSE", + "ANALYZE", + "AND", + "ANY", + "ANY_VALUE", + "ARE", + "ARRAY", + "ARRAY_AGG", + "ARRAY_MAX_CARDINALITY", + "AS", + "ASC", + "ASENSITIVE", + "ASIN", + "ASYMMETRIC", + "ATAN", + "ATOMIC", + "AUTHORIZATION", + "AVG", + "BEGIN_FRAME", + "BEGIN_PARTITION", + "BETWEEN", + "BIGINT", + "BINARY", + "BIT", + "BIT_LENGTH", + "BLOB", + "BOOLEAN", + "BOTH", + "BTRIM", + "CALL", + "CARDINALITY", + "CASE", + "CAST", + "CEIL", + "CEILING", + "CHAR", + "CHARACTER", + "CHARACTER_LENGTH", + "CHAR_LENGTH", + "CHECK", + "CLASSIFIER", + "CLOB", + "COALESCE", + "COLLATE", + "COLLATION", + "COLLECT", + "COLUMN", + "CONCURRENTLY", + "CONDITION", + "CONNECT", + "CONSTRAINT", + "CONTAINS", + "CONVERT", + "CORR", + "CORRESPONDING", + "COS", + "COSH", + "COUNT", + "COVAR_POP", + "COVAR_SAMP", + "CREATE", + "CROSS", + "CUME_DIST", + "CURRENT_CATALOG", + "CURRENT_DATE", + "CURRENT_DEFAULT_TRANSFORM_GROUP", + "CURRENT_PATH", + "CURRENT_ROLE", + "CURRENT_ROW", + "CURRENT_SCHEMA", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_TRANSFORM_GROUP_FOR_TYPE", + "CURRENT_USER", + "DATALINK", + "DATE", + "DAY", + "DEC", + "DECFLOAT", + "DECIMAL", + "DEFAULT", + "DEFERRABLE", + "DEFINE", + "DENSE_RANK", + "DEREF", + "DESC", + "DESCRIBE", + "DESCRIPTOR", + "DETERMINISTIC", + "DIAGNOSTICS", + "DISCONNECT", + "DISTINCT", + "DLNEWCOPY", + "DLPREVIOUSCOPY", + "DLURLCOMPLETE", + "DLURLCOMPLETEONLY", + "DLURLCOMPLETEWRITE", + "DLURLPATH", + "DLURLPATHONLY", + "DLURLPATHWRITE", + "DLURLSCHEME", + "DLURLSERVER", + "DLVALUE", + "DO", + "DYNAMIC", + "ELEMENT", + "ELSE", + "EMPTY", + "END", + "END-EXEC", + "END_FRAME", + "END_PARTITION", + "EQUALS", + "EVERY", + "EXCEPT", + "EXCEPTION", + "EXEC", + "EXISTS", + "EXP", + "EXTRACT", + "FALSE", + "FETCH", + "FILTER", + "FIRST_VALUE", + "FLOAT", + "FLOOR", + "FOR", + "FOREIGN", + "FOUND", + "FRAME_ROW", + "FREE", + "FREEZE", + "FROM", + "FULL", + "FUSION", + "GET", + "GO", + "GOTO", + "GRANT", + "GREATEST", + "GROUP", + "GROUPING", + "GROUPS", + "HAVING", + "HOUR", + "ILIKE", + "IN", + "INDICATOR", + "INITIAL", + "INITIALLY", + "INNER", + "INOUT", + "INT", + "INTEGER", + "INTERSECT", + "INTERSECTION", + "INTERVAL", + "INTO", + "IS", + "ISNULL", + "JOIN", + "JSON", + "JSON_ARRAY", + "JSON_ARRAYAGG", + "JSON_EXISTS", + "JSON_OBJECT", + "JSON_OBJECTAGG", + "JSON_QUERY", + "JSON_SCALAR", + "JSON_SERIALIZE", + "JSON_TABLE", + "JSON_TABLE_PRIMITIVE", + "JSON_VALUE", + "LAG", + "LAST_VALUE", + "LATERAL", + "LEAD", + "LEADING", + "LEAST", + "LEFT", + "LIKE", + "LIKE_REGEX", + "LIMIT", + "LISTAGG", + "LN", + "LOCALTIME", + "LOCALTIMESTAMP", + "LOG", + "LOG10", + "LOWER", + "LPAD", + "LTRIM", + "MATCHES", + "MATCH_NUMBER", + "MATCH_RECOGNIZE", + "MAX", + "MAX_CARDINALITY", + "MEASURES", + "MEMBER", + "MERGE", + "MERGE_ACTION", + "MIN", + "MINUTE", + "MOD", + "MODIFIES", + "MODULE", + "MONTH", + "MULTISET", + "NATIONAL", + "NATURAL", + "NCHAR", + "NCLOB", + "NONE", + "NORMALIZE", + "NOT", + "NOTNULL", + "NTH_VALUE", + "NTILE", + "NULL", + "NULLIF", + "NUMERIC", + "OCCURRENCES_REGEX", + "OCTET_LENGTH", + "OFFSET", + "OMIT", + "ON", + "ONE", + "ONLY", + "OPEN", + "OR", + "ORDER", + "OUT", + "OUTER", + "OUTPUT", + "OVER", + "OVERLAPS", + "OVERLAY", + "PAD", + "PARAMETER", + "PATTERN", + "PER", + "PERCENT", + "PERCENTILE_CONT", + "PERCENTILE_DISC", + "PERCENT_RANK", + "PERIOD", + "PERMUTE", + "PLACING", + "PORTION", + "POSITION", + "POSITION_REGEX", + "POWER", + "PRECEDES", + "PRECISION", + "PRIMARY", + "PTF", + "RANK", + "READS", + "REAL", + "REFERENCES", + "REGR_AVGX", + "REGR_AVGY", + "REGR_COUNT", + "REGR_INTERCEPT", + "REGR_R2", + "REGR_SLOPE", + "REGR_SXX", + "REGR_SXY", + "REGR_SYY", + "RESULT", + "RETURN", + "RETURNING", + "RIGHT", + "ROW", + "ROW_NUMBER", + "RPAD", + "RTRIM", + "RUNNING", + "SCOPE", + "SECOND", + "SECTION", + "SEEK", + "SELECT", + "SENSITIVE", + "SESSION_USER", + "SETOF", + "SIMILAR", + "SIN", + "SINH", + "SIZE", + "SMALLINT", + "SOME", + "SPACE", + "SPECIFIC", + "SPECIFICTYPE", + "SQLCODE", + "SQLERROR", + "SQLEXCEPTION", + "SQLSTATE", + "SQLWARNING", + "SQRT", + "STATIC", + "STDDEV_POP", + "STDDEV_SAMP", + "SUBMULTISET", + "SUBSET", + "SUBSTRING", + "SUBSTRING_REGEX", + "SUCCEEDS", + "SUM", + "SYMMETRIC", + "SYSTEM_TIME", + "SYSTEM_USER", + "TABLE", + "TABLESAMPLE", + "TAN", + "TANH", + "THEN", + "TIME", + "TIMESTAMP", + "TIMEZONE_HOUR", + "TIMEZONE_MINUTE", + "TO", + "TRAILING", + "TRANSLATE", + "TRANSLATE_REGEX", + "TRANSLATION", + "TREAT", + "TRIM", + "TRIM_ARRAY", + "TRUE", + "UESCAPE", + "UNION", + "UNIQUE", + "UNMATCHED", + "UNNEST", + "UPPER", + "USAGE", + "USER", + "USING", + "VALUES", + "VALUE_OF", + "VARBINARY", + "VARCHAR", + "VARIADIC", + "VARYING", + "VAR_POP", + "VAR_SAMP", + "VERBOSE", + "VERSIONING", + "WHEN", + "WHENEVER", + "WHERE", + "WIDTH_BUCKET", + "WINDOW", + "WITH", + "WITHIN", + "WITHOUT", + "XMLAGG", + "XMLATTRIBUTES", + "XMLBINARY", + "XMLCAST", + "XMLCOMMENT", + "XMLCONCAT", + "XMLDOCUMENT", + "XMLELEMENT", + "XMLEXISTS", + "XMLFOREST", + "XMLITERATE", + "XMLNAMESPACES", + "XMLPARSE", + "XMLPI", + "XMLQUERY", + "XMLROOT", + "XMLSERIALIZE", + "XMLTABLE", + "XMLTEXT", + "XMLVALIDATE", + "YEAR", +]; diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/smartComments.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/smartComments.ts new file mode 100644 index 0000000000..8dcc5b584e --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/smartComments.ts @@ -0,0 +1,55 @@ +export interface PgSmartTagsDict { + [tagName: string]: null | true | string | (string | true)[]; +} + +export interface PgSmartTagsAndDescription { + tags: PgSmartTagsDict; + description: string | undefined; +} + +export const parseSmartComment = ( + str: string | undefined, +): PgSmartTagsAndDescription => { + const result: PgSmartTagsAndDescription = { + tags: Object.create(null), + description: undefined, + }; + if (str) { + const lines = str.split(/\r?\n/); + lines.forEach((line, i) => { + if (i === 0 && line === "") { + // Ignore leading newline + return; + } + if (result.description !== undefined) { + result.description += `\n${line}`; + return; + } + const match = line.match(/^[ \t]*@([a-zA-Z][a-zA-Z0-9_]*)($|\s)(.*)$/); + if (!match) { + if (i === 1 && lines[0] === "") { + result.description = "\n" + line; + } else { + result.description = line; + } + return; + } + const [, key, space, rawValue] = match; + const value = space ? rawValue : true; + if (key in result.tags) { + const prev = result.tags[key] as string | true | Array; + if (Array.isArray(prev)) { + prev.push(value); + } else { + result.tags[key] = [prev, value]; + } + } else { + result.tags[key] = value; + } + }); + } + if (result.description?.trim() === "") { + result.description = undefined; + } + return result; +}; diff --git a/graphile/graphile-scoped-introspection/tsconfig.esm.json b/graphile/graphile-scoped-introspection/tsconfig.esm.json new file mode 100644 index 0000000000..8187d342b9 --- /dev/null +++ b/graphile/graphile-scoped-introspection/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "ESNext", + "moduleResolution": "Bundler" + } +} diff --git a/graphile/graphile-scoped-introspection/tsconfig.json b/graphile/graphile-scoped-introspection/tsconfig.json new file mode 100644 index 0000000000..04ee03cc34 --- /dev/null +++ b/graphile/graphile-scoped-introspection/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "isolatedModules": true, + "useUnknownInCatchVariables": false + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ba0972089..ee16e79f0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1152,6 +1152,38 @@ importers: version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist + graphile/graphile-scoped-introspection: + dependencies: + '@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) + grafast: + specifier: 1.1.2 + version: 1.1.2(graphql@16.13.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 + devDependencies: + '@types/node': + specifier: ^22.19.11 + version: 22.19.19 + graphql: + specifier: 16.13.0 + version: 16.13.0 + makage: + specifier: ^0.3.0 + version: 0.3.0 + pg-introspection: + specifier: 1.0.1 + version: 1.0.1 + publishDirectory: dist + graphile/graphile-search: dependencies: '@dataplan/pg': From b941b85d316331e39edf963eef672de820967937 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 06:25:43 +0800 Subject: [PATCH 02/12] Add scoped introspection query and plugin behavior --- .../graphile-scoped-introspection/README.md | 10 + ...oped-introspection-cache-lifecycle.test.ts | 252 ++++++++++ .../scoped-introspection-mixed.test.ts | 237 ++++++++++ .../scoped-introspection-plugin.test.ts | 143 ++++++ .../scoped-introspection-query.test.ts | 73 +++ .../scoped-introspection-runtime.test.ts | 160 +++++++ ...ped-introspection-service-contract.test.ts | 66 +++ .../__tests__/upstream-baseline.test.ts | 27 +- .../src/index.ts | 10 +- .../PgIntrospectionPlugin.ts | 168 ++++++- .../graphile-build-pg/scopedOptions.ts | 10 + .../graphile-build-pg/scopedValidation.ts | 120 +++++ .../src/upstream/pg-introspection/index.ts | 6 + .../pg-introspection/introspection.ts | 67 ++- .../pg-introspection/scopedIntrospection.ts | 441 ++++++++++++++++++ 15 files changed, 1747 insertions(+), 43 deletions(-) create mode 100644 graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts create mode 100644 graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts create mode 100644 graphile/graphile-scoped-introspection/__tests__/scoped-introspection-plugin.test.ts create mode 100644 graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts create mode 100644 graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts create mode 100644 graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts create mode 100644 graphile/graphile-scoped-introspection/src/upstream/pg-introspection/scopedIntrospection.ts diff --git a/graphile/graphile-scoped-introspection/README.md b/graphile/graphile-scoped-introspection/README.md index 2c82fc9370..fd94f4390c 100644 --- a/graphile/graphile-scoped-introspection/README.md +++ b/graphile/graphile-scoped-introspection/README.md @@ -9,3 +9,13 @@ from the published Graphile implementations that own the two relevant seams: The copied source is kept together so scoped changes remain reviewable against the upstream package boundaries. See `UPSTREAM.md` and `UPSTREAM_LICENSE.md` for provenance and licensing. + +`ScopedIntrospectionPreset` atomically disables the stock +`PgIntrospectionPlugin` and installs `PgScopedIntrospectionPlugin`. The +replacement preserves the stock query unless a PG service explicitly selects +`introspectionMode: 'scoped-required'`. Scoped services use a parameterized +catalog query, validate required and allowed namespaces after parsing, and can +apply dependency-closure validation to retained PostgreSQL types. + +Installing this package alone does not change Graphile's default preset or any +PG service. CNC owns the configuration that selects and installs the preset. 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..7bc5b6e91e --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts @@ -0,0 +1,252 @@ +import { watchGather } from 'graphile-build'; +import type { GraphileConfig } from 'graphile-config'; + +import { PgScopedIntrospectionPlugin } from '../src'; + +const SCHEMA = 'tenant_a'; + +const introspectionText = JSON.stringify({ + database: { datdba: '10', datacl: null }, + namespaces: [ + { + _id: '2200', + oid: '2200', + nspname: SCHEMA, + nspowner: '10', + nspacl: null, + }, + ], + classes: [], + attributes: [], + constraints: [], + procs: [], + roles: [ + { + _id: '10', + oid: '10', + rolname: 'postgres', + rolsuper: true, + rolinherit: true, + rolcreaterole: true, + rolcreatedb: true, + rolcanlogin: true, + rolreplication: true, + rolconnlimit: -1, + rolpassword: null, + rolvaliduntil: null, + rolbypassrls: true, + rolconfig: null, + }, + ], + auth_members: [], + types: [], + enums: [], + extensions: [], + indexes: [], + languages: [], + ranges: [], + depends: [], + descriptions: [], + inherits: [], + am: [], + catalog_by_oid: { + 2615: 'pg_namespace', + 1259: 'pg_class', + 1255: 'pg_proc', + 1247: 'pg_type', + 2606: 'pg_constraint', + 3079: 'pg_extension', + }, + current_user: 'postgres', + server_version_num: 180004, +}); +const missingSchemaIntrospectionText = JSON.stringify({ + ...JSON.parse(introspectionText), + namespaces: [], +}); +const unapprovedSchemaIntrospectionText = JSON.stringify({ + ...JSON.parse(introspectionText), + namespaces: [ + ...JSON.parse(introspectionText).namespaces, + { + _id: '2201', + oid: '2201', + nspname: 'unexpected_dependency', + nspowner: '10', + nspacl: null, + }, + ], +}); + +interface GatherResult { + input: Record | null; + error?: Error; +} + +function makeResultQueue() { + const queued: GatherResult[] = []; + const waiters: Array<(result: GatherResult) => void> = []; + + return { + push(result: GatherResult) { + const waiter = waiters.shift(); + if (waiter) waiter(result); + else queued.push(result); + }, + next(): Promise { + const result = queued.shift(); + if (result) return Promise.resolve(result); + return new Promise((resolve) => waiters.push(resolve)); + }, + }; +} + +describe('scoped introspection raw-text lifecycle', () => { + it('releases raw text, re-queries fresh data, and fails closed on regather errors', async () => { + let cache: { introspectionResultsPromise: Promise | null } | null = + null; + let triggerRegather: (() => void) | null = null; + let queryError: Error | null = null; + let nextIntrospectionText = introspectionText; + const seenNamespaceNames: string[] = []; + const query = jest.fn(async () => { + if (queryError) { + const error = queryError; + queryError = null; + throw error; + } + return { rows: [{ introspection: nextIntrospectionText }] }; + }); + const withPgClient = Object.assign( + async ( + _settings: Record | null, + callback: (client: { query: typeof query }) => unknown + ) => callback({ query }), + { release: jest.fn() } + ); + const adaptor = { + createWithPgClient: jest.fn(async () => withPgClient), + }; + + const originalGather = PgScopedIntrospectionPlugin.gather!; + const capturingIntrospectionPlugin = { + ...PgScopedIntrospectionPlugin, + gather: { + ...originalGather, + initialCache(info: never) { + cache = originalGather.initialCache!(info) as typeof cache; + return cache; + }, + // A deterministic test trigger drives the same persistent gather cache + // without needing a live LISTEN/NOTIFY subscriber. + watch: undefined, + }, + } as unknown as GraphileConfig.Plugin; + const observerPlugin = { + name: 'ScopedIntrospectionCacheObserverPlugin', + gather: { + namespace: 'scopedIntrospectionCacheObserver', + async main(output: Record, info: any) { + const first = info.helpers.pgIntrospection.getIntrospection(); + const second = info.helpers.pgIntrospection.getIntrospection(); + expect(second).toBe(first); + const [firstResults, secondResults] = await Promise.all([ + first, + second, + ]); + expect(secondResults).toBe(firstResults); + const [result] = firstResults; + const namespace = result.introspection.namespaces[0]; + seenNamespaceNames.push(namespace.nspname); + output.namespaceName = namespace.nspname; + // Graphile plugins may mutate their gather-local parsed graph. A later + // gather must never observe this mutation. + namespace.nspname = 'mutated_by_plugin'; + }, + watch(_info: never, callback: () => void) { + triggerRegather = callback; + return (): void => undefined; + }, + }, + } as unknown as GraphileConfig.Plugin; + const pgService = { + name: 'main', + schemas: [SCHEMA], + introspectionMode: 'scoped-required', + introspectionAllowedDependencySchemas: [] as readonly string[], + adaptor, + adaptorSettings: {}, + withPgClientKey: 'withPgClient', + pgSettingsKey: 'pgSettings', + }; + const results = makeResultQueue(); + + const stopWatching = await watchGather( + { + plugins: [capturingIntrospectionPlugin, observerPlugin], + pgServices: [pgService as never], + }, + undefined, + (input, error) => { + results.push({ + input: input as unknown as Record | null, + error: error as Error | undefined, + }); + } + ); + + try { + const first = await results.next(); + expect(first.error).toBeUndefined(); + expect(first.input).toMatchObject({ namespaceName: SCHEMA }); + expect(query).toHaveBeenCalledTimes(1); + expect(cache!.introspectionResultsPromise).toBeNull(); + + triggerRegather!(); + const second = await results.next(); + expect(second.error).toBeUndefined(); + expect(second.input).toMatchObject({ namespaceName: SCHEMA }); + expect(query).toHaveBeenCalledTimes(2); + expect(seenNamespaceNames).toEqual([SCHEMA, SCHEMA]); + expect(cache!.introspectionResultsPromise).toBeNull(); + + nextIntrospectionText = unapprovedSchemaIntrospectionText; + triggerRegather!(); + const invalid = await results.next(); + expect(invalid.input).toBeNull(); + expect(invalid.error?.message).toContain( + 'crossed into unapproved dependency schema(s): unexpected_dependency' + ); + expect(query).toHaveBeenCalledTimes(3); + expect(cache!.introspectionResultsPromise).toBeNull(); + + nextIntrospectionText = missingSchemaIntrospectionText; + triggerRegather!(); + const missing = await results.next(); + expect(missing.input).toBeNull(); + expect(missing.error?.message).toContain( + `did not find required schema(s): ${SCHEMA}` + ); + expect(query).toHaveBeenCalledTimes(4); + expect(cache!.introspectionResultsPromise).toBeNull(); + + nextIntrospectionText = introspectionText; + triggerRegather!(); + const recovered = await results.next(); + expect(recovered.error).toBeUndefined(); + expect(recovered.input).toMatchObject({ namespaceName: SCHEMA }); + expect(query).toHaveBeenCalledTimes(5); + + const marker = new Error('scoped introspection re-query failed'); + queryError = marker; + triggerRegather!(); + const failed = await results.next(); + expect(failed.input).toBeNull(); + expect(failed.error).toBe(marker); + expect(query).toHaveBeenCalledTimes(6); + expect(cache!.introspectionResultsPromise).toBeNull(); + } finally { + stopWatching(); + } + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts new file mode 100644 index 0000000000..c8533479fc --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts @@ -0,0 +1,237 @@ +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 { PgScopedIntrospectionPlugin, ScopedIntrospectionPreset } from '../src'; + +const introspectionText = (schema: string): string => + JSON.stringify({ + database: { datdba: '10', datacl: null }, + namespaces: [ + { + _id: schema === 'stock_schema' ? '2200' : '2201', + oid: schema === 'stock_schema' ? '2200' : '2201', + nspname: schema, + nspowner: '10', + nspacl: null, + }, + ], + classes: [], + attributes: [], + constraints: [], + procs: [], + roles: [ + { + _id: '10', + oid: '10', + rolname: 'postgres', + rolsuper: true, + rolinherit: true, + rolcreaterole: true, + rolcreatedb: true, + rolcanlogin: true, + rolreplication: true, + rolconnlimit: -1, + rolpassword: null, + rolvaliduntil: null, + rolbypassrls: true, + rolconfig: null, + }, + ], + auth_members: [], + types: [], + enums: [], + extensions: [], + indexes: [], + languages: [], + ranges: [], + depends: [], + descriptions: [], + inherits: [], + am: [], + catalog_by_oid: { + 2615: 'pg_namespace', + 1259: 'pg_class', + 1255: 'pg_proc', + 1247: 'pg_type', + 2606: 'pg_constraint', + 3079: 'pg_extension', + }, + current_user: 'postgres', + server_version_num: 180004, + }); + +const makeService = ( + name: string, + schema: string, + mode: 'stock' | 'scoped-required', + queries: Array<{ text: string; values?: unknown[] }> +): never => { + const query = jest.fn(async (input: { text: string; values?: unknown[] }) => { + queries.push(input); + return { rows: [{ introspection: introspectionText(schema) }] }; + }); + const withPgClient = Object.assign( + async ( + _settings: Record | null, + callback: (client: { query: typeof query }) => unknown + ) => callback({ query }), + { release: jest.fn() } + ); + return { + name, + schemas: [schema], + introspectionMode: mode, + introspectionAllowedDependencySchemas: [], + adaptor: { + createWithPgClient: jest.fn(async () => withPgClient), + }, + adaptorSettings: {}, + withPgClientKey: `${name}WithPgClient`, + pgSettingsKey: `${name}PgSettings`, + } as never; +}; + +describe('mixed stock/scoped introspection services', () => { + it('selects each service query independently and announces each once', async () => { + const queries: Array<{ text: string; values?: unknown[] }> = []; + const observer = { + name: 'MixedIntrospectionObserverPlugin', + gather: { + namespace: 'mixedIntrospectionObserver', + async main(output: Record, info: any) { + const first = info.helpers.pgIntrospection.getIntrospection(); + const second = info.helpers.pgIntrospection.getIntrospection(); + expect(second).toBe(first); + const [results, sharedResults] = await Promise.all([first, second]); + expect(sharedResults).toBe(results); + output.services = results.map((result: any) => ({ + name: result.pgService.name, + namespaces: result.introspection.namespaces.map( + (namespace: any) => namespace.nspname + ), + })); + }, + }, + } as unknown as GraphileConfig.Plugin; + + const output = await gather({ + plugins: [PgScopedIntrospectionPlugin, observer], + pgServices: [ + makeService('stock', 'stock_schema', 'stock', queries), + makeService('scoped', 'scoped_schema', 'scoped-required', queries), + ], + }); + + expect(output).toMatchObject({ + services: [ + { name: 'stock', namespaces: ['stock_schema'] }, + { name: 'scoped', namespaces: ['scoped_schema'] }, + ], + }); + expect(queries).toHaveLength(2); + const stock = queries.find( + (query) => !query.text.includes('requested_schema_names') + ); + const scoped = queries.find((query) => + query.text.includes('requested_schema_names') + ); + expect(stock).toBeDefined(); + expect(stock?.values).toBeUndefined(); + expect(scoped?.values).toEqual([['scoped_schema'], []]); + }); + + it('keeps replacement stock gather, schema, and runtime equivalent to upstream', async () => { + const makeObserver = (name: string) => + ({ + name, + gather: { + namespace: `${name}Namespace`, + async main(output: Record, info: any) { + const [result] = + await info.helpers.pgIntrospection.getIntrospection(); + output.entityCounts = Object.fromEntries( + [ + 'namespaces', + 'classes', + 'attributes', + 'constraints', + 'procs', + 'roles', + 'types', + 'ranges', + ].map((key) => [key, result.introspection[key].length]) + ); + }, + }, + }) as unknown as GraphileConfig.Plugin; + const upstreamQueries: Array<{ text: string; values?: unknown[] }> = []; + const replacementQueries: Array<{ text: string; values?: unknown[] }> = []; + const upstreamPreset = { + extends: [graphileBuildPreset, graphileBuildPgPreset], + plugins: [makeObserver('UpstreamStockObserverPlugin')], + pgServices: [ + makeService('main', 'stock_schema', 'stock', upstreamQueries), + ], + }; + const replacementPreset = { + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + plugins: [makeObserver('ReplacementStockObserverPlugin')], + pgServices: [ + makeService('main', 'stock_schema', 'stock', replacementQueries), + ], + }; + + const [upstreamGather, replacementGather] = await Promise.all([ + gather(upstreamPreset), + gather(replacementPreset), + ]); + expect((replacementGather as any).entityCounts).toEqual( + (upstreamGather as any).entityCounts + ); + expect(upstreamQueries).toHaveLength(1); + expect(replacementQueries).toHaveLength(1); + expect(replacementQueries[0].text).toBe(upstreamQueries[0].text); + + const [upstream, replacement] = await Promise.all([ + makeSchema({ + extends: [graphileBuildPreset, graphileBuildPgPreset], + pgServices: [ + makeService('main', 'stock_schema', 'stock', upstreamQueries), + ], + }), + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + pgServices: [ + makeService('main', 'stock_schema', 'stock', replacementQueries), + ], + }), + ]); + expect(printSchema(lexicographicSortSchema(replacement.schema))).toEqual( + printSchema(lexicographicSortSchema(upstream.schema)) + ); + const query = parse('{ __typename }'); + expect( + await execute({ schema: replacement.schema, document: query }) + ).toEqual(await execute({ schema: upstream.schema, document: query })); + expect(PgIntrospectionPlugin.name).toBe('PgIntrospectionPlugin'); + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-plugin.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-plugin.test.ts new file mode 100644 index 0000000000..5c690237b6 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-plugin.test.ts @@ -0,0 +1,143 @@ +import { defaultPreset as graphileBuildPreset } from 'graphile-build'; +import { + defaultPreset as graphileBuildPgPreset, + PgIntrospectionPlugin, +} from 'graphile-build-pg'; +import { resolvePreset } from 'graphile-config'; + +import { + PgScopedIntrospectionPlugin, + ScopedIntrospectionPreset, + scopedIntrospectionUpstreamContract, +} from '../src'; + +describe('CNC introspection replacement contract', () => { + it('atomically replaces the upstream namespace owner exactly once', () => { + const stock = resolvePreset({ + extends: [graphileBuildPreset, graphileBuildPgPreset], + }); + const scoped = resolvePreset({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + }); + + expect( + stock.plugins.filter((plugin) => plugin.name === 'PgIntrospectionPlugin') + ).toEqual([PgIntrospectionPlugin]); + expect( + scoped.plugins.filter( + (plugin) => + plugin.name === 'PgIntrospectionPlugin' || + plugin.name === 'PgScopedIntrospectionPlugin' + ) + ).toEqual([PgScopedIntrospectionPlugin]); + expect(scoped.disablePlugins).toContain('PgIntrospectionPlugin'); + expect(PgScopedIntrospectionPlugin.provides).toContain( + 'PgIntrospectionPlugin' + ); + expect(PgScopedIntrospectionPlugin.before).toContain('PgRegistryPlugin'); + }); + + it('creates new plugin, gather, and helper objects without mutating upstream', () => { + expect(PgScopedIntrospectionPlugin).not.toBe(PgIntrospectionPlugin); + expect(PgScopedIntrospectionPlugin.gather).not.toBe( + PgIntrospectionPlugin.gather + ); + expect(PgScopedIntrospectionPlugin.gather!.helpers).not.toBe( + PgIntrospectionPlugin.gather!.helpers + ); + expect(PgIntrospectionPlugin.name).toBe('PgIntrospectionPlugin'); + expect(PgIntrospectionPlugin.provides).toBeUndefined(); + }); + + it('preserves every upstream lifecycle seam in the copied implementation', () => { + const upstream = PgIntrospectionPlugin.gather!; + const replacement = PgScopedIntrospectionPlugin.gather!; + + expect(replacement.namespace).toBe(upstream.namespace); + expect(typeof replacement.initialCache).toBe(typeof upstream.initialCache); + expect(typeof replacement.initialState).toBe(typeof upstream.initialState); + expect(typeof replacement.watch).toBe(typeof upstream.watch); + expect(Object.keys(replacement.helpers ?? {}).sort()).toEqual( + Object.keys(upstream.helpers ?? {}).sort() + ); + expect(Object.keys(replacement.hooks ?? {}).sort()).toEqual( + Object.keys(upstream.hooks ?? {}).sort() + ); + }); + + it('looks up scoped ranges directly from the parsed range collection', async () => { + const helpers = PgScopedIntrospectionPlugin.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..5ebd8f4637 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts @@ -0,0 +1,73 @@ +import { makeSchemaScopedIntrospectionQuery } from '../src/upstream/pg-introspection'; + +describe('CNC-owned scoped introspection SQL', () => { + it('keeps schema and capability input in parameters', () => { + const schema = "tenant_a'); drop schema public; --"; + const capability = "pg_trgm'); select pg_sleep(10); --"; + const query = makeSchemaScopedIntrospectionQuery( + [schema, 'tenant_a', schema], + { capabilityExtensions: [capability, 'pg_trgm', capability] } + ); + + expect(query.text).toContain('pg_catalog.unnest($1::text[])'); + expect(query.text).toContain('pg_catalog.unnest($2::text[])'); + expect(query.text).not.toContain(schema); + expect(query.text).not.toContain(capability); + expect(query.values).toEqual([ + [schema, 'tenant_a'], + [capability, 'pg_trgm'], + ]); + }); + + it('rejects empty, system, NUL, and malformed capability inputs', () => { + expect(() => makeSchemaScopedIntrospectionQuery([])).toThrow( + 'requires at least one schema' + ); + expect(() => makeSchemaScopedIntrospectionQuery(['pg_catalog'])).toThrow( + "cannot expose system schema 'pg_catalog'" + ); + expect(() => + makeSchemaScopedIntrospectionQuery(['information_schema']) + ).toThrow("cannot expose system schema 'information_schema'"); + expect(() => makeSchemaScopedIntrospectionQuery(['tenant\0a'])).toThrow( + 'must not contain NUL bytes' + ); + expect(() => + makeSchemaScopedIntrospectionQuery(['tenant_a'], { + capabilityExtensions: [' pg_trgm'], + }) + ).toThrow('must contain exact non-empty extension names'); + }); + + it('keeps recursive dependency closure and both catalog type policies', () => { + const all = makeSchemaScopedIntrospectionQuery(['tenant_a']); + const closure = makeSchemaScopedIntrospectionQuery(['tenant_a'], { + catalogTypes: 'dependency-closure', + }); + + for (const query of [all, closure]) { + expect(query.text).toContain('with\nrecursive'); + expect(query.text).toContain( + 'object_closure(object_class, object_id) as' + ); + expect(query.text).toContain('retained_index_support_objects'); + expect(query.text).toContain('installed_extensions'); + expect(query.text).toContain('select pg_language.oid as _id'); + expect(query.text).toContain('select pg_am.oid as _id'); + } + expect(all.text).toContain( + "or pg_type.typnamespace = 'pg_catalog'::regnamespace" + ); + expect(closure.text).not.toContain( + "or pg_type.typnamespace = 'pg_catalog'::regnamespace" + ); + }); + + it('rejects unknown options at the runtime boundary', () => { + expect(() => + makeSchemaScopedIntrospectionQuery(['tenant_a'], { + unexpected: true, + } as never) + ).toThrow('Unsupported schema-scoped introspection option(s): unexpected'); + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts new file mode 100644 index 0000000000..264533b736 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts @@ -0,0 +1,160 @@ +import { + defaultPreset as graphileBuildPreset, + makeSchema, +} from 'graphile-build'; +import { defaultPreset as graphileBuildPgPreset } from 'graphile-build-pg'; + +import { ScopedIntrospectionPreset } from '../src'; + +const { makePgService: makePostGraphilePgService } = + require('postgraphile/adaptors/pg') as { + makePgService(options: Record): Record; + }; + +describe('schema-scoped introspection runtime integration', () => { + it.each([ + ['all catalog types by default', undefined, true], + ['dependency-closure catalog types', 'dependency-closure', false], + ] as const)( + 'executes the parameterized scoped query with %s', + async (_label, scopedCatalogTypes, retainsAllCatalogTypes) => { + const marker = new Error('captured introspection query'); + let captured: { text: string; values?: unknown[] } | null = null; + const client = { + query: jest.fn( + async (query: string | { text: string; values?: unknown[] }) => { + if (typeof query === 'string') return { rows: [] as unknown[] }; + captured = query; + throw marker; + } + ), + release: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + }; + const pool = { + connect: jest.fn().mockResolvedValue(client), + }; + + await expect( + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + pgServices: [ + Object.assign( + makePostGraphilePgService({ + pool: pool as never, + schemas: ['tenant_a'], + }), + { + introspectionMode: 'scoped-required', + introspectionCapabilityExtensions: ['pg_trgm'], + ...(scopedCatalogTypes === undefined + ? {} + : { introspectionScopedCatalogTypes: scopedCatalogTypes }), + } + ) as never, + ], + }) + ).rejects.toBe(marker); + + expect(captured).not.toBeNull(); + expect(captured!.text).toContain('requested_schema_names'); + expect(captured!.text).not.toBe('select introspection'); + expect(captured!.values).toEqual([['tenant_a'], ['pg_trgm']]); + expect( + captured!.text.includes( + "or pg_type.typnamespace = 'pg_catalog'::regnamespace" + ) + ).toBe(retainsAllCatalogTypes); + expect(client.release).toHaveBeenCalledTimes(1); + } + ); + + it('fails closed when a retained entity references a missing type', async () => { + const introspection = JSON.stringify({ + database: {}, + namespaces: [ + { + _id: '100', + nspname: 'tenant_a', + nspowner: '10', + nspacl: null, + }, + ], + classes: [ + { + _id: '200', + relname: 'broken_items', + relnamespace: '100', + reltype: '999', + reloftype: null, + }, + ], + attributes: [], + constraints: [], + procs: [], + roles: [], + auth_members: [], + types: [], + enums: [], + extensions: [], + indexes: [], + inherits: [], + languages: [], + policies: [], + ranges: [], + depends: [], + descriptions: [], + am: [], + catalog_by_oid: { + 1255: 'pg_proc', + 1247: 'pg_type', + 1259: 'pg_class', + 2606: 'pg_constraint', + 2615: 'pg_namespace', + 3079: 'pg_extension', + }, + current_user: 'runtime_role', + pg_version: 'PostgreSQL test fixture', + introspection_version: 1, + }); + const client = { + query: jest.fn().mockResolvedValue({ rows: [{ introspection }] }), + release: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + }; + const pool = { + connect: jest.fn().mockResolvedValue(client), + }; + + await expect( + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ScopedIntrospectionPreset, + ], + pgServices: [ + Object.assign( + makePostGraphilePgService({ + pool: pool as never, + schemas: ['tenant_a'], + }), + { + introspectionMode: 'scoped-required', + introspectionScopedCatalogTypes: 'dependency-closure', + } + ) as never, + ], + }) + ).rejects.toThrow( + /service '.+' retained pg_class 'broken_items \(200\)' field 'reltype' referencing missing pg_type OID '999'/ + ); + expect(client.release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts new file mode 100644 index 0000000000..2a809cfa70 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts @@ -0,0 +1,66 @@ +import '@dataplan/pg/adaptors/pg'; + +import { gather } from 'graphile-build'; +import type { GraphileConfig } from 'graphile-config'; + +import { PgScopedIntrospectionPlugin } from '../src'; + +const makeService = (overrides: Record = {}): never => + ({ + name: 'main', + schemas: ['tenant_a'], + introspectionMode: 'scoped-required', + introspectionAllowedDependencySchemas: [], + adaptor: { + createWithPgClient: jest.fn(() => { + throw new Error('query should not be reached'); + }), + }, + adaptorSettings: {}, + withPgClientKey: 'withPgClient', + pgSettingsKey: 'pgSettings', + ...overrides, + }) as never; + +describe('scoped introspection service identity contract', () => { + const consumerPlugin = { + name: 'ScopedIntrospectionIdentityConsumerPlugin', + gather: { + namespace: 'scopedIntrospectionIdentityConsumer', + async main(_output: Record, info: any) { + await info.helpers.pgIntrospection.getIntrospection(); + }, + }, + } as unknown as GraphileConfig.Plugin; + + it.each([ + [ + 'name', + makeService(), + makeService({ + withPgClientKey: 'secondWithPgClient', + pgSettingsKey: 'secondPgSettings', + }), + 'same name', + ], + [ + 'withPgClientKey', + makeService(), + makeService({ name: 'second', pgSettingsKey: 'secondPgSettings' }), + 'same withPgClientKey', + ], + [ + 'pgSettingsKey', + makeService(), + makeService({ name: 'second', withPgClientKey: 'secondWithPgClient' }), + 'same pgSettingsKey', + ], + ])('rejects duplicate %s values', async (_field, first, second, message) => { + await expect( + gather({ + plugins: [PgScopedIntrospectionPlugin, consumerPlugin], + pgServices: [first, second], + }) + ).rejects.toThrow(message); + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/upstream-baseline.test.ts b/graphile/graphile-scoped-introspection/__tests__/upstream-baseline.test.ts index 8858a45286..e7cb90f629 100644 --- a/graphile/graphile-scoped-introspection/__tests__/upstream-baseline.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/upstream-baseline.test.ts @@ -1,10 +1,7 @@ import { PgIntrospectionPlugin as publishedPlugin } from 'graphile-build-pg'; import { makeIntrospectionQuery as publishedQuery } from 'pg-introspection'; -import { - makeIntrospectionQuery, - PgIntrospectionPlugin, -} from '../src'; +import { makeIntrospectionQuery, PgScopedIntrospectionPlugin } from '../src'; describe('upstream introspection baseline', () => { it('starts with the published pg-introspection query unchanged', () => { @@ -12,17 +9,19 @@ describe('upstream introspection baseline', () => { }); it('preserves the published gather lifecycle contract', () => { - expect(PgIntrospectionPlugin).not.toBe(publishedPlugin); - expect(PgIntrospectionPlugin.name).toBe(publishedPlugin.name); - expect(PgIntrospectionPlugin.before).toEqual(publishedPlugin.before); - expect(PgIntrospectionPlugin.gather?.namespace).toBe( - publishedPlugin.gather?.namespace - ); - expect(Object.keys(PgIntrospectionPlugin.gather?.helpers ?? {}).sort()).toEqual( - Object.keys(publishedPlugin.gather?.helpers ?? {}).sort() + expect(PgScopedIntrospectionPlugin).not.toBe(publishedPlugin); + expect(PgScopedIntrospectionPlugin.provides).toContain( + publishedPlugin.name ); - expect(Object.keys(PgIntrospectionPlugin.gather?.hooks ?? {}).sort()).toEqual( - Object.keys(publishedPlugin.gather?.hooks ?? {}).sort() + expect(PgScopedIntrospectionPlugin.before).toEqual(publishedPlugin.before); + expect(PgScopedIntrospectionPlugin.gather?.namespace).toBe( + publishedPlugin.gather?.namespace ); + expect( + Object.keys(PgScopedIntrospectionPlugin.gather?.helpers ?? {}).sort() + ).toEqual(Object.keys(publishedPlugin.gather?.helpers ?? {}).sort()); + expect( + Object.keys(PgScopedIntrospectionPlugin.gather?.hooks ?? {}).sort() + ).toEqual(Object.keys(publishedPlugin.gather?.hooks ?? {}).sort()); }); }); diff --git a/graphile/graphile-scoped-introspection/src/index.ts b/graphile/graphile-scoped-introspection/src/index.ts index 79e4edda57..20899d173a 100644 --- a/graphile/graphile-scoped-introspection/src/index.ts +++ b/graphile/graphile-scoped-introspection/src/index.ts @@ -1,2 +1,10 @@ -export { PgIntrospectionPlugin } from './upstream/graphile-build-pg/PgIntrospectionPlugin'; +export { + PgScopedIntrospectionPlugin, + ScopedIntrospectionPreset, + scopedIntrospectionUpstreamContract, +} from './upstream/graphile-build-pg/PgIntrospectionPlugin'; +export type { + GraphileIntrospectionMode, + ScopedIntrospectionServiceOptions, +} from './upstream/graphile-build-pg/scopedOptions'; export * from './upstream/pg-introspection'; diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts index 118b1af810..cbb6194957 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts @@ -9,7 +9,7 @@ import type { PromiseOrDirect, Step } from "grafast"; import { constant, context, noop, object, promiseWithResolve } from "grafast"; import type { GatherPluginContext } from "graphile-build"; import { EXPORTABLE, gatherConfig } from "graphile-build"; -import type {} from "graphile-build-pg"; +import { version as graphileBuildPgVersion } from "graphile-build-pg"; import type { Introspection, PgAttribute, @@ -31,9 +31,16 @@ import type { } from "pg-introspection"; import { makeIntrospectionQuery, + makeSchemaScopedIntrospectionQuery, parseIntrospectionResults, + type ScopedCatalogTypes, } from "../pg-introspection"; +import type { ScopedIntrospectionServiceOptions } from "./scopedOptions"; +import { + assertDependencyClosureTypes, + assertScopedNamespaces, +} from "./scopedValidation"; import { version } from "./version"; import { watchFixtures } from "./watchFixtures"; @@ -41,6 +48,13 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** Someone else created */ const CLASH_CODES = ["23505", "42P06", "42P07", "42710"]; +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}`, + ); +} export type PgEntityWithId = | PgNamespace @@ -69,9 +83,12 @@ declare global { namespace GraphileConfig { interface Plugins { - PgIntrospectionPlugin: true; + PgScopedIntrospectionPlugin: true; } + interface PgServiceConfiguration + extends ScopedIntrospectionServiceOptions {} + interface GatherHelpers { pgIntrospection: { getIntrospection(): PromiseOrDirect; @@ -243,15 +260,79 @@ declare global { } } -type RawIntrospectionResults = Array<{ +type RawIntrospectionResult = { pgService: GraphileConfig.PgServiceConfiguration; introspectionText: string; -}>; + requiredSchemas: readonly string[] | null; + allowedSchemas: readonly string[] | null; + scopedCatalogTypes: ScopedCatalogTypes | null; +}; +type RawIntrospectionResults = Array; +type PgQuery = { text: string; values?: unknown[] }; type IntrospectionResults = Array<{ pgService: GraphileConfig.PgServiceConfiguration; introspection: Introspection; }>; +function getIntrospectionQuery( + pgService: GraphileConfig.PgServiceConfiguration +): Omit & { + query: PgQuery; +} { + const mode = pgService.introspectionMode ?? 'stock'; + const configuredCatalogTypes = pgService.introspectionScopedCatalogTypes; + const configuredCapabilityExtensions = + pgService.introspectionCapabilityExtensions; + const scopedCatalogTypes = configuredCatalogTypes ?? 'all'; + + if ( + scopedCatalogTypes !== 'all' && + scopedCatalogTypes !== 'dependency-closure' + ) { + throw new Error( + `Unsupported scoped catalog type policy '${scopedCatalogTypes}' for service '${pgService.name}'` + ); + } + if (mode === 'stock') { + if (configuredCatalogTypes !== undefined) { + throw new Error( + `Scoped catalog type policy is only valid with scoped-required introspection for service '${pgService.name}'` + ); + } + if (configuredCapabilityExtensions !== undefined) { + throw new Error( + `Scoped extension capabilities are only valid with scoped-required introspection for service '${pgService.name}'` + ); + } + return { + query: { text: makeIntrospectionQuery() }, + requiredSchemas: null, + allowedSchemas: null, + scopedCatalogTypes: null, + }; + } + if (mode === 'scoped-required') { + const requiredSchemas = pgService.schemas ?? []; + const dependencySchemas = + pgService.introspectionAllowedDependencySchemas ?? []; + return { + query: makeSchemaScopedIntrospectionQuery(requiredSchemas, { + catalogTypes: scopedCatalogTypes, + capabilityExtensions: configuredCapabilityExtensions ?? [], + }), + requiredSchemas, + allowedSchemas: [ + ...new Set([...requiredSchemas, ...dependencySchemas, 'pg_catalog']), + ], + scopedCatalogTypes, + }; + } + throw new Error( + `Unsupported PostgreSQL introspection mode '${mode}' for service '${pgService.name}'` + ); +} + + interface Cache { introspectionResultsPromise: null | Promise; dirty: boolean; @@ -321,11 +402,12 @@ function makeGetEntities< }; } -export const PgIntrospectionPlugin: GraphileConfig.Plugin = { - name: "PgIntrospectionPlugin", +export const PgScopedIntrospectionPlugin: GraphileConfig.Plugin = { + name: "PgScopedIntrospectionPlugin", description: - "Introspects PostgreSQL databases and makes the results available to other plugins", + "Adds opt-in schema-scoped PostgreSQL introspection", version: version, + provides: ["PgIntrospectionPlugin"], // Run before PgRegistryPlugin because we want all the introspection to be // triggered/announced before the registryBuilder is built. @@ -505,11 +587,11 @@ export const PgIntrospectionPlugin: GraphileConfig.Plugin = { }, async getRangeByType(info, serviceName, typeId) { - const type = await info.helpers.pgIntrospection.getType( - serviceName, - typeId, + const relevant = await getDb(info, serviceName); + return relevant.introspection.ranges.find( + (range) => + range.rngtypid === typeId || range.rngmultitypid === typeId, ); - return type?.getRange(); }, async getExtensionByName(info, serviceName, extensionName) { @@ -543,13 +625,36 @@ export const PgIntrospectionPlugin: GraphileConfig.Plugin = { }); const rawIntrospections = await introspectionPromise; + if ( + info.cache.introspectionResultsPromise === introspectionPromise + ) { + info.cache.introspectionResultsPromise = null; + } const introspections: IntrospectionResults = rawIntrospections.map( - ({ pgService, introspectionText }) => ({ + ({ pgService, + introspectionText, + requiredSchemas, + allowedSchemas, + scopedCatalogTypes, + }) => { // IMPORTANT: parseIntrospectionResults must NOT be cached, because other plugins mutate it. - introspection: parseIntrospectionResults(introspectionText), - }), + const introspection = + parseIntrospectionResults(introspectionText); + assertScopedNamespaces( + introspection, + requiredSchemas, + allowedSchemas, + pgService.name, + ); + assertDependencyClosureTypes( + introspection, + scopedCatalogTypes, + pgService.name, + ); + return { pgService, introspection }; + }, ); // Store the resolved state, so access during announcements doesn't cause the system to hang @@ -832,21 +937,46 @@ function introspectPgServices( } // Do the introspection - const introspectionQuery = makeIntrospectionQuery(); + const { query, requiredSchemas, allowedSchemas, scopedCatalogTypes } = + getIntrospectionQuery(pgService); const { rows: [row], } = await withPgClientFromPgService( pgService, pgService.pgSettingsForIntrospection ?? null, (client) => - client.query<{ introspection: string }>({ - text: introspectionQuery, - }), + client.query<{ introspection: string }>(query), ); if (!row) { throw new Error("Introspection failed"); } - return { pgService, introspectionText: row.introspection }; + return { + pgService, + introspectionText: row.introspection, + requiredSchemas, + allowedSchemas, + scopedCatalogTypes, + }; }), ); } + +/** Disable the upstream plugin atomically before installing the scoped-aware replacement. */ +export const ScopedIntrospectionPreset: GraphileConfig.Preset = { + disablePlugins: ["PgIntrospectionPlugin"], + plugins: [PgScopedIntrospectionPlugin], +}; + +const scopedGather = PgScopedIntrospectionPlugin.gather; + +export const scopedIntrospectionUpstreamContract = Object.freeze({ + package: "graphile-build-pg", + version: SUPPORTED_GRAPHILE_BUILD_PG_VERSION, + pluginName: "PgIntrospectionPlugin", + namespace: scopedGather?.namespace, + hasInitialCache: typeof scopedGather?.initialCache === "function", + hasInitialState: typeof scopedGather?.initialState === "function", + hasWatch: typeof scopedGather?.watch === "function", + helperNames: Object.keys(scopedGather?.helpers ?? {}).sort(), + hookNames: Object.keys(scopedGather?.hooks ?? {}).sort(), +}); diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts new file mode 100644 index 0000000000..ef62ec1ced --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts @@ -0,0 +1,10 @@ +import type { ScopedCatalogTypes } from "../pg-introspection"; + +export type GraphileIntrospectionMode = "stock" | "scoped-required"; + +export interface ScopedIntrospectionServiceOptions { + introspectionMode?: GraphileIntrospectionMode; + introspectionAllowedDependencySchemas?: readonly string[]; + introspectionScopedCatalogTypes?: ScopedCatalogTypes; + introspectionCapabilityExtensions?: readonly string[]; +} diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts new file mode 100644 index 0000000000..7941b28afe --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts @@ -0,0 +1,120 @@ +import type { Introspection, ScopedCatalogTypes } from "../pg-introspection"; + +export function assertScopedNamespaces( + introspection: Introspection, + requiredSchemas: readonly string[] | null, + allowedSchemas: readonly string[] | null, + serviceName: string +): void { + if (requiredSchemas === null || allowedSchemas === null) return; + + const found = new Set( + introspection.namespaces.map((namespace) => namespace.nspname) + ); + const missing = requiredSchemas.filter((schema) => !found.has(schema)); + if (missing.length > 0) { + throw new Error( + `Schema-scoped introspection for service '${serviceName}' did not find required schema(s): ${missing.join(', ')}` + ); + } + const allowed = new Set(allowedSchemas); + const unexpected = [...found].filter((schema) => !allowed.has(schema)); + if (unexpected.length > 0) { + throw new Error( + `Schema-scoped introspection for service '${serviceName}' crossed into unapproved dependency schema(s): ${unexpected.join(', ')}` + ); + } +} +export function assertDependencyClosureTypes( + introspection: Introspection, + scopedCatalogTypes: ScopedCatalogTypes | null, + serviceName: string +): void { + if (scopedCatalogTypes !== 'dependency-closure') return; + + const retainedTypeOids = new Set( + introspection.types.map((type) => String(type._id)) + ); + const requireType = ( + oid: unknown, + objectKind: string, + objectContext: string, + field: string + ): void => { + if (oid === null || oid === undefined || String(oid) === '0') return; + const normalizedOid = String(oid); + // pg-introspection removes extension-owned composite resources from its + // public arrays after building lookups. Validate the runtime lookup too. + const introspectionLookups = ( + introspection as Introspection & { + _lookups?: { typeById?: Map }; + } + )._lookups; + const resolves = + retainedTypeOids.has(normalizedOid) || + introspectionLookups?.typeById?.has(normalizedOid) === true; + if (!resolves) { + throw new Error( + `Dependency-closure introspection for service '${serviceName}' retained ${objectKind} '${objectContext}' field '${field}' referencing missing pg_type OID '${normalizedOid}'` + ); + } + }; + const requireTypes = ( + oids: readonly unknown[] | null | undefined, + objectKind: string, + objectContext: string, + field: string + ): void => { + for (const oid of oids ?? []) { + requireType(oid, objectKind, objectContext, field); + } + }; + + for (const entity of introspection.classes) { + const context = `${entity.relname} (${entity._id})`; + requireType(entity.reltype, 'pg_class', context, 'reltype'); + requireType(entity.reloftype, 'pg_class', context, 'reloftype'); + } + for (const entity of introspection.attributes) { + requireType( + entity.atttypid, + 'pg_attribute', + `${entity.attrelid}.${entity.attname}`, + 'atttypid' + ); + } + for (const entity of introspection.constraints) { + requireType( + entity.contypid, + 'pg_constraint', + `${entity.conname} (${entity._id})`, + 'contypid' + ); + } + for (const entity of introspection.procs) { + const context = `${entity.proname} (${entity._id})`; + requireType(entity.prorettype, 'pg_proc', context, 'prorettype'); + requireTypes(entity.proargtypes, 'pg_proc', context, 'proargtypes'); + requireTypes(entity.proallargtypes, 'pg_proc', context, 'proallargtypes'); + } + for (const entity of introspection.types) { + const context = `${entity.typname} (${entity._id})`; + requireType(entity.typbasetype, 'pg_type', context, 'typbasetype'); + requireType(entity.typelem, 'pg_type', context, 'typelem'); + requireType(entity.typarray, 'pg_type', context, 'typarray'); + } + for (const entity of introspection.enums) { + requireType( + entity.enumtypid, + 'pg_enum', + `${entity.enumlabel} (${entity._id})`, + 'enumtypid' + ); + } + for (const entity of introspection.ranges) { + const context = `range ${entity.rngtypid ?? 'unknown'}`; + requireType(entity.rngtypid, 'pg_range', context, 'rngtypid'); + requireType(entity.rngsubtype, 'pg_range', context, 'rngsubtype'); + requireType(entity.rngmultitypid, 'pg_range', context, 'rngmultitypid'); + } +} diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/index.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/index.ts index b93cc6f27e..a302bbd07a 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/index.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/index.ts @@ -22,6 +22,12 @@ import type { PgType, } from "./introspection"; export { makeIntrospectionQuery } from "./introspection"; +export { + makeSchemaScopedIntrospectionQuery, + type SchemaScopedIntrospectionOptions, + type SchemaScopedIntrospectionQuery, + type ScopedCatalogTypes, +} from "./scopedIntrospection"; import type { AclObject } from "./acl"; import { aclContainsRole, diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/introspection.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/introspection.ts index b1e6075a24..89cd0c4fe0 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/introspection.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/introspection.ts @@ -1570,12 +1570,41 @@ export type PgEntity = | PgDescription | PgAm; +export 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 STOCK_QUERY_SCOPE: IntrospectionQueryScope = { + namespacePredicate: "nspname <> 'information_schema'", + classPredicate: + "relnamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%')", + constraintPredicate: + "connamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%')", + procPredicate: + "pronamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%')", + typePredicate: + "(typnamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%'))\n or (typnamespace = 'pg_catalog'::regnamespace)", + accessMethodPredicate: "true", + authMemberPredicate: "roleid in (select roles._id from roles)", +}; + // 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. */ -export const makeIntrospectionQuery = () => `\ +export const buildIntrospectionQuery = (scope: IntrospectionQueryScope) => `\ with +${scope.ctes ?? ""}\ database as ( select pg_database.oid as _id, * from pg_catalog.pg_database @@ -1585,14 +1614,14 @@ with namespaces as ( select pg_namespace.oid as _id, * from pg_catalog.pg_namespace - where nspname <> 'information_schema' + 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 relnamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%') + where ${scope.classPredicate} ), attributes as ( @@ -1604,32 +1633,37 @@ with constraints as ( select pg_constraint.oid as _id, * from pg_catalog.pg_constraint - where connamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%') + where ${scope.constraintPredicate} ), procs as ( select pg_proc.oid as _id, * from pg_catalog.pg_proc - where pronamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%') + 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 roleid in (select roles._id from roles) + where ${scope.authMemberPredicate ?? "roleid in (select roles._id from roles)"} ), types as ( select pg_type.oid as _id, * from pg_catalog.pg_type - where (typnamespace in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%')) - or (typnamespace = 'pg_catalog'::regnamespace) + where ${scope.typePredicate} ), enums as ( @@ -1641,6 +1675,12 @@ with extensions as ( select pg_extension.oid as _id, * from pg_catalog.pg_extension +${ + scope.extensionPredicate + ? ` where ${scope.extensionPredicate} +` + : "" +}\ ), indexes as ( @@ -1658,6 +1698,12 @@ with languages as ( select pg_language.oid as _id, * from pg_catalog.pg_language +${ + scope.languagePredicate + ? ` where ${scope.languagePredicate} +` + : "" +}\ ), policies as ( @@ -1705,7 +1751,7 @@ with am as ( select pg_am.oid as _id, * from pg_catalog.pg_am - where true + where ${scope.accessMethodPredicate} ) select json_build_object( 'database', @@ -1785,3 +1831,6 @@ select json_build_object( 1 )::text as introspection `; + +export const makeIntrospectionQuery = () => + buildIntrospectionQuery(STOCK_QUERY_SCOPE); diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/scopedIntrospection.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/scopedIntrospection.ts new file mode 100644 index 0000000000..2745b8574b --- /dev/null +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/scopedIntrospection.ts @@ -0,0 +1,441 @@ +import { buildIntrospectionQuery } from "./introspection"; + +export type ScopedCatalogTypes = "all" | "dependency-closure"; + +export interface SchemaScopedIntrospectionOptions { + catalogTypes?: ScopedCatalogTypes; + capabilityExtensions?: readonly string[]; +} + +export interface SchemaScopedIntrospectionQuery { + text: string; + values: [string[], 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' + ), + +`; +/** + * Builds a parameterized introspection query scoped to the requested schemas + * and the transitive object dependencies required by their objects. + */ +export const makeSchemaScopedIntrospectionQuery = ( + schemas: readonly string[], + options: SchemaScopedIntrospectionOptions = {} +): SchemaScopedIntrospectionQuery => { + if (!Array.isArray(schemas) || schemas.length === 0) { + throw new Error('Schema-scoped introspection requires at least one schema'); + } + if ( + options === null || + typeof options !== 'object' || + Array.isArray(options) + ) { + throw new Error('Schema-scoped introspection options must be an object'); + } + const unsupportedOptions = Object.keys(options).filter( + (key) => key !== 'catalogTypes' && key !== 'capabilityExtensions' + ); + if (unsupportedOptions.length > 0) { + throw new Error( + `Unsupported schema-scoped introspection option(s): ${unsupportedOptions.join(', ')}` + ); + } + const catalogTypes = options.catalogTypes ?? 'all'; + if (catalogTypes !== 'all' && catalogTypes !== 'dependency-closure') { + throw new Error( + `Unsupported schema-scoped catalog type policy '${catalogTypes}'` + ); + } + const capabilityExtensions = options.capabilityExtensions ?? []; + if (!Array.isArray(capabilityExtensions)) { + throw new Error( + 'Schema-scoped introspection capabilityExtensions must be an array' + ); + } + const normalizedCapabilityExtensions = Array.from( + new Set( + capabilityExtensions.map((extension) => { + if ( + typeof extension !== 'string' || + extension.length === 0 || + extension.trim() !== extension || + extension.includes('\0') + ) { + throw new Error( + 'Schema-scoped introspection capabilityExtensions must contain exact non-empty extension names' + ); + } + return extension; + }) + ) + ); + const normalized = Array.from( + new Set( + schemas.map((schema) => { + if (typeof schema !== 'string' || schema.length === 0) { + throw new Error( + 'Schema-scoped introspection schemas must be non-empty strings' + ); + } + if (schema.includes('\0')) { + throw new Error( + 'Schema-scoped introspection schemas must not contain NUL bytes' + ); + } + if (schema === 'information_schema' || schema.startsWith('pg_')) { + throw new Error( + `Schema-scoped introspection cannot expose system schema '${schema}'` + ); + } + return schema; + }) + ) + ); + const dependencyClosureTypePredicate = + "pg_type.oid = any (array(select object_id from object_closure where object_class = 'pg_catalog.pg_type'::regclass))"; + return { + text: buildIntrospectionQuery({ + ctes: SCOPED_CTES, + namespacePredicate: + 'pg_namespace.oid = any (array(select scoped_namespaces._id from scoped_namespaces))', + classPredicate: + "pg_class.oid = any (array(select object_id from object_closure where object_class = 'pg_catalog.pg_class'::regclass))", + constraintPredicate: + "pg_constraint.oid = any (array(select object_id from object_closure where object_class = 'pg_catalog.pg_constraint'::regclass))", + procPredicate: + "pg_proc.oid = any (array(select object_id from object_closure where object_class = 'pg_catalog.pg_proc'::regclass))", + typePredicate: + catalogTypes === 'all' + ? `${dependencyClosureTypePredicate} or pg_type.typnamespace = 'pg_catalog'::regnamespace` + : dependencyClosureTypePredicate, + extensionPredicate: + 'pg_extension.oid = any (array(select installed_extensions._id from installed_extensions))', + languagePredicate: 'true', + accessMethodPredicate: 'true', + }), + values: [normalized, normalizedCapabilityExtensions], + }; +}; From 0aeea39827e9719dc9cde92d4adf832c0750529f Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 06:31:20 +0800 Subject: [PATCH 03/12] Wire scoped introspection into CNC --- graphile/graphile-settings/README.md | 28 ++++ .../scoped-introspection-wiring.test.ts | 96 ++++++++++++++ graphile/graphile-settings/src/index.ts | 19 ++- .../src/introspection-settings.ts | 49 +++++++ .../src/scoped-introspection-service.ts | 92 +++++++++++++ graphql/env/README.md | 9 +- .../__snapshots__/merge.test.ts.snap | 3 + graphql/env/__tests__/merge.test.ts | 60 +++++++++ graphql/env/src/env.ts | 26 +++- graphql/env/src/merge.ts | 16 ++- graphql/server/README.md | 1 + graphql/server/package.json | 1 + .../__tests__/graphile-introspection.test.ts | 64 +++++++++ .../src/middleware/graphile-introspection.ts | 70 ++++++++++ graphql/server/src/middleware/graphile.ts | 36 ++++-- graphql/server/src/middleware/types.ts | 12 +- graphql/types/src/graphile.ts | 27 ++++ graphql/types/src/index.ts | 6 +- .../scoped-introspection-suite.test.ts | 23 ++++ packages/perf-harness/package.json | 2 + packages/perf-harness/src/index.ts | 1 + .../src/scoped-introspection-suite.ts | 24 ++++ .../src/scoped-introspection-worker.ts | 121 ++++++++++++++++++ pnpm-lock.yaml | 9 ++ 24 files changed, 767 insertions(+), 28 deletions(-) create mode 100644 graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts create mode 100644 graphile/graphile-settings/src/introspection-settings.ts create mode 100644 graphile/graphile-settings/src/scoped-introspection-service.ts create mode 100644 graphql/server/src/middleware/__tests__/graphile-introspection.test.ts create mode 100644 graphql/server/src/middleware/graphile-introspection.ts create mode 100644 packages/perf-harness/__tests__/scoped-introspection-suite.test.ts create mode 100644 packages/perf-harness/src/scoped-introspection-suite.ts create mode 100644 packages/perf-harness/src/scoped-introspection-worker.ts diff --git a/graphile/graphile-settings/README.md b/graphile/graphile-settings/README.md index 84a4156058..683d93e9e7 100644 --- a/graphile/graphile-settings/README.md +++ b/graphile/graphile-settings/README.md @@ -183,6 +183,34 @@ const { schema } = await makeSchema(preset); const sdl = printSchema(schema); ``` +## Opt-in Scoped Introspection + +`ConstructivePreset` and `makePgService` retain PostGraphile's upstream +introspection behavior. Applications that explicitly opt into CNC scoped +introspection should install the independently owned preset and use the scoped +service factory together: + +```typescript +import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection'; +import { ConstructivePreset, makeScopedPgService } from 'graphile-settings'; + +const preset = { + extends: [ConstructivePreset, ScopedIntrospectionPreset], + pgServices: [ + makeScopedPgService({ + connectionString: 'postgres://user:pass@localhost/mydb', + schemas: ['app_public'], + introspectionAllowedDependencySchemas: ['shared'], + introspectionCapabilityExtensions: ['pg_trgm'], + }), + ], +}; +``` + +The Constructive GraphQL server performs this pairing when +`GRAPHILE_INTROSPECTION_MODE=scoped-required`; when unset or set to `stock`, it +does not load the scoped package. + ## Smart Tags Reference Control schema generation with PostgreSQL comments: diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts new file mode 100644 index 0000000000..ac8bc90f9d --- /dev/null +++ b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts @@ -0,0 +1,96 @@ +import type { ScopedIntrospectionServiceOptions } from '@constructive-io/graphql-types'; +import { PgIntrospectionPlugin } from 'graphile-build-pg'; +import { resolvePreset } from 'graphile-config'; + +import { ConstructivePreset } from '../src/presets/constructive-preset'; +import { makeConfiguredPgService } from '../src/scoped-introspection-service'; + +type TestUpstreamOptions = { + pubsub?: boolean; + schemas?: string[]; + pgSettingsForIntrospection?: + Record | null | undefined; +}; + +const makeUpstreamPgService = jest.fn((options: TestUpstreamOptions) => ({ + ...options, + upstream: true, +})); +const makeScopedPgService = ( + options: TestUpstreamOptions & + Omit +) => makeConfiguredPgService(makeUpstreamPgService, options); + +describe('scoped introspection settings wiring', () => { + beforeEach(() => { + makeUpstreamPgService.mockClear(); + }); + + it('normalizes scoped service configuration without forwarding CNC fields upstream', () => { + const service = makeScopedPgService({ + pubsub: false, + schemas: ['tenant_a'], + introspectionScopedCatalogTypes: 'dependency-closure', + introspectionAllowedDependencySchemas: ['shared', 'shared'], + introspectionCapabilityExtensions: ['pg_trgm', 'pg_trgm'], + pgSettingsForIntrospection: { statement_timeout: '30s' }, + }); + + expect(service).toMatchObject({ + schemas: ['tenant_a'], + introspectionMode: 'scoped-required', + introspectionScopedCatalogTypes: 'dependency-closure', + introspectionAllowedDependencySchemas: ['shared'], + introspectionCapabilityExtensions: ['pg_trgm'], + pgSettingsForIntrospection: { + statement_timeout: '30s', + jit: 'off', + work_mem: '512kB', + }, + }); + expect(makeUpstreamPgService).toHaveBeenCalledWith({ + pubsub: false, + schemas: ['tenant_a'], + pgSettingsForIntrospection: { + statement_timeout: '30s', + jit: 'off', + work_mem: '512kB', + }, + }); + }); + + it('fails deterministically on invalid scoped configuration', () => { + expect(() => + makeScopedPgService({ + pubsub: false, + introspectionScopedCatalogTypes: 'unsupported' as never, + }) + ).toThrow("Unsupported scoped catalog type policy 'unsupported'"); + expect(() => + makeScopedPgService({ + pubsub: false, + introspectionCapabilityExtensions: [' pg_trgm'], + }) + ).toThrow( + 'introspectionCapabilityExtensions must contain exact non-empty extension names' + ); + expect(() => + makeScopedPgService({ + pubsub: false, + introspectionAllowedDependencySchemas: ['pg_catalog'], + }) + ).toThrow('must not be a system schema'); + }); + + it('keeps ConstructivePreset on the upstream introspection plugin', () => { + const constructive = resolvePreset(ConstructivePreset); + + expect(constructive.plugins).toContain(PgIntrospectionPlugin); + expect(constructive.plugins.map((plugin) => plugin.name)).not.toContain( + 'PgScopedIntrospectionPlugin' + ); + expect(constructive.disablePlugins ?? []).not.toContain( + 'PgIntrospectionPlugin' + ); + }); +}); diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index afa9154a82..758fef9e1a 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -37,6 +37,11 @@ import 'graphile-build'; import { makePgService } from 'postgraphile/adaptors/pg'; +import { + makeConfiguredPgService, + type ScopedIntrospectionOptions +} from './scoped-introspection-service'; + // ============================================================================ // Re-export all plugins and presets // ============================================================================ @@ -55,9 +60,21 @@ export * from './presets/index'; // Utilities // ============================================================================ -// Re-export makePgService for convenience +export type ScopedPgServiceOptions = Parameters[0] & + ScopedIntrospectionOptions; + +// Keep the default service factory as the untouched upstream implementation. export { makePgService }; +/** Construct a PG service configured for CNC's opt-in scoped introspection. */ +export const makeScopedPgService = (options: ScopedPgServiceOptions) => + makeConfiguredPgService(makePgService, options); + +export { + normalizeIntrospectionDependencySchemas, + resolveIntrospectionSettings +} from './introspection-settings'; + // Presigned URL utilities export { getPresignedUrlS3Config } from './presigned-url-resolver'; diff --git a/graphile/graphile-settings/src/introspection-settings.ts b/graphile/graphile-settings/src/introspection-settings.ts new file mode 100644 index 0000000000..a5fc9eda9a --- /dev/null +++ b/graphile/graphile-settings/src/introspection-settings.ts @@ -0,0 +1,49 @@ +import type { GraphileIntrospectionMode } from '@constructive-io/graphql-types'; + +export type { GraphileIntrospectionMode } from '@constructive-io/graphql-types'; + +export const DEFAULT_INTROSPECTION_STATEMENT_TIMEOUT = '120s'; + +export const normalizeIntrospectionDependencySchemas = ( + schemas: readonly string[] | null | undefined +): string[] => [ + ...new Set( + (schemas ?? []).map((schema) => { + if (typeof schema !== 'string' || schema.trim().length === 0) { + throw new Error( + 'Introspection dependency schemas must be non-empty strings' + ); + } + const normalized = schema.trim(); + if (normalized === 'information_schema' || normalized.startsWith('pg_')) { + throw new Error( + `Introspection dependency schema '${normalized}' must not be a system schema` + ); + } + if (normalized.includes('\0')) { + throw new Error( + 'Introspection dependency schemas must not contain NUL bytes' + ); + } + return normalized; + }) + ), +]; + +export const resolveIntrospectionSettings = ( + mode: GraphileIntrospectionMode, + settings: Record | null | undefined +): Record => { + const boundedSettings = { ...settings }; + if (!boundedSettings.statement_timeout) { + boundedSettings.statement_timeout = DEFAULT_INTROSPECTION_STATEMENT_TIMEOUT; + } + if (mode === 'scoped-required') { + return { + ...boundedSettings, + jit: 'off', + work_mem: '512kB', + }; + } + return boundedSettings; +}; diff --git a/graphile/graphile-settings/src/scoped-introspection-service.ts b/graphile/graphile-settings/src/scoped-introspection-service.ts new file mode 100644 index 0000000000..20cbf695d4 --- /dev/null +++ b/graphile/graphile-settings/src/scoped-introspection-service.ts @@ -0,0 +1,92 @@ +import type { ScopedIntrospectionServiceOptions } from '@constructive-io/graphql-types'; + +import { + normalizeIntrospectionDependencySchemas, + resolveIntrospectionSettings, +} from './introspection-settings'; + +const normalizeIntrospectionCapabilityExtensions = ( + extensions: readonly string[] | undefined +): readonly string[] => { + if (extensions === undefined) return []; + if (!Array.isArray(extensions)) { + throw new Error('introspectionCapabilityExtensions must be an array'); + } + return [ + ...new Set( + extensions.map((extension) => { + if ( + typeof extension !== 'string' || + extension.length === 0 || + extension.trim() !== extension || + extension.includes('\0') + ) { + throw new Error( + 'introspectionCapabilityExtensions must contain exact non-empty extension names' + ); + } + return extension; + }) + ), + ]; +}; + +type UpstreamPgServiceOptions = { + pgSettingsForIntrospection?: + Record | null | undefined; +}; + +export type ScopedIntrospectionOptions = Omit< + ScopedIntrospectionServiceOptions, + 'introspectionMode' +>; + +/** + * Apply CNC's scoped-introspection settings around an upstream PgService + * factory. The injected binding keeps configuration behavior independently + * testable without duplicating or mocking the Graphile adaptor. + */ +export function makeConfiguredPgService< + TOptions extends UpstreamPgServiceOptions, + TService extends object, +>( + makeUpstreamPgService: (options: TOptions) => TService, + options: TOptions & ScopedIntrospectionOptions +) { + const { + introspectionScopedCatalogTypes, + introspectionAllowedDependencySchemas: configuredDependencySchemas, + introspectionCapabilityExtensions: configuredCapabilityExtensions, + ...upstreamOptions + } = options; + const introspectionCapabilityExtensions = + normalizeIntrospectionCapabilityExtensions(configuredCapabilityExtensions); + + if ( + introspectionScopedCatalogTypes !== undefined && + introspectionScopedCatalogTypes !== 'all' && + introspectionScopedCatalogTypes !== 'dependency-closure' + ) { + throw new Error( + `Unsupported scoped catalog type policy '${introspectionScopedCatalogTypes}'` + ); + } + const introspectionAllowedDependencySchemas = + normalizeIntrospectionDependencySchemas(configuredDependencySchemas); + const pgSettingsForIntrospection = resolveIntrospectionSettings( + 'scoped-required', + options.pgSettingsForIntrospection + ); + const service = makeUpstreamPgService({ + ...upstreamOptions, + pgSettingsForIntrospection, + } as TOptions); + + return Object.assign(service, { + introspectionMode: 'scoped-required' as const, + introspectionScopedCatalogTypes: + introspectionScopedCatalogTypes ?? 'dependency-closure', + introspectionAllowedDependencySchemas, + introspectionCapabilityExtensions, + }); +} diff --git a/graphql/env/README.md b/graphql/env/README.md index e5084a59d8..0ecdf458d8 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -43,6 +43,8 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag ### GraphQL Schema - `GRAPHILE_SCHEMA` - Comma-separated list of PostgreSQL schemas to expose +- `GRAPHILE_INTROSPECTION_MODE` - `stock` (default) or `scoped-required`; + invalid values fail during option resolution ### Feature Flags - `FEATURES_SIMPLE_INFLECTION` - Enable simple inflection plugin @@ -63,7 +65,12 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`: ```typescript { - graphile: { schema: [] }, + graphile: { + schema: [], + introspectionMode: 'stock', + introspectionDependencySchemas: [], + introspectionCapabilityExtensions: [] + }, features: { simpleInflection: true, oppositeBaseNames: true, diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 6383de2044..89998b6a15 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -70,6 +70,9 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and }, "graphile": { "extends": [], + "introspectionCapabilityExtensions": [], + "introspectionDependencySchemas": [], + "introspectionMode": "stock", "preset": {}, "schema": [ "override_schema", diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index fa7dd645e8..f856dba16d 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -138,6 +138,66 @@ describe('getEnvOptions', () => { expect(result.api?.metaSchemas).toEqual(['env_meta', 'override_meta']); }); + it('defaults to untouched stock introspection', () => { + const result = getEnvOptions({}, process.cwd(), {}); + + expect(result.graphile?.introspectionMode).toBe('stock'); + }); + + it('accepts stock and scoped-required introspection environment modes', () => { + expect( + getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: 'stock' }).graphile + ?.introspectionMode + ).toBe('stock'); + expect( + getGraphQLEnvVars({ + GRAPHILE_INTROSPECTION_MODE: 'scoped-required' + }).graphile?.introspectionMode + ).toBe('scoped-required'); + }); + + it('rejects malformed explicit introspection modes', () => { + expect(() => + getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: 'scpoed' }) + ).toThrow(/GRAPHILE_INTROSPECTION_MODE/); + expect(() => + getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: '' }) + ).toThrow(/GRAPHILE_INTROSPECTION_MODE/); + }); + + it('honors config, env, and runtime priority for introspection mode', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-introspection-') + ); + writeConfig(tempDir, { + graphile: { introspectionMode: 'stock' } + }); + + expect( + getEnvOptions({}, tempDir, { + GRAPHILE_INTROSPECTION_MODE: 'scoped-required' + }).graphile?.introspectionMode + ).toBe('scoped-required'); + expect( + getEnvOptions({ graphile: { introspectionMode: 'stock' } }, tempDir, { + GRAPHILE_INTROSPECTION_MODE: 'scoped-required' + }).graphile?.introspectionMode + ).toBe('stock'); + }); + + it('rejects malformed config-file introspection modes', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-invalid-introspection-') + ); + writeConfig(tempDir, { + graphile: { introspectionMode: 'scpoed' } + }); + + expect(() => getEnvOptions({}, tempDir, {})).toThrow( + /Unsupported Graphile introspection mode/ + ); + }); + it('parses SMS environment variables into typed options', () => { const result = getGraphQLEnvVars({ SMS_PROVIDER: 'devsms', diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 014924ef24..2d531232a5 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,5 +1,20 @@ -import { ConstructiveOptions } from '@constructive-io/graphql-types'; -import { parseEnvBoolean, parseEnvNumber } from '12factor-env'; +import { ConstructiveOptions, type GraphileIntrospectionMode, graphileIntrospectionModes } from '@constructive-io/graphql-types'; +import { env as validateEnv, parseEnvBoolean, parseEnvNumber, str } from '12factor-env'; + +const parseGraphileIntrospectionMode = ( + value: string | undefined +): GraphileIntrospectionMode | undefined => { + if (value === undefined) return undefined; + return validateEnv( + { GRAPHILE_INTROSPECTION_MODE: value }, + {}, + { + GRAPHILE_INTROSPECTION_MODE: str({ + choices: [...graphileIntrospectionModes] + }) + } + ).GRAPHILE_INTROSPECTION_MODE as GraphileIntrospectionMode; +}; /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) @@ -7,6 +22,7 @@ import { parseEnvBoolean, parseEnvNumber } from '12factor-env'; export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial => { const { GRAPHILE_SCHEMA, + GRAPHILE_INTROSPECTION_MODE, FEATURES_SIMPLE_INFLECTION, FEATURES_OPPOSITE_BASE_NAMES, @@ -38,6 +54,9 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial // let an absent env var overwrite pgpm.json or consumer-specific values. const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); + const introspectionMode = parseGraphileIntrospectionMode( + GRAPHILE_INTROSPECTION_MODE + ); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || SMS_SENDER_ID || @@ -52,7 +71,8 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial schema: GRAPHILE_SCHEMA.includes(',') ? GRAPHILE_SCHEMA.split(',').map(s => s.trim()) : GRAPHILE_SCHEMA - }) + }), + ...(introspectionMode !== undefined && { introspectionMode }) }, features: { ...(FEATURES_SIMPLE_INFLECTION && { simpleInflection: parseEnvBoolean(FEATURES_SIMPLE_INFLECTION) }), diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 15f1402c53..76b77b1995 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -1,4 +1,4 @@ -import { constructiveGraphqlDefaults,ConstructiveOptions } from '@constructive-io/graphql-types'; +import { constructiveGraphqlDefaults,ConstructiveOptions,graphileIntrospectionModes } from '@constructive-io/graphql-types'; import { getEnvOptions as getPgpmEnvOptions, loadConfigSync, replaceArrays } from '@pgpmjs/env'; import deepmerge from 'deepmerge'; @@ -36,7 +36,7 @@ export const getEnvOptions = ( const configOptions = loadConfigSync(cwd) as Partial; // Merge in order: core -> graphql defaults -> config (for graphql keys) -> graphql env -> overrides - return deepmerge.all([ + const options = deepmerge.all([ coreOptions, constructiveGraphqlDefaults, // Only merge graphql-related keys from config (if present) @@ -51,6 +51,18 @@ export const getEnvOptions = ( ], { arrayMerge: replaceArrays }) as ConstructiveOptions; + + const introspectionMode = options.graphile?.introspectionMode; + if ( + introspectionMode !== undefined && + !graphileIntrospectionModes.includes(introspectionMode) + ) { + throw new Error( + `Unsupported Graphile introspection mode '${String(introspectionMode)}'; expected one of: ${graphileIntrospectionModes.join(', ')}` + ); + } + + return options; }; /** diff --git a/graphql/server/README.md b/graphql/server/README.md index f874d18e0f..74fe2953ac 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -123,6 +123,7 @@ Configuration is merged from defaults, config files, and env vars via `@construc | `PGPASSWORD` | Postgres password | `password` | | `PGDATABASE` | Postgres database | `postgres` | | `GRAPHILE_SCHEMA` | Comma-separated schemas to expose | empty | +| `GRAPHILE_INTROSPECTION_MODE` | `stock` or opt-in `scoped-required` | `stock` | | `FEATURES_SIMPLE_INFLECTION` | Enable simple inflection | `true` | | `FEATURES_OPPOSITE_BASE_NAMES` | Enable opposite base names | `true` | | `FEATURES_POSTGIS` | Enable PostGIS support | `true` | diff --git a/graphql/server/package.json b/graphql/server/package.json index 784404c3a2..5a59cbc380 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -67,6 +67,7 @@ "graphile-cache": "workspace:^", "graphile-config": "1.1.0", "graphile-function-bindings": "workspace:^", + "graphile-scoped-introspection": "workspace:^", "graphile-settings": "workspace:^", "graphile-utils": "5.0.3", "graphql": "16.13.0", diff --git a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts new file mode 100644 index 0000000000..104b43d7b2 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts @@ -0,0 +1,64 @@ +import type { GraphileConfig } from 'graphile-config'; +import type { Pool } from 'pg'; + +import { makeIntrospectionWiring } from '../graphile-introspection'; + +const pool = {} as Pool; + +describe('Graphile introspection mode wiring', () => { + it('uses untouched upstream service wiring without loading scoped code by default', async () => { + const loadScopedPreset = jest.fn(async () => { + throw new Error('scoped preset should not load'); + }); + + const wiring = await makeIntrospectionWiring( + pool, + ['tenant_a'], + undefined, + loadScopedPreset + ); + + expect(loadScopedPreset).not.toHaveBeenCalled(); + expect(wiring.presets).toEqual([]); + expect(wiring.pgService).not.toHaveProperty('introspectionMode'); + expect(wiring.pgService).not.toHaveProperty( + 'introspectionAllowedDependencySchemas' + ); + expect(wiring.pgService).not.toHaveProperty( + 'introspectionCapabilityExtensions' + ); + expect(wiring.pgService.pgSettingsForIntrospection).toBeUndefined(); + }); + + it('loads and configures scoped introspection only when explicitly enabled', async () => { + const scopedPreset: GraphileConfig.Preset = { + disablePlugins: ['PgIntrospectionPlugin'], + }; + const loadScopedPreset = jest.fn(async () => scopedPreset); + + const wiring = await makeIntrospectionWiring( + pool, + ['tenant_a'], + { + introspectionMode: 'scoped-required', + introspectionDependencySchemas: ['shared'], + introspectionCapabilityExtensions: ['pg_trgm'], + }, + loadScopedPreset + ); + + expect(loadScopedPreset).toHaveBeenCalledTimes(1); + expect(wiring.presets).toEqual([scopedPreset]); + expect(wiring.pgService).toMatchObject({ + introspectionMode: 'scoped-required', + introspectionScopedCatalogTypes: 'dependency-closure', + introspectionAllowedDependencySchemas: ['shared'], + introspectionCapabilityExtensions: ['pg_trgm'], + pgSettingsForIntrospection: { + statement_timeout: '120s', + jit: 'off', + work_mem: '512kB', + }, + }); + }); +}); diff --git a/graphql/server/src/middleware/graphile-introspection.ts b/graphql/server/src/middleware/graphile-introspection.ts new file mode 100644 index 0000000000..175693841b --- /dev/null +++ b/graphql/server/src/middleware/graphile-introspection.ts @@ -0,0 +1,70 @@ +import type { + GraphileIntrospectionMode, + GraphileOptions, +} from '@constructive-io/graphql-types'; +import type { GraphileConfig } from 'graphile-config'; +import { makePgService, makeScopedPgService } from 'graphile-settings'; +import type { Pool } from 'pg'; + +export interface IntrospectionWiring { + presets: GraphileConfig.Preset[]; + pgService: GraphileConfig.PgServiceConfiguration; +} + +export type ScopedIntrospectionPresetLoader = + () => Promise; + +let scopedIntrospectionPresetPromise: + Promise | undefined; + +const loadScopedIntrospectionPreset = (): Promise => { + scopedIntrospectionPresetPromise ??= + import('graphile-scoped-introspection').then( + ({ ScopedIntrospectionPreset }) => ScopedIntrospectionPreset + ); + return scopedIntrospectionPresetPromise; +}; + +const assertNever = (mode: never): never => { + throw new Error(`Unsupported Graphile introspection mode '${String(mode)}'`); +}; + +/** + * Select the stock or scoped introspection wiring once, while constructing a + * server-owned schema handler. The stock branch returns before the scoped + * package (and its upstream contract sentinel) is loaded. + */ +export const makeIntrospectionWiring = async ( + pool: Pool, + schemas: string[], + graphileOptions: GraphileOptions | undefined, + loadScopedPreset: ScopedIntrospectionPresetLoader = loadScopedIntrospectionPreset +): Promise => { + const mode: GraphileIntrospectionMode = + graphileOptions?.introspectionMode ?? 'stock'; + + if (mode === 'stock') { + return { + presets: [], + pgService: makePgService({ pool, schemas }), + }; + } + + if (mode === 'scoped-required') { + const scopedPreset = await loadScopedPreset(); + return { + presets: [scopedPreset], + pgService: makeScopedPgService({ + pool, + schemas, + introspectionScopedCatalogTypes: 'dependency-closure', + introspectionAllowedDependencySchemas: + graphileOptions?.introspectionDependencySchemas, + introspectionCapabilityExtensions: + graphileOptions?.introspectionCapabilityExtensions, + }), + }; + } + + return assertNever(mode); +}; diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6de98f7ad..c4dce14582 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -12,7 +12,7 @@ import type { GraphQLError, GraphQLFormattedError } from 'grafast/graphql'; import { createGraphileInstance, graphileCache,type GraphileCacheEntry } from 'graphile-cache'; import type { GraphileConfig } from 'graphile-config'; import { createFunctionBindingsPlugin } from 'graphile-function-bindings'; -import { createConstructivePreset, makePgService } from 'graphile-settings'; +import { createConstructivePreset } from 'graphile-settings'; import { getPgPool } from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; @@ -21,6 +21,7 @@ import { HandlerCreationError } from '../errors/api-errors'; import { respondWithGraphQLError } from '../errors/graphql-response'; import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin'; import type { DatabaseSettings } from '../types'; +import { makeIntrospectionWiring } from './graphile-introspection'; import { observeGraphileBuild } from './observability/graphile-build-stats'; const maskErrorLog = new Logger('graphile:maskError'); @@ -160,17 +161,26 @@ const reqLabel = (req: Request): string => (req.requestId ? `[${req.requestId}]` * plugin preset. Without settings the default preset is used * (everything on except aggregates). */ -const buildPreset = ( +const buildPreset = async ( pool: import('pg').Pool, schemas: string[], anonRole: string, roleName: string, + graphileOptions: ConstructiveOptions['graphile'], databaseSettings?: DatabaseSettings, apiId?: string, compute?: ComputeConfig -): GraphileConfig.Preset => { +): Promise => { + const introspection = await makeIntrospectionWiring( + pool, + schemas, + graphileOptions + ); return { - extends: [createConstructivePreset(databaseSettings)], + extends: [ + createConstructivePreset(databaseSettings), + ...introspection.presets + ], plugins: [ AuthCookiePlugin, // Only registered when the compute module is provisioned for this @@ -193,12 +203,7 @@ const buildPreset = ( ] : []) ], - pgServices: [ - makePgService({ - pool, - schemas - }) - ], + pgServices: [introspection.pgService], grafserv: { graphqlPath: '/graphql', graphiqlPath: '/graphiql', @@ -403,7 +408,16 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // Create promise and store in in-flight map BEFORE try block const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; - const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute); + const preset = await buildPreset( + pool, + schema || [], + anonRole, + roleName, + opts.graphile, + api.databaseSettings, + api.apiId, + compute + ); const creationPromise = observeGraphileBuild( { cacheKey: key, diff --git a/graphql/server/src/middleware/types.ts b/graphql/server/src/middleware/types.ts index 5b0868f764..d5b8904c0f 100644 --- a/graphql/server/src/middleware/types.ts +++ b/graphql/server/src/middleware/types.ts @@ -1,14 +1,8 @@ +import type { ConstructiveAPIToken } from '@constructive-io/express-context'; + import type { ApiStructure } from '../types'; -export type ConstructiveAPIToken = { - id?: string; - user_id?: string; - principal_id?: string; - session_id?: string; - access_level?: string; - kind?: string; - [key: string]: unknown; -}; +export type { ConstructiveAPIToken } from '@constructive-io/express-context'; declare global { namespace Express { diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index 72fff4c739..a4e60cc49e 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -1,11 +1,35 @@ import type { GraphileConfig } from 'graphile-config'; +export const graphileIntrospectionModes = ['stock', 'scoped-required'] as const; + +export type GraphileIntrospectionMode = + (typeof graphileIntrospectionModes)[number]; + +export type ScopedCatalogTypes = 'all' | 'dependency-closure'; + +export interface ScopedIntrospectionServiceOptions { + /** Selects the catalog query used during this service's gather phase. */ + introspectionMode?: GraphileIntrospectionMode; + /** Catalog types retained by scoped introspection; defaults to all. */ + introspectionScopedCatalogTypes?: ScopedCatalogTypes; + /** Non-root schemas that scoped dependency closure may retain. */ + introspectionAllowedDependencySchemas?: readonly string[]; + /** Installed extensions whose optional capability metadata is required. */ + introspectionCapabilityExtensions?: readonly string[]; +} + /** * PostGraphile/Graphile v5 configuration */ export interface GraphileOptions { /** Database schema(s) to expose through GraphQL */ schema?: string | string[]; + /** PostgreSQL catalog introspection implementation selected at startup. */ + introspectionMode?: GraphileIntrospectionMode; + /** Additional schemas that scoped dependency closure may retain. */ + introspectionDependencySchemas?: string[]; + /** Installed extensions whose optional capability metadata must be retained. */ + introspectionCapabilityExtensions?: string[]; /** Additional presets to extend */ extends?: GraphileConfig.Preset[]; /** Preset overrides */ @@ -51,6 +75,9 @@ export interface ApiOptions { */ export const graphileDefaults: GraphileOptions = { schema: [], + introspectionMode: 'stock', + introspectionDependencySchemas: [], + introspectionCapabilityExtensions: [], extends: [], preset: {} }; diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index 895604e137..20e684cdef 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -5,7 +5,11 @@ export { graphileDefaults, graphileFeatureDefaults, GraphileFeatureOptions, - GraphileOptions} from './graphile'; + GraphileIntrospectionMode, + graphileIntrospectionModes, + GraphileOptions, + ScopedCatalogTypes, + ScopedIntrospectionServiceOptions} from './graphile'; // Export Constructive combined types export { diff --git a/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts b/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts new file mode 100644 index 0000000000..c1efcd88f7 --- /dev/null +++ b/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts @@ -0,0 +1,23 @@ +import { makeScopedIntrospectionSuite } from '../src/scoped-introspection-suite'; + +describe('scoped introspection benchmark registration', () => { + it('adds two schema-equivalent cases through the generic suite API', () => { + expect( + makeScopedIntrospectionSuite({ schemas: ['cperf_example'] }) + ).toEqual({ + name: 'scoped-introspection', + cases: [ + { + name: 'stock', + workerConfig: { mode: 'stock', schemas: ['cperf_example'] }, + expectedSchemaGroup: 'introspection-equivalence', + }, + { + name: 'scoped', + workerConfig: { mode: 'scoped', schemas: ['cperf_example'] }, + expectedSchemaGroup: 'introspection-equivalence', + }, + ], + }); + }); +}); diff --git a/packages/perf-harness/package.json b/packages/perf-harness/package.json index 8d23b8bc10..f5c235a485 100644 --- a/packages/perf-harness/package.json +++ b/packages/perf-harness/package.json @@ -20,6 +20,8 @@ "graphile-build": "5.1.1", "graphile-build-pg": "5.1.3", "graphile-config": "1.1.0", + "graphile-scoped-introspection": "workspace:^", + "graphile-settings": "workspace:^", "graphql": "16.13.0", "pg": "^8.21.0", "postgraphile": "5.1.4" diff --git a/packages/perf-harness/src/index.ts b/packages/perf-harness/src/index.ts index 7829d7dc66..379752f582 100644 --- a/packages/perf-harness/src/index.ts +++ b/packages/perf-harness/src/index.ts @@ -6,6 +6,7 @@ export * from './process'; export * from './report'; export * from './run'; export * from './schedule'; +export * from './scoped-introspection-suite'; export * from './types'; import { cliMain } from './run'; diff --git a/packages/perf-harness/src/scoped-introspection-suite.ts b/packages/perf-harness/src/scoped-introspection-suite.ts new file mode 100644 index 0000000000..5c59d9f825 --- /dev/null +++ b/packages/perf-harness/src/scoped-introspection-suite.ts @@ -0,0 +1,24 @@ +import type { BenchmarkSuiteDefinition } from './types'; + +export interface ScopedIntrospectionSuiteOptions { + schemas: string[]; +} + +/** Register the stock/scoped cases without teaching the core runner their names. */ +export const makeScopedIntrospectionSuite = ( + options: ScopedIntrospectionSuiteOptions +): BenchmarkSuiteDefinition => ({ + name: 'scoped-introspection', + cases: [ + { + name: 'stock', + workerConfig: { mode: 'stock', schemas: options.schemas }, + expectedSchemaGroup: 'introspection-equivalence', + }, + { + name: 'scoped', + workerConfig: { mode: 'scoped', schemas: options.schemas }, + expectedSchemaGroup: 'introspection-equivalence', + }, + ], +}); diff --git a/packages/perf-harness/src/scoped-introspection-worker.ts b/packages/perf-harness/src/scoped-introspection-worker.ts new file mode 100644 index 0000000000..733331b39f --- /dev/null +++ b/packages/perf-harness/src/scoped-introspection-worker.ts @@ -0,0 +1,121 @@ +import { createHash } from 'node:crypto'; + +import { + defaultPreset as graphileBuildPreset, + makeSchema, +} from 'graphile-build'; +import { defaultPreset as graphileBuildPgPreset } from 'graphile-build-pg'; +import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection'; +import { makeScopedPgService } from 'graphile-settings'; +import { execute, lexicographicSortSchema, parse, printSchema } from 'graphql'; +import { makePgService as makePostGraphilePgService } from 'postgraphile/adaptors/pg'; + +import { measureBenchmarkCase } from './metrics'; +import { + parseWorkerProcessArgs, + redactSecret, + writeWorkerResult, +} from './process'; + +interface ScopedWorkerConfig { + mode: 'stock' | 'scoped'; + schemas: string[]; +} + +const validateConfig = (value: unknown): ScopedWorkerConfig => { + const config = value as Partial; + if (config.mode !== 'stock' && config.mode !== 'scoped') { + throw new Error( + 'scoped introspection worker requires stock or scoped mode' + ); + } + if ( + !Array.isArray(config.schemas) || + config.schemas.length === 0 || + config.schemas.some( + (schema) => typeof schema !== 'string' || schema.length === 0 + ) + ) { + throw new Error( + 'scoped introspection worker requires a non-empty schemas array' + ); + } + return { mode: config.mode, schemas: config.schemas }; +}; + +const main = async (): Promise => { + let databaseUrl = ''; + let caseName = 'unknown'; + let release: (() => Promise) | null = null; + try { + const workerArgs = parseWorkerProcessArgs(process.argv.slice(2)); + databaseUrl = workerArgs.databaseUrl; + const { envelope } = workerArgs; + caseName = envelope.caseName; + const config = validateConfig(envelope.workerConfig); + const serviceOptions = { + connectionString: databaseUrl, + schemas: config.schemas, + pubsub: false, + }; + const scopedServiceOptions = { + ...serviceOptions, + introspectionScopedCatalogTypes: 'dependency-closure' as const, + }; + const service = + config.mode === 'stock' + ? makePostGraphilePgService(serviceOptions) + : makeScopedPgService(scopedServiceOptions); + release = async () => { + await service.release(); + }; + + const result = await measureBenchmarkCase( + caseName, + async () => + makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ...(config.mode === 'scoped' ? [ScopedIntrospectionPreset] : []), + ], + pgServices: [service], + }), + async ({ schema }) => { + const execution = await execute({ + schema, + document: parse('{ __typename }'), + }); + if ( + execution.errors?.length || + execution.data?.__typename !== 'Query' + ) { + throw new Error('runtime verification query failed'); + } + const schemaText = printSchema(lexicographicSortSchema(schema)); + return { + schemaHash: createHash('sha256').update(schemaText).digest('hex'), + schemaTypeCount: Object.keys(schema.getTypeMap()).length, + runtimeVerified: true as const, + metadata: { introspectionMode: config.mode }, + }; + } + ); + writeWorkerResult(result); + } catch (error) { + writeWorkerResult({ + status: 'error', + pid: process.pid, + caseName, + error: redactSecret( + error instanceof Error ? error.message : String(error), + databaseUrl + ), + }); + process.exitCode = 1; + } finally { + await release?.(); + } +}; + +if (require.main === module) void main(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee16e79f0c..f97c85da1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2049,6 +2049,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 @@ -2658,6 +2661,12 @@ importers: graphile-config: specifier: 1.1.0 version: 1.1.0 + graphile-scoped-introspection: + specifier: workspace:^ + version: link:../../graphile/graphile-scoped-introspection/dist + graphile-settings: + specifier: workspace:^ + version: link:../../graphile/graphile-settings/dist graphql: specifier: 16.13.0 version: 16.13.0 From bf54c34430e86d355b39f1d7abad413801300424 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 06:56:04 +0800 Subject: [PATCH 04/12] Use boolean scoped introspection configuration --- .../graphile-scoped-introspection/README.md | 2 +- ...oped-introspection-cache-lifecycle.test.ts | 2 +- .../scoped-introspection-mixed.test.ts | 17 +++-- .../scoped-introspection-runtime.test.ts | 4 +- ...ped-introspection-service-contract.test.ts | 44 +++++++++++- .../src/index.ts | 1 - .../PgIntrospectionPlugin.ts | 68 +++++++++++-------- .../graphile-build-pg/scopedOptions.ts | 4 +- graphile/graphile-settings/README.md | 4 +- .../scoped-introspection-wiring.test.ts | 4 +- .../src/introspection-settings.ts | 8 +-- .../src/scoped-introspection-service.ts | 6 +- graphql/env/README.md | 6 +- .../__snapshots__/merge.test.ts.snap | 2 +- graphql/env/__tests__/merge.test.ts | 68 ++++++++----------- graphql/env/src/env.ts | 27 ++------ graphql/env/src/merge.ts | 15 +--- graphql/server/README.md | 2 +- .../__tests__/graphile-introspection.test.ts | 40 ++++++++++- .../src/middleware/graphile-introspection.ts | 65 ++++++++++-------- graphql/types/src/graphile.ts | 15 ++-- graphql/types/src/index.ts | 2 - .../scoped-introspection-suite.test.ts | 10 ++- .../src/scoped-introspection-suite.ts | 4 +- .../src/scoped-introspection-worker.ts | 23 ++++--- 25 files changed, 250 insertions(+), 193 deletions(-) diff --git a/graphile/graphile-scoped-introspection/README.md b/graphile/graphile-scoped-introspection/README.md index fd94f4390c..210631a551 100644 --- a/graphile/graphile-scoped-introspection/README.md +++ b/graphile/graphile-scoped-introspection/README.md @@ -13,7 +13,7 @@ provenance and licensing. `ScopedIntrospectionPreset` atomically disables the stock `PgIntrospectionPlugin` and installs `PgScopedIntrospectionPlugin`. The replacement preserves the stock query unless a PG service explicitly selects -`introspectionMode: 'scoped-required'`. Scoped services use a parameterized +`scopedIntrospection: true`. Scoped services use a parameterized catalog query, validate required and allowed namespaces after parsing, and can apply dependency-closure validation to retained PostgreSQL types. 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 index 7bc5b6e91e..2cf393b387 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts @@ -172,7 +172,7 @@ describe('scoped introspection raw-text lifecycle', () => { const pgService = { name: 'main', schemas: [SCHEMA], - introspectionMode: 'scoped-required', + scopedIntrospection: true, introspectionAllowedDependencySchemas: [] as readonly string[], adaptor, adaptorSettings: {}, diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts index c8533479fc..6d17ab377a 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts @@ -74,7 +74,7 @@ const introspectionText = (schema: string): string => const makeService = ( name: string, schema: string, - mode: 'stock' | 'scoped-required', + scopedIntrospection: boolean, queries: Array<{ text: string; values?: unknown[] }> ): never => { const query = jest.fn(async (input: { text: string; values?: unknown[] }) => { @@ -91,8 +91,7 @@ const makeService = ( return { name, schemas: [schema], - introspectionMode: mode, - introspectionAllowedDependencySchemas: [], + scopedIntrospection, adaptor: { createWithPgClient: jest.fn(async () => withPgClient), }, @@ -128,8 +127,8 @@ describe('mixed stock/scoped introspection services', () => { const output = await gather({ plugins: [PgScopedIntrospectionPlugin, observer], pgServices: [ - makeService('stock', 'stock_schema', 'stock', queries), - makeService('scoped', 'scoped_schema', 'scoped-required', queries), + makeService('stock', 'stock_schema', false, queries), + makeService('scoped', 'scoped_schema', true, queries), ], }); @@ -181,7 +180,7 @@ describe('mixed stock/scoped introspection services', () => { extends: [graphileBuildPreset, graphileBuildPgPreset], plugins: [makeObserver('UpstreamStockObserverPlugin')], pgServices: [ - makeService('main', 'stock_schema', 'stock', upstreamQueries), + makeService('main', 'stock_schema', false, upstreamQueries), ], }; const replacementPreset = { @@ -192,7 +191,7 @@ describe('mixed stock/scoped introspection services', () => { ], plugins: [makeObserver('ReplacementStockObserverPlugin')], pgServices: [ - makeService('main', 'stock_schema', 'stock', replacementQueries), + makeService('main', 'stock_schema', false, replacementQueries), ], }; @@ -211,7 +210,7 @@ describe('mixed stock/scoped introspection services', () => { makeSchema({ extends: [graphileBuildPreset, graphileBuildPgPreset], pgServices: [ - makeService('main', 'stock_schema', 'stock', upstreamQueries), + makeService('main', 'stock_schema', false, upstreamQueries), ], }), makeSchema({ @@ -221,7 +220,7 @@ describe('mixed stock/scoped introspection services', () => { ScopedIntrospectionPreset, ], pgServices: [ - makeService('main', 'stock_schema', 'stock', replacementQueries), + makeService('main', 'stock_schema', false, replacementQueries), ], }), ]); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts index 264533b736..8cccae1d02 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts @@ -50,7 +50,7 @@ describe('schema-scoped introspection runtime integration', () => { schemas: ['tenant_a'], }), { - introspectionMode: 'scoped-required', + scopedIntrospection: true, introspectionCapabilityExtensions: ['pg_trgm'], ...(scopedCatalogTypes === undefined ? {} @@ -146,7 +146,7 @@ describe('schema-scoped introspection runtime integration', () => { schemas: ['tenant_a'], }), { - introspectionMode: 'scoped-required', + scopedIntrospection: true, introspectionScopedCatalogTypes: 'dependency-closure', } ) as never, 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 index 2a809cfa70..e65d8489fb 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts @@ -9,7 +9,7 @@ const makeService = (overrides: Record = {}): never => ({ name: 'main', schemas: ['tenant_a'], - introspectionMode: 'scoped-required', + scopedIntrospection: true, introspectionAllowedDependencySchemas: [], adaptor: { createWithPgClient: jest.fn(() => { @@ -63,4 +63,46 @@ describe('scoped introspection service identity contract', () => { }) ).rejects.toThrow(message); }); + + it.each([ + ['catalog type policy', { introspectionScopedCatalogTypes: 'all' }], + [ + 'dependency schemas', + { introspectionAllowedDependencySchemas: ['shared'] }, + ], + [ + 'capability extensions', + { introspectionCapabilityExtensions: ['pg_trgm'] }, + ], + ])( + 'rejects %s unless scoped introspection is enabled', + async (_label, option) => { + await expect( + gather({ + plugins: [PgScopedIntrospectionPlugin, consumerPlugin], + pgServices: [ + makeService({ + scopedIntrospection: false, + introspectionAllowedDependencySchemas: undefined, + ...option, + }), + ], + }) + ).rejects.toThrow(/require scopedIntrospection: true/); + } + ); + + it('rejects a non-boolean scoped introspection flag', async () => { + await expect( + gather({ + plugins: [PgScopedIntrospectionPlugin, consumerPlugin], + pgServices: [ + makeService({ + scopedIntrospection: 'true', + introspectionAllowedDependencySchemas: undefined, + }), + ], + }) + ).rejects.toThrow('scopedIntrospection must be a boolean'); + }); }); diff --git a/graphile/graphile-scoped-introspection/src/index.ts b/graphile/graphile-scoped-introspection/src/index.ts index 20899d173a..95e0c82929 100644 --- a/graphile/graphile-scoped-introspection/src/index.ts +++ b/graphile/graphile-scoped-introspection/src/index.ts @@ -4,7 +4,6 @@ export { scopedIntrospectionUpstreamContract, } from './upstream/graphile-build-pg/PgIntrospectionPlugin'; export type { - GraphileIntrospectionMode, ScopedIntrospectionServiceOptions, } from './upstream/graphile-build-pg/scopedOptions'; export * from './upstream/pg-introspection'; diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts index cbb6194957..67dc8537fe 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts @@ -279,12 +279,22 @@ function getIntrospectionQuery( ): Omit & { query: PgQuery; } { - const mode = pgService.introspectionMode ?? 'stock'; + const scopedIntrospection = pgService.scopedIntrospection; const configuredCatalogTypes = pgService.introspectionScopedCatalogTypes; const configuredCapabilityExtensions = pgService.introspectionCapabilityExtensions; + const configuredDependencySchemas = + pgService.introspectionAllowedDependencySchemas; const scopedCatalogTypes = configuredCatalogTypes ?? 'all'; + if ( + scopedIntrospection !== undefined && + typeof scopedIntrospection !== "boolean" + ) { + throw new Error( + `scopedIntrospection must be a boolean for service '${pgService.name}'`, + ); + } if ( scopedCatalogTypes !== 'all' && scopedCatalogTypes !== 'dependency-closure' @@ -293,15 +303,23 @@ function getIntrospectionQuery( `Unsupported scoped catalog type policy '${scopedCatalogTypes}' for service '${pgService.name}'` ); } - if (mode === 'stock') { - if (configuredCatalogTypes !== undefined) { - throw new Error( - `Scoped catalog type policy is only valid with scoped-required introspection for service '${pgService.name}'` - ); - } - if (configuredCapabilityExtensions !== undefined) { + if (scopedIntrospection !== true) { + const configuredScopedOptions = [ + configuredCatalogTypes !== undefined + ? "introspectionScopedCatalogTypes" + : null, + configuredDependencySchemas !== undefined + ? "introspectionAllowedDependencySchemas" + : null, + configuredCapabilityExtensions !== undefined + ? "introspectionCapabilityExtensions" + : null, + ].filter((option): option is string => option !== null); + if (configuredScopedOptions.length > 0) { throw new Error( - `Scoped extension capabilities are only valid with scoped-required introspection for service '${pgService.name}'` + `Scoped introspection option(s) ${configuredScopedOptions.join( + ", ", + )} require scopedIntrospection: true for service '${pgService.name}'`, ); } return { @@ -311,25 +329,19 @@ function getIntrospectionQuery( scopedCatalogTypes: null, }; } - if (mode === 'scoped-required') { - const requiredSchemas = pgService.schemas ?? []; - const dependencySchemas = - pgService.introspectionAllowedDependencySchemas ?? []; - return { - query: makeSchemaScopedIntrospectionQuery(requiredSchemas, { - catalogTypes: scopedCatalogTypes, - capabilityExtensions: configuredCapabilityExtensions ?? [], - }), - requiredSchemas, - allowedSchemas: [ - ...new Set([...requiredSchemas, ...dependencySchemas, 'pg_catalog']), - ], - scopedCatalogTypes, - }; - } - throw new Error( - `Unsupported PostgreSQL introspection mode '${mode}' for service '${pgService.name}'` - ); + const requiredSchemas = pgService.schemas ?? []; + const dependencySchemas = configuredDependencySchemas ?? []; + return { + query: makeSchemaScopedIntrospectionQuery(requiredSchemas, { + catalogTypes: scopedCatalogTypes, + capabilityExtensions: configuredCapabilityExtensions ?? [], + }), + requiredSchemas, + allowedSchemas: [ + ...new Set([...requiredSchemas, ...dependencySchemas, 'pg_catalog']), + ], + scopedCatalogTypes, + }; } diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts index ef62ec1ced..8c7975c3dc 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts @@ -1,9 +1,7 @@ import type { ScopedCatalogTypes } from "../pg-introspection"; -export type GraphileIntrospectionMode = "stock" | "scoped-required"; - export interface ScopedIntrospectionServiceOptions { - introspectionMode?: GraphileIntrospectionMode; + scopedIntrospection?: boolean; introspectionAllowedDependencySchemas?: readonly string[]; introspectionScopedCatalogTypes?: ScopedCatalogTypes; introspectionCapabilityExtensions?: readonly string[]; diff --git a/graphile/graphile-settings/README.md b/graphile/graphile-settings/README.md index 683d93e9e7..32a8f41014 100644 --- a/graphile/graphile-settings/README.md +++ b/graphile/graphile-settings/README.md @@ -208,8 +208,8 @@ const preset = { ``` The Constructive GraphQL server performs this pairing when -`GRAPHILE_INTROSPECTION_MODE=scoped-required`; when unset or set to `stock`, it -does not load the scoped package. +`GRAPHILE_SCOPED_INTROSPECTION=true`; when unset or `false`, it does not load +the scoped package. ## Smart Tags Reference diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts index ac8bc90f9d..999d80f450 100644 --- a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts +++ b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts @@ -18,7 +18,7 @@ const makeUpstreamPgService = jest.fn((options: TestUpstreamOptions) => ({ })); const makeScopedPgService = ( options: TestUpstreamOptions & - Omit + Omit ) => makeConfiguredPgService(makeUpstreamPgService, options); describe('scoped introspection settings wiring', () => { @@ -38,7 +38,7 @@ describe('scoped introspection settings wiring', () => { expect(service).toMatchObject({ schemas: ['tenant_a'], - introspectionMode: 'scoped-required', + scopedIntrospection: true, introspectionScopedCatalogTypes: 'dependency-closure', introspectionAllowedDependencySchemas: ['shared'], introspectionCapabilityExtensions: ['pg_trgm'], diff --git a/graphile/graphile-settings/src/introspection-settings.ts b/graphile/graphile-settings/src/introspection-settings.ts index a5fc9eda9a..ee75f41668 100644 --- a/graphile/graphile-settings/src/introspection-settings.ts +++ b/graphile/graphile-settings/src/introspection-settings.ts @@ -1,7 +1,3 @@ -import type { GraphileIntrospectionMode } from '@constructive-io/graphql-types'; - -export type { GraphileIntrospectionMode } from '@constructive-io/graphql-types'; - export const DEFAULT_INTROSPECTION_STATEMENT_TIMEOUT = '120s'; export const normalizeIntrospectionDependencySchemas = ( @@ -31,14 +27,14 @@ export const normalizeIntrospectionDependencySchemas = ( ]; export const resolveIntrospectionSettings = ( - mode: GraphileIntrospectionMode, + scopedIntrospection: boolean, settings: Record | null | undefined ): Record => { const boundedSettings = { ...settings }; if (!boundedSettings.statement_timeout) { boundedSettings.statement_timeout = DEFAULT_INTROSPECTION_STATEMENT_TIMEOUT; } - if (mode === 'scoped-required') { + if (scopedIntrospection) { return { ...boundedSettings, jit: 'off', diff --git a/graphile/graphile-settings/src/scoped-introspection-service.ts b/graphile/graphile-settings/src/scoped-introspection-service.ts index 20cbf695d4..579bc00a9b 100644 --- a/graphile/graphile-settings/src/scoped-introspection-service.ts +++ b/graphile/graphile-settings/src/scoped-introspection-service.ts @@ -38,7 +38,7 @@ type UpstreamPgServiceOptions = { export type ScopedIntrospectionOptions = Omit< ScopedIntrospectionServiceOptions, - 'introspectionMode' + 'scopedIntrospection' >; /** @@ -74,7 +74,7 @@ export function makeConfiguredPgService< const introspectionAllowedDependencySchemas = normalizeIntrospectionDependencySchemas(configuredDependencySchemas); const pgSettingsForIntrospection = resolveIntrospectionSettings( - 'scoped-required', + true, options.pgSettingsForIntrospection ); const service = makeUpstreamPgService({ @@ -83,7 +83,7 @@ export function makeConfiguredPgService< } as TOptions); return Object.assign(service, { - introspectionMode: 'scoped-required' as const, + scopedIntrospection: true as const, introspectionScopedCatalogTypes: introspectionScopedCatalogTypes ?? 'dependency-closure', introspectionAllowedDependencySchemas, diff --git a/graphql/env/README.md b/graphql/env/README.md index 0ecdf458d8..9d99d6e276 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -43,8 +43,8 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag ### GraphQL Schema - `GRAPHILE_SCHEMA` - Comma-separated list of PostgreSQL schemas to expose -- `GRAPHILE_INTROSPECTION_MODE` - `stock` (default) or `scoped-required`; - invalid values fail during option resolution +- `GRAPHILE_SCOPED_INTROSPECTION` - Enable CNC schema-scoped introspection; + unset or `false` keeps upstream stock introspection ### Feature Flags - `FEATURES_SIMPLE_INFLECTION` - Enable simple inflection plugin @@ -67,7 +67,7 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`: { graphile: { schema: [], - introspectionMode: 'stock', + scopedIntrospection: false, introspectionDependencySchemas: [], introspectionCapabilityExtensions: [] }, diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 89998b6a15..064d0244ba 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -72,11 +72,11 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "extends": [], "introspectionCapabilityExtensions": [], "introspectionDependencySchemas": [], - "introspectionMode": "stock", "preset": {}, "schema": [ "override_schema", ], + "scopedIntrospection": false, }, "migrations": { "codegen": { diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index f856dba16d..0097a2684f 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -141,61 +141,51 @@ describe('getEnvOptions', () => { it('defaults to untouched stock introspection', () => { const result = getEnvOptions({}, process.cwd(), {}); - expect(result.graphile?.introspectionMode).toBe('stock'); + expect(result.graphile?.scopedIntrospection).toBe(false); }); - it('accepts stock and scoped-required introspection environment modes', () => { + it('parses the scoped introspection environment boolean', () => { expect( - getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: 'stock' }).graphile - ?.introspectionMode - ).toBe('stock'); + getGraphQLEnvVars({ GRAPHILE_SCOPED_INTROSPECTION: 'true' }).graphile + ?.scopedIntrospection + ).toBe(true); expect( getGraphQLEnvVars({ - GRAPHILE_INTROSPECTION_MODE: 'scoped-required' - }).graphile?.introspectionMode - ).toBe('scoped-required'); - }); - - it('rejects malformed explicit introspection modes', () => { - expect(() => - getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: 'scpoed' }) - ).toThrow(/GRAPHILE_INTROSPECTION_MODE/); - expect(() => - getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: '' }) - ).toThrow(/GRAPHILE_INTROSPECTION_MODE/); + GRAPHILE_SCOPED_INTROSPECTION: 'false' + }).graphile?.scopedIntrospection + ).toBe(false); + expect( + getGraphQLEnvVars({ GRAPHILE_SCOPED_INTROSPECTION: 'yes' }).graphile + ?.scopedIntrospection + ).toBe(true); + expect( + getGraphQLEnvVars({ GRAPHILE_SCOPED_INTROSPECTION: 'not-enabled' }) + .graphile?.scopedIntrospection + ).toBe(false); + expect( + getGraphQLEnvVars({ GRAPHILE_SCOPED_INTROSPECTION: '' }).graphile + ?.scopedIntrospection + ).toBeUndefined(); }); - it('honors config, env, and runtime priority for introspection mode', () => { + it('honors config, env, and runtime priority for scoped introspection', () => { tempDir = fs.mkdtempSync( path.join(os.tmpdir(), 'graphql-env-introspection-') ); writeConfig(tempDir, { - graphile: { introspectionMode: 'stock' } + graphile: { scopedIntrospection: false } }); expect( getEnvOptions({}, tempDir, { - GRAPHILE_INTROSPECTION_MODE: 'scoped-required' - }).graphile?.introspectionMode - ).toBe('scoped-required'); + GRAPHILE_SCOPED_INTROSPECTION: 'true' + }).graphile?.scopedIntrospection + ).toBe(true); expect( - getEnvOptions({ graphile: { introspectionMode: 'stock' } }, tempDir, { - GRAPHILE_INTROSPECTION_MODE: 'scoped-required' - }).graphile?.introspectionMode - ).toBe('stock'); - }); - - it('rejects malformed config-file introspection modes', () => { - tempDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'graphql-env-invalid-introspection-') - ); - writeConfig(tempDir, { - graphile: { introspectionMode: 'scpoed' } - }); - - expect(() => getEnvOptions({}, tempDir, {})).toThrow( - /Unsupported Graphile introspection mode/ - ); + getEnvOptions({ graphile: { scopedIntrospection: false } }, tempDir, { + GRAPHILE_SCOPED_INTROSPECTION: 'true' + }).graphile?.scopedIntrospection + ).toBe(false); }); it('parses SMS environment variables into typed options', () => { diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 2d531232a5..58e953ba42 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,20 +1,5 @@ -import { ConstructiveOptions, type GraphileIntrospectionMode, graphileIntrospectionModes } from '@constructive-io/graphql-types'; -import { env as validateEnv, parseEnvBoolean, parseEnvNumber, str } from '12factor-env'; - -const parseGraphileIntrospectionMode = ( - value: string | undefined -): GraphileIntrospectionMode | undefined => { - if (value === undefined) return undefined; - return validateEnv( - { GRAPHILE_INTROSPECTION_MODE: value }, - {}, - { - GRAPHILE_INTROSPECTION_MODE: str({ - choices: [...graphileIntrospectionModes] - }) - } - ).GRAPHILE_INTROSPECTION_MODE as GraphileIntrospectionMode; -}; +import { ConstructiveOptions } from '@constructive-io/graphql-types'; +import { parseEnvBoolean, parseEnvNumber } from '12factor-env'; /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) @@ -22,7 +7,7 @@ const parseGraphileIntrospectionMode = ( export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial => { const { GRAPHILE_SCHEMA, - GRAPHILE_INTROSPECTION_MODE, + GRAPHILE_SCOPED_INTROSPECTION, FEATURES_SIMPLE_INFLECTION, FEATURES_OPPOSITE_BASE_NAMES, @@ -54,8 +39,8 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial // let an absent env var overwrite pgpm.json or consumer-specific values. const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); - const introspectionMode = parseGraphileIntrospectionMode( - GRAPHILE_INTROSPECTION_MODE + const scopedIntrospection = parseEnvBoolean( + GRAPHILE_SCOPED_INTROSPECTION ); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || @@ -72,7 +57,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial ? GRAPHILE_SCHEMA.split(',').map(s => s.trim()) : GRAPHILE_SCHEMA }), - ...(introspectionMode !== undefined && { introspectionMode }) + ...(scopedIntrospection !== undefined && { scopedIntrospection }) }, features: { ...(FEATURES_SIMPLE_INFLECTION && { simpleInflection: parseEnvBoolean(FEATURES_SIMPLE_INFLECTION) }), diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 76b77b1995..f0f0fa8450 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -1,4 +1,4 @@ -import { constructiveGraphqlDefaults,ConstructiveOptions,graphileIntrospectionModes } from '@constructive-io/graphql-types'; +import { constructiveGraphqlDefaults,ConstructiveOptions } from '@constructive-io/graphql-types'; import { getEnvOptions as getPgpmEnvOptions, loadConfigSync, replaceArrays } from '@pgpmjs/env'; import deepmerge from 'deepmerge'; @@ -36,7 +36,7 @@ export const getEnvOptions = ( const configOptions = loadConfigSync(cwd) as Partial; // Merge in order: core -> graphql defaults -> config (for graphql keys) -> graphql env -> overrides - const options = deepmerge.all([ + return deepmerge.all([ coreOptions, constructiveGraphqlDefaults, // Only merge graphql-related keys from config (if present) @@ -52,17 +52,6 @@ export const getEnvOptions = ( arrayMerge: replaceArrays }) as ConstructiveOptions; - const introspectionMode = options.graphile?.introspectionMode; - if ( - introspectionMode !== undefined && - !graphileIntrospectionModes.includes(introspectionMode) - ) { - throw new Error( - `Unsupported Graphile introspection mode '${String(introspectionMode)}'; expected one of: ${graphileIntrospectionModes.join(', ')}` - ); - } - - return options; }; /** diff --git a/graphql/server/README.md b/graphql/server/README.md index 74fe2953ac..4958fa9e24 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -123,7 +123,7 @@ Configuration is merged from defaults, config files, and env vars via `@construc | `PGPASSWORD` | Postgres password | `password` | | `PGDATABASE` | Postgres database | `postgres` | | `GRAPHILE_SCHEMA` | Comma-separated schemas to expose | empty | -| `GRAPHILE_INTROSPECTION_MODE` | `stock` or opt-in `scoped-required` | `stock` | +| `GRAPHILE_SCOPED_INTROSPECTION` | Enable CNC schema-scoped introspection | `false` | | `FEATURES_SIMPLE_INFLECTION` | Enable simple inflection | `true` | | `FEATURES_OPPOSITE_BASE_NAMES` | Enable opposite base names | `true` | | `FEATURES_POSTGIS` | Enable PostGIS support | `true` | diff --git a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts index 104b43d7b2..01c08b153b 100644 --- a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts +++ b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts @@ -20,7 +20,7 @@ describe('Graphile introspection mode wiring', () => { expect(loadScopedPreset).not.toHaveBeenCalled(); expect(wiring.presets).toEqual([]); - expect(wiring.pgService).not.toHaveProperty('introspectionMode'); + expect(wiring.pgService).not.toHaveProperty('scopedIntrospection'); expect(wiring.pgService).not.toHaveProperty( 'introspectionAllowedDependencySchemas' ); @@ -40,7 +40,7 @@ describe('Graphile introspection mode wiring', () => { pool, ['tenant_a'], { - introspectionMode: 'scoped-required', + scopedIntrospection: true, introspectionDependencySchemas: ['shared'], introspectionCapabilityExtensions: ['pg_trgm'], }, @@ -50,7 +50,7 @@ describe('Graphile introspection mode wiring', () => { expect(loadScopedPreset).toHaveBeenCalledTimes(1); expect(wiring.presets).toEqual([scopedPreset]); expect(wiring.pgService).toMatchObject({ - introspectionMode: 'scoped-required', + scopedIntrospection: true, introspectionScopedCatalogTypes: 'dependency-closure', introspectionAllowedDependencySchemas: ['shared'], introspectionCapabilityExtensions: ['pg_trgm'], @@ -61,4 +61,38 @@ describe('Graphile introspection mode wiring', () => { }, }); }); + + it.each([ + [ + 'dependency schemas', + { introspectionDependencySchemas: ['shared'] }, + ], + [ + 'capability extensions', + { introspectionCapabilityExtensions: ['pg_trgm'] }, + ], + ])('fails closed on %s while scoped introspection is disabled', async (_label, option) => { + const loadScopedPreset = jest.fn(async () => ({})); + + await expect( + makeIntrospectionWiring( + pool, + ['tenant_a'], + { scopedIntrospection: false, ...option }, + loadScopedPreset + ) + ).rejects.toThrow(/require scopedIntrospection: true/); + expect(loadScopedPreset).not.toHaveBeenCalled(); + }); + + it('rejects a non-boolean scoped introspection flag', async () => { + await expect( + makeIntrospectionWiring( + pool, + ['tenant_a'], + { scopedIntrospection: 'true' } as never, + jest.fn() + ) + ).rejects.toThrow('graphile.scopedIntrospection must be a boolean'); + }); }); diff --git a/graphql/server/src/middleware/graphile-introspection.ts b/graphql/server/src/middleware/graphile-introspection.ts index 175693841b..f6bf16d7eb 100644 --- a/graphql/server/src/middleware/graphile-introspection.ts +++ b/graphql/server/src/middleware/graphile-introspection.ts @@ -1,7 +1,4 @@ -import type { - GraphileIntrospectionMode, - GraphileOptions, -} from '@constructive-io/graphql-types'; +import type { GraphileOptions } from '@constructive-io/graphql-types'; import type { GraphileConfig } from 'graphile-config'; import { makePgService, makeScopedPgService } from 'graphile-settings'; import type { Pool } from 'pg'; @@ -25,10 +22,6 @@ const loadScopedIntrospectionPreset = (): Promise => { return scopedIntrospectionPresetPromise; }; -const assertNever = (mode: never): never => { - throw new Error(`Unsupported Graphile introspection mode '${String(mode)}'`); -}; - /** * Select the stock or scoped introspection wiring once, while constructing a * server-owned schema handler. The stock branch returns before the scoped @@ -40,31 +33,47 @@ export const makeIntrospectionWiring = async ( graphileOptions: GraphileOptions | undefined, loadScopedPreset: ScopedIntrospectionPresetLoader = loadScopedIntrospectionPreset ): Promise => { - const mode: GraphileIntrospectionMode = - graphileOptions?.introspectionMode ?? 'stock'; + const scopedIntrospection = graphileOptions?.scopedIntrospection; + if ( + scopedIntrospection !== undefined && + typeof scopedIntrospection !== 'boolean' + ) { + throw new Error('graphile.scopedIntrospection must be a boolean'); + } - if (mode === 'stock') { + if (scopedIntrospection !== true) { + const configuredScopedOptions = [ + (graphileOptions?.introspectionDependencySchemas?.length ?? 0) > 0 + ? 'introspectionDependencySchemas' + : null, + (graphileOptions?.introspectionCapabilityExtensions?.length ?? 0) > 0 + ? 'introspectionCapabilityExtensions' + : null, + ].filter((option): option is string => option !== null); + if (configuredScopedOptions.length > 0) { + throw new Error( + `Graphile scoped introspection option(s) ${configuredScopedOptions.join( + ', ' + )} require scopedIntrospection: true` + ); + } return { presets: [], pgService: makePgService({ pool, schemas }), }; } - if (mode === 'scoped-required') { - const scopedPreset = await loadScopedPreset(); - return { - presets: [scopedPreset], - pgService: makeScopedPgService({ - pool, - schemas, - introspectionScopedCatalogTypes: 'dependency-closure', - introspectionAllowedDependencySchemas: - graphileOptions?.introspectionDependencySchemas, - introspectionCapabilityExtensions: - graphileOptions?.introspectionCapabilityExtensions, - }), - }; - } - - return assertNever(mode); + const scopedPreset = await loadScopedPreset(); + return { + presets: [scopedPreset], + pgService: makeScopedPgService({ + pool, + schemas, + introspectionScopedCatalogTypes: 'dependency-closure', + introspectionAllowedDependencySchemas: + graphileOptions?.introspectionDependencySchemas, + introspectionCapabilityExtensions: + graphileOptions?.introspectionCapabilityExtensions, + }), + }; }; diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index a4e60cc49e..112cb64d4b 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -1,15 +1,10 @@ import type { GraphileConfig } from 'graphile-config'; -export const graphileIntrospectionModes = ['stock', 'scoped-required'] as const; - -export type GraphileIntrospectionMode = - (typeof graphileIntrospectionModes)[number]; - export type ScopedCatalogTypes = 'all' | 'dependency-closure'; export interface ScopedIntrospectionServiceOptions { - /** Selects the catalog query used during this service's gather phase. */ - introspectionMode?: GraphileIntrospectionMode; + /** Use CNC schema-scoped introspection for this service. */ + scopedIntrospection?: boolean; /** Catalog types retained by scoped introspection; defaults to all. */ introspectionScopedCatalogTypes?: ScopedCatalogTypes; /** Non-root schemas that scoped dependency closure may retain. */ @@ -24,8 +19,8 @@ export interface ScopedIntrospectionServiceOptions { export interface GraphileOptions { /** Database schema(s) to expose through GraphQL */ schema?: string | string[]; - /** PostgreSQL catalog introspection implementation selected at startup. */ - introspectionMode?: GraphileIntrospectionMode; + /** Use CNC schema-scoped PostgreSQL introspection. */ + scopedIntrospection?: boolean; /** Additional schemas that scoped dependency closure may retain. */ introspectionDependencySchemas?: string[]; /** Installed extensions whose optional capability metadata must be retained. */ @@ -75,7 +70,7 @@ export interface ApiOptions { */ export const graphileDefaults: GraphileOptions = { schema: [], - introspectionMode: 'stock', + scopedIntrospection: false, introspectionDependencySchemas: [], introspectionCapabilityExtensions: [], extends: [], diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index 20e684cdef..d54a4d4b77 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -5,8 +5,6 @@ export { graphileDefaults, graphileFeatureDefaults, GraphileFeatureOptions, - GraphileIntrospectionMode, - graphileIntrospectionModes, GraphileOptions, ScopedCatalogTypes, ScopedIntrospectionServiceOptions} from './graphile'; diff --git a/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts b/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts index c1efcd88f7..afcc42ddd6 100644 --- a/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts +++ b/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts @@ -9,12 +9,18 @@ describe('scoped introspection benchmark registration', () => { cases: [ { name: 'stock', - workerConfig: { mode: 'stock', schemas: ['cperf_example'] }, + workerConfig: { + scopedIntrospection: false, + schemas: ['cperf_example'], + }, expectedSchemaGroup: 'introspection-equivalence', }, { name: 'scoped', - workerConfig: { mode: 'scoped', schemas: ['cperf_example'] }, + workerConfig: { + scopedIntrospection: true, + schemas: ['cperf_example'], + }, expectedSchemaGroup: 'introspection-equivalence', }, ], diff --git a/packages/perf-harness/src/scoped-introspection-suite.ts b/packages/perf-harness/src/scoped-introspection-suite.ts index 5c59d9f825..803551e24a 100644 --- a/packages/perf-harness/src/scoped-introspection-suite.ts +++ b/packages/perf-harness/src/scoped-introspection-suite.ts @@ -12,12 +12,12 @@ export const makeScopedIntrospectionSuite = ( cases: [ { name: 'stock', - workerConfig: { mode: 'stock', schemas: options.schemas }, + workerConfig: { scopedIntrospection: false, schemas: options.schemas }, expectedSchemaGroup: 'introspection-equivalence', }, { name: 'scoped', - workerConfig: { mode: 'scoped', schemas: options.schemas }, + workerConfig: { scopedIntrospection: true, schemas: options.schemas }, expectedSchemaGroup: 'introspection-equivalence', }, ], diff --git a/packages/perf-harness/src/scoped-introspection-worker.ts b/packages/perf-harness/src/scoped-introspection-worker.ts index 733331b39f..3e15304798 100644 --- a/packages/perf-harness/src/scoped-introspection-worker.ts +++ b/packages/perf-harness/src/scoped-introspection-worker.ts @@ -18,15 +18,15 @@ import { } from './process'; interface ScopedWorkerConfig { - mode: 'stock' | 'scoped'; + scopedIntrospection: boolean; schemas: string[]; } const validateConfig = (value: unknown): ScopedWorkerConfig => { const config = value as Partial; - if (config.mode !== 'stock' && config.mode !== 'scoped') { + if (typeof config.scopedIntrospection !== 'boolean') { throw new Error( - 'scoped introspection worker requires stock or scoped mode' + 'scoped introspection worker requires a scopedIntrospection boolean' ); } if ( @@ -40,7 +40,10 @@ const validateConfig = (value: unknown): ScopedWorkerConfig => { 'scoped introspection worker requires a non-empty schemas array' ); } - return { mode: config.mode, schemas: config.schemas }; + return { + scopedIntrospection: config.scopedIntrospection, + schemas: config.schemas, + }; }; const main = async (): Promise => { @@ -63,9 +66,9 @@ const main = async (): Promise => { introspectionScopedCatalogTypes: 'dependency-closure' as const, }; const service = - config.mode === 'stock' - ? makePostGraphilePgService(serviceOptions) - : makeScopedPgService(scopedServiceOptions); + config.scopedIntrospection + ? makeScopedPgService(scopedServiceOptions) + : makePostGraphilePgService(serviceOptions); release = async () => { await service.release(); }; @@ -77,7 +80,9 @@ const main = async (): Promise => { extends: [ graphileBuildPreset, graphileBuildPgPreset, - ...(config.mode === 'scoped' ? [ScopedIntrospectionPreset] : []), + ...(config.scopedIntrospection + ? [ScopedIntrospectionPreset] + : []), ], pgServices: [service], }), @@ -97,7 +102,7 @@ const main = async (): Promise => { schemaHash: createHash('sha256').update(schemaText).digest('hex'), schemaTypeCount: Object.keys(schema.getTypeMap()).length, runtimeVerified: true as const, - metadata: { introspectionMode: config.mode }, + metadata: { scopedIntrospection: config.scopedIntrospection }, }; } ); From d488e37031f2add8f632befe77e1949306fcc73c Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 07:26:55 +0800 Subject: [PATCH 05/12] Remove formatting-only churn --- graphql/env/src/merge.ts | 1 - graphql/server/src/middleware/graphile.ts | 20 +++++--------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index f0f0fa8450..15f1402c53 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -51,7 +51,6 @@ export const getEnvOptions = ( ], { arrayMerge: replaceArrays }) as ConstructiveOptions; - }; /** diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index c4dce14582..5709c5ef4c 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -177,10 +177,7 @@ const buildPreset = async ( graphileOptions ); return { - extends: [ - createConstructivePreset(databaseSettings), - ...introspection.presets - ], + extends: [createConstructivePreset(databaseSettings), ...introspection.presets], plugins: [ AuthCookiePlugin, // Only registered when the compute module is provisioned for this @@ -203,7 +200,9 @@ const buildPreset = async ( ] : []) ], - pgServices: [introspection.pgService], + pgServices: [ + introspection.pgService + ], grafserv: { graphqlPath: '/graphql', graphiqlPath: '/graphiql', @@ -408,16 +407,7 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // Create promise and store in in-flight map BEFORE try block const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; - const preset = await buildPreset( - pool, - schema || [], - anonRole, - roleName, - opts.graphile, - api.databaseSettings, - api.apiId, - compute - ); + const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.graphile, api.databaseSettings, api.apiId, compute); const creationPromise = observeGraphileBuild( { cacheKey: key, From f78dccfc09371a676298dbc41383d808c65e01a8 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 10:11:08 +0800 Subject: [PATCH 06/12] Make scoped introspection JIT configurable --- graphile/graphile-settings/README.md | 7 ++- .../scoped-introspection-wiring.test.ts | 32 +++++++++++--- .../src/introspection-settings.ts | 22 +++------- .../src/scoped-introspection-service.ts | 7 ++- graphql/env/README.md | 3 ++ .../__snapshots__/merge.test.ts.snap | 1 + graphql/env/__tests__/merge.test.ts | 44 ++++++++++++++----- graphql/env/src/env.ts | 7 ++- graphql/server/README.md | 1 + .../__tests__/graphile-introspection.test.ts | 17 +++++-- .../src/middleware/graphile-introspection.ts | 9 ++++ graphql/types/src/graphile.ts | 3 ++ 12 files changed, 110 insertions(+), 43 deletions(-) diff --git a/graphile/graphile-settings/README.md b/graphile/graphile-settings/README.md index 32a8f41014..5249ff15e4 100644 --- a/graphile/graphile-settings/README.md +++ b/graphile/graphile-settings/README.md @@ -200,6 +200,7 @@ const preset = { makeScopedPgService({ connectionString: 'postgres://user:pass@localhost/mydb', schemas: ['app_public'], + introspectionJit: false, introspectionAllowedDependencySchemas: ['shared'], introspectionCapabilityExtensions: ['pg_trgm'], }), @@ -209,7 +210,11 @@ const preset = { The Constructive GraphQL server performs this pairing when `GRAPHILE_SCOPED_INTROSPECTION=true`; when unset or `false`, it does not load -the scoped package. +the scoped package. Scoped introspection sets PostgreSQL `jit` to `off` by +default; set `introspectionJit: true` or +`GRAPHILE_SCOPED_INTROSPECTION_JIT=true` to enable it. Other +`pgSettingsForIntrospection` values are preserved but are not supplied with +CNC defaults. ## Smart Tags Reference diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts index 999d80f450..50416571c5 100644 --- a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts +++ b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts @@ -1,9 +1,11 @@ -import type { ScopedIntrospectionServiceOptions } from '@constructive-io/graphql-types'; import { PgIntrospectionPlugin } from 'graphile-build-pg'; import { resolvePreset } from 'graphile-config'; import { ConstructivePreset } from '../src/presets/constructive-preset'; -import { makeConfiguredPgService } from '../src/scoped-introspection-service'; +import { + makeConfiguredPgService, + type ScopedIntrospectionOptions, +} from '../src/scoped-introspection-service'; type TestUpstreamOptions = { pubsub?: boolean; @@ -17,8 +19,7 @@ const makeUpstreamPgService = jest.fn((options: TestUpstreamOptions) => ({ upstream: true, })); const makeScopedPgService = ( - options: TestUpstreamOptions & - Omit + options: TestUpstreamOptions & ScopedIntrospectionOptions ) => makeConfiguredPgService(makeUpstreamPgService, options); describe('scoped introspection settings wiring', () => { @@ -33,7 +34,11 @@ describe('scoped introspection settings wiring', () => { introspectionScopedCatalogTypes: 'dependency-closure', introspectionAllowedDependencySchemas: ['shared', 'shared'], introspectionCapabilityExtensions: ['pg_trgm', 'pg_trgm'], - pgSettingsForIntrospection: { statement_timeout: '30s' }, + pgSettingsForIntrospection: { + statement_timeout: '30s', + jit: 'on', + work_mem: '1MB', + }, }); expect(service).toMatchObject({ @@ -45,7 +50,7 @@ describe('scoped introspection settings wiring', () => { pgSettingsForIntrospection: { statement_timeout: '30s', jit: 'off', - work_mem: '512kB', + work_mem: '1MB', }, }); expect(makeUpstreamPgService).toHaveBeenCalledWith({ @@ -54,11 +59,24 @@ describe('scoped introspection settings wiring', () => { pgSettingsForIntrospection: { statement_timeout: '30s', jit: 'off', - work_mem: '512kB', + work_mem: '1MB', }, }); }); + it('enables introspection JIT only when explicitly configured', () => { + const service = makeScopedPgService({ + pubsub: false, + introspectionJit: true, + }); + + expect(service.pgSettingsForIntrospection).toEqual({ jit: 'on' }); + expect(makeUpstreamPgService).toHaveBeenCalledWith({ + pubsub: false, + pgSettingsForIntrospection: { jit: 'on' }, + }); + }); + it('fails deterministically on invalid scoped configuration', () => { expect(() => makeScopedPgService({ diff --git a/graphile/graphile-settings/src/introspection-settings.ts b/graphile/graphile-settings/src/introspection-settings.ts index ee75f41668..e33d191126 100644 --- a/graphile/graphile-settings/src/introspection-settings.ts +++ b/graphile/graphile-settings/src/introspection-settings.ts @@ -1,5 +1,3 @@ -export const DEFAULT_INTROSPECTION_STATEMENT_TIMEOUT = '120s'; - export const normalizeIntrospectionDependencySchemas = ( schemas: readonly string[] | null | undefined ): string[] => [ @@ -27,19 +25,9 @@ export const normalizeIntrospectionDependencySchemas = ( ]; export const resolveIntrospectionSettings = ( - scopedIntrospection: boolean, + introspectionJit: boolean, settings: Record | null | undefined -): Record => { - const boundedSettings = { ...settings }; - if (!boundedSettings.statement_timeout) { - boundedSettings.statement_timeout = DEFAULT_INTROSPECTION_STATEMENT_TIMEOUT; - } - if (scopedIntrospection) { - return { - ...boundedSettings, - jit: 'off', - work_mem: '512kB', - }; - } - return boundedSettings; -}; +): Record => ({ + ...settings, + jit: introspectionJit ? 'on' : 'off', +}); diff --git a/graphile/graphile-settings/src/scoped-introspection-service.ts b/graphile/graphile-settings/src/scoped-introspection-service.ts index 579bc00a9b..639b5f5008 100644 --- a/graphile/graphile-settings/src/scoped-introspection-service.ts +++ b/graphile/graphile-settings/src/scoped-introspection-service.ts @@ -39,7 +39,9 @@ type UpstreamPgServiceOptions = { export type ScopedIntrospectionOptions = Omit< ScopedIntrospectionServiceOptions, 'scopedIntrospection' ->; +> & { + introspectionJit?: boolean; +}; /** * Apply CNC's scoped-introspection settings around an upstream PgService @@ -57,6 +59,7 @@ export function makeConfiguredPgService< introspectionScopedCatalogTypes, introspectionAllowedDependencySchemas: configuredDependencySchemas, introspectionCapabilityExtensions: configuredCapabilityExtensions, + introspectionJit = false, ...upstreamOptions } = options; const introspectionCapabilityExtensions = @@ -74,7 +77,7 @@ export function makeConfiguredPgService< const introspectionAllowedDependencySchemas = normalizeIntrospectionDependencySchemas(configuredDependencySchemas); const pgSettingsForIntrospection = resolveIntrospectionSettings( - true, + introspectionJit, options.pgSettingsForIntrospection ); const service = makeUpstreamPgService({ diff --git a/graphql/env/README.md b/graphql/env/README.md index 9d99d6e276..f516295732 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -45,6 +45,8 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag - `GRAPHILE_SCHEMA` - Comma-separated list of PostgreSQL schemas to expose - `GRAPHILE_SCOPED_INTROSPECTION` - Enable CNC schema-scoped introspection; unset or `false` keeps upstream stock introspection +- `GRAPHILE_SCOPED_INTROSPECTION_JIT` - Enable PostgreSQL JIT for scoped + introspection queries; defaults to `false` ### Feature Flags - `FEATURES_SIMPLE_INFLECTION` - Enable simple inflection plugin @@ -68,6 +70,7 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`: graphile: { schema: [], scopedIntrospection: false, + introspectionJit: false, introspectionDependencySchemas: [], introspectionCapabilityExtensions: [] }, diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 064d0244ba..3a9d8043e3 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -72,6 +72,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "extends": [], "introspectionCapabilityExtensions": [], "introspectionDependencySchemas": [], + "introspectionJit": false, "preset": {}, "schema": [ "override_schema", diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index 0097a2684f..5add05ff86 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -142,9 +142,10 @@ describe('getEnvOptions', () => { const result = getEnvOptions({}, process.cwd(), {}); expect(result.graphile?.scopedIntrospection).toBe(false); + expect(result.graphile?.introspectionJit).toBe(false); }); - it('parses the scoped introspection environment boolean', () => { + it('parses the scoped introspection environment booleans', () => { expect( getGraphQLEnvVars({ GRAPHILE_SCOPED_INTROSPECTION: 'true' }).graphile ?.scopedIntrospection @@ -166,6 +167,18 @@ describe('getEnvOptions', () => { getGraphQLEnvVars({ GRAPHILE_SCOPED_INTROSPECTION: '' }).graphile ?.scopedIntrospection ).toBeUndefined(); + expect( + getGraphQLEnvVars({ GRAPHILE_SCOPED_INTROSPECTION_JIT: 'true' }).graphile + ?.introspectionJit + ).toBe(true); + expect( + getGraphQLEnvVars({ GRAPHILE_SCOPED_INTROSPECTION_JIT: 'false' }).graphile + ?.introspectionJit + ).toBe(false); + expect( + getGraphQLEnvVars({ GRAPHILE_SCOPED_INTROSPECTION_JIT: '' }).graphile + ?.introspectionJit + ).toBeUndefined(); }); it('honors config, env, and runtime priority for scoped introspection', () => { @@ -173,19 +186,26 @@ describe('getEnvOptions', () => { path.join(os.tmpdir(), 'graphql-env-introspection-') ); writeConfig(tempDir, { - graphile: { scopedIntrospection: false } + graphile: { scopedIntrospection: false, introspectionJit: false } }); - expect( - getEnvOptions({}, tempDir, { - GRAPHILE_SCOPED_INTROSPECTION: 'true' - }).graphile?.scopedIntrospection - ).toBe(true); - expect( - getEnvOptions({ graphile: { scopedIntrospection: false } }, tempDir, { - GRAPHILE_SCOPED_INTROSPECTION: 'true' - }).graphile?.scopedIntrospection - ).toBe(false); + const envOptions = getEnvOptions({}, tempDir, { + GRAPHILE_SCOPED_INTROSPECTION: 'true', + GRAPHILE_SCOPED_INTROSPECTION_JIT: 'true' + }); + expect(envOptions.graphile?.scopedIntrospection).toBe(true); + expect(envOptions.graphile?.introspectionJit).toBe(true); + + const runtimeOptions = getEnvOptions( + { graphile: { scopedIntrospection: false, introspectionJit: false } }, + tempDir, + { + GRAPHILE_SCOPED_INTROSPECTION: 'true', + GRAPHILE_SCOPED_INTROSPECTION_JIT: 'true' + } + ); + expect(runtimeOptions.graphile?.scopedIntrospection).toBe(false); + expect(runtimeOptions.graphile?.introspectionJit).toBe(false); }); it('parses SMS environment variables into typed options', () => { diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 58e953ba42..b4c93c1a2b 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -8,6 +8,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial const { GRAPHILE_SCHEMA, GRAPHILE_SCOPED_INTROSPECTION, + GRAPHILE_SCOPED_INTROSPECTION_JIT, FEATURES_SIMPLE_INFLECTION, FEATURES_OPPOSITE_BASE_NAMES, @@ -42,6 +43,9 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial const scopedIntrospection = parseEnvBoolean( GRAPHILE_SCOPED_INTROSPECTION ); + const introspectionJit = parseEnvBoolean( + GRAPHILE_SCOPED_INTROSPECTION_JIT + ); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || SMS_SENDER_ID || @@ -57,7 +61,8 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial ? GRAPHILE_SCHEMA.split(',').map(s => s.trim()) : GRAPHILE_SCHEMA }), - ...(scopedIntrospection !== undefined && { scopedIntrospection }) + ...(scopedIntrospection !== undefined && { scopedIntrospection }), + ...(introspectionJit !== undefined && { introspectionJit }) }, features: { ...(FEATURES_SIMPLE_INFLECTION && { simpleInflection: parseEnvBoolean(FEATURES_SIMPLE_INFLECTION) }), diff --git a/graphql/server/README.md b/graphql/server/README.md index 4958fa9e24..d0085d1a54 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -124,6 +124,7 @@ Configuration is merged from defaults, config files, and env vars via `@construc | `PGDATABASE` | Postgres database | `postgres` | | `GRAPHILE_SCHEMA` | Comma-separated schemas to expose | empty | | `GRAPHILE_SCOPED_INTROSPECTION` | Enable CNC schema-scoped introspection | `false` | +| `GRAPHILE_SCOPED_INTROSPECTION_JIT` | Enable JIT for scoped introspection | `false` | | `FEATURES_SIMPLE_INFLECTION` | Enable simple inflection | `true` | | `FEATURES_OPPOSITE_BASE_NAMES` | Enable opposite base names | `true` | | `FEATURES_POSTGIS` | Enable PostGIS support | `true` | diff --git a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts index 01c08b153b..d9060fbe5e 100644 --- a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts +++ b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts @@ -41,6 +41,7 @@ describe('Graphile introspection mode wiring', () => { ['tenant_a'], { scopedIntrospection: true, + introspectionJit: true, introspectionDependencySchemas: ['shared'], introspectionCapabilityExtensions: ['pg_trgm'], }, @@ -55,9 +56,7 @@ describe('Graphile introspection mode wiring', () => { introspectionAllowedDependencySchemas: ['shared'], introspectionCapabilityExtensions: ['pg_trgm'], pgSettingsForIntrospection: { - statement_timeout: '120s', - jit: 'off', - work_mem: '512kB', + jit: 'on', }, }); }); @@ -71,6 +70,7 @@ describe('Graphile introspection mode wiring', () => { 'capability extensions', { introspectionCapabilityExtensions: ['pg_trgm'] }, ], + ['enabled introspection JIT', { introspectionJit: true }], ])('fails closed on %s while scoped introspection is disabled', async (_label, option) => { const loadScopedPreset = jest.fn(async () => ({})); @@ -95,4 +95,15 @@ describe('Graphile introspection mode wiring', () => { ) ).rejects.toThrow('graphile.scopedIntrospection must be a boolean'); }); + + it('rejects a non-boolean introspection JIT flag', async () => { + await expect( + makeIntrospectionWiring( + pool, + ['tenant_a'], + { scopedIntrospection: true, introspectionJit: 'true' } as never, + jest.fn() + ) + ).rejects.toThrow('graphile.introspectionJit must be a boolean'); + }); }); diff --git a/graphql/server/src/middleware/graphile-introspection.ts b/graphql/server/src/middleware/graphile-introspection.ts index f6bf16d7eb..2ac3a6c620 100644 --- a/graphql/server/src/middleware/graphile-introspection.ts +++ b/graphql/server/src/middleware/graphile-introspection.ts @@ -34,12 +34,19 @@ export const makeIntrospectionWiring = async ( loadScopedPreset: ScopedIntrospectionPresetLoader = loadScopedIntrospectionPreset ): Promise => { const scopedIntrospection = graphileOptions?.scopedIntrospection; + const introspectionJit = graphileOptions?.introspectionJit; if ( scopedIntrospection !== undefined && typeof scopedIntrospection !== 'boolean' ) { throw new Error('graphile.scopedIntrospection must be a boolean'); } + if ( + introspectionJit !== undefined && + typeof introspectionJit !== 'boolean' + ) { + throw new Error('graphile.introspectionJit must be a boolean'); + } if (scopedIntrospection !== true) { const configuredScopedOptions = [ @@ -49,6 +56,7 @@ export const makeIntrospectionWiring = async ( (graphileOptions?.introspectionCapabilityExtensions?.length ?? 0) > 0 ? 'introspectionCapabilityExtensions' : null, + introspectionJit === true ? 'introspectionJit' : null, ].filter((option): option is string => option !== null); if (configuredScopedOptions.length > 0) { throw new Error( @@ -74,6 +82,7 @@ export const makeIntrospectionWiring = async ( graphileOptions?.introspectionDependencySchemas, introspectionCapabilityExtensions: graphileOptions?.introspectionCapabilityExtensions, + introspectionJit, }), }; }; diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index 112cb64d4b..23f4ad5c8a 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -21,6 +21,8 @@ export interface GraphileOptions { schema?: string | string[]; /** Use CNC schema-scoped PostgreSQL introspection. */ scopedIntrospection?: boolean; + /** Enable PostgreSQL JIT while running scoped introspection queries. */ + introspectionJit?: boolean; /** Additional schemas that scoped dependency closure may retain. */ introspectionDependencySchemas?: string[]; /** Installed extensions whose optional capability metadata must be retained. */ @@ -71,6 +73,7 @@ export interface ApiOptions { export const graphileDefaults: GraphileOptions = { schema: [], scopedIntrospection: false, + introspectionJit: false, introspectionDependencySchemas: [], introspectionCapabilityExtensions: [], extends: [], From e0093c156375115702c6b5fa7dabcdf4160df824 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 10:33:10 +0800 Subject: [PATCH 07/12] Trust typed scoped introspection booleans --- ...ped-introspection-service-contract.test.ts | 14 ----------- .../PgIntrospectionPlugin.ts | 10 +------- .../graphile-build-pg/scopedValidation.ts | 2 +- graphql/env/__tests__/merge.test.ts | 16 +++++++++++++ graphql/env/src/merge.ts | 24 ++++++++++++++++++- .../__tests__/graphile-introspection.test.ts | 22 ----------------- .../src/middleware/graphile-introspection.ts | 19 +++------------ 7 files changed, 44 insertions(+), 63 deletions(-) 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 index e65d8489fb..5f9597ffc0 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts @@ -91,18 +91,4 @@ describe('scoped introspection service identity contract', () => { ).rejects.toThrow(/require scopedIntrospection: true/); } ); - - it('rejects a non-boolean scoped introspection flag', async () => { - await expect( - gather({ - plugins: [PgScopedIntrospectionPlugin, consumerPlugin], - pgServices: [ - makeService({ - scopedIntrospection: 'true', - introspectionAllowedDependencySchemas: undefined, - }), - ], - }) - ).rejects.toThrow('scopedIntrospection must be a boolean'); - }); }); diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts index 67dc8537fe..309b5aa3fd 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts @@ -287,14 +287,6 @@ function getIntrospectionQuery( pgService.introspectionAllowedDependencySchemas; const scopedCatalogTypes = configuredCatalogTypes ?? 'all'; - if ( - scopedIntrospection !== undefined && - typeof scopedIntrospection !== "boolean" - ) { - throw new Error( - `scopedIntrospection must be a boolean for service '${pgService.name}'`, - ); - } if ( scopedCatalogTypes !== 'all' && scopedCatalogTypes !== 'dependency-closure' @@ -303,7 +295,7 @@ function getIntrospectionQuery( `Unsupported scoped catalog type policy '${scopedCatalogTypes}' for service '${pgService.name}'` ); } - if (scopedIntrospection !== true) { + if (!scopedIntrospection) { const configuredScopedOptions = [ configuredCatalogTypes !== undefined ? "introspectionScopedCatalogTypes" diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts index 7941b28afe..de8ce463d4 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts @@ -52,7 +52,7 @@ export function assertDependencyClosureTypes( )._lookups; const resolves = retainedTypeOids.has(normalizedOid) || - introspectionLookups?.typeById?.has(normalizedOid) === true; + introspectionLookups?.typeById?.has(normalizedOid); if (!resolves) { throw new Error( `Dependency-closure introspection for service '${serviceName}' retained ${objectKind} '${objectContext}' field '${field}' referencing missing pg_type OID '${normalizedOid}'` diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index 5add05ff86..f3fa617693 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -181,6 +181,22 @@ describe('getEnvOptions', () => { ).toBeUndefined(); }); + it.each(['scopedIntrospection', 'introspectionJit'] as const)( + 'rejects a non-boolean graphile.%s value at the configuration boundary', + (option) => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-invalid-introspection-') + ); + writeConfig(tempDir, { + graphile: { [option]: 'true' } + }); + + expect(() => getEnvOptions({}, tempDir, {})).toThrow( + `graphile.${option} must be a boolean` + ); + } + ); + it('honors config, env, and runtime priority for scoped introspection', () => { tempDir = fs.mkdtempSync( path.join(os.tmpdir(), 'graphql-env-introspection-') diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 15f1402c53..cee4510ab1 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -4,6 +4,17 @@ import deepmerge from 'deepmerge'; import { getGraphQLEnvVars } from './env'; +type GraphileBooleanOption = 'scopedIntrospection' | 'introspectionJit'; + +const validateGraphileBooleanOption = ( + value: unknown, + option: GraphileBooleanOption +): void => { + if (value !== undefined && typeof value !== 'boolean') { + throw new Error(`graphile.${option} must be a boolean`); + } +}; + /** * Get Constructive environment options by merging: * 1. Core PGPM defaults (from @pgpmjs/env) @@ -36,7 +47,7 @@ export const getEnvOptions = ( const configOptions = loadConfigSync(cwd) as Partial; // Merge in order: core -> graphql defaults -> config (for graphql keys) -> graphql env -> overrides - return deepmerge.all([ + const options = deepmerge.all([ coreOptions, constructiveGraphqlDefaults, // Only merge graphql-related keys from config (if present) @@ -51,6 +62,17 @@ export const getEnvOptions = ( ], { arrayMerge: replaceArrays }) as ConstructiveOptions; + + validateGraphileBooleanOption( + options.graphile?.scopedIntrospection, + 'scopedIntrospection' + ); + validateGraphileBooleanOption( + options.graphile?.introspectionJit, + 'introspectionJit' + ); + + return options; }; /** diff --git a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts index d9060fbe5e..204b1230aa 100644 --- a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts +++ b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts @@ -84,26 +84,4 @@ describe('Graphile introspection mode wiring', () => { ).rejects.toThrow(/require scopedIntrospection: true/); expect(loadScopedPreset).not.toHaveBeenCalled(); }); - - it('rejects a non-boolean scoped introspection flag', async () => { - await expect( - makeIntrospectionWiring( - pool, - ['tenant_a'], - { scopedIntrospection: 'true' } as never, - jest.fn() - ) - ).rejects.toThrow('graphile.scopedIntrospection must be a boolean'); - }); - - it('rejects a non-boolean introspection JIT flag', async () => { - await expect( - makeIntrospectionWiring( - pool, - ['tenant_a'], - { scopedIntrospection: true, introspectionJit: 'true' } as never, - jest.fn() - ) - ).rejects.toThrow('graphile.introspectionJit must be a boolean'); - }); }); diff --git a/graphql/server/src/middleware/graphile-introspection.ts b/graphql/server/src/middleware/graphile-introspection.ts index 2ac3a6c620..400cc5421f 100644 --- a/graphql/server/src/middleware/graphile-introspection.ts +++ b/graphql/server/src/middleware/graphile-introspection.ts @@ -33,22 +33,9 @@ export const makeIntrospectionWiring = async ( graphileOptions: GraphileOptions | undefined, loadScopedPreset: ScopedIntrospectionPresetLoader = loadScopedIntrospectionPreset ): Promise => { - const scopedIntrospection = graphileOptions?.scopedIntrospection; - const introspectionJit = graphileOptions?.introspectionJit; - if ( - scopedIntrospection !== undefined && - typeof scopedIntrospection !== 'boolean' - ) { - throw new Error('graphile.scopedIntrospection must be a boolean'); - } - if ( - introspectionJit !== undefined && - typeof introspectionJit !== 'boolean' - ) { - throw new Error('graphile.introspectionJit must be a boolean'); - } + const { scopedIntrospection, introspectionJit } = graphileOptions ?? {}; - if (scopedIntrospection !== true) { + if (!scopedIntrospection) { const configuredScopedOptions = [ (graphileOptions?.introspectionDependencySchemas?.length ?? 0) > 0 ? 'introspectionDependencySchemas' @@ -56,7 +43,7 @@ export const makeIntrospectionWiring = async ( (graphileOptions?.introspectionCapabilityExtensions?.length ?? 0) > 0 ? 'introspectionCapabilityExtensions' : null, - introspectionJit === true ? 'introspectionJit' : null, + introspectionJit ? 'introspectionJit' : null, ].filter((option): option is string => option !== null); if (configuredScopedOptions.length > 0) { throw new Error( From df14f4af182321d9c8ef6ce428b6f6f0bc7bf4f0 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 11:27:37 +0800 Subject: [PATCH 08/12] Trust scoped introspection TypeScript contracts --- .../scoped-introspection-query.test.ts | 8 ---- .../PgIntrospectionPlugin.ts | 8 ---- .../graphile-build-pg/scopedValidation.ts | 14 +++--- .../pg-introspection/scopedIntrospection.ts | 30 +----------- .../scoped-introspection-wiring.test.ts | 6 --- .../src/introspection-settings.ts | 2 +- .../src/scoped-introspection-service.ts | 48 +++++++------------ graphql/env/__tests__/merge.test.ts | 19 ++++++++ graphql/env/src/merge.ts | 23 +++++++++ 9 files changed, 67 insertions(+), 91 deletions(-) diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts index 5ebd8f4637..55eb289424 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-query.test.ts @@ -62,12 +62,4 @@ describe('CNC-owned scoped introspection SQL', () => { "or pg_type.typnamespace = 'pg_catalog'::regnamespace" ); }); - - it('rejects unknown options at the runtime boundary', () => { - expect(() => - makeSchemaScopedIntrospectionQuery(['tenant_a'], { - unexpected: true, - } as never) - ).toThrow('Unsupported schema-scoped introspection option(s): unexpected'); - }); }); diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts index 309b5aa3fd..120293eac4 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts @@ -287,14 +287,6 @@ function getIntrospectionQuery( pgService.introspectionAllowedDependencySchemas; const scopedCatalogTypes = configuredCatalogTypes ?? 'all'; - if ( - scopedCatalogTypes !== 'all' && - scopedCatalogTypes !== 'dependency-closure' - ) { - throw new Error( - `Unsupported scoped catalog type policy '${scopedCatalogTypes}' for service '${pgService.name}'` - ); - } if (!scopedIntrospection) { const configuredScopedOptions = [ configuredCatalogTypes !== undefined diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts index de8ce463d4..04090e83b6 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts @@ -33,16 +33,15 @@ export function assertDependencyClosureTypes( if (scopedCatalogTypes !== 'dependency-closure') return; const retainedTypeOids = new Set( - introspection.types.map((type) => String(type._id)) + introspection.types.map((type) => type._id) ); const requireType = ( - oid: unknown, + oid: string | null | undefined, objectKind: string, objectContext: string, field: string ): void => { - if (oid === null || oid === undefined || String(oid) === '0') return; - const normalizedOid = String(oid); + if (oid === null || oid === undefined || oid === '0') return; // pg-introspection removes extension-owned composite resources from its // public arrays after building lookups. Validate the runtime lookup too. const introspectionLookups = ( @@ -51,16 +50,15 @@ export function assertDependencyClosureTypes( } )._lookups; const resolves = - retainedTypeOids.has(normalizedOid) || - introspectionLookups?.typeById?.has(normalizedOid); + retainedTypeOids.has(oid) || introspectionLookups?.typeById?.has(oid); if (!resolves) { throw new Error( - `Dependency-closure introspection for service '${serviceName}' retained ${objectKind} '${objectContext}' field '${field}' referencing missing pg_type OID '${normalizedOid}'` + `Dependency-closure introspection for service '${serviceName}' retained ${objectKind} '${objectContext}' field '${field}' referencing missing pg_type OID '${oid}'` ); } }; const requireTypes = ( - oids: readonly unknown[] | null | undefined, + oids: readonly string[] | null | undefined, objectKind: string, objectContext: string, field: string diff --git a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/scopedIntrospection.ts b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/scopedIntrospection.ts index 2745b8574b..bc1a97f532 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/scopedIntrospection.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/pg-introspection/scopedIntrospection.ts @@ -345,41 +345,15 @@ export const makeSchemaScopedIntrospectionQuery = ( schemas: readonly string[], options: SchemaScopedIntrospectionOptions = {} ): SchemaScopedIntrospectionQuery => { - if (!Array.isArray(schemas) || schemas.length === 0) { + if (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') @@ -395,7 +369,7 @@ export const makeSchemaScopedIntrospectionQuery = ( const normalized = Array.from( new Set( schemas.map((schema) => { - if (typeof schema !== 'string' || schema.length === 0) { + if (schema.length === 0) { throw new Error( 'Schema-scoped introspection schemas must be non-empty strings' ); diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts index 50416571c5..b3ec67d2e2 100644 --- a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts +++ b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts @@ -78,12 +78,6 @@ describe('scoped introspection settings wiring', () => { }); it('fails deterministically on invalid scoped configuration', () => { - expect(() => - makeScopedPgService({ - pubsub: false, - introspectionScopedCatalogTypes: 'unsupported' as never, - }) - ).toThrow("Unsupported scoped catalog type policy 'unsupported'"); expect(() => makeScopedPgService({ pubsub: false, diff --git a/graphile/graphile-settings/src/introspection-settings.ts b/graphile/graphile-settings/src/introspection-settings.ts index e33d191126..d3447e2c73 100644 --- a/graphile/graphile-settings/src/introspection-settings.ts +++ b/graphile/graphile-settings/src/introspection-settings.ts @@ -3,7 +3,7 @@ export const normalizeIntrospectionDependencySchemas = ( ): string[] => [ ...new Set( (schemas ?? []).map((schema) => { - if (typeof schema !== 'string' || schema.trim().length === 0) { + if (schema.trim().length === 0) { throw new Error( 'Introspection dependency schemas must be non-empty strings' ); diff --git a/graphile/graphile-settings/src/scoped-introspection-service.ts b/graphile/graphile-settings/src/scoped-introspection-service.ts index 639b5f5008..fb681e0685 100644 --- a/graphile/graphile-settings/src/scoped-introspection-service.ts +++ b/graphile/graphile-settings/src/scoped-introspection-service.ts @@ -7,29 +7,22 @@ import { const normalizeIntrospectionCapabilityExtensions = ( extensions: readonly string[] | undefined -): readonly string[] => { - if (extensions === undefined) return []; - if (!Array.isArray(extensions)) { - throw new Error('introspectionCapabilityExtensions must be an array'); - } - return [ - ...new Set( - extensions.map((extension) => { - if ( - typeof extension !== 'string' || - extension.length === 0 || - extension.trim() !== extension || - extension.includes('\0') - ) { - throw new Error( - 'introspectionCapabilityExtensions must contain exact non-empty extension names' - ); - } - return extension; - }) - ), - ]; -}; +): readonly string[] => [ + ...new Set( + (extensions ?? []).map((extension) => { + if ( + extension.length === 0 || + extension.trim() !== extension || + extension.includes('\0') + ) { + throw new Error( + 'introspectionCapabilityExtensions must contain exact non-empty extension names' + ); + } + return extension; + }) + ), +]; type UpstreamPgServiceOptions = { pgSettingsForIntrospection?: @@ -65,15 +58,6 @@ export function makeConfiguredPgService< const introspectionCapabilityExtensions = normalizeIntrospectionCapabilityExtensions(configuredCapabilityExtensions); - if ( - introspectionScopedCatalogTypes !== undefined && - introspectionScopedCatalogTypes !== 'all' && - introspectionScopedCatalogTypes !== 'dependency-closure' - ) { - throw new Error( - `Unsupported scoped catalog type policy '${introspectionScopedCatalogTypes}'` - ); - } const introspectionAllowedDependencySchemas = normalizeIntrospectionDependencySchemas(configuredDependencySchemas); const pgSettingsForIntrospection = resolveIntrospectionSettings( diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index f3fa617693..094e9782b2 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -197,6 +197,25 @@ describe('getEnvOptions', () => { } ); + it.each([ + ['introspectionDependencySchemas', 'shared'], + ['introspectionCapabilityExtensions', [42]], + ] as const)( + 'rejects an invalid graphile.%s value at the configuration boundary', + (option, value) => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-invalid-introspection-') + ); + writeConfig(tempDir, { + graphile: { [option]: value } + }); + + expect(() => getEnvOptions({}, tempDir, {})).toThrow( + `graphile.${option} must be an array of strings` + ); + } + ); + it('honors config, env, and runtime priority for scoped introspection', () => { tempDir = fs.mkdtempSync( path.join(os.tmpdir(), 'graphql-env-introspection-') diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index cee4510ab1..726dd6c6d3 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -5,6 +5,9 @@ import deepmerge from 'deepmerge'; import { getGraphQLEnvVars } from './env'; type GraphileBooleanOption = 'scopedIntrospection' | 'introspectionJit'; +type GraphileStringArrayOption = + | 'introspectionDependencySchemas' + | 'introspectionCapabilityExtensions'; const validateGraphileBooleanOption = ( value: unknown, @@ -15,6 +18,18 @@ const validateGraphileBooleanOption = ( } }; +const validateGraphileStringArrayOption = ( + value: unknown, + option: GraphileStringArrayOption +): void => { + if ( + value !== undefined && + (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) + ) { + throw new Error(`graphile.${option} must be an array of strings`); + } +}; + /** * Get Constructive environment options by merging: * 1. Core PGPM defaults (from @pgpmjs/env) @@ -71,6 +86,14 @@ export const getEnvOptions = ( options.graphile?.introspectionJit, 'introspectionJit' ); + validateGraphileStringArrayOption( + options.graphile?.introspectionDependencySchemas, + 'introspectionDependencySchemas' + ); + validateGraphileStringArrayOption( + options.graphile?.introspectionCapabilityExtensions, + 'introspectionCapabilityExtensions' + ); return options; }; From 5b4af709e8c194ee48e98ed83074fe98d0fc4080 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 13:03:38 +0800 Subject: [PATCH 09/12] Move scoped validation to its owner --- ...ped-introspection-service-contract.test.ts | 16 +++++++++ .../PgIntrospectionPlugin.ts | 2 ++ .../graphile-build-pg/scopedValidation.ts | 22 +++++++++++++ .../scoped-introspection-wiring.test.ts | 23 ++----------- graphile/graphile-settings/src/index.ts | 5 +-- .../src/introspection-settings.ts | 26 --------------- .../src/scoped-introspection-service.ts | 33 ++----------------- 7 files changed, 47 insertions(+), 80 deletions(-) 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 index 5f9597ffc0..9867bdc4b0 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts @@ -91,4 +91,20 @@ describe('scoped introspection service identity contract', () => { ).rejects.toThrow(/require scopedIntrospection: true/); } ); + + it.each([ + ['empty', '', 'exact non-empty schema names'], + ['surrounding whitespace', ' shared', 'exact non-empty schema names'], + ['system', 'pg_catalog', 'must not be a system schema'], + ['NUL', 'tenant\0a', 'must not contain NUL bytes'], + ])('rejects %s dependency schema names', async (_label, schema, message) => { + await expect( + gather({ + plugins: [PgScopedIntrospectionPlugin, consumerPlugin], + pgServices: [ + makeService({ introspectionAllowedDependencySchemas: [schema] }), + ], + }) + ).rejects.toThrow(message); + }); }); diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts index 120293eac4..1ab549104e 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts @@ -38,6 +38,7 @@ import { import type { ScopedIntrospectionServiceOptions } from "./scopedOptions"; import { + assertAllowedDependencySchemas, assertDependencyClosureTypes, assertScopedNamespaces, } from "./scopedValidation"; @@ -315,6 +316,7 @@ function getIntrospectionQuery( } const requiredSchemas = pgService.schemas ?? []; const dependencySchemas = configuredDependencySchemas ?? []; + assertAllowedDependencySchemas(dependencySchemas); return { query: makeSchemaScopedIntrospectionQuery(requiredSchemas, { catalogTypes: scopedCatalogTypes, diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts index 04090e83b6..ca9666bf0f 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedValidation.ts @@ -1,5 +1,27 @@ import type { Introspection, ScopedCatalogTypes } from "../pg-introspection"; +export function assertAllowedDependencySchemas( + schemas: readonly string[] +): void { + for (const schema of schemas) { + if (schema.length === 0 || schema.trim() !== schema) { + throw new Error( + 'Introspection dependency schemas must contain exact non-empty schema names' + ); + } + if (schema === 'information_schema' || schema.startsWith('pg_')) { + throw new Error( + `Introspection dependency schema '${schema}' must not be a system schema` + ); + } + if (schema.includes('\0')) { + throw new Error( + 'Introspection dependency schemas must not contain NUL bytes' + ); + } + } +} + export function assertScopedNamespaces( introspection: Introspection, requiredSchemas: readonly string[] | null, diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts index b3ec67d2e2..3d96e287eb 100644 --- a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts +++ b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts @@ -27,7 +27,7 @@ describe('scoped introspection settings wiring', () => { makeUpstreamPgService.mockClear(); }); - it('normalizes scoped service configuration without forwarding CNC fields upstream', () => { + it('forwards scoped service configuration without interpreting it', () => { const service = makeScopedPgService({ pubsub: false, schemas: ['tenant_a'], @@ -45,8 +45,8 @@ describe('scoped introspection settings wiring', () => { schemas: ['tenant_a'], scopedIntrospection: true, introspectionScopedCatalogTypes: 'dependency-closure', - introspectionAllowedDependencySchemas: ['shared'], - introspectionCapabilityExtensions: ['pg_trgm'], + introspectionAllowedDependencySchemas: ['shared', 'shared'], + introspectionCapabilityExtensions: ['pg_trgm', 'pg_trgm'], pgSettingsForIntrospection: { statement_timeout: '30s', jit: 'off', @@ -77,23 +77,6 @@ describe('scoped introspection settings wiring', () => { }); }); - it('fails deterministically on invalid scoped configuration', () => { - expect(() => - makeScopedPgService({ - pubsub: false, - introspectionCapabilityExtensions: [' pg_trgm'], - }) - ).toThrow( - 'introspectionCapabilityExtensions must contain exact non-empty extension names' - ); - expect(() => - makeScopedPgService({ - pubsub: false, - introspectionAllowedDependencySchemas: ['pg_catalog'], - }) - ).toThrow('must not be a system schema'); - }); - it('keeps ConstructivePreset on the upstream introspection plugin', () => { const constructive = resolvePreset(ConstructivePreset); diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index 758fef9e1a..d7223a8870 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -70,10 +70,7 @@ export { makePgService }; export const makeScopedPgService = (options: ScopedPgServiceOptions) => makeConfiguredPgService(makePgService, options); -export { - normalizeIntrospectionDependencySchemas, - resolveIntrospectionSettings -} from './introspection-settings'; +export { resolveIntrospectionSettings } from './introspection-settings'; // Presigned URL utilities export { getPresignedUrlS3Config } from './presigned-url-resolver'; diff --git a/graphile/graphile-settings/src/introspection-settings.ts b/graphile/graphile-settings/src/introspection-settings.ts index d3447e2c73..0b5a54a490 100644 --- a/graphile/graphile-settings/src/introspection-settings.ts +++ b/graphile/graphile-settings/src/introspection-settings.ts @@ -1,29 +1,3 @@ -export const normalizeIntrospectionDependencySchemas = ( - schemas: readonly string[] | null | undefined -): string[] => [ - ...new Set( - (schemas ?? []).map((schema) => { - if (schema.trim().length === 0) { - throw new Error( - 'Introspection dependency schemas must be non-empty strings' - ); - } - const normalized = schema.trim(); - if (normalized === 'information_schema' || normalized.startsWith('pg_')) { - throw new Error( - `Introspection dependency schema '${normalized}' must not be a system schema` - ); - } - if (normalized.includes('\0')) { - throw new Error( - 'Introspection dependency schemas must not contain NUL bytes' - ); - } - return normalized; - }) - ), -]; - export const resolveIntrospectionSettings = ( introspectionJit: boolean, settings: Record | null | undefined diff --git a/graphile/graphile-settings/src/scoped-introspection-service.ts b/graphile/graphile-settings/src/scoped-introspection-service.ts index fb681e0685..127bef39ea 100644 --- a/graphile/graphile-settings/src/scoped-introspection-service.ts +++ b/graphile/graphile-settings/src/scoped-introspection-service.ts @@ -1,28 +1,6 @@ import type { ScopedIntrospectionServiceOptions } from '@constructive-io/graphql-types'; -import { - normalizeIntrospectionDependencySchemas, - resolveIntrospectionSettings, -} from './introspection-settings'; - -const normalizeIntrospectionCapabilityExtensions = ( - extensions: readonly string[] | undefined -): readonly string[] => [ - ...new Set( - (extensions ?? []).map((extension) => { - if ( - extension.length === 0 || - extension.trim() !== extension || - extension.includes('\0') - ) { - throw new Error( - 'introspectionCapabilityExtensions must contain exact non-empty extension names' - ); - } - return extension; - }) - ), -]; +import { resolveIntrospectionSettings } from './introspection-settings'; type UpstreamPgServiceOptions = { pgSettingsForIntrospection?: @@ -55,11 +33,6 @@ export function makeConfiguredPgService< introspectionJit = false, ...upstreamOptions } = options; - const introspectionCapabilityExtensions = - normalizeIntrospectionCapabilityExtensions(configuredCapabilityExtensions); - - const introspectionAllowedDependencySchemas = - normalizeIntrospectionDependencySchemas(configuredDependencySchemas); const pgSettingsForIntrospection = resolveIntrospectionSettings( introspectionJit, options.pgSettingsForIntrospection @@ -73,7 +46,7 @@ export function makeConfiguredPgService< scopedIntrospection: true as const, introspectionScopedCatalogTypes: introspectionScopedCatalogTypes ?? 'dependency-closure', - introspectionAllowedDependencySchemas, - introspectionCapabilityExtensions, + introspectionAllowedDependencySchemas: configuredDependencySchemas ?? [], + introspectionCapabilityExtensions: configuredCapabilityExtensions ?? [], }); } From f83637810ade36099fb96f40a4cfa4f6154a0a50 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 13:39:18 +0800 Subject: [PATCH 10/12] Test scoped dependency closure against PostgreSQL --- .../dependency-closure.integration.test.ts | 371 ++++++++++++++++++ .../__tests__/fixtures/dependency-closure.sql | 110 ++++++ .../package.json | 4 +- pnpm-lock.yaml | 6 + 4 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts create mode 100644 graphile/graphile-scoped-introspection/__tests__/fixtures/dependency-closure.sql diff --git a/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts b/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts new file mode 100644 index 0000000000..764a8c5356 --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts @@ -0,0 +1,371 @@ +import '@dataplan/pg/adaptors/pg'; + +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 { + execute, + type GraphQLSchema, + lexicographicSortSchema, + parse, + printSchema, +} from 'graphql'; +import { getConnections, seed } from 'pgsql-test'; +import { makePgService } from 'postgraphile/adaptors/pg'; + +import { + type Introspection, + ScopedIntrospectionPreset, +} from '../src'; + +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 TestConnections = Awaited>; +type TestPool = ReturnType; + +interface SchemaBuild { + schema: GraphQLSchema; + introspection: Introspection; + hash: string; +} + +const makeCapturePlugin = ( + name: string, + capture: (introspection: Introspection) => void +): GraphileConfig.Plugin => + ({ + name, + gather: { + namespace: `${name}Namespace`, + hooks: { + pgIntrospection_introspection(_info, event) { + capture(event.introspection); + }, + }, + }, + }) as GraphileConfig.Plugin; + +const buildSchema = async ( + pool: TestPool, + scoped: boolean, + allowedDependencySchemas: readonly string[] = [], + rootSchema = ROOT_SCHEMA +): Promise => { + let introspection: Introspection | undefined; + const service = Object.assign( + makePgService({ pool, schemas: [rootSchema] }), + scoped + ? { + scopedIntrospection: true as const, + introspectionAllowedDependencySchemas: allowedDependencySchemas, + introspectionScopedCatalogTypes: 'dependency-closure' as const, + introspectionCapabilityExtensions: ['pg_trgm'], + } + : {} + ); + + try { + const result = await makeSchema({ + extends: [ + graphileBuildPreset, + graphileBuildPgPreset, + ...(scoped ? [ScopedIntrospectionPreset] : []), + ], + plugins: [ + makeCapturePlugin( + scoped + ? 'ScopedDependencyClosureCapturePlugin' + : 'StockDependencyClosureCapturePlugin', + (value) => { + introspection = value; + } + ), + ], + pgServices: [service], + }); + if (!introspection) { + throw new Error('Graphile introspection lifecycle event was not emitted'); + } + const sdl = printSchema(lexicographicSortSchema(result.schema)); + return { + schema: result.schema, + introspection, + hash: createHash('sha256').update(sdl).digest('hex'), + }; + } finally { + await service.release(); + } +}; + +describe('real PostgreSQL dependency closure', () => { + let connections: TestConnections; + let pool: TestPool; + let stock: SchemaBuild; + let scoped: SchemaBuild; + + beforeAll(async () => { + connections = await getConnections({}, [ + seed.sqlfile([join(__dirname, 'fixtures/dependency-closure.sql')]), + ]); + pool = connections.manager.getPool(connections.pg.config); + stock = await buildSchema(pool, false); + scoped = await buildSchema(pool, true, [ + DEPENDENCY_SCHEMA, + EXTENSION_SCHEMA, + ]); + }); + + beforeEach(async () => { + await connections.pg.beforeEach(); + await connections.db.beforeEach(); + }); + + afterEach(async () => { + await connections.db.afterEach(); + await connections.pg.afterEach(); + }); + + afterAll(async () => { + await connections.teardown(); + }); + + it('builds equivalent stock and scoped schemas with a working runtime', async () => { + expect(scoped.hash).toBe(stock.hash); + + const document = parse('{ __typename }'); + const stockResult = await execute({ schema: stock.schema, document }); + const scopedResult = await execute({ schema: scoped.schema, document }); + expect(stockResult.errors).toBeUndefined(); + expect(scopedResult).toEqual(stockResult); + expect(scopedResult.data?.__typename).toBe('Query'); + }); + + it('retains cross-schema 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'); + for (const type of attributeTypes.values()) { + if (type?.typname === 'text' || type?.typname === 'int8') continue; + expect(type?.getNamespace()?.nspname).toBe(DEPENDENCY_SCHEMA); + } + + const statusType = attributeTypes.get('status'); + expect(statusType?.getEnumValues().map((value) => value.enumlabel)).toEqual([ + 'draft', + 'active', + 'archived', + ]); + expect(statusType?.getArrayType()?.typname).toBe('_item_status'); + + const scoreType = attributeTypes.get('score'); + expect(scoreType?.typtype).toBe('d'); + expect( + scoped.introspection.types.find( + (type) => type._id === scoreType?.typbasetype + )?.typname + ).toBe('int4'); + + const payloadType = attributeTypes.get('payload'); + const payloadAttributeTypes = payloadType + ?.getClass() + ?.getAttributes() + .map((attribute) => attribute.getType()?.typname); + expect(payloadAttributeTypes).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'); + }); + + it('retains ordinary and pg_trgm index metadata without unrelated entities', async () => { + const indexes = new Map( + scoped.introspection.indexes.map((index) => [ + index.getIndexClass()?.relname, + index, + ]) + ); + expect(indexes.has('closure_items_status_idx')).toBe(true); + expect(indexes.has('closure_items_score_idx')).toBe(true); + expect(indexes.has('closure_items_title_gin_trgm_idx')).toBe(true); + expect(indexes.has('closure_items_title_gist_trgm_idx')).toBe(true); + + const opclasses = await connections.pg.query<{ + oid: string; + opcname: string; + support_function_count: number; + }>( + ` + SELECT + pg_opclass.oid::text AS oid, + pg_opclass.opcname, + count(DISTINCT support_proc.oid)::integer AS support_function_count + FROM pg_catalog.pg_opclass + INNER JOIN pg_catalog.pg_namespace + ON pg_namespace.oid = pg_opclass.opcnamespace + INNER JOIN pg_catalog.pg_amproc + ON pg_amproc.amprocfamily = pg_opclass.opcfamily + INNER JOIN pg_catalog.pg_proc AS support_proc + ON support_proc.oid = pg_amproc.amproc + INNER JOIN pg_catalog.pg_depend + ON pg_depend.classid = 'pg_catalog.pg_proc'::regclass + AND pg_depend.objid = support_proc.oid + AND pg_depend.refclassid = 'pg_catalog.pg_extension'::regclass + AND pg_depend.deptype = 'e' + INNER JOIN pg_catalog.pg_extension + ON pg_extension.oid = pg_depend.refobjid + WHERE pg_namespace.nspname = $1 + AND pg_opclass.opcname = ANY($2::text[]) + AND pg_extension.extname = 'pg_trgm' + GROUP BY pg_opclass.oid, pg_opclass.opcname + `, + [EXTENSION_SCHEMA, ['gin_trgm_ops', 'gist_trgm_ops']] + ); + const opclassByName = new Map( + opclasses.rows.map(({ oid, opcname }) => [opcname, oid]) + ); + expect(opclasses.rows).toHaveLength(2); + expect( + opclasses.rows.every(({ support_function_count }) => + support_function_count > 0 + ) + ).toBe(true); + expect( + indexes.get('closure_items_title_gin_trgm_idx')?.indclass + ).toContain(opclassByName.get('gin_trgm_ops')); + expect( + indexes.get('closure_items_title_gist_trgm_idx')?.indclass + ).toContain(opclassByName.get('gist_trgm_ops')); + + const extension = scoped.introspection.extensions.find( + (entity) => entity.extname === 'pg_trgm' + ); + const extensionNamespace = scoped.introspection.namespaces.find( + (namespace) => namespace.nspname === EXTENSION_SCHEMA + ); + expect(extension?.extnamespace).toBe(extensionNamespace?._id); + + expect( + scoped.introspection.classes.some( + (entity) => entity.relname === 'unrelated_closure_items_status_idx' + ) + ).toBe(false); + expect( + scoped.introspection.types.some( + (type) => + type.typname === 'item_status' && + type.getNamespace()?.nspname === UNRELATED_SCHEMA + ) + ).toBe(false); + expect( + scoped.introspection.procs.some( + (proc) => proc.getNamespace()?.nspname === UNRELATED_SCHEMA + ) + ).toBe(false); + + const stockCatalogTypeCount = stock.introspection.types.filter( + (type) => type.getNamespace()?.nspname === 'pg_catalog' + ).length; + const scopedCatalogTypeCount = scoped.introspection.types.filter( + (type) => type.getNamespace()?.nspname === 'pg_catalog' + ).length; + expect(scopedCatalogTypeCount).toBeLessThan(stockCatalogTypeCount); + }); + + it('retains explicitly requested extension capability metadata', async () => { + const capabilityOnly = await buildSchema( + pool, + true, + [EXTENSION_SCHEMA], + CAPABILITY_ROOT_SCHEMA + ); + const namespaceNames = capabilityOnly.introspection.namespaces.map( + (namespace) => namespace.nspname + ); + expect(namespaceNames).toEqual( + expect.arrayContaining([ + CAPABILITY_ROOT_SCHEMA, + EXTENSION_SCHEMA, + 'pg_catalog', + ]) + ); + 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 closed when a required dependency schema is not approved', async () => { + await expect( + buildSchema(pool, true, [EXTENSION_SCHEMA]) + ).rejects.toThrow( + `crossed into unapproved dependency schema(s): ${DEPENDENCY_SCHEMA}` + ); + }); +}); diff --git a/graphile/graphile-scoped-introspection/__tests__/fixtures/dependency-closure.sql b/graphile/graphile-scoped-introspection/__tests__/fixtures/dependency-closure.sql new file mode 100644 index 0000000000..533c044bac --- /dev/null +++ b/graphile/graphile-scoped-introspection/__tests__/fixtures/dependency-closure.sql @@ -0,0 +1,110 @@ +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_root"."closure_items" ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + 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 INDEX "closure_items_status_idx" + ON "scope_root"."closure_items" (status); + +CREATE INDEX "closure_items_score_idx" + ON "scope_root"."closure_items" (score); + +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 DOMAIN "scope_unrelated"."positive_integer" AS integer + CHECK (VALUE > 0); + +CREATE TABLE "scope_unrelated"."closure_items" ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title text NOT NULL, + status "scope_unrelated"."item_status" NOT NULL, + score "scope_unrelated"."positive_integer" NOT NULL +); + +CREATE INDEX "unrelated_closure_items_status_idx" + ON "scope_unrelated"."closure_items" (status); + +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 +); diff --git a/graphile/graphile-scoped-introspection/package.json b/graphile/graphile-scoped-introspection/package.json index 004c84df65..00d2e87ad4 100644 --- a/graphile/graphile-scoped-introspection/package.json +++ b/graphile/graphile-scoped-introspection/package.json @@ -32,7 +32,9 @@ "@types/node": "^22.19.11", "graphql": "16.13.0", "makage": "^0.3.0", - "pg-introspection": "^1.0.1" + "pg-introspection": "^1.0.1", + "pgsql-test": "workspace:^", + "postgraphile": "5.1.4" }, "peerDependencies": { "@dataplan/pg": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f97c85da1b..9a42389365 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1182,6 +1182,12 @@ importers: pg-introspection: specifier: 1.0.1 version: 1.0.1 + pgsql-test: + specifier: workspace:^ + version: link:../../postgres/pgsql-test/dist + postgraphile: + specifier: 5.1.4 + version: 5.1.4(f282a162d8bd20a217e08c60f5396af8) publishDirectory: dist graphile/graphile-search: From 2160b651964fee0dcbcc52a1e7b4ba875209ed15 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 19 Aug 2026 18:46:44 +0800 Subject: [PATCH 11/12] Benchmark scoped introspection across catalog sizes --- .gitignore | 1 + .../dependency-closure.integration.test.ts | 36 ++ .../__tests__/fixtures/dependency-closure.sql | 24 + packages/perf-harness/README.md | 37 ++ .../__tests__/scoped-catalog-fixture.test.ts | 40 ++ .../scoped-introspection-analysis.test.ts | 112 +++++ .../scoped-introspection-query-worker.test.ts | 62 +++ .../scoped-introspection-suite.test.ts | 34 +- packages/perf-harness/package.json | 4 +- packages/perf-harness/src/index.ts | 2 + .../src/scoped-catalog-fixture.ts | 426 ++++++++++++++++++ packages/perf-harness/src/scoped-catalog.ts | 217 +++++++++ .../src/scoped-introspection-analysis.ts | 286 ++++++++++++ .../src/scoped-introspection-query-worker.ts | 223 +++++++++ .../src/scoped-introspection-suite.ts | 65 ++- .../src/scoped-introspection-worker.ts | 132 +++++- pnpm-lock.yaml | 3 + 17 files changed, 1673 insertions(+), 31 deletions(-) create mode 100644 packages/perf-harness/__tests__/scoped-catalog-fixture.test.ts create mode 100644 packages/perf-harness/__tests__/scoped-introspection-analysis.test.ts create mode 100644 packages/perf-harness/__tests__/scoped-introspection-query-worker.test.ts create mode 100644 packages/perf-harness/src/scoped-catalog-fixture.ts create mode 100644 packages/perf-harness/src/scoped-catalog.ts create mode 100644 packages/perf-harness/src/scoped-introspection-analysis.ts create mode 100644 packages/perf-harness/src/scoped-introspection-query-worker.ts diff --git a/.gitignore b/.gitignore index 60d96a6d7e..5c4defe583 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ postgres/pgsql-test/output/ .env.local graphql/server/logs/ graphql/server/*.heapsnapshot +packages/perf-harness/artifacts/ # Ephemeral pgpm modules installed by `pnpm fixtures:install` (pgpm install) /extensions/ diff --git a/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts b/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts index 764a8c5356..e807698a33 100644 --- a/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts @@ -238,6 +238,42 @@ describe('real PostgreSQL dependency closure', () => { ).toBe('integer_span_set'); }); + it('retains cross-schema foreign-key targets and required inheritance parents only', () => { + const closureItems = scoped.introspection.classes.find( + (entity) => + entity.relname === 'closure_items' && + entity.getNamespace()?.nspname === ROOT_SCHEMA + ); + const foreignKey = closureItems + ?.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' && + entity.getNamespace()?.nspname === DEPENDENCY_SCHEMA + ) + ).toBe(false); + }); + it('retains ordinary and pg_trgm index metadata without unrelated entities', async () => { const indexes = new Map( scoped.introspection.indexes.map((index) => [ diff --git a/graphile/graphile-scoped-introspection/__tests__/fixtures/dependency-closure.sql b/graphile/graphile-scoped-introspection/__tests__/fixtures/dependency-closure.sql index 533c044bac..b8f54e3fc5 100644 --- a/graphile/graphile-scoped-introspection/__tests__/fixtures/dependency-closure.sql +++ b/graphile/graphile-scoped-introspection/__tests__/fixtures/dependency-closure.sql @@ -25,8 +25,19 @@ CREATE TYPE "scope_dependency"."integer_span" AS RANGE ( 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, @@ -34,6 +45,19 @@ CREATE TABLE "scope_root"."closure_items" ( 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); diff --git a/packages/perf-harness/README.md b/packages/perf-harness/README.md index 01fc2f1d47..cb4ea09231 100644 --- a/packages/perf-harness/README.md +++ b/packages/perf-harness/README.md @@ -40,3 +40,40 @@ 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 catalog benchmark + +`cperf-scoped-catalog` isolates the upstream stock introspection query from the +opt-in scoped query. It does not load `ConstructivePreset`, build-state +retirement, cache limits, request admission, or other CNC application plugins. + +Prepare an explicitly named fixture outside the measured phase, then run both +JIT settings with fresh, interleaved stock/scoped processes: + +```sh +node packages/perf-harness/dist/scoped-catalog.js prepare \ + --database-url "$DATABASE_URL" \ + --fixture cperf_scoped_small_20260819 \ + --size small + +node packages/perf-harness/dist/scoped-catalog.js run \ + --database-url "$DATABASE_URL" \ + --fixture cperf_scoped_small_20260819 \ + --size small \ + --repetitions 10 \ + --seed 20260819 \ + --output-directory packages/perf-harness/artifacts/cperf_scoped_small_20260819 +``` + +Repeat with unique `medium` and `large` fixture names. Medium and large are +local diagnostics and are not ordinary CI tests. Fixture preparation never +replaces, drops, or reuses existing schemas. Its root GraphQL surface remains fixed while +unrelated tables, indexes, sequences, types, and functions increase catalog +size. + +Each run writes generic build and query reports plus a scoped analysis with +p50/p95, paired percent changes, schema/runtime validation, payload and parsed +entity counts, real catalog counts, and SHA-256 hashes of the raw reports. The +query diagnostic is labeled `shared-server-not-reset`: it is not a pristine +PostgreSQL catalog-cache cold start. Synthetic large-catalog results show scale +behavior and should not be described as typical production gains. diff --git a/packages/perf-harness/__tests__/scoped-catalog-fixture.test.ts b/packages/perf-harness/__tests__/scoped-catalog-fixture.test.ts new file mode 100644 index 0000000000..48b428fc73 --- /dev/null +++ b/packages/perf-harness/__tests__/scoped-catalog-fixture.test.ts @@ -0,0 +1,40 @@ +import { + makeScopedCatalogLayout, + parseScopedCatalogSize, + SCOPED_CATALOG_SCALES, + validateScopedCatalogFixtureName, +} from '../src/scoped-catalog-fixture'; + +describe('scoped catalog fixture safety', () => { + it('derives bounded cperf schema names from an explicit fixture name', () => { + expect(makeScopedCatalogLayout('cperf_catalog_20260819')).toEqual({ + fixture: 'cperf_catalog_20260819', + rootSchema: 'cperf_catalog_20260819_root', + dependencySchema: 'cperf_catalog_20260819_dep', + noiseSchema: 'cperf_catalog_20260819_noise', + }); + expect(() => validateScopedCatalogFixtureName('public')).toThrow( + 'must start with cperf_' + ); + expect(() => + validateScopedCatalogFixtureName('cperf_fixture; drop schema public') + ).toThrow('must start with cperf_'); + expect(() => validateScopedCatalogFixtureName(`cperf_${'a'.repeat(50)}`)) + .toThrow('at most 52 characters'); + }); + + it('defines increasing small, medium, and large catalog scales', () => { + expect(parseScopedCatalogSize('small')).toBe('small'); + expect(parseScopedCatalogSize('medium')).toBe('medium'); + expect(parseScopedCatalogSize('large')).toBe('large'); + expect(() => parseScopedCatalogSize('production')).toThrow( + "'small', 'medium', or 'large'" + ); + expect(SCOPED_CATALOG_SCALES.small.noiseTableCount).toBeLessThan( + SCOPED_CATALOG_SCALES.medium.noiseTableCount + ); + expect(SCOPED_CATALOG_SCALES.medium.noiseTableCount).toBeLessThan( + SCOPED_CATALOG_SCALES.large.noiseTableCount + ); + }); +}); diff --git a/packages/perf-harness/__tests__/scoped-introspection-analysis.test.ts b/packages/perf-harness/__tests__/scoped-introspection-analysis.test.ts new file mode 100644 index 0000000000..3e5728115c --- /dev/null +++ b/packages/perf-harness/__tests__/scoped-introspection-analysis.test.ts @@ -0,0 +1,112 @@ +import { analyzePairedMetric } from '../src/scoped-introspection-analysis'; +import type { + BenchmarkReport, + BenchmarkRun, + SuccessfulWorkerResult, +} from '../src/types'; + +const result = ( + caseName: string, + pid: number, + value: number +): SuccessfulWorkerResult => ({ + status: 'ok', + pid, + caseName, + buildMs: value, + schemaHash: 'same', + schemaTypeCount: 1, + runtimeVerified: true, + caseValidation: { passed: true, errors: [] }, + memory: { + baseline: { + rss: 0, + heapTotal: 0, + heapUsed: 0, + external: 0, + arrayBuffers: 0, + }, + afterBuild: { + rss: value, + heapTotal: value, + heapUsed: value, + external: value, + arrayBuffers: value, + }, + delta: { + rss: value, + heapTotal: value, + heapUsed: value, + external: value, + arrayBuffers: value, + }, + processPeakRss: value, + }, +}); + +const report = (runs: BenchmarkRun[], repetitions = 3): BenchmarkReport => ({ + format: 'constructive-performance-suite/v1', + generatedAt: '2026-08-19T00:00:00.000Z', + node: 'v22', + platform: 'darwin', + architecture: 'arm64', + suite: { name: 'test', cases: [] }, + config: { repetitions, seed: 1, order: null }, + schedule: [], + runs, + validation: { + allRunsSucceeded: true, + freshProcessPerRun: true, + caseValidationPassed: true, + schemaGroupsEquivalent: true, + schemaGroups: {}, + errors: [], + }, + summaries: {}, +}); + +describe('scoped introspection paired analysis', () => { + it('reports distributions and the median paired percent change', () => { + const runs: BenchmarkRun[] = []; + [100, 200, 300].forEach((value, index) => { + runs.push({ + repetition: index + 1, + position: 1, + caseName: 'stock', + result: result('stock', index + 1, value), + }); + runs.push({ + repetition: index + 1, + position: 2, + caseName: 'scoped', + result: result('scoped', index + 11, value / 2), + }); + }); + const analysis = analyzePairedMetric(report(runs), (item) => item.buildMs); + expect(analysis.status).toBe('ok'); + expect(analysis.stock).toEqual({ + sampleCount: 3, + p50: 200, + p95: 300, + min: 100, + max: 300, + }); + expect(analysis.pairedMedianPercentChange).toBe(-50); + }); + + it('does not synthesize a percentage from incomplete pairs', () => { + const analysis = analyzePairedMetric( + report([ + { + repetition: 1, + position: 1, + caseName: 'stock', + result: result('stock', 1, 100), + }, + ]), + (item) => item.buildMs + ); + expect(analysis.status).toBe('unavailable'); + expect(analysis.pairedMedianPercentChange).toBeNull(); + }); +}); diff --git a/packages/perf-harness/__tests__/scoped-introspection-query-worker.test.ts b/packages/perf-harness/__tests__/scoped-introspection-query-worker.test.ts new file mode 100644 index 0000000000..ae42c124c3 --- /dev/null +++ b/packages/perf-harness/__tests__/scoped-introspection-query-worker.test.ts @@ -0,0 +1,62 @@ +import { + parseIntrospectionEntityCounts, + validateQueryWorkerConfig, +} from '../src/scoped-introspection-query-worker'; + +describe('scoped query diagnostic boundaries', () => { + it('validates the JSON worker configuration once', () => { + expect( + validateQueryWorkerConfig({ + scopedIntrospection: true, + introspectionJit: false, + schemas: ['cperf_root'], + allowedDependencySchemas: ['cperf_dep'], + noiseSchemas: ['cperf_noise'], + }) + ).toEqual({ + scopedIntrospection: true, + introspectionJit: false, + schemas: ['cperf_root'], + allowedDependencySchemas: ['cperf_dep'], + noiseSchemas: ['cperf_noise'], + }); + expect(() => + validateQueryWorkerConfig({ + scopedIntrospection: true, + introspectionJit: 'off', + schemas: ['cperf_root'], + allowedDependencySchemas: [], + noiseSchemas: [], + }) + ).toThrow('introspectionJit boolean'); + }); + + it('counts the public top-level introspection arrays', () => { + expect( + parseIntrospectionEntityCounts({ + namespaces: [{}, {}], + classes: [{}], + attributes: [{}, {}, {}], + procs: [], + types: [{}, {}], + constraints: [{}], + indexes: [{}], + ranges: [], + extensions: [{}], + }) + ).toEqual({ + namespaces: 2, + classes: 1, + attributes: 3, + procedures: 0, + types: 2, + constraints: 1, + indexes: 1, + ranges: 0, + extensions: 1, + }); + expect(() => parseIntrospectionEntityCounts({ namespaces: null })).toThrow( + "field 'namespaces' is not an array" + ); + }); +}); diff --git a/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts b/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts index afcc42ddd6..c009558166 100644 --- a/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts +++ b/packages/perf-harness/__tests__/scoped-introspection-suite.test.ts @@ -5,13 +5,16 @@ describe('scoped introspection benchmark registration', () => { expect( makeScopedIntrospectionSuite({ schemas: ['cperf_example'] }) ).toEqual({ - name: 'scoped-introspection', + name: 'scoped-introspection-jit-off', cases: [ { name: 'stock', workerConfig: { scopedIntrospection: false, schemas: ['cperf_example'], + allowedDependencySchemas: [], + noiseSchemas: [], + introspectionJit: false, }, expectedSchemaGroup: 'introspection-equivalence', }, @@ -20,10 +23,39 @@ describe('scoped introspection benchmark registration', () => { workerConfig: { scopedIntrospection: true, schemas: ['cperf_example'], + allowedDependencySchemas: [], + noiseSchemas: [], + introspectionJit: false, }, expectedSchemaGroup: 'introspection-equivalence', }, ], }); }); + + it('keeps JIT and dependency/noise configuration identical across arms', () => { + const suite = makeScopedIntrospectionSuite({ + schemas: ['cperf_root'], + allowedDependencySchemas: ['cperf_dep'], + noiseSchemas: ['cperf_noise'], + introspectionJit: true, + }); + expect(suite.name).toBe('scoped-introspection-jit-on'); + expect(suite.cases.map((item) => item.workerConfig)).toEqual([ + { + scopedIntrospection: false, + schemas: ['cperf_root'], + allowedDependencySchemas: ['cperf_dep'], + noiseSchemas: ['cperf_noise'], + introspectionJit: true, + }, + { + scopedIntrospection: true, + schemas: ['cperf_root'], + allowedDependencySchemas: ['cperf_dep'], + noiseSchemas: ['cperf_noise'], + introspectionJit: true, + }, + ]); + }); }); diff --git a/packages/perf-harness/package.json b/packages/perf-harness/package.json index f5c235a485..7772eae0af 100644 --- a/packages/perf-harness/package.json +++ b/packages/perf-harness/package.json @@ -7,7 +7,8 @@ "module": "esm/index.js", "types": "index.d.ts", "bin": { - "cperf": "index.js" + "cperf": "index.js", + "cperf-scoped-catalog": "scoped-catalog.js" }, "scripts": { "clean": "makage clean", @@ -17,6 +18,7 @@ "test": "jest" }, "dependencies": { + "@dataplan/pg": "1.1.1", "graphile-build": "5.1.1", "graphile-build-pg": "5.1.3", "graphile-config": "1.1.0", diff --git a/packages/perf-harness/src/index.ts b/packages/perf-harness/src/index.ts index 379752f582..528c26cf64 100644 --- a/packages/perf-harness/src/index.ts +++ b/packages/perf-harness/src/index.ts @@ -6,6 +6,8 @@ export * from './process'; export * from './report'; export * from './run'; export * from './schedule'; +export * from './scoped-catalog-fixture'; +export * from './scoped-introspection-analysis'; export * from './scoped-introspection-suite'; export * from './types'; diff --git a/packages/perf-harness/src/scoped-catalog-fixture.ts b/packages/perf-harness/src/scoped-catalog-fixture.ts new file mode 100644 index 0000000000..2a91ebc051 --- /dev/null +++ b/packages/perf-harness/src/scoped-catalog-fixture.ts @@ -0,0 +1,426 @@ +import { Pool, type PoolClient } from 'pg'; + +export const SCOPED_CATALOG_FIXTURE_VERSION = 1; +export const SCOPED_CATALOG_ROOT_TABLES = 65; + +export type ScopedCatalogSize = 'small' | 'medium' | 'large'; + +export interface ScopedCatalogScale { + size: ScopedCatalogSize; + targetPgClassCount: number; + noiseTableCount: number; + noiseTypeAndFunctionCount: number; +} + +export const SCOPED_CATALOG_SCALES: Record< + ScopedCatalogSize, + ScopedCatalogScale +> = { + small: { + size: 'small', + targetPgClassCount: 650, + noiseTableCount: 1, + noiseTypeAndFunctionCount: 1, + }, + medium: { + size: 'medium', + targetPgClassCount: 5_000, + noiseTableCount: 1_100, + noiseTypeAndFunctionCount: 110, + }, + large: { + size: 'large', + targetPgClassCount: 60_000, + noiseTableCount: 14_750, + noiseTypeAndFunctionCount: 256, + }, +}; + +export interface ScopedCatalogLayout { + fixture: string; + rootSchema: string; + dependencySchema: string; + noiseSchema: string; +} + +export interface CatalogEntityCounts { + namespaces: number; + classes: number; + attributes: number; + procedures: number; + types: number; + constraints: number; + indexes: number; + ranges: number; + extensions: number; +} + +export interface PreparedScopedCatalogFixture { + fixtureVersion: number; + fixture: string; + size: ScopedCatalogSize; + targetPgClassCount: number; + database: string; + serverVersion: string; + schemas: ScopedCatalogLayout; + rootTableCount: number; + noiseTableCount: number; + noiseTypeAndFunctionCount: number; + catalogCounts: CatalogEntityCounts; + fixtureCounts: CatalogEntityCounts; +} + +export const validateScopedCatalogFixtureName = (fixture: string): string => { + if ( + !/^cperf_[a-z0-9_]+$/.test(fixture) || + fixture.length > 52 || + fixture.includes('\0') + ) { + throw new Error( + 'scoped catalog fixture must start with cperf_, use lowercase letters, digits, and underscores, and be at most 52 characters' + ); + } + return fixture; +}; + +export const parseScopedCatalogSize = (value: string): ScopedCatalogSize => { + if (value !== 'small' && value !== 'medium' && value !== 'large') { + throw new Error("scoped catalog size must be 'small', 'medium', or 'large'"); + } + return value; +}; + +export const makeScopedCatalogLayout = ( + fixture: string +): ScopedCatalogLayout => { + const valid = validateScopedCatalogFixtureName(fixture); + return { + fixture: valid, + rootSchema: `${valid}_root`, + dependencySchema: `${valid}_dep`, + noiseSchema: `${valid}_noise`, + }; +}; + +const quoteIdentifier = (identifier: string): string => + `"${identifier.replaceAll('"', '""')}"`; + +const quoteLiteral = (value: string): string => + `'${value.replaceAll("'", "''")}'`; + +const fixtureMarker = (size: ScopedCatalogSize): string => + `cperf scoped catalog fixture version ${SCOPED_CATALOG_FIXTURE_VERSION} size ${size}`; + +const rootFixtureSql = (layout: ScopedCatalogLayout): string => { + const root = quoteIdentifier(layout.rootSchema); + const dependency = quoteIdentifier(layout.dependencySchema); + const noise = quoteIdentifier(layout.noiseSchema); + return ` + CREATE SCHEMA ${root}; + CREATE SCHEMA ${dependency}; + CREATE SCHEMA ${noise}; + + CREATE TYPE ${dependency}."entity_status" AS ENUM ('draft', 'active', 'archived'); + CREATE DOMAIN ${dependency}."positive_integer" AS integer CHECK (VALUE > 0); + CREATE TYPE ${dependency}."entity_payload" AS ( + status ${dependency}."entity_status", + score ${dependency}."positive_integer" + ); + CREATE TABLE ${dependency}."owners" ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + status ${dependency}."entity_status" NOT NULL + ); + CREATE TABLE ${dependency}."inherited_base" ( + inherited_status ${dependency}."entity_status" NOT NULL + ); + + DO $fixture$ + DECLARE + item integer; + table_name text; + BEGIN + FOR item IN 1..${SCOPED_CATALOG_ROOT_TABLES} LOOP + table_name := 'entity_' || lpad(item::text, 5, '0'); + EXECUTE format( + 'CREATE TABLE %I.%I ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + owner_id bigint NOT NULL REFERENCES %I.owners(id), + status %I.entity_status NOT NULL, + score %I.positive_integer NOT NULL, + payload %I.entity_payload, + title text NOT NULL + )', + ${quoteLiteral(layout.rootSchema)}, table_name, + ${quoteLiteral(layout.dependencySchema)}, + ${quoteLiteral(layout.dependencySchema)}, + ${quoteLiteral(layout.dependencySchema)}, + ${quoteLiteral(layout.dependencySchema)} + ); + EXECUTE format( + 'CREATE INDEX %I ON %I.%I (owner_id, status)', + table_name || '_owner_status_idx', + ${quoteLiteral(layout.rootSchema)}, table_name + ); + EXECUTE format( + 'CREATE FUNCTION %I.%I(requested_status %I.entity_status) + RETURNS SETOF %I.%I LANGUAGE sql STABLE AS %L', + ${quoteLiteral(layout.rootSchema)}, table_name || '_by_status', + ${quoteLiteral(layout.dependencySchema)}, + ${quoteLiteral(layout.rootSchema)}, table_name, + format('SELECT * FROM %I.%I WHERE status = requested_status', + ${quoteLiteral(layout.rootSchema)}, table_name) + ); + END LOOP; + END + $fixture$; + + CREATE TABLE ${root}."inherited_items" ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY + ) INHERITS (${dependency}."inherited_base"); + CREATE TABLE ${root}."inheritance_root" ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + root_note text NOT NULL + ); + CREATE TABLE ${noise}."reverse_inherited_item" ( + noise_note text NOT NULL + ) INHERITS (${root}."inheritance_root"); + `; +}; + +const noiseTablesSql = ( + layout: ScopedCatalogLayout, + firstTable: number, + lastTable: number +): string => ` + DO $noise$ + DECLARE + item integer; + sequence_name text; + table_name text; + type_name text; + BEGIN + FOR item IN ${firstTable}..${lastTable} LOOP + sequence_name := 'noise_sequence_' || lpad(item::text, 5, '0'); + table_name := 'noise_table_' || lpad(item::text, 5, '0'); + EXECUTE format('CREATE SEQUENCE %I.%I', + ${quoteLiteral(layout.noiseSchema)}, sequence_name); + EXECUTE format( + 'CREATE TABLE %I.%I ( + id bigint PRIMARY KEY DEFAULT nextval(%L::regclass), + noise_value integer NOT NULL + )', + ${quoteLiteral(layout.noiseSchema)}, table_name, + ${quoteLiteral(`${layout.noiseSchema}.`)} || quote_ident(sequence_name) + ); + EXECUTE format('ALTER SEQUENCE %I.%I OWNED BY %I.%I.id', + ${quoteLiteral(layout.noiseSchema)}, sequence_name, + ${quoteLiteral(layout.noiseSchema)}, table_name); + EXECUTE format('CREATE INDEX %I ON %I.%I (noise_value)', + table_name || '_value_idx', + ${quoteLiteral(layout.noiseSchema)}, table_name); + END LOOP; + END + $noise$; +`; + +const noiseTypesAndFunctionsSql = ( + layout: ScopedCatalogLayout, + count: number +): string => ` + DO $noise_types$ + DECLARE + item integer; + type_name text; + BEGIN + FOR item IN 1..${count} LOOP + type_name := 'noise_type_' || lpad(item::text, 5, '0'); + EXECUTE format('CREATE TYPE %I.%I AS ENUM (%L, %L)', + ${quoteLiteral(layout.noiseSchema)}, type_name, 'one', 'two'); + EXECUTE format( + 'CREATE FUNCTION %I.%I(input_value %I.%I) + RETURNS %I.%I LANGUAGE sql IMMUTABLE AS %L', + ${quoteLiteral(layout.noiseSchema)}, 'noise_function_' || lpad(item::text, 5, '0'), + ${quoteLiteral(layout.noiseSchema)}, type_name, + ${quoteLiteral(layout.noiseSchema)}, type_name, + 'SELECT input_value' + ); + END LOOP; + END + $noise_types$; +`; + +const countRow = async ( + client: PoolClient, + schemas?: readonly string[] +): Promise => { + const filter = schemas + ? 'WHERE pg_namespace.nspname = ANY($1::text[])' + : ''; + const values = schemas ? [schemas] : []; + const result = await client.query( + ` + SELECT + (SELECT count(*)::integer FROM pg_catalog.pg_namespace + ${schemas ? 'WHERE nspname = ANY($1::text[])' : ''}) AS namespaces, + (SELECT count(*)::integer FROM pg_catalog.pg_class + INNER JOIN pg_catalog.pg_namespace ON pg_namespace.oid = pg_class.relnamespace + ${filter}) AS classes, + (SELECT count(*)::integer FROM pg_catalog.pg_attribute + INNER JOIN pg_catalog.pg_class ON pg_class.oid = pg_attribute.attrelid + INNER JOIN pg_catalog.pg_namespace ON pg_namespace.oid = pg_class.relnamespace + ${filter} ${schemas ? 'AND' : 'WHERE'} pg_attribute.attnum > 0) AS attributes, + (SELECT count(*)::integer FROM pg_catalog.pg_proc + INNER JOIN pg_catalog.pg_namespace ON pg_namespace.oid = pg_proc.pronamespace + ${filter}) AS procedures, + (SELECT count(*)::integer FROM pg_catalog.pg_type + INNER JOIN pg_catalog.pg_namespace ON pg_namespace.oid = pg_type.typnamespace + ${filter}) AS types, + (SELECT count(*)::integer FROM pg_catalog.pg_constraint + INNER JOIN pg_catalog.pg_namespace ON pg_namespace.oid = pg_constraint.connamespace + ${filter}) AS constraints, + (SELECT count(*)::integer FROM pg_catalog.pg_index + INNER JOIN pg_catalog.pg_class ON pg_class.oid = pg_index.indexrelid + INNER JOIN pg_catalog.pg_namespace ON pg_namespace.oid = pg_class.relnamespace + ${filter}) AS indexes, + (SELECT count(*)::integer FROM pg_catalog.pg_range + INNER JOIN pg_catalog.pg_type ON pg_type.oid = pg_range.rngtypid + INNER JOIN pg_catalog.pg_namespace ON pg_namespace.oid = pg_type.typnamespace + ${filter}) AS ranges, + (SELECT count(*)::integer FROM pg_catalog.pg_extension + INNER JOIN pg_catalog.pg_namespace ON pg_namespace.oid = pg_extension.extnamespace + ${filter}) AS extensions + `, + values + ); + const row = result.rows[0]; + if (!row) throw new Error('could not inspect PostgreSQL catalog counts'); + return row; +}; + +const inspectWithClient = async ( + client: PoolClient, + layout: ScopedCatalogLayout, + size: ScopedCatalogSize +): Promise => { + const schemaNames = [ + layout.rootSchema, + layout.dependencySchema, + layout.noiseSchema, + ]; + const identity = await client.query<{ + database: string; + server_version: string; + marker: string | null; + }>( + ` + SELECT + current_database() AS database, + current_setting('server_version') AS server_version, + pg_catalog.obj_description(pg_namespace.oid, 'pg_namespace') AS marker + FROM pg_catalog.pg_namespace + WHERE nspname = $1 + `, + [layout.rootSchema] + ); + const row = identity.rows[0]; + if (!row || row.marker !== fixtureMarker(size)) { + throw new Error( + `fixture '${layout.fixture}' is absent or does not match size '${size}' and fixture version ${SCOPED_CATALOG_FIXTURE_VERSION}` + ); + } + return { + fixtureVersion: SCOPED_CATALOG_FIXTURE_VERSION, + fixture: layout.fixture, + size, + targetPgClassCount: SCOPED_CATALOG_SCALES[size].targetPgClassCount, + database: row.database, + serverVersion: row.server_version, + schemas: layout, + rootTableCount: SCOPED_CATALOG_ROOT_TABLES + 2, + noiseTableCount: SCOPED_CATALOG_SCALES[size].noiseTableCount + 1, + noiseTypeAndFunctionCount: + SCOPED_CATALOG_SCALES[size].noiseTypeAndFunctionCount, + catalogCounts: await countRow(client), + fixtureCounts: await countRow(client, schemaNames), + }; +}; + +export const inspectScopedCatalogFixture = async (options: { + databaseUrl: string; + fixture: string; + size: ScopedCatalogSize; +}): Promise => { + const layout = makeScopedCatalogLayout(options.fixture); + const pool = new Pool({ connectionString: options.databaseUrl, max: 1 }); + const client = await pool.connect(); + try { + return await inspectWithClient(client, layout, options.size); + } finally { + client.release(); + await pool.end(); + } +}; + +export const prepareScopedCatalogFixture = async (options: { + databaseUrl: string; + fixture: string; + size: ScopedCatalogSize; +}): Promise => { + const layout = makeScopedCatalogLayout(options.fixture); + const scale = SCOPED_CATALOG_SCALES[options.size]; + const pool = new Pool({ connectionString: options.databaseUrl, max: 1 }); + const client = await pool.connect(); + try { + await client.query('begin'); + const existing = await client.query<{ nspname: string }>( + `SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname = ANY($1::text[])`, + [[layout.rootSchema, layout.dependencySchema, layout.noiseSchema]] + ); + if (existing.rows.length > 0) { + throw new Error( + `fixture '${layout.fixture}' cannot be prepared because schema '${existing.rows[0].nspname}' already exists; this command never replaces or drops schemas` + ); + } + await client.query(rootFixtureSql(layout)); + await client.query('commit'); + const noiseBatchSize = 250; + for ( + let firstTable = 1; + firstTable <= scale.noiseTableCount; + firstTable += noiseBatchSize + ) { + const lastTable = Math.min( + scale.noiseTableCount, + firstTable + noiseBatchSize - 1 + ); + await client.query('begin'); + await client.query(noiseTablesSql(layout, firstTable, lastTable)); + await client.query('commit'); + } + await client.query('begin'); + await client.query( + noiseTypesAndFunctionsSql(layout, scale.noiseTypeAndFunctionCount) + ); + for (const schema of [ + layout.rootSchema, + layout.dependencySchema, + layout.noiseSchema, + ]) { + await client.query( + `COMMENT ON SCHEMA ${quoteIdentifier(schema)} IS ${quoteLiteral(fixtureMarker(options.size))}` + ); + } + await client.query('commit'); + return await inspectWithClient(client, layout, options.size); + } catch (error) { + try { + await client.query('rollback'); + } catch { + // Preserve the preparation error; the client is discarded below. + } + throw error; + } finally { + client.release(); + await pool.end(); + } +}; diff --git a/packages/perf-harness/src/scoped-catalog.ts b/packages/perf-harness/src/scoped-catalog.ts new file mode 100644 index 0000000000..db6c818341 --- /dev/null +++ b/packages/perf-harness/src/scoped-catalog.ts @@ -0,0 +1,217 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { parseValueArgs, redactSecret } from './process'; +import { runBenchmarkSuite, writeJsonAtomically } from './run'; +import { + inspectScopedCatalogFixture, + parseScopedCatalogSize, + prepareScopedCatalogFixture, +} from './scoped-catalog-fixture'; +import { analyzeScopedIntrospectionReports } from './scoped-introspection-analysis'; +import { + makeScopedIntrospectionQuerySuite, + makeScopedIntrospectionSuite, +} from './scoped-introspection-suite'; +import type { BenchmarkReport } from './types'; + +const required = ( + values: Map, + name: string +): string => { + const value = values.get(name); + if (!value) throw new Error(`--${name} is required`); + return value; +}; + +const positiveInteger = ( + value: string | undefined, + name: string, + defaultValue: number, + maximum: number +): number => { + if (value === undefined) return defaultValue; + if (!/^\d+$/.test(value)) throw new Error(`--${name} must be an integer`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) { + throw new Error(`--${name} must be between 1 and ${maximum}`); + } + return parsed; +}; + +const sha256File = async (file: string): Promise => + createHash('sha256').update(await readFile(file)).digest('hex'); + +const assertCredentialFree = (text: string, databaseUrl: string): void => { + if (text.includes(databaseUrl)) { + throw new Error('benchmark artifact contains the database URL'); + } + try { + const password = new URL(databaseUrl).password; + if (password && text.includes(password)) { + throw new Error('benchmark artifact contains the database password'); + } + } catch (error) { + if (error instanceof Error && error.message.includes('artifact contains')) { + throw error; + } + // PostgreSQL accepts connection strings that are not URL-shaped. + } +}; + +export const runScopedCatalogMatrix = async (options: { + databaseUrl: string; + fixture: string; + size: 'small' | 'medium' | 'large'; + repetitions: number; + seed: number; + outputDirectory: string; +}) => { + const fixture = await inspectScopedCatalogFixture(options); + const outputDirectory = resolve(options.outputDirectory); + const workerOptions = { + schemas: [fixture.schemas.rootSchema], + allowedDependencySchemas: [fixture.schemas.dependencySchema], + noiseSchemas: [fixture.schemas.noiseSchema], + }; + const buildReports = {} as Record<'off' | 'on', BenchmarkReport>; + const queryReports = {} as Record<'off' | 'on', BenchmarkReport>; + const reportFiles: Record = {}; + for (const jit of ['off', 'on'] as const) { + const introspectionJit = jit === 'on'; + buildReports[jit] = await runBenchmarkSuite( + makeScopedIntrospectionSuite({ ...workerOptions, introspectionJit }), + { + databaseUrl: options.databaseUrl, + repetitions: options.repetitions, + seed: options.seed, + order: null, + }, + resolve(__dirname, 'scoped-introspection-worker.js') + ); + queryReports[jit] = await runBenchmarkSuite( + makeScopedIntrospectionQuerySuite({ ...workerOptions, introspectionJit }), + { + databaseUrl: options.databaseUrl, + repetitions: options.repetitions, + seed: options.seed, + order: null, + }, + resolve(__dirname, 'scoped-introspection-query-worker.js') + ); + reportFiles[`build-jit-${jit}`] = await writeJsonAtomically( + resolve(outputDirectory, `build-jit-${jit}.json`), + buildReports[jit] + ); + reportFiles[`query-jit-${jit}`] = await writeJsonAtomically( + resolve(outputDirectory, `query-jit-${jit}.json`), + queryReports[jit] + ); + } + const fixtureFile = await writeJsonAtomically( + resolve(outputDirectory, 'fixture.json'), + fixture + ); + const rawReportSha256 = Object.fromEntries( + await Promise.all( + Object.entries(reportFiles).map(async ([name, file]) => [ + name, + await sha256File(file), + ]) + ) + ); + const analysis = analyzeScopedIntrospectionReports({ + fixture, + buildReports, + queryReports, + rawReportSha256, + }); + const analysisFile = await writeJsonAtomically( + resolve(outputDirectory, 'analysis.json'), + analysis + ); + for (const file of [fixtureFile, ...Object.values(reportFiles), analysisFile]) { + assertCredentialFree(await readFile(file, 'utf8'), options.databaseUrl); + } + return { + fixture, + outputDirectory, + analysisFile, + reportFiles, + rawReportSha256, + validationPassed: + analysis.validation.errors.length === 0 && + Object.values(analysis.jit).every( + (jit) => jit.validationErrors.length === 0 + ), + }; +}; + +export const scopedCatalogCliMain = async ( + args = process.argv.slice(2) +): Promise => { + const [command, ...rest] = args; + const parsed = parseValueArgs(rest); + const databaseUrl = required(parsed.values, 'database-url'); + const fixture = required(parsed.values, 'fixture'); + const size = parseScopedCatalogSize(required(parsed.values, 'size')); + if (command === 'prepare') { + const prepared = await prepareScopedCatalogFixture({ + databaseUrl, + fixture, + size, + }); + process.stdout.write(`${JSON.stringify(prepared)}\n`); + return; + } + if (command !== 'run') { + throw new Error("expected 'prepare' or 'run' command"); + } + const result = await runScopedCatalogMatrix({ + databaseUrl, + fixture, + size, + repetitions: positiveInteger( + parsed.values.get('repetitions'), + 'repetitions', + 10, + 50 + ), + seed: positiveInteger( + parsed.values.get('seed'), + 'seed', + 20260819, + 0xffffffff + ), + outputDirectory: + parsed.values.get('output-directory') ?? + resolve('packages/perf-harness/artifacts', fixture), + }); + process.stdout.write( + `${JSON.stringify({ + outputDirectory: result.outputDirectory, + analysisFile: result.analysisFile, + validationPassed: result.validationPassed, + })}\n` + ); + if (!result.validationPassed) process.exitCode = 1; +}; + +if (require.main === module) { + void scopedCatalogCliMain().catch((error: unknown) => { + const args = process.argv.slice(2); + const databaseUrlIndex = args.indexOf('--database-url'); + const databaseUrl = + databaseUrlIndex >= 0 ? (args[databaseUrlIndex + 1] ?? '') : ''; + process.stderr.write( + `${redactSecret( + error instanceof Error ? error.message : String(error), + databaseUrl + )}\n` + ); + process.exitCode = 1; + }); +} diff --git a/packages/perf-harness/src/scoped-introspection-analysis.ts b/packages/perf-harness/src/scoped-introspection-analysis.ts new file mode 100644 index 0000000000..619043a7e4 --- /dev/null +++ b/packages/perf-harness/src/scoped-introspection-analysis.ts @@ -0,0 +1,286 @@ +import type { PreparedScopedCatalogFixture } from './scoped-catalog-fixture'; +import type { + BenchmarkReport, + BenchmarkRun, + JsonValue, + SuccessfulWorkerResult, +} from './types'; + +export interface DistributionSummary { + sampleCount: number; + p50: number; + p95: number; + min: number; + max: number; +} + +export interface PairedMetricAnalysis { + status: 'ok' | 'unavailable' | 'insufficient'; + reason: string | null; + stock: DistributionSummary | null; + scoped: DistributionSummary | null; + pairedDifferences: Array<{ + repetition: number; + stock: number; + scoped: number; + difference: number; + percentChange: number | null; + }>; + pairedMedianPercentChange: number | null; +} + +type MetricExtractor = (result: SuccessfulWorkerResult) => number | undefined; + +const percentile = (values: readonly number[], probability: number): number => { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.max(0, Math.ceil(sorted.length * probability) - 1); + return sorted[index]; +}; + +const summary = (values: readonly number[]): DistributionSummary => ({ + sampleCount: values.length, + p50: percentile(values, 0.5), + p95: percentile(values, 0.95), + min: Math.min(...values), + max: Math.max(...values), +}); + +const successfulByCase = ( + runs: readonly BenchmarkRun[], + caseName: string +): Map => + new Map( + runs.flatMap((run) => + run.caseName === caseName && run.result.status === 'ok' + ? [[run.repetition, run.result] as const] + : [] + ) + ); + +export const analyzePairedMetric = ( + report: BenchmarkReport, + extractor: MetricExtractor +): PairedMetricAnalysis => { + const stockResults = successfulByCase(report.runs, 'stock'); + const scopedResults = successfulByCase(report.runs, 'scoped'); + const repetitions = Array.from( + { length: report.config.repetitions }, + (_value, index) => index + 1 + ); + const pairs = repetitions.flatMap((repetition) => { + const stockResult = stockResults.get(repetition); + const scopedResult = scopedResults.get(repetition); + if (!stockResult || !scopedResult) return []; + const stock = extractor(stockResult); + const scoped = extractor(scopedResult); + if (stock === undefined || scoped === undefined) return []; + return [ + { + repetition, + stock, + scoped, + difference: scoped - stock, + percentChange: + stock === 0 ? null : ((scoped - stock) / stock) * 100, + }, + ]; + }); + const stockValues = pairs.map((pair) => pair.stock); + const scopedValues = pairs.map((pair) => pair.scoped); + const changes = pairs.flatMap((pair) => + pair.percentChange === null ? [] : [pair.percentChange] + ); + if (pairs.length === 0) { + return { + status: 'unavailable', + reason: 'no complete successful stock/scoped repetition contained this metric', + stock: null, + scoped: null, + pairedDifferences: [], + pairedMedianPercentChange: null, + }; + } + const complete = pairs.length === report.config.repetitions; + return { + status: complete ? 'ok' : 'insufficient', + reason: complete + ? null + : `expected ${report.config.repetitions} paired samples, found ${pairs.length}`, + stock: summary(stockValues), + scoped: summary(scopedValues), + pairedDifferences: pairs, + pairedMedianPercentChange: + changes.length === pairs.length ? percentile(changes, 0.5) : null, + }; +}; + +const metadataNumber = (path: readonly string[]): MetricExtractor => + (result) => { + let value: JsonValue | undefined = result.metadata; + for (const key of path) { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) + ) { + return undefined; + } + value = value[key]; + } + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; + }; + +const buildMetricExtractors: Record = { + buildMs: (result) => result.buildMs, + heapUsedAfterBuild: (result) => result.memory.afterBuild.heapUsed, + heapUsedDelta: (result) => result.memory.delta.heapUsed, + rssAfterBuild: (result) => result.memory.afterBuild.rss, + rssDelta: (result) => result.memory.delta.rss, + processPeakRss: (result) => result.memory.processPeakRss, +}; + +const queryMetricExtractors: Record = { + queryMs: (result) => result.buildMs, + payloadBytes: metadataNumber(['payloadBytes']), + bindParameterCount: metadataNumber(['bindParameterCount']), + namespaces: metadataNumber(['introspectionEntityCounts', 'namespaces']), + classes: metadataNumber(['introspectionEntityCounts', 'classes']), + attributes: metadataNumber(['introspectionEntityCounts', 'attributes']), + procedures: metadataNumber(['introspectionEntityCounts', 'procedures']), + types: metadataNumber(['introspectionEntityCounts', 'types']), + constraints: metadataNumber(['introspectionEntityCounts', 'constraints']), + indexes: metadataNumber(['introspectionEntityCounts', 'indexes']), + ranges: metadataNumber(['introspectionEntityCounts', 'ranges']), + extensions: metadataNumber(['introspectionEntityCounts', 'extensions']), +}; + +const analyzeMetrics = ( + report: BenchmarkReport, + extractors: Record +): Record => + Object.fromEntries( + Object.entries(extractors).map(([name, extractor]) => [ + name, + analyzePairedMetric(report, extractor), + ]) + ); + +const successfulResults = ( + report: BenchmarkReport +): SuccessfulWorkerResult[] => + report.runs.flatMap((run) => + run.result.status === 'ok' ? [run.result] : [] + ); + +const metadataStrings = ( + report: BenchmarkReport, + key: string +): string[] => + [ + ...new Set( + successfulResults(report).flatMap((result) => { + const value = result.metadata?.[key]; + return typeof value === 'string' ? [value] : []; + }) + ), + ]; + +const validationErrors = ( + build: BenchmarkReport, + query: BenchmarkReport +): string[] => { + const errors = [ + ...build.validation.errors.map((error) => `build: ${error}`), + ...query.validation.errors.map((error) => `query: ${error}`), + ]; + if (!build.validation.allRunsSucceeded) errors.push('build runs did not all succeed'); + if (!query.validation.allRunsSucceeded) errors.push('query runs did not all succeed'); + if (!build.validation.freshProcessPerRun) errors.push('build PIDs were not all unique'); + if (!query.validation.freshProcessPerRun) errors.push('query PIDs were not all unique'); + if (!build.validation.schemaGroupsEquivalent) errors.push('schema hashes differ'); + if (!successfulResults(build).every((result) => result.runtimeVerified)) { + errors.push('one or more build runtime validations did not pass'); + } + return [...new Set(errors)]; +}; + +export const analyzeScopedIntrospectionReports = (input: { + fixture: PreparedScopedCatalogFixture; + buildReports: Record<'off' | 'on', BenchmarkReport>; + queryReports: Record<'off' | 'on', BenchmarkReport>; + rawReportSha256?: Record; +}) => { + const reports = [ + input.buildReports.off, + input.queryReports.off, + input.buildReports.on, + input.queryReports.on, + ]; + const pids = reports.flatMap((report) => + successfulResults(report).map((result) => result.pid) + ); + const matrixValidationErrors = + new Set(pids).size === pids.length + ? [] + : ['worker PIDs were not unique across the complete build/query matrix']; + const jitAnalysis = Object.fromEntries( + (['off', 'on'] as const).map((jit) => { + const build = input.buildReports[jit]; + const query = input.queryReports[jit]; + return [ + jit, + { + expectedJit: jit, + observedBuildJit: metadataStrings(build, 'actualJit'), + observedQueryJit: metadataStrings(query, 'actualJit'), + sampleCount: { + build: { + stock: build.runs.filter( + (run) => run.caseName === 'stock' && run.result.status === 'ok' + ).length, + scoped: build.runs.filter( + (run) => run.caseName === 'scoped' && run.result.status === 'ok' + ).length, + }, + query: { + stock: query.runs.filter( + (run) => run.caseName === 'stock' && run.result.status === 'ok' + ).length, + scoped: query.runs.filter( + (run) => run.caseName === 'scoped' && run.result.status === 'ok' + ).length, + }, + }, + schemaHash: + build.validation.schemaGroups['introspection-equivalence'] ?? null, + runtimeValidationPassed: successfulResults(build).every( + (result) => result.runtimeVerified && result.caseValidation.passed + ), + validationErrors: validationErrors(build, query), + build: analyzeMetrics(build, buildMetricExtractors), + query: analyzeMetrics(query, queryMetricExtractors), + }, + ]; + }) + ); + return { + format: 'constructive-scoped-introspection-analysis/v1' as const, + generatedAt: new Date().toISOString(), + catalogWarmth: 'shared-server-not-reset' as const, + fixture: input.fixture, + environment: { + node: input.buildReports.off.node, + platform: input.buildReports.off.platform, + architecture: input.buildReports.off.architecture, + postgres: input.fixture.serverVersion, + }, + rawReportSha256: input.rawReportSha256 ?? {}, + validation: { + allWorkerPidsUnique: matrixValidationErrors.length === 0, + errors: matrixValidationErrors, + }, + jit: jitAnalysis, + }; +}; diff --git a/packages/perf-harness/src/scoped-introspection-query-worker.ts b/packages/perf-harness/src/scoped-introspection-query-worker.ts new file mode 100644 index 0000000000..bb8d35d786 --- /dev/null +++ b/packages/perf-harness/src/scoped-introspection-query-worker.ts @@ -0,0 +1,223 @@ +import { createHash } from 'node:crypto'; + +import { + makeIntrospectionQuery, + makeSchemaScopedIntrospectionQuery, +} from 'graphile-scoped-introspection'; +import { Pool } from 'pg'; + +import { measureBenchmarkCase } from './metrics'; +import { + parseWorkerProcessArgs, + redactSecret, + writeWorkerResult, +} from './process'; + +interface QueryWorkerConfig { + scopedIntrospection: boolean; + introspectionJit: boolean; + schemas: string[]; + allowedDependencySchemas: string[]; + noiseSchemas: string[]; +} + +interface IntrospectionEntityCounts { + [key: string]: number; + namespaces: number; + classes: number; + attributes: number; + procedures: number; + types: number; + constraints: number; + indexes: number; + ranges: number; + extensions: number; +} + +const stringArray = (value: unknown, name: string, allowEmpty: boolean) => { + if ( + !Array.isArray(value) || + (!allowEmpty && value.length === 0) || + value.some((item) => typeof item !== 'string' || item.length === 0) + ) { + throw new Error( + `scoped query worker requires ${allowEmpty ? 'a' : 'a non-empty'} ${name} array` + ); + } + return value as string[]; +}; + +export const validateQueryWorkerConfig = ( + value: unknown +): QueryWorkerConfig => { + const config = value as Partial; + if (typeof config.scopedIntrospection !== 'boolean') { + throw new Error('scoped query worker requires a scopedIntrospection boolean'); + } + if (typeof config.introspectionJit !== 'boolean') { + throw new Error('scoped query worker requires an introspectionJit boolean'); + } + return { + scopedIntrospection: config.scopedIntrospection, + introspectionJit: config.introspectionJit, + schemas: stringArray(config.schemas, 'schemas', false), + allowedDependencySchemas: stringArray( + config.allowedDependencySchemas, + 'allowedDependencySchemas', + true + ), + noiseSchemas: stringArray(config.noiseSchemas, 'noiseSchemas', true), + }; +}; + +const countArray = ( + value: Record, + key: string +): number => { + const items = value[key]; + if (!Array.isArray(items)) { + throw new Error(`introspection query result field '${key}' is not an array`); + } + return items.length; +}; + +export const parseIntrospectionEntityCounts = ( + value: unknown +): IntrospectionEntityCounts => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('introspection query result is not an object'); + } + const result = value as Record; + return { + namespaces: countArray(result, 'namespaces'), + classes: countArray(result, 'classes'), + attributes: countArray(result, 'attributes'), + procedures: countArray(result, 'procs'), + types: countArray(result, 'types'), + constraints: countArray(result, 'constraints'), + indexes: countArray(result, 'indexes'), + ranges: countArray(result, 'ranges'), + extensions: countArray(result, 'extensions'), + }; +}; + +const namespaceNames = (value: unknown): string[] => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('introspection query result is not an object'); + } + const namespaces = (value as Record).namespaces; + if (!Array.isArray(namespaces)) { + throw new Error("introspection query result field 'namespaces' is not an array"); + } + return namespaces.map((namespace) => { + if ( + typeof namespace !== 'object' || + namespace === null || + Array.isArray(namespace) || + typeof (namespace as Record).nspname !== 'string' + ) { + throw new Error('introspection namespace result is invalid'); + } + return (namespace as Record).nspname; + }); +}; + +const main = async (): Promise => { + let databaseUrl = ''; + let caseName = 'unknown'; + let pool: Pool | undefined; + try { + const workerArgs = parseWorkerProcessArgs(process.argv.slice(2)); + databaseUrl = workerArgs.databaseUrl; + caseName = workerArgs.envelope.caseName; + const config = validateQueryWorkerConfig( + workerArgs.envelope.workerConfig + ); + const query = config.scopedIntrospection + ? makeSchemaScopedIntrospectionQuery(config.schemas, { + catalogTypes: 'dependency-closure', + }) + : { text: makeIntrospectionQuery(), values: [] as unknown[] }; + const expectedJit = config.introspectionJit ? 'on' : 'off'; + pool = new Pool({ connectionString: databaseUrl, max: 1 }); + const client = await pool.connect(); + try { + await client.query('begin'); + await client.query(`set local jit to ${expectedJit}`); + const setting = await client.query<{ jit: string }>('show jit'); + const actualJit = setting.rows[0]?.jit; + const result = await measureBenchmarkCase( + caseName, + async () => { + const response = await client.query<{ introspection: string }>(query); + const introspectionText = response.rows[0]?.introspection; + if (typeof introspectionText !== 'string') { + throw new Error('introspection query did not return JSON text'); + } + return introspectionText; + }, + async (introspectionText) => { + const parsed: unknown = JSON.parse(introspectionText); + const names = namespaceNames(parsed); + const validationErrors = [ + ...config.schemas + .filter((schemaName) => !names.includes(schemaName)) + .map((schemaName) => `missing root schema '${schemaName}'`), + ...config.allowedDependencySchemas + .filter((schemaName) => !names.includes(schemaName)) + .map((schemaName) => `missing dependency schema '${schemaName}'`), + ...(config.scopedIntrospection + ? config.noiseSchemas + .filter((schemaName) => names.includes(schemaName)) + .map((schemaName) => `retained noise schema '${schemaName}'`) + : []), + ...(actualJit === expectedJit + ? [] + : [`expected introspection JIT '${expectedJit}', received '${String(actualJit)}'`]), + ]; + const counts = parseIntrospectionEntityCounts(parsed); + return { + schemaHash: createHash('sha256') + .update(introspectionText) + .digest('hex'), + schemaTypeCount: counts.types, + runtimeVerified: true as const, + caseValidation: { + passed: validationErrors.length === 0, + errors: validationErrors, + }, + metadata: { + scopedIntrospection: config.scopedIntrospection, + introspectionJit: config.introspectionJit, + actualJit: actualJit ?? 'unavailable', + catalogWarmth: 'shared-server-not-reset', + payloadBytes: Buffer.byteLength(introspectionText, 'utf8'), + bindParameterCount: query.values.length, + introspectionEntityCounts: counts, + introspectionNamespaces: names, + }, + }; + } + ); + await client.query('rollback'); + writeWorkerResult(result); + } finally { + client.release(); + } + } catch (error) { + writeWorkerResult({ + status: 'error', + pid: process.pid, + caseName, + error: redactSecret( + error instanceof Error ? error.message : String(error), + databaseUrl + ), + }); + process.exitCode = 1; + } finally { + await pool?.end(); + } +}; + +if (require.main === module) void main(); diff --git a/packages/perf-harness/src/scoped-introspection-suite.ts b/packages/perf-harness/src/scoped-introspection-suite.ts index 803551e24a..fad9b158d1 100644 --- a/packages/perf-harness/src/scoped-introspection-suite.ts +++ b/packages/perf-harness/src/scoped-introspection-suite.ts @@ -2,23 +2,58 @@ import type { BenchmarkSuiteDefinition } from './types'; export interface ScopedIntrospectionSuiteOptions { schemas: string[]; + allowedDependencySchemas?: string[]; + noiseSchemas?: string[]; + introspectionJit?: boolean; } /** 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: { scopedIntrospection: false, schemas: options.schemas }, - expectedSchemaGroup: 'introspection-equivalence', - }, - { - name: 'scoped', - workerConfig: { scopedIntrospection: true, schemas: options.schemas }, - expectedSchemaGroup: 'introspection-equivalence', - }, - ], -}); +): BenchmarkSuiteDefinition => { + const workerConfig = { + schemas: options.schemas, + allowedDependencySchemas: options.allowedDependencySchemas ?? [], + noiseSchemas: options.noiseSchemas ?? [], + introspectionJit: options.introspectionJit ?? false, + }; + return { + name: `scoped-introspection-jit-${workerConfig.introspectionJit ? 'on' : 'off'}`, + cases: [ + { + name: 'stock', + workerConfig: { ...workerConfig, scopedIntrospection: false }, + expectedSchemaGroup: 'introspection-equivalence', + }, + { + name: 'scoped', + workerConfig: { ...workerConfig, scopedIntrospection: true }, + expectedSchemaGroup: 'introspection-equivalence', + }, + ], + }; +}; + +export const makeScopedIntrospectionQuerySuite = ( + options: ScopedIntrospectionSuiteOptions +): BenchmarkSuiteDefinition => { + const workerConfig = { + schemas: options.schemas, + allowedDependencySchemas: options.allowedDependencySchemas ?? [], + noiseSchemas: options.noiseSchemas ?? [], + introspectionJit: options.introspectionJit ?? false, + }; + return { + name: `scoped-introspection-query-jit-${workerConfig.introspectionJit ? 'on' : 'off'}`, + cases: [ + { + name: 'stock', + workerConfig: { ...workerConfig, scopedIntrospection: false }, + }, + { + name: 'scoped', + workerConfig: { ...workerConfig, scopedIntrospection: true }, + }, + ], + }; +}; diff --git a/packages/perf-harness/src/scoped-introspection-worker.ts b/packages/perf-harness/src/scoped-introspection-worker.ts index 3e15304798..02f970e83f 100644 --- a/packages/perf-harness/src/scoped-introspection-worker.ts +++ b/packages/perf-harness/src/scoped-introspection-worker.ts @@ -1,11 +1,16 @@ import { createHash } from 'node:crypto'; +import { withPgClientFromPgService } from '@dataplan/pg'; import { defaultPreset as graphileBuildPreset, makeSchema, } from 'graphile-build'; import { defaultPreset as graphileBuildPgPreset } from 'graphile-build-pg'; -import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection'; +import type { GraphileConfig } from 'graphile-config'; +import { + type Introspection, + ScopedIntrospectionPreset, +} from 'graphile-scoped-introspection'; import { makeScopedPgService } from 'graphile-settings'; import { execute, lexicographicSortSchema, parse, printSchema } from 'graphql'; import { makePgService as makePostGraphilePgService } from 'postgraphile/adaptors/pg'; @@ -19,9 +24,25 @@ import { interface ScopedWorkerConfig { scopedIntrospection: boolean; + introspectionJit: boolean; schemas: string[]; + allowedDependencySchemas: string[]; + noiseSchemas: string[]; } +const stringArray = (value: unknown, name: string, allowEmpty: boolean) => { + if ( + !Array.isArray(value) || + (!allowEmpty && value.length === 0) || + value.some((item) => typeof item !== 'string' || item.length === 0) + ) { + throw new Error( + `scoped introspection worker requires ${allowEmpty ? 'a' : 'a non-empty'} ${name} array` + ); + } + return value as string[]; +}; + const validateConfig = (value: unknown): ScopedWorkerConfig => { const config = value as Partial; if (typeof config.scopedIntrospection !== 'boolean') { @@ -29,23 +50,52 @@ const validateConfig = (value: unknown): ScopedWorkerConfig => { 'scoped introspection worker requires a scopedIntrospection boolean' ); } - if ( - !Array.isArray(config.schemas) || - config.schemas.length === 0 || - config.schemas.some( - (schema) => typeof schema !== 'string' || schema.length === 0 - ) - ) { - throw new Error( - 'scoped introspection worker requires a non-empty schemas array' - ); + if (typeof config.introspectionJit !== 'boolean') { + throw new Error('scoped introspection worker requires an introspectionJit boolean'); } return { scopedIntrospection: config.scopedIntrospection, - schemas: config.schemas, + introspectionJit: config.introspectionJit, + schemas: stringArray(config.schemas, 'schemas', false), + allowedDependencySchemas: stringArray( + config.allowedDependencySchemas, + 'allowedDependencySchemas', + true + ), + noiseSchemas: stringArray(config.noiseSchemas, 'noiseSchemas', true), }; }; +const entityCounts = (introspection: Introspection) => ({ + namespaces: introspection.namespaces.length, + classes: introspection.classes.length, + attributes: introspection.attributes.length, + procedures: introspection.procs.length, + types: introspection.types.length, + constraints: introspection.constraints.length, + indexes: introspection.indexes.length, + ranges: introspection.ranges.length, + extensions: introspection.extensions.length, +}); + +const makeCapturePlugin = ( + capture: (introspection: Introspection) => void +): GraphileConfig.Plugin => + ({ + name: 'ScopedIntrospectionBenchmarkCapturePlugin', + gather: { + namespace: 'scopedIntrospectionBenchmarkCapture', + hooks: { + pgIntrospection_introspection( + _info: unknown, + event: { introspection: unknown } + ) { + capture(event.introspection as unknown as Introspection); + }, + }, + }, + }) as unknown as GraphileConfig.Plugin; + const main = async (): Promise => { let databaseUrl = ''; let caseName = 'unknown'; @@ -56,6 +106,8 @@ const main = async (): Promise => { const { envelope } = workerArgs; caseName = envelope.caseName; const config = validateConfig(envelope.workerConfig); + let introspection: Introspection | undefined; + const expectedJit = config.introspectionJit ? 'on' : 'off'; const serviceOptions = { connectionString: databaseUrl, schemas: config.schemas, @@ -64,11 +116,16 @@ const main = async (): Promise => { const scopedServiceOptions = { ...serviceOptions, introspectionScopedCatalogTypes: 'dependency-closure' as const, + introspectionAllowedDependencySchemas: config.allowedDependencySchemas, + introspectionJit: config.introspectionJit, }; const service = config.scopedIntrospection ? makeScopedPgService(scopedServiceOptions) - : makePostGraphilePgService(serviceOptions); + : makePostGraphilePgService({ + ...serviceOptions, + pgSettingsForIntrospection: { jit: expectedJit }, + }); release = async () => { await service.release(); }; @@ -84,6 +141,11 @@ const main = async (): Promise => { ? [ScopedIntrospectionPreset] : []), ], + plugins: [ + makeCapturePlugin((value) => { + introspection = value; + }), + ], pgServices: [service], }), async ({ schema }) => { @@ -97,12 +159,54 @@ const main = async (): Promise => { ) { throw new Error('runtime verification query failed'); } + if (!introspection) { + throw new Error('introspection lifecycle event was not emitted'); + } + const actualJit = await withPgClientFromPgService( + service, + service.pgSettingsForIntrospection ?? null, + async (client) => { + const setting = await client.query<{ jit: string }>({ + text: 'show jit', + }); + return setting.rows[0]?.jit; + } + ); + const namespaceNames = introspection.namespaces.map( + (namespace) => namespace.nspname + ); + const validationErrors = [ + ...config.schemas + .filter((schemaName) => !namespaceNames.includes(schemaName)) + .map((schemaName) => `missing root schema '${schemaName}'`), + ...config.allowedDependencySchemas + .filter((schemaName) => !namespaceNames.includes(schemaName)) + .map((schemaName) => `missing dependency schema '${schemaName}'`), + ...(config.scopedIntrospection + ? config.noiseSchemas + .filter((schemaName) => namespaceNames.includes(schemaName)) + .map((schemaName) => `retained noise schema '${schemaName}'`) + : []), + ...(actualJit === expectedJit + ? [] + : [`expected introspection JIT '${expectedJit}', received '${String(actualJit)}'`]), + ]; const schemaText = printSchema(lexicographicSortSchema(schema)); return { schemaHash: createHash('sha256').update(schemaText).digest('hex'), schemaTypeCount: Object.keys(schema.getTypeMap()).length, runtimeVerified: true as const, - metadata: { scopedIntrospection: config.scopedIntrospection }, + caseValidation: { + passed: validationErrors.length === 0, + errors: validationErrors, + }, + metadata: { + scopedIntrospection: config.scopedIntrospection, + introspectionJit: config.introspectionJit, + actualJit: actualJit ?? 'unavailable', + introspectionEntityCounts: entityCounts(introspection), + introspectionNamespaces: namespaceNames, + }, }; } ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a42389365..30d48b8b76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2658,6 +2658,9 @@ importers: packages/perf-harness: dependencies: + '@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) From 3493107f88bbb212cd12e8ab172779e0bd7aa7a5 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 20 Aug 2026 16:32:22 +0800 Subject: [PATCH 12/12] Use gather options for scoped introspection --- .../graphile-scoped-introspection/README.md | 4 +- .../dependency-closure.integration.test.ts | 25 +++--- ...oped-introspection-cache-lifecycle.test.ts | 7 +- .../scoped-introspection-mixed.test.ts | 19 +++-- .../scoped-introspection-runtime.test.ts | 44 +++++------ ...ped-introspection-service-contract.test.ts | 51 +++++-------- .../src/index.ts | 2 +- .../PgIntrospectionPlugin.ts | 66 ++++++++-------- .../graphile-build-pg/scopedOptions.ts | 31 ++++++-- graphile/graphile-settings/README.md | 22 ++++-- .../scoped-introspection-wiring.test.ts | 76 +++---------------- graphile/graphile-settings/src/index.ts | 12 --- .../src/scoped-introspection-service.ts | 52 ------------- .../__tests__/graphile-introspection.test.ts | 23 ++++-- .../src/middleware/graphile-introspection.ts | 40 +++++++--- graphql/types/src/graphile.ts | 11 --- graphql/types/src/index.ts | 3 +- .../src/scoped-introspection-worker.ts | 28 +++---- 18 files changed, 220 insertions(+), 296 deletions(-) delete mode 100644 graphile/graphile-settings/src/scoped-introspection-service.ts diff --git a/graphile/graphile-scoped-introspection/README.md b/graphile/graphile-scoped-introspection/README.md index 210631a551..32fc081c63 100644 --- a/graphile/graphile-scoped-introspection/README.md +++ b/graphile/graphile-scoped-introspection/README.md @@ -12,8 +12,8 @@ provenance and licensing. `ScopedIntrospectionPreset` atomically disables the stock `PgIntrospectionPlugin` and installs `PgScopedIntrospectionPlugin`. The -replacement preserves the stock query unless a PG service explicitly selects -`scopedIntrospection: true`. Scoped services use a parameterized +replacement preserves the stock query unless `gather.pgScopedIntrospection` +contains an entry for the PG service name. Scoped services use a parameterized catalog query, validate required and allowed namespaces after parsing, and can apply dependency-closure validation to retained PostgreSQL types. diff --git a/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts b/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts index e807698a33..f125e122e0 100644 --- a/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/dependency-closure.integration.test.ts @@ -62,17 +62,7 @@ const buildSchema = async ( rootSchema = ROOT_SCHEMA ): Promise => { let introspection: Introspection | undefined; - const service = Object.assign( - makePgService({ pool, schemas: [rootSchema] }), - scoped - ? { - scopedIntrospection: true as const, - introspectionAllowedDependencySchemas: allowedDependencySchemas, - introspectionScopedCatalogTypes: 'dependency-closure' as const, - introspectionCapabilityExtensions: ['pg_trgm'], - } - : {} - ); + const service = makePgService({ pool, schemas: [rootSchema] }); try { const result = await makeSchema({ @@ -81,6 +71,19 @@ const buildSchema = async ( graphileBuildPgPreset, ...(scoped ? [ScopedIntrospectionPreset] : []), ], + ...(scoped + ? { + gather: { + pgScopedIntrospection: { + [service.name]: { + allowedDependencySchemas, + catalogTypes: 'dependency-closure' as const, + capabilityExtensions: ['pg_trgm'], + }, + }, + }, + } + : {}), plugins: [ makeCapturePlugin( scoped 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 index 2cf393b387..8054fd7d31 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-cache-lifecycle.test.ts @@ -172,8 +172,6 @@ describe('scoped introspection raw-text lifecycle', () => { const pgService = { name: 'main', schemas: [SCHEMA], - scopedIntrospection: true, - introspectionAllowedDependencySchemas: [] as readonly string[], adaptor, adaptorSettings: {}, withPgClientKey: 'withPgClient', @@ -185,6 +183,11 @@ describe('scoped introspection raw-text lifecycle', () => { { plugins: [capturingIntrospectionPlugin, observerPlugin], pgServices: [pgService as never], + gather: { + pgScopedIntrospection: { + main: { allowedDependencySchemas: [] }, + }, + }, }, undefined, (input, error) => { diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts index 6d17ab377a..03f6cfcc46 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-mixed.test.ts @@ -74,7 +74,6 @@ const introspectionText = (schema: string): string => const makeService = ( name: string, schema: string, - scopedIntrospection: boolean, queries: Array<{ text: string; values?: unknown[] }> ): never => { const query = jest.fn(async (input: { text: string; values?: unknown[] }) => { @@ -91,7 +90,6 @@ const makeService = ( return { name, schemas: [schema], - scopedIntrospection, adaptor: { createWithPgClient: jest.fn(async () => withPgClient), }, @@ -127,9 +125,14 @@ describe('mixed stock/scoped introspection services', () => { const output = await gather({ plugins: [PgScopedIntrospectionPlugin, observer], pgServices: [ - makeService('stock', 'stock_schema', false, queries), - makeService('scoped', 'scoped_schema', true, queries), + makeService('stock', 'stock_schema', queries), + makeService('scoped', 'scoped_schema', queries), ], + gather: { + pgScopedIntrospection: { + scoped: {}, + }, + }, }); expect(output).toMatchObject({ @@ -180,7 +183,7 @@ describe('mixed stock/scoped introspection services', () => { extends: [graphileBuildPreset, graphileBuildPgPreset], plugins: [makeObserver('UpstreamStockObserverPlugin')], pgServices: [ - makeService('main', 'stock_schema', false, upstreamQueries), + makeService('main', 'stock_schema', upstreamQueries), ], }; const replacementPreset = { @@ -191,7 +194,7 @@ describe('mixed stock/scoped introspection services', () => { ], plugins: [makeObserver('ReplacementStockObserverPlugin')], pgServices: [ - makeService('main', 'stock_schema', false, replacementQueries), + makeService('main', 'stock_schema', replacementQueries), ], }; @@ -210,7 +213,7 @@ describe('mixed stock/scoped introspection services', () => { makeSchema({ extends: [graphileBuildPreset, graphileBuildPgPreset], pgServices: [ - makeService('main', 'stock_schema', false, upstreamQueries), + makeService('main', 'stock_schema', upstreamQueries), ], }), makeSchema({ @@ -220,7 +223,7 @@ describe('mixed stock/scoped introspection services', () => { ScopedIntrospectionPreset, ], pgServices: [ - makeService('main', 'stock_schema', false, replacementQueries), + makeService('main', 'stock_schema', replacementQueries), ], }), ]); diff --git a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts index 8cccae1d02..1c752b7d32 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-runtime.test.ts @@ -44,20 +44,21 @@ describe('schema-scoped introspection runtime integration', () => { ScopedIntrospectionPreset, ], pgServices: [ - Object.assign( - makePostGraphilePgService({ - pool: pool as never, - schemas: ['tenant_a'], - }), - { - scopedIntrospection: true, - introspectionCapabilityExtensions: ['pg_trgm'], + makePostGraphilePgService({ + pool: pool as never, + schemas: ['tenant_a'], + }) as never, + ], + gather: { + pgScopedIntrospection: { + main: { + capabilityExtensions: ['pg_trgm'], ...(scopedCatalogTypes === undefined ? {} - : { introspectionScopedCatalogTypes: scopedCatalogTypes }), - } - ) as never, - ], + : { catalogTypes: scopedCatalogTypes }), + }, + }, + }, }) ).rejects.toBe(marker); @@ -140,17 +141,16 @@ describe('schema-scoped introspection runtime integration', () => { ScopedIntrospectionPreset, ], pgServices: [ - Object.assign( - makePostGraphilePgService({ - pool: pool as never, - schemas: ['tenant_a'], - }), - { - scopedIntrospection: true, - introspectionScopedCatalogTypes: 'dependency-closure', - } - ) as never, + makePostGraphilePgService({ + pool: pool as never, + schemas: ['tenant_a'], + }) as never, ], + gather: { + pgScopedIntrospection: { + main: { catalogTypes: 'dependency-closure' }, + }, + }, }) ).rejects.toThrow( /service '.+' retained pg_class 'broken_items \(200\)' field 'reltype' referencing missing pg_type OID '999'/ 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 index 9867bdc4b0..aa7d7d35d6 100644 --- a/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts +++ b/graphile/graphile-scoped-introspection/__tests__/scoped-introspection-service-contract.test.ts @@ -9,8 +9,6 @@ const makeService = (overrides: Record = {}): never => ({ name: 'main', schemas: ['tenant_a'], - scopedIntrospection: true, - introspectionAllowedDependencySchemas: [], adaptor: { createWithPgClient: jest.fn(() => { throw new Error('query should not be reached'); @@ -64,33 +62,19 @@ describe('scoped introspection service identity contract', () => { ).rejects.toThrow(message); }); - it.each([ - ['catalog type policy', { introspectionScopedCatalogTypes: 'all' }], - [ - 'dependency schemas', - { introspectionAllowedDependencySchemas: ['shared'] }, - ], - [ - 'capability extensions', - { introspectionCapabilityExtensions: ['pg_trgm'] }, - ], - ])( - 'rejects %s unless scoped introspection is enabled', - async (_label, option) => { - await expect( - gather({ - plugins: [PgScopedIntrospectionPlugin, consumerPlugin], - pgServices: [ - makeService({ - scopedIntrospection: false, - introspectionAllowedDependencySchemas: undefined, - ...option, - }), - ], - }) - ).rejects.toThrow(/require scopedIntrospection: true/); - } - ); + it('rejects configuration for an unknown PostgreSQL service', async () => { + await expect( + gather({ + plugins: [PgScopedIntrospectionPlugin, consumerPlugin], + pgServices: [makeService()], + gather: { + pgScopedIntrospection: { + analytics: {}, + }, + }, + }) + ).rejects.toThrow(/unknown PostgreSQL service\(s\): analytics/); + }); it.each([ ['empty', '', 'exact non-empty schema names'], @@ -101,9 +85,12 @@ describe('scoped introspection service identity contract', () => { await expect( gather({ plugins: [PgScopedIntrospectionPlugin, consumerPlugin], - pgServices: [ - makeService({ introspectionAllowedDependencySchemas: [schema] }), - ], + pgServices: [makeService()], + gather: { + pgScopedIntrospection: { + main: { allowedDependencySchemas: [schema] }, + }, + }, }) ).rejects.toThrow(message); }); diff --git a/graphile/graphile-scoped-introspection/src/index.ts b/graphile/graphile-scoped-introspection/src/index.ts index 95e0c82929..9b600cd56d 100644 --- a/graphile/graphile-scoped-introspection/src/index.ts +++ b/graphile/graphile-scoped-introspection/src/index.ts @@ -4,6 +4,6 @@ export { scopedIntrospectionUpstreamContract, } from './upstream/graphile-build-pg/PgIntrospectionPlugin'; export type { - ScopedIntrospectionServiceOptions, + PgScopedIntrospectionOptions, } from './upstream/graphile-build-pg/scopedOptions'; export * from './upstream/pg-introspection'; diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts index 1ab549104e..a3e0155867 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/PgIntrospectionPlugin.ts @@ -36,7 +36,7 @@ import { type ScopedCatalogTypes, } from "../pg-introspection"; -import type { ScopedIntrospectionServiceOptions } from "./scopedOptions"; +import type { PgScopedIntrospectionOptions } from "./scopedOptions"; import { assertAllowedDependencySchemas, assertDependencyClosureTypes, @@ -87,9 +87,6 @@ declare global { PgScopedIntrospectionPlugin: true; } - interface PgServiceConfiguration - extends ScopedIntrospectionServiceOptions {} - interface GatherHelpers { pgIntrospection: { getIntrospection(): PromiseOrDirect; @@ -276,37 +273,12 @@ type IntrospectionResults = Array<{ }>; function getIntrospectionQuery( - pgService: GraphileConfig.PgServiceConfiguration + pgService: GraphileConfig.PgServiceConfiguration, + options?: PgScopedIntrospectionOptions, ): Omit & { query: PgQuery; } { - const scopedIntrospection = pgService.scopedIntrospection; - const configuredCatalogTypes = pgService.introspectionScopedCatalogTypes; - const configuredCapabilityExtensions = - pgService.introspectionCapabilityExtensions; - const configuredDependencySchemas = - pgService.introspectionAllowedDependencySchemas; - const scopedCatalogTypes = configuredCatalogTypes ?? 'all'; - - if (!scopedIntrospection) { - const configuredScopedOptions = [ - configuredCatalogTypes !== undefined - ? "introspectionScopedCatalogTypes" - : null, - configuredDependencySchemas !== undefined - ? "introspectionAllowedDependencySchemas" - : null, - configuredCapabilityExtensions !== undefined - ? "introspectionCapabilityExtensions" - : null, - ].filter((option): option is string => option !== null); - if (configuredScopedOptions.length > 0) { - throw new Error( - `Scoped introspection option(s) ${configuredScopedOptions.join( - ", ", - )} require scopedIntrospection: true for service '${pgService.name}'`, - ); - } + if (!options) { return { query: { text: makeIntrospectionQuery() }, requiredSchemas: null, @@ -315,12 +287,13 @@ function getIntrospectionQuery( }; } const requiredSchemas = pgService.schemas ?? []; - const dependencySchemas = configuredDependencySchemas ?? []; + const dependencySchemas = options.allowedDependencySchemas ?? []; assertAllowedDependencySchemas(dependencySchemas); + const scopedCatalogTypes = options.catalogTypes ?? "all"; return { query: makeSchemaScopedIntrospectionQuery(requiredSchemas, { catalogTypes: scopedCatalogTypes, - capabilityExtensions: configuredCapabilityExtensions ?? [], + capabilityExtensions: options.capabilityExtensions ?? [], }), requiredSchemas, allowedSchemas: [ @@ -330,6 +303,26 @@ function getIntrospectionQuery( }; } +function assertScopedIntrospectionServices( + pgServices: ReadonlyArray | undefined, + options: GraphileBuild.GatherOptions["pgScopedIntrospection"], +): void { + if (!options) return; + + 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( + ", ", + )}`, + ); + } +} interface Cache { introspectionResultsPromise: null | Promise; @@ -615,6 +608,7 @@ export const PgScopedIntrospectionPlugin: GraphileConfig.Plugin = { info.cache.introspectionResultsPromise ?? (info.cache.introspectionResultsPromise = introspectPgServices( info.resolvedPreset.pgServices, + info.options.pgScopedIntrospection, )); // Don't cache errors @@ -879,7 +873,9 @@ export const PgScopedIntrospectionPlugin: GraphileConfig.Plugin = { function introspectPgServices( pgServices: ReadonlyArray | undefined, + scopedIntrospection: GraphileBuild.GatherOptions["pgScopedIntrospection"], ): Promise { + assertScopedIntrospectionServices(pgServices, scopedIntrospection); if (!pgServices) { return Promise.resolve([]); } @@ -936,7 +932,7 @@ function introspectPgServices( // Do the introspection const { query, requiredSchemas, allowedSchemas, scopedCatalogTypes } = - getIntrospectionQuery(pgService); + getIntrospectionQuery(pgService, scopedIntrospection?.[name]); const { rows: [row], } = await withPgClientFromPgService( diff --git a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts index 8c7975c3dc..552e4e586d 100644 --- a/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts +++ b/graphile/graphile-scoped-introspection/src/upstream/graphile-build-pg/scopedOptions.ts @@ -1,8 +1,29 @@ import type { ScopedCatalogTypes } from "../pg-introspection"; -export interface ScopedIntrospectionServiceOptions { - scopedIntrospection?: boolean; - introspectionAllowedDependencySchemas?: readonly string[]; - introspectionScopedCatalogTypes?: ScopedCatalogTypes; - introspectionCapabilityExtensions?: readonly string[]; +declare global { + namespace GraphileBuild { + interface GatherOptions { + /** + * Schema-scoped introspection options keyed by PostgreSQL service name. + * Services without an entry continue to use stock introspection. + */ + pgScopedIntrospection?: Readonly< + Record + >; + } + } +} + +export interface PgScopedIntrospectionOptions { + /** Schemas that the dependency closure may cross into. */ + allowedDependencySchemas?: readonly string[]; + + /** Controls how many `pg_catalog` types scoped introspection retains. */ + catalogTypes?: ScopedCatalogTypes; + + /** + * Extensions whose metadata should be retained even if no scoped object + * directly depends on them. + */ + capabilityExtensions?: readonly string[]; } diff --git a/graphile/graphile-settings/README.md b/graphile/graphile-settings/README.md index 5249ff15e4..0a2ae4f4c1 100644 --- a/graphile/graphile-settings/README.md +++ b/graphile/graphile-settings/README.md @@ -187,24 +187,32 @@ const sdl = printSchema(schema); `ConstructivePreset` and `makePgService` retain PostGraphile's upstream introspection behavior. Applications that explicitly opt into CNC scoped -introspection should install the independently owned preset and use the scoped -service factory together: +introspection should install the independently owned preset and configure its +gather options by PostgreSQL service name: ```typescript import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection'; -import { ConstructivePreset, makeScopedPgService } from 'graphile-settings'; +import { ConstructivePreset, makePgService } from 'graphile-settings'; const preset = { extends: [ConstructivePreset, ScopedIntrospectionPreset], pgServices: [ - makeScopedPgService({ + makePgService({ + name: 'main', connectionString: 'postgres://user:pass@localhost/mydb', schemas: ['app_public'], - introspectionJit: false, - introspectionAllowedDependencySchemas: ['shared'], - introspectionCapabilityExtensions: ['pg_trgm'], + pgSettingsForIntrospection: { jit: 'off' }, }), ], + gather: { + pgScopedIntrospection: { + main: { + catalogTypes: 'dependency-closure', + allowedDependencySchemas: ['shared'], + capabilityExtensions: ['pg_trgm'], + }, + }, + }, }; ``` diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts index 3d96e287eb..7ae73806a3 100644 --- a/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts +++ b/graphile/graphile-settings/__tests__/scoped-introspection-wiring.test.ts @@ -1,79 +1,27 @@ import { PgIntrospectionPlugin } from 'graphile-build-pg'; import { resolvePreset } from 'graphile-config'; +import { resolveIntrospectionSettings } from '../src/introspection-settings'; import { ConstructivePreset } from '../src/presets/constructive-preset'; -import { - makeConfiguredPgService, - type ScopedIntrospectionOptions, -} from '../src/scoped-introspection-service'; - -type TestUpstreamOptions = { - pubsub?: boolean; - schemas?: string[]; - pgSettingsForIntrospection?: - Record | null | undefined; -}; - -const makeUpstreamPgService = jest.fn((options: TestUpstreamOptions) => ({ - ...options, - upstream: true, -})); -const makeScopedPgService = ( - options: TestUpstreamOptions & ScopedIntrospectionOptions -) => makeConfiguredPgService(makeUpstreamPgService, options); describe('scoped introspection settings wiring', () => { - beforeEach(() => { - makeUpstreamPgService.mockClear(); - }); - - it('forwards scoped service configuration without interpreting it', () => { - const service = makeScopedPgService({ - pubsub: false, - schemas: ['tenant_a'], - introspectionScopedCatalogTypes: 'dependency-closure', - introspectionAllowedDependencySchemas: ['shared', 'shared'], - introspectionCapabilityExtensions: ['pg_trgm', 'pg_trgm'], - pgSettingsForIntrospection: { + it('disables introspection JIT while preserving other session settings', () => { + expect( + resolveIntrospectionSettings(false, { statement_timeout: '30s', jit: 'on', work_mem: '1MB', - }, - }); - - expect(service).toMatchObject({ - schemas: ['tenant_a'], - scopedIntrospection: true, - introspectionScopedCatalogTypes: 'dependency-closure', - introspectionAllowedDependencySchemas: ['shared', 'shared'], - introspectionCapabilityExtensions: ['pg_trgm', 'pg_trgm'], - pgSettingsForIntrospection: { - statement_timeout: '30s', - jit: 'off', - work_mem: '1MB', - }, - }); - expect(makeUpstreamPgService).toHaveBeenCalledWith({ - pubsub: false, - schemas: ['tenant_a'], - pgSettingsForIntrospection: { - statement_timeout: '30s', - jit: 'off', - work_mem: '1MB', - }, + }) + ).toEqual({ + statement_timeout: '30s', + jit: 'off', + work_mem: '1MB', }); }); - it('enables introspection JIT only when explicitly configured', () => { - const service = makeScopedPgService({ - pubsub: false, - introspectionJit: true, - }); - - expect(service.pgSettingsForIntrospection).toEqual({ jit: 'on' }); - expect(makeUpstreamPgService).toHaveBeenCalledWith({ - pubsub: false, - pgSettingsForIntrospection: { jit: 'on' }, + it('enables introspection JIT only when explicitly requested', () => { + expect(resolveIntrospectionSettings(true, undefined)).toEqual({ + jit: 'on', }); }); diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index d7223a8870..2f253a1b6f 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -37,11 +37,6 @@ import 'graphile-build'; import { makePgService } from 'postgraphile/adaptors/pg'; -import { - makeConfiguredPgService, - type ScopedIntrospectionOptions -} from './scoped-introspection-service'; - // ============================================================================ // Re-export all plugins and presets // ============================================================================ @@ -60,16 +55,9 @@ export * from './presets/index'; // Utilities // ============================================================================ -export type ScopedPgServiceOptions = Parameters[0] & - ScopedIntrospectionOptions; - // Keep the default service factory as the untouched upstream implementation. export { makePgService }; -/** Construct a PG service configured for CNC's opt-in scoped introspection. */ -export const makeScopedPgService = (options: ScopedPgServiceOptions) => - makeConfiguredPgService(makePgService, options); - export { resolveIntrospectionSettings } from './introspection-settings'; // Presigned URL utilities diff --git a/graphile/graphile-settings/src/scoped-introspection-service.ts b/graphile/graphile-settings/src/scoped-introspection-service.ts deleted file mode 100644 index 127bef39ea..0000000000 --- a/graphile/graphile-settings/src/scoped-introspection-service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ScopedIntrospectionServiceOptions } from '@constructive-io/graphql-types'; - -import { resolveIntrospectionSettings } from './introspection-settings'; - -type UpstreamPgServiceOptions = { - pgSettingsForIntrospection?: - Record | null | undefined; -}; - -export type ScopedIntrospectionOptions = Omit< - ScopedIntrospectionServiceOptions, - 'scopedIntrospection' -> & { - introspectionJit?: boolean; -}; - -/** - * Apply CNC's scoped-introspection settings around an upstream PgService - * factory. The injected binding keeps configuration behavior independently - * testable without duplicating or mocking the Graphile adaptor. - */ -export function makeConfiguredPgService< - TOptions extends UpstreamPgServiceOptions, - TService extends object, ->( - makeUpstreamPgService: (options: TOptions) => TService, - options: TOptions & ScopedIntrospectionOptions -) { - const { - introspectionScopedCatalogTypes, - introspectionAllowedDependencySchemas: configuredDependencySchemas, - introspectionCapabilityExtensions: configuredCapabilityExtensions, - introspectionJit = false, - ...upstreamOptions - } = options; - const pgSettingsForIntrospection = resolveIntrospectionSettings( - introspectionJit, - options.pgSettingsForIntrospection - ); - const service = makeUpstreamPgService({ - ...upstreamOptions, - pgSettingsForIntrospection, - } as TOptions); - - return Object.assign(service, { - scopedIntrospection: true as const, - introspectionScopedCatalogTypes: - introspectionScopedCatalogTypes ?? 'dependency-closure', - introspectionAllowedDependencySchemas: configuredDependencySchemas ?? [], - introspectionCapabilityExtensions: configuredCapabilityExtensions ?? [], - }); -} diff --git a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts index 204b1230aa..ce400819e9 100644 --- a/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts +++ b/graphql/server/src/middleware/__tests__/graphile-introspection.test.ts @@ -49,16 +49,29 @@ describe('Graphile introspection mode wiring', () => { ); expect(loadScopedPreset).toHaveBeenCalledTimes(1); - expect(wiring.presets).toEqual([scopedPreset]); + expect(wiring.presets).toEqual([ + scopedPreset, + { + gather: { + pgScopedIntrospection: { + main: { + catalogTypes: 'dependency-closure', + allowedDependencySchemas: ['shared'], + capabilityExtensions: ['pg_trgm'], + }, + }, + }, + }, + ]); expect(wiring.pgService).toMatchObject({ - scopedIntrospection: true, - introspectionScopedCatalogTypes: 'dependency-closure', - introspectionAllowedDependencySchemas: ['shared'], - introspectionCapabilityExtensions: ['pg_trgm'], pgSettingsForIntrospection: { jit: 'on', }, }); + expect(wiring.pgService).not.toHaveProperty('scopedIntrospection'); + expect(wiring.pgService).not.toHaveProperty( + 'introspectionAllowedDependencySchemas' + ); }); it.each([ diff --git a/graphql/server/src/middleware/graphile-introspection.ts b/graphql/server/src/middleware/graphile-introspection.ts index 400cc5421f..19fea047a3 100644 --- a/graphql/server/src/middleware/graphile-introspection.ts +++ b/graphql/server/src/middleware/graphile-introspection.ts @@ -1,6 +1,7 @@ import type { GraphileOptions } from '@constructive-io/graphql-types'; import type { GraphileConfig } from 'graphile-config'; -import { makePgService, makeScopedPgService } from 'graphile-settings'; +import type { PgScopedIntrospectionOptions } from 'graphile-scoped-introspection'; +import { makePgService, resolveIntrospectionSettings } from 'graphile-settings'; import type { Pool } from 'pg'; export interface IntrospectionWiring { @@ -59,17 +60,32 @@ export const makeIntrospectionWiring = async ( } const scopedPreset = await loadScopedPreset(); + const pgService = makePgService({ + pool, + schemas, + pgSettingsForIntrospection: resolveIntrospectionSettings( + introspectionJit ?? false, + undefined + ), + }); + const scopedOptions: PgScopedIntrospectionOptions = { + catalogTypes: 'dependency-closure', + allowedDependencySchemas: + graphileOptions?.introspectionDependencySchemas ?? [], + capabilityExtensions: + graphileOptions?.introspectionCapabilityExtensions ?? [], + }; return { - presets: [scopedPreset], - pgService: makeScopedPgService({ - pool, - schemas, - introspectionScopedCatalogTypes: 'dependency-closure', - introspectionAllowedDependencySchemas: - graphileOptions?.introspectionDependencySchemas, - introspectionCapabilityExtensions: - graphileOptions?.introspectionCapabilityExtensions, - introspectionJit, - }), + presets: [ + scopedPreset, + { + gather: { + pgScopedIntrospection: { + [pgService.name]: scopedOptions, + }, + }, + }, + ], + pgService, }; }; diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index 23f4ad5c8a..3217575af8 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -2,17 +2,6 @@ import type { GraphileConfig } from 'graphile-config'; export type ScopedCatalogTypes = 'all' | 'dependency-closure'; -export interface ScopedIntrospectionServiceOptions { - /** Use CNC schema-scoped introspection for this service. */ - scopedIntrospection?: boolean; - /** Catalog types retained by scoped introspection; defaults to all. */ - introspectionScopedCatalogTypes?: ScopedCatalogTypes; - /** Non-root schemas that scoped dependency closure may retain. */ - introspectionAllowedDependencySchemas?: readonly string[]; - /** Installed extensions whose optional capability metadata is required. */ - introspectionCapabilityExtensions?: readonly string[]; -} - /** * PostGraphile/Graphile v5 configuration */ diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index d54a4d4b77..87b8503cce 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -6,8 +6,7 @@ export { graphileFeatureDefaults, GraphileFeatureOptions, GraphileOptions, - ScopedCatalogTypes, - ScopedIntrospectionServiceOptions} from './graphile'; + ScopedCatalogTypes} from './graphile'; // Export Constructive combined types export { diff --git a/packages/perf-harness/src/scoped-introspection-worker.ts b/packages/perf-harness/src/scoped-introspection-worker.ts index 02f970e83f..3a587d6598 100644 --- a/packages/perf-harness/src/scoped-introspection-worker.ts +++ b/packages/perf-harness/src/scoped-introspection-worker.ts @@ -11,7 +11,6 @@ import { type Introspection, ScopedIntrospectionPreset, } from 'graphile-scoped-introspection'; -import { makeScopedPgService } from 'graphile-settings'; import { execute, lexicographicSortSchema, parse, printSchema } from 'graphql'; import { makePgService as makePostGraphilePgService } from 'postgraphile/adaptors/pg'; @@ -113,19 +112,10 @@ const main = async (): Promise => { schemas: config.schemas, pubsub: false, }; - const scopedServiceOptions = { + const service = makePostGraphilePgService({ ...serviceOptions, - introspectionScopedCatalogTypes: 'dependency-closure' as const, - introspectionAllowedDependencySchemas: config.allowedDependencySchemas, - introspectionJit: config.introspectionJit, - }; - const service = - config.scopedIntrospection - ? makeScopedPgService(scopedServiceOptions) - : makePostGraphilePgService({ - ...serviceOptions, - pgSettingsForIntrospection: { jit: expectedJit }, - }); + pgSettingsForIntrospection: { jit: expectedJit }, + }); release = async () => { await service.release(); }; @@ -147,6 +137,18 @@ const main = async (): Promise => { }), ], pgServices: [service], + ...(config.scopedIntrospection + ? { + gather: { + pgScopedIntrospection: { + [service.name]: { + catalogTypes: 'dependency-closure' as const, + allowedDependencySchemas: config.allowedDependencySchemas, + }, + }, + }, + } + : {}), }), async ({ schema }) => { const execution = await execute({