Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions graphile/graphile-settings/__tests__/grafast-cache-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { grafastSync } from 'grafast';
import { buildSchema, GraphQLSchema, type GraphQLSchemaConfig } from 'graphql';

import {
applyGrafastCacheLimits,
createGrafastCacheLimitsPlugin,
createGrafastCacheLimitsPreset,
} from '../src/grafast-cache-limits';

const schemaConfig = (): GraphQLSchemaConfig => ({
extensions: {
existing: true,
grafast: { queryCacheMaxLength: 99 },
},
});

describe('Grafast schema-local cache limits', () => {
it('preserves unrelated schema and Grafast extensions', () => {
const result = applyGrafastCacheLimits(schemaConfig(), {
operationsCacheMaxLength: 16,
operationOperationPlansCacheMaxLength: 8,
});

expect(result.extensions).toMatchObject({
existing: true,
grafast: {
queryCacheMaxLength: 99,
operationsCacheMaxLength: 16,
operationOperationPlansCacheMaxLength: 8,
},
});
});

it('installs an immutable limit snapshot through the GraphQLSchema hook', () => {
const limits = {
queryCacheMaxLength: 16,
operationsCacheMaxLength: 8,
operationOperationPlansCacheMaxLength: 4,
};
const plugin = createGrafastCacheLimitsPlugin(limits);
limits.operationsCacheMaxLength = 64;

expect(plugin.name).toBe('GrafastCacheLimitsPlugin');
const hook = plugin.schema?.hooks?.GraphQLSchema;
expect(typeof hook).toBe('function');

const result = (hook as Function)(schemaConfig(), {}, {});
expect(result.extensions?.grafast).toMatchObject({
queryCacheMaxLength: 16,
operationsCacheMaxLength: 8,
operationOperationPlansCacheMaxLength: 4,
});
});

it('is inert when no limits are configured', () => {
expect(createGrafastCacheLimitsPreset()).toEqual({});
expect(createGrafastCacheLimitsPreset({})).toEqual({});
});

it('returns one plugin preset when at least one limit is configured', () => {
const preset = createGrafastCacheLimitsPreset({ queryCacheMaxLength: 8 });

expect(preset.plugins).toHaveLength(1);
expect(preset.plugins?.[0].name).toBe('GrafastCacheLimitsPlugin');
});

it('bounds Grafast query and operation caches under query diversity', () => {
const config = applyGrafastCacheLimits(
buildSchema('type Query { hello: String }').toConfig(),
{
queryCacheMaxLength: 2,
operationsCacheMaxLength: 2,
}
);
const schema = new GraphQLSchema(config);

for (let index = 0; index < 4; index += 1) {
const result = grafastSync({
schema,
source: `query CacheCase${index} { hello }`,
rootValue: { hello: 'world' },
});
expect(result.errors).toBeUndefined();
}

const grafastExtensions = schema.extensions.grafast as unknown as Record<
symbol,
{ length?: number }
>;
const cacheLengths = Object.fromEntries(
Object.getOwnPropertySymbols(grafastExtensions).map((symbol) => [
symbol.description,
grafastExtensions[symbol].length,
])
);
expect(cacheLengths).toMatchObject({ queryCache: 2, cacheByOperation: 2 });
});

it('rejects invalid limits before Graphile schema construction', () => {
expect(() =>
createGrafastCacheLimitsPreset({ operationsCacheMaxLength: 1 })
).toThrow(
'graphile.grafastCache.operationsCacheMaxLength must be a safe integer of at least 2'
);
});
});
47 changes: 47 additions & 0 deletions graphile/graphile-settings/src/grafast-cache-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { normalizeGrafastCacheLimits } from '@constructive-io/graphql-env';
import type { GrafastCacheLimits } from '@constructive-io/graphql-types';
import type { GraphileConfig } from 'graphile-config';
import type { GraphQLSchemaConfig } from 'graphql';

