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
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { createI18nPlugin } from '../plugin';

function makeBuild(origin: string, translatedField: string) {
const idCodec = { name: 'uuid' };
const textCodec = { name: 'text' };
const baseCodec = {
name: 'posts',
attributes: {
id: { codec: idCodec },
[translatedField]: { codec: textCodec },
},
extensions: {
pg: { schemaName: 'tenant', name: 'posts' },
tags: { i18n: 'posts_translations' },
},
};
const translationCodec = {
name: 'postsTranslations',
attributes: {
posts_id: { codec: idCodec },
lang_code: { codec: textCodec },
[translatedField]: { codec: textCodec },
},
extensions: {
pg: { schemaName: 'tenant', name: 'posts_translations' },
},
};
class GraphQLObjectType {
readonly origin = origin;
constructor(readonly config: any) {}
}
class GraphQLNonNull {
constructor(readonly ofType: any) {}
}
const build = {
input: {
pgRegistry: {
pgCodecs: { baseCodec, translationCodec },
pgResources: {
base: {
codec: baseCodec,
uniques: [{ isPrimary: true, attributes: ['id'] }],
},
translation: { codec: translationCodec },
},
},
},
inflection: {
camelCase: (value: string) => value,
tableType: () => 'Post',
},
graphql: {
GraphQLString: { name: 'String', origin },
GraphQLObjectType,
GraphQLNonNull,
},
extend: (base: object, extra: object) => ({ ...base, ...extra }),
};
return { build, baseCodec };
}

describe('I18nPlugin cache ownership', () => {
it('keeps registry and GraphQL types local to the exact build', () => {
const plugin = createI18nPlugin();
const init = (plugin.schema!.hooks!.init as any).callback;
const fieldsHook = plugin.schema!.hooks!.GraphQLObjectType_fields as any;
const tenantA = makeBuild('tenant-a', 'title');
const tenantB = makeBuild('tenant-b', 'summary');

init({}, tenantA.build);
init({}, tenantB.build);

const fieldsA = fieldsHook({}, tenantA.build, {
scope: { isPgClassType: true, pgCodec: tenantA.baseCodec },
});
const fieldsAAgain = fieldsHook({}, tenantA.build, {
scope: { isPgClassType: true, pgCodec: tenantA.baseCodec },
});
const fieldsB = fieldsHook({}, tenantB.build, {
scope: { isPgClassType: true, pgCodec: tenantB.baseCodec },
});
const localeTypeA = fieldsA.localeStrings.type.ofType;
const localeTypeB = fieldsB.localeStrings.type.ofType;

expect(localeTypeA).toBe(fieldsAAgain.localeStrings.type.ofType);
expect(localeTypeA).not.toBe(localeTypeB);
expect(localeTypeA.origin).toBe('tenant-a');
expect(localeTypeB.origin).toBe('tenant-b');
expect(localeTypeA.config.fields).toHaveProperty('title');
expect(localeTypeA.config.fields).not.toHaveProperty('summary');
expect(localeTypeB.config.fields).toHaveProperty('summary');
});
});
33 changes: 22 additions & 11 deletions graphile/graphile-i18n/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ function resolveAttrPgType(codec: any): string {
return codec?.name ?? 'text';
}

interface I18nBuildState {
registry: WeakMap<PgCodecWithAttributes, I18nTableInfo>;
localeTypeCache: Map<string, any>;
}

// ─── Plugin Factory ──────────────────────────────────────────────────────────

export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfig.Plugin {
Expand All @@ -73,9 +78,9 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi
defaultLanguages = ['en'],
} = options;

// Closure-scoped state shared between init and field hooks
let i18nRegistry: Record<string, I18nTableInfo> = {};
const localeTypeCache: Record<string, any> = {};
// A preset/plugin instance may be reused for multiple schema builds. Keep
// discovery and GraphQL type state owned by the exact build that created it.
const stateByBuild = new WeakMap<object, I18nBuildState>();

