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 @@ -64,6 +64,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 @@ -74,9 +79,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 @@ -86,7 +91,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 @@ -190,15 +199,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 @@ -211,8 +220,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 @@ -226,13 +237,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
9 changes: 9 additions & 0 deletions graphile/graphile-llm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ The preset bundles all plugins listed below. You can also import each plugin ind
- **Toggleable** — each capability (`enableTextSearch`, `enableTextMutations`, `enableRag`) can be independently enabled or disabled
- **Plugin-conditional** — fields only appear in the schema when the plugin is loaded

### Cache ownership

Runtime billing and inference-log configuration is cached per database ID and
cache owner. `getLlmBillingConfig(client, databaseId, owner)` accepts the
Graphile build as the owner; the owner is optional for compatibility with the
original two-argument call and then defaults to the client. Cache statistics
require an owner. `invalidateLlmBillingConfig(databaseId, owner)` clears one
owner, while omitting the owner clears all owners.

## Plugins

| Plugin | Description | Toggle |
Expand Down
60 changes: 60 additions & 0 deletions graphile/graphile-llm/__tests__/agent-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ const fakePool = (respond: (values: unknown[]) => { rows: unknown[] }) => {

const pgError = (code: string) => Object.assign(new Error(`pg error ${code}`), { code });

function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>(res => {
resolve = res;
});
return { promise, resolve };
}

beforeEach(() => clearAgentDiscoveryCache());

describe('getAgentDiscovery', () => {
Expand Down Expand Up @@ -65,6 +73,58 @@ 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('keeps overlapping same-database discovery isolated by pool identity', async () => {
const gate = deferred();
const first = fakePool(() => ({ rows: [row('overlap_a')] }));
const second = fakePool(() => ({ rows: [row('overlap_b')] }));
const firstQuery = first.pool.query as jest.Mock;
const secondQuery = second.pool.query as jest.Mock;
firstQuery.mockImplementation(async (text: string, values?: unknown[]) => {
first.calls.push({ text, values });
await gate.promise;
return { rows: [row('overlap_a')] };
});
secondQuery.mockImplementation(async (text: string, values?: unknown[]) => {
second.calls.push({ text, values });
await gate.promise;
return { rows: [row('overlap_b')] };
});

const pending = Promise.all([
getAgentDiscovery(first.pool, DB_A),
getAgentDiscovery(second.pool, DB_A),
]);
expect(first.calls).toHaveLength(1);
expect(second.calls).toHaveLength(1);
gate.resolve();

const [fromFirst, fromSecond] = await pending;
const [cachedFirst, cachedSecond] = await Promise.all([
getAgentDiscovery(first.pool, DB_A),
getAgentDiscovery(second.pool, DB_A),
]);

expect(fromFirst?.thread?.schemaName).toBe('overlap_a_agent_public');
expect(fromSecond?.thread?.schemaName).toBe('overlap_b_agent_public');
expect(cachedFirst).toBe(fromFirst);
expect(cachedSecond).toBe(fromSecond);
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
Loading
Loading