/** Apply authoritative per-schema cache limits without disturbing extensions. */
export const applyGrafastCacheLimits = (
config: GraphQLSchemaConfig,
limits: Readonly<GrafastCacheLimits>
): GraphQLSchemaConfig => ({
...config,
extensions: {
...(config.extensions ?? {}),
grafast: {
...(config.extensions?.grafast ?? {}),
...limits,
},
},
});

/** Reusable plugin for bounding Grafast's schema-local runtime caches. */
export const createGrafastCacheLimitsPlugin = (
limits: GrafastCacheLimits
): GraphileConfig.Plugin => {
const normalized = normalizeGrafastCacheLimits(limits) ?? {};
return {
name: 'GrafastCacheLimitsPlugin',
version: '1.0.0',
description: 'Bounds schema-local Grafast parse and operation-plan caches',
schema: {
hooks: {
GraphQLSchema(config) {
return applyGrafastCacheLimits(config, normalized);
},
},
},
};
};

export const createGrafastCacheLimitsPreset = (
limits?: GrafastCacheLimits
): GraphileConfig.Preset => {
const normalized = normalizeGrafastCacheLimits(limits);
return normalized === undefined || Object.keys(normalized).length === 0
? {}
: { plugins: [createGrafastCacheLimitsPlugin(normalized)] };
};
6 changes: 6 additions & 0 deletions graphile/graphile-settings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ import 'graphile-build';

import { makePgService } from 'postgraphile/adaptors/pg';

export {
applyGrafastCacheLimits,
createGrafastCacheLimitsPlugin,
createGrafastCacheLimitsPreset
} from './grafast-cache-limits';

// ============================================================================
// Re-export all plugins and presets
// ============================================================================
Expand Down
8 changes: 8 additions & 0 deletions graphql/env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag
### GraphQL Schema
- `GRAPHILE_SCHEMA` - Comma-separated list of PostgreSQL schemas to expose

### Grafast Cache Limits
- `GRAPHILE_QUERY_CACHE_MAX_LENGTH` - Maximum parsed and validated queries retained per schema
- `GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH` - Maximum operations retained for plan lookup per schema
- `GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH` - Maximum context/variable-specific plans retained per operation

Each cache limit must be a safe integer of at least `2`. When omitted, Grafast's
upstream default for that cache remains in effect.

### Feature Flags
- `FEATURES_SIMPLE_INFLECTION` - Enable simple inflection plugin
- `FEATURES_OPPOSITE_BASE_NAMES` - Enable opposite base names
Expand Down
124 changes: 124 additions & 0 deletions graphql/env/__tests__/grafast-cache-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

import { getGraphQLEnvVars } from '../src/env';
import { normalizeGrafastCacheLimits } from '../src/grafast-cache-limits';
import { getEnvOptions } from '../src/merge';

