diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 7a89b17250..5a4b52fdf1 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -109,7 +109,7 @@ jobs: - batch: packages-core packages: 'packages/url-domains packages/coerce packages/csrf packages/oauth packages/12factor-env packages/orm packages/express-context packages/errors packages/llm-env packages/node-type-registry packages/query-spec packages/server-utils packages/site-deploy examples/site-deploy-ssg postgres/pg-cache postgres/pg-env' - batch: packages-services - packages: 'packages/postmaster packages/smtppostmaster packages/csv-to-pg packages/cli postgres/pgsql-client postgres/pg-ast' + packages: 'packages/postmaster packages/smtppostmaster packages/csv-to-pg packages/cli packages/perf-harness postgres/pgsql-client postgres/pg-ast' - batch: graphql packages: 'graphql/query graphql/codegen' - batch: graphile-unit diff --git a/graphile/graphile-bulk-mutations/src/__tests__/pg-client.test.ts b/graphile/graphile-bulk-mutations/src/__tests__/pg-client.test.ts new file mode 100644 index 0000000000..311bc55a8a --- /dev/null +++ b/graphile/graphile-bulk-mutations/src/__tests__/pg-client.test.ts @@ -0,0 +1,35 @@ +import { queryPgClient } from '../utils/pg-client'; + +describe('queryPgClient', () => { + it('uses the native @dataplan/pg query-config contract', async () => { + const query = jest.fn(async () => ({ rows: [{ id: 1 }], rowCount: 1 })); + const client = { query }; + + await expect( + queryPgClient<{ id: number }>( + client as never, + 'UPDATE app.items SET name = $1 RETURNING id', + ['updated'] + ) + ).resolves.toEqual({ rows: [{ id: 1 }], rowCount: 1 }); + + expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledWith({ + text: 'UPDATE app.items SET name = $1 RETURNING id', + values: ['updated'], + }); + }); + + it('preserves query failures', async () => { + const original = new Error('database rejected mutation'); + const client = { + query: jest.fn(async () => { + throw original; + }), + }; + + await expect( + queryPgClient(client as never, 'DELETE FROM app.items', []) + ).rejects.toBe(original); + }); +}); diff --git a/graphile/graphile-bulk-mutations/src/plugins/BulkDeletePlugin.ts b/graphile/graphile-bulk-mutations/src/plugins/BulkDeletePlugin.ts index 4731702283..17874d4757 100644 --- a/graphile/graphile-bulk-mutations/src/plugins/BulkDeletePlugin.ts +++ b/graphile/graphile-bulk-mutations/src/plugins/BulkDeletePlugin.ts @@ -1,9 +1,11 @@ import '../augmentations'; -import { sideEffectWithPgClient } from '@dataplan/pg'; +import { type PgClient, sideEffectWithPgClient } from '@dataplan/pg'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLInputType,GraphQLOutputType } from 'graphql'; +import { queryPgClient } from '../utils/pg-client'; + const version = '0.1.0'; /** @@ -105,7 +107,7 @@ export const BulkDeletePlugin: GraphileConfig.Plugin = { const $result = sideEffectWithPgClient( executor, $input, - async (pgClient: any, input: any) => { + async (pgClient: PgClient, input: any) => { if (requireWhere && (!input.where || Object.keys(input.where).length === 0)) { throw new Error( 'Bulk delete requires a non-empty where condition. Set bulkRequireWhere: false to allow unrestricted deletes.' @@ -194,12 +196,16 @@ export const BulkDeletePlugin: GraphileConfig.Plugin = { // Use RETURNING instead of RETURNING * // For delete, we capture PKs before rows are gone const text = `DELETE FROM ${compiledFrom}\nWHERE ${whereStr}\nRETURNING ${pkReturning}`; - const mutationResult = await pgClient.query(text, values); + const mutationResult = await queryPgClient>( + pgClient, + text, + values + ); const affectedCount = mutationResult.rowCount ?? 0; // For delete, rows no longer exist so we can't do a // follow-up SELECT. Return the PK values directly. - const returning = mutationResult.rows || []; + const returning = [...mutationResult.rows]; return { affectedCount, diff --git a/graphile/graphile-bulk-mutations/src/plugins/BulkInsertPlugin.ts b/graphile/graphile-bulk-mutations/src/plugins/BulkInsertPlugin.ts index 6b128024a1..9ef154e365 100644 --- a/graphile/graphile-bulk-mutations/src/plugins/BulkInsertPlugin.ts +++ b/graphile/graphile-bulk-mutations/src/plugins/BulkInsertPlugin.ts @@ -1,9 +1,10 @@ import '../augmentations'; -import { sideEffectWithPgClient } from '@dataplan/pg'; +import { type PgClient, sideEffectWithPgClient } from '@dataplan/pg'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLInputType, GraphQLOutputType } from 'graphql'; +import { queryPgClient } from '../utils/pg-client'; import type { NestedRelationInfo } from '../utils/relations'; import { discoverNestedRelations } from '../utils/relations'; import type { ColumnSpec } from '../utils/sql-builder'; @@ -131,7 +132,7 @@ export const BulkInsertPlugin: GraphileConfig.Plugin = { const $result = sideEffectWithPgClient( executor, $input, - async (pgClient: any, input: any) => { + async (pgClient: PgClient, input: any) => { const values = input.values; if (!values || !Array.isArray(values) || values.length === 0) { return { affectedCount: 0, returning: [] }; @@ -200,10 +201,9 @@ export const BulkInsertPlugin: GraphileConfig.Plugin = { const allPkRows: Record[] = []; for (const batch of batches) { - const result = await pgClient.query( - batch.text, - batch.values - ); + const result = await queryPgClient< + Record + >(pgClient, batch.text, batch.values); totalAffected += result.rowCount ?? 0; if (result.rows) { allPkRows.push(...result.rows); @@ -259,7 +259,8 @@ export const BulkInsertPlugin: GraphileConfig.Plugin = { ); for (const batch of childBatches) { - const result = await pgClient.query( + const result = await queryPgClient( + pgClient, batch.text, batch.values ); @@ -282,11 +283,12 @@ export const BulkInsertPlugin: GraphileConfig.Plugin = { const selectParams = allPkRows.flatMap((pkRow) => pkColumns.map((col) => pkRow[col]) ); - const selectResult = await pgClient.query( + const selectResult = await queryPgClient( + pgClient, `SELECT * FROM ${compiledFrom} WHERE ${whereClause}`, selectParams ); - returning = selectResult.rows || []; + returning = [...selectResult.rows]; } return { diff --git a/graphile/graphile-bulk-mutations/src/plugins/BulkUpdatePlugin.ts b/graphile/graphile-bulk-mutations/src/plugins/BulkUpdatePlugin.ts index 8ef1170067..4c57533e31 100644 --- a/graphile/graphile-bulk-mutations/src/plugins/BulkUpdatePlugin.ts +++ b/graphile/graphile-bulk-mutations/src/plugins/BulkUpdatePlugin.ts @@ -1,9 +1,11 @@ import '../augmentations'; -import { sideEffectWithPgClient } from '@dataplan/pg'; +import { type PgClient, sideEffectWithPgClient } from '@dataplan/pg'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLInputType, GraphQLOutputType } from 'graphql'; +import { queryPgClient } from '../utils/pg-client'; + const version = '0.1.0'; /** @@ -106,7 +108,7 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = { const $result = sideEffectWithPgClient( executor, $input, - async (pgClient: any, input: any) => { + async (pgClient: PgClient, input: any) => { if (requireWhere && (!input.where || Object.keys(input.where).length === 0)) { throw new Error( 'Bulk update requires a non-empty where condition. Set bulkRequireWhere: false to allow unrestricted updates.' @@ -212,13 +214,15 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = { // Use RETURNING instead of RETURNING * const text = `UPDATE ${compiledFrom}\nSET ${setClauses.join(', ')}\nWHERE ${whereStr}\nRETURNING ${pkReturning}`; - const mutationResult = await pgClient.query(text, values); + const mutationResult = await queryPgClient< + Record + >(pgClient, text, values); const affectedCount = mutationResult.rowCount ?? 0; // Follow-up SELECT using PKs to respect column-level grants let returning: unknown[] = []; if (mutationResult.rows && mutationResult.rows.length > 0) { - const pkRows: Record[] = mutationResult.rows; + const pkRows = mutationResult.rows; const pkConditions = pkRows.map((pkRow, rowIdx) => { return pkColumns.map((col, colIdx) => { const paramIdx = rowIdx * pkColumns.length + colIdx + 1; @@ -229,11 +233,12 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = { const selectParams = pkRows.flatMap((pkRow) => pkColumns.map((col) => pkRow[col]) ); - const selectResult = await pgClient.query( + const selectResult = await queryPgClient( + pgClient, `SELECT * FROM ${compiledFrom} WHERE ${selectWhere}`, selectParams ); - returning = selectResult.rows || []; + returning = [...selectResult.rows]; } return { diff --git a/graphile/graphile-bulk-mutations/src/plugins/BulkUpsertPlugin.ts b/graphile/graphile-bulk-mutations/src/plugins/BulkUpsertPlugin.ts index 4b261c7562..1996f48e89 100644 --- a/graphile/graphile-bulk-mutations/src/plugins/BulkUpsertPlugin.ts +++ b/graphile/graphile-bulk-mutations/src/plugins/BulkUpsertPlugin.ts @@ -1,9 +1,10 @@ import '../augmentations'; -import { sideEffectWithPgClient } from '@dataplan/pg'; +import { type PgClient, sideEffectWithPgClient } from '@dataplan/pg'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLInputType, GraphQLOutputType } from 'graphql'; +import { queryPgClient } from '../utils/pg-client'; import type { ColumnSpec } from '../utils/sql-builder'; import { buildBulkInsertSQL } from '../utils/sql-builder'; @@ -118,7 +119,7 @@ export const BulkUpsertPlugin: GraphileConfig.Plugin = { const $result = sideEffectWithPgClient( executor, $input, - async (pgClient: any, input: any) => { + async (pgClient: PgClient, input: any) => { const values = input.values; if (!values || !Array.isArray(values) || values.length === 0) { return { affectedCount: 0, returning: [] }; @@ -177,10 +178,9 @@ export const BulkUpsertPlugin: GraphileConfig.Plugin = { const allPkRows: Record[] = []; for (const batch of batches) { - const result = await pgClient.query( - batch.text, - batch.values - ); + const result = await queryPgClient< + Record + >(pgClient, batch.text, batch.values); totalAffected += result.rowCount ?? 0; if (result.rows) { allPkRows.push(...result.rows); @@ -200,11 +200,12 @@ export const BulkUpsertPlugin: GraphileConfig.Plugin = { const selectParams = allPkRows.flatMap((pkRow) => pkColumns.map((col) => pkRow[col]) ); - const selectResult = await pgClient.query( + const selectResult = await queryPgClient( + pgClient, `SELECT * FROM ${compiledFrom} WHERE ${whereClause}`, selectParams ); - returning = selectResult.rows || []; + returning = [...selectResult.rows]; } return { diff --git a/graphile/graphile-bulk-mutations/src/utils/pg-client.ts b/graphile/graphile-bulk-mutations/src/utils/pg-client.ts new file mode 100644 index 0000000000..306d599d89 --- /dev/null +++ b/graphile/graphile-bulk-mutations/src/utils/pg-client.ts @@ -0,0 +1,10 @@ +import type { PgClient, PgClientResult } from '@dataplan/pg'; + +/** Execute SQL using @dataplan/pg's native query-config contract. */ +export function queryPgClient( + client: Pick, + text: string, + values: any[] +): Promise> { + return client.query({ text, values }); +} diff --git a/graphile/graphile-i18n/package.json b/graphile/graphile-i18n/package.json index 3d449c21fe..d3bc23f2df 100644 --- a/graphile/graphile-i18n/package.json +++ b/graphile/graphile-i18n/package.json @@ -1,7 +1,7 @@ { "name": "graphile-i18n", "version": "2.15.0", - "description": "PostGraphile v5 i18n plugin — language-aware fields from @i18n translation tables with Accept-Language negotiation and fallback chains", + "description": "PostGraphile v5 i18n plugin \u2014 language-aware fields from @i18n translation tables with Accept-Language negotiation and fallback chains", "author": "Constructive ", "homepage": "https://github.com/constructive-io/constructive", "license": "MIT", @@ -30,7 +30,8 @@ }, "dependencies": { "accept-language-parser": "^1.5.0", - "graphile-plugin-utils": "workspace:^" + "graphile-plugin-utils": "workspace:^", + "@constructive-io/express-context": "workspace:^" }, "peerDependencies": { "@dataplan/pg": "^1.1.1", diff --git a/graphile/graphile-i18n/src/__tests__/i18n.test.ts b/graphile/graphile-i18n/src/__tests__/i18n.test.ts index 4435813e32..58811f96c0 100644 --- a/graphile/graphile-i18n/src/__tests__/i18n.test.ts +++ b/graphile/graphile-i18n/src/__tests__/i18n.test.ts @@ -8,6 +8,7 @@ * - Fallback to base table values when no translation exists */ +import { buildPgSettings } from '@constructive-io/express-context'; import type { GraphQLResponse } from 'graphile-test'; import { getConnections, seed } from 'graphile-test'; import { join } from 'path'; @@ -70,7 +71,28 @@ describe('graphile-i18n plugin', () => { db = connections.db; teardown = connections.teardown; - query = connections.query; + const baseQuery: QueryFn = connections.query; + const canonicalSettings = buildPgSettings({ + api: { + apiId: 'i18n-test-api', + databaseId: 'i18n-test-database', + dbname: 'i18n_test', + anonRole: 'postgres', + roleName: 'postgres', + schema: ['i18n_test'], + }, + token: { user_id: 'i18n-test-user' }, + requestId: 'i18n-test-request', + }); + query = (document, variables, commit, reqOptions = {}) => + baseQuery(document, variables, commit, { + ...reqOptions, + pgSettings: { + ...canonicalSettings, + ...((reqOptions.pgSettings as Record | undefined) ?? + {}), + }, + }); }); afterAll(async () => { diff --git a/graphile/graphile-i18n/src/__tests__/pg-query.test.ts b/graphile/graphile-i18n/src/__tests__/pg-query.test.ts new file mode 100644 index 0000000000..ba4c2b9ada --- /dev/null +++ b/graphile/graphile-i18n/src/__tests__/pg-query.test.ts @@ -0,0 +1,79 @@ +import { buildPgSettings } from '@constructive-io/express-context'; + +import { queryI18nWithContext } from '../pg-query'; + +const pgSettings = buildPgSettings({ + api: { + apiId: 'api-1', + databaseId: 'database-1', + dbname: 'testdb', + anonRole: 'anonymous_runtime', + roleName: 'authenticated_runtime', + schema: ['i18n_test'], + }, + token: { user_id: 'user-1' }, + requestId: 'request-1', +}); + +describe('queryI18nWithContext', () => { + it('passes the complete settings unchanged and uses the native query contract', async () => { + const original = { ...pgSettings }; + const query = jest.fn(async () => ({ + rows: [{ lang_code: 'en', title: 'Hello' }], + })); + const withPgClient = jest.fn(async (settings, callback) => + callback({ query }) + ); + + await expect( + queryI18nWithContext( + withPgClient, + pgSettings, + 1, + 'SELECT translation WHERE id = $1 AND lang = ANY($2)', + [1, ['en']] + ) + ).resolves.toEqual({ lang_code: 'en', title: 'Hello' }); + + expect(withPgClient).toHaveBeenCalledWith(pgSettings, expect.any(Function)); + expect(withPgClient.mock.calls[0][0]).not.toBeNull(); + expect(query).toHaveBeenCalledWith({ + text: 'SELECT translation WHERE id = $1 AND lang = ANY($2)', + values: [1, ['en']], + }); + expect(pgSettings).toEqual(original); + }); + + it.each([ + [ + 'missing withPgClient', + undefined, + pgSettings, + 1, + 'I18N_PG_CLIENT_CONTEXT_UNAVAILABLE', + ], + ['missing pgSettings', jest.fn(), undefined, 1, 'i18n pgSettings'], + [ + 'incomplete pgSettings', + jest.fn(), + { role: 'anonymous_runtime' }, + 1, + 'i18n pgSettings', + ], + ])( + 'fails closed for %s', + async (_label, withPgClient, settings, id, message) => { + await expect( + queryI18nWithContext(withPgClient, settings, id, 'SELECT 1', []) + ).rejects.toThrow(message); + } + ); + + it('preserves the base-row fallback when the parent id is unavailable', async () => { + const withPgClient = jest.fn(); + await expect( + queryI18nWithContext(withPgClient, pgSettings, null, 'SELECT 1', []) + ).resolves.toBeNull(); + expect(withPgClient).not.toHaveBeenCalled(); + }); +}); diff --git a/graphile/graphile-i18n/src/pg-query.ts b/graphile/graphile-i18n/src/pg-query.ts new file mode 100644 index 0000000000..c29df27358 --- /dev/null +++ b/graphile/graphile-i18n/src/pg-query.ts @@ -0,0 +1,36 @@ +import { + assertCompletePgSettings, + type PgSettings, +} from '@constructive-io/express-context'; +import type { PgClient } from '@dataplan/pg'; + +export type GraphileWithPgClient = ( + pgSettings: PgSettings, + callback: (client: PgClient) => Promise +) => Promise; + +export async function queryI18nWithContext( + withPgClient: unknown, + pgSettings: unknown, + id: unknown, + text: string, + values: any[] +): Promise | null> { + if (typeof withPgClient !== 'function') { + throw new Error('I18N_PG_CLIENT_CONTEXT_UNAVAILABLE'); + } + assertCompletePgSettings(pgSettings, 'i18n pgSettings'); + if (id === null || id === undefined) { + // Preserve the plugin's existing base-row fallback when the parent has no + // usable key; there is no request-lane SQL to authorize in this case. + return null; + } + + return (withPgClient as GraphileWithPgClient)(pgSettings, async (client) => { + const { rows } = await client.query>({ + text, + values, + }); + return rows[0] ?? null; + }); +} diff --git a/graphile/graphile-i18n/src/plugin.ts b/graphile/graphile-i18n/src/plugin.ts index 9226830a7a..408bc76fe5 100644 --- a/graphile/graphile-i18n/src/plugin.ts +++ b/graphile/graphile-i18n/src/plugin.ts @@ -24,8 +24,8 @@ import type { PgCodecWithAttributes } from '@dataplan/pg'; import { TYPES } from '@dataplan/pg'; import { context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; -import { withSystemLaneClient } from 'graphile-plugin-utils'; +import { queryI18nWithContext } from './pg-query'; import type { I18nPluginOptions, I18nTableInfo, TranslatableField } from './types'; // ─── Namespace Augmentations ───────────────────────────────────────────────── @@ -267,35 +267,29 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi $baseCols[column] = $parent.get(column); } const $withPgClient = (grafastContext() as any).get('withPgClient'); + const $pgSettings = (grafastContext() as any).get('pgSettings'); const $langCodes = (grafastContext() as any).get('langCodes'); // Combine all inputs into a single step const $input = object({ id: $id, withPgClient: $withPgClient, + pgSettings: $pgSettings, langCodes: $langCodes, ...$baseCols, }); return lambda($input, async (input: any) => { - const { id, withPgClient, langCodes: ctxLangCodes, ...baseCols } = input; + const { id, withPgClient, pgSettings, langCodes: ctxLangCodes, ...baseCols } = input; const langs: string[] = ctxLangCodes ?? defaultLanguages; - if (!withPgClient || !id) { - const result: Record = { [langCodeGqlField]: null }; - for (const { gqlName, column } of baseColNames) { - result[gqlName] = baseCols[column] ?? null; - } - return result; - } - - // Translation lookup is a server-side read, so it runs in the - // system lane's bounded role inside one transaction rather - // than inheriting the pool's connecting role. - const row = await withSystemLaneClient(withPgClient, async (client) => { - const { rows } = await client.query({ text: sqlQuery, values: [id, langs] }); - return rows[0] ?? null; - }); + const row = await queryI18nWithContext( + withPgClient, + pgSettings, + id, + sqlQuery, + [id, langs] + ); if (!row) { const result: Record = { [langCodeGqlField]: null }; diff --git a/graphile/graphile-llm/src/__tests__/graphile-llm.test.ts b/graphile/graphile-llm/src/__tests__/graphile-llm.test.ts index 30f2b58439..b584106643 100644 --- a/graphile/graphile-llm/src/__tests__/graphile-llm.test.ts +++ b/graphile/graphile-llm/src/__tests__/graphile-llm.test.ts @@ -1,4 +1,5 @@ import OllamaClient from '@agentic-kit/ollama'; +import { buildPgSettings } from '@constructive-io/express-context'; import type { GraphileConfig } from 'graphile-config'; import { ConnectionFilterPreset } from 'graphile-connection-filter'; import { createPgvectorAdapter } from 'graphile-search/adapters/pgvector'; @@ -45,6 +46,8 @@ async function ensureNomicModel(): Promise { type QueryFn = ( query: string, variables?: Record, + commit?: boolean, + reqOptions?: Record ) => Promise>; // ============================================================================= @@ -173,7 +176,31 @@ describe('graphile-llm schema enrichment', () => { db = connections.db; teardown = connections.teardown; - query = connections.query; + const baseQuery: QueryFn = connections.query; + const canonicalSettings = buildPgSettings({ + api: { + apiId: 'llm-rag-test-api', + databaseId: 'llm-rag-test-database', + dbname: 'llm_test', + anonRole: 'postgres', + roleName: 'postgres', + schema: ['llm_test'], + }, + token: { user_id: 'llm-rag-test-user' }, + requestId: 'llm-rag-test-request', + // The fixture installs pgvector in public; request search_path is explicit. + dependencySchemas: ['public'], + }); + query = ( + document: string, + variables?: Record, + commit?: boolean, + reqOptions: Record = {} + ) => + baseQuery(document, variables, commit, { + ...reqOptions, + pgSettings: canonicalSettings, + }); }); afterAll(async () => { @@ -706,7 +733,31 @@ describe('RAG plugin schema enrichment', () => { db = connections.db; teardown = connections.teardown; - query = connections.query; + const baseQuery: QueryFn = connections.query; + const canonicalSettings = buildPgSettings({ + api: { + apiId: 'llm-rag-test-api', + databaseId: 'llm-rag-test-database', + dbname: 'llm_test', + anonRole: 'postgres', + roleName: 'postgres', + schema: ['llm_test'], + }, + token: { user_id: 'llm-rag-test-user' }, + requestId: 'llm-rag-test-request', + // The fixture installs pgvector in public; request search_path is explicit. + dependencySchemas: ['public'], + }); + query = ( + document: string, + variables?: Record, + commit?: boolean, + reqOptions: Record = {} + ) => + baseQuery(document, variables, commit, { + ...reqOptions, + pgSettings: canonicalSettings, + }); }); afterAll(async () => { diff --git a/graphile/graphile-llm/src/__tests__/request-context.test.ts b/graphile/graphile-llm/src/__tests__/request-context.test.ts new file mode 100644 index 0000000000..34c483b439 --- /dev/null +++ b/graphile/graphile-llm/src/__tests__/request-context.test.ts @@ -0,0 +1,169 @@ +import { buildPgSettings } from '@constructive-io/express-context'; + +import { + getLlmBillingConfig, + invalidateLlmBillingConfig, +} from '../config-cache'; +import { buildMeteringContext } from '../plugins/metering-plugin'; +import { withGraphileRequestPgClient } from '../request-context'; + +const api = { + apiId: 'api-1', + databaseId: 'database-1', + dbname: 'tenant_db', + anonRole: 'anonymous_runtime', + roleName: 'authenticated_runtime', + schema: ['app_public'], +}; + +const pgSettings = buildPgSettings({ + api, + token: { user_id: 'user-1' }, + requestId: 'request-1', +}); + +describe('graphile-llm request context', () => { + afterEach(() => invalidateLlmBillingConfig()); + + it('passes complete settings unchanged and uses a native PgClient callback', async () => { + const original = { ...pgSettings }; + const query = jest.fn(async () => ({ rows: [{ ok: true }], rowCount: 1 })); + const withPgClient = jest.fn(async (settings, callback) => + callback({ query }) + ); + + await expect( + withGraphileRequestPgClient( + withPgClient, + pgSettings, + async (client) => + client.query({ text: 'SELECT $1::text', values: ['ok'] }), + 'RAG' + ) + ).resolves.toMatchObject({ rows: [{ ok: true }] }); + + expect(withPgClient).toHaveBeenCalledWith(pgSettings, expect.any(Function)); + expect(query).toHaveBeenCalledWith({ + text: 'SELECT $1::text', + values: ['ok'], + }); + expect(pgSettings).toEqual(original); + }); + + it.each([ + [ + 'missing withPgClient', + undefined, + pgSettings, + 'RAG_PG_CLIENT_CONTEXT_UNAVAILABLE', + ], + ['missing pgSettings', jest.fn(), undefined, 'RAG pgSettings'], + [ + 'incomplete pgSettings', + jest.fn(), + { role: 'anonymous_runtime' }, + 'RAG pgSettings', + ], + ])('fails closed for %s', async (_label, withPgClient, settings, message) => { + await expect( + withGraphileRequestPgClient( + withPgClient, + settings, + async (): Promise => undefined, + 'RAG' + ) + ).rejects.toThrow(message); + }); + + it('uses native query configs for metering metadata resolution', async () => { + const query = jest.fn(async ({ text }: { text: string }) => { + if (text.includes('to_regclass')) { + return { rows: [{ relation: 'provisioned' }], rowCount: 1 }; + } + if (text.includes('billing_module')) { + return { + rows: [ + { + public_schema: 'billing_public', + private_schema: 'billing_private', + record_usage_function: 'record_usage', + }, + ], + rowCount: 1, + }; + } + return { + rows: [{ schema: 'log_private', table_name: 'usage_log_inference' }], + rowCount: 1, + }; + }); + + await expect( + getLlmBillingConfig({ query } as never, 'database-native-contract') + ).resolves.toMatchObject({ + billing: { recordUsageFunction: 'record_usage' }, + inferenceLog: { tableName: 'usage_log_inference' }, + }); + + expect(query).toHaveBeenCalledTimes(4); + for (const [queryConfig] of query.mock.calls) { + expect(queryConfig).toEqual({ + text: expect.any(String), + values: expect.any(Array), + }); + } + }); + + it('preserves metering metadata query failures', async () => { + const original = new Error('metadata query failed'); + const query = jest.fn(async () => { + throw original; + }); + + await expect( + getLlmBillingConfig({ query } as never, 'database-error-contract') + ).rejects.toBe(original); + }); + + it('treats absent optional module relations as unprovisioned', async () => { + const query = jest.fn(async () => ({ + rows: [{ relation: null as string | null }], + rowCount: 1, + })); + + await expect( + getLlmBillingConfig({ query } as never, 'database-unprovisioned-contract') + ).resolves.toEqual({ billing: null, inferenceLog: null }); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('fails closed for invalid metering context but stays optional without identity', async () => { + await expect( + buildMeteringContext( + { pgSettings }, + (settings) => settings['jwt.claims.user_id'] || null + ) + ).rejects.toThrow('LLM_METERING_PG_CLIENT_CONTEXT_UNAVAILABLE'); + + await expect( + buildMeteringContext( + { pgSettings: { role: 'anonymous_runtime' }, withPgClient: jest.fn() }, + () => null + ) + ).rejects.toThrow('LLM_METERING pgSettings'); + + const anonymousSettings = buildPgSettings({ + api, + token: null, + requestId: 'request-anonymous', + }); + const withPgClient = jest.fn(); + await expect( + buildMeteringContext( + { pgSettings: anonymousSettings, withPgClient }, + (settings) => settings['jwt.claims.user_id'] || null + ) + ).resolves.toBeNull(); + expect(withPgClient).not.toHaveBeenCalled(); + }); +}); diff --git a/graphile/graphile-llm/src/config-cache.ts b/graphile/graphile-llm/src/config-cache.ts index c3a5ae82fb..f3b0784ae2 100644 --- a/graphile/graphile-llm/src/config-cache.ts +++ b/graphile/graphile-llm/src/config-cache.ts @@ -17,6 +17,7 @@ * billing piece. */ +import type { PgClient as DataplanPgClient } from '@dataplan/pg'; import { ModuleConfigCache } from 'graphile-cache'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -25,9 +26,7 @@ import { ModuleConfigCache } from 'graphile-cache'; * Generic pg client interface matching what Graphile's withPgClient provides. * Avoids a hard dependency on the `pg` package. */ -export interface PgClient { - query(sql: string, values?: unknown[]): Promise<{ rows: Record[] }>; -} +export type PgClient = DataplanPgClient; /** * Billing function metadata resolved from the billing_module metaschema table. @@ -105,61 +104,64 @@ const billingCache = new ModuleConfigCache({ // ─── Resolution Functions ─────────────────────────────────────────────────── -/** - * SQL to check if a schema exists. Used as a guard before querying - * metaschema tables that may not be provisioned. - */ -const SCHEMA_EXISTS_SQL = ` - SELECT 1 FROM information_schema.schemata WHERE schema_name = $1 LIMIT 1 +/** Check the exact optional module relation before querying it. */ +const RELATION_EXISTS_SQL = ` + SELECT pg_catalog.to_regclass($1) AS relation `; async function resolveInferenceLogConfig( pgClient: PgClient, databaseId: string ): Promise { - try { - const schemaCheck = await pgClient.query(SCHEMA_EXISTS_SQL, ['metaschema_modules_public']); - if (schemaCheck.rows.length === 0) return null; - - const result = await pgClient.query(INFERENCE_LOG_MODULE_SQL, [databaseId]); - const row = result.rows[0]; - if (!row?.schema || !row?.table_name) return null; - - return { - schema: row.schema as string, - tableName: row.table_name as string - }; - } catch { - return null; + const relationCheck = await pgClient.query<{ relation: string | null }>({ + text: RELATION_EXISTS_SQL, + values: ['metaschema_modules_public.inference_log_module'], + }); + if (!relationCheck.rows[0]?.relation) return null; + + const result = await pgClient.query>({ + text: INFERENCE_LOG_MODULE_SQL, + values: [databaseId], + }); + const row = result.rows[0]; + if (!row) return null; + if (!row.schema || !row.table_name) { + throw new Error('LLM_INFERENCE_LOG_CONFIG_INCOMPLETE'); } + + return { + schema: row.schema as string, + tableName: row.table_name as string, + }; } async function resolveBillingConfig( pgClient: PgClient, databaseId: string ): Promise { - try { - // Guard: check if the metaschema_modules_public schema exists. - // If the database doesn't have the billing module provisioned, - // this schema (or the billing_module table) won't exist. - const schemaCheck = await pgClient.query(SCHEMA_EXISTS_SQL, ['metaschema_modules_public']); - if (schemaCheck.rows.length === 0) return null; - - const result = await pgClient.query(BILLING_MODULE_SQL, [databaseId]); - const row = result.rows[0]; - if (!row?.record_usage_function) return null; - - return { - publicSchema: row.public_schema as string, - privateSchema: row.private_schema as string, - recordUsageFunction: row.record_usage_function as string, - // The check_billing_quota function name follows the inflection pattern - checkBillingQuotaFunction: 'check_billing_quota' - }; - } catch { - // Schema/table doesn't exist or query failed — billing not available - return null; + const relationCheck = await pgClient.query<{ relation: string | null }>({ + text: RELATION_EXISTS_SQL, + values: ['metaschema_modules_public.billing_module'], + }); + if (!relationCheck.rows[0]?.relation) return null; + + const result = await pgClient.query>({ + text: BILLING_MODULE_SQL, + values: [databaseId], + }); + const row = result.rows[0]; + if (!row) return null; + if (!row.public_schema || !row.private_schema || !row.record_usage_function) { + throw new Error('LLM_BILLING_CONFIG_INCOMPLETE'); } + + return { + publicSchema: row.public_schema as string, + privateSchema: row.private_schema as string, + recordUsageFunction: row.record_usage_function as string, + // The check_billing_quota function name follows the inflection pattern + checkBillingQuotaFunction: 'check_billing_quota', + }; } // ─── Public API ───────────────────────────────────────────────────────────── diff --git a/graphile/graphile-llm/src/plugins/metering-plugin.ts b/graphile/graphile-llm/src/plugins/metering-plugin.ts index 754f4aabfd..78c8c89f17 100644 --- a/graphile/graphile-llm/src/plugins/metering-plugin.ts +++ b/graphile/graphile-llm/src/plugins/metering-plugin.ts @@ -39,6 +39,10 @@ import type { PgClient } from '../config-cache'; import { getLlmBillingConfig } from '../config-cache'; import type { MeteringContext, MeteringOptions, WithPgClient } from '../metering'; import { meteredEmbed } from '../metering'; +import { + assertGraphileRequestContext, + withGraphileRequestPgClient, +} from '../request-context'; import type { EmbedderFunction, MeteringConfig } from '../types'; // ─── TypeScript Augmentation ──────────────────────────────────────────────── @@ -61,31 +65,35 @@ function defaultResolveEntityId(pgSettings: Record): string | nu return pgSettings['jwt.claims.user_id'] ?? null; } -async function buildMeteringContext( +export async function buildMeteringContext( graphqlContext: any, resolveEntityId: (pgSettings: Record) => string | null ): Promise { - const pgSettings: Record = graphqlContext?.pgSettings ?? {}; + const pgSettings = graphqlContext?.pgSettings; + // Metering is a request plugin; malformed or missing request context is not + // equivalent to an unprovisioned optional billing module. + const withPgClient: WithPgClient | undefined = graphqlContext?.withPgClient; + // Validate before reading identity so an absent context cannot silently + // downgrade a request to the unmetered path. + assertGraphileRequestContext(withPgClient, pgSettings, 'LLM_METERING'); const entityId = resolveEntityId(pgSettings); const databaseId = pgSettings['jwt.claims.database_id'] ?? null; const requestId = pgSettings['request.id'] ?? null; const actorId = pgSettings['jwt.claims.user_id'] ?? null; if (!entityId || !databaseId) return null; - const withPgClient: WithPgClient | undefined = graphqlContext?.withPgClient; - if (!withPgClient) return null; - let billingConfig = null; let inferenceLogConfig = null; - try { - await withPgClient(pgSettings, async (pgClient: PgClient) => { + await withGraphileRequestPgClient( + withPgClient, + pgSettings, + async (pgClient: PgClient) => { const entry = await getLlmBillingConfig(pgClient, databaseId); billingConfig = entry.billing; inferenceLogConfig = entry.inferenceLog; - }); - } catch { - return null; - } + }, + 'LLM_METERING' + ); if (!billingConfig) return null; diff --git a/graphile/graphile-llm/src/plugins/rag-plugin.ts b/graphile/graphile-llm/src/plugins/rag-plugin.ts index 3c1a3e15cf..6f9b9e8b7d 100644 --- a/graphile/graphile-llm/src/plugins/rag-plugin.ts +++ b/graphile/graphile-llm/src/plugins/rag-plugin.ts @@ -24,7 +24,13 @@ import { context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; import { extendSchema, gql } from 'graphile-utils'; -import type { ChatFunction, ChunkTableInfo, EmbedderFunction, RagDefaults } from '../types'; +import { withGraphileRequestPgClient } from '../request-context'; +import type { + ChatFunction, + ChunkTableInfo, + EmbedderFunction, + RagDefaults, +} from '../types'; // ─── TypeScript Augmentation ──────────────────────────────────────────────── @@ -307,20 +313,34 @@ export function createLlmRagPlugin( }> = []; if (chunkTables.length > 0) { - await withPgClient(pgSettings, async (pgClient: any) => { - for (const table of chunkTables) { - const query = buildChunkSearchSql(table, vectorString, limit, maxDistance); - const result = await pgClient.query(query); - for (const row of result.rows) { - allChunks.push({ - content: row.content, - parent_id: row.parent_id, - distance: parseFloat(row.distance), - table_name: table.parentCodecName - }); + await withGraphileRequestPgClient( + withPgClient, + pgSettings, + async (pgClient) => { + for (const table of chunkTables) { + const query = buildChunkSearchSql( + table, + vectorString, + limit, + maxDistance + ); + const result = await pgClient.query<{ + content: string; + parent_id: string; + distance: string; + }>(query); + for (const row of result.rows) { + allChunks.push({ + content: row.content, + parent_id: row.parent_id, + distance: parseFloat(row.distance), + table_name: table.parentCodecName, + }); + } } - } - }); + }, + 'RAG' + ); } // Sort by distance (ascending) and take top N diff --git a/graphile/graphile-llm/src/request-context.ts b/graphile/graphile-llm/src/request-context.ts new file mode 100644 index 0000000000..0e933250f5 --- /dev/null +++ b/graphile/graphile-llm/src/request-context.ts @@ -0,0 +1,27 @@ +import { + assertCompletePgSettings, + type PgSettings, +} from '@constructive-io/express-context'; +import type { PgClient, WithPgClient } from '@dataplan/pg'; + +export function assertGraphileRequestContext( + withPgClient: unknown, + pgSettings: unknown, + label: string +): asserts pgSettings is PgSettings { + if (typeof withPgClient !== 'function') { + throw new Error(`${label}_PG_CLIENT_CONTEXT_UNAVAILABLE`); + } + assertCompletePgSettings(pgSettings, `${label} pgSettings`); +} + +/** Run request-lane SQL with the complete Graphile request settings. */ +export async function withGraphileRequestPgClient( + withPgClient: unknown, + pgSettings: unknown, + callback: (client: PgClient) => T | Promise, + label: string +): Promise { + assertGraphileRequestContext(withPgClient, pgSettings, label); + return (withPgClient as WithPgClient)(pgSettings as PgSettings, callback); +} diff --git a/graphile/graphile-settings/__tests__/PublicKeySignature.test.ts b/graphile/graphile-settings/__tests__/PublicKeySignature.test.ts index cc833a3333..e7f55b3acb 100644 --- a/graphile/graphile-settings/__tests__/PublicKeySignature.test.ts +++ b/graphile/graphile-settings/__tests__/PublicKeySignature.test.ts @@ -1,8 +1,15 @@ +import { buildPgSettings } from '@constructive-io/express-context'; + import type { PublicKeyChallengeConfig } from '../src/plugins/PublicKeySignature'; -import { PublicKeySignature } from '../src/plugins/PublicKeySignature'; +import { + PublicKeySignature, + queryPublicKeyFunction, + withAnonymousPublicKeyClient, +} from '../src/plugins/PublicKeySignature'; const defaultConfig: PublicKeyChallengeConfig = { schema: 'app_private', + anonymousRole: 'anonymous_runtime', crypto_network: 'btc', sign_up_with_key: 'sign_up_with_key', sign_in_request_challenge: 'sign_in_request_challenge', @@ -29,6 +36,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 +67,15 @@ describe('PublicKeySignature config validation', () => { expect(() => PublicKeySignature({ ...defaultConfig, schema: 'DROP TABLE' })).toThrow(/invalid schema/); }); + it('throws on invalid anonymous role', () => { + expect(() => + PublicKeySignature({ + ...defaultConfig, + anonymousRole: 'anonymous; RESET ALL', + }) + ).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 +104,120 @@ describe('PublicKeySignature config validation', () => { expect(() => PublicKeySignature(defaultConfig)).not.toThrow(); }); }); + +describe('PublicKeySignature request context', () => { + const pgSettings = buildPgSettings({ + api: { + apiId: 'api-1', + databaseId: 'database-1', + dbname: 'tenant_db', + anonRole: 'anonymous_runtime', + roleName: 'authenticated_runtime', + schema: ['app_public'], + }, + token: { + id: 'token-1', + user_id: 'user-1', + entity_id: 'entity-1', + }, + requestId: 'request-1', + dependencySchemas: ['app_shared'], + }); + + it('copies every setting and replaces only the role', async () => { + const original = { ...pgSettings }; + const query = jest.fn(); + const callback = jest.fn(async () => 'ok'); + const withPgClient = jest.fn(async (settings, fn) => fn({ query })); + + await expect( + withAnonymousPublicKeyClient( + withPgClient, + pgSettings, + 'anonymous_runtime', + callback + ) + ).resolves.toBe('ok'); + + const anonymousSettings = withPgClient.mock.calls[0][0]; + expect(anonymousSettings).toEqual({ + ...pgSettings, + role: 'anonymous_runtime', + }); + expect(anonymousSettings).not.toBe(pgSettings); + expect(Object.keys(anonymousSettings)).toEqual(Object.keys(pgSettings)); + expect(anonymousSettings['jwt.claims.api_id']).toBe('api-1'); + expect(anonymousSettings['jwt.claims.database_id']).toBe('database-1'); + expect(anonymousSettings['request.id']).toBe('request-1'); + expect(anonymousSettings.row_security).toBe('on'); + expect(anonymousSettings.search_path).toBe( + 'pg_catalog, "app_shared", "app_public"' + ); + expect(anonymousSettings['jwt.claims.email']).toBe(''); + expect(pgSettings).toEqual(original); + }); + + it.each([ + [ + 'missing withPgClient', + undefined, + pgSettings, + 'PUBLIC_KEY_PG_CLIENT_CONTEXT_UNAVAILABLE', + ], + ['missing settings', jest.fn(), undefined, 'PublicKeySignature pgSettings'], + ['null settings', jest.fn(), null, 'PublicKeySignature pgSettings'], + ['array settings', jest.fn(), [], 'PublicKeySignature pgSettings'], + [ + 'incomplete settings', + jest.fn(), + { role: 'authenticated_runtime' }, + 'PublicKeySignature pgSettings', + ], + ])('fails closed for %s', async (_label, withPgClient, settings, message) => { + await expect( + withAnonymousPublicKeyClient( + withPgClient, + settings, + 'anonymous_runtime', + async (): Promise => undefined + ) + ).rejects.toThrow(message); + }); + + it('uses the native query config and validates database identifiers', async () => { + const query = jest.fn(async () => ({ + rows: [{ sign_in_request_challenge: 'challenge' }], + rowCount: 1, + })); + + await expect( + queryPublicKeyFunction( + { query } as never, + 'app_private', + 'sign_in_request_challenge', + ['public-key'] + ) + ).resolves.toMatchObject({ rowCount: 1 }); + + expect(query).toHaveBeenCalledWith({ + text: 'SELECT * FROM app_private.sign_in_request_challenge($1)', + values: ['public-key'], + }); + expect(() => + queryPublicKeyFunction( + { query } as never, + 'unsafe.schema', + 'sign_in_request_challenge', + [] + ) + ).toThrow(/invalid schema/); + expect(() => + queryPublicKeyFunction( + { query } as never, + 'app_private', + 'unsafe_function()', + [] + ) + ).toThrow(/invalid function/); + }); +}); diff --git a/graphile/graphile-settings/__tests__/request-context.integration.test.ts b/graphile/graphile-settings/__tests__/request-context.integration.test.ts new file mode 100644 index 0000000000..54e5fefa37 --- /dev/null +++ b/graphile/graphile-settings/__tests__/request-context.integration.test.ts @@ -0,0 +1,344 @@ +import { join } from 'node:path'; + +import { buildPgSettings } from '@constructive-io/express-context'; +import { createI18nPlugin } from 'graphile-i18n'; +import type { GraphQLResponse } from 'graphile-test'; +import { getConnections, seed } from 'graphile-test'; + +import { PublicKeySignature } from '../src/plugins/PublicKeySignature'; + +const api = { + apiId: 'api-1', + databaseId: 'database-1', + dbname: 'request_context_db', + anonRole: 'anonymous', + roleName: 'authenticated', + schema: ['request_context_test'], +}; + +const settings = (userId: string | null, requestId: string) => + buildPgSettings({ + api, + token: userId ? { id: 'token-1', user_id: userId } : null, + requestId, + }); + +describe('complete Graphile request context integration', () => { + let db: any; + let teardown: () => Promise; + let query: ( + document: string, + variables?: Record, + commit?: boolean, + reqOptions?: Record + ) => Promise>; + + beforeAll(async () => { + const connections = await getConnections( + { + schemas: ['request_context_test'], + authRole: 'authenticated', + preset: { + plugins: [ + createI18nPlugin({ defaultLanguages: ['en'] }), + PublicKeySignature({ + schema: 'request_context_test', + anonymousRole: 'anonymous', + crypto_network: 'test', + sign_up_with_key: 'sign_up_with_key', + sign_in_request_challenge: 'sign_in_request_challenge', + sign_in_record_failure: 'sign_in_record_failure', + sign_in_with_challenge: 'sign_in_with_challenge', + }), + ], + }, + }, + [ + seed.fn(async ({ admin, config, connect }) => { + await admin.streamSql( + `DO $roles$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'anonymous') THEN + EXECUTE 'CREATE ROLE anonymous'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE 'CREATE ROLE authenticated'; + END IF; + END + $roles$;`, + config.database + ); + const appUser = connect.connections?.app?.user; + if (!appUser) + throw new Error('request-context test requires an app user'); + await admin.grantRole('anonymous', appUser, config.database); + await admin.grantRole('authenticated', appUser, config.database); + }), + seed.sqlfile([join(__dirname, 'request-context.setup.sql')]), + ] + ); + db = connections.db; + teardown = connections.teardown; + query = connections.query; + }, 30_000); + + afterAll(async () => { + if (teardown) await teardown(); + }); + + beforeEach(async () => { + if (db) await db.beforeEach(); + }); + + afterEach(async () => { + if (db) await db.afterEach(); + }); + + it('preserves the PublicKeySignature mutation schema contract', async () => { + const result = await query<{ + mutationType: { + fields: Array<{ + name: string; + args: Array<{ + name: string; + type: { + kind: string; + name: string | null; + ofType: { kind: string; name: string | null } | null; + }; + }>; + type: { kind: string; name: string | null }; + }>; + } | null; + }>( + ` + query PublicKeySchemaContract { + mutationType: __type(name: "Mutation") { + fields { + name + args { + name + type { + kind + name + ofType { kind name } + } + } + type { kind name } + } + } + } + `, + undefined, + false, + { pgSettings: settings(null, 'schema-contract') } + ); + + expect(result.errors).toBeUndefined(); + const fields = result.data?.mutationType?.fields + .filter((field) => + [ + 'createUserAccountWithPublicKey', + 'getMessageForSigning', + 'verifyMessageForSigning', + ].includes(field.name) + ) + .sort((a, b) => a.name.localeCompare(b.name)); + + expect(fields).toEqual([ + { + name: 'createUserAccountWithPublicKey', + args: [ + { + name: 'input', + type: { + kind: 'INPUT_OBJECT', + name: 'CreateUserAccountWithPublicKeyInput', + ofType: null, + }, + }, + ], + type: { + kind: 'OBJECT', + name: 'createUserAccountWithPublicKeyPayload', + }, + }, + { + name: 'getMessageForSigning', + args: [ + { + name: 'input', + type: { + kind: 'INPUT_OBJECT', + name: 'GetMessageForSigningInput', + ofType: null, + }, + }, + ], + type: { + kind: 'OBJECT', + name: 'getMessageForSigningPayload', + }, + }, + { + name: 'verifyMessageForSigning', + args: [ + { + name: 'input', + type: { + kind: 'INPUT_OBJECT', + name: 'VerifyMessageForSigningInput', + ofType: null, + }, + }, + ], + type: { + kind: 'OBJECT', + name: 'verifyMessageForSigningPayload', + }, + }, + ]); + }); + + it('keeps normal, F13 and anonymous PublicKey lanes isolated', async () => { + const authenticated = settings('user-1', 'authenticated-request'); + const authenticatedProbe = await query<{ + contextProbe: Record; + }>( + ` + query AuthenticatedContext { + contextProbe + } + `, + undefined, + false, + { pgSettings: authenticated } + ); + + expect(authenticatedProbe.errors).toBeUndefined(); + expect(authenticatedProbe.data?.contextProbe).toMatchObject({ + currentUser: 'authenticated', + userId: 'user-1', + apiId: 'api-1', + databaseId: 'database-1', + requestId: 'authenticated-request', + readOnly: 'off', + rowSecurity: 'on', + searchPath: 'pg_catalog, "request_context_test"', + }); + + const anonymous = settings(null, 'anonymous-request'); + const anonymousProbe = await query<{ + contextProbe: Record; + }>( + ` + query AnonymousContext { + contextProbe + } + `, + undefined, + false, + { pgSettings: anonymous } + ); + + expect(anonymousProbe.errors).toBeUndefined(); + expect(anonymousProbe.data?.contextProbe).toMatchObject({ + currentUser: 'anonymous', + userId: '', + apiId: 'api-1', + databaseId: 'database-1', + requestId: 'anonymous-request', + }); + + const f13Settings = settings('user-1', 'f13-request'); + const i18nResult = await query<{ + postByRowId: { localeStrings: { title: string } } | null; + }>( + ` + query F13Context { + postByRowId(rowId: 1) { + localeStrings { + title + } + } + } + `, + undefined, + false, + { pgSettings: f13Settings } + ); + + expect(i18nResult.errors).toBeUndefined(); + expect(i18nResult.data?.postByRowId?.localeStrings.title).toBe( + 'Context-approved translation' + ); + + const publicKeyResult = await query<{ + getMessageForSigning: { message: string } | null; + }>( + ` + mutation PublicKeyAnonymousLane { + getMessageForSigning(input: { publicKey: "public-key-1" }) { + message + } + } + `, + undefined, + false, + { pgSettings: authenticated } + ); + + expect(publicKeyResult.errors).toBeUndefined(); + const publicKeyContext = JSON.parse( + publicKeyResult.data?.getMessageForSigning?.message ?? '{}' + ); + expect(publicKeyContext).toMatchObject({ + currentUser: 'anonymous', + userId: 'user-1', + apiId: 'api-1', + databaseId: 'database-1', + requestId: 'authenticated-request', + readOnly: 'off', + rowSecurity: 'on', + searchPath: 'pg_catalog, "request_context_test"', + }); + + const rollbackResult = await query( + ` + mutation PublicKeyRollback { + createUserAccountWithPublicKey(input: { publicKey: "force-rollback" }) { + message + } + } + `, + undefined, + false, + { pgSettings: authenticated } + ); + expect(rollbackResult.errors?.[0]?.message).toContain( + 'forced public-key rollback' + ); + + const audit = await db.client.query( + 'SELECT count(*)::int AS count FROM request_context_test.public_key_audit' + ); + expect(audit.rows[0].count).toBe(0); + + const afterRollback = await query<{ contextProbe: Record }>( + ` + query AfterRollback { + contextProbe + } + `, + undefined, + false, + { pgSettings: anonymous } + ); + expect(afterRollback.errors).toBeUndefined(); + expect(afterRollback.data?.contextProbe).toMatchObject({ + currentUser: 'anonymous', + userId: '', + requestId: 'anonymous-request', + }); + }); +}); diff --git a/graphile/graphile-settings/__tests__/request-context.setup.sql b/graphile/graphile-settings/__tests__/request-context.setup.sql new file mode 100644 index 0000000000..8cfb83376f --- /dev/null +++ b/graphile/graphile-settings/__tests__/request-context.setup.sql @@ -0,0 +1,126 @@ +CREATE SCHEMA request_context_test; +GRANT USAGE ON SCHEMA request_context_test TO anonymous, authenticated; + +CREATE FUNCTION request_context_test.context_probe() +RETURNS jsonb +LANGUAGE sql +STABLE +AS $$ + SELECT jsonb_build_object( + 'currentUser', current_user, + 'userId', current_setting('jwt.claims.user_id', true), + 'apiId', current_setting('jwt.claims.api_id', true), + 'databaseId', current_setting('jwt.claims.database_id', true), + 'requestId', current_setting('request.id', true), + 'readOnly', current_setting('transaction_read_only'), + 'rowSecurity', current_setting('row_security'), + 'searchPath', current_setting('search_path') + ) +$$; +GRANT EXECUTE ON FUNCTION request_context_test.context_probe() TO anonymous, authenticated; + +CREATE TABLE request_context_test.posts ( + id integer PRIMARY KEY, + title text NOT NULL +); +COMMENT ON TABLE request_context_test.posts IS E'@i18n posts_translations'; + +CREATE TABLE request_context_test.posts_translations ( + id integer PRIMARY KEY, + post_id integer NOT NULL REFERENCES request_context_test.posts(id), + lang_code text NOT NULL, + title text NOT NULL, + UNIQUE (post_id, lang_code) +); + +INSERT INTO request_context_test.posts (id, title) +VALUES (1, 'Base title'); +INSERT INTO request_context_test.posts_translations (id, post_id, lang_code, title) +VALUES (1, 1, 'en', 'Context-approved translation'); + +ALTER TABLE request_context_test.posts ENABLE ROW LEVEL SECURITY; +ALTER TABLE request_context_test.posts_translations ENABLE ROW LEVEL SECURITY; + +CREATE POLICY complete_request_context_posts +ON request_context_test.posts +FOR SELECT +TO authenticated +USING ( + current_user = 'authenticated' + AND current_setting('jwt.claims.user_id', true) = 'user-1' + AND current_setting('jwt.claims.api_id', true) = 'api-1' + AND current_setting('jwt.claims.database_id', true) = 'database-1' + AND current_setting('request.id', true) = 'f13-request' + AND current_setting('transaction_read_only') = 'off' + AND current_setting('row_security') = 'on' + AND current_setting('search_path') = 'pg_catalog, "request_context_test"' +); + +CREATE POLICY complete_request_context_translations +ON request_context_test.posts_translations +FOR SELECT +TO authenticated +USING ( + current_user = 'authenticated' + AND current_setting('jwt.claims.user_id', true) = 'user-1' + AND current_setting('jwt.claims.api_id', true) = 'api-1' + AND current_setting('jwt.claims.database_id', true) = 'database-1' + AND current_setting('request.id', true) = 'f13-request' + AND current_setting('transaction_read_only') = 'off' + AND current_setting('row_security') = 'on' + AND current_setting('search_path') = 'pg_catalog, "request_context_test"' +); + +GRANT SELECT ON request_context_test.posts TO anonymous, authenticated; +GRANT SELECT ON request_context_test.posts_translations TO anonymous, authenticated; + +CREATE TABLE request_context_test.public_key_audit ( + public_key text NOT NULL +); +GRANT SELECT, INSERT ON request_context_test.public_key_audit TO anonymous, authenticated; + +CREATE FUNCTION request_context_test.sign_up_with_key(public_key text) +RETURNS TABLE(sign_up_with_key text) +LANGUAGE plpgsql +VOLATILE +AS $$ +BEGIN + INSERT INTO request_context_test.public_key_audit VALUES (public_key); + IF public_key = 'force-rollback' THEN + RAISE EXCEPTION 'forced public-key rollback'; + END IF; + RETURN QUERY SELECT public_key; +END +$$; + +CREATE FUNCTION request_context_test.sign_in_request_challenge(public_key text) +RETURNS TABLE(sign_in_request_challenge text) +LANGUAGE sql +STABLE +AS $$ + SELECT jsonb_build_object( + 'currentUser', current_user, + 'userId', current_setting('jwt.claims.user_id', true), + 'apiId', current_setting('jwt.claims.api_id', true), + 'databaseId', current_setting('jwt.claims.database_id', true), + 'requestId', current_setting('request.id', true), + 'readOnly', current_setting('transaction_read_only'), + 'rowSecurity', current_setting('row_security'), + 'searchPath', current_setting('search_path'), + 'publicKey', public_key + )::text +$$; + +CREATE FUNCTION request_context_test.sign_in_record_failure(public_key text) +RETURNS void +LANGUAGE sql +VOLATILE +AS $$ SELECT NULL::void $$; + +CREATE FUNCTION request_context_test.sign_in_with_challenge(public_key text, message text) +RETURNS TABLE(access_token text, access_token_expires_at timestamptz) +LANGUAGE sql +VOLATILE +AS $$ SELECT public_key || message, now() + interval '1 hour' $$; + +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA request_context_test TO anonymous, authenticated; diff --git a/graphile/graphile-settings/package.json b/graphile/graphile-settings/package.json index 5c9d781298..df97d42ab3 100644 --- a/graphile/graphile-settings/package.json +++ b/graphile/graphile-settings/package.json @@ -31,6 +31,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1052.0", "@constructive-io/bucket-provisioner": "workspace:^", + "@constructive-io/express-context": "workspace:^", "@constructive-io/graphql-env": "workspace:^", "@constructive-io/graphql-types": "workspace:^", "@constructive-io/s3-streamer": "workspace:^", @@ -69,7 +70,6 @@ "lru-cache": "^11.2.7", "mime-bytes": "workspace:^", "pg": "^8.21.0", - "pg-query-context": "workspace:^", "pg-sql2": "5.0.1", "postgraphile": "5.1.4", "request-ip": "^3.3.0", diff --git a/graphile/graphile-settings/src/plugins/PublicKeySignature.ts b/graphile/graphile-settings/src/plugins/PublicKeySignature.ts index 2d01aa9712..26a4df6db3 100644 --- a/graphile/graphile-settings/src/plugins/PublicKeySignature.ts +++ b/graphile/graphile-settings/src/plugins/PublicKeySignature.ts @@ -1,13 +1,20 @@ // import Networks from '@pyramation/crypto-networks'; // import { verifyMessage } from '@pyramation/crypto-keys'; +import { + assertCompletePgSettings, + type PgSettings, + withPgSettingsRole, +} from '@constructive-io/express-context'; +import type { PgClient, PgClientResult, WithPgClient } from '@dataplan/pg'; 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; @@ -20,13 +27,15 @@ const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/; const SAFE_CRYPTO_NETWORK = /^[a-z0-9_-]{1,64}$/i; function validateIdentifier(name: string, label: string): void { - if (!SAFE_IDENTIFIER.test(name)) { - throw new Error(`PublicKeySignature: invalid ${label} "${name}" — must match /^[a-z_][a-z0-9_]*$/`); + if (typeof name !== 'string' || !SAFE_IDENTIFIER.test(name)) { + throw new Error( + `PublicKeySignature: invalid ${label} "${name}" — must match /^[a-z_][a-z0-9_]*$/` + ); } } function validateCryptoNetwork(name: string): void { - if (!SAFE_CRYPTO_NETWORK.test(name)) { + if (typeof name !== 'string' || !SAFE_CRYPTO_NETWORK.test(name)) { throw new Error( 'PublicKeySignature: invalid crypto_network — must match /^[a-z0-9_-]{1,64}$/i', ); @@ -38,9 +47,50 @@ const MAX_MESSAGE_LENGTH = 4096; const MAX_SIGNATURE_LENGTH = 1024; const ENABLE_SIGNATURE_VERIFICATION = process.env.ENABLE_SIGNATURE_VERIFICATION === 'true'; -export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): GraphileConfig.Plugin => { +/** + * Run a public-key authentication operation with the request's complete GUC + * context while retaining the deliberately anonymous database role. + */ +export async function withAnonymousPublicKeyClient( + withPgClient: unknown, + pgSettings: unknown, + anonymousRole: string, + callback: (pgClient: PgClient) => T | Promise +): Promise { + if (typeof withPgClient !== 'function') { + throw new Error('PUBLIC_KEY_PG_CLIENT_CONTEXT_UNAVAILABLE'); + } + assertCompletePgSettings(pgSettings, 'PublicKeySignature pgSettings'); + validateIdentifier(anonymousRole, 'anonymousRole'); + + const anonymousSettings: PgSettings = withPgSettingsRole( + pgSettings, + anonymousRole + ); + return (withPgClient as WithPgClient)(anonymousSettings, callback); +} + +/** Use @dataplan/pg's native query-config contract for every public-key call. */ +export function queryPublicKeyFunction( + pgClient: Pick, + schema: string, + functionName: string, + values: any[] +): Promise> { + validateIdentifier(schema, 'schema'); + validateIdentifier(functionName, 'function'); + return pgClient.query({ + text: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, functionName)}(${values.map((_, index) => `$${index + 1}`).join(', ')})`, + values, + }); +} + +export const PublicKeySignature = ( + pubkey_challenge: PublicKeyChallengeConfig +): GraphileConfig.Plugin => { const { schema, + anonymousRole, crypto_network, sign_up_with_key, sign_in_request_challenge, @@ -49,6 +99,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,69 +154,94 @@ 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 }); - - return lambda($combined, async ({ input, withPgClient }: any) => { - if (!input.publicKey || typeof input.publicKey !== 'string' || input.publicKey.length > MAX_PUBLIC_KEY_LENGTH) { - throw new Error('INVALID_PUBLIC_KEY'); - } + const $pgSettings = (grafastContext() as any).get('pgSettings'); + const $combined = object({ + input: $input, + withPgClient: $withPgClient, + pgSettings: $pgSettings, + }); - 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 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 withAnonymousPublicKeyClient( + withPgClient, + pgSettings, + anonymousRole, + async (pgClient) => { + await queryPublicKeyFunction( + pgClient, + schema, + sign_up_with_key, + [input.publicKey] + ); + + const { + rows: [{ [sign_in_request_challenge]: message }], + } = await queryPublicKeyFunction>( + pgClient, + schema, + sign_in_request_challenge, + [input.publicKey] + ); + + return { message }; + } + ); + } + ); }, getMessageForSigning(_$mutation: any, fieldArgs: any) { const $input = fieldArgs.getRaw('input'); const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $combined = object({ input: $input, withPgClient: $withPgClient }); - - return lambda($combined, async ({ input, withPgClient }: 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) => { - 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] - }); + const $pgSettings = (grafastContext() as any).get('pgSettings'); + const $combined = object({ + input: $input, + withPgClient: $withPgClient, + pgSettings: $pgSettings, + }); - if (!message) throw new Error('NO_ACCOUNT_EXISTS'); + 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 { message }; - }); - }); + return withAnonymousPublicKeyClient( + withPgClient, + pgSettings, + anonymousRole, + async (pgClient) => { + const { + rows: [{ [sign_in_request_challenge]: message }], + } = await queryPublicKeyFunction>( + pgClient, + schema, + sign_in_request_challenge, + [input.publicKey] + ); + + if (!message) throw new Error('NO_ACCOUNT_EXISTS'); + + return { message }; + } + ); + } + ); }, // NOTE: Verification remains behind a feature flag until crypto @@ -173,57 +249,73 @@ 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) => { - const { publicKey, message, signature: _signature } = input; + 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 + ) { + throw new Error('INVALID_PUBLIC_KEY'); + } + if ( + !message || + typeof message !== 'string' || + message.length > MAX_MESSAGE_LENGTH + ) { + throw new Error('INVALID_MESSAGE'); + } + if ( + !_signature || + typeof _signature !== 'string' || + _signature.length > MAX_SIGNATURE_LENGTH + ) { + throw new Error('INVALID_SIGNATURE'); + } - if (!publicKey || typeof publicKey !== 'string' || publicKey.length > MAX_PUBLIC_KEY_LENGTH) { - throw new Error('INVALID_PUBLIC_KEY'); - } - if (!message || typeof message !== 'string' || message.length > MAX_MESSAGE_LENGTH) { - throw new Error('INVALID_MESSAGE'); - } - if (!_signature || typeof _signature !== 'string' || _signature.length > MAX_SIGNATURE_LENGTH) { - throw new Error('INVALID_SIGNATURE'); - } + if (!ENABLE_SIGNATURE_VERIFICATION) { + // Fail closed without mutating lockout counters while verification + // is disabled. + throw new Error('FEATURE_DISABLED'); + } - if (!ENABLE_SIGNATURE_VERIFICATION) { - // Fail closed without mutating lockout counters while verification - // is disabled. - throw new Error('FEATURE_DISABLED'); + return withAnonymousPublicKeyClient( + withPgClient, + pgSettings, + anonymousRole, + async (pgClient) => { + const { + rows: [token], + } = await queryPublicKeyFunction>( + pgClient, + schema, + sign_in_with_challenge, + [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, + }; + } + ); } - - 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; - } - }); - }); - } - } - } + ); + }, + }, + }, })); }; diff --git a/graphile/graphile-test/src/context.ts b/graphile/graphile-test/src/context.ts index bcc9ed4adb..50b791d995 100644 --- a/graphile/graphile-test/src/context.ts +++ b/graphile/graphile-test/src/context.ts @@ -271,7 +271,7 @@ export const runGraphQLInContext = async ({ // instead of getting a new connection from the pool const withPgClientKey = pgService.withPgClientKey ?? 'withPgClient'; contextValue[withPgClientKey] = async ( - _pgSettings: Record | null, + requestedPgSettings: Record | null, callback: (client: Client) => T | Promise ): Promise => { // Augment the client with withTransaction if it doesn't already have it. @@ -296,7 +296,35 @@ export const runGraphQLInContext = async ({ } }; } - return callback(pgClient); + const callbackSettings = requestedPgSettings ?? pgSettings; + if (!isInTransaction) { + await client.query('BEGIN'); + try { + await setContextOnClient( + client, + callbackSettings, + callbackSettings.role ?? pgSettings.role + ); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + throw error; + } + } + + await setContextOnClient( + client, + callbackSettings, + callbackSettings.role ?? pgSettings.role + ); + const result = await callback(client); + // Errors are rolled back by the existing execution savepoint below. On a + // successful derivative lane, explicitly restore the primary request + // context before returning control to the rest of the GraphQL operation. + await setContextOnClient(client, pgSettings, pgSettings.role); + return result; }; // Wrap the entire query execution in a savepoint if we're in a transaction diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 6383de2044..27e9075742 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -70,6 +70,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and }, "graphile": { "extends": [], + "introspectionDependencySchemas": [], "preset": {}, "schema": [ "override_schema", diff --git a/graphql/server/src/middleware/__tests__/graphile-request-context.test.ts b/graphql/server/src/middleware/__tests__/graphile-request-context.test.ts new file mode 100644 index 0000000000..bba98811d7 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-request-context.test.ts @@ -0,0 +1,134 @@ +import { buildPgSettings, DEFAULT_REQUEST_PROTECTION } from '@constructive-io/express-context'; +import type { Request } from 'express'; + +import { getGraphileRequestPgSettings } from '../graphile-request-context'; + +const baseApi = { + apiId: 'api-1', + databaseId: 'database-1', + dbname: 'tenant_db', + anonRole: 'anonymous_runtime', + roleName: 'authenticated_runtime', + schema: ['tenant_api'], + isPublic: true, +}; + +function makeRequest( + overrides: Record = {}, + headers: Record = {} +): Request { + const normalizedHeaders = Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]) + ); + return { + get: (name: string) => normalizedHeaders[name.toLowerCase()], + ...overrides, + } as unknown as Request; +} + +describe('Graphile canonical request context', () => { + it.each([ + ['anonymous', null], + ['authenticated', { id: 'token-1', user_id: 'user-1' }], + ])('reuses the exact canonical object for %s requests', (_label, token) => { + const pgSettings = buildPgSettings({ + api: baseApi, + token, + requestId: 'request-1', + }); + const req = makeRequest({ + api: baseApi, + token, + constructive: { pgSettings }, + }); + + expect(getGraphileRequestPgSettings(req)).toBe(pgSettings); + }); + + it('does not derive identity from unauthenticated private headers', () => { + const privateApi = { ...baseApi, isPublic: false }; + const pgSettings = buildPgSettings({ + api: privateApi, + token: null, + requestId: 'request-private', + clientIp: '192.0.2.8', + }); + const req = makeRequest( + { + api: privateApi, + token: null, + constructive: { pgSettings }, + }, + { + 'X-Actor-Id': 'actor-1', + 'X-Entity-Id': 'entity-1', + 'X-Organization-Id': 'organization-1', + } + ); + + expect(getGraphileRequestPgSettings(req)).toBe(pgSettings); + expect(pgSettings.role).toBe('anonymous_runtime'); + expect(pgSettings['jwt.claims.user_id']).toBe(''); + expect(pgSettings['jwt.claims.entity_id']).toBe('database-1'); + expect(pgSettings['jwt.claims.entity_type']).toBe('database'); + expect(pgSettings['jwt.claims.organization_id']).toBe(''); + }); + + it('does not trust private identity headers on a public surface', () => { + const pgSettings = buildPgSettings({ + api: baseApi, + token: null, + requestId: 'request-public', + }); + const req = makeRequest( + { api: baseApi, token: null, constructive: { pgSettings } }, + { 'X-Actor-Id': 'attacker-controlled' } + ); + + expect(getGraphileRequestPgSettings(req)).toBe(pgSettings); + expect(pgSettings['jwt.claims.user_id']).toBe(''); + }); + + it('does not replace authenticated identity on a private surface', () => { + const privateApi = { ...baseApi, isPublic: false }; + const token = { id: 'token-1', user_id: 'token-user' }; + const pgSettings = buildPgSettings({ + api: privateApi, + token, + requestId: 'request-authenticated-private', + }); + const req = makeRequest( + { api: privateApi, token, constructive: { pgSettings } }, + { 'X-Actor-Id': 'header-user' } + ); + + expect(getGraphileRequestPgSettings(req)).toBe(pgSettings); + expect(pgSettings['jwt.claims.user_id']).toBe('token-user'); + }); + + it.each([ + ['missing request', undefined], + ['missing constructive context', makeRequest()], + [ + 'incomplete settings', + makeRequest({ + constructive: { pgSettings: { role: 'anonymous_runtime' } }, + }), + ], + ])('fails closed for %s', (_label, req) => { + expect(() => getGraphileRequestPgSettings(req)).toThrow( + /req\.constructive\.pgSettings/ + ); + }); + it('applies per-request protection to the canonical object at execution time', () => { + const pgSettings = buildPgSettings({ api: baseApi, token: null, requestId: 'protected' }); + const requestProtection = { ...DEFAULT_REQUEST_PROTECTION, statementTimeoutMs: 1234 }; + const req = makeRequest({ constructive: { pgSettings }, requestProtection }); + expect(getGraphileRequestPgSettings(req)).toBe(pgSettings); + expect(pgSettings.statement_timeout).toBe('1234'); + expect(pgSettings.lock_timeout).toBe(String(DEFAULT_REQUEST_PROTECTION.lockTimeoutMs)); + expect(pgSettings['jwt.claims.entity_id']).toBe('database-1'); + expect(pgSettings['jwt.claims.entity_type']).toBe('database'); + }); + +}); diff --git a/graphql/server/src/middleware/graphile-request-context.ts b/graphql/server/src/middleware/graphile-request-context.ts new file mode 100644 index 0000000000..8bb8a861e5 --- /dev/null +++ b/graphql/server/src/middleware/graphile-request-context.ts @@ -0,0 +1,24 @@ +import { + assertCompletePgSettings, + DEFAULT_REQUEST_PROTECTION, + protectionPgSettings, + type PgSettings, +} from '@constructive-io/express-context'; +import type { Request } from 'express'; + +/** + * Read the canonical request context assembled by express-context. + * + * Identity-bearing private headers remain inert until an authenticated + * internal-ingress boundary owns their translation into trusted claims. + */ +export function getGraphileRequestPgSettings( + req: Request | undefined +): PgSettings { + const canonical = req?.constructive?.pgSettings; + assertCompletePgSettings(canonical, 'req.constructive.pgSettings'); + // Protection is resolved after express-context, so apply its current bounds + // at execution time while retaining the single canonical request object. + Object.assign(canonical, protectionPgSettings(req?.requestProtection ?? DEFAULT_REQUEST_PROTECTION)); + return canonical; +} diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index dca05e19c9..cf12e155f1 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -2,7 +2,6 @@ import './types'; // for Request type import { errors } from '@constructive-io/errors'; import type { ComputeConfig } from '@constructive-io/express-context'; -import { DEFAULT_REQUEST_PROTECTION, protectionPgSettings } from '@constructive-io/express-context'; import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import { getNodeEnv } from '@pgpmjs/env'; import { Logger } from '@pgpmjs/logger'; @@ -22,6 +21,7 @@ import { createErrorEventsPlugin } from '../plugins/error-events-plugin'; import { RequestProtectionPlugin } from '../plugins/request-protection-plugin'; import type { DatabaseSettings } from '../types'; import { maskError } from './mask-error'; +import { getGraphileRequestPgSettings } from './graphile-request-context'; import { observeGraphileBuild } from './observability/graphile-build-stats'; const isDev = (): boolean => getNodeEnv() === 'development'; @@ -74,8 +74,6 @@ const reqLabel = (req: Request): string => (req.requestId ? `[${req.requestId}]` const buildPreset = ( pool: import('pg').Pool, schemas: string[], - anonRole: string, - roleName: string, introspectionRole: string | undefined, databaseSettings?: DatabaseSettings, apiId?: string, @@ -131,143 +129,11 @@ const buildPreset = ( grafast: { explain: process.env.NODE_ENV === 'development', context: (requestContext: Partial) => { - // In grafserv/express/v4, the request is available at requestContext.expressv4.req - const req = (requestContext as { expressv4?: { req?: Request } })?.expressv4?.req; - const context: Record = {}; - - // Timeouts travel with the transaction as GUCs, so they bound the work - // this request can do inside PostgreSQL whatever the plan turns out to - // be. Resolved per request (not baked into the cached preset) so a - // tenant lowering a timeout takes effect on the next request. - const timeouts = protectionPgSettings(req?.requestProtection ?? DEFAULT_REQUEST_PROTECTION); - - if (req) { - if (req.databaseId) { - context['jwt.claims.database_id'] = req.databaseId; - } - // API provenance — which API surface this request arrived through. - // Derived server-side by resolving the hostname through the scoped - // routing plane (resolve_route -> api_id); never taken from - // client-supplied headers, body, or token payload. - if (req.api?.apiId) { - context['jwt.claims.api_id'] = req.api.apiId; - } - if (req.clientIp) { - context['jwt.claims.ip_address'] = req.clientIp; - } - if (req.get('origin')) { - context['jwt.claims.origin'] = req.get('origin') as string; - } - if (req.get('User-Agent')) { - context['jwt.claims.user_agent'] = req.get('User-Agent') as string; - } - if (req.deviceToken) { - context['jwt.claims.device_token'] = req.deviceToken; - } - - if (req.token?.user_id) { - const pgSettings: Record = { - ...timeouts, - role: roleName, - 'jwt.claims.token_id': req.token.id, - 'jwt.claims.user_id': req.token.user_id, - ...context - }; - - if (req.token.session_id) { - pgSettings['jwt.claims.session_id'] = req.token.session_id; - } - if (req.token.root_session_id) { - pgSettings['jwt.claims.root_session_id'] = req.token.root_session_id; - } - if (req.token.parent_session_id) { - pgSettings['jwt.claims.parent_session_id'] = req.token.parent_session_id; - } - if (req.token.intent) { - pgSettings['jwt.claims.intent'] = req.token.intent; - } - - // Propagate credential metadata as JWT claims so PG functions - // can read them via current_setting('jwt.claims.access_level') etc. - if (req.token.access_level) { - pgSettings['jwt.claims.access_level'] = req.token.access_level; - } - if (req.token.kind) { - pgSettings['jwt.claims.kind'] = req.token.kind; - } - - // Principal identity — always set; equals user_id for human sessions - pgSettings['jwt.claims.principal_id'] = req.token.principal_id || req.token.user_id; - - // Enforce read-only transactions for read_only credentials - if (req.token.access_level === 'read_only') { - pgSettings['default_transaction_read_only'] = 'on'; - } - - if (req.requestId) { - pgSettings['request.id'] = req.requestId; - } - - return { pgSettings }; - } - - // Private (in-cluster) surface: there is no token — identity - // arrives on the trusted internal X-* headers stamped by the - // dispatching worker/sync gateway (the same vocabulary as - // X-Database-Id above). Map it into per-request claims so writes - // made through this surface carry actor attribution. Never applied - // on the public surface, where client-supplied identity headers - // must not assert identity. - const headerActorId = req.get('X-Actor-Id'); - if (req.api?.isPublic === false && headerActorId) { - const pgSettings: Record = { - ...timeouts, - role: roleName, - 'jwt.claims.user_id': headerActorId, - 'jwt.claims.principal_id': headerActorId, - ...context - }; - // The entity pair travels together: the tenant's writers reject an - // entity id whose type they cannot interpret (`ENTITY_TYPE_REQUIRED`). - const headerEntityId = req.get('X-Entity-Id'); - const headerEntityType = req.get('X-Entity-Type'); - if (headerEntityId) { - pgSettings['jwt.claims.entity_id'] = headerEntityId; - } - if (headerEntityType) { - pgSettings['jwt.claims.entity_type'] = headerEntityType; - } - const headerOrganizationId = req.get('X-Organization-Id'); - if (headerOrganizationId) { - pgSettings['jwt.claims.organization_id'] = headerOrganizationId; - } - if (req.requestId) { - pgSettings['request.id'] = req.requestId; - } - return { pgSettings }; - } - } - - // No actor to name, so the tenant database the request addresses carries - // the attribution — the same rule the sync gateway applies to a request - // that arrives without a credential. Without it the tenant's own writers - // refuse the work an anonymous request legitimately does - // (`ATTRIBUTION_REQUIRED`), so a public mutation cannot enqueue a job. - const anonSettings: Record = { - ...timeouts, - role: anonRole, - ...context - }; - if (req?.databaseId) { - anonSettings['jwt.claims.entity_id'] = req.databaseId; - anonSettings['jwt.claims.entity_type'] = 'database'; - } - if (req?.requestId) { - anonSettings['request.id'] = req.requestId; - } - + // In grafserv/express/v4, the request is available at requestContext.expressv4.req + const req = (requestContext as { expressv4?: { req?: Request } }) + ?.expressv4?.req; return { - pgSettings: anonSettings + pgSettings: getGraphileRequestPgSettings(req), }; } } @@ -361,8 +227,6 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { const preset = buildPreset( pool, schema || [], - anonRole, - roleName, opts.api?.introspectionRole, api.databaseSettings, api.apiId, diff --git a/graphql/server/src/middleware/types.ts b/graphql/server/src/middleware/types.ts index 4eec6e8b60..658fec8c71 100644 --- a/graphql/server/src/middleware/types.ts +++ b/graphql/server/src/middleware/types.ts @@ -1,19 +1,10 @@ -import type { RequestProtection } from '@constructive-io/express-context'; +import type { + ApiStructure, + ConstructiveAPIToken, + RequestProtection, +} from '@constructive-io/express-context'; -import type { ApiStructure } from '../types'; - -export type ConstructiveAPIToken = { - id?: string; - user_id?: string; - principal_id?: string; - session_id?: string; - access_level?: string; - kind?: string; - root_session_id?: string; - parent_session_id?: string; - intent?: string; - [key: string]: unknown; -}; +export type { ConstructiveAPIToken } from '@constructive-io/express-context'; declare global { namespace Express { diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index d0229f5ad6..ef8a3e217a 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -170,6 +170,7 @@ class Server { app.use(authenticate); app.use(createContextMiddleware({ pg: effectiveOpts.pg, + dependencySchemas: effectiveOpts.graphile?.introspectionDependencySchemas, loaders: createDefaultRegistry(), routingSchema: getRoutingSchema(effectiveOpts) })); diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index cbbf6cea76..64ff97eccb 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -6,6 +6,8 @@ import type { GraphileConfig } from 'graphile-config'; export interface GraphileOptions { /** Database schema(s) to expose through GraphQL */ schema?: string | string[]; + /** Ordered extension/shared schemas required by Graphile and request SQL. */ + introspectionDependencySchemas?: string[]; /** Additional presets to extend */ extends?: GraphileConfig.Preset[]; /** Preset overrides */ @@ -69,6 +71,7 @@ export interface ApiOptions { */ export const graphileDefaults: GraphileOptions = { schema: [], + introspectionDependencySchemas: [], extends: [], preset: {} }; diff --git a/packages/express-context/__tests__/context-pg-settings.test.ts b/packages/express-context/__tests__/context-pg-settings.test.ts new file mode 100644 index 0000000000..a24a21cc0b --- /dev/null +++ b/packages/express-context/__tests__/context-pg-settings.test.ts @@ -0,0 +1,46 @@ +import type { Request } from 'express'; + +import { buildContext } from '../src/context'; + +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn(() => ({ query: jest.fn(), connect: jest.fn() })), +})); + +describe('buildContext pgSettings forwarding', () => { + it('forwards server-owned HTTP metadata into the canonical builder', () => { + const headers: Record = { + origin: 'https://app.example.test', + 'user-agent': 'context-test/1.0', + }; + const req = { + api: { + apiId: 'api-1', + databaseId: 'database-1', + dbname: 'tenant_db', + anonRole: 'anonymous_runtime', + roleName: 'authenticated_runtime', + schema: ['tenant_api'], + }, + token: { user_id: 'user-1' }, + requestId: 'request-1', + clientIp: '192.0.2.4', + deviceToken: 'device-1', + get: (name: string) => headers[name.toLowerCase()], + } as unknown as Request; + + const context = buildContext(req, { dependencySchemas: ['shared_api'] }); + + expect(context?.pgSettings).toMatchObject({ + role: 'authenticated_runtime', + 'request.id': 'request-1', + 'jwt.claims.user_id': 'user-1', + 'jwt.claims.api_id': 'api-1', + 'jwt.claims.database_id': 'database-1', + 'jwt.claims.ip_address': '192.0.2.4', + 'jwt.claims.origin': 'https://app.example.test', + 'jwt.claims.user_agent': 'context-test/1.0', + 'jwt.claims.device_token': 'device-1', + search_path: 'pg_catalog, "shared_api", "tenant_api"', + }); + }); +}); diff --git a/packages/express-context/__tests__/pg-settings.test.ts b/packages/express-context/__tests__/pg-settings.test.ts index 14b9cafb4c..a05b91619a 100644 --- a/packages/express-context/__tests__/pg-settings.test.ts +++ b/packages/express-context/__tests__/pg-settings.test.ts @@ -1,52 +1,233 @@ -import { buildPgSettings } from '../src/pg-settings'; +import { + assertCompletePgSettings, + buildPgSettings, + REQUIRED_PG_SETTING_KEYS, + SECURITY_GUC_KEYS, + withPgSettingsRole, + withTrustedPgClaims, +} from '../src/pg-settings'; import type { ApiStructure, ConstructiveAPIToken } from '../src/types'; const api: ApiStructure = { - apiId: '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + apiId: 'api-1', dbname: 'testdb', - anonRole: 'anonymous', - roleName: 'authenticated', - schema: ['public'], + anonRole: 'anonymous_runtime', + roleName: 'authenticated_runtime', + schema: ['public', 'app'], domains: [], - databaseId: '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', - isPublic: true + databaseId: 'database-1', + isPublic: true, }; -describe('buildPgSettings — jwt.claims.api_id provenance', () => { - it('sets jwt.claims.api_id from the resolved api for anonymous requests', () => { - const settings = buildPgSettings({ api, token: null, requestId: 'r1' }); +const token: ConstructiveAPIToken = { + id: 'token-1', + user_id: 'user-1', + principal_id: 'principal-1', + session_id: 'session-1', + access_level: 'read_only', + kind: 'api_token', + email: 'primary@example.test', + user_email: 'user@example.test', + entity_id: 'entity-1', + organization_id: 'organization-1', + tenant_id: 'tenant-1', + role_type: 'member', +}; + +describe('buildPgSettings', () => { + it('builds a value-complete anonymous request context', () => { + const settings = buildPgSettings({ + api, + token: null, + requestId: 'request-1', + }); - expect(settings['jwt.claims.api_id']).toBe(api.apiId); - expect(settings['role']).toBe('anonymous'); + expect(Object.keys(settings)).toEqual( + expect.arrayContaining(REQUIRED_PG_SETTING_KEYS) + ); + expect(settings.role).toBe('anonymous_runtime'); + expect(settings['request.id']).toBe('request-1'); + expect(settings.transaction_read_only).toBe('off'); + expect(settings.row_security).toBe('on'); + expect(settings.search_path).toBe('pg_catalog, "public", "app"'); + for (const key of SECURITY_GUC_KEYS) { + expect(typeof settings[key]).toBe('string'); + } + expect(settings['jwt.claims.user_id']).toBe(''); + expect(settings['jwt.claims.api_id']).toBe('api-1'); + expect(settings['jwt.claims.database_id']).toBe('database-1'); + expect(() => assertCompletePgSettings(settings)).not.toThrow(); }); - it('sets jwt.claims.api_id from the resolved api for authenticated requests', () => { - const token = { user_id: 'u1' } as ConstructiveAPIToken; - const settings = buildPgSettings({ api, token, requestId: 'r1' }); + it('maps every supported authenticated claim and trusted request fact', () => { + const settings = buildPgSettings({ + api, + token, + requestId: 'request-2', + clientIp: '192.0.2.10', + origin: 'https://app.example.test', + userAgent: 'test-agent/1.0', + deviceToken: 'device-1', + }); - expect(settings['jwt.claims.api_id']).toBe(api.apiId); - expect(settings['role']).toBe('authenticated'); - expect(settings['jwt.claims.user_id']).toBe('u1'); + expect(settings).toMatchObject({ + role: 'authenticated_runtime', + 'request.id': 'request-2', + transaction_read_only: 'on', + row_security: 'on', + 'jwt.claims.token_id': 'token-1', + 'jwt.claims.user_id': 'user-1', + 'jwt.claims.principal_id': 'principal-1', + 'jwt.claims.session_id': 'session-1', + 'jwt.claims.access_level': 'read_only', + 'jwt.claims.kind': 'api_token', + 'jwt.claims.email': 'primary@example.test', + 'jwt.claims.user_email': 'user@example.test', + 'jwt.claims.entity_id': 'entity-1', + 'jwt.claims.organization_id': 'organization-1', + 'jwt.claims.tenant_id': 'tenant-1', + 'jwt.claims.role_type': 'member', + 'jwt.claims.api_id': 'api-1', + 'jwt.claims.database_id': 'database-1', + 'jwt.claims.ip_address': '192.0.2.10', + 'jwt.claims.origin': 'https://app.example.test', + 'jwt.claims.user_agent': 'test-agent/1.0', + 'jwt.claims.device_token': 'device-1', + }); }); - it('omits jwt.claims.api_id when the api has no apiId (non-API surface)', () => { + it('represents every unavailable claim with an empty string', () => { const settings = buildPgSettings({ - api: { ...api, apiId: undefined }, + api: { ...api, apiId: undefined, databaseId: undefined }, token: null, - requestId: 'r1' + requestId: '', }); - expect(settings['jwt.claims.api_id']).toBeUndefined(); + for (const key of SECURITY_GUC_KEYS) { + expect(settings[key]).toBe(''); + } }); - it('is derived only from the resolved api, never from the token', () => { - const token = { - user_id: 'u1', - api_id: 'attacker-controlled' - } as unknown as ConstructiveAPIToken; - const settings = buildPgSettings({ api, token, requestId: 'r1' }); + it('does not retain authenticated claims or read-only state in a later anonymous request', () => { + const authenticated = buildPgSettings({ + api, + token, + requestId: 'authenticated', + }); + const anonymous = buildPgSettings({ + api, + token: null, + requestId: 'anonymous', + }); + + expect(authenticated['jwt.claims.user_id']).toBe('user-1'); + expect(authenticated.transaction_read_only).toBe('on'); + expect(anonymous['jwt.claims.user_id']).toBe(''); + expect(anonymous['jwt.claims.token_id']).toBe(''); + expect(anonymous.transaction_read_only).toBe('off'); + }); + + it('returns an independent object for every request', () => { + const first = buildPgSettings({ api, token: null, requestId: 'first' }); + const second = buildPgSettings({ api, token: null, requestId: 'second' }); + + expect(first).not.toBe(second); + first['jwt.claims.user_id'] = 'mutated'; + expect(second['jwt.claims.user_id']).toBe(''); + }); + + it('builds a deterministic, deduplicated and quoted search path', () => { + const settings = buildPgSettings({ + api, + token: null, + requestId: 'request-3', + dependencySchemas: ['shared', 'strange"name', 'public', 'shared'], + }); + + expect(settings.search_path).toBe( + 'pg_catalog, "shared", "strange""name", "public", "app"' + ); + }); + + it('derives principal_id from user_id when no explicit principal exists', () => { + const settings = buildPgSettings({ + api, + token: { user_id: 'user-fallback' }, + requestId: 'request-4', + }); + + expect(settings['jwt.claims.principal_id']).toBe('user-fallback'); + }); + + it('accepts allowlisted trusted claims without mutating either input', () => { + const trustedClaims = { 'jwt.claims.entity_id': 'trusted-entity' } as const; + const settings = buildPgSettings({ + api, + token: null, + requestId: 'request-5', + trustedClaims, + }); + const derived = withTrustedPgClaims(settings, { + 'jwt.claims.user_id': 'trusted-user', + }); + + expect(settings['jwt.claims.entity_id']).toBe('trusted-entity'); + expect(settings['jwt.claims.user_id']).toBe(''); + expect(derived['jwt.claims.user_id']).toBe('trusted-user'); + expect(derived).not.toBe(settings); + expect(trustedClaims).toEqual({ 'jwt.claims.entity_id': 'trusted-entity' }); + }); + + it.each([ + ['arbitrary setting', { role: 'postgres' }], + ['non-string value', { 'jwt.claims.user_id': null }], + ['array', []], + ['null', null], + ])('rejects invalid trusted claims: %s', (_label, trustedClaims) => { + expect(() => + buildPgSettings({ + api, + token: null, + requestId: 'request-6', + trustedClaims: trustedClaims as never, + }) + ).toThrow(TypeError); + }); + + it('rejects symbol and accessor properties in trusted claims', () => { + const symbolClaims = { 'jwt.claims.user_id': 'user' } as Record< + PropertyKey, + unknown + >; + symbolClaims[Symbol('claim')] = 'hidden'; + expect(() => + withTrustedPgClaims( + buildPgSettings({ api, token: null, requestId: 'request-7' }), + symbolClaims + ) + ).toThrow('must not contain symbol properties'); + + const accessorClaims = {}; + Object.defineProperty(accessorClaims, 'jwt.claims.user_id', { + enumerable: true, + get: () => 'user', + }); + expect(() => + withTrustedPgClaims( + buildPgSettings({ api, token: null, requestId: 'request-8' }), + accessorClaims + ) + ).toThrow('must be a string data property'); + }); + + it('copies a complete context when switching role and rejects invalid roles', () => { + const settings = buildPgSettings({ api, token, requestId: 'request-9' }); + const anonymous = withPgSettingsRole(settings, 'anonymous_runtime'); - expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(anonymous).toEqual({ ...settings, role: 'anonymous_runtime' }); + expect(anonymous).not.toBe(settings); + expect(settings.role).toBe('authenticated_runtime'); + expect(() => withPgSettingsRole(settings, '')).toThrow('non-empty string'); }); }); @@ -67,20 +248,20 @@ describe('buildPgSettings — agent-auth claims (intent, session lineage)', () = expect(settings['jwt.claims.intent']).toBe('deploy:preview'); }); - it('omits the lineage/intent GUCs when the token does not carry them', () => { + it('clears the lineage/intent GUCs when the token does not carry them', () => { const token: ConstructiveAPIToken = { user_id: 'u1', session_id: 's1' }; const settings = buildPgSettings({ api, token, requestId: 'r1' }); - expect(settings).not.toHaveProperty('jwt.claims.root_session_id'); - expect(settings).not.toHaveProperty('jwt.claims.parent_session_id'); - expect(settings).not.toHaveProperty('jwt.claims.intent'); + expect(settings['jwt.claims.root_session_id']).toBe(''); + expect(settings['jwt.claims.parent_session_id']).toBe(''); + expect(settings['jwt.claims.intent']).toBe(''); }); - it('omits them for anonymous requests', () => { + it('clears them for anonymous requests', () => { const settings = buildPgSettings({ api, token: null, requestId: 'r1' }); - expect(settings).not.toHaveProperty('jwt.claims.root_session_id'); - expect(settings).not.toHaveProperty('jwt.claims.parent_session_id'); - expect(settings).not.toHaveProperty('jwt.claims.intent'); + expect(settings['jwt.claims.root_session_id']).toBe(''); + expect(settings['jwt.claims.parent_session_id']).toBe(''); + expect(settings['jwt.claims.intent']).toBe(''); }); }); diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index 82d87de9d3..87ece44dca 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -34,6 +34,8 @@ export interface ContextMiddlewareOptions { loaders?: LoaderRegistry; /** Routing-plane schema loaders query (defaults to routing_public) */ routingSchema?: string; + /** Ordered, audited extension/shared schemas required by request SQL. */ + dependencySchemas?: readonly string[]; } /** @@ -77,7 +79,11 @@ export function buildContext( api, token, requestId, - clientIp: req.clientIp + clientIp: req.clientIp, + origin: req.get('origin'), + userAgent: req.get('User-Agent'), + deviceToken: req.deviceToken, + dependencySchemas: opts.dependencySchemas, }); const tenantPool: Pool = getPgPool({ diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 6192c2cc0d..cc9e081ccc 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -65,9 +65,23 @@ export type { export type { BillingClient, InferenceLogEntry } from './billing-client'; export { createBillingClient } from './billing-client'; -// pgSettings builder -export type { PgSettingsInput } from './pg-settings'; -export { buildPgSettings } from './pg-settings'; +// pgSettings builder and validation contract +export type { + PgSettings, + PgSettingsInput, + RequiredPgSettingKey, + SecurityGucKey, + TrustedPgClaims, +} from './pg-settings'; +export { + assertCompletePgSettings, + assertPgSettings, + buildPgSettings, + REQUIRED_PG_SETTING_KEYS, + SECURITY_GUC_KEYS, + withPgSettingsRole, + withTrustedPgClaims, +} from './pg-settings'; // withPgClient helper export { withPgClient } from './pg-client'; diff --git a/packages/express-context/src/pg-settings.ts b/packages/express-context/src/pg-settings.ts index 92dfa89e77..53b34a7365 100644 --- a/packages/express-context/src/pg-settings.ts +++ b/packages/express-context/src/pg-settings.ts @@ -1,89 +1,231 @@ /** - * pg-settings — Build pgSettings from resolved API + auth token + * Canonical PostgreSQL request settings. * - * pgSettings are key-value pairs passed to PostgreSQL via SET LOCAL - * within each transaction. They carry the JWT claims, role, database_id, - * and request_id so that RLS policies and SQL functions can reference - * the current user context via `current_setting('jwt.claims.user_id')`. - * - * This module extracts the pgSettings construction so it's reusable - * across the PostGraphile server, LLM sidecar, or any Express service. + * Every request receives a value-complete security context. Missing claims are + * represented by empty strings so a reused execution path cannot accidentally + * retain facts from an earlier request. */ import type { ApiStructure, ConstructiveAPIToken } from './types'; +export const SECURITY_GUC_KEYS = [ + 'jwt.claims.access_level', + 'jwt.claims.api_id', + 'jwt.claims.database_id', + 'jwt.claims.device_token', + 'jwt.claims.email', + 'jwt.claims.entity_id', + 'jwt.claims.ip_address', + 'jwt.claims.kind', + 'jwt.claims.organization_id', + 'jwt.claims.origin', + 'jwt.claims.principal_id', + 'jwt.claims.role_type', + 'jwt.claims.root_session_id', + 'jwt.claims.parent_session_id', + 'jwt.claims.intent', + 'jwt.claims.entity_type', + 'jwt.claims.session_id', + 'jwt.claims.tenant_id', + 'jwt.claims.token_id', + 'jwt.claims.user_agent', + 'jwt.claims.user_email', + 'jwt.claims.user_id', +] as const; + +export const REQUIRED_PG_SETTING_KEYS = [ + ...SECURITY_GUC_KEYS, + 'role', + 'request.id', + 'transaction_read_only', + 'search_path', + 'row_security', +] as const; + +export type SecurityGucKey = (typeof SECURITY_GUC_KEYS)[number]; +export type RequiredPgSettingKey = (typeof REQUIRED_PG_SETTING_KEYS)[number]; +export type PgSettings = Record; +export type TrustedPgClaims = Partial>; + +const SECURITY_GUC_KEY_SET: ReadonlySet = new Set(SECURITY_GUC_KEYS); + +function assertStringDataProperties( + value: unknown, + label: string +): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${label} must be an object of string data properties`); + } + + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') { + throw new TypeError(`${label} must not contain symbol properties`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + !('value' in descriptor) || + typeof descriptor.value !== 'string' + ) { + throw new TypeError(`${label}.${key} must be a string data property`); + } + } +} + +/** Validate the value shape accepted by Graphile's `withPgClient`. */ +export function assertPgSettings( + value: unknown, + label = 'pgSettings' +): asserts value is PgSettings { + assertStringDataProperties(value, label); +} + +/** Validate that a request carries the complete canonical settings contract. */ +export function assertCompletePgSettings( + value: unknown, + label = 'pgSettings' +): asserts value is PgSettings { + assertStringDataProperties(value, label); + for (const key of REQUIRED_PG_SETTING_KEYS) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + throw new TypeError(`${label} is missing required setting '${key}'`); + } + } +} + +function copyTrustedClaims(claims: unknown, label: string): TrustedPgClaims { + assertStringDataProperties(claims, label); + const copy: TrustedPgClaims = {}; + for (const key of Object.keys(claims)) { + if (!SECURITY_GUC_KEY_SET.has(key)) { + throw new TypeError( + `${label} contains unsupported security GUC '${key}'` + ); + } + copy[key as SecurityGucKey] = claims[key]; + } + return copy; +} + +/** Add server-owned claims without admitting arbitrary PostgreSQL settings. */ +export function withTrustedPgClaims( + pgSettings: unknown, + trustedClaims: unknown +): PgSettings { + assertCompletePgSettings(pgSettings); + return { + ...pgSettings, + ...copyTrustedClaims(trustedClaims, 'trustedClaims'), + }; +} + +/** Copy a complete request context while changing only its execution role. */ +export function withPgSettingsRole( + pgSettings: unknown, + role: string +): PgSettings { + assertCompletePgSettings(pgSettings); + if (typeof role !== 'string' || role.length === 0) { + throw new TypeError('role must be a non-empty string'); + } + return { ...pgSettings, role }; +} + export interface PgSettingsInput { - /** Resolved API config (provides role names, database_id) */ + /** Resolved API config (provides role names, database_id, physical schemas). */ api: ApiStructure; - /** Authenticated token (null for anonymous) */ + /** Authenticated token (null for anonymous). */ token: ConstructiveAPIToken | null; - /** Per-request correlation ID */ + /** Per-request correlation ID. */ requestId: string; - /** Client IP address (from request-ip middleware) */ + /** Client IP address resolved by server middleware. */ clientIp?: string; + /** Origin header captured by the server. */ + origin?: string; + /** User-Agent header captured by the server. */ + userAgent?: string; + /** Trusted device cookie resolved by authentication middleware. */ + deviceToken?: string; + /** Server-derived claims for an existing trusted private surface. */ + trustedClaims?: TrustedPgClaims; + /** Ordered, audited extension/shared schemas required by request SQL. */ + dependencySchemas?: readonly string[]; } -/** - * Build pgSettings from the resolved API + auth token. - * - * These settings are applied via SET LOCAL in each transaction, - * making them available to RLS policies and SQL functions. - */ -export function buildPgSettings(input: PgSettingsInput): Record { - const { api, token, requestId, clientIp } = input; - const settings: Record = {}; - - // Role: from token (authenticated) or api (anonymous fallback) - if (token?.user_id) { - settings['role'] = api.roleName || 'authenticated'; - settings['jwt.claims.user_id'] = token.user_id; - } else { - settings['role'] = api.anonRole || 'anonymous'; - } +const quoteIdentifier = (identifier: string): string => + `"${identifier.replace(/"/g, '""')}"`; - // Session claims - if (token?.session_id) { - settings['jwt.claims.session_id'] = token.session_id; - } +function setClaim( + settings: PgSettings, + key: SecurityGucKey, + value: unknown +): void { + if (typeof value === 'string') settings[key] = value; +} - // Session lineage (token exchange chains) - if (token?.root_session_id) { - settings['jwt.claims.root_session_id'] = token.root_session_id; - } - if (token?.parent_session_id) { - settings['jwt.claims.parent_session_id'] = token.parent_session_id; - } +/** Build a fresh, complete PostgreSQL security context for one request. */ +export function buildPgSettings(input: PgSettingsInput): PgSettings { + const { api, token, requestId, clientIp, origin, userAgent, deviceToken } = + input; + const settings: PgSettings = Object.fromEntries( + SECURITY_GUC_KEYS.map((key) => [key, '']) + ); - // Declared purpose of the credential - if (token?.intent) { - settings['jwt.claims.intent'] = token.intent; - } + settings.role = token?.user_id + ? api.roleName || 'authenticated' + : api.anonRole || 'anonymous'; - // Principal identity (service accounts / bots) - if (token?.principal_id) { - settings['jwt.claims.principal_id'] = token.principal_id; - } + setClaim(settings, 'jwt.claims.token_id', token?.id); + setClaim(settings, 'jwt.claims.user_id', token?.user_id); + setClaim(settings, 'jwt.claims.root_session_id', token?.root_session_id); + setClaim(settings, 'jwt.claims.parent_session_id', token?.parent_session_id); + setClaim(settings, 'jwt.claims.intent', token?.intent); + setClaim(settings, 'jwt.claims.entity_type', token?.entity_type); + setClaim(settings, 'jwt.claims.session_id', token?.session_id); + setClaim(settings, 'jwt.claims.access_level', token?.access_level); + setClaim(settings, 'jwt.claims.kind', token?.kind); + setClaim(settings, 'jwt.claims.email', token?.email); + setClaim(settings, 'jwt.claims.user_email', token?.user_email); + setClaim(settings, 'jwt.claims.entity_id', token?.entity_id); + setClaim(settings, 'jwt.claims.organization_id', token?.organization_id); + setClaim(settings, 'jwt.claims.tenant_id', token?.tenant_id); + setClaim(settings, 'jwt.claims.role_type', token?.role_type); + setClaim( + settings, + 'jwt.claims.principal_id', + token?.principal_id || token?.user_id + ); + setClaim(settings, 'jwt.claims.database_id', api.databaseId); + setClaim(settings, 'jwt.claims.api_id', api.apiId); + setClaim(settings, 'jwt.claims.ip_address', clientIp); + setClaim(settings, 'jwt.claims.origin', origin); + setClaim(settings, 'jwt.claims.user_agent', userAgent); + setClaim(settings, 'jwt.claims.device_token', deviceToken); - // Database context - if (api.databaseId) { - settings['jwt.claims.database_id'] = api.databaseId; + // Preserve database attribution for requests without an authenticated actor. + if (!token?.user_id && api.databaseId) { + settings['jwt.claims.entity_id'] = api.databaseId; + settings['jwt.claims.entity_type'] = 'database'; } - // API provenance — which API surface this request arrived through. - // Derived server-side by resolving the hostname through the scoped routing - // plane (resolve_route -> api_id); never taken from client-supplied headers, - // body, or token payload. - if (api.apiId) { - settings['jwt.claims.api_id'] = api.apiId; + if (input.trustedClaims !== undefined) { + Object.assign( + settings, + copyTrustedClaims(input.trustedClaims, 'trustedClaims') + ); } - // Distributed tracing settings['request.id'] = requestId; + settings.transaction_read_only = + token?.access_level === 'read_only' ? 'on' : 'off'; + settings.row_security = 'on'; - // Client metadata (for audit functions) - if (clientIp) { - settings['jwt.claims.ip_address'] = clientIp; - } + const physicalSchemas = [...(input.dependencySchemas ?? []), ...api.schema]; + settings.search_path = [ + 'pg_catalog', + ...[...new Set(physicalSchemas)].map(quoteIdentifier), + ].join(', '); + assertCompletePgSettings(settings, 'built pgSettings'); return settings; } diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index bae75b63e7..44bcadb3d2 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -170,6 +170,13 @@ export type ConstructiveAPIToken = { parent_session_id?: string; /** Caller-declared purpose recorded on the credential at exchange time. */ intent?: string; + email?: string; + user_email?: string; + entity_id?: string; + entity_type?: string; + organization_id?: string; + tenant_id?: string; + role_type?: string; [key: string]: unknown; }; @@ -355,6 +362,7 @@ declare global { clientIp?: string; requestId?: string; token?: ConstructiveAPIToken; + deviceToken?: string; constructive?: ConstructiveContext; } } diff --git a/packages/perf-harness/README.md b/packages/perf-harness/README.md new file mode 100644 index 0000000000..01fc2f1d47 --- /dev/null +++ b/packages/perf-harness/README.md @@ -0,0 +1,42 @@ +# Graphile performance harness + +Reusable infrastructure for measuring Graphile schema builds in fresh Node +processes. The core accepts any list of serializable benchmark cases; it does not +interpret case names or optimization-specific configuration. + +Each measurement receives a new PID, starts Node with `--expose-gc`, runs a +deterministic GC sequence, records build time and memory metrics, validates a +runtime query, and reports a schema hash. Cases can opt into schema equivalence +groups and provide their own lifecycle validation through the worker result. + +## Extending the harness + +Define a suite and provide a dedicated worker entry point: + +```ts +const suite = { + name: 'example', + cases: [ + { + name: 'baseline', + workerConfig: { schemas: ['cperf_example'] }, + expectedSchemaGroup: 'example-schema', + }, + ], +}; + +await runBenchmarkSuite(suite, options, workerPath); +``` + +`workerConfig` must be JSON-serializable. Logic is implemented in the worker +entry rather than serializing functions across process boundaries. + +The package includes `stock-worker.js` as a minimal upstream Graphile baseline. +The top-level commands require `--database-url`; the runner forwards it and the +opaque case configuration to each short-lived worker as CLI arguments. Database +credentials are redacted from worker failures and JSON reports. This harness is +intended for local development on a trusted machine because command arguments +may be visible to other local processes. + +The PostgreSQL fixture command only creates a previously absent schema whose +name starts with `cperf_`; it never drops or replaces schemas. diff --git a/packages/perf-harness/__tests__/fixture.test.ts b/packages/perf-harness/__tests__/fixture.test.ts new file mode 100644 index 0000000000..08e9109e1e --- /dev/null +++ b/packages/perf-harness/__tests__/fixture.test.ts @@ -0,0 +1,22 @@ +import { + validateFixtureSchema, + validateFixtureTableCount, +} from '../src/fixture'; + +describe('fixture safety', () => { + test('only accepts narrowly scoped benchmark schema names', () => { + expect(validateFixtureSchema('cperf_example_1')).toBe('cperf_example_1'); + expect(() => validateFixtureSchema('public')).toThrow( + 'must start with cperf_' + ); + expect(() => + validateFixtureSchema('cperf_example; drop schema public') + ).toThrow('must start with cperf_'); + }); + + test('bounds generated fixture size', () => { + expect(validateFixtureTableCount(64)).toBe(64); + expect(() => validateFixtureTableCount(0)).toThrow('between 1 and 500'); + expect(() => validateFixtureTableCount(501)).toThrow('between 1 and 500'); + }); +}); diff --git a/packages/perf-harness/__tests__/fixtures/fake-worker.js b/packages/perf-harness/__tests__/fixtures/fake-worker.js new file mode 100644 index 0000000000..5ba1e05b83 --- /dev/null +++ b/packages/perf-harness/__tests__/fixtures/fake-worker.js @@ -0,0 +1,40 @@ +'use strict'; + +const valueFor = (name) => { + const flag = `--${name}`; + const index = process.argv.indexOf(flag); + if (index < 0 || !process.argv[index + 1]) { + throw new Error(`${flag} is required`); + } + return process.argv[index + 1]; +}; + +valueFor('database-url'); +const envelope = JSON.parse( + Buffer.from(valueFor('worker-config'), 'base64url').toString('utf8') +); +const value = envelope.workerConfig.value; +const memory = { + rss: value, + heapTotal: value, + heapUsed: value, + external: value, + arrayBuffers: value, +}; +const result = { + status: 'ok', + pid: process.pid, + caseName: envelope.caseName, + buildMs: value, + schemaHash: envelope.workerConfig.schemaHash, + schemaTypeCount: 10, + runtimeVerified: true, + caseValidation: { passed: true, errors: [] }, + memory: { + baseline: memory, + afterBuild: memory, + delta: memory, + processPeakRss: value, + }, +}; +process.stdout.write(`CPERF_RESULT ${JSON.stringify(result)}\n`); diff --git a/packages/perf-harness/__tests__/process.test.ts b/packages/perf-harness/__tests__/process.test.ts new file mode 100644 index 0000000000..4016c8df51 --- /dev/null +++ b/packages/perf-harness/__tests__/process.test.ts @@ -0,0 +1,71 @@ +import { resolve } from 'node:path'; + +import { parseWorkerProcessArgs, runWorkerProcess } from '../src/process'; + +describe('worker CLI protocol', () => { + const encodedConfig = Buffer.from( + JSON.stringify({ caseName: 'baseline', workerConfig: { value: 1 } }) + ).toString('base64url'); + + test('parses the database URL and worker envelope from CLI arguments', () => { + expect( + parseWorkerProcessArgs([ + '--database-url', + 'postgres:///benchmark', + '--worker-config', + encodedConfig, + ]) + ).toEqual({ + databaseUrl: 'postgres:///benchmark', + envelope: { caseName: 'baseline', workerConfig: { value: 1 } }, + }); + }); + + test('rejects missing, duplicate, and unsupported worker arguments', () => { + expect(() => + parseWorkerProcessArgs(['--worker-config', encodedConfig]) + ).toThrow('--database-url is required'); + expect(() => + parseWorkerProcessArgs(['--database-url', 'postgres:///benchmark']) + ).toThrow('--worker-config is required'); + expect(() => + parseWorkerProcessArgs([ + '--database-url', + 'postgres:///one', + '--database-url', + 'postgres:///two', + '--worker-config', + encodedConfig, + ]) + ).toThrow('--database-url may only be specified once'); + expect(() => + parseWorkerProcessArgs([ + '--database-url', + 'postgres:///benchmark', + '--worker-config', + encodedConfig, + '--unexpected', + 'value', + ]) + ).toThrow("unsupported worker argument '--unexpected'"); + }); +}); + +describe('fresh worker process', () => { + test('uses distinct PIDs and does not expose the database URL', async () => { + const worker = resolve(__dirname, 'fixtures/fake-worker.js'); + const definition = { + name: 'baseline', + workerConfig: { value: 1, schemaHash: 'same' }, + }; + const databaseUrl = 'postgres://secret@example.test/database'; + const first = await runWorkerProcess(worker, databaseUrl, definition); + const second = await runWorkerProcess(worker, databaseUrl, definition); + expect(first.pid).not.toBe(process.pid); + expect(second.pid).not.toBe(process.pid); + expect(first.pid).not.toBe(second.pid); + expect(JSON.stringify([first.result, second.result])).not.toContain( + databaseUrl + ); + }); +}); diff --git a/packages/perf-harness/__tests__/report.test.ts b/packages/perf-harness/__tests__/report.test.ts new file mode 100644 index 0000000000..6d7e043d7a --- /dev/null +++ b/packages/perf-harness/__tests__/report.test.ts @@ -0,0 +1,78 @@ +import { + compareCases, + summarizeCase, + validateSchemaGroups, +} from '../src/report'; +import type { BenchmarkRun, SuccessfulWorkerResult } from '../src/types'; + +const result = (caseName: string, value: number): SuccessfulWorkerResult => ({ + status: 'ok', + pid: value, + caseName, + buildMs: value, + schemaHash: 'same', + schemaTypeCount: 10, + runtimeVerified: true, + caseValidation: { passed: true, errors: [] }, + memory: { + baseline: { + rss: 10, + heapTotal: 10, + heapUsed: 10, + external: 10, + arrayBuffers: 10, + }, + afterBuild: { + rss: value, + heapTotal: value, + heapUsed: value, + external: value, + arrayBuffers: value, + }, + delta: { + rss: value - 10, + heapTotal: value - 10, + heapUsed: value - 10, + external: value - 10, + arrayBuffers: value - 10, + }, + processPeakRss: value, + }, +}); + +describe('generic reports', () => { + test('summarizes arbitrary cases and compares medians', () => { + const runs: BenchmarkRun[] = [10, 30, 20].map((value, index) => ({ + repetition: index + 1, + position: 1, + caseName: 'base', + result: result('base', value), + })); + runs.push( + ...[5, 15, 10].map((value, index) => ({ + repetition: index + 1, + position: 2, + caseName: 'candidate', + result: result('candidate', value), + })) + ); + const base = summarizeCase(runs, 'base')!; + const candidate = summarizeCase(runs, 'candidate')!; + expect( + compareCases('base', 'candidate', base, candidate).buildMs.percentChange + ).toBe(-50); + expect( + validateSchemaGroups( + [ + { name: 'base', workerConfig: null, expectedSchemaGroup: 'schema' }, + { + name: 'candidate', + workerConfig: null, + expectedSchemaGroup: 'schema', + }, + ], + runs + ) + ).toEqual({ equivalent: true, hashes: { schema: 'same' }, errors: [] }); + }); +}); diff --git a/packages/perf-harness/__tests__/run.test.ts b/packages/perf-harness/__tests__/run.test.ts new file mode 100644 index 0000000000..4e8ef65d93 --- /dev/null +++ b/packages/perf-harness/__tests__/run.test.ts @@ -0,0 +1,43 @@ +import { resolve } from 'node:path'; + +import { cliMain, runBenchmarkSuite } from '../src/run'; + +describe('generic suite runner', () => { + test('requires the database URL as an explicit CLI argument', async () => { + await expect( + cliMain(['prepare', '--schema', 'cperf_explicit_cli']) + ).rejects.toThrow('--database-url is required'); + }); + + test('validates fresh processes and schema groups without fixed case names', async () => { + const report = await runBenchmarkSuite( + { + name: 'test-suite', + cases: ['alpha', 'beta', 'gamma'].map((name, index) => ({ + name, + workerConfig: { value: index + 1, schemaHash: 'same' }, + expectedSchemaGroup: 'schema', + })), + }, + { + databaseUrl: 'postgres:///not-used-by-fake-worker', + repetitions: 1, + seed: 1, + order: ['alpha', 'beta', 'gamma'], + }, + resolve(__dirname, 'fixtures/fake-worker.js') + ); + expect(report.validation).toEqual( + expect.objectContaining({ + allRunsSucceeded: true, + freshProcessPerRun: true, + caseValidationPassed: true, + schemaGroupsEquivalent: true, + schemaGroups: { schema: 'same' }, + errors: [], + }) + ); + expect(new Set(report.runs.map((run) => run.result.pid)).size).toBe(3); + expect(JSON.stringify(report)).not.toContain('postgres:///'); + }); +}); diff --git a/packages/perf-harness/__tests__/schedule.test.ts b/packages/perf-harness/__tests__/schedule.test.ts new file mode 100644 index 0000000000..89d5e672f8 --- /dev/null +++ b/packages/perf-harness/__tests__/schedule.test.ts @@ -0,0 +1,29 @@ +import { makeSchedule } from '../src/schedule'; + +const cases = [ + { name: 'a', workerConfig: null }, + { name: 'b', workerConfig: null }, + { name: 'c', workerConfig: null }, +]; + +describe('generic benchmark scheduling', () => { + test('is deterministic and supports any case list', () => { + const first = makeSchedule(cases, 4, 1234); + expect(makeSchedule(cases, 4, 1234)).toEqual(first); + expect(makeSchedule(cases, 4, 4321)).not.toEqual(first); + for (let repetition = 1; repetition <= 4; repetition += 1) { + expect( + first + .filter((item) => item.repetition === repetition) + .map((item) => item.caseName) + .sort() + ).toEqual(['a', 'b', 'c']); + } + }); + + test('accepts an exact order containing each case once', () => { + expect( + makeSchedule(cases, 2, 1, ['c', 'a', 'b']).map((item) => item.caseName) + ).toEqual(['c', 'a', 'b', 'c', 'a', 'b']); + }); +}); diff --git a/packages/perf-harness/jest.config.js b/packages/perf-harness/jest.config.js new file mode 100644 index 0000000000..f34711d4ed --- /dev/null +++ b/packages/perf-harness/jest.config.js @@ -0,0 +1,12 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }], + }, + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'], + testPathIgnorePatterns: ['/__tests__/fixtures/'], +}; diff --git a/packages/perf-harness/package.json b/packages/perf-harness/package.json new file mode 100644 index 0000000000..d975054937 --- /dev/null +++ b/packages/perf-harness/package.json @@ -0,0 +1,36 @@ +{ + "name": "@constructive-io/perf-harness", + "version": "0.1.0", + "private": true, + "description": "Reusable fresh-process Graphile performance harness", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "bin": { + "cperf": "index.js" + }, + "scripts": { + "clean": "makage clean", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest" + }, + "dependencies": { + "graphile-build": "5.1.1", + "graphile-build-pg": "5.1.3", + "graphile-config": "1.1.0", + "graphql": "16.13.0", + "pg": "^8.21.0", + "postgraphile": "5.1.4" + }, + "devDependencies": { + "@types/node": "^22.19.11", + "@types/pg": "^8.20.4", + "makage": "^0.8.0" + }, + "engines": { + "node": ">=22" + }, + "license": "MIT" +} diff --git a/packages/perf-harness/src/fixture.ts b/packages/perf-harness/src/fixture.ts new file mode 100644 index 0000000000..b7f35935b6 --- /dev/null +++ b/packages/perf-harness/src/fixture.ts @@ -0,0 +1,127 @@ +import { Pool } from 'pg'; + +export const FIXTURE_VERSION = 1; + +export interface PrepareFixtureOptions { + databaseUrl: string; + schema: string; + tables: number; +} + +export interface PreparedFixture { + fixtureVersion: number; + database: string; + serverVersion: string; + schema: string; + tableCount: number; + functionCount: number; +} + +export const validateFixtureSchema = (schema: string): string => { + if ( + !/^cperf_[a-z0-9_]*$/.test(schema) || + schema.length > 63 || + schema.includes('\0') + ) { + throw new Error( + 'fixture schema must start with cperf_, use only lowercase letters, digits, and underscores, and fit PostgreSQL identifiers' + ); + } + return schema; +}; + +export const validateFixtureTableCount = (tables: number): number => { + if (!Number.isSafeInteger(tables) || tables < 1 || tables > 500) { + throw new Error('fixture table count must be an integer between 1 and 500'); + } + return tables; +}; + +const quoteIdentifier = (identifier: string): string => + `"${identifier.replaceAll('"', '""')}"`; + +export const prepareFixture = async ( + options: PrepareFixtureOptions +): Promise => { + const schema = validateFixtureSchema(options.schema); + const tables = validateFixtureTableCount(options.tables); + const quotedSchema = quoteIdentifier(schema); + const pool = new Pool({ connectionString: options.databaseUrl, max: 1 }); + const client = await pool.connect(); + try { + await client.query('begin'); + const existing = await client.query<{ exists: boolean }>( + 'select exists(select 1 from pg_catalog.pg_namespace where nspname = $1) as exists', + [schema] + ); + if (existing.rows[0]?.exists) { + throw new Error( + `fixture schema '${schema}' already exists; this command never replaces schemas` + ); + } + await client.query(`create schema ${quotedSchema}`); + await client.query( + `comment on schema ${quotedSchema} is 'cperf fixture version ${FIXTURE_VERSION}'` + ); + await client.query( + `create type ${quotedSchema}."entity_status" as enum ('draft', 'active', 'archived')` + ); + await client.query(` + create table ${quotedSchema}."account" ( + id bigint generated always as identity primary key, + external_id uuid not null unique, + name text not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() + ) + `); + for (let index = 1; index <= tables; index += 1) { + const table = quoteIdentifier(`entity_${index}`); + const functionName = quoteIdentifier(`entity_${index}_by_account`); + const indexName = quoteIdentifier(`entity_${index}_account_created_idx`); + await client.query(` + create table ${quotedSchema}.${table} ( + id bigint generated always as identity primary key, + account_id bigint not null references ${quotedSchema}."account"(id), + status ${quotedSchema}."entity_status" not null default 'draft', + title text not null, + tags text[] not null default array[]::text[], + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + unique (account_id, title) + ); + create index ${indexName} + on ${quotedSchema}.${table} (account_id, created_at desc); + create function ${quotedSchema}.${functionName}(requested_account_id bigint) + returns setof ${quotedSchema}.${table} + language sql stable + as 'select * from ${quotedSchema}.${table} where account_id = requested_account_id'; + `); + } + const identity = await client.query<{ + database: string; + server_version: string; + }>( + "select current_database() as database, current_setting('server_version') as server_version" + ); + await client.query('commit'); + return { + fixtureVersion: FIXTURE_VERSION, + database: identity.rows[0].database, + serverVersion: identity.rows[0].server_version, + schema, + tableCount: tables + 1, + functionCount: tables, + }; + } catch (error) { + try { + await client.query('rollback'); + } catch { + // Preserve the fixture preparation error; the client is discarded below. + } + throw error; + } finally { + client.release(); + await pool.end(); + } +}; diff --git a/packages/perf-harness/src/index.ts b/packages/perf-harness/src/index.ts new file mode 100644 index 0000000000..7829d7dc66 --- /dev/null +++ b/packages/perf-harness/src/index.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +export * from './fixture'; +export * from './metrics'; +export * from './process'; +export * from './report'; +export * from './run'; +export * from './schedule'; +export * from './types'; + +import { cliMain } from './run'; + +if (require.main === module) { + void cliMain().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + }); +} diff --git a/packages/perf-harness/src/metrics.ts b/packages/perf-harness/src/metrics.ts new file mode 100644 index 0000000000..007e79fa13 --- /dev/null +++ b/packages/perf-harness/src/metrics.ts @@ -0,0 +1,79 @@ +import { performance } from 'node:perf_hooks'; + +import type { + CaseValidation, + JsonValue, + MemorySnapshot, + SuccessfulWorkerResult, +} from './types'; + +export interface MeasuredCaseResult { + schemaHash: string; + schemaTypeCount: number; + runtimeVerified: true; + caseValidation?: CaseValidation; + metadata?: Record; +} + +const collectGarbage = (): void => { + if (typeof global.gc !== 'function') { + throw new Error('benchmark worker requires Node --expose-gc'); + } + global.gc(); + global.gc(); + global.gc(); +}; + +const memorySnapshot = (): MemorySnapshot => { + const memory = process.memoryUsage(); + return { + rss: memory.rss, + heapTotal: memory.heapTotal, + heapUsed: memory.heapUsed, + external: memory.external, + arrayBuffers: memory.arrayBuffers, + }; +}; + +const memoryDelta = ( + baseline: MemorySnapshot, + afterBuild: MemorySnapshot +): MemorySnapshot => ({ + rss: afterBuild.rss - baseline.rss, + heapTotal: afterBuild.heapTotal - baseline.heapTotal, + heapUsed: afterBuild.heapUsed - baseline.heapUsed, + external: afterBuild.external - baseline.external, + arrayBuffers: afterBuild.arrayBuffers - baseline.arrayBuffers, +}); + +export const measureBenchmarkCase = async ( + caseName: string, + build: () => Promise, + validate: (built: Built) => Promise +): Promise => { + collectGarbage(); + const baseline = memorySnapshot(); + const startedAt = performance.now(); + const built = await build(); + const buildMs = performance.now() - startedAt; + const measured = await validate(built); + collectGarbage(); + const afterBuild = memorySnapshot(); + return { + status: 'ok', + pid: process.pid, + caseName, + buildMs, + schemaHash: measured.schemaHash, + schemaTypeCount: measured.schemaTypeCount, + runtimeVerified: measured.runtimeVerified, + caseValidation: measured.caseValidation ?? { passed: true, errors: [] }, + ...(measured.metadata ? { metadata: measured.metadata } : {}), + memory: { + baseline, + afterBuild, + delta: memoryDelta(baseline, afterBuild), + processPeakRss: process.resourceUsage().maxRSS * 1024, + }, + }; +}; diff --git a/packages/perf-harness/src/process.ts b/packages/perf-harness/src/process.ts new file mode 100644 index 0000000000..dee9097204 --- /dev/null +++ b/packages/perf-harness/src/process.ts @@ -0,0 +1,183 @@ +import { spawn } from 'node:child_process'; + +import type { + BenchmarkCaseDefinition, + WorkerConfigEnvelope, + WorkerResult, +} from './types'; + +export const WORKER_RESULT_PREFIX = 'CPERF_RESULT '; +export const DATABASE_URL_ARGUMENT = 'database-url'; +export const WORKER_CONFIG_ARGUMENT = 'worker-config'; + +export interface ParsedValueArgs { + values: Map; +} + +export interface WorkerProcessArgs { + databaseUrl: string; + envelope: WorkerConfigEnvelope; +} + +export interface SpawnedWorkerResult { + pid: number; + result: WorkerResult; +} + +const lastLines = (value: string, count = 20): string => + value.trim().split('\n').slice(-count).join('\n'); + +export const redactSecret = (value: string, secret: string): string => + secret ? value.replaceAll(secret, '') : value; + +export const parseValueArgs = (args: readonly string[]): ParsedValueArgs => { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if ( + !flag?.startsWith('--') || + value === undefined || + value.startsWith('--') + ) { + throw new Error(`expected --name value near '${flag ?? ''}'`); + } + const name = flag.slice(2); + if (values.has(name)) + throw new Error(`--${name} may only be specified once`); + values.set(name, value); + } + return { values }; +}; + +export const parseWorkerProcessArgs = ( + args: readonly string[] +): WorkerProcessArgs => { + const parsed = parseValueArgs(args); + for (const name of parsed.values.keys()) { + if (name !== DATABASE_URL_ARGUMENT && name !== WORKER_CONFIG_ARGUMENT) { + throw new Error(`unsupported worker argument '--${name}'`); + } + } + const databaseUrl = parsed.values.get(DATABASE_URL_ARGUMENT); + if (!databaseUrl) throw new Error('--database-url is required'); + return { + databaseUrl, + envelope: parseWorkerEnvelope(parsed.values.get(WORKER_CONFIG_ARGUMENT)), + }; +}; + +export const runWorkerProcess = ( + workerPath: string, + databaseUrl: string, + definition: BenchmarkCaseDefinition +): Promise => + new Promise((resolve, reject) => { + const config: WorkerConfigEnvelope = { + caseName: definition.name, + workerConfig: definition.workerConfig, + }; + const child = spawn( + process.execPath, + [ + '--expose-gc', + workerPath, + `--${DATABASE_URL_ARGUMENT}`, + databaseUrl, + `--${WORKER_CONFIG_ARGUMENT}`, + Buffer.from(JSON.stringify(config)).toString('base64url'), + ], + { + env: { + ...process.env, + NODE_ENV: 'production', + GRAPHILE_ENV: 'production', + }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + const pid = child.pid; + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', reject); + child.once('close', (code, signal) => { + const resultLine = stdout + .split('\n') + .reverse() + .find((line) => line.startsWith(WORKER_RESULT_PREFIX)); + if (!resultLine) { + reject( + new Error( + redactSecret( + `benchmark worker ${pid ?? 'unknown'} exited without a result ` + + `(code=${String(code)}, signal=${String(signal)})` + + (stderr.trim() ? `\n${lastLines(stderr)}` : ''), + databaseUrl + ) + ) + ); + return; + } + try { + const result = JSON.parse( + resultLine.slice(WORKER_RESULT_PREFIX.length) + ) as WorkerResult; + if (typeof pid !== 'number' || result.pid !== pid) { + throw new Error( + `worker PID mismatch: spawned ${String(pid)}, reported ${String( + result.pid + )}` + ); + } + if (result.caseName !== definition.name) { + throw new Error( + `worker case mismatch: expected ${definition.name}, reported ${result.caseName}` + ); + } + if (result.status === 'ok' && code !== 0) { + throw new Error(`successful worker exited with code ${String(code)}`); + } + resolve({ pid, result }); + } catch (error) { + reject( + new Error( + redactSecret( + `invalid result from benchmark worker ${String(pid)}: ${String( + error instanceof Error ? error.message : error + )}`, + databaseUrl + ) + ) + ); + } + }); + }); + +export const parseWorkerEnvelope = ( + encoded: string | undefined +): WorkerConfigEnvelope => { + if (!encoded) throw new Error('--worker-config is required'); + const parsed = JSON.parse( + Buffer.from(encoded, 'base64url').toString('utf8') + ) as Partial; + if ( + typeof parsed.caseName !== 'string' || + parsed.caseName.length === 0 || + parsed.workerConfig === undefined + ) { + throw new Error('worker configuration envelope is invalid'); + } + return parsed as WorkerConfigEnvelope; +}; + +export const writeWorkerResult = (result: WorkerResult): void => { + process.stdout.write(`${WORKER_RESULT_PREFIX}${JSON.stringify(result)}\n`); +}; diff --git a/packages/perf-harness/src/report.ts b/packages/perf-harness/src/report.ts new file mode 100644 index 0000000000..680852a961 --- /dev/null +++ b/packages/perf-harness/src/report.ts @@ -0,0 +1,123 @@ +import type { + BenchmarkCaseDefinition, + BenchmarkRun, + CaseComparison, + CaseSummary, + MetricComparison, + MetricSummary, + SuccessfulWorkerResult, +} from './types'; + +const median = (values: readonly number[]): number => { + if (values.length === 0) throw new Error('cannot summarize zero values'); + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +}; + +const metricSummary = (values: number[]): MetricSummary => ({ + median: median(values), + min: Math.min(...values), + max: Math.max(...values), + samples: values, +}); + +const successfulResultsFor = ( + runs: readonly BenchmarkRun[], + caseName: string +): SuccessfulWorkerResult[] => + runs + .filter((run) => run.caseName === caseName && run.result.status === 'ok') + .map((run) => run.result as SuccessfulWorkerResult); + +export const summarizeCase = ( + runs: readonly BenchmarkRun[], + caseName: string +): CaseSummary | undefined => { + const results = successfulResultsFor(runs, caseName); + if (results.length === 0) return undefined; + return { + sampleCount: results.length, + buildMs: metricSummary(results.map((result) => result.buildMs)), + heapUsedAfterBuild: metricSummary( + results.map((result) => result.memory.afterBuild.heapUsed) + ), + heapUsedDelta: metricSummary( + results.map((result) => result.memory.delta.heapUsed) + ), + rssAfterBuild: metricSummary( + results.map((result) => result.memory.afterBuild.rss) + ), + rssDelta: metricSummary(results.map((result) => result.memory.delta.rss)), + processPeakRss: metricSummary( + results.map((result) => result.memory.processPeakRss) + ), + }; +}; + +const compareMetric = ( + baseline: MetricSummary, + candidate: MetricSummary +): MetricComparison => ({ + baseline: baseline.median, + candidate: candidate.median, + difference: candidate.median - baseline.median, + percentChange: + baseline.median === 0 + ? null + : ((candidate.median - baseline.median) / Math.abs(baseline.median)) * + 100, +}); + +export const compareCases = ( + baselineCase: string, + candidateCase: string, + baseline: CaseSummary, + candidate: CaseSummary +): CaseComparison => ({ + baselineCase, + candidateCase, + buildMs: compareMetric(baseline.buildMs, candidate.buildMs), + heapUsedAfterBuild: compareMetric( + baseline.heapUsedAfterBuild, + candidate.heapUsedAfterBuild + ), + heapUsedDelta: compareMetric(baseline.heapUsedDelta, candidate.heapUsedDelta), + rssAfterBuild: compareMetric(baseline.rssAfterBuild, candidate.rssAfterBuild), + rssDelta: compareMetric(baseline.rssDelta, candidate.rssDelta), + processPeakRss: compareMetric( + baseline.processPeakRss, + candidate.processPeakRss + ), +}); + +export const validateSchemaGroups = ( + definitions: readonly BenchmarkCaseDefinition[], + runs: readonly BenchmarkRun[] +): { + equivalent: boolean; + hashes: Record; + errors: string[]; +} => { + const groups = new Map>(); + for (const definition of definitions) { + if (!definition.expectedSchemaGroup) continue; + const hashes = + groups.get(definition.expectedSchemaGroup) ?? new Set(); + for (const result of successfulResultsFor(runs, definition.name)) { + hashes.add(result.schemaHash); + } + groups.set(definition.expectedSchemaGroup, hashes); + } + const output: Record = {}; + const errors: string[] = []; + for (const [group, hashes] of groups) { + output[group] = hashes.size === 1 ? [...hashes][0] : null; + if (hashes.size !== 1) { + errors.push(`schema group '${group}' did not produce one schema hash`); + } + } + return { equivalent: errors.length === 0, hashes: output, errors }; +}; diff --git a/packages/perf-harness/src/run.ts b/packages/perf-harness/src/run.ts new file mode 100644 index 0000000000..ea6cbaed89 --- /dev/null +++ b/packages/perf-harness/src/run.ts @@ -0,0 +1,245 @@ +import { mkdir, rename, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import { prepareFixture } from './fixture'; +import { parseValueArgs, redactSecret, runWorkerProcess } from './process'; +import { summarizeCase, validateSchemaGroups } from './report'; +import { makeSchedule, validateCaseDefinitions } from './schedule'; +import type { + BenchmarkCaseDefinition, + BenchmarkReport, + BenchmarkRun, + BenchmarkSuiteDefinition, +} from './types'; + +export interface RunSuiteOptions { + databaseUrl: string; + repetitions: number; + seed: number; + order: string[] | null; + output?: string; +} + +export const runBenchmarkSuite = async ( + suite: BenchmarkSuiteDefinition, + options: RunSuiteOptions, + workerPath: string +): Promise => { + validateCaseDefinitions(suite.cases); + const byName = new Map( + suite.cases.map((definition) => [definition.name, definition]) + ); + const schedule = makeSchedule( + suite.cases, + options.repetitions, + options.seed, + options.order + ); + const runs: BenchmarkRun[] = []; + for (const coordinate of schedule) { + const definition = byName.get(coordinate.caseName)!; + process.stderr.write( + `[${runs.length + 1}/${schedule.length}] repetition ${ + coordinate.repetition + }, ${coordinate.caseName}\n` + ); + try { + const spawned = await runWorkerProcess( + workerPath, + options.databaseUrl, + definition + ); + runs.push({ ...coordinate, result: spawned.result }); + } catch (error) { + runs.push({ + ...coordinate, + result: { + status: 'error', + pid: -1, + caseName: coordinate.caseName, + error: redactSecret( + error instanceof Error ? error.message : String(error), + options.databaseUrl + ), + }, + }); + } + } + const successfulRuns = runs.filter((run) => run.result.status === 'ok'); + const allRunsSucceeded = successfulRuns.length === schedule.length; + const pids = successfulRuns.map((run) => run.result.pid); + const freshProcessPerRun = + allRunsSucceeded && + pids.every((pid) => pid > 0 && pid !== process.pid) && + new Set(pids).size === pids.length; + const caseValidationPassed = + allRunsSucceeded && + successfulRuns.every( + (run) => run.result.status === 'ok' && run.result.caseValidation.passed + ); + const schemaGroups = validateSchemaGroups(suite.cases, runs); + const errors: string[] = []; + for (const run of runs) { + if (run.result.status === 'error') { + errors.push( + `${run.caseName} repetition ${run.repetition}: ${run.result.error}` + ); + } else { + for (const error of run.result.caseValidation.errors) { + errors.push(`${run.caseName} repetition ${run.repetition}: ${error}`); + } + } + } + if (!freshProcessPerRun) { + errors.push('fresh-process validation did not pass for every run'); + } + errors.push(...schemaGroups.errors); + const summaries: Record< + string, + NonNullable> + > = {}; + for (const definition of suite.cases) { + const summary = summarizeCase(runs, definition.name); + if (summary) summaries[definition.name] = summary; + } + return { + format: 'constructive-performance-suite/v1', + generatedAt: new Date().toISOString(), + node: process.version, + platform: process.platform, + architecture: process.arch, + suite, + config: { + repetitions: options.repetitions, + seed: options.seed, + order: options.order, + }, + schedule, + runs, + validation: { + allRunsSucceeded, + freshProcessPerRun, + caseValidationPassed, + schemaGroupsEquivalent: schemaGroups.equivalent, + schemaGroups: schemaGroups.hashes, + errors, + }, + summaries, + }; +}; + +export const writeJsonAtomically = async ( + output: string, + value: unknown +): Promise => { + const absoluteOutput = resolve(output); + await mkdir(dirname(absoluteOutput), { recursive: true }); + const temporary = `${absoluteOutput}.tmp-${process.pid}`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rename(temporary, absoluteOutput); + return absoluteOutput; +}; + +const positiveInteger = ( + value: string | undefined, + name: string, + defaultValue: number, + maximum: number +): number => { + if (value === undefined) return defaultValue; + if (!/^\d+$/.test(value)) throw new Error(`--${name} must be an integer`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) { + throw new Error(`--${name} must be between 1 and ${maximum}`); + } + return parsed; +}; + +const databaseUrl = (args: ReturnType): string => { + const value = args.values.get('database-url'); + if (!value) throw new Error('--database-url is required'); + return value; +}; + +const stringList = (value: string | undefined): string[] | null => { + if (value === undefined) return null; + const result = value.split(','); + if ( + result.some((item) => item.length === 0 || item.trim() !== item) || + new Set(result).size !== result.length + ) { + throw new Error('list values must be unique exact non-empty strings'); + } + return result; +}; + +const parseCases = (encoded: string): BenchmarkCaseDefinition[] => { + const parsed = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); + if (!Array.isArray(parsed)) + throw new Error('--cases must encode a JSON array'); + return parsed as BenchmarkCaseDefinition[]; +}; + +export const cliMain = async (args = process.argv.slice(2)): Promise => { + const [command, ...rest] = args; + const parsed = parseValueArgs(rest); + if (command === 'prepare') { + const schema = parsed.values.get('schema'); + if (!schema) throw new Error('--schema is required'); + const result = await prepareFixture({ + databaseUrl: databaseUrl(parsed), + schema, + tables: positiveInteger(parsed.values.get('tables'), 'tables', 64, 500), + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (command !== 'run') { + throw new Error('expected prepare or run command'); + } + const cases = parsed.values.get('cases'); + const worker = parsed.values.get('worker'); + if (!cases || !worker) throw new Error('--cases and --worker are required'); + const report = await runBenchmarkSuite( + { + name: parsed.values.get('suite') ?? 'benchmark-suite', + cases: parseCases(cases), + }, + { + databaseUrl: databaseUrl(parsed), + repetitions: positiveInteger( + parsed.values.get('repetitions'), + 'repetitions', + 3, + 50 + ), + seed: positiveInteger( + parsed.values.get('seed'), + 'seed', + 20260813, + 0xffffffff + ), + order: stringList(parsed.values.get('order')), + output: parsed.values.get('output'), + }, + resolve(worker) + ); + const output = await writeJsonAtomically( + parsed.values.get('output') ?? 'performance-report.json', + report + ); + process.stdout.write( + `${JSON.stringify({ output, validation: report.validation })}\n` + ); + if ( + !report.validation.allRunsSucceeded || + !report.validation.freshProcessPerRun || + !report.validation.caseValidationPassed || + !report.validation.schemaGroupsEquivalent + ) { + process.exitCode = 1; + } +}; diff --git a/packages/perf-harness/src/schedule.ts b/packages/perf-harness/src/schedule.ts new file mode 100644 index 0000000000..8eaa98fb86 --- /dev/null +++ b/packages/perf-harness/src/schedule.ts @@ -0,0 +1,77 @@ +import type { BenchmarkCaseDefinition, BenchmarkCoordinate } from './types'; + +const seededRandom = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +}; + +const shuffledCaseNames = ( + definitions: readonly BenchmarkCaseDefinition[], + seed: number, + repetition: number +): string[] => { + const result = definitions.map(({ name }) => name); + const random = seededRandom((seed ^ Math.imul(repetition, 0x9e3779b1)) >>> 0); + for (let index = result.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [result[index], result[swapIndex]] = [result[swapIndex], result[index]]; + } + return result; +}; + +export const validateCaseDefinitions = ( + definitions: readonly BenchmarkCaseDefinition[] +): void => { + if (definitions.length === 0) { + throw new Error('benchmark suite must contain at least one case'); + } + const names = definitions.map(({ name }) => name); + if ( + names.some( + (name) => name.length === 0 || name.trim() !== name || name.includes('\0') + ) || + new Set(names).size !== names.length + ) { + throw new Error( + 'benchmark case names must be unique exact non-empty strings' + ); + } +}; + +export const makeSchedule = ( + definitions: readonly BenchmarkCaseDefinition[], + repetitions: number, + seed: number, + exactOrder: readonly string[] | null = null +): BenchmarkCoordinate[] => { + validateCaseDefinitions(definitions); + if (!Number.isSafeInteger(repetitions) || repetitions < 1) { + throw new Error('repetitions must be a positive safe integer'); + } + const expectedNames = definitions.map(({ name }) => name).sort(); + const schedule: BenchmarkCoordinate[] = []; + for (let repetition = 1; repetition <= repetitions; repetition += 1) { + const order = exactOrder + ? [...exactOrder] + : shuffledCaseNames(definitions, seed, repetition); + if ( + order.length !== definitions.length || + new Set(order).size !== definitions.length || + [...order].sort().some((name, index) => name !== expectedNames[index]) + ) { + throw new Error( + 'exact order must contain each benchmark case exactly once' + ); + } + order.forEach((caseName, index) => { + schedule.push({ repetition, position: index + 1, caseName }); + }); + } + return schedule; +}; diff --git a/packages/perf-harness/src/stock-worker.ts b/packages/perf-harness/src/stock-worker.ts new file mode 100644 index 0000000000..c68cc18f0d --- /dev/null +++ b/packages/perf-harness/src/stock-worker.ts @@ -0,0 +1,93 @@ +import { createHash } from 'node:crypto'; + +import { + defaultPreset as graphileBuildPreset, + makeSchema, +} from 'graphile-build'; +import { defaultPreset as graphileBuildPgPreset } from 'graphile-build-pg'; +import { execute, lexicographicSortSchema, parse, printSchema } from 'graphql'; +import { makePgService } from 'postgraphile/adaptors/pg'; + +import { measureBenchmarkCase } from './metrics'; +import { + parseWorkerProcessArgs, + redactSecret, + writeWorkerResult, +} from './process'; + +interface StockConfig { + schemas: string[]; +} + +const validateConfig = (value: unknown): StockConfig => { + const schemas = (value as Partial)?.schemas; + if ( + !Array.isArray(schemas) || + schemas.length === 0 || + schemas.some((schema) => typeof schema !== 'string' || schema.length === 0) + ) { + throw new Error('stock worker requires a non-empty schemas array'); + } + return { schemas }; +}; + +const main = async (): Promise => { + let databaseUrl = ''; + let caseName = 'unknown'; + try { + const workerArgs = parseWorkerProcessArgs(process.argv.slice(2)); + databaseUrl = workerArgs.databaseUrl; + const { envelope } = workerArgs; + caseName = envelope.caseName; + const config = validateConfig(envelope.workerConfig); + const service = makePgService({ + connectionString: databaseUrl, + schemas: config.schemas, + pubsub: false, + }); + try { + const result = await measureBenchmarkCase( + caseName, + async () => + makeSchema({ + extends: [graphileBuildPreset, graphileBuildPgPreset], + pgServices: [service], + }), + async ({ schema }) => { + const execution = await execute({ + schema, + document: parse('{ __typename }'), + }); + if ( + execution.errors?.length || + execution.data?.__typename !== 'Query' + ) { + throw new Error('runtime verification query failed'); + } + const schemaText = printSchema(lexicographicSortSchema(schema)); + return { + schemaHash: createHash('sha256').update(schemaText).digest('hex'), + schemaTypeCount: Object.keys(schema.getTypeMap()).length, + runtimeVerified: true as const, + }; + } + ); + writeWorkerResult(result); + } finally { + await service.release(); + } + } catch (error) { + writeWorkerResult({ + status: 'error', + pid: process.pid, + caseName, + error: redactSecret( + error instanceof Error ? error.message : String(error), + databaseUrl + ), + }); + process.exitCode = 1; + } +}; + +if (require.main === module) void main(); diff --git a/packages/perf-harness/src/types.ts b/packages/perf-harness/src/types.ts new file mode 100644 index 0000000000..3713a4edc4 --- /dev/null +++ b/packages/perf-harness/src/types.ts @@ -0,0 +1,129 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = + JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export interface BenchmarkCaseDefinition { + name: string; + workerConfig: JsonValue; + expectedSchemaGroup?: string; +} + +export interface BenchmarkSuiteDefinition { + name: string; + cases: BenchmarkCaseDefinition[]; +} + +export interface BenchmarkCoordinate { + repetition: number; + position: number; + caseName: string; +} + +export interface WorkerConfigEnvelope { + caseName: string; + workerConfig: JsonValue; +} + +export interface MemorySnapshot { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + arrayBuffers: number; +} + +export interface CaseValidation { + passed: boolean; + errors: string[]; +} + +export interface SuccessfulWorkerResult { + status: 'ok'; + pid: number; + caseName: string; + buildMs: number; + schemaHash: string; + schemaTypeCount: number; + runtimeVerified: true; + caseValidation: CaseValidation; + metadata?: Record; + memory: { + baseline: MemorySnapshot; + afterBuild: MemorySnapshot; + delta: MemorySnapshot; + processPeakRss: number; + }; +} + +export interface FailedWorkerResult { + status: 'error'; + pid: number; + caseName: string; + error: string; +} + +export type WorkerResult = SuccessfulWorkerResult | FailedWorkerResult; + +export interface BenchmarkRun extends BenchmarkCoordinate { + result: WorkerResult; +} + +export interface MetricSummary { + median: number; + min: number; + max: number; + samples: number[]; +} + +export interface CaseSummary { + sampleCount: number; + buildMs: MetricSummary; + heapUsedAfterBuild: MetricSummary; + heapUsedDelta: MetricSummary; + rssAfterBuild: MetricSummary; + rssDelta: MetricSummary; + processPeakRss: MetricSummary; +} + +export interface MetricComparison { + baseline: number; + candidate: number; + difference: number; + percentChange: number | null; +} + +export interface CaseComparison { + baselineCase: string; + candidateCase: string; + buildMs: MetricComparison; + heapUsedAfterBuild: MetricComparison; + heapUsedDelta: MetricComparison; + rssAfterBuild: MetricComparison; + rssDelta: MetricComparison; + processPeakRss: MetricComparison; +} + +export interface BenchmarkReport { + format: 'constructive-performance-suite/v1'; + generatedAt: string; + node: string; + platform: string; + architecture: string; + suite: BenchmarkSuiteDefinition; + config: { + repetitions: number; + seed: number; + order: string[] | null; + }; + schedule: BenchmarkCoordinate[]; + runs: BenchmarkRun[]; + validation: { + allRunsSucceeded: boolean; + freshProcessPerRun: boolean; + caseValidationPassed: boolean; + schemaGroupsEquivalent: boolean; + schemaGroups: Record; + errors: string[]; + }; + summaries: Record; +} diff --git a/packages/perf-harness/tsconfig.esm.json b/packages/perf-harness/tsconfig.esm.json new file mode 100644 index 0000000000..1a3c9914f1 --- /dev/null +++ b/packages/perf-harness/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "esnext", + "moduleResolution": "bundler" + } +} diff --git a/packages/perf-harness/tsconfig.json b/packages/perf-harness/tsconfig.json new file mode 100644 index 0000000000..319daa4b0d --- /dev/null +++ b/packages/perf-harness/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "moduleResolution": "nodenext", + "module": "nodenext", + "isolatedModules": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f681e75ea..c624189e66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -681,6 +681,9 @@ importers: graphile/graphile-i18n: dependencies: + '@constructive-io/express-context': + specifier: workspace:^ + version: link:../../packages/express-context/dist '@dataplan/pg': specifier: 1.1.1 version: 1.1.1(@dataplan/json@1.0.1(grafast@1.1.2(graphql@16.13.0)))(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) @@ -1283,6 +1286,9 @@ importers: '@constructive-io/bucket-provisioner': specifier: workspace:^ version: link:../../packages/bucket-provisioner/dist + '@constructive-io/express-context': + specifier: workspace:^ + version: link:../../packages/express-context/dist '@constructive-io/graphql-env': specifier: workspace:^ version: link:../../graphql/env/dist @@ -1397,9 +1403,6 @@ importers: pg: specifier: ^8.21.0 version: 8.21.0 - pg-query-context: - specifier: workspace:^ - version: link:../../postgres/pg-query-context/dist pg-sql2: specifier: 5.0.1 version: 5.0.1 @@ -2711,6 +2714,37 @@ importers: version: 0.8.0 publishDirectory: dist + packages/perf-harness: + dependencies: + graphile-build: + specifier: 5.1.1 + version: 5.1.1(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0) + graphile-build-pg: + specifier: 5.1.3 + version: 5.1.3(@dataplan/pg@1.1.1(@dataplan/json@1.0.1(grafast@1.1.2(graphql@16.13.0)))(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.1.2(graphql@16.13.0))(graphile-build@5.1.1(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + graphile-config: + specifier: 1.1.0 + version: 1.1.0 + graphql: + specifier: 16.13.0 + version: 16.13.0 + pg: + specifier: ^8.21.0 + version: 8.21.0 + postgraphile: + specifier: 5.1.4 + version: 5.1.4(f282a162d8bd20a217e08c60f5396af8) + devDependencies: + '@types/node': + specifier: ^22.19.11 + version: 22.19.19 + '@types/pg': + specifier: ^8.20.4 + version: 8.20.4 + makage: + specifier: ^0.8.0 + version: 0.8.0 + packages/postmaster: dependencies: 12factor-env: @@ -3530,6 +3564,9 @@ importers: makage: specifier: ^0.8.0 version: 0.8.0 + pgsql-test: + specifier: workspace:^ + version: link:../pgsql-test/dist publishDirectory: dist postgres/pg-seed: diff --git a/postgres/pg-query-context/package.json b/postgres/pg-query-context/package.json index fdde15b00c..aede3b8bee 100644 --- a/postgres/pg-query-context/package.json +++ b/postgres/pg-query-context/package.json @@ -33,7 +33,8 @@ }, "devDependencies": { "@types/pg": "^8.20.4", - "makage": "^0.8.0" + "makage": "^0.8.0", + "pgsql-test": "workspace:^" }, "keywords": [ "postgresql", diff --git a/postgres/pg-query-context/src/__tests__/index.test.ts b/postgres/pg-query-context/src/__tests__/index.test.ts new file mode 100644 index 0000000000..2cb18a32cb --- /dev/null +++ b/postgres/pg-query-context/src/__tests__/index.test.ts @@ -0,0 +1,174 @@ +import type { Pool, PoolClient } from 'pg'; + +import pgQueryContext, { + UNSAFE_POOLED_CONTEXT_ERROR_CODE, + UnsafePooledContextError, + withPgClient, +} from '../index'; + +const SETTINGS_SQL = + 'SELECT pg_catalog.set_config(setting->>0, setting->>1, true) ' + + 'FROM pg_catalog.json_array_elements($1::json) AS setting'; + +const makePool = () => { + const client = { + query: jest.fn(async () => ({ rows: [] as unknown[] })), + release: jest.fn(), + } as unknown as PoolClient; + const pool = { + connect: jest.fn(async () => client), + totalCount: 1, + } as unknown as Pool; + return { client, pool }; +}; + +describe('pg query context', () => { + it('applies the complete ordered context in one parameterized round trip', async () => { + const { client, pool } = makePool(); + const context = { + 'jwt.claims.user_id': '', + role: 'tenant_runtime', + transaction_read_only: 'off', + search_path: 'pg_catalog, "tenant_api"', + row_security: 'on', + }; + const callback = jest.fn(async () => 'ok'); + + await expect(withPgClient(pool, context, callback)).resolves.toBe('ok'); + + expect(client.query).toHaveBeenNthCalledWith(1, 'BEGIN'); + expect(client.query).toHaveBeenNthCalledWith(2, SETTINGS_SQL, [ + JSON.stringify(Object.entries(context)), + ]); + expect(client.query).toHaveBeenNthCalledWith(3, 'COMMIT'); + expect(callback).toHaveBeenCalledWith(client); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it('does not issue a context query for an empty context', async () => { + const { client, pool } = makePool(); + + await withPgClient(pool, {}, async (): Promise => undefined); + + expect(client.query).toHaveBeenCalledTimes(2); + expect(client.query).toHaveBeenNthCalledWith(1, 'BEGIN'); + expect(client.query).toHaveBeenNthCalledWith(2, 'COMMIT'); + }); + + it('fails closed instead of coercing non-string security settings', async () => { + const { client, pool } = makePool(); + + await expect( + withPgClient( + pool, + { 'jwt.claims.user_id': null } as unknown as Record, + async (): Promise => undefined + ) + ).rejects.toThrow( + "PostgreSQL context setting 'jwt.claims.user_id' must be a string" + ); + + expect(client.query).toHaveBeenNthCalledWith(1, 'BEGIN'); + expect(client.query).toHaveBeenNthCalledWith(2, 'ROLLBACK'); + expect(client.query).toHaveBeenCalledTimes(2); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it('rolls back and releases when the batched context is rejected', async () => { + const { client, pool } = makePool(); + (client.query as jest.Mock) + .mockResolvedValueOnce({ rows: [] }) + .mockRejectedValueOnce(new Error('invalid role')) + .mockResolvedValueOnce({ rows: [] }); + + await expect( + withPgClient( + pool, + { role: 'missing_role' }, + async (): Promise => undefined + ) + ).rejects.toThrow('invalid role'); + + expect(client.query).toHaveBeenNthCalledWith(3, 'ROLLBACK'); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it('preserves callback failures while rolling back and releasing', async () => { + const { client, pool } = makePool(); + const original = new Error('callback failed'); + + await expect( + withPgClient(pool, { role: 'tenant_runtime' }, async () => { + throw original; + }) + ).rejects.toBe(original); + + expect(client.query).toHaveBeenNthCalledWith(3, 'ROLLBACK'); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it('uses the same single context batch for the one-query API', async () => { + const { client, pool } = makePool(); + (client.query as jest.Mock).mockImplementation(async (query: unknown) => ({ + rows: [ + query === 'SELECT tenant_id FROM documents' + ? { tenant_id: 'a' } + : undefined, + ].filter(Boolean), + })); + + await pgQueryContext({ + client: pool, + context: { role: 'tenant_runtime', 'jwt.claims.tenant_id': 'a' }, + query: 'SELECT tenant_id FROM documents', + }); + + expect(client.query).toHaveBeenNthCalledWith(2, SETTINGS_SQL, [ + JSON.stringify([ + ['role', 'tenant_runtime'], + ['jwt.claims.tenant_id', 'a'], + ]), + ]); + expect(client.query).toHaveBeenCalledTimes(4); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it('rejects transaction-local context through a pool without a transaction', async () => { + const { pool } = makePool(); + + await expect( + withPgClient( + pool, + { role: 'tenant_runtime' }, + async (): Promise => undefined, + { skipTransaction: true } + ) + ).rejects.toMatchObject({ + name: UnsafePooledContextError.name, + code: UNSAFE_POOLED_CONTEXT_ERROR_CODE, + }); + + expect(pool.connect).not.toHaveBeenCalled(); + + await expect( + pgQueryContext({ + client: pool, + context: { 'jwt.claims.tenant_id': 'tenant-a' }, + query: 'SELECT 1', + skipTransaction: true, + }) + ).rejects.toBeInstanceOf(UnsafePooledContextError); + expect(pool.connect).not.toHaveBeenCalled(); + }); + + it('allows transaction-free pooled execution only when no context is requested', async () => { + const { client, pool } = makePool(); + + await expect( + withPgClient(pool, {}, async () => 'ok', { skipTransaction: true }) + ).resolves.toBe('ok'); + + expect(client.query).not.toHaveBeenCalled(); + expect(client.release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/postgres/pg-query-context/src/__tests__/postgres.integration.test.ts b/postgres/pg-query-context/src/__tests__/postgres.integration.test.ts new file mode 100644 index 0000000000..1b2e935aef --- /dev/null +++ b/postgres/pg-query-context/src/__tests__/postgres.integration.test.ts @@ -0,0 +1,201 @@ +import { Pool, type PoolClient } from 'pg'; +import { getConnections } from 'pgsql-test'; + +import pgQueryContext, { withPgClient } from '../index'; + +interface SessionState { + role: string; + transaction_read_only: string; + search_path: string; + row_security: string; + user_id: string; +} + +async function readSessionState(client: PoolClient): Promise { + const result = await client.query(` + SELECT + current_setting('role') AS role, + current_setting('transaction_read_only') AS transaction_read_only, + current_setting('search_path') AS search_path, + current_setting('row_security') AS row_security, + current_setting('jwt.claims.user_id', true) AS user_id + `); + return result.rows[0]; +} + +describe('pg-query-context transaction-local integration', () => { + let db: Awaited>['db']; + let teardown: Awaited>['teardown']; + let singleClientPool: Pool; + + beforeAll(async () => { + ({ db, teardown } = await getConnections({}, [])); + singleClientPool = new Pool({ ...db.config, max: 1 }); + }); + + afterAll(async () => { + if (singleClientPool) await singleClientPool.end(); + if (teardown) await teardown(); + }); + + beforeEach(async () => { + if (db) await db.beforeEach(); + }); + + afterEach(async () => { + if (db) await db.afterEach(); + }); + + it('applies a complete context and restores state after rollback', async () => { + const { rows: identityRows } = await db.client.query<{ + current_user: string; + }>('SELECT current_user'); + const currentUser = identityRows[0].current_user; + const context = { + role: currentUser, + 'jwt.claims.user_id': 'integration-user', + 'jwt.claims.api_id': 'integration-api', + 'jwt.claims.database_id': 'integration-database', + 'request.id': 'integration-request', + transaction_read_only: 'on', + row_security: 'on', + search_path: 'pg_catalog, public', + }; + + const result = await pgQueryContext({ + client: db.client, + context, + skipTransaction: true, + query: ` + SELECT + current_user, + current_setting('jwt.claims.user_id', true) AS user_id, + current_setting('jwt.claims.api_id', true) AS api_id, + current_setting('jwt.claims.database_id', true) AS database_id, + current_setting('request.id', true) AS request_id, + current_setting('transaction_read_only') AS read_only, + current_setting('row_security') AS row_security, + current_setting('search_path') AS search_path + `, + }); + + expect(result.rows[0]).toEqual({ + current_user: currentUser, + user_id: 'integration-user', + api_id: 'integration-api', + database_id: 'integration-database', + request_id: 'integration-request', + read_only: 'on', + row_security: 'on', + search_path: 'pg_catalog, public', + }); + + await db.rollback(); + const restored = await db.client.query<{ + user_id: string; + request_id: string; + }>(` + SELECT + current_setting('jwt.claims.user_id', true) AS user_id, + current_setting('request.id', true) AS request_id + `); + // PostgreSQL retains an empty placeholder for a custom GUC after its first + // transaction-local use; importantly, the request values themselves do not + // survive the rollback. + expect(restored.rows[0]).toEqual({ user_id: '', request_id: '' }); + await db.savepoint(); + }); + + it('restores a reused backend baseline after commit and rollback', async () => { + const baselineClient = await singleClientPool.connect(); + let runtimeRole: string; + try { + const identity = await baselineClient.query<{ current_user: string }>( + 'SELECT current_user' + ); + runtimeRole = identity.rows[0].current_user; + + await baselineClient.query('RESET ROLE'); + await baselineClient.query('SET transaction_read_only TO off'); + await baselineClient.query('SET search_path TO public'); + await baselineClient.query('SET row_security TO off'); + await baselineClient.query( + "SELECT pg_catalog.set_config('jwt.claims.user_id', 'baseline-user', false)" + ); + } finally { + baselineClient.release(); + } + + const committedInside = await withPgClient( + singleClientPool, + { + role: runtimeRole, + transaction_read_only: 'on', + search_path: 'pg_catalog', + row_security: 'on', + 'jwt.claims.user_id': '', + }, + readSessionState + ); + + expect(committedInside).toEqual({ + role: runtimeRole, + transaction_read_only: 'on', + search_path: 'pg_catalog', + row_security: 'on', + user_id: '', + }); + + const afterCommitClient = await singleClientPool.connect(); + try { + await expect(readSessionState(afterCommitClient)).resolves.toEqual({ + role: 'none', + transaction_read_only: 'off', + search_path: 'public', + row_security: 'off', + user_id: 'baseline-user', + }); + } finally { + afterCommitClient.release(); + } + + let rolledBackInside: SessionState | undefined; + await expect( + withPgClient( + singleClientPool, + { + role: runtimeRole, + transaction_read_only: 'on', + search_path: 'pg_catalog', + row_security: 'on', + 'jwt.claims.user_id': 'rollback-canary', + }, + async (client) => { + rolledBackInside = await readSessionState(client); + throw new Error('force rollback'); + } + ) + ).rejects.toThrow('force rollback'); + + expect(rolledBackInside).toEqual({ + role: runtimeRole, + transaction_read_only: 'on', + search_path: 'pg_catalog', + row_security: 'on', + user_id: 'rollback-canary', + }); + + const afterRollbackClient = await singleClientPool.connect(); + try { + await expect(readSessionState(afterRollbackClient)).resolves.toEqual({ + role: 'none', + transaction_read_only: 'off', + search_path: 'public', + row_security: 'off', + user_id: 'baseline-user', + }); + } finally { + afterRollbackClient.release(); + } + }); +}); diff --git a/postgres/pg-query-context/src/index.ts b/postgres/pg-query-context/src/index.ts index 188d8670f2..eebcdb608c 100644 --- a/postgres/pg-query-context/src/index.ts +++ b/postgres/pg-query-context/src/index.ts @@ -2,18 +2,58 @@ import { ClientBase, Pool, PoolClient, QueryResult } from 'pg'; // --- Internal helpers --- -function setContext(ctx: Record): { query: string; values: string[] }[] { - return Object.keys(ctx || {}).reduce<{ query: string; values: string[] }[]>((m, el) => { - m.push({ query: 'SELECT set_config($1, $2, true)', values: [el, ctx[el]] }); - return m; - }, []); +export const UNSAFE_POOLED_CONTEXT_ERROR_CODE = + 'PG_QUERY_CONTEXT_UNSAFE_POOLED_CONTEXT'; + +export class UnsafePooledContextError extends Error { + readonly code = UNSAFE_POOLED_CONTEXT_ERROR_CODE; + + constructor() { + super( + 'Transaction-local PostgreSQL context cannot be applied through a pool ' + + 'when skipTransaction is enabled' + ); + this.name = 'UnsafePooledContextError'; + } +} + +function assertContextHasTransaction( + usesPool: boolean, + skipTransaction: boolean, + context: Record +): void { + if (usesPool && skipTransaction && Object.keys(context).length > 0) { + throw new UnsafePooledContextError(); + } } -async function execContext(client: ClientBase, ctx: Record): Promise { - const local = setContext(ctx); - for (const { query, values } of local) { - await client.query(query, values); +function isPgPool(client: Pool | ClientBase): client is Pool { + return ( + typeof (client as Pool).connect === 'function' && + typeof (client as Pool).totalCount === 'number' + ); +} + +async function execContext( + client: ClientBase, + ctx: Record +): Promise { + const entries = Object.entries(ctx || {}); + if (entries.length === 0) return; + + for (const [key, value] of entries) { + if (typeof value !== 'string') { + throw new TypeError( + `PostgreSQL context setting '${key}' must be a string` + ); + } } + + await client.query( + 'SELECT pg_catalog.set_config(setting->>0, setting->>1, true) ' + + 'FROM pg_catalog.json_array_elements($1::json) AS setting', + [JSON.stringify(entries)] + ); } // --- Single-query API (original) --- @@ -27,10 +67,12 @@ export interface ExecOptions { } async function pgQueryContext({ client, context = {}, query = '', variables = [], skipTransaction = false }: ExecOptions): Promise { - const isPool = 'connect' in client; + const isPool = isPgPool(client); const shouldRelease = isPool; let pgClient: ClientBase | PoolClient | null = null; + assertContextHasTransaction(isPool, skipTransaction, context); + try { pgClient = isPool ? await (client as Pool).connect() : client as ClientBase; @@ -80,6 +122,7 @@ export async function withPgClient( fn: (client: PoolClient) => Promise, opts: WithPgClientOptions = {}, ): Promise { + assertContextHasTransaction(true, opts.skipTransaction === true, context); const client = await pool.connect(); try { if (!opts.skipTransaction) {