From 6314199ead0458edfad2813d3a9496a7a30969ca Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Wed, 5 Aug 2026 09:27:06 +0700 Subject: [PATCH] Build exact Graphile schemas from scoped introspection --- .../scoped-introspection-equivalence.test.ts | 155 ++++ graphile/graphile-settings/README.md | 10 + .../__tests__/PublicKeySignature.test.ts | 61 +- .../__tests__/build-state-retirement.test.ts | 203 +++++ .../constructive-preset-bucket-wiring.test.ts | 29 +- .../__tests__/grafast-cache-limits.test.ts | 55 ++ .../introspection-capabilities.test.ts | 26 + .../introspection-client-release.test.ts | 253 ++++++ .../__tests__/make-pg-service.test.ts | 42 + ...ed-bm25-cross-database.integration.test.ts | 362 +++++++++ ...oped-introspection-cache-lifecycle.test.ts | 211 +++++ ...ion-capability-closure.integration.test.ts | 729 ++++++++++++++++++ .../scoped-introspection-runtime.test.ts | 125 +++ ...coped-introspection-capability-closure.sql | 208 +++++ .../src/grafast-cache-limits.ts | 68 ++ graphile/graphile-settings/src/index.ts | 125 ++- .../src/introspection-client-release.ts | 49 ++ .../src/introspection-settings.ts | 43 ++ .../src/plugins/PublicKeySignature.ts | 166 ++-- .../graphile-settings/src/plugins/index.ts | 2 - .../src/presets/constructive-preset.ts | 55 +- graphql/query/tsconfig.esm.json | 1 + graphql/query/tsconfig.json | 5 +- graphql/server/package.json | 1 + .../__tests__/graphile-build-contract.test.ts | 296 +++++++ .../__tests__/graphile-build-governor.test.ts | 215 ++++++ .../graphile-preset-composition.test.ts | 188 +++++ .../pg-introspection-memo-contract.test.ts | 140 ++++ .../__tests__/scoped-introspection.test.ts | 112 +++ .../src/middleware/graphile-build-contract.ts | 289 +++++++ .../src/middleware/graphile-build-governor.ts | 442 +++++++++++ .../middleware/graphile-preset-composition.ts | 221 ++++++ package.json | 9 +- patches/@dataplan__pg@1.0.3.patch | 179 +++++ ...-contrib__pg-many-to-many@2.0.0-rc.2.patch | 15 + patches/graphile-build-pg.patch | 402 ++++++++++ patches/graphile-build@5.0.2.patch | 423 ++++++++++ patches/pg-introspection@1.0.1.patch | 599 ++++++++++++++ pnpm-lock.yaml | 354 +++++---- 39 files changed, 6622 insertions(+), 246 deletions(-) create mode 100644 graphile/graphile-schema/__tests__/scoped-introspection-equivalence.test.ts create mode 100644 graphile/graphile-settings/__tests__/build-state-retirement.test.ts create mode 100644 graphile/graphile-settings/__tests__/grafast-cache-limits.test.ts create mode 100644 graphile/graphile-settings/__tests__/introspection-capabilities.test.ts create mode 100644 graphile/graphile-settings/__tests__/introspection-client-release.test.ts create mode 100644 graphile/graphile-settings/__tests__/make-pg-service.test.ts create mode 100644 graphile/graphile-settings/__tests__/scoped-bm25-cross-database.integration.test.ts create mode 100644 graphile/graphile-settings/__tests__/scoped-introspection-cache-lifecycle.test.ts create mode 100644 graphile/graphile-settings/__tests__/scoped-introspection-capability-closure.integration.test.ts create mode 100644 graphile/graphile-settings/__tests__/scoped-introspection-runtime.test.ts create mode 100644 graphile/graphile-settings/sql/scoped-introspection-capability-closure.sql create mode 100644 graphile/graphile-settings/src/grafast-cache-limits.ts create mode 100644 graphile/graphile-settings/src/introspection-client-release.ts create mode 100644 graphile/graphile-settings/src/introspection-settings.ts create mode 100644 graphql/server/src/middleware/__tests__/graphile-build-contract.test.ts create mode 100644 graphql/server/src/middleware/__tests__/graphile-build-governor.test.ts create mode 100644 graphql/server/src/middleware/__tests__/graphile-preset-composition.test.ts create mode 100644 graphql/server/src/middleware/__tests__/pg-introspection-memo-contract.test.ts create mode 100644 graphql/server/src/middleware/__tests__/scoped-introspection.test.ts create mode 100644 graphql/server/src/middleware/graphile-build-contract.ts create mode 100644 graphql/server/src/middleware/graphile-build-governor.ts create mode 100644 graphql/server/src/middleware/graphile-preset-composition.ts create mode 100644 patches/@dataplan__pg@1.0.3.patch create mode 100644 patches/@graphile-contrib__pg-many-to-many@2.0.0-rc.2.patch create mode 100644 patches/graphile-build-pg.patch create mode 100644 patches/graphile-build@5.0.2.patch create mode 100644 patches/pg-introspection@1.0.1.patch diff --git a/graphile/graphile-schema/__tests__/scoped-introspection-equivalence.test.ts b/graphile/graphile-schema/__tests__/scoped-introspection-equivalence.test.ts new file mode 100644 index 0000000000..1e7c7f96dd --- /dev/null +++ b/graphile/graphile-schema/__tests__/scoped-introspection-equivalence.test.ts @@ -0,0 +1,155 @@ +import path from 'node:path'; + +import { makeSchema } from 'graphile-build'; +import { makePgService, MinimalPreset } from 'graphile-settings'; +import { + lexicographicSortSchema, + parse, + printSchema, + type ExecutionResult +} from 'graphql'; +import type { Pool } from 'pg'; +import { getConnections, PgTestClient } from 'pgsql-test'; + +const SCHEMA = 'scoped_equivalence'; + +// graphile-schema consumes these through graphile-settings in production; the +// test resolves that package's exact dependency instances without adding +// test-only runtime dependencies to graphile-schema. +const graphileSettingsDirectory = path.dirname(require.resolve('graphile-settings')); +const { execute } = require(require.resolve('grafast', { + paths: [graphileSettingsDirectory] +})); +const { withPgClientFromPgService } = require(require.resolve('graphile-build-pg', { + paths: [graphileSettingsDirectory] +})); + +let pg: PgTestClient; +let pool: Pool; +let teardown: () => Promise; + +beforeAll(async () => { + const connections = await getConnections({}, []); + ({ pg, teardown } = connections); + pool = connections.manager.getPool(pg.config); + + await pg.query(` + CREATE SCHEMA ${SCHEMA}; + CREATE TYPE ${SCHEMA}.item_state AS ENUM ('draft', 'published'); + + CREATE TABLE ${SCHEMA}.organizations ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE + ); + + CREATE TABLE ${SCHEMA}.items ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + organization_id bigint NOT NULL REFERENCES ${SCHEMA}.organizations(id), + state ${SCHEMA}.item_state NOT NULL DEFAULT 'draft', + title text NOT NULL + ); + + CREATE FUNCTION ${SCHEMA}.item_title(item ${SCHEMA}.items) + RETURNS text + LANGUAGE sql + IMMUTABLE + AS 'SELECT item.title'; + + CREATE FUNCTION ${SCHEMA}.tenant_token() + RETURNS text + LANGUAGE sql + STABLE + AS 'SELECT ''scoped-equivalence-token''::text'; + + COMMENT ON TABLE ${SCHEMA}.items IS 'Scoped-introspection equivalence canary'; + `); +}); + +beforeEach(async () => { + await pg.beforeEach(); +}); + +afterEach(async () => { + await pg.afterEach(); +}); + +afterAll(async () => { + await teardown(); +}); + +async function build( + mode: 'stock' | 'scoped-required', + schemas = [SCHEMA], + scopedCatalogTypes?: 'all' | 'dependency-closure' +) { + const pgService = makePgService({ + pool, + schemas, + introspectionMode: mode, + ...(scopedCatalogTypes === undefined + ? {} + : { introspectionScopedCatalogTypes: scopedCatalogTypes }) + }); + const built = await makeSchema({ + extends: [MinimalPreset], + pgServices: [pgService] + }); + return { ...built, pgService }; +} + +describe('scoped introspection schema equivalence', () => { + it('builds byte-equivalent SDL and executes the same token in every arm', async () => { + const stockBuild = await build('stock'); + const scopedAllBuild = await build('scoped-required'); + const scopedClosureBuild = await build( + 'scoped-required', + [SCHEMA], + 'dependency-closure' + ); + const stock = printSchema(lexicographicSortSchema(stockBuild.schema)); + const scopedAll = printSchema(lexicographicSortSchema(scopedAllBuild.schema)); + const scopedClosure = printSchema(lexicographicSortSchema( + scopedClosureBuild.schema + )); + + expect(scopedAll).toBe(stock); + expect(scopedClosure).toBe(stock); + expect(scopedClosure).toContain('type Item'); + expect(scopedClosure).toContain('enum ItemState'); + + for (const built of [stockBuild, scopedAllBuild, scopedClosureBuild]) { + const withPgClientKey = built.pgService.withPgClientKey ?? 'withPgClient'; + const result = await execute({ + schema: built.schema, + document: parse('{ tenantToken }'), + contextValue: { + pgSettings: {}, + [withPgClientKey]: withPgClientFromPgService.bind( + null, + built.pgService + ) + }, + resolvedPreset: built.resolvedPreset + }) as ExecutionResult<{ tenantToken?: unknown }>; + if (Symbol.asyncIterator in result) { + throw new Error('tenant token canary unexpectedly returned a stream'); + } + expect(result.errors).toBeUndefined(); + expect(result.data).toEqual({ tenantToken: 'scoped-equivalence-token' }); + } + }); + + it.each([undefined, 'dependency-closure'] as const)( + 'fails closed when a required schema is absent (catalog types: %s)', + async (scopedCatalogTypes) => { + await expect(build( + 'scoped-required', + ['missing_required_schema'], + scopedCatalogTypes + )) + .rejects.toThrow( + 'did not find required schema(s): missing_required_schema' + ); + } + ); +}); diff --git a/graphile/graphile-settings/README.md b/graphile/graphile-settings/README.md index 84a4156058..edd3aa6f8d 100644 --- a/graphile/graphile-settings/README.md +++ b/graphile/graphile-settings/README.md @@ -40,6 +40,10 @@ const preset = { makePgService({ connectionString: 'postgres://user:pass@localhost/mydb', schemas: ['app_public'], + // Optional density optimization: retire the exact connection that ran + // catalog introspection instead of returning its enlarged backend to + // the request pool. The default is 'reuse'. + introspectionClientReleaseMode: 'destroy', }), ], }; @@ -52,6 +56,12 @@ serv.addTo(app, httpServer); httpServer.listen(5000); ``` +`introspectionClientReleaseMode: 'destroy'` applies only to the connection +checked out by Graphile for the catalog gather query. It fails closed when the +configured adaptor cannot prove that it owns and can destroy that exact +connection; caller-owned clients are never destroyed. Runtime requests still +use a dedicated tenant/API service and its normal pool. + ## Features The `ConstructivePreset` combines multiple plugins and configurations to provide a clean, opinionated GraphQL API. Below is a detailed breakdown of each feature. diff --git a/graphile/graphile-settings/__tests__/PublicKeySignature.test.ts b/graphile/graphile-settings/__tests__/PublicKeySignature.test.ts index cc833a3333..2214bc92e7 100644 --- a/graphile/graphile-settings/__tests__/PublicKeySignature.test.ts +++ b/graphile/graphile-settings/__tests__/PublicKeySignature.test.ts @@ -1,8 +1,12 @@ import type { PublicKeyChallengeConfig } from '../src/plugins/PublicKeySignature'; -import { PublicKeySignature } from '../src/plugins/PublicKeySignature'; +import { + PublicKeySignature, + withAnonymousPublicKeyClient, +} from '../src/plugins/PublicKeySignature'; const defaultConfig: PublicKeyChallengeConfig = { schema: 'app_private', + anonymousRole: 'api_anonymous', crypto_network: 'btc', sign_up_with_key: 'sign_up_with_key', sign_in_request_challenge: 'sign_in_request_challenge', @@ -29,6 +33,7 @@ describe('PublicKeySignature plugin factory', () => { it('accepts custom config values', () => { const customConfig: PublicKeyChallengeConfig = { schema: 'custom_schema', + anonymousRole: 'custom_anonymous', crypto_network: 'eth', sign_up_with_key: 'custom_signup', sign_in_request_challenge: 'custom_challenge', @@ -59,6 +64,11 @@ describe('PublicKeySignature config validation', () => { expect(() => PublicKeySignature({ ...defaultConfig, schema: 'DROP TABLE' })).toThrow(/invalid schema/); }); + it('throws on an invalid anonymous role', () => { + expect(() => PublicKeySignature({ ...defaultConfig, anonymousRole: 'tenant-a; SET ROLE owner' })) + .toThrow(/invalid anonymousRole/); + }); + it('throws on invalid function name', () => { expect(() => PublicKeySignature({ ...defaultConfig, sign_up_with_key: 'evil"; DROP' })).toThrow( /invalid sign_up_with_key/, @@ -87,3 +97,52 @@ describe('PublicKeySignature config validation', () => { expect(() => PublicKeySignature(defaultConfig)).not.toThrow(); }); }); + +describe('PublicKeySignature request context', () => { + it('preserves the complete request GUC contract while forcing the anonymous role', async () => { + const pgSettings = { + role: 'authenticated', + 'jwt.claims.api_id': 'api-a', + 'jwt.claims.database_id': 'database-a', + 'jwt.claims.user_id': '', + 'jwt.claims.session_id': '', + 'request.id': 'request-a', + transaction_read_only: 'off', + search_path: 'pg_catalog, "tenant_a"', + row_security: 'on', + }; + const pgClient = { + query: jest.fn(async (): Promise<{ rows: Record[] }> => ({ rows: [] })), + }; + const callback = jest.fn(async (client) => client); + const withPgClient = jest.fn(async (settings, fn) => { + expect(settings).toEqual({ ...pgSettings, role: 'api_anonymous' }); + expect(settings).not.toBe(pgSettings); + return fn(pgClient); + }); + + await expect(withAnonymousPublicKeyClient( + withPgClient, + pgSettings, + 'api_anonymous', + callback, + )).resolves.toBe(pgClient); + + expect(withPgClient).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(pgClient); + expect(pgSettings.role).toBe('authenticated'); + }); + + it.each([ + ['missing withPgClient', undefined, { role: 'anonymous' }, 'PG_CLIENT_CONTEXT_UNAVAILABLE'], + ['missing pgSettings', jest.fn(), undefined, 'PG_SETTINGS_UNAVAILABLE'], + ['null pgSettings', jest.fn(), null, 'PG_SETTINGS_UNAVAILABLE'], + ])('fails closed for %s', async (_label, withPgClient, pgSettings, expected) => { + await expect(withAnonymousPublicKeyClient( + withPgClient as any, + pgSettings, + 'api_anonymous', + async (): Promise => null, + )).rejects.toThrow(expected); + }); +}); diff --git a/graphile/graphile-settings/__tests__/build-state-retirement.test.ts b/graphile/graphile-settings/__tests__/build-state-retirement.test.ts new file mode 100644 index 0000000000..5a2273107b --- /dev/null +++ b/graphile/graphile-settings/__tests__/build-state-retirement.test.ts @@ -0,0 +1,203 @@ +import { + BUILD_STATE_RELEASED_ERROR_CODE, + defaultPreset, + getBuilder, + isBuildStateReleased, + releaseBuildState +} from 'graphile-build'; +import { + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + graphqlSync, + printSchema +} from 'graphql'; + +type CapturedBuild = GraphileBuild.BuildBase & Partial; + +interface Capture { + builds: CapturedBuild[]; + owned: Map[]; + disposalOrder: string[]; +} + +const makeCapturePlugin = ( + capture: Capture, + configure?: (build: CapturedBuild) => void +): GraphileConfig.Plugin => ({ + name: 'BuildStateRetirementContractPlugin', + version: '0.0.0', + schema: { + hooks: { + build(build) { + const owned = new Map([['construction-only', 'retained']]); + capture.builds.push(build); + capture.owned.push(owned); + build.registerBuildStateDisposer(() => { + capture.disposalOrder.push('owned'); + owned.clear(); + }); + configure?.(build); + return build; + } + } + } +}); + +const makeCapture = (): Capture => ({ + builds: [], + owned: [], + disposalOrder: [] +}); + +const buildTestSchema = ( + capture: Capture, + releaseBuildStateAfterValidation: boolean, + extraPlugins: GraphileConfig.Plugin[] = [] +): ReturnType['buildSchema']> => getBuilder({ + extends: [defaultPreset], + plugins: [makeCapturePlugin(capture), ...extraPlugins], + schema: { releaseBuildStateAfterValidation } +}).buildSchema(Object.create(null)); + +const expectReleasedError = (callback: () => unknown): void => { + try { + callback(); + throw new Error('Expected released build state to reject late access'); + } catch (error) { + expect((error as Error & { code?: string }).code).toBe( + BUILD_STATE_RELEASED_ERROR_CODE + ); + } +}; + +describe('Graphile build-state retirement contract', () => { + it('is default-off and retains plugin-owned construction state', () => { + const capture = makeCapture(); + const schema = buildTestSchema(capture, false); + + expect(capture.disposalOrder).toEqual([]); + expect(capture.owned[0].size).toBe(1); + expect(isBuildStateReleased(capture.builds[0] as GraphileBuild.Build)).toBe(false); + expect(capture.builds[0].getAllTypes()).toBeDefined(); + expect(graphqlSync({ schema, source: '{ __typename }' })).toEqual({ + data: { __typename: 'Query' } + }); + }); + + it('preserves schema bytes and execution while failing closed on late access', () => { + const baselineCapture = makeCapture(); + const candidateCapture = makeCapture(); + const baseline = buildTestSchema(baselineCapture, false); + const candidate = buildTestSchema(candidateCapture, true); + const releasedBuild = candidateCapture.builds[0]; + + expect(printSchema(candidate)).toBe(printSchema(baseline)); + expect(candidateCapture.disposalOrder).toEqual(['owned']); + expect(candidateCapture.owned[0].size).toBe(0); + expect(isBuildStateReleased(releasedBuild as GraphileBuild.Build)).toBe(true); + expect(graphqlSync({ schema: candidate, source: '{ __typename }' })).toEqual({ + data: { __typename: 'Query' } + }); + expectReleasedError(() => releasedBuild.input); + expectReleasedError(() => releasedBuild.scopeByType); + expectReleasedError(() => releasedBuild.getAllTypes()); + expectReleasedError(() => releasedBuild.behavior!.getDefaultBehaviorFor('string')); + expect(releaseBuildState(releasedBuild as GraphileBuild.Build)).toBe(false); + }); + + it('runs every disposer in LIFO order and aggregates disposal failures', () => { + const capture = makeCapture(); + const plugin = makeCapturePlugin(capture, (build) => { + build.registerBuildStateDisposer(() => capture.disposalOrder.push('first')); + build.registerBuildStateDisposer(() => { + capture.disposalOrder.push('second-error'); + throw new Error('synthetic disposal failure'); + }); + build.registerBuildStateDisposer(() => capture.disposalOrder.push('third')); + }); + const builder = getBuilder({ + extends: [defaultPreset], + plugins: [plugin], + schema: { releaseBuildStateAfterValidation: true } + }); + + let thrown: unknown; + try { + builder.buildSchema(Object.create(null)); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(AggregateError); + expect((thrown as AggregateError).errors).toHaveLength(1); + expect((thrown as AggregateError).errors[0]).toEqual( + new Error('synthetic disposal failure') + ); + expect(capture.disposalOrder).toEqual([ + 'third', + 'second-error', + 'first', + 'owned' + ]); + expect(capture.owned[0].size).toBe(0); + expect(isBuildStateReleased(capture.builds[0] as GraphileBuild.Build)).toBe(true); + }); + + it('keeps diagnostic state when schema validation fails', () => { + const capture = makeCapture(); + const invalidSchemaPlugin: GraphileConfig.Plugin = { + name: 'InvalidSchemaForRetirementContractPlugin', + version: '0.0.0', + schema: { + hooks: { + finalize(schema) { + const requiredInterface = new GraphQLInterfaceType({ + name: 'RetirementRequiredInterface', + fields: { required: { type: GraphQLString } } + }); + const brokenObject = new GraphQLObjectType({ + name: 'RetirementBrokenObject', + interfaces: [requiredInterface], + fields: { other: { type: GraphQLString } } + }); + const config = schema.toConfig(); + return new GraphQLSchema({ + ...config, + types: [...config.types, requiredInterface, brokenObject] + }); + } + } + } + }; + + expect(() => buildTestSchema(capture, true, [invalidSchemaPlugin])) + .toThrow(/validation failure/i); + expect(capture.disposalOrder).toEqual([]); + expect(capture.owned[0].size).toBe(1); + expect(isBuildStateReleased(capture.builds[0] as GraphileBuild.Build)).toBe(false); + expect(capture.builds[0].getAllTypes()).toBeDefined(); + }); + + it('retires each rebuild without clearing the builder hook registry', () => { + const capture = makeCapture(); + const builder = getBuilder({ + extends: [defaultPreset], + plugins: [makeCapturePlugin(capture)], + schema: { releaseBuildStateAfterValidation: true } + }); + + const first = builder.buildSchema(Object.create(null)); + const second = builder.buildSchema(Object.create(null)); + + expect(capture.builds).toHaveLength(2); + expect(capture.owned).toHaveLength(2); + expect(capture.owned.every((owned) => owned.size === 0)).toBe(true); + expect(capture.builds.every((build) => + isBuildStateReleased(build as GraphileBuild.Build) + )).toBe(true); + expect(capture.disposalOrder).toEqual(['owned', 'owned']); + expect(printSchema(second)).toBe(printSchema(first)); + }); +}); diff --git a/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts b/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts index e045370cd4..16b2c23d41 100644 --- a/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts +++ b/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts @@ -8,7 +8,7 @@ * cross-tenant bucket-name collisions. */ -const captured: { bucketProvisionerOptions?: any } = {}; +const captured: { bucketProvisionerOptions?: any; presignedOptions?: any } = {}; // Capture the options handed to BucketProvisionerPreset without pulling in the // real plugin (and its S3 machinery). @@ -19,6 +19,17 @@ jest.mock('graphile-bucket-provisioner-plugin', () => ({ }), })); +jest.mock('graphile-presigned-url-plugin', () => ({ + PresignedUrlPreset: jest.fn((options: any) => { + captured.presignedOptions = options; + return { plugins: [] as any[] }; + }), + snapshotPreloadedStorageModules: jest.fn((modules: any[] | undefined) => { + if (modules === undefined) return undefined; + return Object.freeze(modules.map((module) => Object.freeze({ ...module }))); + }), +})); + // The preset reads CDN config eagerly when building the presigned/provisioner // plugin options; provide a prefix so name minting is deterministic. const PREFIX = 'test-bucket'; @@ -42,6 +53,7 @@ const DATABASE_ID = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; describe('ConstructivePreset bucket-provisioner wiring', () => { beforeEach(() => { captured.bucketProvisionerOptions = undefined; + captured.presignedOptions = undefined; }); it('passes a resolveBucketName into BucketProvisionerPreset when presigned uploads are enabled', () => { @@ -50,6 +62,10 @@ describe('ConstructivePreset bucket-provisioner wiring', () => { const options = captured.bucketProvisionerOptions; expect(options).toBeDefined(); expect(typeof options.resolveBucketName).toBe('function'); + expect(options.preloadedStorageModules).toEqual([]); + expect(options.preloadedStorageModules).toBe( + captured.presignedOptions.preloadedStorageModules, + ); }); it('the wired resolver mints the tenant-aware {prefix}-{bucketKey}-{databaseId} name', () => { @@ -66,6 +82,17 @@ describe('ConstructivePreset bucket-provisioner wiring', () => { expect(captured.bucketProvisionerOptions.autoProvision).toBe(false); }); + it('passes the same immutable build snapshot to both storage plugins', () => { + const modules = [{ id: 'storage-module-a' }] as any[]; + createConstructivePreset({ preloadedStorageModules: modules }); + + const provisionerSnapshot = captured.bucketProvisionerOptions.preloadedStorageModules; + expect(provisionerSnapshot).toBe(captured.presignedOptions.preloadedStorageModules); + expect(provisionerSnapshot).not.toBe(modules); + expect(Object.isFrozen(provisionerSnapshot)).toBe(true); + expect(Object.isFrozen(provisionerSnapshot[0])).toBe(true); + }); + it('does not wire the provisioner preset when presigned uploads are disabled', () => { createConstructivePreset({ enablePresignedUploads: false }); expect(captured.bucketProvisionerOptions).toBeUndefined(); diff --git a/graphile/graphile-settings/__tests__/grafast-cache-limits.test.ts b/graphile/graphile-settings/__tests__/grafast-cache-limits.test.ts new file mode 100644 index 0000000000..c5d1197bfb --- /dev/null +++ b/graphile/graphile-settings/__tests__/grafast-cache-limits.test.ts @@ -0,0 +1,55 @@ +import type { GraphQLSchemaConfig } from 'graphql'; + +import { + applyGrafastCacheLimits, + createGrafastCacheLimitsPlugin, + createGrafastCacheLimitsPreset, + normalizeGrafastCacheLimits +} from '../src/grafast-cache-limits'; + +const schemaConfig = (): GraphQLSchemaConfig => ({ + extensions: { + existing: true, + grafast: { queryCacheMaxLength: 99 } + } +}); + +describe('Grafast schema-local cache limits', () => { + it('preserves unrelated schema and Grafast extensions', () => { + const result = applyGrafastCacheLimits(schemaConfig(), { + operationsCacheMaxLength: 16, + operationOperationPlansCacheMaxLength: 8 + }); + + expect(result.extensions).toMatchObject({ + existing: true, + grafast: { + queryCacheMaxLength: 99, + operationsCacheMaxLength: 16, + operationOperationPlansCacheMaxLength: 8 + } + }); + }); + + it.each([0, 1, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects an unsafe bound %s', + (value) => { + expect(() => normalizeGrafastCacheLimits({ + operationsCacheMaxLength: value + })).toThrow('must be a safe integer of at least 2'); + } + ); + + it('installs a reusable GraphQLSchema hook', () => { + const plugin = createGrafastCacheLimitsPlugin({ operationsCacheMaxLength: 8 }); + const hook = plugin.schema?.hooks?.GraphQLSchema; + expect(typeof hook).toBe('function'); + + const result = (hook as Function)(schemaConfig(), {}, {}); + expect(result.extensions?.grafast?.operationsCacheMaxLength).toBe(8); + }); + + it('is inert when no limits are configured', () => { + expect(createGrafastCacheLimitsPreset({})).toEqual({}); + }); +}); diff --git a/graphile/graphile-settings/__tests__/introspection-capabilities.test.ts b/graphile/graphile-settings/__tests__/introspection-capabilities.test.ts new file mode 100644 index 0000000000..7d2d195d5b --- /dev/null +++ b/graphile/graphile-settings/__tests__/introspection-capabilities.test.ts @@ -0,0 +1,26 @@ +import { resolveConstructiveIntrospectionCapabilityExtensions } from + '../src/presets/constructive-preset'; + +describe('Constructive scoped-introspection extension capabilities', () => { + it('derives only the exact extensions required by enabled plugins', () => { + expect(resolveConstructiveIntrospectionCapabilityExtensions()).toEqual([ + 'pg_trgm', + 'vector', + 'pg_textsearch', + 'postgis', + 'ltree' + ]); + expect(resolveConstructiveIntrospectionCapabilityExtensions({ + enableSearch: false, + enableLlm: false, + enablePostgis: false, + enableLtree: false + })).toEqual([]); + expect(resolveConstructiveIntrospectionCapabilityExtensions({ + enableSearch: false, + enableLlm: true, + enablePostgis: false, + enableLtree: false + })).toEqual(['vector']); + }); +}); diff --git a/graphile/graphile-settings/__tests__/introspection-client-release.test.ts b/graphile/graphile-settings/__tests__/introspection-client-release.test.ts new file mode 100644 index 0000000000..37bc725e81 --- /dev/null +++ b/graphile/graphile-settings/__tests__/introspection-client-release.test.ts @@ -0,0 +1,253 @@ +import { withPgClientFromPgService } from '@dataplan/pg'; +import { makeSchema } from 'graphile-build'; +import { Pool } from 'pg'; + +import { assertIntrospectionClientReleaseCapabilities } from '../src/introspection-client-release'; +import { MinimalPreset } from '../src/plugins'; + +type TestWithPgClient = { + ( + pgSettings: Record | null, + callback: (client: { rawClient: unknown }) => T | Promise, + options?: { clientReleaseMode?: 'reuse' | 'destroy' } + ): Promise; + supportedClientReleaseModes?: readonly ('reuse' | 'destroy')[]; +}; + +const { + makePgAdaptorWithPgClient, + makeWithPgClientViaPgClientAlreadyInTransaction +} = require('@dataplan/pg/adaptors/pg') as { + makePgAdaptorWithPgClient(pool: unknown): TestWithPgClient; + makeWithPgClientViaPgClientAlreadyInTransaction(client: unknown): TestWithPgClient; +}; + +const { makePgService: makePostGraphilePgService } = require( + 'postgraphile/adaptors/pg' +) as { + makePgService(options: Record): Record; +}; + +const makeRawClient = () => ({ + query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn() +}); + +const makeNodePostgresPool = (rawClient: ReturnType) => { + const pool = Object.create(Pool.prototype) as Pool & { + connect: jest.Mock; + }; + pool.connect = jest.fn().mockResolvedValue(rawClient); + return pool; +}; + +describe('published dependency capability gate', () => { + const supported = { + dataplanPg: 'dataplan-pg-exact-client-destroy-v1', + graphileBuildPg: 'graphile-build-pg-exact-client-destroy-v1' + }; + + it('allows reuse without patched downstream dependencies', () => { + expect(() => assertIntrospectionClientReleaseCapabilities('reuse', { + dataplanPg: undefined, + graphileBuildPg: undefined + })).not.toThrow(); + }); + + it('requires both exact destroy protocol capabilities', () => { + expect(() => assertIntrospectionClientReleaseCapabilities( + 'destroy', + supported + )).not.toThrow(); + expect(() => assertIntrospectionClientReleaseCapabilities('destroy', { + ...supported, + dataplanPg: undefined + })).toThrow( + 'GRAPHILE_INTROSPECTION_CLIENT_DESTROY_UNSUPPORTED:@dataplan/pg' + ); + expect(() => assertIntrospectionClientReleaseCapabilities('destroy', { + ...supported, + graphileBuildPg: undefined + })).toThrow( + 'GRAPHILE_INTROSPECTION_CLIENT_DESTROY_UNSUPPORTED:graphile-build-pg' + ); + }); +}); + +describe('per-use PostgreSQL client release mode', () => { + it('destroys the exact successful checkout once', async () => { + const rawClient = makeRawClient(); + const pool = makeNodePostgresPool(rawClient); + const withPgClient = makePgAdaptorWithPgClient(pool as never); + + expect(withPgClient.supportedClientReleaseModes).toEqual(['reuse', 'destroy']); + await expect(withPgClient( + null, + async (client) => client.rawClient, + { clientReleaseMode: 'destroy' } + )).resolves.toBe(rawClient); + + expect(pool.connect).toHaveBeenCalledTimes(1); + expect(rawClient.release.mock.calls).toEqual([[true]]); + }); + + it('destroys the exact failed checkout once', async () => { + const marker = new Error('callback failed'); + const rawClient = makeRawClient(); + const pool = makeNodePostgresPool(rawClient); + const withPgClient = makePgAdaptorWithPgClient(pool as never); + + await expect(withPgClient( + null, + async () => { + throw marker; + }, + { clientReleaseMode: 'destroy' } + )).rejects.toBe(marker); + + expect(pool.connect).toHaveBeenCalledTimes(1); + expect(rawClient.release.mock.calls).toEqual([[true]]); + }); + + it('destroys the exact checkout when first-use setup throws synchronously', async () => { + const marker = new Error('client setup failed'); + const rawClient = makeRawClient(); + rawClient.query.mockImplementationOnce(() => { + throw marker; + }); + const pool = makeNodePostgresPool(rawClient); + const callback = jest.fn(); + const withPgClient = makePgAdaptorWithPgClient(pool as never); + + await expect(withPgClient( + null, + callback, + { clientReleaseMode: 'destroy' } + )).rejects.toBe(marker); + + expect(callback).not.toHaveBeenCalled(); + expect(pool.connect).toHaveBeenCalledTimes(1); + expect(rawClient.release.mock.calls).toEqual([[true]]); + }); + + it('rejects destroy mode for a structurally compatible custom pool', async () => { + const rawClient = makeRawClient(); + const pool = { connect: jest.fn().mockResolvedValue(rawClient) }; + const callback = jest.fn(); + const withPgClient = makePgAdaptorWithPgClient(pool as never); + + expect(withPgClient.supportedClientReleaseModes).toEqual(['reuse']); + await expect(withPgClient( + null, + callback, + { clientReleaseMode: 'destroy' } + )).rejects.toThrow( + 'Exact PostgreSQL client destruction requires a node-postgres Pool' + ); + + expect(pool.connect).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + expect(rawClient.release).not.toHaveBeenCalled(); + }); + + it('reuses the exact checkout once by default', async () => { + const rawClient = makeRawClient(); + const pool = makeNodePostgresPool(rawClient); + const withPgClient = makePgAdaptorWithPgClient(pool as never); + + await withPgClient(null, async (client) => client.rawClient); + + expect(pool.connect).toHaveBeenCalledTimes(1); + expect(rawClient.release.mock.calls).toEqual([[]]); + }); + + it('fails closed before invoking an adaptor that does not advertise destruction', async () => { + const callback = jest.fn(); + const originalWithPgClient = Object.assign( + jest.fn(async (): Promise => undefined), + { release: jest.fn() } + ); + const service = { + name: 'unsupported-adaptor', + adaptor: { + createWithPgClient: jest.fn().mockReturnValue(originalWithPgClient) + } + }; + + await expect(withPgClientFromPgService( + service as never, + null, + callback, + { clientReleaseMode: 'destroy' } + )).rejects.toThrow( + "PostgreSQL service 'unsupported-adaptor' does not support exact client destruction" + ); + + expect(callback).not.toHaveBeenCalled(); + expect(originalWithPgClient).not.toHaveBeenCalled(); + }); + + it('refuses to destroy a caller-owned client', async () => { + const callback = jest.fn(); + const rawClient = makeRawClient(); + const withPgClient = makeWithPgClientViaPgClientAlreadyInTransaction( + rawClient as never + ); + + expect(withPgClient.supportedClientReleaseModes).toEqual(['reuse']); + await expect(withPgClient( + null, + callback, + { clientReleaseMode: 'destroy' } + )).rejects.toThrow('Cannot destroy a caller-owned PostgreSQL client'); + + expect(callback).not.toHaveBeenCalled(); + expect(rawClient.release).not.toHaveBeenCalled(); + }); +}); + +describe('introspection client release forwarding', () => { + it.each([ + ['destroy', 'destroy', [[true]]], + ['default reuse', undefined, [[]]] + ] as const)('uses %s for the introspection checkout', async ( + _label, + clientReleaseMode, + expectedReleaseCalls + ) => { + const marker = new Error('captured introspection query'); + let sawIntrospection = false; + const rawClient = makeRawClient(); + rawClient.query.mockImplementation(async (query: string | { text: string }) => { + if ( + typeof query === 'object' + && query.text.includes('requested_schema_names') + ) { + sawIntrospection = true; + throw marker; + } + return { rows: [], rowCount: 0 }; + }); + const pool = makeNodePostgresPool(rawClient); + + await expect(makeSchema({ + extends: [MinimalPreset], + pgServices: [Object.assign(makePostGraphilePgService({ + pool: pool as never, + pubsub: false, + schemas: ['tenant_a'] + }), { + introspectionMode: 'scoped-required', + ...(clientReleaseMode === undefined + ? {} + : { introspectionClientReleaseMode: clientReleaseMode }) + }) as never] + })).rejects.toBe(marker); + + expect(sawIntrospection).toBe(true); + expect(pool.connect).toHaveBeenCalledTimes(1); + expect(rawClient.release.mock.calls).toEqual(expectedReleaseCalls); + }); +}); diff --git a/graphile/graphile-settings/__tests__/make-pg-service.test.ts b/graphile/graphile-settings/__tests__/make-pg-service.test.ts new file mode 100644 index 0000000000..f5890731b5 --- /dev/null +++ b/graphile/graphile-settings/__tests__/make-pg-service.test.ts @@ -0,0 +1,42 @@ +import { + normalizeIntrospectionDependencySchemas, + resolveIntrospectionSettings +} from '../src/introspection-settings'; + +describe('resolveIntrospectionSettings', () => { + it('disables JIT only for scoped introspection', () => { + const scoped = resolveIntrospectionSettings( + 'scoped-required', + { statement_timeout: '5000', jit: 'on', work_mem: '16MB' } + ); + const stock = resolveIntrospectionSettings('stock', { statement_timeout: '5000' }); + const defaultBound = resolveIntrospectionSettings('stock', undefined); + + expect(scoped).toEqual({ + statement_timeout: '5000', + jit: 'off', + work_mem: '512kB' + }); + expect(stock).toEqual({ statement_timeout: '5000' }); + expect(defaultBound).toEqual({ statement_timeout: '120s' }); + }); +}); + +describe('normalizeIntrospectionDependencySchemas', () => { + it('preserves lookup order while trimming and deduplicating', () => { + expect(normalizeIntrospectionDependencySchemas([ + ' extensions ', + 'shared_api', + 'extensions' + ])).toEqual(['extensions', 'shared_api']); + }); + + it.each(['pg_catalog', 'pg_toast', 'information_schema'])( + 'rejects system dependency schema %s', + (schema) => { + expect(() => normalizeIntrospectionDependencySchemas([schema])).toThrow( + 'must not be a system schema' + ); + } + ); +}); diff --git a/graphile/graphile-settings/__tests__/scoped-bm25-cross-database.integration.test.ts b/graphile/graphile-settings/__tests__/scoped-bm25-cross-database.integration.test.ts new file mode 100644 index 0000000000..fb57c9bb61 --- /dev/null +++ b/graphile/graphile-settings/__tests__/scoped-bm25-cross-database.integration.test.ts @@ -0,0 +1,362 @@ +import { randomUUID } from 'crypto'; + +import { QuoteUtils } from '@pgsql/quotes'; +import { execute } from 'grafast'; +import { makeSchema } from 'graphile-build'; +import { withPgClientFromPgService } from 'graphile-build-pg'; +import type { GraphileConfig } from 'graphile-config'; +import { parse, type ExecutionResult } from 'graphql'; +import { Pool } from 'pg'; + +import { resolveIntrospectionSettings } from '../src/introspection-settings'; +import { ConstructivePreset } from '../src/presets/constructive-preset'; + +const { makePgService: makePostGraphilePgService } = require('postgraphile/adaptors/pg') as { + makePgService(options: Record): any; +}; + +jest.setTimeout(120000); + +const API_SCHEMA = 'tenant_api'; + +function makePgService( + options: Record & { + introspectionMode: 'stock' | 'scoped-required'; + } +) { + const pgSettingsForIntrospection = resolveIntrospectionSettings( + options.introspectionMode, + options.pgSettingsForIntrospection as Record | undefined + ); + return Object.assign(makePostGraphilePgService({ + ...options, + pgSettingsForIntrospection + }), { + introspectionMode: options.introspectionMode, + introspectionAllowedDependencySchemas: options.introspectionAllowedDependencySchemas, + introspectionCapabilityExtensions: options.introspectionCapabilityExtensions + }); +} + +interface FixtureSpec { + database: string; + role: string; + unrelatedAclRole: string; + password: string; + index: string; + token: string; +} + +interface DocumentsResult { + documents: { + nodes: Array<{ + body: string; + bodyBm25Score: number | null; + }>; + }; +} + +interface BuiltApi { + pool: Pool; + query: (term: string) => Promise>; +} + +async function readPlannerSettings(pool: Pool) { + return (await pool.query<{ + pid: number; + jit: string; + work_mem: string; + }>(` + SELECT + pg_backend_pid() AS pid, + current_setting('jit') AS jit, + current_setting('work_mem') AS work_mem + `)).rows[0]; +} + +function fixtureSpec(label: 'a' | 'b', token: string): FixtureSpec { + const suffix = randomUUID().replace(/-/g, '').slice(0, 10); + return { + database: `gsi_bm25_${label}_${suffix}`, + role: `gsi_bm25_${label}_role_${suffix}`, + unrelatedAclRole: `gsi_bm25_${label}_auditor_${suffix}`, + password: `gsi_${suffix}_${label}_local_only`, + index: `${label}_opaque_lexicon_${suffix}`, + token + }; +} + +async function provisionFixture( + adminPool: Pool, + spec: FixtureSpec, + createdDatabases: string[], + createdRoles: string[] +): Promise { + const database = QuoteUtils.quoteIdentifier(spec.database); + const role = QuoteUtils.quoteIdentifier(spec.role); + const unrelatedAclRole = QuoteUtils.quoteIdentifier(spec.unrelatedAclRole); + + await adminPool.query( + `CREATE ROLE ${role} + LOGIN PASSWORD ${QuoteUtils.escape(spec.password)} + NOSUPERUSER NOBYPASSRLS NOCREATEROLE NOCREATEDB NOREPLICATION NOINHERIT` + ); + createdRoles.push(spec.role); + + await adminPool.query( + `CREATE ROLE ${unrelatedAclRole} + NOLOGIN NOSUPERUSER NOBYPASSRLS NOCREATEROLE NOCREATEDB NOREPLICATION NOINHERIT` + ); + createdRoles.push(spec.unrelatedAclRole); + + await adminPool.query(`CREATE DATABASE ${database}`); + createdDatabases.push(spec.database); + await adminPool.query(`REVOKE CONNECT ON DATABASE ${database} FROM PUBLIC`); + await adminPool.query(`GRANT CONNECT ON DATABASE ${database} TO ${role}`); + + const ownerPool = new Pool({ database: spec.database }); + try { + await ownerPool.query('CREATE EXTENSION pg_textsearch'); + await ownerPool.query(`CREATE SCHEMA ${API_SCHEMA}`); + await ownerPool.query(`REVOKE ALL ON SCHEMA ${API_SCHEMA} FROM PUBLIC`); + await ownerPool.query(` + CREATE TABLE ${API_SCHEMA}.documents ( + id integer PRIMARY KEY, + body text NOT NULL + ) + `); + await ownerPool.query(` + INSERT INTO ${API_SCHEMA}.documents (id, body) + VALUES (1, $1) + `, [spec.token]); + await ownerPool.query(` + CREATE INDEX ${QuoteUtils.quoteIdentifier(spec.index)} + ON ${API_SCHEMA}.documents USING bm25(body) + WITH (text_config = 'english') + `); + await ownerPool.query(`GRANT USAGE ON SCHEMA ${API_SCHEMA} TO ${role}`); + await ownerPool.query(`GRANT SELECT ON ${API_SCHEMA}.documents TO ${role}`); + // PgRBACPlugin resolves every retained ACL entry, even when the grantee is + // unrelated to the runtime login. Scoped introspection must retain this role. + await ownerPool.query( + `GRANT SELECT ON ${API_SCHEMA}.documents TO ${unrelatedAclRole}` + ); + } finally { + await ownerPool.end(); + } +} + +async function assertLeastPrivilege(pool: Pool, spec: FixtureSpec): Promise { + const result = await pool.query<{ + rolname: string; + rolsuper: boolean; + rolbypassrls: boolean; + rolcreaterole: boolean; + rolcreatedb: boolean; + rolreplication: boolean; + rolinherit: boolean; + owns_database: boolean; + owns_schema: boolean; + can_create_in_schema: boolean; + }>(` + SELECT + role.rolname, + role.rolsuper, + role.rolbypassrls, + role.rolcreaterole, + role.rolcreatedb, + role.rolreplication, + role.rolinherit, + database.datdba = role.oid AS owns_database, + namespace.nspowner = role.oid AS owns_schema, + pg_catalog.has_schema_privilege( + role.oid, + namespace.oid, + 'CREATE' + ) AS can_create_in_schema + FROM pg_catalog.pg_roles AS role + JOIN pg_catalog.pg_database AS database + ON database.datname = current_database() + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.nspname = $1 + WHERE role.rolname = current_user + `, [API_SCHEMA]); + + expect(result.rows).toEqual([{ + rolname: spec.role, + rolsuper: false, + rolbypassrls: false, + rolcreaterole: false, + rolcreatedb: false, + rolreplication: false, + rolinherit: false, + owns_database: false, + owns_schema: false, + can_create_in_schema: false + }]); +} + +async function expectUnapprovedExtensionSchemaRejected(spec: FixtureSpec): Promise { + const pool = new Pool({ + database: spec.database, + user: spec.role, + password: spec.password, + max: 1 + }); + try { + const settingsBefore = await readPlannerSettings(pool); + const pgService = makePgService({ + pool, + schemas: [API_SCHEMA], + introspectionMode: 'scoped-required', + // Naming a capability controls retained extension metadata; it must not + // silently approve the extension's physical schema. + introspectionCapabilityExtensions: ['pg_textsearch'] + }); + await expect(makeSchema({ + extends: [ConstructivePreset], + pgServices: [pgService] + })).rejects.toThrow('crossed into unapproved dependency schema(s): public'); + expect(await readPlannerSettings(pool)).toEqual(settingsBefore); + } finally { + await pool.end(); + } +} + +async function buildApi(spec: FixtureSpec): Promise { + const pool = new Pool({ + database: spec.database, + user: spec.role, + password: spec.password, + max: 1 + }); + + try { + await assertLeastPrivilege(pool, spec); + const settingsBefore = await readPlannerSettings(pool); + + const pgService = makePgService({ + pool, + schemas: [API_SCHEMA], + introspectionMode: 'scoped-required', + introspectionAllowedDependencySchemas: ['public'], + introspectionCapabilityExtensions: ['pg_textsearch'] + }); + const preset: GraphileConfig.Preset = { + // Deliberately reuse the module-level preset, including its plugin objects. + extends: [ConstructivePreset], + pgServices: [pgService] + }; + const { schema, resolvedPreset } = await makeSchema(preset); + expect(await readPlannerSettings(pool)).toEqual(settingsBefore); + const withPgClientKey = pgService.withPgClientKey ?? 'withPgClient'; + + return { + pool, + async query(term: string) { + const result = await execute({ + schema, + document: parse(` + query ScopedBm25Isolation($term: String!) { + documents(where: { bm25Body: { query: $term } }) { + nodes { + body + bodyBm25Score + } + } + } + `), + variableValues: { term }, + contextValue: { + pgSettings: {}, + [withPgClientKey]: withPgClientFromPgService.bind(null, pgService) + }, + resolvedPreset + }); + if (Symbol.asyncIterator in result) { + throw new Error('BM25 isolation query unexpectedly returned a stream'); + } + return result as unknown as ExecutionResult; + } + }; + } catch (error) { + await pool.end(); + throw error; + } +} + +async function expectDatabaseConnectionDenied( + source: FixtureSpec, + target: FixtureSpec +): Promise { + const crossTenantPool = new Pool({ + database: target.database, + user: source.role, + password: source.password, + max: 1 + }); + try { + await expect(crossTenantPool.query('SELECT 1')).rejects.toMatchObject({ + code: '42501' + }); + } finally { + await crossTenantPool.end(); + } +} + +describe('scoped BM25 cross-database isolation', () => { + it('keeps same-named tenant APIs bound to their own database and BM25 index', async () => { + const adminPool = new Pool({ database: 'postgres', max: 1 }); + const createdDatabases: string[] = []; + const createdRoles: string[] = []; + const builtApis: BuiltApi[] = []; + const tenantA = fixtureSpec('a', 'amber lunar archive tenant-a-only'); + const tenantB = fixtureSpec('b', 'violet orchard ledger tenant-b-only'); + + try { + await provisionFixture(adminPool, tenantA, createdDatabases, createdRoles); + await provisionFixture(adminPool, tenantB, createdDatabases, createdRoles); + + await expectDatabaseConnectionDenied(tenantA, tenantB); + await expectDatabaseConnectionDenied(tenantB, tenantA); + await expectUnapprovedExtensionSchemaRejected(tenantA); + + // Build sequentially so the second build exercises reuse of the exact same + // long-lived ConstructivePreset and its UnifiedSearchPlugin instance. + const apiA = await buildApi(tenantA); + builtApis.push(apiA); + const apiB = await buildApi(tenantB); + builtApis.push(apiB); + + const [resultA, resultB] = await Promise.all([ + apiA.query('lunar'), + apiB.query('orchard') + ]); + + expect(resultA.errors).toBeUndefined(); + expect(resultB.errors).toBeUndefined(); + expect(resultA.data?.documents.nodes).toEqual([{ + body: tenantA.token, + bodyBm25Score: expect.any(Number) + }]); + expect(resultB.data?.documents.nodes).toEqual([{ + body: tenantB.token, + bodyBm25Score: expect.any(Number) + }]); + } finally { + await Promise.allSettled(builtApis.map(({ pool }) => pool.end())); + + for (const databaseName of [...createdDatabases].reverse()) { + await adminPool.query( + `DROP DATABASE IF EXISTS ${QuoteUtils.quoteIdentifier(databaseName)} WITH (FORCE)` + ); + } + for (const roleName of [...createdRoles].reverse()) { + await adminPool.query( + `DROP ROLE IF EXISTS ${QuoteUtils.quoteIdentifier(roleName)}` + ); + } + await adminPool.end(); + } + }); +}); diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-cache-lifecycle.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-cache-lifecycle.test.ts new file mode 100644 index 0000000000..23834db60a --- /dev/null +++ b/graphile/graphile-settings/__tests__/scoped-introspection-cache-lifecycle.test.ts @@ -0,0 +1,211 @@ +import { watchGather } from 'graphile-build'; +import { PgIntrospectionPlugin } from 'graphile-build-pg'; +import type { GraphileConfig } from 'graphile-config'; + +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: [] +}); + +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 = PgIntrospectionPlugin.gather!; + const capturingIntrospectionPlugin = { + ...PgIntrospectionPlugin, + 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 [result] = await info.helpers.pgIntrospection.getIntrospection(); + 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 = missingSchemaIntrospectionText; + triggerRegather!(); + const invalid = await results.next(); + expect(invalid.input).toBeNull(); + expect(invalid.error?.message).toContain( + `did not find required schema(s): ${SCHEMA}` + ); + expect(query).toHaveBeenCalledTimes(3); + 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(4); + + 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(5); + expect(cache!.introspectionResultsPromise).toBeNull(); + } finally { + stopWatching(); + } + }); +}); diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-capability-closure.integration.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-capability-closure.integration.test.ts new file mode 100644 index 0000000000..6d0789621a --- /dev/null +++ b/graphile/graphile-settings/__tests__/scoped-introspection-capability-closure.integration.test.ts @@ -0,0 +1,729 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { execute } from 'grafast'; +import { makeSchema } from 'graphile-build'; +import { withPgClientFromPgService } from 'graphile-build-pg'; +import type { GraphileConfig } from 'graphile-config'; +import { + type ExecutionResult, + type GraphQLSchema, + isObjectType, + lexicographicSortSchema, + parse, + printSchema +} from 'graphql'; +import type { Pool } from 'pg'; + +import { resolveIntrospectionSettings } from '../src/introspection-settings'; +import { createConstructivePreset } from '../src/presets/constructive-preset'; + +jest.setTimeout(180000); + +const API_SCHEMA = 'closure_api'; +const PLAIN_API_SCHEMA = 'closure_plain_api'; +const EXTENSION_SCHEMA = 'closure_ext'; +const FIXTURE = join( + __dirname, + '../sql/scoped-introspection-capability-closure.sql' +); +const REQUIRED_EXTENSIONS = [ + 'ltree', + 'pg_textsearch', + 'pg_trgm', + 'postgis', + 'vector' +] as const; +const REQUIRED_INDEXES = [ + 'closure_records_body_bm25_idx', + 'closure_records_embedding_idx', + 'closure_records_geom_idx', + 'closure_records_name_trgm_idx', + 'closure_records_path_idx', + 'closure_records_pkey', + 'closure_records_score_window_idx', + 'closure_records_search_document_idx' +] as const; +const REQUIRED_RECORD_FIELDS = [ + 'id', + 'name', + 'body', + 'status', + 'statuses', + 'label', + 'labels', + 'payload', + 'payloads', + 'scoreWindow', + 'scoreWindowArray', + 'scoreWindows', + 'searchDocument', + 'path', + 'paths', + 'embedding', + 'embeddings', + 'geom', + 'geoms' +] as const; +const MAX_SERVICE_CATALOG_ROWS = 128; + +const CORE_DOCUMENT = parse(` + query ClosureCoreTypes { + closureRecords { + nodes { + id + name + status + statuses + label + labels + payload { label weight } + payloads { label weight } + scoreWindow { + start { value inclusive } + end { value inclusive } + } + scoreWindowArray { + start { value inclusive } + end { value inclusive } + } + scoreWindows { + start { value inclusive } + end { value inclusive } + } + path + paths + embedding + embeddings + geom { geojson } + } + } + closureFunctionProbe { + nodes { id name } + } + closureMultirangeProbe( + expected: [ + { + start: { value: "40", inclusive: true } + end: { value: "50", inclusive: false } + } + { + start: { value: "60", inclusive: true } + end: { value: "70", inclusive: true } + } + ] + ) { + start { value inclusive } + end { value inclusive } + } + closureTimestampMultirangeProbe( + expected: [ + { + start: { value: "2026-08-01T00:00:00Z", inclusive: true } + end: { value: "2026-08-02T00:00:00Z", inclusive: false } + } + { + start: { value: "2026-08-03T00:00:00Z", inclusive: true } + end: { value: "2026-08-04T00:00:00Z", inclusive: true } + } + ] + ) { + start { value inclusive } + end { value inclusive } + } + } +`); + +const EXTENSION_DOCUMENT = parse(` + query ClosureExtensionCapabilities($bbox: GeoJSON!) { + tsvector: closureRecords( + where: { tsvSearchDocument: "tenant" } + ) { + nodes { id searchDocumentTsvRank } + } + trigram: closureRecords( + where: { trgmName: { value: "Acme", threshold: 0.1 } } + ) { + nodes { id nameTrgmSimilarity } + } + bm25: closureRecords( + where: { bm25Body: { query: "tenant" } } + ) { + nodes { id bodyBm25Score } + } + vector: closureRecords( + where: { + vectorEmbedding: { + nearby: { embedding: [1, 0, 0], distance: 0.1 } + } + } + ) { + nodes { id embeddingVectorDistance } + } + postgis: closureRecords( + where: { geom: { intersects: $bbox } } + ) { + nodes { id } + } + ltree: closureRecords( + where: { path: { within: "/customers" } } + ) { + nodes { id path } + } + } +`); + +const PLAIN_EXTENSION_DOCUMENT = parse(` + query InstalledExtensionWithoutObjectDependency { + plainRecords( + where: { trgmBody: { value: "tenant", threshold: 0.1 } } + ) { + nodes { id body bodyTrgmSimilarity } + } + } +`); + +const BBOX = { + type: 'Polygon', + coordinates: [[ + [-74.1, 40.6], + [-73.9, 40.6], + [-73.9, 40.9], + [-74.1, 40.9], + [-74.1, 40.6] + ]], + crs: { + type: 'name', + properties: { name: 'EPSG:4326' } + } +}; + +type PgTestClientLike = { + config: Record; + query: (text: string, values?: unknown[]) => Promise<{ + rows: T[]; + }>; + beforeEach: () => Promise; + afterEach: () => Promise; +}; + +type ConnectionResult = { + pg: PgTestClientLike; + manager: { getPool: (config: Record) => Pool }; + teardown: () => Promise; +}; + +type RawIntrospection = { + namespaces: Array<{ _id: string; nspname: string }>; + classes: Array<{ _id: string; relname: string }>; + types: Array<{ + _id: string; + typname: string; + typnamespace: string; + typtype: string; + typelem: string; + typarray: string; + }>; + extensions: Array<{ extname: string }>; + languages: Array<{ lanname: string }>; + am: Array<{ amname: string }>; + indexes: Array<{ indexrelid: string }>; +}; + +type BuiltSchema = Awaited> & { + pgService: ReturnType; + schemaName: string; +}; + +const { makePgService: makePostGraphilePgService } = require( + 'postgraphile/adaptors/pg' +) as { + makePgService: ( + options: Record + ) => GraphileConfig.PgServiceConfiguration; +}; + +function makePgService(options: { + pool: Pool; + schemas: readonly string[]; + introspectionMode: 'stock' | 'scoped-required'; + introspectionScopedCatalogTypes?: 'dependency-closure'; + introspectionAllowedDependencySchemas?: readonly string[]; + introspectionCapabilityExtensions?: readonly string[]; +}) { + const pgSettingsForIntrospection = resolveIntrospectionSettings( + options.introspectionMode, + undefined + ); + return Object.assign(makePostGraphilePgService({ + pool: options.pool, + schemas: options.schemas, + pgSettingsForIntrospection + }), { + introspectionMode: options.introspectionMode, + introspectionScopedCatalogTypes: options.introspectionScopedCatalogTypes, + introspectionAllowedDependencySchemas: + options.introspectionAllowedDependencySchemas ?? [], + ...(options.introspectionCapabilityExtensions === undefined + ? {} + : { + introspectionCapabilityExtensions: + options.introspectionCapabilityExtensions + }) + }); +} + +const graphileTestDirectory = dirname(require.resolve('graphile-test')); +const { getConnections } = require(require.resolve('pgsql-test', { + paths: [graphileTestDirectory] +})) as { + getConnections: ( + options: Record, + seeders: never[] + ) => Promise; +}; + +const graphileBuildPgDirectory = dirname(require.resolve('graphile-build-pg')); +const { + makeIntrospectionQuery, + makeSchemaScopedIntrospectionQuery, + parseIntrospectionResults +} = require(require.resolve('pg-introspection', { + paths: [graphileBuildPgDirectory] +})) as { + makeIntrospectionQuery: () => string; + makeSchemaScopedIntrospectionQuery: ( + schemas: readonly string[], + options: { + catalogTypes: 'dependency-closure'; + capabilityExtensions?: readonly string[]; + } + ) => { text: string; values: [string[], string[]] }; + parseIntrospectionResults: (value: string) => RawIntrospection; +}; + +function unsupportedExtensions(missing: readonly string[]): Error & { + code: string; +} { + const code = 'GRAPHILE_CLOSURE_UNSUPPORTED_EXTENSIONS'; + return Object.assign( + new Error(`${code}: ${sorted(missing).join(', ')}`), + { code } + ); +} + +function unsupportedExtensionSchema(extension: string): Error & { + code: string; + extension: string; +} { + const code = 'GRAPHILE_CLOSURE_UNSUPPORTED_EXTENSION_SCHEMA'; + return Object.assign(new Error(`${code}: ${extension}`), { + code, + extension + }); +} + +function extensionNamedBy(error: unknown): string | undefined { + const candidate = error as { message?: unknown; detail?: unknown }; + const diagnostic = [candidate?.message, candidate?.detail] + .filter((value): value is string => typeof value === 'string') + .join(' ') + .toLowerCase(); + return REQUIRED_EXTENSIONS.find((extension) => + diagnostic.includes(extension.toLowerCase()) + ); +} + +function sorted(values: Iterable): string[] { + return [...values].sort((left, right) => left.localeCompare(right)); +} + +function captureCatalog(introspection: RawIntrospection) { + const namespaces = new Map( + introspection.namespaces.map(({ _id, nspname }) => [String(_id), nspname]) + ); + const classes = new Map( + introspection.classes.map(({ _id, relname }) => [String(_id), relname]) + ); + const capturedTypeNames = new Set([ + `${API_SCHEMA}.closure_status`, + `${API_SCHEMA}._closure_status`, + `${API_SCHEMA}.closure_label`, + `${API_SCHEMA}._closure_label`, + `${API_SCHEMA}.closure_payload`, + `${API_SCHEMA}._closure_payload`, + `${API_SCHEMA}.score_window`, + `${API_SCHEMA}._score_window`, + `${API_SCHEMA}.score_window_multirange`, + `${API_SCHEMA}._score_window_multirange`, + `${EXTENSION_SCHEMA}.ltree`, + `${EXTENSION_SCHEMA}._ltree`, + `${EXTENSION_SCHEMA}.vector`, + `${EXTENSION_SCHEMA}._vector`, + `${EXTENSION_SCHEMA}.geometry`, + `${EXTENSION_SCHEMA}._geometry`, + 'pg_catalog.tsvector', + 'pg_catalog._tsvector' + ]); + + return { + namespaces: sorted( + introspection.namespaces + .map(({ nspname }) => nspname) + .filter((name) => + [API_SCHEMA, EXTENSION_SCHEMA, 'pg_catalog'].includes(name) + ) + ), + extensions: sorted( + introspection.extensions + .map(({ extname }) => extname) + .filter((name) => (REQUIRED_EXTENSIONS as readonly string[]).includes(name)) + ), + indexes: sorted( + introspection.indexes + .map(({ indexrelid }) => classes.get(String(indexrelid))) + .filter((name): name is string => + name !== undefined && (REQUIRED_INDEXES as readonly string[]).includes(name) + ) + ), + types: introspection.types + .map((type) => ({ + name: `${namespaces.get(String(type.typnamespace))}.${type.typname}`, + typtype: type.typtype, + typelem: String(type.typelem), + typarray: String(type.typarray) + })) + .filter(({ name }) => capturedTypeNames.has(name)) + .sort((left, right) => left.name.localeCompare(right.name)) + }; +} + +function recordFields(schema: GraphQLSchema): string[] { + const type = schema.getType('ClosureRecord'); + if (!type || !isObjectType(type)) return []; + return sorted(Object.keys(type.getFields())); +} + +function qualification(schema: GraphQLSchema): { + code?: string; + missingFields: string[]; + productionQualified: boolean; +} { + const fields = new Set(recordFields(schema)); + const queryFields = new Set(Object.keys(schema.getQueryType()?.getFields() ?? {})); + const missingFields = [ + ...REQUIRED_RECORD_FIELDS + .filter((field) => !fields.has(field)) + .map((field) => `ClosureRecord.${field}`), + ...[ + 'closureRecords', + 'closureFunctionProbe', + 'closureMultirangeProbe', + 'closureTimestampMultirangeProbe' + ] + .filter((field) => !queryFields.has(field)) + .map((field) => `Query.${field}`) + ]; + + return missingFields.length === 0 + ? { productionQualified: true, missingFields: [] } + : { + code: 'GRAPHILE_CLOSURE_CAPABILITY_MISMATCH', + missingFields, + productionQualified: false + }; +} + +async function build( + pool: Pool, + mode: 'stock' | 'scoped-required', + schemaName = API_SCHEMA +) { + const pgService = makePgService({ + pool, + schemas: [schemaName], + introspectionMode: mode, + ...(mode === 'scoped-required' + ? { + introspectionScopedCatalogTypes: 'dependency-closure' as const, + introspectionAllowedDependencySchemas: [EXTENSION_SCHEMA], + introspectionCapabilityExtensions: REQUIRED_EXTENSIONS + } + : {}) + }); + const built = await makeSchema({ + extends: [createConstructivePreset({ preloadedStorageModules: [] })], + pgServices: [pgService] + }); + return { ...built, pgService, schemaName }; +} + +async function runDocument( + built: BuiltSchema, + document: ReturnType, + variableValues: Record = {} +): Promise>> { + const withPgClientKey = built.pgService.withPgClientKey ?? 'withPgClient'; + const result = await execute({ + schema: built.schema, + document, + variableValues, + contextValue: { + // Shared extension schemas are deliberately absent. Plugin SQL must use + // the introspected physical schema instead of relying on search_path. + pgSettings: { search_path: `pg_catalog,${built.schemaName}` }, + [withPgClientKey]: withPgClientFromPgService.bind( + null, + built.pgService + ) + }, + resolvedPreset: built.resolvedPreset + }) as ExecutionResult>; + if (Symbol.asyncIterator in result) { + throw new Error('capability closure canary unexpectedly returned a stream'); + } + return result; +} + +describe('schema-scoped dependency-closure capability matrix', () => { + let pg: PgTestClientLike; + let pool: Pool; + let teardown: () => Promise; + let stock: BuiltSchema; + let scoped: BuiltSchema; + let plainStock: BuiltSchema; + let plainScoped: BuiltSchema; + let stockIntrospection: RawIntrospection; + let scopedIntrospection: RawIntrospection; + let plainScopedIntrospection: RawIntrospection; + let transactionStarted = false; + + beforeAll(async () => { + const connections = await getConnections({}, []); + ({ pg, teardown } = connections); + pool = connections.manager.getPool(pg.config); + + const available = await pg.query<{ name: string }>(` + SELECT name + FROM pg_catalog.pg_available_extensions + WHERE name = ANY($1::text[]) + `, [[...REQUIRED_EXTENSIONS]]); + const found = new Set(available.rows.map(({ name }) => name)); + const missing = REQUIRED_EXTENSIONS.filter((name) => !found.has(name)); + if (missing.length > 0) throw unsupportedExtensions(missing); + + try { + await pg.query(readFileSync(FIXTURE, 'utf8')); + } catch (error) { + const extension = extensionNamedBy(error); + if (extension) throw unsupportedExtensionSchema(extension); + throw error; + } + + const stockQuery = { text: makeIntrospectionQuery() }; + const scopedQuery = makeSchemaScopedIntrospectionQuery([API_SCHEMA], { + catalogTypes: 'dependency-closure', + capabilityExtensions: REQUIRED_EXTENSIONS + }); + const plainScopedQuery = makeSchemaScopedIntrospectionQuery( + [PLAIN_API_SCHEMA], + { + catalogTypes: 'dependency-closure', + capabilityExtensions: REQUIRED_EXTENSIONS + } + ); + const [stockResult, scopedResult, plainScopedResult] = await Promise.all([ + pool.query<{ introspection: string }>(stockQuery), + pool.query<{ introspection: string }>(scopedQuery), + pool.query<{ introspection: string }>(plainScopedQuery) + ]); + stockIntrospection = parseIntrospectionResults( + stockResult.rows[0].introspection + ); + scopedIntrospection = parseIntrospectionResults( + scopedResult.rows[0].introspection + ); + plainScopedIntrospection = parseIntrospectionResults( + plainScopedResult.rows[0].introspection + ); + + stock = await build(pool, 'stock'); + scoped = await build(pool, 'scoped-required'); + plainStock = await build(pool, 'stock', PLAIN_API_SCHEMA); + plainScoped = await build(pool, 'scoped-required', PLAIN_API_SCHEMA); + }); + + beforeEach(async () => { + await pg.beforeEach(); + transactionStarted = true; + }); + + afterEach(async () => { + if (transactionStarted) { + transactionStarted = false; + await pg.afterEach(); + } + }); + + afterAll(async () => { + if (teardown) await teardown(); + }); + + it('retains the required type, extension, namespace, and index closure', async () => { + const stockCatalog = captureCatalog(stockIntrospection); + const scopedCatalog = captureCatalog(scopedIntrospection); + + expect(scopedCatalog).toEqual(stockCatalog); + expect(scopedCatalog.namespaces).toEqual([ + API_SCHEMA, + EXTENSION_SCHEMA, + 'pg_catalog' + ]); + expect(scopedCatalog.extensions).toEqual([...REQUIRED_EXTENSIONS]); + expect(scopedCatalog.indexes).toEqual([...REQUIRED_INDEXES]); + expect(scopedCatalog.types.map(({ name, typtype }) => ({ name, typtype }))) + .toEqual(expect.arrayContaining([ + { name: `${API_SCHEMA}.closure_status`, typtype: 'e' }, + { name: `${API_SCHEMA}.closure_label`, typtype: 'd' }, + { name: `${API_SCHEMA}.closure_payload`, typtype: 'c' }, + { name: `${API_SCHEMA}.score_window`, typtype: 'r' }, + { name: `${API_SCHEMA}.score_window_multirange`, typtype: 'm' }, + { name: `${API_SCHEMA}._closure_status`, typtype: 'b' }, + { name: `${API_SCHEMA}._closure_label`, typtype: 'b' }, + { name: `${API_SCHEMA}._closure_payload`, typtype: 'b' }, + { name: `${API_SCHEMA}._score_window`, typtype: 'b' }, + { name: `${API_SCHEMA}._score_window_multirange`, typtype: 'b' } + ])); + + const installed = await pg.query<{ + extname: string; + nspname: string; + }>(` + SELECT extension.extname, namespace.nspname + FROM pg_catalog.pg_extension AS extension + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = extension.extnamespace + WHERE extension.extname = ANY($1::text[]) + ORDER BY extension.extname + `, [[...REQUIRED_EXTENSIONS]]); + expect(installed.rows).toEqual( + REQUIRED_EXTENSIONS.map((extname) => ({ + extname, + nspname: EXTENSION_SCHEMA + })) + ); + }); + + it('builds byte-equivalent SDL and qualifies every required field', () => { + const stockSdl = printSchema(lexicographicSortSchema(stock.schema)); + const scopedSdl = printSchema(lexicographicSortSchema(scoped.schema)); + + expect(scopedSdl).toBe(stockSdl); + expect(recordFields(stock.schema)).toEqual( + expect.arrayContaining(REQUIRED_RECORD_FIELDS) + ); + expect(recordFields(scoped.schema)).toEqual( + expect.arrayContaining(REQUIRED_RECORD_FIELDS) + ); + expect(qualification(stock.schema)).toEqual({ + missingFields: [], + productionQualified: true + }); + expect(qualification(scoped.schema)).toEqual({ + missingFields: [], + productionQualified: true + }); + }); + + it('retains installed extension capabilities without an object dependency', async () => { + const stockExtensions = sorted(stockIntrospection.extensions + .map(({ extname }) => extname) + .filter((name) => (REQUIRED_EXTENSIONS as readonly string[]).includes(name))); + const scopedExtensions = sorted( + plainScopedIntrospection.extensions.map(({ extname }) => extname) + ); + const stockLanguages = sorted( + stockIntrospection.languages.map(({ lanname }) => lanname) + ); + const scopedLanguages = sorted( + plainScopedIntrospection.languages.map(({ lanname }) => lanname) + ); + const stockAccessMethods = sorted( + stockIntrospection.am.map(({ amname }) => amname) + ); + const scopedAccessMethods = sorted( + plainScopedIntrospection.am.map(({ amname }) => amname) + ); + + expect(scopedExtensions).toEqual(stockExtensions); + expect(scopedLanguages).toEqual(stockLanguages); + expect(scopedAccessMethods).toEqual(stockAccessMethods); + expect(scopedExtensions).toEqual(expect.arrayContaining([ + ...REQUIRED_EXTENSIONS + ])); + expect(scopedExtensions).not.toContain('plpgsql'); + expect(scopedExtensions.length).toBeLessThan(MAX_SERVICE_CATALOG_ROWS); + expect(scopedLanguages.length).toBeLessThan(MAX_SERVICE_CATALOG_ROWS); + expect(scopedAccessMethods.length).toBeLessThan(MAX_SERVICE_CATALOG_ROWS); + expect(sorted( + plainScopedIntrospection.namespaces.map(({ nspname }) => nspname) + )).toEqual([EXTENSION_SCHEMA, PLAIN_API_SCHEMA, 'pg_catalog']); + + const stockSdl = printSchema(lexicographicSortSchema(plainStock.schema)); + const scopedSdl = printSchema(lexicographicSortSchema(plainScoped.schema)); + expect(scopedSdl).toBe(stockSdl); + + const plainRecord = plainStock.schema.getType('PlainRecord'); + expect(plainRecord && isObjectType(plainRecord) + ? Object.keys(plainRecord.getFields()) + : []).toEqual(expect.arrayContaining(['body', 'bodyTrgmSimilarity'])); + expect(plainStock.schema.getQueryType()?.getFields().plainRecords) + .toBeDefined(); + + const [stockResult, scopedResult] = await Promise.all([ + runDocument(plainStock, PLAIN_EXTENSION_DOCUMENT), + runDocument(plainScoped, PLAIN_EXTENSION_DOCUMENT) + ]); + expect(stockResult.errors).toBeUndefined(); + expect(scopedResult).toEqual(stockResult); + expect((stockResult.data?.plainRecords as { nodes: unknown[] }).nodes) + .toHaveLength(1); + }); + + it('executes matching core and extension documents without extension search_path', async () => { + const [stockCore, scopedCore, stockExtensions, scopedExtensions] = + await Promise.all([ + runDocument(stock, CORE_DOCUMENT), + runDocument(scoped, CORE_DOCUMENT), + runDocument(stock, EXTENSION_DOCUMENT, { bbox: BBOX }), + runDocument(scoped, EXTENSION_DOCUMENT, { bbox: BBOX }) + ]); + + expect(stockCore.errors).toBeUndefined(); + expect(scopedCore).toEqual(stockCore); + expect(scopedExtensions).toEqual(stockExtensions); + expect(stockExtensions.errors).toBeUndefined(); + + const records = (stockCore.data?.closureRecords as { + nodes: Array<{ scoreWindows: unknown[] }>; + }).nodes; + expect(records[0].scoreWindows).toHaveLength(2); + expect(stockCore.data?.closureMultirangeProbe).toHaveLength(2); + expect(stockCore.data?.closureTimestampMultirangeProbe).toHaveLength(2); + + const extensionData = stockExtensions.data as Record< + string, + { nodes: unknown[] } | null + >; + for (const alias of [ + 'tsvector', + 'trigram', + 'bm25', + 'vector', + 'postgis', + 'ltree' + ]) { + expect(extensionData[alias]?.nodes).toHaveLength(1); + } + }); +}); diff --git a/graphile/graphile-settings/__tests__/scoped-introspection-runtime.test.ts b/graphile/graphile-settings/__tests__/scoped-introspection-runtime.test.ts new file mode 100644 index 0000000000..471de308cf --- /dev/null +++ b/graphile/graphile-settings/__tests__/scoped-introspection-runtime.test.ts @@ -0,0 +1,125 @@ +import { makeSchema } from 'graphile-build'; +import { MinimalPreset } from '../src/plugins'; + +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: [MinimalPreset], + 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: [MinimalPreset], + 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-settings/sql/scoped-introspection-capability-closure.sql b/graphile/graphile-settings/sql/scoped-introspection-capability-closure.sql new file mode 100644 index 0000000000..fd3812f524 --- /dev/null +++ b/graphile/graphile-settings/sql/scoped-introspection-capability-closure.sql @@ -0,0 +1,208 @@ +-- Capability fixture for schema-scoped dependency-closure introspection. +-- +-- The integration test performs an explicit pg_available_extensions preflight +-- before executing this file. Keep extension creation unconditional: a missing +-- or partially installed capability must fail the fixture instead of silently +-- producing a smaller GraphQL schema. + +BEGIN; + +CREATE SCHEMA closure_ext; + +CREATE EXTENSION ltree WITH SCHEMA closure_ext; +CREATE EXTENSION pg_textsearch WITH SCHEMA closure_ext; +CREATE EXTENSION pg_trgm WITH SCHEMA closure_ext; +CREATE EXTENSION postgis WITH SCHEMA closure_ext; +CREATE EXTENSION vector WITH SCHEMA closure_ext; + +CREATE SCHEMA closure_api; +SET LOCAL search_path = closure_api, pg_catalog, closure_ext; + +-- Exercise every user-defined pg_type.typtype that Graphile is expected to +-- retain, plus their generated array types. PostgreSQL 18 creates the +-- multirange type alongside the range type. +CREATE TYPE closure_api.closure_status AS ENUM ('ready', 'archived'); + +CREATE DOMAIN closure_api.closure_label AS text + CHECK (VALUE <> ''); + +CREATE TYPE closure_api.closure_payload AS ( + label closure_api.closure_label, + weight integer +); + +CREATE TYPE closure_api.score_window AS RANGE ( + subtype = numeric, + multirange_type_name = score_window_multirange +); + +-- Exercise multirange input serialization independently from table output. +-- The runtime search_path omits closure_ext, so the signature and body remain +-- bound to the tenant schema through explicit identifiers. +CREATE FUNCTION closure_api.closure_multirange_probe( + expected closure_api.score_window_multirange +) +RETURNS closure_api.score_window_multirange +LANGUAGE sql +IMMUTABLE +STRICT +SET search_path = pg_catalog, closure_api +AS $function$ + SELECT expected +$function$; + +-- Timestamp codecs use Graphile's SQL cast path, so this probe covers the +-- multirange representation that cannot be decoded from raw PostgreSQL text. +CREATE FUNCTION closure_api.closure_timestamp_multirange_probe( + expected pg_catalog.tstzmultirange +) +RETURNS pg_catalog.tstzmultirange +LANGUAGE sql +IMMUTABLE +STRICT +SET search_path = pg_catalog, closure_api +AS $function$ + SELECT expected +$function$; + +CREATE TABLE closure_api.closure_records ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL, + body text NOT NULL, + status closure_api.closure_status NOT NULL, + statuses closure_api.closure_status[] NOT NULL, + label closure_api.closure_label NOT NULL, + labels closure_api.closure_label[] NOT NULL, + payload closure_api.closure_payload NOT NULL, + payloads closure_api.closure_payload[] NOT NULL, + score_window closure_api.score_window NOT NULL, + score_window_array closure_api.score_window[] NOT NULL, + score_windows closure_api.score_window_multirange NOT NULL, + search_document tsvector NOT NULL, + path closure_ext.ltree NOT NULL, + paths closure_ext.ltree[] NOT NULL, + embedding closure_ext.vector(3) NOT NULL, + embeddings closure_ext.vector(3)[] NOT NULL, + geom closure_ext.geometry(Point, 4326) NOT NULL, + geoms closure_ext.geometry(Point, 4326)[] NOT NULL +); + +CREATE INDEX closure_records_search_document_idx + ON closure_api.closure_records USING gin(search_document); + +CREATE INDEX closure_records_name_trgm_idx + ON closure_api.closure_records + USING gin(name closure_ext.gin_trgm_ops); + +CREATE INDEX closure_records_body_bm25_idx + ON closure_api.closure_records USING bm25(body) + WITH (text_config = 'english'); + +CREATE INDEX closure_records_embedding_idx + ON closure_api.closure_records + USING hnsw (embedding closure_ext.vector_cosine_ops); + +CREATE INDEX closure_records_geom_idx + ON closure_api.closure_records USING gist(geom); + +CREATE INDEX closure_records_path_idx + ON closure_api.closure_records + USING gist(path closure_ext.gist_ltree_ops); + +CREATE INDEX closure_records_score_window_idx + ON closure_api.closure_records USING gist(score_window); + +INSERT INTO closure_api.closure_records ( + name, + body, + status, + statuses, + label, + labels, + payload, + payloads, + score_window, + score_window_array, + score_windows, + search_document, + path, + paths, + embedding, + embeddings, + geom, + geoms +) +VALUES ( + 'Acme tenant', + 'tenant memory density isolation canary', + 'ready', + ARRAY['ready', 'archived']::closure_api.closure_status[], + 'primary', + ARRAY['primary', 'secondary']::closure_api.closure_label[], + ROW('payload', 7)::closure_api.closure_payload, + ARRAY[ + ROW('payload', 7)::closure_api.closure_payload, + ROW('archive', 3)::closure_api.closure_payload + ], + closure_api.score_window(0, 10, '[]'), + ARRAY[ + closure_api.score_window(0, 10, '[]'), + closure_api.score_window(20, 30, '[)') + ], + closure_api.score_window_multirange( + closure_api.score_window(0, 10, '[]'), + closure_api.score_window(20, 30, '[)') + ), + to_tsvector('english', 'tenant memory density isolation canary'), + 'customers.acme', + ARRAY['customers.acme', 'customers.acme.documents']::closure_ext.ltree[], + '[1,0,0]', + ARRAY['[1,0,0]', '[0,1,0]']::closure_ext.vector(3)[], + closure_ext.st_setsrid(closure_ext.st_makepoint(-73.968, 40.785), 4326), + ARRAY[ + closure_ext.st_setsrid(closure_ext.st_makepoint(-73.968, 40.785), 4326), + closure_ext.st_setsrid(closure_ext.st_makepoint(-73.969, 40.786), 4326) + ]::closure_ext.geometry(Point, 4326)[] +); + +-- Defaults make this a root query field while its signature forces closure +-- traversal through enum, ltree, vector, and PostGIS types. The SQL body uses +-- fully qualified extension objects, so runtime search_path never needs the +-- shared extension schema. +CREATE FUNCTION closure_api.closure_function_probe( + expected_status closure_api.closure_status DEFAULT 'ready', + expected_path closure_ext.ltree DEFAULT 'customers.acme', + expected_embedding closure_ext.vector(3) DEFAULT '[1,0,0]', + expected_geom closure_ext.geometry DEFAULT + closure_ext.st_setsrid(closure_ext.st_makepoint(-73.968, 40.785), 4326) +) +RETURNS SETOF closure_api.closure_records +LANGUAGE sql +STABLE +SET search_path = pg_catalog, closure_api +AS $function$ + SELECT record.* + FROM closure_api.closure_records AS record + WHERE record.status = expected_status + AND record.path OPERATOR(closure_ext.<@) expected_path + AND record.embedding OPERATOR(closure_ext.<=>) expected_embedding < 0.01 + AND closure_ext.st_intersects(record.geom, expected_geom) +$function$; + +-- This schema deliberately has no type, function, operator-class, or index +-- dependency on pg_trgm/vector. Installed extensions are still a service-level +-- Graphile capability: @trgmSearch must behave identically under stock and +-- scoped introspection even when no retained object points at pg_trgm. +CREATE SCHEMA closure_plain_api; + +CREATE TABLE closure_plain_api.plain_records ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + body text NOT NULL +); + +COMMENT ON COLUMN closure_plain_api.plain_records.body IS E'@trgmSearch'; + +INSERT INTO closure_plain_api.plain_records (body) +VALUES ('tenant density capability without an extension-owned index'); + +COMMIT; diff --git a/graphile/graphile-settings/src/grafast-cache-limits.ts b/graphile/graphile-settings/src/grafast-cache-limits.ts new file mode 100644 index 0000000000..25855167c3 --- /dev/null +++ b/graphile/graphile-settings/src/grafast-cache-limits.ts @@ -0,0 +1,68 @@ +import type { GrafastCacheLimits } from '@constructive-io/graphql-types'; +import type { GraphileConfig } from 'graphile-config'; +import type { GraphQLSchemaConfig } from 'graphql'; + +const LIMIT_KEYS = [ + 'queryCacheMaxLength', + 'operationsCacheMaxLength', + 'operationOperationPlansCacheMaxLength' +] as const; + +/** Validate cache bounds before they reach Grafast's LRU constructors. */ +export const normalizeGrafastCacheLimits = ( + limits: GrafastCacheLimits +): Readonly => { + const normalized: GrafastCacheLimits = {}; + for (const key of LIMIT_KEYS) { + const value = limits[key]; + if (value === undefined) continue; + if (!Number.isSafeInteger(value) || value < 2) { + throw new Error(`grafastCache.${key} must be a safe integer of at least 2`); + } + normalized[key] = value; + } + return Object.freeze(normalized); +}; + +/** Apply authoritative per-schema cache limits without disturbing other extensions. */ +export const applyGrafastCacheLimits = ( + config: GraphQLSchemaConfig, + limits: Readonly +): GraphQLSchemaConfig => ({ + ...config, + extensions: { + ...(config.extensions ?? {}), + grafast: { + ...(config.extensions?.grafast ?? {}), + ...limits + } + } +}); + +/** Reusable plugin API for bounding Grafast's schema-local memory growth. */ +export const createGrafastCacheLimitsPlugin = ( + limits: GrafastCacheLimits +): GraphileConfig.Plugin => { + const normalized = normalizeGrafastCacheLimits(limits); + return { + name: 'GrafastCacheLimitsPlugin', + version: '1.0.0', + description: 'Bounds schema-local Grafast parse and operation-plan caches', + schema: { + hooks: { + GraphQLSchema(config) { + return applyGrafastCacheLimits(config, normalized); + } + } + } + }; +}; + +export const createGrafastCacheLimitsPreset = ( + limits: GrafastCacheLimits +): GraphileConfig.Preset => { + const normalized = normalizeGrafastCacheLimits(limits); + return Object.keys(normalized).length === 0 + ? {} + : { plugins: [createGrafastCacheLimitsPlugin(normalized)] }; +}; diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index afa9154a82..4b3b929260 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -35,7 +35,22 @@ import 'postgraphile/grafserv'; import 'graphile-build'; -import { makePgService } from 'postgraphile/adaptors/pg'; +import { makePgService as makePostGraphilePgService } from 'postgraphile/adaptors/pg'; + +import { + normalizeIntrospectionDependencySchemas, + resolveIntrospectionSettings +} from './introspection-settings'; +import { assertIntrospectionClientReleaseCapabilities } from './introspection-client-release'; + +export * from './introspection-client-release'; + +export { + applyGrafastCacheLimits, + createGrafastCacheLimitsPlugin, + createGrafastCacheLimitsPreset, + normalizeGrafastCacheLimits +} from './grafast-cache-limits'; // ============================================================================ // Re-export all plugins and presets @@ -43,7 +58,11 @@ import { makePgService } from 'postgraphile/adaptors/pg'; // Main preset + factory export type { ConstructivePresetOptions } from './presets/constructive-preset'; -export { ConstructivePreset, createConstructivePreset } from './presets/constructive-preset'; +export { + ConstructivePreset, + createConstructivePreset, + resolveConstructiveIntrospectionCapabilityExtensions +} from './presets/constructive-preset'; // Re-export all plugins for convenience export * from './plugins/index'; @@ -55,8 +74,106 @@ export * from './presets/index'; // Utilities // ============================================================================ -// Re-export makePgService for convenience -export { makePgService }; +export type ConstructivePgServiceOptions = Parameters[0] & { + introspectionMode?: 'stock' | 'scoped-required'; + introspectionScopedCatalogTypes?: 'all' | 'dependency-closure'; + introspectionAllowedDependencySchemas?: readonly string[]; + introspectionCapabilityExtensions?: readonly string[]; + introspectionClientReleaseMode?: 'reuse' | 'destroy'; +}; + +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; + }))]; +}; + +/** + * Constructive's pgService factory adds the explicit catalog-introspection mode + * consumed by Graphile's gather phase. + */ +export const makePgService = (options: ConstructivePgServiceOptions) => { + const introspectionMode = options.introspectionMode ?? 'stock'; + const introspectionScopedCatalogTypes = options.introspectionScopedCatalogTypes; + const introspectionCapabilityExtensions = normalizeIntrospectionCapabilityExtensions( + options.introspectionCapabilityExtensions + ); + const introspectionClientReleaseMode = options.introspectionClientReleaseMode ?? 'reuse'; + if ( + introspectionScopedCatalogTypes !== undefined + && introspectionScopedCatalogTypes !== 'all' + && introspectionScopedCatalogTypes !== 'dependency-closure' + ) { + throw new Error( + `Unsupported scoped catalog type policy '${introspectionScopedCatalogTypes}'` + ); + } + if (introspectionMode === 'stock' && introspectionScopedCatalogTypes !== undefined) { + throw new Error( + 'introspectionScopedCatalogTypes requires scoped-required introspection' + ); + } + if ( + introspectionMode === 'stock' + && options.introspectionCapabilityExtensions !== undefined + ) { + throw new Error( + 'introspectionCapabilityExtensions requires scoped-required introspection' + ); + } + if ( + introspectionClientReleaseMode !== 'reuse' + && introspectionClientReleaseMode !== 'destroy' + ) { + throw new Error( + `Unsupported introspection client release mode '${introspectionClientReleaseMode}'` + ); + } + assertIntrospectionClientReleaseCapabilities(introspectionClientReleaseMode); + const introspectionAllowedDependencySchemas = normalizeIntrospectionDependencySchemas( + options.introspectionAllowedDependencySchemas + ); + const pgSettingsForIntrospection = resolveIntrospectionSettings( + introspectionMode, + options.pgSettingsForIntrospection + ); + const service = makePostGraphilePgService({ + ...options, + pgSettingsForIntrospection + }); + return Object.assign(service, { + introspectionMode, + ...(introspectionScopedCatalogTypes === undefined + ? {} + : { introspectionScopedCatalogTypes }), + introspectionAllowedDependencySchemas, + ...(introspectionMode === 'scoped-required' + ? { introspectionCapabilityExtensions } + : {}), + introspectionClientReleaseMode + }); +}; + +export { + normalizeIntrospectionDependencySchemas, + resolveIntrospectionSettings +} from './introspection-settings'; // Presigned URL utilities export { getPresignedUrlS3Config } from './presigned-url-resolver'; diff --git a/graphile/graphile-settings/src/introspection-client-release.ts b/graphile/graphile-settings/src/introspection-client-release.ts new file mode 100644 index 0000000000..1e72e4f0a3 --- /dev/null +++ b/graphile/graphile-settings/src/introspection-client-release.ts @@ -0,0 +1,49 @@ +import * as dataplanPg from '@dataplan/pg'; +import * as graphileBuildPg from 'graphile-build-pg'; + +export type IntrospectionClientReleaseMode = 'reuse' | 'destroy'; + +export interface IntrospectionClientReleaseCapabilities { + dataplanPg: unknown; + graphileBuildPg: unknown; +} + +const REQUIRED_DATAPLAN_PG_RELEASE_CAPABILITY = + 'dataplan-pg-exact-client-destroy-v1'; +const REQUIRED_GRAPHILE_BUILD_PG_RELEASE_CAPABILITY = + 'graphile-build-pg-exact-client-destroy-v1'; + +const runtimeIntrospectionClientReleaseCapabilities = Object.freeze({ + dataplanPg: (dataplanPg as Record).exactClientReleaseCapability, + graphileBuildPg: + (graphileBuildPg as Record).introspectionClientReleaseCapability +}); + +/** + * Dependency patches do not propagate through a published package. Destroy + * mode is accepted only when both upstream seams advertise the exact protocol + * this package was tested against. + */ +export function assertIntrospectionClientReleaseCapabilities( + mode: IntrospectionClientReleaseMode, + capabilities: IntrospectionClientReleaseCapabilities = + runtimeIntrospectionClientReleaseCapabilities +): void { + if (mode === 'reuse') return; + + const missing: string[] = []; + if (capabilities.dataplanPg !== REQUIRED_DATAPLAN_PG_RELEASE_CAPABILITY) { + missing.push('@dataplan/pg'); + } + if ( + capabilities.graphileBuildPg + !== REQUIRED_GRAPHILE_BUILD_PG_RELEASE_CAPABILITY + ) { + missing.push('graphile-build-pg'); + } + if (missing.length > 0) { + throw new Error( + `GRAPHILE_INTROSPECTION_CLIENT_DESTROY_UNSUPPORTED:${missing.join(',')}` + ); + } +} diff --git a/graphile/graphile-settings/src/introspection-settings.ts b/graphile/graphile-settings/src/introspection-settings.ts new file mode 100644 index 0000000000..a3099c652a --- /dev/null +++ b/graphile/graphile-settings/src/introspection-settings.ts @@ -0,0 +1,43 @@ +export type GraphileIntrospectionMode = 'stock' | 'scoped-required'; + +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) { + // An admitted build owns the sole process-wide heap slot. Bound catalog + // SQL so a lock wait or pathological plan cannot block every cold tenant + // indefinitely; the setting is transaction-local in @dataplan/pg. + boundedSettings.statement_timeout = DEFAULT_INTROSPECTION_STATEMENT_TIMEOUT; + } + return mode === 'scoped-required' + ? { + ...boundedSettings, + // The scoped recursive query is deliberately short-lived. PostgreSQL's + // JIT compilation costs more than the catalog work, while wide catalog + // hashes multiply work_mem. Bound both only inside the introspection + // transaction; @dataplan/pg restores the runtime session afterwards. + jit: 'off', + work_mem: '512kB' + } + : boundedSettings; +}; diff --git a/graphile/graphile-settings/src/plugins/PublicKeySignature.ts b/graphile/graphile-settings/src/plugins/PublicKeySignature.ts index 2d01aa9712..861c3b5b67 100644 --- a/graphile/graphile-settings/src/plugins/PublicKeySignature.ts +++ b/graphile/graphile-settings/src/plugins/PublicKeySignature.ts @@ -4,10 +4,11 @@ import { QuoteUtils } from '@pgsql/quotes'; import { context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; import { extendSchema, gql } from 'graphile-utils'; -import pgQueryWithContext from 'pg-query-context'; export interface PublicKeyChallengeConfig { schema: string; + /** Exact anonymous role configured for this Graphile API surface. */ + anonymousRole: string; crypto_network: string; // crypto_network: keyof typeof Networks; sign_up_with_key: string; @@ -38,9 +39,54 @@ const MAX_MESSAGE_LENGTH = 4096; const MAX_SIGNATURE_LENGTH = 1024; const ENABLE_SIGNATURE_VERIFICATION = process.env.ENABLE_SIGNATURE_VERIFICATION === 'true'; +type PublicKeyPgSettings = Record; + +export type PublicKeyPgClient = { + query>(opts: { + text: string; + values?: unknown[]; + }): Promise<{ rows: TData[] }>; +}; + +export type PublicKeyWithPgClient = ( + pgSettings: PublicKeyPgSettings, + callback: (pgClient: PublicKeyPgClient) => Promise | T, +) => Promise; + +/** + * Run a public-key authentication operation in the request's complete GUC + * context while retaining the plugin's deliberately anonymous database role. + * + * The copy is important: Grafast's request context remains immutable, and the + * role override cannot leak back into other plans sharing that context. + */ +export async function withAnonymousPublicKeyClient( + withPgClient: PublicKeyWithPgClient | null | undefined, + pgSettings: unknown, + anonymousRole: string, + callback: (pgClient: PublicKeyPgClient) => Promise | T, +): Promise { + if (typeof withPgClient !== 'function') { + throw new Error('PG_CLIENT_CONTEXT_UNAVAILABLE'); + } + if (typeof pgSettings !== 'object' || pgSettings === null || Array.isArray(pgSettings)) { + throw new Error('PG_SETTINGS_UNAVAILABLE'); + } + validateIdentifier(anonymousRole, 'anonymousRole'); + + return withPgClient( + { + ...(pgSettings as PublicKeyPgSettings), + role: anonymousRole, + }, + callback, + ); +} + export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): GraphileConfig.Plugin => { const { schema, + anonymousRole, crypto_network, sign_up_with_key, sign_in_request_challenge, @@ -49,6 +95,7 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): } = pubkey_challenge; validateIdentifier(schema, 'schema'); + validateIdentifier(anonymousRole, 'anonymousRole'); validateIdentifier(sign_up_with_key, 'sign_up_with_key'); validateIdentifier(sign_in_request_challenge, 'sign_in_request_challenge'); validateIdentifier(sign_in_record_failure, 'sign_in_record_failure'); @@ -103,40 +150,32 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): createUserAccountWithPublicKey(_$mutation: any, fieldArgs: any) { const $input = fieldArgs.getRaw('input'); const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $combined = object({ input: $input, withPgClient: $withPgClient }); + const $pgSettings = (grafastContext() as any).get('pgSettings'); + const $combined = object({ + input: $input, + withPgClient: $withPgClient, + pgSettings: $pgSettings, + }); - return lambda($combined, async ({ input, withPgClient }: any) => { + return lambda($combined, async ({ input, withPgClient, pgSettings }: any) => { if (!input.publicKey || typeof input.publicKey !== 'string' || input.publicKey.length > MAX_PUBLIC_KEY_LENGTH) { throw new Error('INVALID_PUBLIC_KEY'); } - return withPgClient(null, async (pgClient: any) => { - await pgClient.query('BEGIN'); - try { - await pgQueryWithContext({ - client: pgClient, - context: { role: 'anonymous' }, - query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_up_with_key)}($1)`, - variables: [input.publicKey], - skipTransaction: true - }); - - const { - rows: [{ [sign_in_request_challenge]: message }] - } = await pgQueryWithContext({ - client: pgClient, - context: { role: 'anonymous' }, - query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_request_challenge)}($1)`, - variables: [input.publicKey], - skipTransaction: true - }); - - await pgClient.query('COMMIT'); - return { message }; - } catch (err) { - await pgClient.query('ROLLBACK'); - throw err; - } + return withAnonymousPublicKeyClient(withPgClient, pgSettings, anonymousRole, async (pgClient) => { + await pgClient.query({ + text: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_up_with_key)}($1)`, + values: [input.publicKey], + }); + + const { + rows: [{ [sign_in_request_challenge]: message }] + } = await pgClient.query>({ + text: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_request_challenge)}($1)`, + values: [input.publicKey], + }); + + return { message }; }); }); }, @@ -144,21 +183,24 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): getMessageForSigning(_$mutation: any, fieldArgs: any) { const $input = fieldArgs.getRaw('input'); const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $combined = object({ input: $input, withPgClient: $withPgClient }); + const $pgSettings = (grafastContext() as any).get('pgSettings'); + const $combined = object({ + input: $input, + withPgClient: $withPgClient, + pgSettings: $pgSettings, + }); - return lambda($combined, async ({ input, withPgClient }: any) => { + return lambda($combined, async ({ input, withPgClient, pgSettings }: any) => { if (!input.publicKey || typeof input.publicKey !== 'string' || input.publicKey.length > MAX_PUBLIC_KEY_LENGTH) { throw new Error('INVALID_PUBLIC_KEY'); } - return withPgClient(null, async (pgClient: any) => { + return withAnonymousPublicKeyClient(withPgClient, pgSettings, anonymousRole, async (pgClient) => { const { rows: [{ [sign_in_request_challenge]: message }] - } = await pgQueryWithContext({ - client: pgClient, - context: { role: 'anonymous' }, - query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_request_challenge)}($1)`, - variables: [input.publicKey] + } = await pgClient.query>({ + text: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_request_challenge)}($1)`, + values: [input.publicKey], }); if (!message) throw new Error('NO_ACCOUNT_EXISTS'); @@ -173,9 +215,14 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): verifyMessageForSigning(_$mutation: any, fieldArgs: any) { const $input = fieldArgs.getRaw('input'); const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $combined = object({ input: $input, withPgClient: $withPgClient }); + const $pgSettings = (grafastContext() as any).get('pgSettings'); + const $combined = object({ + input: $input, + withPgClient: $withPgClient, + pgSettings: $pgSettings, + }); - return lambda($combined, async ({ input, withPgClient }: any) => { + return lambda($combined, async ({ input, withPgClient, pgSettings }: any) => { const { publicKey, message, signature: _signature } = input; if (!publicKey || typeof publicKey !== 'string' || publicKey.length > MAX_PUBLIC_KEY_LENGTH) { @@ -194,31 +241,20 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): throw new Error('FEATURE_DISABLED'); } - return withPgClient(null, async (pgClient: any) => { - // Only the success path needs a transaction (multi-step) - await pgClient.query('BEGIN'); - try { - const { - rows: [token] - } = await pgQueryWithContext({ - client: pgClient, - context: { role: 'anonymous' }, - query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_with_challenge)}($1, $2)`, - variables: [publicKey, message], - skipTransaction: true - }); - - if (!token?.access_token) throw new Error('BAD_SIGNIN'); - - await pgClient.query('COMMIT'); - return { - access_token: token.access_token, - access_token_expires_at: token.access_token_expires_at - }; - } catch (err) { - await pgClient.query('ROLLBACK'); - throw err; - } + return withAnonymousPublicKeyClient(withPgClient, pgSettings, anonymousRole, async (pgClient) => { + const { + rows: [token] + } = await pgClient.query>({ + text: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_with_challenge)}($1, $2)`, + values: [publicKey, message], + }); + + if (!token?.access_token) throw new Error('BAD_SIGNIN'); + + return { + access_token: token.access_token, + access_token_expires_at: token.access_token_expires_at + }; }); }); } diff --git a/graphile/graphile-settings/src/plugins/index.ts b/graphile/graphile-settings/src/plugins/index.ts index 829bba18c6..fe62a0b129 100644 --- a/graphile/graphile-settings/src/plugins/index.ts +++ b/graphile/graphile-settings/src/plugins/index.ts @@ -106,14 +106,12 @@ export type { export { Bm25CodecPlugin, Bm25CodecPreset, - bm25IndexStore, createBm25Adapter, // Operator factories for connection filter integration createMatchesOperatorFactory, createPgvectorAdapter, createTrgmAdapter, createTrgmOperatorFactories, - // Adapters createTsvectorAdapter, createTsvectorCodecPlugin, // Core plugin + preset diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts index fdaf48fcd9..ddaf8593d4 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -8,7 +8,9 @@ import { GraphileLlmPreset } from 'graphile-llm'; import { createFolderOperatorFactory, GraphileLtreePreset } from 'graphile-ltree'; import { PgAggregatesPreset } from 'graphile-pg-aggregates'; import { createPostgisOperatorFactory,GraphilePostgisPreset } from 'graphile-postgis'; -import { PresignedUrlPreset } from 'graphile-presigned-url-plugin'; +import type { StorageModuleConfig } from 'graphile-presigned-url-plugin'; +import { PresignedUrlPreset, snapshotPreloadedStorageModules } from 'graphile-presigned-url-plugin'; +import type { RealtimeSubscriptionsPluginOptions } from 'graphile-realtime-subscriptions'; import { RealtimeSubscriptionsPreset } from 'graphile-realtime-subscriptions'; import { createMatchesOperatorFactory, createTrgmOperatorFactories,UnifiedSearchPreset } from 'graphile-search'; import { UploadPreset } from 'graphile-upload-plugin'; @@ -52,6 +54,14 @@ export interface ConstructivePresetOptions { enableBulk?: boolean; enableI18n?: boolean; enableHistory?: boolean; + /** Build-time and delivery settings forwarded to the realtime plugin. */ + realtimeSubscriptions?: RealtimeSubscriptionsPluginOptions; + /** + * Control-plane-resolved metadata. Constructive treats omission and an empty + * list as authoritative absence; it never falls back to runtime metaschema + * discovery. Standalone storage-plugin consumers retain the legacy mode. + */ + preloadedStorageModules?: readonly StorageModuleConfig[]; } /** @@ -72,7 +82,10 @@ function assertSupportedNodeVersion(): void { } } -const DEFAULTS: Required = { +const DEFAULTS: Required> = { enableAggregates: false, enablePostgis: true, enableSearch: true, @@ -88,6 +101,31 @@ const DEFAULTS: Required = { enableHistory: false }; +/** + * Extension metadata required by Constructive's enabled Graphile plugins. + * + * These are extension names, not schemas. Scoped introspection uses the exact + * names to retain optional capability metadata without treating every + * installed extension as part of the tenant's runtime surface. + */ +export function resolveConstructiveIntrospectionCapabilityExtensions( + options?: ConstructivePresetOptions +): readonly string[] { + const opts = { ...DEFAULTS, ...options }; + const extensions = new Set(); + + if (opts.enableSearch) { + extensions.add('pg_trgm'); + extensions.add('vector'); + extensions.add('pg_textsearch'); + } + if (opts.enableLlm) extensions.add('vector'); + if (opts.enablePostgis) extensions.add('postgis'); + if (opts.enableLtree) extensions.add('ltree'); + + return Object.freeze([...extensions]); +} + /** * Create a Constructive PostGraphile v5 Preset. * @@ -198,15 +236,24 @@ export function createConstructivePreset( } if (opts.enablePresignedUploads) { + // Freeze one authoritative control-plane snapshot and hand the exact same + // object to both storage plugins. Constructive always chooses the strict + // path: omitted metadata becomes an authoritative empty snapshot rather + // than enabling either plugin's legacy runtime lookup. + const storageModules = snapshotPreloadedStorageModules( + opts.preloadedStorageModules ?? [], + ); presets.push( PresignedUrlPreset({ s3: getPresignedUrlS3Config, resolveBucketName: createBucketNameResolver(), - ensureBucketProvisioned: createEnsureBucketProvisioned() + ensureBucketProvisioned: createEnsureBucketProvisioned(), + preloadedStorageModules: storageModules }), BucketProvisionerPreset({ connection: getBucketProvisionerConnection, allowedOrigins: getAllowedOrigins(), + preloadedStorageModules: storageModules, // Same tenant-aware naming policy as the presigned (lazy) path, so the // eager provisionBucket mutation mints the identical physical name // (`{prefix}-{bucketKey}-{databaseId}`) instead of falling back to the @@ -226,7 +273,7 @@ export function createConstructivePreset( } if (opts.enableRealtime) { - presets.push(RealtimeSubscriptionsPreset()); + presets.push(RealtimeSubscriptionsPreset(opts.realtimeSubscriptions)); } if (opts.enableBulk) { diff --git a/graphql/query/tsconfig.esm.json b/graphql/query/tsconfig.esm.json index 800d7506d3..d767c87fcd 100644 --- a/graphql/query/tsconfig.esm.json +++ b/graphql/query/tsconfig.esm.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "dist/esm", "module": "es2022", + "moduleResolution": "bundler", "rootDir": "src/", "declaration": false } diff --git a/graphql/query/tsconfig.json b/graphql/query/tsconfig.json index 1a9d5696cb..1141f4f971 100644 --- a/graphql/query/tsconfig.json +++ b/graphql/query/tsconfig.json @@ -2,7 +2,10 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src/" + "rootDir": "src/", + "module": "nodenext", + "moduleResolution": "nodenext", + "isolatedModules": true }, "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] diff --git a/graphql/server/package.json b/graphql/server/package.json index b149a052e5..df5d3519ab 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -95,6 +95,7 @@ "makage": "^0.3.0", "nodemon": "^3.1.14", "supertest": "^7.2.2", + "pg-introspection": "1.0.1", "ts-node": "^10.9.2" } } diff --git a/graphql/server/src/middleware/__tests__/graphile-build-contract.test.ts b/graphql/server/src/middleware/__tests__/graphile-build-contract.test.ts new file mode 100644 index 0000000000..ab7d4b0925 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-build-contract.test.ts @@ -0,0 +1,296 @@ +import { + createGraphileBuildContract, + type CreateGraphileBuildContractInput, + hashGraphileBuildContract +} from '../graphile-build-contract'; + +const makeContract = (overrides: Partial = {}) => + createGraphileBuildContract({ + configurationIdentity: 'graphile-configuration:v1:test', + poolIdentity: 'pg:v1:abc', + databaseId: 'database-a', + databaseName: 'tenant_a', + apiId: 'api-a', + schemas: ['tenant_a_public', 'tenant_a_private'], + authenticatedRole: 'tenant_user', + anonymousRole: 'tenant_anon', + graphiql: true, + graphiqlOnGraphQLGET: false, + pluginSettings: { + enableAggregates: false, + enablePostgis: true, + enableSearch: true, + enableDirectUploads: false, + enablePresignedUploads: true, + enableManyToMany: true, + enableConnectionFilter: true, + enableLtree: true, + enableLlm: true, + enableRealtime: false, + enableBulk: true, + enableI18n: true + }, + ...overrides + }); + +describe('GraphileBuildContractV1', () => { + it('is deterministic across object property ordering', () => { + const first = makeContract(); + const second = { ...makeContract(), roles: { ...makeContract().roles } }; + expect(hashGraphileBuildContract(first)).toBe(hashGraphileBuildContract(second)); + }); + + it('isolates every build-affecting tenant boundary', () => { + const base = makeContract(); + const variants = [ + { ...base, configurationIdentity: 'graphile-configuration:v1:other' }, + { ...base, poolIdentity: 'pg:v1:different' }, + { ...base, databaseId: 'database-b' }, + { ...base, apiId: 'api-b' }, + { ...base, schemas: [...base.schemas].reverse() }, + { ...base, roles: { ...base.roles, anonymous: 'different_anon' } }, + { + ...base, + storageModules: [{ + id: 'storage-a', + bucketsQualifiedName: '"tenant"."buckets"', + filesQualifiedName: '"tenant"."files"', + schemaName: 'tenant', + bucketsTableName: 'buckets', + filesTableName: 'files', + scope: 'app', + entityTableId: null, + entityQualifiedName: null, + endpoint: null, + publicUrlPrefix: null, + provider: null, + allowedOrigins: null, + uploadUrlExpirySeconds: 900, + downloadUrlExpirySeconds: 3600, + defaultMaxFileSize: 1024, + maxFilenameLength: 255, + cacheTtlSeconds: 300, + hasPathShares: false, + maxBulkFiles: 100, + maxBulkTotalSize: 1024 + }] + }, + { ...base, introspectionMode: 'scoped-required' as const }, + { ...base, introspectionClientReleaseMode: 'destroy' as const }, + { + ...base, + graphileSettings: { releaseBuildStateAfterValidation: true } + }, + { + ...base, + surface: { + ...base.surface, + enableRealtime: true, + realtimeSchema: 'tenant_a_realtime' + } + }, + { ...base, surface: { ...base.surface, graphiql: false } }, + { ...base, surface: { ...base.surface, graphiqlOnGraphQLGET: true } } + ]; + + for (const variant of variants) { + expect(hashGraphileBuildContract(variant)).not.toBe(hashGraphileBuildContract(base)); + } + }); + + it('accepts exact GraphiQL surface flags and preserves legacy defaults', () => { + const explicit = makeContract({ + graphiql: false, + graphiqlOnGraphQLGET: true + }); + expect(explicit.surface.graphiql).toBe(false); + expect(explicit.surface.graphiqlOnGraphQLGET).toBe(true); + + const legacy = createGraphileBuildContract({ + configurationIdentity: 'graphile-configuration:v1:legacy', + poolIdentity: 'pg:v1:legacy', + databaseId: 'database-legacy', + databaseName: 'tenant_legacy', + apiId: 'api-legacy', + schemas: ['tenant_legacy_public'], + authenticatedRole: 'tenant_user', + anonymousRole: 'tenant_anon' + }); + expect(legacy.surface.graphiql).toBe(true); + expect(legacy.surface.graphiqlOnGraphQLGET).toBe(false); + expect(legacy.surface.realtimeSchema).toBeNull(); + expect(legacy.introspectionClientReleaseMode).toBe('reuse'); + }); + + it('separates same-source plugin closures with different captured values', () => { + const pluginFactory = (tenantPolicy: string) => () => tenantPolicy; + const policyA = pluginFactory('policy-a'); + const policyB = pluginFactory('policy-b'); + + expect(policyA.toString()).toBe(policyB.toString()); + const first = makeContract({ + graphileSettings: { + preset: { schema: { policy: policyA } } as any + } + }); + const repeated = makeContract({ + graphileSettings: { + preset: { schema: { policy: policyA } } as any + } + }); + const distinctClosure = makeContract({ + graphileSettings: { + preset: { schema: { policy: policyB } } as any + } + }); + + expect(hashGraphileBuildContract(first)).toBe( + hashGraphileBuildContract(repeated) + ); + expect(hashGraphileBuildContract(first)).not.toBe( + hashGraphileBuildContract(distinctClosure) + ); + }); + + it('binds ordered caller plugin code and settings but ignores admission policy', () => { + const firstHook = () => 'first'; + const secondHook = () => 'second'; + const firstPlugin = { + name: 'FirstCallerPlugin', + version: '1.0.0', + schema: { hooks: { build: firstHook } } + }; + const secondPlugin = { + name: 'SecondCallerPlugin', + version: '2.0.0', + schema: { hooks: { build: secondHook } } + }; + const first = makeContract({ + graphileSettings: { + extends: [{ plugins: [firstPlugin, secondPlugin] } as any], + trustCallerPresetsInProduction: false + } + }); + const reordered = makeContract({ + graphileSettings: { + extends: [{ plugins: [secondPlugin, firstPlugin] } as any], + trustCallerPresetsInProduction: false + } + }); + const admissionOnly = makeContract({ + graphileSettings: { + extends: [{ plugins: [firstPlugin, secondPlugin] } as any], + trustCallerPresetsInProduction: true + } + }); + + expect(hashGraphileBuildContract(first)).not.toBe( + hashGraphileBuildContract(reordered) + ); + expect(hashGraphileBuildContract(first)).toBe( + hashGraphileBuildContract(admissionOnly) + ); + }); + + it('binds the effective realtime cursor schema only when realtime is enabled', () => { + const compatibilityDefault = makeContract({ enableRealtime: true }); + const explicitDefault = makeContract({ + enableRealtime: true, + realtimeSchema: 'realtime_public' + }); + const tenantScoped = makeContract({ + enableRealtime: true, + realtimeSchema: 'ctf_a_realtime' + }); + const disabled = makeContract({ + enableRealtime: false, + realtimeSchema: 'ignored_when_disabled' + }); + + expect(compatibilityDefault.surface.realtimeSchema).toBe('realtime_public'); + expect(hashGraphileBuildContract(compatibilityDefault)).toBe( + hashGraphileBuildContract(explicitDefault) + ); + expect(hashGraphileBuildContract(tenantScoped)).not.toBe( + hashGraphileBuildContract(compatibilityDefault) + ); + expect(disabled.surface.realtimeSchema).toBeNull(); + expect(disabled.surface.realtimeNotificationMode).toBeNull(); + expect(disabled.surface.realtimeCursorPollIntervalMs).toBeNull(); + }); + + it('binds shared transport identity, role TTL, and cursor timings', () => { + const first = makeContract({ + enableRealtime: true, + realtimeNotificationMode: 'shared-exact', + realtimeListenerPoolIdentity: 'pg-notification-broker:v1:first', + realtimeNotificationRoleRevalidationMs: 60_000, + realtimeCursorPollIntervalMs: 5_000, + realtimeCursorHeartbeatIntervalMs: 30_000 + }); + const changedIdentity = makeContract({ + enableRealtime: true, + realtimeNotificationMode: 'shared-exact', + realtimeListenerPoolIdentity: 'pg-notification-broker:v1:second', + realtimeNotificationRoleRevalidationMs: 60_000, + realtimeCursorPollIntervalMs: 5_000, + realtimeCursorHeartbeatIntervalMs: 30_000 + }); + const changedPolling = makeContract({ + enableRealtime: true, + realtimeNotificationMode: 'shared-exact', + realtimeListenerPoolIdentity: 'pg-notification-broker:v1:first', + realtimeNotificationRoleRevalidationMs: 60_000, + realtimeCursorPollIntervalMs: 30_000, + realtimeCursorHeartbeatIntervalMs: 30_000 + }); + + expect(first.surface).toMatchObject({ + realtimeNotificationMode: 'shared-exact', + realtimeListenerPoolIdentity: 'pg-notification-broker:v1:first', + realtimeNotificationRoleRevalidationMs: 60_000, + realtimeCursorPollIntervalMs: 5_000, + realtimeCursorHeartbeatIntervalMs: 30_000 + }); + expect(hashGraphileBuildContract(first)).not.toBe( + hashGraphileBuildContract(changedIdentity) + ); + expect(hashGraphileBuildContract(first)).not.toBe( + hashGraphileBuildContract(changedPolling) + ); + }); + + it('rejects shared transport without an opaque listener pool identity', () => { + expect(() => makeContract({ + enableRealtime: true, + realtimeNotificationMode: 'shared-exact' + })).toThrow('requires an opaque listener pool identity'); + }); + + it('does not split realtime-disabled identities on an irrelevant cursor schema', () => { + const first = makeContract({ + enableRealtime: false, + graphileSettings: { + realtimeSchema: 'tenant_a_realtime', + realtimeNotificationMode: 'shared-exact', + realtimeNotificationRoleRevalidationMs: 1, + realtimeCursorPollIntervalMs: 1, + realtimeCursorHeartbeatIntervalMs: 1 + } + }); + const second = makeContract({ + enableRealtime: false, + graphileSettings: { + realtimeSchema: 'tenant_b_realtime', + realtimeNotificationMode: 'dedicated', + realtimeNotificationRoleRevalidationMs: 120_000, + realtimeCursorPollIntervalMs: 60_000, + realtimeCursorHeartbeatIntervalMs: 180_000 + } + }); + + expect(first.graphileSettings).toEqual({}); + expect(second.graphileSettings).toEqual({}); + expect(hashGraphileBuildContract(first)).toBe(hashGraphileBuildContract(second)); + }); +}); diff --git a/graphql/server/src/middleware/__tests__/graphile-build-governor.test.ts b/graphql/server/src/middleware/__tests__/graphile-build-governor.test.ts new file mode 100644 index 0000000000..31267f25d0 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-build-governor.test.ts @@ -0,0 +1,215 @@ +import { + BuildCoordinator, + captureGraphileBuildGeneration, + closeGraphileBuildCoordinator, + getGraphileGovernorCounters, + GRAPHILE_BUILD_QUEUE_FULL_CODE, + GRAPHILE_BUILD_SHUTTING_DOWN_CODE, + GRAPHILE_BUILD_STUCK_RESTART_REQUIRED_CODE, + GraphileBuildCoordinatorError, + GraphileBuildWaitAbortedError, + isGraphileBuildGenerationCurrent, + reopenGraphileBuildCoordinator, + runGraphileBuild, + waitForGraphileBuild +} from '../graphile-build-governor'; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +describe('Graphile build coordinator', () => { + it('serializes large builds process-wide', async () => { + const firstGate = deferred(); + const secondStarted = jest.fn(); + const first = runGraphileBuild(async () => { + await firstGate.promise; + return 'first'; + }); + const second = runGraphileBuild(async () => { + secondStarted(); + return 'second'; + }); + + await Promise.resolve(); + expect(secondStarted).not.toHaveBeenCalled(); + expect(getGraphileGovernorCounters().queueDepth).toBe(1); + firstGate.resolve(); + await expect(first).resolves.toBe('first'); + await expect(second).resolves.toBe('second'); + expect(secondStarted).toHaveBeenCalledTimes(1); + }); + + it('clears its wait timer when a build settles', async () => { + jest.useFakeTimers(); + try { + await expect(waitForGraphileBuild(Promise.resolve('ready'), 60_000)).resolves.toBe('ready'); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }); + + it('returns null with a stable timeout counter while the build continues', async () => { + jest.useFakeTimers(); + try { + const before = getGraphileGovernorCounters().buildWaitTimeouts; + const pending = deferred(); + const waiting = waitForGraphileBuild(pending.promise, 25); + await jest.advanceTimersByTimeAsync(25); + await expect(waiting).resolves.toBeNull(); + expect(getGraphileGovernorCounters().buildWaitTimeouts).toBe(before + 1); + pending.resolve('eventually cached'); + } finally { + jest.useRealTimers(); + } + }); + + it('bounds queued builds with a stable refusal code', async () => { + const coordinator = new BuildCoordinator(1, 1); + const releaseFirst = await coordinator.acquire(); + const queued = coordinator.acquire(); + + await expect(coordinator.acquire()).rejects.toMatchObject({ + code: GRAPHILE_BUILD_QUEUE_FULL_CODE, + retryAfterSeconds: 1 + } satisfies Partial); + + releaseFirst(); + const releaseQueued = await queued; + releaseQueued(); + }); + + it('removes an aborted waiter before it can start', async () => { + const coordinator = new BuildCoordinator(1, 1); + const releaseFirst = await coordinator.acquire(); + const abortController = new AbortController(); + const admitted = jest.fn(); + const queued = coordinator.acquire({ + signal: abortController.signal, + onAdmitted: admitted + }); + expect(coordinator.queueDepth).toBe(1); + + abortController.abort(); + await expect(queued).rejects.toBeInstanceOf(GraphileBuildWaitAbortedError); + expect(coordinator.queueDepth).toBe(0); + releaseFirst(); + expect(admitted).not.toHaveBeenCalled(); + }); + + it('aborts request waiting without canceling an already active build', async () => { + const pending = deferred(); + const abortController = new AbortController(); + const waiting = waitForGraphileBuild( + pending.promise, + 60_000, + abortController.signal + ); + + abortController.abort(); + await expect(waiting).rejects.toBeInstanceOf(GraphileBuildWaitAbortedError); + pending.resolve('still allowed to finish'); + await expect(pending.promise).resolves.toBe('still allowed to finish'); + }); + + it('rejects unsafe concurrent-build configuration', () => { + expect(() => new BuildCoordinator(2, 1)).toThrow( + 'GRAPHILE_BUILD_CONCURRENCY must be exactly 1' + ); + }); + + it('latches an unhealthy restart-required state without releasing the active slot', async () => { + jest.useFakeTimers(); + try { + const coordinator = new BuildCoordinator(1, 1, 25); + const queuedAdmitted = jest.fn(); + const releaseActive = await coordinator.acquire(); + const queued = coordinator.acquire({ onAdmitted: queuedAdmitted }); + const queuedRefusal = expect(queued).rejects.toMatchObject({ + code: GRAPHILE_BUILD_STUCK_RESTART_REQUIRED_CODE, + retryAfterSeconds: 30 + } satisfies Partial); + + await jest.advanceTimersByTimeAsync(25); + await queuedRefusal; + + expect(coordinator.isUnhealthy).toBe(true); + expect(coordinator.stuckSinceMs).not.toBeNull(); + expect(coordinator.activeCount).toBe(1); + expect(coordinator.queueDepth).toBe(0); + expect(queuedAdmitted).not.toHaveBeenCalled(); + await expect(coordinator.acquire()).rejects.toMatchObject({ + code: GRAPHILE_BUILD_STUCK_RESTART_REQUIRED_CODE + } satisfies Partial); + + // Only completion of the real build may release its slot. The unhealthy + // latch remains, so the same process still cannot admit replacement work. + releaseActive(); + expect(coordinator.activeCount).toBe(0); + expect(coordinator.isUnhealthy).toBe(true); + await expect(coordinator.acquire()).rejects.toMatchObject({ + code: GRAPHILE_BUILD_STUCK_RESTART_REQUIRED_CODE + } satisfies Partial); + } finally { + jest.useRealTimers(); + } + }); + + it('notifies active request waiters when the build watchdog trips', async () => { + jest.useFakeTimers(); + try { + const coordinator = new BuildCoordinator(1, 0, 10); + const release = await coordinator.acquire(); + const stuck = new Promise((resolve) => { + coordinator.onStuck(resolve); + }); + + await jest.advanceTimersByTimeAsync(10); + await expect(stuck).resolves.toMatchObject({ + code: GRAPHILE_BUILD_STUCK_RESTART_REQUIRED_CODE + }); + expect(coordinator.activeCount).toBe(1); + release(); + } finally { + jest.useRealTimers(); + } + }); + + it('rejects an invalid build watchdog configuration', () => { + expect(() => new BuildCoordinator(1, 1, 0)).toThrow( + 'GRAPHILE_BUILD_WATCHDOG_MS must be a positive safe integer' + ); + }); + + it('closes admission, rejects queued work, drains active work, and advances generation', async () => { + const firstGate = deferred(); + const queuedStarted = jest.fn(); + const generation = captureGraphileBuildGeneration(); + const first = runGraphileBuild(async () => { + await firstGate.promise; + return 'first'; + }); + const queued = runGraphileBuild(async () => { + queuedStarted(); + return 'queued'; + }); + await Promise.resolve(); + + const draining = closeGraphileBuildCoordinator(100); + await expect(queued).rejects.toMatchObject({ + code: GRAPHILE_BUILD_SHUTTING_DOWN_CODE + } satisfies Partial); + expect(isGraphileBuildGenerationCurrent(generation)).toBe(false); + firstGate.resolve(); + await expect(first).resolves.toBe('first'); + await expect(draining).resolves.toBe(true); + expect(queuedStarted).not.toHaveBeenCalled(); + expect(reopenGraphileBuildCoordinator()).toBe(true); + await expect(runGraphileBuild(async () => 'restarted')).resolves.toBe('restarted'); + }); +}); diff --git a/graphql/server/src/middleware/__tests__/graphile-preset-composition.test.ts b/graphql/server/src/middleware/__tests__/graphile-preset-composition.test.ts new file mode 100644 index 0000000000..b45f15d7cd --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-preset-composition.test.ts @@ -0,0 +1,188 @@ +import type { GraphileConfig } from 'graphile-config'; +import { resolvePreset } from 'graphile-config'; + +import { + composeGraphilePreset, + GRAPHILE_CALLER_PRESET_NOT_TRUSTED_CODE, + GRAPHILE_PROTECTED_PRESET_OVERRIDE_CODE, + GraphileCallerPresetNotTrustedError, + GraphileProtectedPresetOverrideError +} from '../graphile-preset-composition'; + +const callerPlugin: GraphileConfig.Plugin = { + name: 'CallerSchemaPlugin', + version: '1.0.0' +}; + +const authPlugin: GraphileConfig.Plugin = { + name: 'AuthCookiePlugin', + version: '1.0.0' +}; + +const websocketAdmissionPlugin: GraphileConfig.Plugin = { + name: 'ConstructiveWebSocketOperationAdmissionPlugin', + version: '1.0.0' +}; + +const exactService = { + name: 'main', + adaptor: 'constructive-test-adaptor' +} as unknown as NonNullable[number]; + +const protectedContext = jest.fn(() => ({ + pgSettings: { role: 'tenant_runtime' } +})); +const protectedMaskError = jest.fn((error) => error); + +const compose = ( + overrides: Partial[0]> = {} +): GraphileConfig.Preset => composeGraphilePreset({ + basePresets: [], + callerPresetsTrusted: true, + protectedPlugins: [authPlugin, websocketAdmissionPlugin], + pgServices: [exactService], + schema: { releaseBuildStateAfterValidation: true }, + grafserv: { + graphqlPath: '/graphql', + graphiqlPath: '/graphiql', + graphiql: true, + graphiqlOnGraphQLGET: false, + websockets: true, + maskError: protectedMaskError + }, + grafast: { + context: protectedContext, + explain: false + }, + ...overrides +}); + +describe('Graphile caller preset composition', () => { + it('rejects every non-empty caller preset unless startup admitted it as trusted code', () => { + for (const input of [ + { callerExtends: [{ plugins: [callerPlugin] }] }, + { callerPreset: { schema: { defaultBehavior: '-delete' } } } + ]) { + expect(() => compose({ + ...input, + callerPresetsTrusted: false + })).toThrow(expect.objectContaining({ + code: GRAPHILE_CALLER_PRESET_NOT_TRUSTED_CODE + })); + } + }); + + it('allows empty defaults without widening the production trust boundary', () => { + expect(() => compose({ + callerExtends: [], + callerPreset: {}, + callerPresetsTrusted: false + })).not.toThrow(); + }); + + it('reports caller trust admission as a startup configuration error', () => { + expect(() => compose({ + callerExtends: [{ plugins: [callerPlugin] }], + callerPresetsTrusted: false + })).toThrow(GraphileCallerPresetNotTrustedError); + }); + + it('applies caller plugins and safe schema/runtime configuration', () => { + const resolved = resolvePreset(compose({ + callerExtends: [{ plugins: [callerPlugin] }], + callerPreset: { + schema: { defaultBehavior: '-delete' }, + grafserv: { maxRequestLength: 123_456 } + } + })); + + expect(resolved.plugins).toEqual(expect.arrayContaining([ + callerPlugin, + authPlugin, + websocketAdmissionPlugin + ])); + expect(resolved.schema).toMatchObject({ + defaultBehavior: '-delete', + releaseBuildStateAfterValidation: true + }); + expect(resolved.grafserv).toMatchObject({ + maxRequestLength: 123_456, + graphqlPath: '/graphql', + websockets: true, + maskError: protectedMaskError + }); + expect(resolved.grafast).toMatchObject({ + context: protectedContext, + explain: false + }); + expect(resolved.pgServices).toEqual([exactService]); + }); + + it.each([ + [ + 'runtime service', + { pgServices: [{ name: 'attacker' }] }, + 'pgServices' + ], + [ + 'tenant context', + { grafast: { context: () => ({ pgSettings: {} }) } }, + 'grafast.context' + ], + [ + 'error masking', + { grafserv: { maskError: (error: unknown) => error } }, + 'grafserv.maskError' + ], + [ + 'WebSocket transport', + { grafserv: { websockets: false } }, + 'grafserv.websockets' + ], + [ + 'server build-state policy', + { schema: { releaseBuildStateAfterValidation: false } }, + 'schema.releaseBuildStateAfterValidation' + ], + [ + 'protected plugin replacement', + { plugins: [{ name: 'AuthCookiePlugin' }] }, + 'plugins.AuthCookiePlugin' + ], + [ + 'protected plugin disable', + { disablePlugins: ['ConstructiveWebSocketOperationAdmissionPlugin'] }, + 'disablePlugins.ConstructiveWebSocketOperationAdmissionPlugin' + ] + ])('rejects caller %s overrides', (_label, callerPreset, protectedSetting) => { + let thrown: unknown; + try { + compose({ + callerPreset: callerPreset as unknown as GraphileConfig.Preset + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(GraphileProtectedPresetOverrideError); + expect(thrown).toMatchObject({ + code: GRAPHILE_PROTECTED_PRESET_OVERRIDE_CODE, + presetPath: 'graphile.preset', + protectedSetting + }); + }); + + it('rejects protected overrides hidden in nested caller extends', () => { + expect(() => compose({ + callerExtends: [{ + extends: [{ + pgServices: [{ name: 'attacker' }] + } as unknown as GraphileConfig.Preset] + }] + })).toThrow(expect.objectContaining({ + code: GRAPHILE_PROTECTED_PRESET_OVERRIDE_CODE, + presetPath: 'graphile.extends[0].extends[0]', + protectedSetting: 'pgServices' + })); + }); +}); diff --git a/graphql/server/src/middleware/__tests__/pg-introspection-memo-contract.test.ts b/graphql/server/src/middleware/__tests__/pg-introspection-memo-contract.test.ts new file mode 100644 index 0000000000..0ccf64ee5d --- /dev/null +++ b/graphql/server/src/middleware/__tests__/pg-introspection-memo-contract.test.ts @@ -0,0 +1,140 @@ +import { parseIntrospectionResults } from 'pg-introspection'; + +function makeIntrospectionText(): string { + return JSON.stringify({ + database: { + _id: '1', + oid: '1', + datname: 'memo_contract', + datdba: '10', + datacl: null + }, + namespaces: [{ + _id: '2200', + oid: '2200', + nspname: 'tenant_api', + nspowner: '10', + nspacl: null + }], + classes: [], + attributes: [], + constraints: [], + procs: [], + roles: [{ + _id: '10', + oid: '10', + rolname: 'runtime_role', + rolsuper: false, + rolinherit: false, + rolcreaterole: false, + rolcreatedb: false, + rolcanlogin: true, + rolreplication: false, + rolconnlimit: -1, + rolpassword: null, + rolvaliduntil: null, + rolbypassrls: false, + rolconfig: null + }], + auth_members: [], + types: [], + enums: [], + extensions: [], + indexes: [], + inherits: [], + languages: [], + policies: [], + ranges: [], + depends: [], + descriptions: [], + am: [], + catalog_by_oid: { + 1247: 'pg_type', + 1255: 'pg_proc', + 1259: 'pg_class', + 2606: 'pg_constraint', + 2615: 'pg_namespace', + 3079: 'pg_extension' + }, + current_user: 'runtime_role', + pg_version: 'PostgreSQL test', + introspection_version: 1 + }); +} + +const parseFixture = (): any => parseIntrospectionResults(makeIntrospectionText()); + +describe('pg-introspection memo helper contract', () => { + it('installs memoized helpers as ordinary own data-function properties', () => { + const introspection = parseFixture(); + const namespace = introspection.namespaces[0]; + const getOwner = namespace.getOwner; + const descriptor = Object.getOwnPropertyDescriptor(namespace, 'getOwner'); + + expect(descriptor).toMatchObject({ + value: getOwner, + enumerable: true, + writable: true, + configurable: true + }); + expect(descriptor).not.toHaveProperty('get'); + expect(Object.keys(namespace)).toContain('getOwner'); + expect(getOwner.length).toBe(0); + expect(getOwner()).toBe(introspection.roles[0]); + expect(getOwner()).toBe(introspection.roles[0]); + expect(getOwner.call({ unrelated: true })).toBe(introspection.roles[0]); + expect(namespace.getOwner).toBe(getOwner); + expect(() => Reflect.construct(getOwner, [])).toThrow(TypeError); + + const spread = { ...namespace }; + expect(spread.getOwner).toBe(getOwner); + expect(JSON.parse(JSON.stringify(namespace))).toMatchObject({ + _id: '2200', + nspname: 'tenant_api' + }); + expect(JSON.stringify(namespace)).not.toContain('getOwner'); + }); + + it('preserves assignment, deletion, and post-freeze memoization', () => { + const mutable = parseFixture().namespaces[0]; + const replacement = jest.fn(() => 'replacement'); + + mutable.getOwner = replacement; + expect(mutable.getOwner()).toBe('replacement'); + expect(Reflect.deleteProperty(mutable, 'getOwner')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(mutable, 'getOwner')).toBe(false); + + const frozen = parseFixture().namespaces[0]; + const getTags = frozen.getTags; + Object.freeze(frozen); + const first = getTags(); + + expect(getTags()).toBe(first); + expect(frozen.getTags).toBe(getTags); + expect(Object.isFrozen(frozen)).toBe(true); + }); + + it('preserves lazy helper identity and memoization on non-extensible entities', () => { + for (const lock of [Object.preventExtensions, Object.seal, Object.freeze]) { + const introspection = parseFixture(); + const namespace = introspection.namespaces[0]; + lock(namespace); + + const getOwner = namespace.getOwner; + expect(namespace.getOwner).toBe(getOwner); + expect(getOwner()).toBe(introspection.roles[0]); + expect(getOwner()).toBe(introspection.roles[0]); + expect(getOwner.call({ unrelated: true })).toBe(introspection.roles[0]); + } + }); + + it('keeps memo state isolated between parsed builds', () => { + const first = parseFixture(); + const second = parseFixture(); + + expect(first.namespaces[0].getOwner).not.toBe(second.namespaces[0].getOwner); + expect(first.namespaces[0].getOwner()).toBe(first.roles[0]); + expect(second.namespaces[0].getOwner()).toBe(second.roles[0]); + expect(first.roles[0]).not.toBe(second.roles[0]); + }); +}); diff --git a/graphql/server/src/middleware/__tests__/scoped-introspection.test.ts b/graphql/server/src/middleware/__tests__/scoped-introspection.test.ts new file mode 100644 index 0000000000..a86789e8df --- /dev/null +++ b/graphql/server/src/middleware/__tests__/scoped-introspection.test.ts @@ -0,0 +1,112 @@ +import { createHash } from 'node:crypto'; + +import { + makeIntrospectionQuery, + makeSchemaScopedIntrospectionQuery +} from 'pg-introspection'; + +const makeScopedQueryWithCatalogTypes = makeSchemaScopedIntrospectionQuery as unknown as ( + schemas: readonly string[], + options?: { + catalogTypes?: 'all' | 'dependency-closure'; + capabilityExtensions?: readonly string[]; + } +) => ReturnType; + +describe('schema-scoped PostgreSQL introspection', () => { + it('preserves the stock query byte for byte', () => { + const stock = makeIntrospectionQuery(); + expect(stock).toHaveLength(7332); + expect(createHash('sha256').update(stock).digest('hex')).toBe( + 'c0ed817b912f78e1ea68c70d89ff4b7f9cb4c02d88112a69ac4109d5b996e4c5' + ); + }); + + it('keeps schema names in bind values and includes dependency closure', () => { + const schemas = ['tenant_a', "tenant_'quoted", 'tenant_a']; + const query = makeSchemaScopedIntrospectionQuery(schemas); + + expect(query.values).toEqual([['tenant_a', "tenant_'quoted"], []]); + expect(query.text).toContain('$1::text[]'); + expect(query.text).toContain('$2::text[]'); + expect(query.text).not.toContain('tenant_a'); + expect(query.text).not.toContain("tenant_'quoted"); + expect(query.text).toContain('object_closure(object_class, object_id)'); + expect(query.text).toContain('installed_extensions(_id, extnamespace)'); + expect(query.text).toContain('pg_depend.refclassid'); + expect(query.text).toContain("where deptype IN ('a', 'e')"); + expect(query.text).toContain('pg_constraint.conrelid = object_closure.object_id'); + expect(query.text).toContain('pg_proc.proallargtypes'); + expect(query.text).toContain('pg_type.typbasetype'); + expect(query.text).toContain('pg_inherits.inhparent'); + expect(query.text).toContain('pg_extension.extnamespace'); + expect(query.text).toContain( + 'pg_extension.oid = any (array(select installed_extensions._id' + ); + // ACL evaluation walks every grant entry, including roles unrelated to the + // runtime login. Keep the stock role and membership result sets complete so + // PgRBACPlugin cannot fail on a retained object's unrelated ACL grantee. + expect(query.text).toContain('from pg_catalog.pg_roles\n ),'); + expect(query.text).toContain('where roleid in (select roles._id from roles)'); + expect(query.text).not.toContain('pg_roles.rolname = current_user'); + expect(query.text).not.toContain('and member in (select roles._id from roles)'); + expect(query.text).toMatch(/from pg_catalog\.pg_language\s+where true/); + expect(query.text).toMatch(/from pg_catalog\.pg_am\s+where true/); + expect(query.text).not.toContain('namespace_closure'); + expect(query.text).not.toContain('pg_inherits.inhparent = object_closure.object_id'); + expect(query.text).toContain( + "or pg_type.typnamespace = 'pg_catalog'::regnamespace" + ); + }); + + it('can retain only catalog types reached by the dependency closure', () => { + const query = makeScopedQueryWithCatalogTypes(['tenant_a'], { + catalogTypes: 'dependency-closure', + capabilityExtensions: ['pg_trgm', 'vector', 'pg_trgm'] + }); + + expect(query.values).toEqual([['tenant_a'], ['pg_trgm', 'vector']]); + expect(query.text).not.toContain('pg_trgm'); + expect(query.text).not.toContain('vector'); + expect(query.text).toContain( + 'pg_extension.extname in (\n select capability_extension_names.extension_name' + ); + expect(query.text).toContain( + "pg_type.oid = any (array(select object_id from object_closure where object_class = 'pg_catalog.pg_type'::regclass))" + ); + expect(query.text).not.toContain( + "or pg_type.typnamespace = 'pg_catalog'::regnamespace" + ); + expect(query.text).toContain( + 'retained_index_support_objects(object_class, object_id)' + ); + expect(query.text).toContain('retained_index_metadata.indclass::oid[]'); + expect(query.text).toContain("'pg_catalog.pg_opclass'::regclass::oid"); + expect(query.text).toContain("'pg_catalog.pg_opfamily'::regclass::oid"); + expect(query.text).toContain("'pg_catalog.pg_operator'::regclass::oid"); + expect(query.text).toContain('pg_catalog.pg_amop'); + expect(query.text).toContain('pg_catalog.pg_amproc'); + expect(query.text).toContain('retained_index_metadata.indcollation::oid[]'); + }); + + it('rejects empty and system-schema scopes', () => { + expect(() => makeSchemaScopedIntrospectionQuery([])).toThrow( + 'requires at least one schema' + ); + expect(() => makeSchemaScopedIntrospectionQuery(['pg_catalog'])).toThrow( + 'cannot expose system schema' + ); + expect(() => makeSchemaScopedIntrospectionQuery(['information_schema'])).toThrow( + 'cannot expose system schema' + ); + expect(() => makeScopedQueryWithCatalogTypes(['tenant_a'], { + catalogTypes: 'unknown' + } as never)).toThrow('Unsupported schema-scoped catalog type policy'); + expect(() => makeScopedQueryWithCatalogTypes(['tenant_a'], { + catalogType: 'dependency-closure' + } as never)).toThrow('Unsupported schema-scoped introspection option'); + expect(() => makeScopedQueryWithCatalogTypes(['tenant_a'], { + capabilityExtensions: [' pg_trgm'] + })).toThrow('must contain exact non-empty extension names'); + }); +}); diff --git a/graphql/server/src/middleware/graphile-build-contract.ts b/graphql/server/src/middleware/graphile-build-contract.ts new file mode 100644 index 0000000000..becc854928 --- /dev/null +++ b/graphql/server/src/middleware/graphile-build-contract.ts @@ -0,0 +1,289 @@ +import { createHash, createHmac, randomBytes } from 'node:crypto'; + +import type { ComputeConfig, StorageConfig } from '@constructive-io/express-context'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; + +import type { DatabaseSettings } from '../types'; + +type GraphileBuildSettings = Omit< + NonNullable, + | 'realtimeSchema' + | 'realtimeNotificationMode' + | 'realtimeNotificationRoleRevalidationMs' + | 'realtimeCursorPollIntervalMs' + | 'realtimeCursorHeartbeatIntervalMs' + | 'trustCallerPresetsInProduction' +>; + +// The resident cache is process-local, and its keys are emitted in diagnostics. +// A plain digest of plugin/settings configuration could act as an offline +// verifier for a low-entropy secret captured by a caller preset. Key the digest +// per process so equality remains stable for this cache lifetime without making +// the serialized contract portable or reversible evidence. +const graphileBuildContractHmacKey = randomBytes(32); + +export interface GraphileBuildContractV1 { + version: 1; + /** Process-local identity for the exact graphile(opts) configuration owner. */ + configurationIdentity: string; + poolIdentity: string; + databaseId: string; + databaseName: string; + apiId: string; + schemas: string[]; + roles: { + authenticated: string; + anonymous: string; + }; + pluginSettings: DatabaseSettings | null; + graphileSettings: GraphileBuildSettings | null; + computeModules: ComputeConfig['modules']; + computeBindings: ComputeConfig['bindings']; + storageModules: StorageConfig['modules']; + surface: { + isPublic: boolean; + enableRealtime: boolean; + realtimeSchema: string | null; + realtimeNotificationMode: 'dedicated' | 'shared-exact' | null; + realtimeListenerPoolIdentity: string | null; + realtimeNotificationRoleRevalidationMs: number | null; + realtimeCursorPollIntervalMs: number | null; + realtimeCursorHeartbeatIntervalMs: number | null; + graphiql: boolean; + graphiqlOnGraphQLGET: boolean; + explain: boolean; + }; + introspectionMode: 'stock' | 'scoped-required'; + introspectionClientReleaseMode: 'reuse' | 'destroy'; +} + +export interface CreateGraphileBuildContractInput { + configurationIdentity: string; + poolIdentity: string; + databaseId: string; + databaseName: string; + apiId: string; + schemas: string[]; + authenticatedRole: string; + anonymousRole: string; + pluginSettings?: DatabaseSettings; + graphileSettings?: ConstructiveOptions['graphile']; + compute?: ComputeConfig; + storage?: StorageConfig; + isPublic?: boolean; + enableRealtime?: boolean; + /** + * Physical schema containing realtime cursor functions. It is part of the + * exact instance identity only when realtime is enabled. + */ + realtimeSchema?: string; + realtimeNotificationMode?: 'dedicated' | 'shared-exact'; + /** Opaque digest only; raw listener connection configuration is forbidden. */ + realtimeListenerPoolIdentity?: string; + realtimeNotificationRoleRevalidationMs?: number; + realtimeCursorPollIntervalMs?: number; + realtimeCursorHeartbeatIntervalMs?: number; + /** Whether this exact build serves the GraphiQL UI. Defaults to the legacy true value. */ + graphiql?: boolean; + /** Whether GraphQL GET requests serve GraphiQL. Defaults to the legacy false value. */ + graphiqlOnGraphQLGET?: boolean; + explain?: boolean; + introspectionMode?: 'stock' | 'scoped-required'; + introspectionClientReleaseMode?: 'reuse' | 'destroy'; +} + +const graphileBuildSettings = ( + settings: ConstructiveOptions['graphile'] +): GraphileBuildSettings | null => { + if (!settings) return null; + const normalized = { ...settings }; + // Realtime cursor routing is represented by `surface.realtimeSchema` below. + // Keeping it here as well would split disabled surfaces on an irrelevant + // process-wide option and needlessly reduce resident tenant density. + delete normalized.realtimeSchema; + delete normalized.realtimeNotificationMode; + delete normalized.realtimeNotificationRoleRevalidationMs; + delete normalized.realtimeCursorPollIntervalMs; + delete normalized.realtimeCursorHeartbeatIntervalMs; + // Admission policy controls whether startup configuration may enter the + // process trust boundary; it does not change the admitted Graphile build. + delete normalized.trustCallerPresetsInProduction; + return normalized; +}; + +export const createGraphileBuildContract = ( + input: CreateGraphileBuildContractInput +): GraphileBuildContractV1 => { + const realtimeNotificationMode = input.enableRealtime + ? input.realtimeNotificationMode ?? 'dedicated' + : null; + if ( + realtimeNotificationMode === 'shared-exact' + && !input.realtimeListenerPoolIdentity + ) { + throw new Error('Shared realtime requires an opaque listener pool identity'); + } + return { + version: 1, + configurationIdentity: input.configurationIdentity, + poolIdentity: input.poolIdentity, + databaseId: input.databaseId, + databaseName: input.databaseName, + apiId: input.apiId, + schemas: [...input.schemas], + roles: { + authenticated: input.authenticatedRole, + anonymous: input.anonymousRole + }, + pluginSettings: input.pluginSettings ?? null, + graphileSettings: graphileBuildSettings(input.graphileSettings), + computeModules: input.compute?.modules.map((module) => ({ ...module })) ?? [], + computeBindings: input.compute?.bindings.map((binding) => ({ + ...binding, + module: { ...binding.module } + })) ?? [], + storageModules: input.storage?.modules.map((module) => ({ ...module })) ?? [], + surface: { + isPublic: input.isPublic ?? true, + enableRealtime: input.enableRealtime ?? false, + realtimeSchema: input.enableRealtime + ? input.realtimeSchema ?? 'realtime_public' + : null, + realtimeNotificationMode, + realtimeListenerPoolIdentity: + realtimeNotificationMode === 'shared-exact' + ? input.realtimeListenerPoolIdentity ?? null + : null, + realtimeNotificationRoleRevalidationMs: + realtimeNotificationMode === 'shared-exact' + ? input.realtimeNotificationRoleRevalidationMs ?? 60_000 + : null, + realtimeCursorPollIntervalMs: input.enableRealtime + ? input.realtimeCursorPollIntervalMs ?? 5_000 + : null, + realtimeCursorHeartbeatIntervalMs: input.enableRealtime + ? input.realtimeCursorHeartbeatIntervalMs ?? 30_000 + : null, + graphiql: input.graphiql ?? true, + graphiqlOnGraphQLGET: input.graphiqlOnGraphQLGET ?? false, + explain: input.explain ?? false + }, + introspectionMode: input.introspectionMode ?? 'stock', + introspectionClientReleaseMode: input.introspectionClientReleaseMode ?? 'reuse' + }; +}; + +let nextReferenceIdentity = 0; +const referenceIdentities = new WeakMap(); +const symbolIdentities = new Map(); + +const referenceIdentity = (value: object): number => { + let identity = referenceIdentities.get(value); + if (identity === undefined) { + identity = ++nextReferenceIdentity; + referenceIdentities.set(value, identity); + } + return identity; +}; + +const symbolIdentity = (value: symbol): number => { + let identity = symbolIdentities.get(value); + if (identity === undefined) { + identity = ++nextReferenceIdentity; + symbolIdentities.set(value, identity); + } + return identity; +}; + +const canonicalize = (value: unknown, ancestors = new Set()): unknown => { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' + ) { + return value; + } + if (typeof value === 'number') { + if (Number.isNaN(value)) return { $number: 'NaN' }; + if (value === Number.POSITIVE_INFINITY) return { $number: 'Infinity' }; + if (value === Number.NEGATIVE_INFINITY) return { $number: '-Infinity' }; + if (Object.is(value, -0)) return { $number: '-0' }; + return value; + } + if (value === undefined) return { $undefined: true }; + if (typeof value === 'function') { + return { + $function: value.name, + source: createHash('sha256').update(Function.prototype.toString.call(value)).digest('hex'), + // Function source cannot distinguish closures that captured different + // tenant/plugin configuration. Cache reuse is process-local, so bind + // the contract to the exact configured function object as well. + reference: referenceIdentity(value) + }; + } + if (typeof value === 'bigint') return { $bigint: value.toString() }; + if (typeof value === 'symbol') { + return { + $symbol: value.description ?? null, + reference: symbolIdentity(value) + }; + } + if (typeof value !== 'object') { + return { $type: typeof value, value: String(value) }; + } + + if (ancestors.has(value)) { + throw new Error('Graphile build contract contains a circular value'); + } + const nextAncestors = new Set(ancestors).add(value); + if (Array.isArray(value)) { + return value.map((item) => canonicalize(item, nextAncestors)); + } + if (value instanceof Date) return { $date: value.toISOString() }; + if (Buffer.isBuffer(value)) { + return { + $buffer: createHash('sha256').update(value).digest('hex'), + bytes: value.byteLength + }; + } + if (value instanceof RegExp) { + return { $regexp: value.source, flags: value.flags }; + } + + const record = value as Record; + const symbolRecord = value as Record; + const stringProperties = Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalize(record[key], nextAncestors)]) + ); + const symbolProperties = Object.getOwnPropertySymbols(record) + .map((key) => ({ + key: symbolIdentity(key), + description: key.description ?? null, + value: canonicalize(symbolRecord[key], nextAncestors) + })) + .sort((left, right) => left.key - right.key); + const prototype = Object.getPrototypeOf(record); + if (prototype === Object.prototype || prototype === null) { + return symbolProperties.length === 0 + ? stringProperties + : { $properties: stringProperties, $symbols: symbolProperties }; + } + // Unknown class instances can hide effective configuration in private + // fields or accessors. Their exact reference is safer than treating two + // empty-looking instances as equivalent. + return { + $instance: record.constructor?.name ?? null, + reference: referenceIdentity(record), + properties: stringProperties, + symbols: symbolProperties + }; +}; + +export const hashGraphileBuildContract = (contract: GraphileBuildContractV1): string => { + const canonical = JSON.stringify(canonicalize(contract)); + return `graphile:v1:${createHmac('sha256', graphileBuildContractHmacKey) + .update(canonical) + .digest('hex')}`; +}; diff --git a/graphql/server/src/middleware/graphile-build-governor.ts b/graphql/server/src/middleware/graphile-build-governor.ts new file mode 100644 index 0000000000..df903a2cf2 --- /dev/null +++ b/graphql/server/src/middleware/graphile-build-governor.ts @@ -0,0 +1,442 @@ +import { raceWithClearedTimeout } from 'graphile-cache'; + +export interface GraphileGovernorCounters { + buildsStarted: number; + coalescedRequests: number; + buildWaitTimeouts: number; + buildWaitAborts: number; + queueRefusals: number; + shutdownRefusals: number; + buildWatchdogTrips: number; + stuckRefusals: number; + queueDepth: number; + activeBuilds: number; + unhealthy: boolean; + restartRequired: boolean; + stuckSinceMs: number | null; + activeBuildAgeMs: number | null; + watchdogMs: number; +} + +export const GRAPHILE_BUILD_QUEUE_FULL_CODE = 'GRAPHILE_BUILD_QUEUE_FULL'; +export const GRAPHILE_BUILD_SHUTTING_DOWN_CODE = 'GRAPHILE_BUILD_SHUTTING_DOWN'; +export const GRAPHILE_BUILD_STUCK_RESTART_REQUIRED_CODE = + 'GRAPHILE_BUILD_STUCK_RESTART_REQUIRED'; + +type GraphileBuildCoordinatorErrorCode = + | typeof GRAPHILE_BUILD_QUEUE_FULL_CODE + | typeof GRAPHILE_BUILD_SHUTTING_DOWN_CODE + | typeof GRAPHILE_BUILD_STUCK_RESTART_REQUIRED_CODE; + +export class GraphileBuildCoordinatorError extends Error { + readonly retryAfterSeconds: number; + + constructor( + readonly code: GraphileBuildCoordinatorErrorCode, + message: string, + retryAfterSeconds = 1 + ) { + super(message); + this.name = 'GraphileBuildCoordinatorError'; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +export class GraphileBuildWaitAbortedError extends Error { + readonly code = 'GRAPHILE_BUILD_WAIT_ABORTED'; + + constructor() { + super('The request ended while waiting for a GraphQL schema build'); + this.name = 'GraphileBuildWaitAbortedError'; + } +} + +interface BuildWaiter { + resolve(release: () => void): void; + reject(error: Error): void; + signal?: AbortSignal; + onAbort?: () => void; + onAdmitted?: () => void; +} + +export interface BuildAcquireOptions { + signal?: AbortSignal; + onAdmitted?: () => void; +} + +export class BuildCoordinator { + private active = 0; + private readonly waiters: BuildWaiter[] = []; + private readonly drainWaiters = new Set<() => void>(); + private readonly unhealthyListeners = new Set<( + error: GraphileBuildCoordinatorError + ) => void>(); + private closed = false; + private unhealthyError: GraphileBuildCoordinatorError | null = null; + private stuckAtMs: number | null = null; + private activeStartedAtMs: number | null = null; + private watchdogTimer: ReturnType | null = null; + + constructor( + private readonly capacity: number, + private readonly maxQueueDepth: number, + private readonly buildWatchdogMs = 300_000, + private readonly onUnhealthy?: () => void + ) { + if (capacity !== 1) { + throw new Error( + 'GRAPHILE_BUILD_CONCURRENCY must be exactly 1 until concurrent builds reserve independent heap budgets' + ); + } + if (!Number.isSafeInteger(maxQueueDepth) || maxQueueDepth < 0) { + throw new Error('GRAPHILE_BUILD_QUEUE_MAX must be a non-negative safe integer'); + } + if (!Number.isSafeInteger(buildWatchdogMs) || buildWatchdogMs <= 0) { + throw new Error('GRAPHILE_BUILD_WATCHDOG_MS must be a positive safe integer'); + } + } + + acquire(options: BuildAcquireOptions = {}): Promise<() => void> { + const { signal, onAdmitted } = options; + if (this.unhealthyError) { + counters.stuckRefusals++; + return Promise.reject(this.unhealthyError); + } + if (this.closed) { + counters.shutdownRefusals++; + return Promise.reject(new GraphileBuildCoordinatorError( + GRAPHILE_BUILD_SHUTTING_DOWN_CODE, + 'GraphQL schema build admission is closed for shutdown' + )); + } + if (signal?.aborted) { + return Promise.reject(new GraphileBuildWaitAbortedError()); + } + if (this.active < this.capacity) { + this.active++; + onAdmitted?.(); + return Promise.resolve(this.createRelease()); + } + if (this.waiters.length >= this.maxQueueDepth) { + counters.queueRefusals++; + return Promise.reject(new GraphileBuildCoordinatorError( + GRAPHILE_BUILD_QUEUE_FULL_CODE, + 'GraphQL schema build queue is full' + )); + } + return new Promise((resolve, reject) => { + const waiter: BuildWaiter = { resolve, reject, signal, onAdmitted }; + this.waiters.push(waiter); + if (signal) { + waiter.onAbort = () => { + const index = this.waiters.indexOf(waiter); + if (index < 0) return; + this.waiters.splice(index, 1); + reject(new GraphileBuildWaitAbortedError()); + }; + signal.addEventListener('abort', waiter.onAbort, { once: true }); + if (signal.aborted) waiter.onAbort(); + } + }); + } + + private createRelease(): () => void { + this.activeStartedAtMs = Date.now(); + this.watchdogTimer = setTimeout(() => this.markUnhealthy(), this.buildWatchdogMs); + this.watchdogTimer.unref?.(); + let released = false; + return () => { + if (released) return; + released = true; + if (this.watchdogTimer) { + clearTimeout(this.watchdogTimer); + this.watchdogTimer = null; + } + this.activeStartedAtMs = null; + if (this.unhealthyError) { + // The watchdog never releases this slot. Only completion of the actual + // build reaches this callback, and the latched unhealthy state still + // prevents this process from admitting another build. + this.active = Math.max(0, this.active - 1); + if (this.active === 0) { + for (const resolve of this.drainWaiters) resolve(); + this.drainWaiters.clear(); + } + return; + } + const next = this.waiters.shift(); + if (next) { + if (next.signal && next.onAbort) { + next.signal.removeEventListener('abort', next.onAbort); + } + next.onAdmitted?.(); + next.resolve(this.createRelease()); + } else { + this.active = Math.max(0, this.active - 1); + if (this.active === 0) { + for (const resolve of this.drainWaiters) resolve(); + this.drainWaiters.clear(); + } + } + }; + } + + private markUnhealthy(): void { + if (this.unhealthyError || this.active === 0) return; + this.stuckAtMs = Date.now(); + this.unhealthyError = new GraphileBuildCoordinatorError( + GRAPHILE_BUILD_STUCK_RESTART_REQUIRED_CODE, + 'GraphQL schema build exceeded its watchdog; process restart is required', + 30 + ); + counters.buildWatchdogTrips++; + this.onUnhealthy?.(); + + for (const waiter of this.waiters.splice(0)) { + if (waiter.signal && waiter.onAbort) { + waiter.signal.removeEventListener('abort', waiter.onAbort); + } + counters.stuckRefusals++; + waiter.reject(this.unhealthyError); + } + for (const listener of this.unhealthyListeners) { + listener(this.unhealthyError); + } + } + + onStuck( + listener: (error: GraphileBuildCoordinatorError) => void + ): () => void { + if (this.unhealthyError) { + listener(this.unhealthyError); + return () => undefined; + } + this.unhealthyListeners.add(listener); + return () => this.unhealthyListeners.delete(listener); + } + + close(): boolean { + if (this.closed) return false; + this.closed = true; + const error = new GraphileBuildCoordinatorError( + GRAPHILE_BUILD_SHUTTING_DOWN_CODE, + 'GraphQL schema build admission closed during shutdown' + ); + for (const waiter of this.waiters.splice(0)) { + if (waiter.signal && waiter.onAbort) { + waiter.signal.removeEventListener('abort', waiter.onAbort); + } + counters.shutdownRefusals++; + waiter.reject(error); + } + return true; + } + + async closeAndDrain(timeoutMs: number): Promise { + this.close(); + if (this.active === 0) return true; + const drained = new Promise((resolve) => this.drainWaiters.add(resolve)); + const result = await raceWithClearedTimeout(drained, timeoutMs); + if (result.timedOut) this.drainWaiters.clear(); + return !result.timedOut; + } + + get activeCount(): number { + return this.active; + } + + get queueDepth(): number { + return this.waiters.length; + } + + get isClosed(): boolean { + return this.closed; + } + + get isUnhealthy(): boolean { + return this.unhealthyError !== null; + } + + get stuckSinceMs(): number | null { + return this.stuckAtMs; + } + + get activeBuildAgeMs(): number | null { + return this.activeStartedAtMs == null + ? null + : Math.max(0, Date.now() - this.activeStartedAtMs); + } + + get watchdogMs(): number { + return this.buildWatchdogMs; + } +} + +const parsePositiveInt = (value: string | undefined, fallback: number): number => { + const parsed = value ? Number.parseInt(value, 10) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const parseNonNegativeInt = (value: string | undefined, fallback: number): number => { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error('GRAPHILE_BUILD_QUEUE_MAX must be a non-negative safe integer'); + } + return parsed; +}; + +const parseSerializedBuildConcurrency = (value: string | undefined): number => { + if (value === undefined) return 1; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed !== 1) { + throw new Error( + 'GRAPHILE_BUILD_CONCURRENCY must be exactly 1 until concurrent builds reserve independent heap budgets' + ); + } + return parsed; +}; + +const parseBuildWatchdogMs = (value: string | undefined): number => { + if (value === undefined) return 300_000; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error('GRAPHILE_BUILD_WATCHDOG_MS must be a positive safe integer'); + } + return parsed; +}; + +let coordinatorGeneration = 0; + +const createBuildCoordinator = (): BuildCoordinator => new BuildCoordinator( + parseSerializedBuildConcurrency(process.env.GRAPHILE_BUILD_CONCURRENCY), + parseNonNegativeInt(process.env.GRAPHILE_BUILD_QUEUE_MAX, 16), + parseBuildWatchdogMs(process.env.GRAPHILE_BUILD_WATCHDOG_MS), + () => { + // Late completion from the stuck generation must never publish. + coordinatorGeneration++; + } +); + +let coordinator = createBuildCoordinator(); + +const counters = { + buildsStarted: 0, + coalescedRequests: 0, + buildWaitTimeouts: 0, + buildWaitAborts: 0, + queueRefusals: 0, + shutdownRefusals: 0, + buildWatchdogTrips: 0, + stuckRefusals: 0 +}; + +export interface RunGraphileBuildOptions extends BuildAcquireOptions {} + +export const runGraphileBuild = async ( + build: () => Promise, + options: RunGraphileBuildOptions = {} +): Promise => { + const release = await coordinator.acquire(options); + counters.buildsStarted++; + try { + return await build(); + } finally { + release(); + } +}; + +export const recordCoalescedRequest = (): void => { + counters.coalescedRequests++; +}; + +export const waitForGraphileBuild = async ( + build: Promise, + timeoutMs = parsePositiveInt(process.env.GRAPHILE_BUILD_TIMEOUT_MS, 180_000), + signal?: AbortSignal +): Promise => { + if (signal?.aborted) { + counters.buildWaitAborts++; + throw new GraphileBuildWaitAbortedError(); + } + let timer: ReturnType | undefined; + let abortListener: (() => void) | undefined; + let removeStuckListener: (() => void) | undefined; + const timeout = new Promise<{ type: 'timeout' }>((resolve) => { + timer = setTimeout(() => resolve({ type: 'timeout' }), timeoutMs); + timer.unref?.(); + }); + const aborted = signal + ? new Promise<{ type: 'aborted' }>((resolve) => { + abortListener = () => resolve({ type: 'aborted' }); + signal.addEventListener('abort', abortListener, { once: true }); + if (signal.aborted) abortListener(); + }) + : new Promise(() => undefined); + const stuck = new Promise<{ + type: 'stuck'; + error: GraphileBuildCoordinatorError; + }>((resolve) => { + removeStuckListener = coordinator.onStuck((error) => { + resolve({ type: 'stuck', error }); + }); + }); + try { + const result = await Promise.race([ + build.then((value) => ({ type: 'ready' as const, value })), + timeout, + aborted, + stuck + ]); + if (result.type === 'timeout') { + counters.buildWaitTimeouts++; + return null; + } + if (result.type === 'aborted') { + counters.buildWaitAborts++; + throw new GraphileBuildWaitAbortedError(); + } + if (result.type === 'stuck') throw result.error; + return result.value; + } finally { + if (timer) clearTimeout(timer); + if (signal && abortListener) signal.removeEventListener('abort', abortListener); + removeStuckListener?.(); + } +}; + +export const captureGraphileBuildGeneration = (): number => coordinatorGeneration; + +export const isGraphileBuildGenerationCurrent = (generation: number): boolean => + generation === coordinatorGeneration; + +export const closeGraphileBuildCoordinator = async ( + timeoutMs = parsePositiveInt(process.env.GRAPHILE_BUILD_SHUTDOWN_TIMEOUT_MS, 30_000) +): Promise => { + if (coordinator.close()) coordinatorGeneration++; + return coordinator.closeAndDrain(timeoutMs); +}; + +/** + * Reopen admission only after the previous coordinator fully drained. This + * supports a clean in-process Server restart without ever overlapping a late + * build from the previous generation. + */ +export const reopenGraphileBuildCoordinator = (): boolean => { + if (coordinator.isUnhealthy) return false; + if (!coordinator.isClosed) return true; + if (coordinator.activeCount !== 0 || coordinator.queueDepth !== 0) return false; + coordinator = createBuildCoordinator(); + return true; +}; + +export const getGraphileGovernorCounters = (): GraphileGovernorCounters => ({ + ...counters, + queueDepth: coordinator.queueDepth, + activeBuilds: coordinator.activeCount, + unhealthy: coordinator.isUnhealthy, + restartRequired: coordinator.isUnhealthy, + stuckSinceMs: coordinator.stuckSinceMs, + activeBuildAgeMs: coordinator.activeBuildAgeMs, + watchdogMs: coordinator.watchdogMs +}); diff --git a/graphql/server/src/middleware/graphile-preset-composition.ts b/graphql/server/src/middleware/graphile-preset-composition.ts new file mode 100644 index 0000000000..2dc719c02a --- /dev/null +++ b/graphql/server/src/middleware/graphile-preset-composition.ts @@ -0,0 +1,221 @@ +import type { GraphileConfig } from 'graphile-config'; + +const hasOwn = (value: object, key: PropertyKey): boolean => + Object.prototype.hasOwnProperty.call(value, key); + +/** Names owned by the server even when a particular surface is disabled. */ +export const CONSTRUCTIVE_PROTECTED_GRAPHILE_PLUGINS = Object.freeze([ + 'AuthCookiePlugin', + 'ConstructiveWebSocketOperationAdmissionPlugin', + 'FunctionBindingsPlugin', + 'GrafastCacheLimitsPlugin' +] as const); + +const protectedPluginNames = new Set( + CONSTRUCTIVE_PROTECTED_GRAPHILE_PLUGINS +); + +const PROTECTED_SCOPE_FIELDS = Object.freeze({ + grafast: Object.freeze(['context', 'explain']), + grafserv: Object.freeze([ + 'graphqlPath', + 'graphiqlPath', + 'graphiql', + 'graphiqlOnGraphQLGET', + 'websockets', + 'maskError' + ]), + schema: Object.freeze(['releaseBuildStateAfterValidation']) +} as const); + +export const GRAPHILE_PROTECTED_PRESET_OVERRIDE_CODE = + 'GRAPHILE_PROTECTED_PRESET_OVERRIDE'; +export const GRAPHILE_CALLER_PRESET_NOT_TRUSTED_CODE = + 'GRAPHILE_CALLER_PRESET_NOT_TRUSTED'; + +/** + * Caller presets are executable server code, not tenant-scoped configuration. + * This error keeps production deny-by-default unless the deployment explicitly + * admits that code into the same trust boundary as Constructive itself. + */ +export class GraphileCallerPresetNotTrustedError extends Error { + readonly code = GRAPHILE_CALLER_PRESET_NOT_TRUSTED_CODE; + + constructor() { + super( + 'Graphile caller presets are disabled in production unless trustCallerPresetsInProduction is explicitly enabled' + ); + this.name = 'GraphileCallerPresetNotTrustedError'; + } +} + +/** + * A startup configuration error, never a request error. Values are omitted so + * connection credentials and plugin configuration cannot leak into logs. + */ +export class GraphileProtectedPresetOverrideError extends Error { + readonly code = GRAPHILE_PROTECTED_PRESET_OVERRIDE_CODE; + + constructor( + readonly presetPath: string, + readonly protectedSetting: string + ) { + super( + `Graphile caller preset '${presetPath}' may not configure protected setting '${protectedSetting}'` + ); + this.name = 'GraphileProtectedPresetOverrideError'; + } +} + +const reject = (path: string, setting: string): never => { + throw new GraphileProtectedPresetOverrideError(path, setting); +}; + +const assertScopeDoesNotOverride = ( + preset: Record, + path: string, + scope: keyof typeof PROTECTED_SCOPE_FIELDS +): void => { + const value = preset[scope]; + if (typeof value !== 'object' || value === null || Array.isArray(value)) return; + for (const field of PROTECTED_SCOPE_FIELDS[scope]) { + if (hasOwn(value, field)) reject(path, `${scope}.${field}`); + } +}; + +const assertPluginNamesAreNotProtected = ( + value: unknown, + path: string, + field: 'plugins' | 'disablePlugins' +): void => { + if (!Array.isArray(value)) return; + for (const plugin of value) { + const name = field === 'plugins' + ? (plugin as { name?: unknown } | null)?.name + : plugin; + if (typeof name === 'string' && protectedPluginNames.has(name)) { + reject(path, `${field}.${name}`); + } + } +}; + +const assertPresetDoesNotOverrideProtectedSettings = ( + preset: unknown, + path: string, + visiting: Set, + validated: Set +): void => { + if (typeof preset !== 'object' || preset === null || Array.isArray(preset)) return; + if (validated.has(preset)) return; + if (visiting.has(preset)) { + reject(path, 'extends.circular'); + } + + visiting.add(preset); + const record = preset as Record; + if (hasOwn(record, 'pgServices')) reject(path, 'pgServices'); + assertScopeDoesNotOverride(record, path, 'grafast'); + assertScopeDoesNotOverride(record, path, 'grafserv'); + assertScopeDoesNotOverride(record, path, 'schema'); + assertPluginNamesAreNotProtected(record.plugins, path, 'plugins'); + assertPluginNamesAreNotProtected(record.disablePlugins, path, 'disablePlugins'); + + if (Array.isArray(record.extends)) { + record.extends.forEach((nested, index) => { + assertPresetDoesNotOverrideProtectedSettings( + nested, + `${path}.extends[${index}]`, + visiting, + validated + ); + }); + } + visiting.delete(preset); + validated.add(preset); +}; + +export interface ComposeGraphilePresetInput { + /** Constructive feature presets applied before trusted caller customization. */ + basePresets: readonly GraphileConfig.Preset[]; + callerExtends?: readonly GraphileConfig.Preset[]; + callerPreset?: Partial; + /** Whether caller preset code has been explicitly admitted into the TCB. */ + callerPresetsTrusted: boolean; + /** Server-owned presets applied after callers, for example cache bounds. */ + protectedPresets?: readonly GraphileConfig.Preset[]; + protectedPlugins: GraphileConfig.Plugin[]; + pgServices: NonNullable; + schema: NonNullable; + grafserv: NonNullable; + grafast: NonNullable; +} + +export interface GraphileCallerPresetInput { + callerExtends?: readonly GraphileConfig.Preset[]; + callerPreset?: Partial; + /** Whether all caller preset code has been admitted into the process TCB. */ + callerPresetsTrusted: boolean; +} + +const hasCallerPresetConfiguration = ( + input: GraphileCallerPresetInput +): boolean => { + if ((input.callerExtends?.length ?? 0) > 0) return true; + const preset = input.callerPreset; + if (!preset) return false; + return Reflect.ownKeys(preset).length > 0; +}; + +/** Validate eagerly at server construction and again at lazy tenant build. */ +export const assertGraphileCallerPresetsSafe = ( + input: GraphileCallerPresetInput +): void => { + if (!input.callerPresetsTrusted && hasCallerPresetConfiguration(input)) { + throw new GraphileCallerPresetNotTrustedError(); + } + const callerExtends = input.callerExtends ?? []; + const validated = new Set(); + callerExtends.forEach((preset, index) => { + assertPresetDoesNotOverrideProtectedSettings( + preset, + `graphile.extends[${index}]`, + new Set(), + validated + ); + }); + if (input.callerPreset) { + assertPresetDoesNotOverrideProtectedSettings( + input.callerPreset, + 'graphile.preset', + new Set(), + validated + ); + } +}; + +/** + * Compose trusted caller Graphile configuration inside the server-owned tenant + * boundary. The root fields are deliberately written by Constructive after all + * caller presets, so the exact pool, request context, transports, and security + * plugins cannot be replaced through Graphile's shallow preset merging. + */ +export const composeGraphilePreset = ( + input: ComposeGraphilePresetInput +): GraphileConfig.Preset => { + const callerExtends = input.callerExtends ?? []; + assertGraphileCallerPresetsSafe(input); + + return { + extends: [ + ...input.basePresets, + ...callerExtends, + ...(input.callerPreset ? [input.callerPreset] : []), + ...(input.protectedPresets ?? []) + ], + plugins: input.protectedPlugins, + pgServices: input.pgServices, + schema: input.schema, + grafserv: input.grafserv, + grafast: input.grafast + }; +}; diff --git a/package.json b/package.json index 197e3b3514..fb4c9c2e8c 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,13 @@ "onlyBuiltDependencies": [ "@launchql/protobufjs", "core-js-pure" - ] + ], + "patchedDependencies": { + "@dataplan/pg@1.0.3": "patches/@dataplan__pg@1.0.3.patch", + "pg-introspection@1.0.1": "patches/pg-introspection@1.0.1.patch", + "graphile-build-pg@5.0.2": "patches/graphile-build-pg.patch", + "graphile-build@5.0.2": "patches/graphile-build@5.0.2.patch", + "@graphile-contrib/pg-many-to-many@2.0.0-rc.2": "patches/@graphile-contrib__pg-many-to-many@2.0.0-rc.2.patch" + } } } diff --git a/patches/@dataplan__pg@1.0.3.patch b/patches/@dataplan__pg@1.0.3.patch new file mode 100644 index 0000000000..b9339962ab --- /dev/null +++ b/patches/@dataplan__pg@1.0.3.patch @@ -0,0 +1,179 @@ +diff --git a/dist/adaptors/pg.js b/dist/adaptors/pg.js +--- a/dist/adaptors/pg.js ++++ b/dist/adaptors/pg.js +@@ -26,6 +26,15 @@ const cacheSizeFromEnv = process.env.DATAPLAN_PG_PREPARED_STATEMENT_CACHE_SIZE + */ + const PREPARED_STATEMENT_CACHE_SIZE = !!cacheSizeFromEnv || cacheSizeFromEnv === 0 ? cacheSizeFromEnv : 100; + const $$isSetup = Symbol("isConfiguredForDataplanPg"); ++const REUSABLE_CLIENT_RELEASE_MODES = Object.freeze(["reuse"]); ++const DESTROYABLE_CLIENT_RELEASE_MODES = Object.freeze(["reuse", "destroy"]); ++function getClientReleaseMode(options) { ++ const mode = options?.clientReleaseMode ?? "reuse"; ++ if (mode !== "reuse" && mode !== "destroy") { ++ throw new Error(`Unsupported PostgreSQL client release mode '${mode}'`); ++ } ++ return mode; ++} + /** + * \> JIT compilation is beneficial primarily for long-running CPU-bound + * \> queries. Frequently these will be analytical queries. For short +@@ -253,24 +262,37 @@ async function makeNodePostgresWithPgClient_inner(pgClient, pgSettings, callback + * Returns a `withPgClient` for the given `Pool` instance. + */ + function makePgAdaptorWithPgClient(pool, release = () => { }) { +- const withPgClient = async (pgSettings, callback) => { ++ const withPgClient = async (pgSettings, callback, options) => { ++ const clientReleaseMode = getClientReleaseMode(options); ++ const supportsExactClientDestruction = typeof PgPool === "function" && pool instanceof PgPool; ++ if (clientReleaseMode === "destroy" && !supportsExactClientDestruction) { ++ throw new Error("Exact PostgreSQL client destruction requires a node-postgres Pool"); ++ } + const pgClient = await pool.connect(); +- if (!pgClient[$$isSetup]) { +- pgClient[$$isSetup] = true; +- if (!DONT_DISABLE_JIT) { +- // We don't actually disable JIT, it's the optimization that's expensive so we disable that. +- pgClient.query("set jit_optimize_above_cost = -1;").catch((e) => { +- console.error(`Error occurred applying @dataplan/pg global Postgres settings: ${e}`); +- }); ++ try { ++ if (!pgClient[$$isSetup]) { ++ pgClient[$$isSetup] = true; ++ if (!DONT_DISABLE_JIT) { ++ // We don't actually disable JIT, it's the optimization that's expensive so we disable that. ++ pgClient.query("set jit_optimize_above_cost = -1;").catch((e) => { ++ console.error(`Error occurred applying @dataplan/pg global Postgres settings: ${e}`); ++ }); ++ } + } +- } +- try { + return await makeNodePostgresWithPgClient_inner(pgClient, pgSettings, callback, false, false); + } + finally { + // NOTE: have decided not to `RESET ALL` here; otherwise timezone,jit,etc will reset +- pgClient.release(); ++ if (clientReleaseMode === "destroy") { ++ pgClient.release(true); ++ } ++ else { ++ pgClient.release(); ++ } + } + }; ++ withPgClient.supportedClientReleaseModes = typeof PgPool === "function" && pool instanceof PgPool ++ ? DESTROYABLE_CLIENT_RELEASE_MODES ++ : REUSABLE_CLIENT_RELEASE_MODES; + let released = false; + const releaseOnce = () => { +@@ -292,11 +314,16 @@ function makePgAdaptorWithPgClient(pool, release = () => { }) { + */ + function makeWithPgClientViaPgClientAlreadyInTransaction(pgClient, alreadyInTransaction = false) { + const release = () => { }; +- const withPgClient = async (pgSettings, callback) => { ++ const withPgClient = async (pgSettings, callback, options) => { ++ const clientReleaseMode = getClientReleaseMode(options); ++ if (clientReleaseMode !== "reuse") { ++ throw new Error("Cannot destroy a caller-owned PostgreSQL client"); ++ } + return makeNodePostgresWithPgClient_inner(pgClient, pgSettings, callback, + // Ensure only one withPgClient can run at a time, since we only have on pgClient. + true, alreadyInTransaction); + }; ++ withPgClient.supportedClientReleaseModes = REUSABLE_CLIENT_RELEASE_MODES; + let released = false; + const releaseOnce = () => { + if (released) { +diff --git a/dist/executor.d.ts b/dist/executor.d.ts +--- a/dist/executor.d.ts ++++ b/dist/executor.d.ts +@@ -62,8 +62,13 @@ export interface PgClient { + query(opts: PgClientQuery): Promise>; + withTransaction(callback: (client: this) => Promise): Promise; + } ++export type PgClientReleaseMode = "reuse" | "destroy"; ++export interface WithPgClientUseOptions { ++ clientReleaseMode?: PgClientReleaseMode; ++} + export interface WithPgClient { +- (pgSettings: Record | null, callback: (client: TPgClient) => T | Promise): Promise; ++ (pgSettings: Record | null, callback: (client: TPgClient) => T | Promise, options?: WithPgClientUseOptions): Promise; ++ supportedClientReleaseModes?: readonly PgClientReleaseMode[]; + release?(): PromiseOrDirect; + } + export type PgExecutorContext = { +diff --git a/dist/index.js b/dist/index.js +--- a/dist/index.js ++++ b/dist/index.js +@@ -1,2 +1,3 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); ++exports.exactClientReleaseCapability = "dataplan-pg-exact-client-destroy-v1"; +diff --git a/dist/index.d.ts b/dist/index.d.ts +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -1,3 +1,4 @@ + import type { GrafastSubscriber } from "grafast"; ++export declare const exactClientReleaseCapability: "dataplan-pg-exact-client-destroy-v1"; + export { sql } from "pg-sql2"; + import type { ObjectFromPgCodecAttributes, PgCodecAttribute, PgCodecAttributeExtensions, PgCodecAttributes, PgCodecAttributeVia, PgCodecAttributeViaExplicit, PgEnumCodecSpec, PgRecordTypeCodecSpec } from "./codecs.ts"; +@@ -39,6 +39,7 @@ import type { SideEffectWithPgClientStepCallback } from "./steps/withPgClient.ts + import { loadManyWithPgClient, loadOneWithPgClient, sideEffectWithPgClient, SideEffectWithPgClientStep, sideEffectWithPgClientTransaction, withPgClient, withPgClientTransaction } from "./steps/withPgClient.ts"; + import { assertPgClassSingleStep } from "./utils.ts"; + export type { GetPgCodecAttributes, GetPgRegistryCodecRelations, GetPgRegistryCodecs, GetPgRegistrySources, GetPgResourceAttributes, GetPgResourceCodec, GetPgResourceRegistry, GetPgResourceRelations, GetPgResourceUniques, KeysOfType, MakePgServiceOptions, ObjectFromPgCodecAttributes, PgAdaptor, PgBox, PgCircle, PgClassSingleStep, PgClient, PgClientQuery, PgClientResult, PgCodec, PgCodecAnyScalar, PgCodecAttribute, PgCodecAttributeExtensions, PgCodecAttributes, PgCodecAttributeVia, PgCodecAttributeViaExplicit, PgCodecExtensions, PgCodecList, PgCodecPolymorphism, PgCodecPolymorphismRelational, PgCodecPolymorphismRelationalTypeSpec, PgCodecPolymorphismSingle, PgCodecPolymorphismSingleTypeAttributeSpec, PgCodecPolymorphismSingleTypeSpec, PgCodecPolymorphismUnion, PgCodecRef, PgCodecRefExtensions, PgCodecRefPath, PgCodecRefPathEntry, PgCodecRefs, PgCodecRelation, PgCodecRelationConfig, PgCodecRelationExtensions, PgCodecWithAttributes, PgConditionCapableParent, PgConditionLike, PgDecode, PgDeleteSingleQueryBuilder, PgEncode, PgEnumCodec, PgEnumCodecSpec, PgEnumValue, PgExecutorContext, PgExecutorContextPlans, PgExecutorInput, PgExecutorMutationOptions, PgExecutorOptions, PgFunctionResourceOptions, PgGroupDetails, PgGroupSpec, PgHavingConditionSpec, PgHStore, PgInsertSingleQueryBuilder, PgInterval, PgLine, PgLockableParameter, PgLockCallback, PgLseg, PgOrderSpec, PgPath, PgPoint, PgPolygon, PgRecordTypeCodecSpec, PgRefDefinition, PgRefDefinitionExtensions, PgRefDefinitions, PgRegistry, PgRegistryBuilder, PgResourceExtensions, PgResourceOptions, PgResourceParameter, PgResourceUnique, PgResourceUniqueExtensions, PgRootStep, PgSelectArgumentDigest, PgSelectArgumentRuntimeValue, PgSelectArgumentSpec, PgSelectIdentifierSpec, PgSelectMode, PgSelectOptions, PgSelectParsedCursorStep, PgSelectQueryBuilder, PgSelectQueryBuilderCallback, PgSelectSinglePlanOptions, PgTypedStep, PgUnionAllQueryBuilder, PgUnionAllQueryBuilderCallback, PgUnionAllStepCondition, PgUnionAllStepConfig, PgUnionAllStepConfigAttributes, PgUnionAllStepMember, PgUnionAllStepOrder, PgUpdateSingleQueryBuilder, PgWhereConditionSpec, PlanByUniques, SideEffectWithPgClientStepCallback, TuplePlanMap, WithPgClient, }; ++export type { PgClientReleaseMode, WithPgClientUseOptions } from "./executor.ts"; + export { assertPgClassSingleStep, domainOfCodec, enumCodec, generatePgParameterAnalysis, getCodecByPgCatalogTypeName, getInnerCodec, getWithPgClientFromPgService, isEnumCodec, LIST_TYPES, listOfCodec, loadManyWithPgClient, loadOneWithPgClient, pgResourceOptions as makePgResourceOptions, makeRegistry, makeRegistryBuilder, PgBooleanFilter, pgClassExpression, PgClassExpressionStep, PgClassFilter, PgCondition, PgContextPlugin, PgCursorStep, pgDeleteSingle, PgDeleteSingleStep, PgExecutor, pgFromExpression, pgFromExpressionRuntime, pgInsertSingle, PgInsertSingleStep, PgManyFilter, PgOrFilter, PgResource, pgResourceOptions, pgSelect, pgSelectFromRecord, pgSelectFromRecords, PgSelectRowsStep, pgSelectSingleFromRecord, PgSelectSingleStep, PgSelectStep, PgTempTable, pgUnionAll, PgUnionAllRowsStep, PgUnionAllSingleStep, PgUnionAllStep, pgUpdateSingle, PgUpdateSingleStep, pgValidateParsedCursor, PgValidateParsedCursorStep, pgWhereConditionSpecListToSQL, rangeOfCodec, recordCodec, sideEffectWithPgClient, SideEffectWithPgClientStep, sideEffectWithPgClientTransaction, sqlFromArgDigests, sqlValueWithCodec, toPg, ToPgStep, TYPES, withPgClient, withPgClientFromPgService, withPgClientTransaction, withSuperuserPgClientFromPgService, }; + export { version } from "./version.ts"; + declare global { +diff --git a/dist/pgServices.d.ts b/dist/pgServices.d.ts +--- a/dist/pgServices.d.ts ++++ b/dist/pgServices.d.ts +@@ -1,4 +1,4 @@ +-import type { PgClient, WithPgClient } from "./executor.ts"; ++import type { PgClient, WithPgClient, WithPgClientUseOptions } from "./executor.ts"; + type PromiseOrDirect = T | PromiseLike; + /** @experimental */ + export interface PgAdaptor { +@@ -14,6 +14,6 @@ export declare function isPromiseLike(t: T | Promise | PromiseLike): t + * config, caching it to make future lookups faster. + */ + export declare function getWithPgClientFromPgService(config: GraphileConfig.PgServiceConfiguration): PromiseOrDirect>; +-export declare function withPgClientFromPgService(config: GraphileConfig.PgServiceConfiguration, pgSettings: Record | null, callback: (client: PgClient) => T | Promise): Promise; ++export declare function withPgClientFromPgService(config: GraphileConfig.PgServiceConfiguration, pgSettings: Record | null, callback: (client: PgClient) => T | Promise, options?: WithPgClientUseOptions): Promise; + export declare function withSuperuserPgClientFromPgService(config: GraphileConfig.PgServiceConfiguration, pgSettings: Record | null, callback: (client: PgClient) => T | Promise): Promise; + export {}; +diff --git a/dist/pgServices.js b/dist/pgServices.js +--- a/dist/pgServices.js ++++ b/dist/pgServices.js +@@ -38,6 +38,7 @@ function getWithPgClientFromPgService(config) { + } + const originalWithPgClient = await factory(config.adaptorSettings); + const withPgClient = ((...args) => originalWithPgClient.apply(null, args)); ++ withPgClient.supportedClientReleaseModes = originalWithPgClient.supportedClientReleaseModes; + const cachedValue = { + withPgClient, + retainers: 1, +@@ -70,13 +71,21 @@ function getWithPgClientFromPgService(config) { + return promise.then((v) => v.withPgClient); + } + } +-async function withPgClientFromPgService(config, pgSettings, callback) { ++async function withPgClientFromPgService(config, pgSettings, callback, options) { + const withPgClientFromPgService = getWithPgClientFromPgService(config); + const withPgClient = isPromiseLike(withPgClientFromPgService) + ? await withPgClientFromPgService + : withPgClientFromPgService; + try { +- return await withPgClient(pgSettings, callback); ++ const clientReleaseMode = options?.clientReleaseMode ?? "reuse"; ++ if (clientReleaseMode !== "reuse" && clientReleaseMode !== "destroy") { ++ throw new Error(`Unsupported PostgreSQL client release mode '${clientReleaseMode}' for service '${config.name}'`); ++ } ++ if (clientReleaseMode === "destroy" && ++ !withPgClient.supportedClientReleaseModes?.includes("destroy")) { ++ throw new Error(`PostgreSQL service '${config.name}' does not support exact client destruction`); ++ } ++ return await withPgClient(pgSettings, callback, options); + } + finally { + withPgClient.release(); diff --git a/patches/@graphile-contrib__pg-many-to-many@2.0.0-rc.2.patch b/patches/@graphile-contrib__pg-many-to-many@2.0.0-rc.2.patch new file mode 100644 index 0000000000..95d546b2be --- /dev/null +++ b/patches/@graphile-contrib__pg-many-to-many@2.0.0-rc.2.patch @@ -0,0 +1,15 @@ +diff --git a/dist/PgManyToManyRelationPlugin.js b/dist/PgManyToManyRelationPlugin.js +index 983bc745b4707efb8e3ade5b25b88b9e2cd114e7..d362f41b307e68b28e0737559cd6c40bb95e3109 100644 +--- a/dist/PgManyToManyRelationPlugin.js ++++ b/dist/PgManyToManyRelationPlugin.js +@@ -265,7 +265,9 @@ exports.PgManyToManyRelationPlugin = { + }, + hooks: { + build(build) { +- build.pgManyToManyRealtionshipsByResource = new Map(); ++ const relationshipsByResource = new Map(); ++ build.pgManyToManyRealtionshipsByResource = relationshipsByResource; ++ build.registerBuildStateDisposer(() => relationshipsByResource.clear()); + return build; + }, + init(_, build, _context) { diff --git a/patches/graphile-build-pg.patch b/patches/graphile-build-pg.patch new file mode 100644 index 0000000000..3e50c9fae0 --- /dev/null +++ b/patches/graphile-build-pg.patch @@ -0,0 +1,402 @@ +diff --git a/dist/index.js b/dist/index.js +--- a/dist/index.js ++++ b/dist/index.js +@@ -1,2 +1,3 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); ++exports.introspectionClientReleaseCapability = "graphile-build-pg-exact-client-destroy-v1"; +diff --git a/dist/index.d.ts b/dist/index.d.ts +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -1,2 +1,3 @@ + import type { PgRegistry } from "@dataplan/pg"; + import type { PartitionExpose } from "./interfaces.ts"; ++export declare const introspectionClientReleaseCapability: "graphile-build-pg-exact-client-destroy-v1"; +diff --git a/dist/plugins/PgBasicsPlugin.js b/dist/plugins/PgBasicsPlugin.js +index 97d175d650d0a2cec51932a3da004563c6815500..3766b3f998f2f128e7669765aac309dff6d09f6d 100644 +--- a/dist/plugins/PgBasicsPlugin.js ++++ b/dist/plugins/PgBasicsPlugin.js +@@ -220,6 +220,7 @@ exports.PgBasicsPlugin = { + build.pgCodecs = pgRegistry.pgCodecs; + build.pgRelations = pgRegistry.pgRelations; + const pgCodecMetaLookup = (0, inputUtils_ts_1.getCodecMetaLookupFromInput)(build.input); ++ build.registerBuildStateDisposer(() => pgCodecMetaLookup.clear()); + const getGraphQLTypeNameByPgCodec = (codec, situation) => { + if (codec.arrayOfCodec) { + throw new Error("Do not use getGraphQLTypeNameByPgCodec with an array type, find the underlying type instead"); +diff --git a/dist/plugins/PgCodecsPlugin.js b/dist/plugins/PgCodecsPlugin.js +index fe7110a577eae139451d2b431b9f9a9d4e2a5337..eed17c45df4a188c5b23ffc68dd626e096f0fe96 100644 +--- a/dist/plugins/PgCodecsPlugin.js ++++ b/dist/plugins/PgCodecsPlugin.js +@@ -421,6 +421,135 @@ exports.PgCodecsPlugin = { + event.pgCodec = (0, graphile_build_1.EXPORTABLE)((codecName, innerCodec, rangeOfCodec, spec, sqlIdent) => rangeOfCodec(innerCodec, codecName, sqlIdent, spec), [codecName, innerCodec, pg_1.rangeOfCodec, spec, sqlIdent], `${codecName}Codec`); + return; + } ++ // Multirange types are represented in GraphQL as lists of the ++ // corresponding range type. PostgreSQL's multirange text format ++ // is deliberately not treated as an array: its range entries are ++ // not quoted, so commas inside a range are not array delimiters. ++ if (type.typtype === "m") { ++ const range = await info.helpers.pgIntrospection.getRangeByType(serviceName, type._id); ++ if (!range?.rngtypid) { ++ throw new Error(`Failed to get range entry related to multirange '${type._id}'`); ++ } ++ const rangeCodec = (await info.helpers.pgCodecs.getCodecFromType(serviceName, range.rngtypid)); ++ if (!rangeCodec?.rangeOfCodec) { ++ throw new Error(`Failed to build the range codec related to multirange '${type._id}'`); ++ } ++ const typeName = type.typname; ++ const codecName = info.inflection.typeCodecName({ ++ pgType: type, ++ serviceName, ++ }); ++ const { tags, description } = type.getTagsAndDescription(); ++ const extensions = { ++ oid: type._id, ++ pg: { ++ serviceName, ++ schemaName: namespaceName, ++ name: typeName, ++ }, ++ listItemNonNull: true, ++ ...(Object.keys(tags).length > 0 ? { tags } : null), ++ }; ++ await info.process("pgCodecs_listOfCodec_extensions", { ++ serviceName, ++ pgType: type, ++ innerCodec: rangeCodec, ++ extensions, ++ }); ++ // A PostgreSQL multirange cannot contain null ranges. ++ extensions.listItemNonNull = true; ++ const sqlIdent = info.helpers.pgBasics.identifier(namespaceName, typeName); ++ (0, utils_ts_1.exportNameHint)(sqlIdent, `${codecName}Identifier`); ++ const spec = { ++ name: codecName, ++ identifier: sqlIdent, ++ extensions, ++ ...(description ? { description } : null), ++ }; ++ event.pgCodec = (0, graphile_build_1.EXPORTABLE)((listOfCodec, rangeCodec, spec) => { ++ const listCodec = listOfCodec(rangeCodec, spec); ++ const parseCastedList = listCodec.fromPg; ++ const hasCastedRepresentation = typeof listCodec.castFromPg === "function"; ++ return { ++ ...listCodec, ++ fromPg(value) { ++ if (hasCastedRepresentation) { ++ return parseCastedList(value); ++ } ++ if (typeof value !== "string" || ++ value.length < 2 || ++ value[0] !== "{" || ++ value[value.length - 1] !== "}") { ++ throw new Error(`Invalid PostgreSQL multirange value '${String(value)}'`); ++ } ++ const body = value.slice(1, -1); ++ if (body.length === 0) { ++ return []; ++ } ++ const encodedRanges = []; ++ let start = 0; ++ let depth = 0; ++ let quoted = false; ++ let escaped = false; ++ for (let index = 0; index < body.length; index++) { ++ const character = body[index]; ++ if (quoted) { ++ if (escaped) { ++ escaped = false; ++ } ++ else if (character === "\\") { ++ escaped = true; ++ } ++ else if (character === '"') { ++ quoted = false; ++ } ++ } ++ else if (character === '"') { ++ quoted = true; ++ } ++ else if (character === "[" || character === "(") { ++ depth++; ++ } ++ else if (character === "]" || character === ")") { ++ depth--; ++ if (depth < 0) { ++ throw new Error(`Invalid PostgreSQL multirange value '${value}'`); ++ } ++ } ++ else if (character === "," && depth === 0) { ++ const encodedRange = body.slice(start, index).trim(); ++ if (!encodedRange) { ++ throw new Error(`Invalid PostgreSQL multirange value '${value}'`); ++ } ++ encodedRanges.push(encodedRange); ++ start = index + 1; ++ } ++ } ++ if (quoted || escaped || depth !== 0) { ++ throw new Error(`Invalid PostgreSQL multirange value '${value}'`); ++ } ++ const encodedRange = body.slice(start).trim(); ++ if (!encodedRange) { ++ throw new Error(`Invalid PostgreSQL multirange value '${value}'`); ++ } ++ encodedRanges.push(encodedRange); ++ return encodedRanges.map((entry) => rangeCodec.fromPg(entry)); ++ }, ++ toPg(value) { ++ if (!Array.isArray(value)) { ++ throw new Error("PostgreSQL multirange input must be a list of ranges"); ++ } ++ return `{${value.map((entry) => { ++ if (entry == null) { ++ throw new Error("PostgreSQL multirange input cannot contain a null range"); ++ } ++ return rangeCodec.toPg(entry); ++ }).join(",")}}`; ++ }, ++ }; ++ }, [pg_1.listOfCodec, rangeCodec, spec], `${codecName}Codec`); ++ return; ++ } + // Domains are wrappers under an underlying type + if (type.typtype === "d") { + const { typnotnull: notNull, typbasetype: baseTypeOid, typtypmod: baseTypeModifier, typndims: _numberOfArrayDimensions, typcollation: _domainCollation, typdefaultbin: _defaultValueNodeTree, } = type; +@@ -533,7 +662,9 @@ exports.PgCodecsPlugin = { + schema: { + hooks: { + build(build) { +- build.allPgCodecs = new Set(); ++ const allPgCodecs = new Set(); ++ build.allPgCodecs = allPgCodecs; ++ build.registerBuildStateDisposer(() => allPgCodecs.clear()); + function walkCodec(codec) { + if (build.allPgCodecs.has(codec)) { + return; +diff --git a/dist/plugins/PgIntrospectionPlugin.d.ts b/dist/plugins/PgIntrospectionPlugin.d.ts +index 821629b96574c90ff58804be3aa3b98c4443c7a0..3c85017d8a765c4368febfd766e5082b0609b5a1 100644 +--- a/dist/plugins/PgIntrospectionPlugin.d.ts ++++ b/dist/plugins/PgIntrospectionPlugin.d.ts +@@ -1,6 +1,22 @@ + import { PgExecutor } from "@dataplan/pg"; + import type { PromiseOrDirect } from "grafast"; + import type { Introspection, PgAttribute, PgAuthMembers, PgClass, PgConstraint, PgDepend, PgDescription, PgEnum, PgExtension, PgIndex, PgInherits, PgLanguage, PgNamespace, PgProc, PgRange, PgRoles, PgType } from "pg-introspection"; ++declare global { ++ namespace GraphileConfig { ++ interface PgServiceConfiguration { ++ /** Selects the catalog query used during this service's gather phase. */ ++ introspectionMode?: "stock" | "scoped-required"; ++ /** Catalog types retained by scoped introspection; defaults to all for compatibility. */ ++ introspectionScopedCatalogTypes?: "all" | "dependency-closure"; ++ /** Non-root schemas that scoped dependency closure may retain. */ ++ introspectionAllowedDependencySchemas?: readonly string[]; ++ /** Exact installed extension names whose optional capability metadata is required. */ ++ introspectionCapabilityExtensions?: readonly string[]; ++ /** How to release the exact client after its catalog query. */ ++ introspectionClientReleaseMode?: "reuse" | "destroy"; ++ } ++ } ++} + export type PgEntityWithId = PgNamespace | PgClass | PgConstraint | PgProc | PgRoles | PgType | PgEnum | PgExtension | PgExtension | PgIndex | PgLanguage; + declare global { + namespace GraphileBuild { +diff --git a/dist/plugins/PgIntrospectionPlugin.js b/dist/plugins/PgIntrospectionPlugin.js +index a1597b1dc9a4f76d3debc80f5ef151f4e58bc2cd..e97d2aecfdbe6e94eeb2182b3f0e3f94f9a57cab 100644 +--- a/dist/plugins/PgIntrospectionPlugin.js ++++ b/dist/plugins/PgIntrospectionPlugin.js +@@ -42,6 +42,118 @@ function makeGetEntities(loc) { + return list; + }; + } ++function getIntrospectionQuery(pgService) { ++ 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: (0, pg_introspection_1.makeIntrospectionQuery)() }, ++ requiredSchemas: null, ++ allowedSchemas: null, ++ scopedCatalogTypes: null, ++ }; ++ } ++ if (mode === "scoped-required") { ++ const requiredSchemas = pgService.schemas ?? []; ++ const dependencySchemas = pgService.introspectionAllowedDependencySchemas ?? []; ++ const capabilityExtensions = configuredCapabilityExtensions ?? []; ++ return { ++ query: (0, pg_introspection_1.makeSchemaScopedIntrospectionQuery)(requiredSchemas, { ++ catalogTypes: scopedCatalogTypes, ++ capabilityExtensions, ++ }), ++ requiredSchemas, ++ allowedSchemas: [...new Set([...requiredSchemas, ...dependencySchemas, "pg_catalog"])], ++ scopedCatalogTypes, ++ }; ++ } ++ throw new Error(`Unsupported PostgreSQL introspection mode '${mode}' for service '${pgService.name}'`); ++} ++function assertScopedNamespaces(introspection, requiredSchemas, allowedSchemas, serviceName) { ++ if (requiredSchemas === null) { ++ return; ++ } ++ const found = new Set(introspection.namespaces.map((namespace) => namespace.nspname)); ++ const missing = requiredSchemas.filter((schema) => !found.has(schema)); ++ if (missing.length > 0) { ++ throw new Error(`Schema-scoped introspection for service '${serviceName}' did not find required schema(s): ${missing.join(", ")}`); ++ } ++ const allowed = new Set(allowedSchemas); ++ const unexpected = [...found].filter((schema) => !allowed.has(schema)); ++ if (unexpected.length > 0) { ++ throw new Error(`Schema-scoped introspection for service '${serviceName}' crossed into unapproved dependency schema(s): ${unexpected.join(", ")}`); ++ } ++} ++function assertDependencyClosureTypes(introspection, scopedCatalogTypes, serviceName) { ++ if (scopedCatalogTypes !== "dependency-closure") { ++ return; ++ } ++ const retainedTypeOids = new Set(introspection.types.map((type) => String(type._id))); ++ const requireType = (oid, objectKind, objectContext, field) => { ++ if (oid === null || oid === undefined || String(oid) === "0") { ++ return; ++ } ++ const normalizedOid = String(oid); ++ // pg-introspection deliberately removes extension-owned composite ++ // resources from the public arrays after constructing its lookup maps. ++ // Array and user-object dependencies still resolve through that exact ++ // lookup, so validate the same runtime resolution surface instead of ++ // rejecting a coherent extension-pruned result. ++ const resolvesThroughIntrospection = retainedTypeOids.has(normalizedOid) || ++ introspection._lookups?.typeById?.has(normalizedOid) === true; ++ if (!resolvesThroughIntrospection) { ++ throw new Error(`Dependency-closure introspection for service '${serviceName}' retained ${objectKind} '${objectContext}' field '${field}' referencing missing pg_type OID '${normalizedOid}'`); ++ } ++ }; ++ const requireTypes = (oids, objectKind, objectContext, field) => { ++ 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"); ++ } ++} + exports.PgIntrospectionPlugin = { + name: "PgIntrospectionPlugin", + description: "Introspects PostgreSQL databases and makes the results available to other plugins", +@@ -171,8 +283,8 @@ exports.PgIntrospectionPlugin = { + return type?.getEnumValues() ?? []; + }, + async getRangeByType(info, serviceName, typeId) { +- const type = await info.helpers.pgIntrospection.getType(serviceName, typeId); +- return type?.getRange(); ++ const relevant = await getDb(info, serviceName); ++ return relevant.introspection.ranges.find((range) => range.rngtypid === typeId || range.rngmultitypid === typeId); + }, + async getExtensionByName(info, serviceName, extensionName) { + const relevant = await getDb(info, serviceName); +@@ -198,11 +310,20 @@ exports.PgIntrospectionPlugin = { + info.cache.introspectionResultsPromise = null; + }); + const rawIntrospections = await introspectionPromise; +- const introspections = rawIntrospections.map(({ pgService, introspectionText }) => ({ +- pgService, ++ // Scoped mode keeps raw text only in this gather's async frame. ++ // Clear only the exact promise we consumed so every later gather ++ // re-queries, while concurrent helpers still share parsed state. ++ if (rawIntrospections.some(({ requiredSchemas }) => requiredSchemas !== null) && ++ info.cache.introspectionResultsPromise === introspectionPromise) { ++ info.cache.introspectionResultsPromise = null; ++ } ++ const introspections = rawIntrospections.map(({ pgService, introspectionText, requiredSchemas, allowedSchemas, scopedCatalogTypes }) => { + // IMPORTANT: parseIntrospectionResults must NOT be cached, because other plugins mutate it. +- introspection: (0, pg_introspection_1.parseIntrospectionResults)(introspectionText), +- })); ++ const introspection = (0, pg_introspection_1.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 + info.state.getIntrospectionPromise = introspections; + // Announce the introspection results. +@@ -416,13 +537,15 @@ function introspectPgServices(pgServices) { + seenPgSettingsKeys.set(pgSettingsKey, i); + } + // Do the introspection +- const introspectionQuery = (0, pg_introspection_1.makeIntrospectionQuery)(); ++ const { query, requiredSchemas, allowedSchemas, scopedCatalogTypes } = getIntrospectionQuery(pgService); ++ const clientReleaseMode = pgService.introspectionClientReleaseMode ?? "reuse"; + const { rows: [row], } = await (0, pg_1.withPgClientFromPgService)(pgService, pgService.pgSettingsForIntrospection ?? null, (client) => client.query({ +- text: introspectionQuery, ++ text: query.text, ++ values: query.values, +- })); ++ }), clientReleaseMode === "reuse" ? undefined : { clientReleaseMode }); + if (!row) { + throw new Error("Introspection failed"); + } +- return { pgService, introspectionText: row.introspection }; ++ return { pgService, introspectionText: row.introspection, requiredSchemas, allowedSchemas, scopedCatalogTypes }; + })); + } +diff --git a/dist/plugins/PgPolymorphismPlugin.js b/dist/plugins/PgPolymorphismPlugin.js +index b1892244aebf936cb697d9e664e6b3c2432e8c0e..6907c0d70640bebd87c93377c3e30f901b18efa9 100644 +--- a/dist/plugins/PgPolymorphismPlugin.js ++++ b/dist/plugins/PgPolymorphismPlugin.js +@@ -554,6 +554,14 @@ exports.PgPolymorphismPlugin = { + } + } + const pgCodecByPolymorphicUnionModeTypeName = Object.create(null); ++ build.registerBuildStateDisposer(() => { ++ for (const key of Object.keys(pgResourcesByPolymorphicTypeName)) { ++ delete pgResourcesByPolymorphicTypeName[key]; ++ } ++ for (const key of Object.keys(pgCodecByPolymorphicUnionModeTypeName)) { ++ delete pgCodecByPolymorphicUnionModeTypeName[key]; ++ } ++ }); + for (const codec of Object.values(pgRegistry.pgCodecs)) { + if (!codec.polymorphism) + continue; diff --git a/patches/graphile-build@5.0.2.patch b/patches/graphile-build@5.0.2.patch new file mode 100644 index 0000000000..de2bf336e9 --- /dev/null +++ b/patches/graphile-build@5.0.2.patch @@ -0,0 +1,423 @@ +diff --git a/dist/SchemaBuilder.js b/dist/SchemaBuilder.js +index 551b884b615a094c50dec2a2c4acec5a4b303bcf..2b199700aa366e5f36c2bdc33ce8dbfc1e09998b 100644 +--- a/dist/SchemaBuilder.js ++++ b/dist/SchemaBuilder.js +@@ -182,6 +182,9 @@ class SchemaBuilder extends events_1.EventEmitter { + if (validationErrors.length) { + throw new AggregateError(validationErrors, `Schema construction failed due to ${validationErrors.length} validation failure(s). First failure was: ${String(validationErrors[0])}`); + } ++ if (this.options.releaseBuildStateAfterValidation === true) { ++ (0, makeNewBuild_ts_1.releaseBuildState)(build); ++ } + return schema; + } + } +diff --git a/dist/behavior.d.ts b/dist/behavior.d.ts +index ad1c639575544039b66298af8f63f9202a5d775d..54a8862e576da6046fe84583ef9e946f3644e3d1 100644 +--- a/dist/behavior.d.ts ++++ b/dist/behavior.d.ts +@@ -9,6 +9,7 @@ export type BehaviorDynamicMethods = { + [entityType in keyof GraphileBuild.BehaviorEntities as `${entityType}Behavior`]: (entity: GraphileBuild.BehaviorEntities[entityType]) => string; + }; + export declare class Behavior { ++ private _retirementState; + private behaviorEntities; + private behaviorRegistry; + behaviorEntityTypes: (keyof GraphileBuild.BehaviorEntities)[]; +@@ -55,6 +56,9 @@ export declare class Behavior { + private validateBehavior; + _defaultBehaviorByEntityTypeCache: Map<"string", string>; + getDefaultBehaviorFor(entityType: keyof GraphileBuild.BehaviorEntities): string; ++ /** @internal */ ++ releaseBuildState(): boolean; ++ private assertBuildStateLive; + } + export declare function joinBehaviors(strings: ReadonlyArray): GraphileBuild.BehaviorString; + export declare function joinResolvedBehaviors(behaviors: ReadonlyArray): ResolvedBehavior; +diff --git a/dist/behavior.js b/dist/behavior.js +index 80d7ef5543ac45e3922ff9d16413d3372e4d0f1d..42e7f46ae9057924ebb9e42619ee7c97913c4ebd 100644 +--- a/dist/behavior.js ++++ b/dist/behavior.js +@@ -10,6 +10,12 @@ const NULL_BEHAVIOR = Object.freeze({ + behaviorString: "", + stack: Object.freeze([]), + }); ++const BUILD_STATE_RELEASED_ERROR_CODE = "GRAPHILE_BUILD_STATE_RELEASED"; ++function buildStateReleasedError(api) { ++ const error = new Error(`Cannot use ${api} after Graphile build state has been released`); ++ error.code = BUILD_STATE_RELEASED_ERROR_CODE; ++ return error; ++} + const getEntityBehaviorHooks = (plugin, type) => { + const val = plugin.schema?.entityBehavior; + if (!val) +@@ -87,6 +93,7 @@ const getEntityBehaviorOverrideHooks = (plugin) => { + }; + class Behavior { + constructor(resolvedPreset, build) { ++ this._retirementState = { released: false }; + this.behaviorEntityTypes = []; + this._defaultBehaviorByEntityTypeCache = new Map(); + this.resolvedPreset = resolvedPreset; +@@ -202,6 +209,7 @@ class Behavior { + this[`${entityType}Behavior`] = (entity) => this.getBehaviorForEntity(entityType, entity).behaviorString; + } + assertEntity(entityType) { ++ this.assertBuildStateLive("build.behavior"); + if (entityType === "string") { + throw new Error(`Runtime behaviors cannot be attached to strings, please use 'behavior.stringMatches' directly.`); + } +@@ -216,6 +224,7 @@ class Behavior { + * @param filter - the behavior the plugin specifies + */ + entityMatches(entityType, entity, filter) { ++ this.assertBuildStateLive("build.behavior.entityMatches"); + if (!this.behaviorRegistry[filter]) { + // DIAGNOSTIC: enable for all filters + /* +@@ -317,6 +326,7 @@ class Behavior { + return behavior; + } + getPreferencesAppliedBehaviors(entityType, inferredBehavior, overrideBehavior) { ++ this.assertBuildStateLive("build.behavior.getPreferencesAppliedBehaviors"); + const defaultBehavior = this.getDefaultBehaviorFor(entityType); + const inferredBehaviorWithPreferencesApplied = multiplyBehavior(defaultBehavior, inferredBehavior.behaviorString, entityType); + const behaviorString = joinBehaviors([ +@@ -342,6 +352,7 @@ class Behavior { + } + /** @deprecated Please use entityMatches or stringMatches instead */ + matches(localBehaviorSpecsString, filter, defaultBehavior = "") { ++ this.assertBuildStateLive("build.behavior.matches"); + let err; + try { + throw new Error("Deprecated call happened here"); +@@ -369,6 +380,7 @@ class Behavior { + resolveBehavior(entityType, initialBehavior, + // Misnomer; also allows strings or nothings + callbacks, ...args) { ++ this.assertBuildStateLive("build.behavior.resolveBehavior"); + let behaviorString = initialBehavior.behaviorString; + const stack = [...initialBehavior.stack]; + for (const [source, rawG] of callbacks) { +@@ -425,6 +437,7 @@ class Behavior { + } + } + getDefaultBehaviorFor(entityType) { ++ this.assertBuildStateLive("build.behavior.getDefaultBehaviorFor"); + if (!this._defaultBehaviorByEntityTypeCache.has(entityType)) { + const supportedBehaviors = new Set(); + for (const [behaviorString, spec] of Object.entries(this.behaviorRegistry)) { +@@ -455,6 +468,38 @@ class Behavior { + } + return this._defaultBehaviorByEntityTypeCache.get(entityType); + } ++ assertBuildStateLive(api) { ++ if (this._retirementState.released) { ++ throw buildStateReleasedError(api); ++ } ++ } ++ releaseBuildState() { ++ if (this._retirementState.released) { ++ return false; ++ } ++ this._retirementState.released = true; ++ this._defaultBehaviorByEntityTypeCache.clear(); ++ this.behaviorEntityTypes.length = 0; ++ for (const key of Object.keys(this.behaviorEntities)) { ++ const behaviorEntity = this.behaviorEntities[key]; ++ behaviorEntity.listCache.clear(); ++ behaviorEntity.inferredCache.clear(); ++ behaviorEntity.overrideCache.clear(); ++ behaviorEntity.fullCache.clear(); ++ behaviorEntity.inferredBehaviorCallbacks.length = 0; ++ behaviorEntity.overrideBehaviorCallbacks.length = 0; ++ for (const behaviorString of Object.keys(behaviorEntity.behaviorStrings)) { ++ delete behaviorEntity.behaviorStrings[behaviorString]; ++ } ++ } ++ for (const key of Object.keys(this.behaviorRegistry)) { ++ delete this.behaviorRegistry[key]; ++ } ++ if (Array.isArray(this.globalDefaultBehavior.stack)) { ++ this.globalDefaultBehavior.stack.length = 0; ++ } ++ return true; ++ } + } + exports.Behavior = Behavior; + /** +diff --git a/dist/global.d.ts b/dist/global.d.ts +index d53e2d8648fe42b5e9811dc83455061a8d75e1a8..ace058ae10a3f4c5a9861a19431ab0fc44d520d0 100644 +--- a/dist/global.d.ts ++++ b/dist/global.d.ts +@@ -67,6 +67,14 @@ declare global { + muteWarnings?: boolean; + } + interface SchemaOptions { ++ /** ++ * Release schema-construction-only state after successful schema ++ * validation. Materialized schema execution remains available, but ++ * late plugin access to retired build APIs fails closed. ++ * ++ * @experimental ++ */ ++ releaseBuildStateAfterValidation?: boolean; + /** + * A behavior string to prepend to all behavior checks, can be overriden + * but is a great way to disable things by default and then re-enable +@@ -211,6 +219,18 @@ declare global { + * influence what types/fields/etc are added to the GraphQL schema. + */ + input: BuildInput; ++ /** ++ * Register synchronous cleanup for plugin-owned state that is used ++ * only while constructing the schema. Disposers run once, in reverse ++ * registration order, after successful schema validation when ++ * `releaseBuildStateAfterValidation` is enabled. ++ * ++ * Disposers must capture the exact state they own and must not call ++ * other build APIs, which fail closed while disposal is in progress. ++ * ++ * @experimental ++ */ ++ registerBuildStateDisposer(disposer: () => void): void; + /** + * Returns true if `Build.versions` contains an entry for `packageName` + * compatible with the version range `range`, false otherwise. +diff --git a/dist/index.d.ts b/dist/index.d.ts +index f6d0d7fd6d772f8433b3890f25b79369b565963f..57992c592558a61d231f5a92bced97d40b2ea246 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -1,6 +1,7 @@ + import "./global.ts"; + import "./interfaces.ts"; + export { isValidBehaviorString } from "./behavior.ts"; ++export { BUILD_STATE_RELEASED_ERROR_CODE, isBuildStateReleased, releaseBuildState } from "./makeNewBuild.ts"; + import { AddNodeInterfaceToSuitableTypesPlugin, BuiltinScalarConnectionsPlugin, ClientMutationIdDescriptionPlugin, CommonBehaviorsPlugin, CommonTypesPlugin, CursorTypePlugin, MinifySchemaPlugin, MutationPayloadQueryPlugin, MutationPlugin, NodeAccessorPlugin, NodeIdCodecBase64JSONPlugin, NodeIdCodecPipeStringPlugin, NodePlugin, PageInfoStartEndCursorPlugin, QueryPlugin, QueryQueryPlugin, RegisterQueryNodePlugin, StreamDeferPlugin, SubscriptionPlugin, SwallowErrorsPlugin, TrimEmptyDescriptionsPlugin } from "./plugins/index.ts"; + import SchemaBuilder from "./SchemaBuilder.ts"; + export { camelCase, constantCase, constantCaseAll, EXPORTABLE, EXPORTABLE_ARRAY_CLONE, EXPORTABLE_OBJECT_CLONE, formatInsideUnderscores, gatherConfig, pluralize, singularize, upperCamelCase, upperFirst, } from "./utils.ts"; +diff --git a/dist/index.js b/dist/index.js +index 544e9961824999d10f191510c05a0bd1edb02349..6015ec06aff2ed5ec802f1e57bd082afdebbe3ab 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -1,6 +1,6 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); +-exports.version = exports.defaultPreset = exports.TrimEmptyDescriptionsPlugin = exports.SwallowErrorsPlugin = exports.SubscriptionPlugin = exports.StreamDeferPlugin = exports.RegisterQueryNodePlugin = exports.QueryQueryPlugin = exports.QueryPlugin = exports.PageInfoStartEndCursorPlugin = exports.NodePlugin = exports.NodeIdCodecPipeStringPlugin = exports.NodeIdCodecBase64JSONPlugin = exports.NodeAccessorPlugin = exports.MutationPlugin = exports.MutationPayloadQueryPlugin = exports.MinifySchemaPlugin = exports.CursorTypePlugin = exports.CommonTypesPlugin = exports.CommonBehaviorsPlugin = exports.ClientMutationIdDescriptionPlugin = exports.BuiltinScalarConnectionsPlugin = exports.AddNodeInterfaceToSuitableTypesPlugin = exports.buildSchema = exports.getBuilder = exports.watchGather = exports.gather = exports.buildInflection = exports.SchemaBuilder = exports.upperFirst = exports.upperCamelCase = exports.singularize = exports.pluralize = exports.gatherConfig = exports.formatInsideUnderscores = exports.EXPORTABLE_OBJECT_CLONE = exports.EXPORTABLE_ARRAY_CLONE = exports.EXPORTABLE = exports.constantCaseAll = exports.constantCase = exports.camelCase = exports.isValidBehaviorString = void 0; ++exports.version = exports.defaultPreset = exports.TrimEmptyDescriptionsPlugin = exports.SwallowErrorsPlugin = exports.SubscriptionPlugin = exports.StreamDeferPlugin = exports.RegisterQueryNodePlugin = exports.QueryQueryPlugin = exports.QueryPlugin = exports.PageInfoStartEndCursorPlugin = exports.NodePlugin = exports.NodeIdCodecPipeStringPlugin = exports.NodeIdCodecBase64JSONPlugin = exports.NodeAccessorPlugin = exports.MutationPlugin = exports.MutationPayloadQueryPlugin = exports.MinifySchemaPlugin = exports.CursorTypePlugin = exports.CommonTypesPlugin = exports.CommonBehaviorsPlugin = exports.ClientMutationIdDescriptionPlugin = exports.BuiltinScalarConnectionsPlugin = exports.AddNodeInterfaceToSuitableTypesPlugin = exports.buildSchema = exports.getBuilder = exports.watchGather = exports.gather = exports.buildInflection = exports.SchemaBuilder = exports.upperFirst = exports.upperCamelCase = exports.singularize = exports.pluralize = exports.gatherConfig = exports.formatInsideUnderscores = exports.EXPORTABLE_OBJECT_CLONE = exports.EXPORTABLE_ARRAY_CLONE = exports.EXPORTABLE = exports.constantCaseAll = exports.constantCase = exports.camelCase = exports.releaseBuildState = exports.isBuildStateReleased = exports.BUILD_STATE_RELEASED_ERROR_CODE = exports.isValidBehaviorString = void 0; + exports.makeSchema = makeSchema; + exports.watchSchema = watchSchema; + const tslib_1 = require("tslib"); +@@ -11,6 +11,10 @@ const graphql_1 = require("grafast/graphql"); + const graphile_config_1 = require("graphile-config"); + var behavior_ts_1 = require("./behavior.js"); + Object.defineProperty(exports, "isValidBehaviorString", { enumerable: true, get: function () { return behavior_ts_1.isValidBehaviorString; } }); ++var makeNewBuild_ts_1 = require("./makeNewBuild.js"); ++Object.defineProperty(exports, "BUILD_STATE_RELEASED_ERROR_CODE", { enumerable: true, get: function () { return makeNewBuild_ts_1.BUILD_STATE_RELEASED_ERROR_CODE; } }); ++Object.defineProperty(exports, "isBuildStateReleased", { enumerable: true, get: function () { return makeNewBuild_ts_1.isBuildStateReleased; } }); ++Object.defineProperty(exports, "releaseBuildState", { enumerable: true, get: function () { return makeNewBuild_ts_1.releaseBuildState; } }); + const extend_ts_1 = tslib_1.__importDefault(require("./extend.js")); + const inflection_ts_1 = require("./inflection.js"); + const index_ts_1 = require("./plugins/index.js"); +diff --git a/dist/makeNewBuild.d.ts b/dist/makeNewBuild.d.ts +index c294c17ab5486a4a8c7b7d448d5b921f4af5c2ff..5665d2b4a0173a24ec88694ff9be1c69fbed9d02 100644 +--- a/dist/makeNewBuild.d.ts ++++ b/dist/makeNewBuild.d.ts +@@ -1,5 +1,8 @@ + import "./global.ts"; + import type SchemaBuilder from "./SchemaBuilder.ts"; ++export declare const BUILD_STATE_RELEASED_ERROR_CODE: "GRAPHILE_BUILD_STATE_RELEASED"; ++export declare function isBuildStateReleased(build: GraphileBuild.Build): boolean; ++export declare function releaseBuildState(build: GraphileBuild.Build): boolean; + /** + * Makes a new 'Build' object suitable to be passed through the 'build' hook. + */ +diff --git a/dist/makeNewBuild.js b/dist/makeNewBuild.js +index 5a873bb569247840eeb110a754095236849fc35a..00b098b4c38b1e2dc36b9457c3af3e11ad887a8c 100644 +--- a/dist/makeNewBuild.js ++++ b/dist/makeNewBuild.js +@@ -1,5 +1,8 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); ++exports.BUILD_STATE_RELEASED_ERROR_CODE = void 0; ++exports.isBuildStateReleased = isBuildStateReleased; ++exports.releaseBuildState = releaseBuildState; + exports.default = makeNewBuild; + const tslib_1 = require("tslib"); + require("./global.js"); +@@ -11,6 +14,57 @@ const extend_ts_1 = tslib_1.__importStar(require("./extend.js")); + const utils_ts_1 = require("./utils.js"); + const version_ts_1 = require("./version.js"); + const BUILTINS = ["Int", "Float", "Boolean", "ID", "String"]; ++exports.BUILD_STATE_RELEASED_ERROR_CODE = "GRAPHILE_BUILD_STATE_RELEASED"; ++const buildRetirementStates = new WeakMap(); ++function buildStateReleasedError(api) { ++ const error = new Error(`Cannot use ${api} after Graphile build state has been released`); ++ error.code = exports.BUILD_STATE_RELEASED_ERROR_CODE; ++ return error; ++} ++function assertBuildStateLive(build, api) { ++ if (isBuildStateReleased(build)) { ++ throw buildStateReleasedError(api); ++ } ++} ++function isBuildStateReleased(build) { ++ const state = buildRetirementStates.get(build); ++ return state !== undefined && state.status !== "live"; ++} ++function releaseBuildState(build) { ++ const state = buildRetirementStates.get(build); ++ if (state === undefined || state.status !== "live") { ++ return false; ++ } ++ state.status = "releasing"; ++ const errors = []; ++ const disposers = state.disposers.splice(0).reverse(); ++ for (const disposer of disposers) { ++ try { ++ disposer(); ++ } ++ catch (error) { ++ errors.push(error); ++ } ++ } ++ try { ++ state.releaseCore(); ++ } ++ catch (error) { ++ errors.push(error); ++ } ++ try { ++ build.behavior.releaseBuildState(); ++ } ++ catch (error) { ++ errors.push(error); ++ } ++ state.status = "released"; ++ state.releaseCore = null; ++ if (errors.length > 0) { ++ throw new AggregateError(errors, `Failed to release Graphile build state (${errors.length} disposal error${errors.length === 1 ? "" : "s"})`); ++ } ++ return true; ++} + /** Have we warned the user they're using the 5-arg deprecated registerObjectType call? */ + let registerObjectType5argsDeprecatedWarned = false; + /** +@@ -18,15 +72,16 @@ let registerObjectType5argsDeprecatedWarned = false; + */ + function makeNewBuild(builder, input, inflection) { + const lib = builder.resolvedPreset.lib ?? {}; +- const building = new Set(); +- const allTypes = { ++ let inputState = input; ++ let building = new Set(); ++ let allTypes = { + Int: graphql_1.GraphQLInt, + Float: graphql_1.GraphQLFloat, + String: graphql_1.GraphQLString, + Boolean: graphql_1.GraphQLBoolean, + ID: graphql_1.GraphQLID, + }; +- const allTypesSources = { ++ let allTypesSources = { + Int: "GraphQL Built-in", + Float: "GraphQL Built-in", + String: "GraphQL Built-in", +@@ -36,10 +91,11 @@ function makeNewBuild(builder, input, inflection) { + /** + * Where the type factories are; so we don't construct types until they're needed. + */ +- const typeRegistry = Object.create(null); +- const scopeByType = new Map(); ++ let typeRegistry = Object.create(null); ++ let scopeByType = new Map(); + // TODO: allow registering a previously constructed type. + function register(klass, typeName, scope, specGenerator, origin) { ++ assertBuildStateLive(build, "build.register*"); + if (!this.status.isBuildPhaseComplete || this.status.isInitPhaseComplete) { + throw new Error("Types may only be registered in the 'init' phase"); + } +@@ -83,7 +139,17 @@ function makeNewBuild(builder, input, inflection) { + graphql: lib.graphql.version, + "graphile-build": version_ts_1.version, + }, +- input, ++ get input() { ++ assertBuildStateLive(build, "build.input"); ++ return inputState; ++ }, ++ registerBuildStateDisposer(disposer) { ++ assertBuildStateLive(build, "build.registerBuildStateDisposer"); ++ if (typeof disposer !== "function") { ++ throw new TypeError("build.registerBuildStateDisposer requires a function"); ++ } ++ retirementState.disposers.push(disposer); ++ }, + hasVersion(packageName, range, options = { includePrerelease: true }) { + const packageVersion = this.versions[packageName]; + if (!packageVersion) +@@ -119,9 +185,13 @@ function makeNewBuild(builder, input, inflection) { + } + }, + getAllTypes() { ++ assertBuildStateLive(build, "build.getAllTypes"); + return allTypes; + }, +- scopeByType, ++ get scopeByType() { ++ assertBuildStateLive(build, "build.scopeByType"); ++ return scopeByType; ++ }, + inflection, + handleRecoverableError(e) { + e["recoverable"] = true; +@@ -186,6 +256,7 @@ function makeNewBuild(builder, input, inflection) { + }, + __postInitAssertions: [], + assertTypeName(typeName, deferrable = false) { ++ assertBuildStateLive(build, "build.assertTypeName"); + if (!this.status.isBuildPhaseComplete) { + throw new Error("Must not call build.assertTypeName before 'build' phase is complete; use 'init' phase instead"); + } +@@ -207,6 +278,7 @@ function makeNewBuild(builder, input, inflection) { + } + }, + getTypeMetaByName(typeName) { ++ assertBuildStateLive(build, "build.getTypeMetaByName"); + if (!this.status.isBuildPhaseComplete) { + throw new Error("Must not call build.getTypeMetaByName before 'build' phase is complete; use 'init' phase instead"); + } +@@ -253,6 +325,7 @@ function makeNewBuild(builder, input, inflection) { + return null; + }, + getTypeByName(typeName) { ++ assertBuildStateLive(build, "build.getTypeByName"); + if (currentTypeDetails && !BUILTINS.includes(typeName)) { + throw new Error(`Error in spec callback for ${currentTypeDetails.klass.name} '${currentTypeDetails.typeName}'; the callback made a call to \`build.getTypeByName(${JSON.stringify(typeName)})\` (directly or indirectly) - this is the wrong time for such a call \ + to occur since it can lead to circular dependence. To fix this, ensure that any \ +@@ -377,6 +450,25 @@ style for these configuration options (e.g. change \`interfaces: \ + }, + _pluginMeta: Object.create(null), + }; ++ const retirementState = { ++ status: "live", ++ disposers: [], ++ releaseCore() { ++ inputState = undefined; ++ building.clear(); ++ building = undefined; ++ allTypes = undefined; ++ allTypesSources = undefined; ++ typeRegistry = undefined; ++ scopeByType.clear(); ++ scopeByType = undefined; ++ build.__postInitAssertions.length = 0; ++ for (const key of Object.keys(build._pluginMeta)) { ++ delete build._pluginMeta[key]; ++ } ++ }, ++ }; ++ buildRetirementStates.set(build, retirementState); + return build; + } + let currentTypeDetails = null; diff --git a/patches/pg-introspection@1.0.1.patch b/patches/pg-introspection@1.0.1.patch new file mode 100644 index 0000000000..c12fa79220 --- /dev/null +++ b/patches/pg-introspection@1.0.1.patch @@ -0,0 +1,599 @@ +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 8b378fb1edcdf727c35d86360b38baf042b29476..fb49ae92221e6052f48a702c9632ef4a439ea449 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -1,5 +1,5 @@ + import type { Introspection, PgAttribute, PgAuthMembers, PgClass, PgConstraint, PgDatabase, PgDepend, PgDescription, PgEntity, PgEnum, PgExtension, PgIndex, PgInherits, PgLanguage, PgNamespace, PgProc, PgProcArgument, PgRange, PgRoles, PgType } from "./introspection.ts"; +-export { makeIntrospectionQuery } from "./introspection.ts"; ++export { makeIntrospectionQuery, makeSchemaScopedIntrospectionQuery, type SchemaScopedCatalogTypes, type SchemaScopedIntrospectionOptions } from "./introspection.ts"; + import type { AclObject } from "./acl.ts"; + import { aclContainsRole, entityPermissions, expandRoles, resolvePermissions } from "./acl.ts"; + import type { PgSmartTagsAndDescription, PgSmartTagsDict } from "./smartComments.ts"; +diff --git a/dist/index.js b/dist/index.js +index e74695ac2c2469f423637837ac454c26f1ea03d9..81044f8b02061d6db8716fd9f9bd65bac70974f9 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -1,10 +1,11 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); +-exports.resolvePermissions = exports.expandRoles = exports.entityPermissions = exports.aclContainsRole = exports.parseSmartComment = exports.reservedWords = exports.makeIntrospectionQuery = void 0; ++exports.resolvePermissions = exports.expandRoles = exports.entityPermissions = exports.aclContainsRole = exports.parseSmartComment = exports.reservedWords = exports.makeSchemaScopedIntrospectionQuery = exports.makeIntrospectionQuery = void 0; + exports.parseIntrospectionResults = parseIntrospectionResults; + const tslib_1 = require("tslib"); + var introspection_ts_1 = require("./introspection.js"); + Object.defineProperty(exports, "makeIntrospectionQuery", { enumerable: true, get: function () { return introspection_ts_1.makeIntrospectionQuery; } }); ++Object.defineProperty(exports, "makeSchemaScopedIntrospectionQuery", { enumerable: true, get: function () { return introspection_ts_1.makeSchemaScopedIntrospectionQuery; } }); + const acl_ts_1 = require("./acl.js"); + Object.defineProperty(exports, "aclContainsRole", { enumerable: true, get: function () { return acl_ts_1.aclContainsRole; } }); + Object.defineProperty(exports, "entityPermissions", { enumerable: true, get: function () { return acl_ts_1.entityPermissions; } }); +diff --git a/dist/introspection.d.ts b/dist/introspection.d.ts +index d4d1dd52e4e53dcbbd13c206b62309991ab38d2d..3461e09ca361c5ac7af991844c7d80595416cf1f 100644 +--- a/dist/introspection.d.ts ++++ b/dist/introspection.d.ts +@@ -1224,5 +1224,20 @@ export type PgEntity = PgDatabase | PgNamespace | PgClass | PgAttribute | PgCons + * Builds a PostgreSQL introspection SQL query to return an object with the same shape as `Introspection` above. + */ + export declare const makeIntrospectionQuery: () => string; ++/** ++ * Builds a parameterized introspection query scoped to the requested schemas ++ * and the transitive object dependencies required by their objects. ++ */ ++export type SchemaScopedCatalogTypes = "all" | "dependency-closure"; ++export interface SchemaScopedIntrospectionOptions { ++ /** Catalog type retention policy. Defaults to the compatibility-safe `all`. */ ++ catalogTypes?: SchemaScopedCatalogTypes; ++ /** Exact installed extension names whose optional capability metadata is required. */ ++ capabilityExtensions?: readonly string[]; ++} ++export declare const makeSchemaScopedIntrospectionQuery: (schemas: readonly string[], options?: SchemaScopedIntrospectionOptions) => { ++ text: string; ++ values: [string[], string[]]; ++}; + export {}; + //# sourceMappingURL=introspection.d.ts.map +diff --git a/dist/introspection.js b/dist/introspection.js +index b21ef287be6d529c1801bba6b7c2096758849884..19bfae9ba9dadac29a947e7d893aaa81a331ebeb 100644 +--- a/dist/introspection.js ++++ b/dist/introspection.js +@@ -5,13 +5,341 @@ + * DO NOT EDIT! + */ + Object.defineProperty(exports, "__esModule", { value: true }); +-exports.makeIntrospectionQuery = void 0; ++exports.makeSchemaScopedIntrospectionQuery = exports.makeIntrospectionQuery = void 0; ++const STOCK_NAMESPACE_PREDICATE = "nspname <> 'information_schema'"; ++const STOCK_OBJECT_NAMESPACE_PREDICATE = "in (select namespaces._id from namespaces where nspname <> 'information_schema' and nspname not like 'pg\\_%')"; ++const SCOPED_CTES = `recursive ++ requested_schema_names(schema_name) as ( ++ select distinct requested.schema_name ++ from pg_catalog.unnest($1::text[]) as requested(schema_name) ++ ), ++ ++ capability_extension_names(extension_name) as ( ++ select distinct capability.extension_name ++ from pg_catalog.unnest($2::text[]) as capability(extension_name) ++ ), ++ ++ requested_namespaces as ( ++ select pg_namespace.oid as _id, pg_namespace.nspname ++ from pg_catalog.pg_namespace ++ inner join requested_schema_names ++ on requested_schema_names.schema_name = pg_namespace.nspname ++ ), ++ ++ root_objects(object_class, object_id) as ( ++ select 'pg_catalog.pg_class'::regclass::oid, pg_class.oid ++ from pg_catalog.pg_class ++ where pg_class.relnamespace in (select requested_namespaces._id from requested_namespaces) ++ ++ union ++ ++ select 'pg_catalog.pg_constraint'::regclass::oid, pg_constraint.oid ++ from pg_catalog.pg_constraint ++ where pg_constraint.connamespace in (select requested_namespaces._id from requested_namespaces) ++ ++ union ++ ++ select 'pg_catalog.pg_proc'::regclass::oid, pg_proc.oid ++ from pg_catalog.pg_proc ++ where pg_proc.pronamespace in (select requested_namespaces._id from requested_namespaces) ++ and pg_proc.prorettype operator(pg_catalog.<>) 2279 ++ ++ union ++ ++ select 'pg_catalog.pg_type'::regclass::oid, pg_type.oid ++ from pg_catalog.pg_type ++ where pg_type.typnamespace in (select requested_namespaces._id from requested_namespaces) ++ ), ++ ++ object_closure(object_class, object_id) as ( ++ select root_objects.object_class, root_objects.object_id ++ from root_objects ++ ++ union ++ ++ select dependency.object_class, dependency.object_id ++ from object_closure ++ cross join lateral ( ++ select ++ 'pg_catalog.pg_type'::regclass::oid as object_class, ++ pg_class.reltype as object_id ++ from pg_catalog.pg_class ++ where object_closure.object_class = 'pg_catalog.pg_class'::regclass ++ and pg_class.oid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_type'::regclass::oid, pg_class.reloftype ++ from pg_catalog.pg_class ++ where object_closure.object_class = 'pg_catalog.pg_class'::regclass ++ and pg_class.oid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_type'::regclass::oid, pg_attribute.atttypid ++ from pg_catalog.pg_attribute ++ where object_closure.object_class = 'pg_catalog.pg_class'::regclass ++ and pg_attribute.attrelid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_constraint'::regclass::oid, pg_constraint.oid ++ from pg_catalog.pg_constraint ++ where object_closure.object_class = 'pg_catalog.pg_class'::regclass ++ and pg_constraint.conrelid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_class'::regclass::oid, pg_index.indexrelid ++ from pg_catalog.pg_index ++ where object_closure.object_class = 'pg_catalog.pg_class'::regclass ++ and pg_index.indrelid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_class'::regclass::oid, pg_inherits.inhparent ++ from pg_catalog.pg_inherits ++ where object_closure.object_class = 'pg_catalog.pg_class'::regclass ++ and pg_inherits.inhrelid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_class'::regclass::oid, constraint_class.oid ++ from pg_catalog.pg_constraint ++ cross join lateral pg_catalog.unnest( ++ array[ ++ pg_constraint.conrelid, ++ pg_constraint.confrelid, ++ pg_constraint.conindid ++ ]::oid[] ++ ) as constraint_class(oid) ++ where object_closure.object_class = 'pg_catalog.pg_constraint'::regclass ++ and pg_constraint.oid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_type'::regclass::oid, pg_constraint.contypid ++ from pg_catalog.pg_constraint ++ where object_closure.object_class = 'pg_catalog.pg_constraint'::regclass ++ and pg_constraint.oid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_constraint'::regclass::oid, pg_constraint.conparentid ++ from pg_catalog.pg_constraint ++ where object_closure.object_class = 'pg_catalog.pg_constraint'::regclass ++ and pg_constraint.oid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_type'::regclass::oid, procedure_type.oid ++ from pg_catalog.pg_proc ++ cross join lateral pg_catalog.unnest( ++ coalesce(pg_proc.proallargtypes, pg_proc.proargtypes::oid[]) ++ || array[pg_proc.prorettype]::oid[] ++ ) as procedure_type(oid) ++ where object_closure.object_class = 'pg_catalog.pg_proc'::regclass ++ and pg_proc.oid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_type'::regclass::oid, dependency_type.oid ++ from pg_catalog.pg_type ++ cross join lateral pg_catalog.unnest( ++ array[ ++ pg_type.typbasetype, ++ pg_type.typelem, ++ pg_type.typarray ++ ]::oid[] ++ ) as dependency_type(oid) ++ where object_closure.object_class = 'pg_catalog.pg_type'::regclass ++ and pg_type.oid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_class'::regclass::oid, pg_type.typrelid ++ from pg_catalog.pg_type ++ where object_closure.object_class = 'pg_catalog.pg_type'::regclass ++ and pg_type.oid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_constraint'::regclass::oid, pg_constraint.oid ++ from pg_catalog.pg_constraint ++ where object_closure.object_class = 'pg_catalog.pg_type'::regclass ++ and pg_constraint.contypid = object_closure.object_id ++ ++ union all ++ ++ select 'pg_catalog.pg_type'::regclass::oid, range_type.oid ++ from pg_catalog.pg_range ++ cross join lateral pg_catalog.unnest( ++ array[ ++ pg_range.rngtypid, ++ pg_range.rngsubtype, ++ pg_range.rngmultitypid ++ ]::oid[] ++ ) as range_type(oid) ++ where object_closure.object_class = 'pg_catalog.pg_type'::regclass ++ and object_closure.object_id in (pg_range.rngtypid, pg_range.rngmultitypid) ++ ) as dependency ++ where dependency.object_id operator(pg_catalog.<>) 0 ++ ), ++ ++ retained_index_metadata(indexrelid, indclass, indcollation) as ( ++ select pg_index.indexrelid, pg_index.indclass, pg_index.indcollation ++ from object_closure ++ inner join pg_catalog.pg_class retained_index ++ on object_closure.object_class = 'pg_catalog.pg_class'::regclass ++ and retained_index.oid = object_closure.object_id ++ and retained_index.relkind in ('i', 'I') ++ inner join pg_catalog.pg_index ++ on pg_index.indexrelid = retained_index.oid ++ ), ++ ++ retained_index_opclasses(_id, opcfamily) as ( ++ select pg_opclass.oid, pg_opclass.opcfamily ++ from retained_index_metadata ++ cross join lateral pg_catalog.unnest( ++ retained_index_metadata.indclass::oid[] ++ ) as index_opclass(_id) ++ inner join pg_catalog.pg_opclass ++ on pg_opclass.oid = index_opclass._id ++ ), ++ ++ retained_index_support_objects(object_class, object_id) as ( ++ select 'pg_catalog.pg_opclass'::regclass::oid, retained_index_opclasses._id ++ from retained_index_opclasses ++ ++ union ++ ++ select 'pg_catalog.pg_opfamily'::regclass::oid, retained_index_opclasses.opcfamily ++ from retained_index_opclasses ++ ++ union ++ ++ select 'pg_catalog.pg_operator'::regclass::oid, pg_amop.amopopr ++ from retained_index_opclasses ++ inner join pg_catalog.pg_amop ++ on pg_amop.amopfamily = retained_index_opclasses.opcfamily ++ ++ union ++ ++ select 'pg_catalog.pg_proc'::regclass::oid, pg_amproc.amproc ++ from retained_index_opclasses ++ inner join pg_catalog.pg_amproc ++ on pg_amproc.amprocfamily = retained_index_opclasses.opcfamily ++ ++ union ++ ++ select 'pg_catalog.pg_collation'::regclass::oid, index_collation._id ++ from retained_index_metadata ++ cross join lateral pg_catalog.unnest( ++ retained_index_metadata.indcollation::oid[] ++ ) as index_collation(_id) ++ where index_collation._id operator(pg_catalog.<>) 0 ++ ), ++ ++ installed_extensions(_id, extnamespace) as ( ++ select pg_extension.oid, pg_extension.extnamespace ++ from pg_catalog.pg_extension ++ where pg_extension.extname in ( ++ select capability_extension_names.extension_name ++ from capability_extension_names ++ ) ++ or exists ( ++ select 1 ++ from object_closure ++ inner join pg_catalog.pg_depend ++ on pg_depend.classid = object_closure.object_class ++ and pg_depend.objid = object_closure.object_id ++ and pg_depend.refclassid = 'pg_catalog.pg_extension'::regclass ++ and pg_depend.refobjid = pg_extension.oid ++ and pg_depend.deptype = 'e' ++ ) ++ or exists ( ++ select 1 ++ from retained_index_support_objects ++ inner join pg_catalog.pg_depend ++ on pg_depend.classid = retained_index_support_objects.object_class ++ and pg_depend.objid = retained_index_support_objects.object_id ++ and pg_depend.refclassid = 'pg_catalog.pg_extension'::regclass ++ and pg_depend.refobjid = pg_extension.oid ++ and pg_depend.deptype = 'e' ++ ) ++ or exists ( ++ select 1 ++ from object_closure ++ inner join pg_catalog.pg_class retained_index ++ on object_closure.object_class = 'pg_catalog.pg_class'::regclass ++ and retained_index.oid = object_closure.object_id ++ and retained_index.relkind = 'i' ++ inner join pg_catalog.pg_depend ++ on pg_depend.classid = 'pg_catalog.pg_am'::regclass ++ and pg_depend.objid = retained_index.relam ++ and pg_depend.refclassid = 'pg_catalog.pg_extension'::regclass ++ and pg_depend.refobjid = pg_extension.oid ++ and pg_depend.deptype = 'e' ++ ) ++ ), ++ ++ scoped_namespaces(_id) as ( ++ select requested_namespaces._id ++ from requested_namespaces ++ ++ union ++ ++ select pg_class.relnamespace ++ from object_closure ++ inner join pg_catalog.pg_class ++ on object_closure.object_class = 'pg_catalog.pg_class'::regclass ++ and pg_class.oid = object_closure.object_id ++ ++ union ++ ++ select pg_constraint.connamespace ++ from object_closure ++ inner join pg_catalog.pg_constraint ++ on object_closure.object_class = 'pg_catalog.pg_constraint'::regclass ++ and pg_constraint.oid = object_closure.object_id ++ ++ union ++ ++ select pg_proc.pronamespace ++ from object_closure ++ inner join pg_catalog.pg_proc ++ on object_closure.object_class = 'pg_catalog.pg_proc'::regclass ++ and pg_proc.oid = object_closure.object_id ++ ++ union ++ ++ select pg_type.typnamespace ++ from object_closure ++ inner join pg_catalog.pg_type ++ on object_closure.object_class = 'pg_catalog.pg_type'::regclass ++ and pg_type.oid = object_closure.object_id ++ ++ union ++ ++ select installed_extensions.extnamespace ++ from installed_extensions ++ where installed_extensions.extnamespace operator(pg_catalog.<>) 0 ++ ++ union ++ ++ select pg_namespace.oid ++ from pg_catalog.pg_namespace ++ where pg_namespace.nspname = 'pg_catalog' ++ ), ++ ++`; + // We might want this to take options in future, so we've made it a function. + /** + * Builds a PostgreSQL introspection SQL query to return an object with the same shape as `Introspection` above. + */ +-const makeIntrospectionQuery = () => `\ ++const buildIntrospectionQuery = (scope) => `\ + with ++${scope?.ctes ?? ""}\ + database as ( + select pg_database.oid as _id, * + from pg_catalog.pg_database +@@ -21,14 +349,14 @@ with + namespaces as ( + select pg_namespace.oid as _id, * + from pg_catalog.pg_namespace +- where nspname <> 'information_schema' ++ where ${scope?.namespacePredicate ?? STOCK_NAMESPACE_PREDICATE} + ), + + 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 ?? `relnamespace ${STOCK_OBJECT_NAMESPACE_PREDICATE}`} + ), + + attributes as ( +@@ -40,32 +368,34 @@ 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 ?? `connamespace ${STOCK_OBJECT_NAMESPACE_PREDICATE}`} + ), + + 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 ?? `pronamespace ${STOCK_OBJECT_NAMESPACE_PREDICATE}`} + 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 ?? `(typnamespace ${STOCK_OBJECT_NAMESPACE_PREDICATE}) ++ or (typnamespace = 'pg_catalog'::regnamespace)`} + ), + + enums as ( +@@ -77,6 +407,8 @@ with + extensions as ( + select pg_extension.oid as _id, * + from pg_catalog.pg_extension ++${scope?.extensionPredicate ? ` where ${scope.extensionPredicate} ++` : ""}\ + ), + + indexes as ( +@@ -94,6 +426,8 @@ with + languages as ( + select pg_language.oid as _id, * + from pg_catalog.pg_language ++${scope?.languagePredicate ? ` where ${scope.languagePredicate} ++` : ""}\ + ), + + policies as ( +@@ -141,7 +475,7 @@ with + am as ( + select pg_am.oid as _id, * + from pg_catalog.pg_am +- where true ++ where ${scope?.accessMethodPredicate ?? "true"} + ) + select json_build_object( + 'database', +@@ -221,5 +555,66 @@ select json_build_object( + 1 + )::text as introspection + `; ++const makeIntrospectionQuery = () => buildIntrospectionQuery(); + exports.makeIntrospectionQuery = makeIntrospectionQuery; ++/** ++ * Builds a parameterized introspection query scoped to the requested schemas ++ * and the transitive object dependencies required by their objects. ++ */ ++const makeSchemaScopedIntrospectionQuery = (schemas, options = {}) => { ++ 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], ++ }; ++}; ++exports.makeSchemaScopedIntrospectionQuery = makeSchemaScopedIntrospectionQuery; + //# sourceMappingURL=introspection.js.map +diff --git a/dist/augmentIntrospection.js b/dist/augmentIntrospection.js +--- a/dist/augmentIntrospection.js ++++ b/dist/augmentIntrospection.js +@@ -7,16 +7,28 @@ const smartComments_ts_1 = require("./smartComments.js"); + /** + * Only suitable for functions that accept no arguments. + */ +-function memo(fn) { +- let cache; +- let called = false; +- return () => { +- if (!called) { +- cache = fn(); +- called = true; ++function createMemoDispatcher() { ++ const resolvers = []; ++ const values = []; ++ const resolved = []; ++ // Bound functions retain only their integer slot. Resolver state lives in ++ // compact per-introspection arrays instead of one wrapper closure context ++ // per helper. An arrow keeps the original helpers non-constructable. ++ const dispatchMemo = (index) => { ++ if (resolved[index] !== true) { ++ const value = resolvers[index](); ++ values[index] = value; ++ resolved[index] = true; ++ // A resolved helper no longer needs to retain its resolver closure. ++ resolvers[index] = undefined; + } +- return cache; ++ return values[index]; + }; ++ return (resolver) => { ++ const index = resolvers.length; ++ resolvers.push(resolver); ++ return dispatchMemo.bind(undefined, index); ++ }; + } + function del(toDelete, collection, attr) { + for (let i = collection.length - 1; i >= 0; i--) { +@@ -176,5 +188,6 @@ function augmentIntrospection(introspectionResultsString, includeExtensionResour + } + /** @internal */ + function augmentIntrospectionParsed(introspection, includeExtensionResources = false) { ++ const memo = createMemoDispatcher(); + introspection._lookups = createLookups(introspection); + introspection._caches = createCaches(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2831e757f1..ecb8038fa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,23 @@ overrides: packageExtensionsChecksum: sha256-x8B4zkJ4KLRX+yspUWxuggXWlz6zrBLSIh72pNhpPiE= +patchedDependencies: + '@dataplan/pg@1.0.3': + hash: 1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb + path: patches/@dataplan__pg@1.0.3.patch + '@graphile-contrib/pg-many-to-many@2.0.0-rc.2': + hash: 71202d598d0d80e79890b8fbd8a1fc60cee20622dca60e59a0a6e994b6ab92c8 + path: patches/@graphile-contrib__pg-many-to-many@2.0.0-rc.2.patch + graphile-build-pg@5.0.2: + hash: 869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624 + path: patches/graphile-build-pg.patch + graphile-build@5.0.2: + hash: f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6 + path: patches/graphile-build@5.0.2.patch + pg-introspection@1.0.1: + hash: 987dfb1b6491b9423e890d57722efdb618efce72183de5dd25c3fde11189fbc4 + path: patches/pg-introspection@1.0.1.patch + importers: .: devDependencies: @@ -348,22 +365,22 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 graphile-utils: specifier: 5.0.0 - version: 5.0.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + version: 5.0.0(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: specifier: 16.13.0 version: 16.13.0 postgraphile: specifier: 5.0.3 - version: 5.0.3(bc368b92b12e127d4eb1f1e143433708) + version: 5.0.3(8e984f3e9cfc73b4b77b478748651d25) devDependencies: '@types/node': specifier: ^22.19.11 @@ -377,7 +394,7 @@ importers: dependencies: '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@pgsql/quotes': specifier: ^18.1.0 version: 18.2.1 @@ -386,10 +403,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -401,7 +418,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(bc368b92b12e127d4eb1f1e143433708) + version: 5.0.3(8e984f3e9cfc73b4b77b478748651d25) devDependencies: '@types/node': specifier: ^22.19.11 @@ -442,7 +459,7 @@ importers: version: link:../../postgres/pg-cache/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) devDependencies: '@types/express': specifier: ^5.0.6 @@ -462,13 +479,13 @@ importers: dependencies: '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -483,7 +500,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(f0ca433f04a3f28b00a29892b7510c70) + version: 5.0.3(65ecfcf3543e7c87a3dabce321c8b0d1) devDependencies: '@types/node': specifier: ^22.19.11 @@ -506,7 +523,7 @@ importers: version: link:../../postgres/query-builder/dist '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@pgpmjs/logger': specifier: workspace:^ version: link:../../pgpm/logger/dist @@ -515,10 +532,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -556,16 +573,16 @@ importers: dependencies: '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) grafast: specifier: 1.0.2 version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -577,7 +594,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(f0a861a74cc4311fffaf615b439fe994) + version: 5.0.3(df995cf62d46f35f4829b81730cdf6da) devDependencies: '@types/node': specifier: ^22.19.11 @@ -603,7 +620,7 @@ importers: dependencies: '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@pgsql/quotes': specifier: ^18.1.0 version: 18.2.1 @@ -615,10 +632,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -630,7 +647,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(f0a861a74cc4311fffaf615b439fe994) + version: 5.0.3(df995cf62d46f35f4829b81730cdf6da) devDependencies: '@types/accept-language-parser': specifier: ^1.5.4 @@ -668,7 +685,7 @@ importers: version: link:../../packages/llm-env/dist '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@pgsql/quotes': specifier: ^18.1.0 version: 18.2.1 @@ -677,10 +694,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-cache: specifier: workspace:^ version: link:../graphile-cache/dist @@ -689,7 +706,7 @@ importers: version: 1.0.1 graphile-utils: specifier: 5.0.0 - version: 5.0.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + version: 5.0.0(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: specifier: 16.13.0 version: 16.13.0 @@ -698,7 +715,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(bc368b92b12e127d4eb1f1e143433708) + version: 5.0.3(8e984f3e9cfc73b4b77b478748651d25) devDependencies: '@types/node': specifier: ^22.19.11 @@ -724,22 +741,22 @@ importers: dependencies: '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) grafast: specifier: 1.0.2 version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 graphile-connection-filter: specifier: ^1.13.0 - version: 1.14.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(postgraphile@5.0.3(bc368b92b12e127d4eb1f1e143433708)) + version: 1.14.0(1cb56828c00532c0532fac2b893bbf72) graphql: specifier: 16.13.0 version: 16.13.0 @@ -748,7 +765,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(bc368b92b12e127d4eb1f1e143433708) + version: 5.0.3(8e984f3e9cfc73b4b77b478748651d25) devDependencies: '@types/node': specifier: ^22.19.11 @@ -768,10 +785,10 @@ importers: dependencies: graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -780,7 +797,7 @@ importers: version: 16.13.0 postgraphile: specifier: 5.0.3 - version: 5.0.3(f0a861a74cc4311fffaf615b439fe994) + version: 5.0.3(df995cf62d46f35f4829b81730cdf6da) devDependencies: '@types/node': specifier: ^22.19.11 @@ -794,22 +811,22 @@ importers: dependencies: '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) grafast: specifier: 1.0.2 version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 graphile-connection-filter: specifier: ^1.13.0 - version: 1.14.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(postgraphile@5.0.3(bc368b92b12e127d4eb1f1e143433708)) + version: 1.14.0(1cb56828c00532c0532fac2b893bbf72) graphile-plugin-utils: specifier: workspace:^ version: link:../graphile-plugin-utils/dist @@ -821,7 +838,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(bc368b92b12e127d4eb1f1e143433708) + version: 5.0.3(8e984f3e9cfc73b4b77b478748651d25) devDependencies: '@types/node': specifier: ^22.19.11 @@ -841,13 +858,13 @@ importers: dependencies: '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -859,7 +876,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(f0a861a74cc4311fffaf615b439fe994) + version: 5.0.3(df995cf62d46f35f4829b81730cdf6da) devDependencies: '@types/node': specifier: ^22.19.11 @@ -873,22 +890,22 @@ importers: dependencies: '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) grafast: specifier: 1.0.2 version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 graphile-connection-filter: specifier: ^1.13.0 - version: 1.14.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(postgraphile@5.0.3(f0ca433f04a3f28b00a29892b7510c70)) + version: 1.14.0(3cdce88baf7cc47d1e7a2ba13375cd59) graphql: specifier: 16.13.0 version: 16.13.0 @@ -897,7 +914,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(f0ca433f04a3f28b00a29892b7510c70) + version: 5.0.3(65ecfcf3543e7c87a3dabce321c8b0d1) devDependencies: '@types/geojson': specifier: ^7946.0.14 @@ -935,16 +952,16 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 graphile-utils: specifier: 5.0.0 - version: 5.0.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + version: 5.0.0(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: specifier: 16.13.0 version: 16.13.0 @@ -953,7 +970,7 @@ importers: version: 11.2.7 postgraphile: specifier: 5.0.3 - version: 5.0.3(bc368b92b12e127d4eb1f1e143433708) + version: 5.0.3(8e984f3e9cfc73b4b77b478748651d25) devDependencies: '@constructive-io/s3-utils': specifier: workspace:^ @@ -973,10 +990,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -991,7 +1008,7 @@ importers: version: 8.21.0 postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) devDependencies: '@types/pg': specifier: ^8.20.0 @@ -1017,22 +1034,22 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 graphile-utils: specifier: 5.0.0 - version: 5.0.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + version: 5.0.0(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: specifier: 16.13.0 version: 16.13.0 postgraphile: specifier: 5.0.3 - version: 5.0.3(bc368b92b12e127d4eb1f1e143433708) + version: 5.0.3(8e984f3e9cfc73b4b77b478748651d25) devDependencies: '@types/node': specifier: ^22.19.11 @@ -1049,10 +1066,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -1098,7 +1115,7 @@ importers: version: link:../../postgres/pgsql-test/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) publishDirectory: dist graphile/graphile-schema: @@ -1108,7 +1125,7 @@ importers: version: 4.3.1 graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -1140,16 +1157,16 @@ importers: dependencies: '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@pgsql/quotes': specifier: ^18.1.0 version: 18.2.1 graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -1164,7 +1181,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(f0ca433f04a3f28b00a29892b7510c70) + version: 5.0.3(65ecfcf3543e7c87a3dabce321c8b0d1) devDependencies: '@types/node': specifier: ^22.19.11 @@ -1217,10 +1234,10 @@ importers: version: 1.0.0(grafast@1.0.2(graphql@16.13.0)) '@dataplan/pg': specifier: 1.0.3 - version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + version: 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@graphile-contrib/pg-many-to-many': specifier: 2.0.0-rc.2 - version: 2.0.0-rc.2 + version: 2.0.0-rc.2(patch_hash=71202d598d0d80e79890b8fbd8a1fc60cee20622dca60e59a0a6e994b6ab92c8) '@pgpmjs/logger': specifier: workspace:^ version: link:../../pgpm/logger/dist @@ -1247,10 +1264,10 @@ importers: version: link:../graphile-bucket-provisioner-plugin/dist graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-bulk-mutations: specifier: workspace:^ version: link:../graphile-bulk-mutations/dist @@ -1295,7 +1312,7 @@ importers: version: link:../graphile-upload-plugin/dist graphile-utils: specifier: 5.0.1 - version: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + version: 5.0.1(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: specifier: 16.13.0 version: 16.13.0 @@ -1316,7 +1333,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -1357,10 +1374,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -1398,10 +1415,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -1419,7 +1436,7 @@ importers: version: link:../../postgres/pgsql-test/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) devDependencies: '@types/pg': specifier: ^8.20.0 @@ -1436,10 +1453,10 @@ importers: dependencies: graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -1448,7 +1465,7 @@ importers: version: 16.13.0 postgraphile: specifier: 5.0.3 - version: 5.0.3(f0ca433f04a3f28b00a29892b7510c70) + version: 5.0.3(65ecfcf3543e7c87a3dabce321c8b0d1) devDependencies: '@types/node': specifier: ^22.19.11 @@ -1587,10 +1604,10 @@ importers: version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-cache: specifier: workspace:^ version: link:../../graphile/graphile-cache/dist @@ -1602,7 +1619,7 @@ importers: version: link:../../graphile/graphile-settings/dist graphile-utils: specifier: 5.0.1 - version: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + version: 5.0.1(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: specifier: 16.13.0 version: 16.13.0 @@ -1620,7 +1637,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) devDependencies: '@types/express': specifier: ^5.0.6 @@ -1693,7 +1710,7 @@ importers: version: link:../../postgres/pg-env/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) devDependencies: '@types/express': specifier: ^5.0.6 @@ -1837,7 +1854,7 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -1858,7 +1875,7 @@ importers: version: 11.2.7 postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) devDependencies: makage: specifier: ^0.3.0 @@ -1910,10 +1927,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -1937,7 +1954,7 @@ importers: version: 8.20.0 postgraphile: specifier: 5.0.3 - version: 5.0.3(4b1f938b62eff937cb4c255aac53ddd9) + version: 5.0.3(260a0a3f37f6479930862ca6ba480e4c) ws: specifier: ^8.20.0 version: 8.20.1 @@ -1990,7 +2007,7 @@ importers: version: link:../../packages/url-domains/dist '@graphile-contrib/pg-many-to-many': specifier: 2.0.0-rc.2 - version: 2.0.0-rc.2 + version: 2.0.0-rc.2(patch_hash=71202d598d0d80e79890b8fbd8a1fc60cee20622dca60e59a0a6e994b6ab92c8) '@pgpmjs/env': specifier: workspace:^ version: link:../../pgpm/env/dist @@ -2023,10 +2040,10 @@ importers: version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-cache: specifier: workspace:^ version: link:../../graphile/graphile-cache/dist @@ -2044,7 +2061,7 @@ importers: version: link:../../graphile/graphile-settings/dist graphile-utils: specifier: 5.0.1 - version: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + version: 5.0.1(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: specifier: 16.13.0 version: 16.13.0 @@ -2071,7 +2088,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -2112,6 +2129,9 @@ importers: nodemon: specifier: ^3.1.14 version: 3.1.14 + pg-introspection: + specifier: 1.0.1 + version: 1.0.1(patch_hash=987dfb1b6491b9423e890d57722efdb618efce72183de5dd25c3fde11189fbc4) supertest: specifier: ^7.2.2 version: 7.2.2 @@ -2222,10 +2242,10 @@ importers: version: 1.0.2(graphql@16.13.0) graphile-build: specifier: 5.0.2 - version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + version: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-build-pg: specifier: 5.0.2 - version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + version: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: specifier: 1.0.1 version: 1.0.1 @@ -2249,7 +2269,7 @@ importers: version: link:../../postgres/pgsql-test/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(2fb27169ec8b91d10ab754f66c750f53) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -16381,7 +16401,7 @@ snapshots: grafast: 1.0.2(graphql@16.13.0) tslib: 2.8.1 - '@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)': + '@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)': dependencies: '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) '@graphile/lru': 5.0.0 @@ -16401,7 +16421,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)': + '@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)': dependencies: '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) '@graphile/lru': 5.0.0 @@ -16812,7 +16832,7 @@ snapshots: - supports-color - utf-8-validate - '@graphile-contrib/pg-many-to-many@2.0.0-rc.2': {} + '@graphile-contrib/pg-many-to-many@2.0.0-rc.2(patch_hash=71202d598d0d80e79890b8fbd8a1fc60cee20622dca60e59a0a6e994b6ab92c8)': {} '@graphile/lru@5.0.0': dependencies: @@ -20569,17 +20589,17 @@ snapshots: - supports-color - use-sync-external-store - graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1): + graphile-build-pg@5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1): dependencies: - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0) '@types/node': 22.19.19 debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-config: 1.0.1 graphql: 16.13.0 jsonwebtoken: 9.0.3 - pg-introspection: 1.0.1 + pg-introspection: 1.0.1(patch_hash=987dfb1b6491b9423e890d57722efdb618efce72183de5dd25c3fde11189fbc4) pg-sql2: 5.0.1 tamedevil: 0.1.1 tslib: 2.8.1 @@ -20588,17 +20608,17 @@ snapshots: transitivePeerDependencies: - supports-color - graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1): + graphile-build-pg@5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1): dependencies: - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@types/node': 22.19.19 debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-config: 1.0.1 graphql: 16.13.0 jsonwebtoken: 9.0.3 - pg-introspection: 1.0.1 + pg-introspection: 1.0.1(patch_hash=987dfb1b6491b9423e890d57722efdb618efce72183de5dd25c3fde11189fbc4) pg-sql2: 5.0.1 tamedevil: 0.1.1 tslib: 2.8.1 @@ -20607,7 +20627,7 @@ snapshots: transitivePeerDependencies: - supports-color - graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0): + graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0): dependencies: '@types/node': 22.19.19 '@types/pluralize': 0.0.33 @@ -20637,71 +20657,71 @@ snapshots: transitivePeerDependencies: - supports-color - graphile-connection-filter@1.14.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(postgraphile@5.0.3(bc368b92b12e127d4eb1f1e143433708)): + graphile-connection-filter@1.14.0(1cb56828c00532c0532fac2b893bbf72): dependencies: - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: 1.0.1 graphql: 16.13.0 pg-sql2: 5.0.1 - postgraphile: 5.0.3(bc368b92b12e127d4eb1f1e143433708) + postgraphile: 5.0.3(8e984f3e9cfc73b4b77b478748651d25) - graphile-connection-filter@1.14.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(postgraphile@5.0.3(f0ca433f04a3f28b00a29892b7510c70)): + graphile-connection-filter@1.14.0(3cdce88baf7cc47d1e7a2ba13375cd59): dependencies: - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: 1.0.1 graphql: 16.13.0 pg-sql2: 5.0.1 - postgraphile: 5.0.3(f0ca433f04a3f28b00a29892b7510c70) + postgraphile: 5.0.3(65ecfcf3543e7c87a3dabce321c8b0d1) - graphile-utils@5.0.0(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1): + graphile-utils@5.0.0(421ca45f0ba699c78ec7eea2d25ec7d4): dependencies: - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-config: 1.0.1 graphql: 16.13.0 json5: 2.2.3 tamedevil: 0.1.1 tslib: 2.8.1 optionalDependencies: - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) transitivePeerDependencies: - supports-color - graphile-utils@5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1): + graphile-utils@5.0.1(421ca45f0ba699c78ec7eea2d25ec7d4): dependencies: - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-config: 1.0.1 graphql: 16.13.0 json5: 2.2.3 tamedevil: 0.1.1 tslib: 2.8.1 optionalDependencies: - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) transitivePeerDependencies: - supports-color - graphile-utils@5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1): + graphile-utils@5.0.1(8232bac9ba396693c72a53ebcaff3457): dependencies: - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0) debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) graphile-config: 1.0.1 graphql: 16.13.0 json5: 2.2.3 tamedevil: 0.1.1 tslib: 2.8.1 optionalDependencies: - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1) transitivePeerDependencies: - supports-color @@ -22574,7 +22594,7 @@ snapshots: pg-int8@1.0.1: {} - pg-introspection@1.0.1: + pg-introspection@1.0.1(patch_hash=987dfb1b6491b9423e890d57722efdb618efce72183de5dd25c3fde11189fbc4): dependencies: tslib: 2.8.1 @@ -22712,24 +22732,24 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgraphile@5.0.3(02c18e7c6179c4ae54031f8cdca16ab5): + postgraphile@5.0.3(260a0a3f37f6479930862ca6ba480e4c): dependencies: '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0) '@graphile/lru': 5.0.0 '@types/node': 22.19.19 '@types/pg': 8.20.0 debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) grafserv: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1) graphile-config: 1.0.1 - graphile-utils: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + graphile-utils: 5.0.1(8232bac9ba396693c72a53ebcaff3457) graphql: 16.13.0 iterall: 1.3.0 jsonwebtoken: 9.0.3 - pg: 8.21.0 + pg: 8.20.0 pg-sql2: 5.0.1 tamedevil: 0.1.1 tslib: 2.8.1 @@ -22739,24 +22759,24 @@ snapshots: - supports-color - utf-8-validate - postgraphile@5.0.3(4b1f938b62eff937cb4c255aac53ddd9): + postgraphile@5.0.3(2fb27169ec8b91d10ab754f66c750f53): dependencies: '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@graphile/lru': 5.0.0 '@types/node': 22.19.19 '@types/pg': 8.20.0 debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) grafserv: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: 1.0.1 - graphile-utils: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.20.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + graphile-utils: 5.0.1(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: 16.13.0 iterall: 1.3.0 jsonwebtoken: 9.0.3 - pg: 8.20.0 + pg: 8.21.0 pg-sql2: 5.0.1 tamedevil: 0.1.1 tslib: 2.8.1 @@ -22766,20 +22786,20 @@ snapshots: - supports-color - utf-8-validate - postgraphile@5.0.3(bc368b92b12e127d4eb1f1e143433708): + postgraphile@5.0.3(65ecfcf3543e7c87a3dabce321c8b0d1): dependencies: '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@graphile/lru': 5.0.0 '@types/node': 22.19.19 '@types/pg': 8.20.0 debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) - grafserv: 1.0.0(@types/node@22.19.15)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + grafserv: 1.0.0(@types/node@22.19.11)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: 1.0.1 - graphile-utils: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + graphile-utils: 5.0.1(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: 16.13.0 iterall: 1.3.0 jsonwebtoken: 9.0.3 @@ -22793,20 +22813,20 @@ snapshots: - supports-color - utf-8-validate - postgraphile@5.0.3(f0a861a74cc4311fffaf615b439fe994): + postgraphile@5.0.3(8e984f3e9cfc73b4b77b478748651d25): dependencies: '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@graphile/lru': 5.0.0 '@types/node': 22.19.19 '@types/pg': 8.20.0 debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) - grafserv: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + grafserv: 1.0.0(@types/node@22.19.15)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: 1.0.1 - graphile-utils: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + graphile-utils: 5.0.1(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: 16.13.0 iterall: 1.3.0 jsonwebtoken: 9.0.3 @@ -22820,20 +22840,20 @@ snapshots: - supports-color - utf-8-validate - postgraphile@5.0.3(f0ca433f04a3f28b00a29892b7510c70): + postgraphile@5.0.3(df995cf62d46f35f4829b81730cdf6da): dependencies: '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@dataplan/pg': 1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) '@graphile/lru': 5.0.0 '@types/node': 22.19.19 '@types/pg': 8.20.0 debug: 4.4.3(supports-color@5.5.0) grafast: 1.0.2(graphql@16.13.0) - grafserv: 1.0.0(@types/node@22.19.11)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + grafserv: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) + graphile-build: 5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build-pg: 5.0.2(patch_hash=869428d66480f2dba792fb7055538a827edeacf0e34631da8f16b648005cb624)(@dataplan/pg@1.0.3(patch_hash=1580ee5de9b0792b9892233f0616ea4e2801b7f387a3fb73468de1e4dfaa76cb)(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(patch_hash=f53ea77ecb2de5d2a48441d8f9ce57f161bffba9a6f6e22d0dc853dccea5fce6)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) graphile-config: 1.0.1 - graphile-utils: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + graphile-utils: 5.0.1(421ca45f0ba699c78ec7eea2d25ec7d4) graphql: 16.13.0 iterall: 1.3.0 jsonwebtoken: 9.0.3