describe('Grafast cache-limit configuration', () => {
it('normalizes a partial configuration into an immutable copy', () => {
const input = {
queryCacheMaxLength: 64,
operationOperationPlansCacheMaxLength: 8,
};

const normalized = normalizeGrafastCacheLimits(input);
input.queryCacheMaxLength = 128;

expect(normalized).toEqual({
queryCacheMaxLength: 64,
operationOperationPlansCacheMaxLength: 8,
});
expect(Object.isFrozen(normalized)).toBe(true);
});

it.each([0, 1, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Infinity, NaN])(
'rejects an unsafe bound %s',
(value) => {
expect(() =>
normalizeGrafastCacheLimits({ operationsCacheMaxLength: value })
).toThrow(
'graphile.grafastCache.operationsCacheMaxLength must be a safe integer of at least 2'
);
}
);

it('rejects malformed objects and unknown settings', () => {
expect(() => normalizeGrafastCacheLimits([] as unknown as {})).toThrow(
'graphile.grafastCache must be an object'
);
expect(() =>
normalizeGrafastCacheLimits({
queryCacheMaximum: 8,
} as unknown as {})
).toThrow("contains unsupported setting 'queryCacheMaximum'");
});

it('maps all three environment variables', () => {
expect(
getGraphQLEnvVars({
GRAPHILE_QUERY_CACHE_MAX_LENGTH: '64',
GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH: '32',
GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH: '8',
}).graphile?.grafastCache
).toEqual({
queryCacheMaxLength: 64,
operationsCacheMaxLength: 32,
operationOperationPlansCacheMaxLength: 8,
});
});

it.each(['', '0', '1', '-1', '1.5', '12entries'])(
'rejects an invalid environment bound %s',
(value) => {
expect(() =>
getGraphQLEnvVars({ GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH: value })
).toThrow(
'GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH must be a safe integer of at least 2'
);
}
);

it('keeps the feature absent when no limit is configured', () => {
expect(getGraphQLEnvVars({}).graphile?.grafastCache).toBeUndefined();
});

it('validates the final config, environment, and override merge', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'grafast-cache-'));
try {
fs.writeFileSync(
path.join(tempDir, 'pgpm.json'),
JSON.stringify({
graphile: {
grafastCache: {
queryCacheMaxLength: 64,
operationsCacheMaxLength: 64,
},
},
})
);

const options = getEnvOptions(
{
graphile: {
grafastCache: { operationOperationPlansCacheMaxLength: 8 },
},
},
tempDir,
{ GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH: '32' }
);

expect(options.graphile?.grafastCache).toEqual({
queryCacheMaxLength: 64,
operationsCacheMaxLength: 32,
operationOperationPlansCacheMaxLength: 8,
});
expect(Object.isFrozen(options.graphile?.grafastCache)).toBe(true);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

it('rejects an invalid runtime override during final validation', () => {
expect(() =>
getEnvOptions(
{ graphile: { grafastCache: { queryCacheMaxLength: 1 } } },
process.cwd(),
{}
)
).toThrow(
'graphile.grafastCache.queryCacheMaxLength must be a safe integer of at least 2'
);
});
});
29 changes: 29 additions & 0 deletions graphql/env/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { ConstructiveOptions } from '@constructive-io/graphql-types';
import { parseEnvBoolean, parseEnvNumber } from '12factor-env';

import {
normalizeGrafastCacheLimits,
parseGrafastCacheLimitEnv
} from './grafast-cache-limits';

/**
* @param env - Environment object to read from (defaults to process.env for backwards compatibility)
*/
export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial<ConstructiveOptions> => {
const {
GRAPHILE_SCHEMA,
GRAPHILE_QUERY_CACHE_MAX_LENGTH,
GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH,
GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH,

FEATURES_SIMPLE_INFLECTION,
FEATURES_OPPOSITE_BASE_NAMES,
Expand Down Expand Up @@ -38,6 +46,11 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
// let an absent env var overwrite pgpm.json or consumer-specific values.
const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS);
const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN);
const hasGrafastCacheLimits = [
GRAPHILE_QUERY_CACHE_MAX_LENGTH,
GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH,
GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH
].some(value => value !== undefined);
const hasSmsEnvOverrides = Boolean(
SMS_PROVIDER ||
SMS_SENDER_ID ||
Expand All @@ -48,6 +61,22 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial

return {
graphile: {
...(hasGrafastCacheLimits && {
grafastCache: normalizeGrafastCacheLimits({
queryCacheMaxLength: parseGrafastCacheLimitEnv(
GRAPHILE_QUERY_CACHE_MAX_LENGTH,
'GRAPHILE_QUERY_CACHE_MAX_LENGTH'
),
operationsCacheMaxLength: parseGrafastCacheLimitEnv(
GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH,
'GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH'
),
operationOperationPlansCacheMaxLength: parseGrafastCacheLimitEnv(
GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH,
'GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH'
)
})
}),
...(GRAPHILE_SCHEMA && {
schema: GRAPHILE_SCHEMA.includes(',')
? GRAPHILE_SCHEMA.split(',').map(s => s.trim())
Expand Down
Loading