return {
name: 'I18nPlugin',
Expand All @@ -85,7 +90,11 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi
hooks: {
init: {
callback(_, build) {
i18nRegistry = {};
const state: I18nBuildState = {
registry: new WeakMap(),
localeTypeCache: new Map()
};
stateByBuild.set(build, state);

for (const [, codec] of Object.entries(build.input.pgRegistry.pgCodecs)) {
const c = codec as PgCodecWithAttributes;
Expand Down Expand Up @@ -189,15 +198,15 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi

if (Object.keys(fields).length === 0) continue;

i18nRegistry[c.name] = {
state.registry.set(c, {
baseTable: c.name,
translationTable: translationTableName,
schemaName,
fkColumn,
pkColumn,
pkType,
fields,
};
});
}

return _;
Expand All @@ -210,8 +219,10 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi

if (!scope.pgCodec || !scope.isPgClassType) return fields;

const state = stateByBuild.get(build);
if (!state) return fields;
const codec = scope.pgCodec as PgCodecWithAttributes;
const info = i18nRegistry[codec.name];
const info = state.registry.get(codec);
if (!info) return fields;

const localeFieldsConfig: Record<string, any> = {
Expand All @@ -225,13 +236,13 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi
}

const localeTypeName = `${build.inflection.tableType(codec)}LocaleStrings`;
if (!localeTypeCache[localeTypeName]) {
localeTypeCache[localeTypeName] = new GraphQLObjectType({
if (!state.localeTypeCache.has(localeTypeName)) {
state.localeTypeCache.set(localeTypeName, new GraphQLObjectType({
name: localeTypeName,
fields: localeFieldsConfig,
});
}));
}
const localeType = localeTypeCache[localeTypeName];
const localeType = state.localeTypeCache.get(localeTypeName);

const { schemaName, baseTable, translationTable, fkColumn, pkColumn, pkType, fields: i18nFields } = info;

Expand Down
13 changes: 13 additions & 0 deletions graphile/graphile-llm/__tests__/agent-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ describe('getAgentDiscovery', () => {
expect(calls).toHaveLength(2);
});

it('does not share discovery across physical pool identities', async () => {
const first = fakePool(() => ({ rows: [row('physical_a')] }));
const second = fakePool(() => ({ rows: [row('physical_b')] }));

const fromFirst = await getAgentDiscovery(first.pool, DB_A);
const fromSecond = await getAgentDiscovery(second.pool, DB_A);

expect(fromFirst?.thread?.schemaName).toBe('physical_a_agent_public');
expect(fromSecond?.thread?.schemaName).toBe('physical_b_agent_public');
expect(first.calls).toHaveLength(1);
expect(second.calls).toHaveLength(1);
});

it('treats an absent module as not provisioned', async () => {
const { pool } = fakePool(() => {
throw pgError('42P01');
Expand Down
64 changes: 64 additions & 0 deletions graphile/graphile-llm/src/__tests__/config-cache-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
getLlmBillingCacheStats,
getLlmBillingConfig,
invalidateLlmBillingConfig,
} from '../config-cache';

function makeClient(privateSchema: string) {
const query = jest.fn(async (text: string) => {
if (text.includes('information_schema.schemata'))
return { rows: [{ exists: 1 }] };
if (text.includes('billing_module')) {
return {
rows: [
{
public_schema: `${privateSchema}_public`,
private_schema: privateSchema,
record_usage_function: 'record_usage',
},
],
};
}
if (text.includes('inference_log_module')) {
return {
rows: [{ schema: privateSchema, table_name: 'usage_log_inference' }],
};
}
throw new Error('unexpected SQL');
});
return { query };
}

describe('LLM config cache ownership', () => {
beforeEach(() => invalidateLlmBillingConfig());

it('isolates the same database UUID by exact build identity', async () => {
const databaseId = '11111111-1111-1111-1111-111111111111';
const buildA = {};
const buildB = {};
const clientA = makeClient('tenant_a_private');
const clientB = makeClient('tenant_b_private');

const firstA = await getLlmBillingConfig(clientA, databaseId, buildA);
const cachedA = await getLlmBillingConfig(clientA, databaseId, buildA);
const firstB = await getLlmBillingConfig(clientB, databaseId, buildB);

expect(firstA).toBe(cachedA);
expect(firstA.billing?.privateSchema).toBe('tenant_a_private');
expect(firstB.billing?.privateSchema).toBe('tenant_b_private');
expect(clientA.query).toHaveBeenCalledTimes(4);
expect(clientB.query).toHaveBeenCalledTimes(4);
expect(getLlmBillingCacheStats(buildA).size).toBe(1);
expect(getLlmBillingCacheStats(buildB).size).toBe(1);
});

it('requires an explicit cache owner', async () => {
await expect(
getLlmBillingConfig(
makeClient('tenant_private'),
'database-a',
null as any
)
).rejects.toThrow('LLM_CONFIG_CACHE_SCOPE_UNAVAILABLE');
});
});
56 changes: 45 additions & 11 deletions graphile/graphile-llm/src/config-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,29 @@ const INFERENCE_LOG_MODULE_SQL = `
`;
// ─── Cache ──────────────────────────────────────────────────────────────────

const billingCache = new ModuleConfigCache<LlmBillingCacheEntry>({
name: 'billing-config',
ttlMs: 5 * 60 * 1000, // 5 minutes
max: 50
});
const BILLING_CACHE_MAX = 50;
let billingCachesByScope = new WeakMap<
object,
ModuleConfigCache<LlmBillingCacheEntry>
>();

function getBillingCache(
cacheScope: object
): ModuleConfigCache<LlmBillingCacheEntry> {
if ((typeof cacheScope !== 'object' && typeof cacheScope !== 'function') || cacheScope === null) {
throw new Error('LLM_CONFIG_CACHE_SCOPE_UNAVAILABLE');
}
let cache = billingCachesByScope.get(cacheScope);
if (!cache) {
cache = new ModuleConfigCache<LlmBillingCacheEntry>({
name: 'billing-config',
ttlMs: 5 * 60 * 1000, // 5 minutes
max: BILLING_CACHE_MAX
});
billingCachesByScope.set(cacheScope, cache);
}
return cache;
}

// ─── Resolution Functions ───────────────────────────────────────────────────

Expand Down Expand Up @@ -170,11 +188,14 @@ async function resolveBillingConfig(
*
* @param pgClient - A client connected to the tenant database (from withPgClient)
* @param databaseId - The database UUID
* @param cacheScope - The exact Graphile build that owns the cached result
*/
export async function getLlmBillingConfig(
pgClient: PgClient,
databaseId: string
databaseId: string,
cacheScope: object
): Promise<LlmBillingCacheEntry> {
const billingCache = getBillingCache(cacheScope);
const cached = billingCache.get(databaseId);
if (cached) return cached;

Expand All @@ -189,9 +210,19 @@ export async function getLlmBillingConfig(
}

/**
* Invalidate the cached config for a specific database (or all).
* Invalidate cached config for one exact owner. Omitting the owner resets all
* weakly owned caches without retaining their build identities.
*/
export function invalidateLlmBillingConfig(databaseId?: string): void {
export function invalidateLlmBillingConfig(
databaseId?: string,
cacheScope?: object
): void {
if (!cacheScope) {
billingCachesByScope = new WeakMap();
return;
}
const billingCache = billingCachesByScope.get(cacheScope);
if (!billingCache) return;
if (databaseId) {
billingCache.delete(databaseId);
} else {
Expand All @@ -200,8 +231,11 @@ export function invalidateLlmBillingConfig(databaseId?: string): void {
}

/**
* Get cache stats for diagnostics.
* Get cache stats for an exact owner without retaining other build identities.
*/
export function getLlmBillingCacheStats(): { size: number; max: number } {
return { size: billingCache.size, max: 50 };
export function getLlmBillingCacheStats(cacheScope: object): { size: number; max: number } {
return {
size: billingCachesByScope.get(cacheScope)?.size ?? 0,
max: BILLING_CACHE_MAX
};
}
23 changes: 18 additions & 5 deletions graphile/graphile-llm/src/plugins/agent-discovery-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,26 @@ export interface AgentDiscovery {

// ─── Cache ──────────────────────────────────────────────────────────────────

const agentDiscoveryCache = new ModuleConfigCache<AgentDiscovery | null>({
name: 'agent-discovery',
ttlMs: 60_000
});
let agentDiscoveryCaches = new WeakMap<
object,
ModuleConfigCache<AgentDiscovery | null>
>();

function cacheForPool(pool: object): ModuleConfigCache<AgentDiscovery | null> {
let cache = agentDiscoveryCaches.get(pool);
if (!cache) {
cache = new ModuleConfigCache<AgentDiscovery | null>({
name: 'agent-discovery',
ttlMs: 60_000
});
agentDiscoveryCaches.set(pool, cache);
}
return cache;
}

/** Clear all cached discovery results (for testing) */
export function clearAgentDiscoveryCache(): void {
agentDiscoveryCache.clear();
agentDiscoveryCaches = new WeakMap();
}

// ─── Discovery Query ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -83,6 +95,7 @@ export async function getAgentDiscovery(
throw new Error('getAgentDiscovery: databaseId is required');
}

const agentDiscoveryCache = cacheForPool(pool);
const cached = agentDiscoveryCache.get(databaseId);
if (cached !== undefined) {
return cached;
Expand Down
Loading