diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts index c767326646..ab059933f9 100644 --- a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts @@ -75,10 +75,32 @@ jest.mock('graphile-utils', () => ({ })); import { createBucketProvisionerPlugin } from '../src/plugin'; -import type { BucketProvisionerPluginOptions } from '../src/types'; +import type { + BucketProvisionerPluginOptions, + BucketProvisionerStorageModule, +} from '../src/types'; // --- Test helpers --- +function storageModule( + overrides: Partial = {}, +): BucketProvisionerStorageModule { + return { + id: 'sm-uuid-456', + bucketsQualifiedName: 'app_public.buckets', + schemaName: 'app_public', + bucketsTableName: 'buckets', + scope: 'app', + entityTableId: null, + entityQualifiedName: null, + endpoint: null, + publicUrlPrefix: null, + provider: null, + allowedOrigins: null, + ...overrides, + }; +} + function createDefaultOptions( overrides: Partial = {}, ): BucketProvisionerPluginOptions { @@ -91,6 +113,7 @@ function createDefaultOptions( secretAccessKey: 'minioadmin', }, allowedOrigins: ['https://app.example.com'], + preloadedStorageModules: [storageModule()], ...overrides, }; } @@ -100,17 +123,6 @@ function createMockPgClient(overrides: Record = {}) { 'jwt_private.current_database_id': { rows: [{ id: 'db-uuid-123' }], }, - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: null, - }], - }, app_public: { rows: [{ id: 'bucket-uuid-789', @@ -127,6 +139,9 @@ function createMockPgClient(overrides: Record = {}) { return { query: jest.fn((arg: any) => { const sql: string = typeof arg === 'string' ? arg : arg.text; + if (sql.includes('UPDATE') && sql.includes('SET physical_name')) { + return Promise.resolve({ rows: [{ physical_name: arg.values[0] }] }); + } for (const [key, value] of Object.entries({ ...defaultQueries, ...overrides })) { if (sql.includes(key)) { return Promise.resolve(value); @@ -369,11 +384,11 @@ describe('createBucketProvisionerPlugin', () => { }); it('throws STORAGE_MODULE_NOT_PROVISIONED when no storage module exists', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); + createBucketProvisionerPlugin(createDefaultOptions({ + preloadedStorageModules: [], + })); - const pgClient = createMockPgClient({ - 'metaschema_modules_public.storage_module': { rows: [] }, - }); + const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient), ); @@ -423,7 +438,7 @@ describe('createBucketProvisionerPlugin', () => { }); expect(result.success).toBe(false); - expect(result.error).toBe('S3 connection refused'); + expect(result.error).toBe('BUCKET_PROVISIONING_FAILED'); expect(result.bucketName).toBe('public'); }); @@ -451,6 +466,8 @@ describe('createBucketProvisionerPlugin', () => { expect(update![0].text).toContain('physical_name IS NULL'); // Records the exact name returned by the provisioner against the row id. expect(update![0].values).toEqual(['public', 'bucket-uuid-789']); + expect(mockWithPgClient).toHaveBeenCalledTimes(1); + expect(mockWithPgClient.mock.calls[0][0]).toEqual({ role: 'admin' }); }); it('provisions the stored physical_name verbatim when already recorded', async () => { @@ -521,20 +538,15 @@ describe('createBucketProvisionerPlugin', () => { }); it('applies per-database endpoint override from storage module', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); + createBucketProvisionerPlugin(createDefaultOptions({ + preloadedStorageModules: [storageModule({ + endpoint: 'http://custom-minio:9000', + publicUrlPrefix: 'https://cdn.example.com', + provider: 'minio', + })], + })); - const pgClient = createMockPgClient({ - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: 'http://custom-minio:9000', - public_url_prefix: 'https://cdn.example.com', - provider: 'minio', - }], - }, - }); + const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient), ); @@ -578,20 +590,13 @@ describe('createBucketProvisionerPlugin', () => { }); it('passes publicUrlPrefix from storage module to provision call', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); + createBucketProvisionerPlugin(createDefaultOptions({ + preloadedStorageModules: [storageModule({ + publicUrlPrefix: 'https://cdn.example.com', + })], + })); - const pgClient = createMockPgClient({ - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: 'https://cdn.example.com', - provider: null, - }], - }, - }); + const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient), ); @@ -610,6 +615,108 @@ describe('createBucketProvisionerPlugin', () => { }); }); + describe('storage snapshot isolation', () => { + it('rejects missing request settings before acquiring a PostgreSQL client', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + const withPgClient = jest.fn(); + + await expect(capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient, + pgSettings: null, + })).rejects.toThrow('STORAGE_REQUEST_SETTINGS_UNAVAILABLE'); + expect(withPgClient).not.toHaveBeenCalled(); + expect(mockProvision).not.toHaveBeenCalled(); + }); + + it('fails closed without a snapshot and never queries metaschema or calls S3', async () => { + createBucketProvisionerPlugin(createDefaultOptions({ + preloadedStorageModules: undefined, + })); + const pgClient = createMockPgClient(); + const withPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); + + await expect(capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient, + pgSettings: { role: 'tenant_member' }, + })).rejects.toThrow('STORAGE_MODULE_SNAPSHOT_REQUIRED'); + + expect(pgClient.query.mock.calls.map((call: any[]) => call[0].text).join('\n')) + .not.toContain('metaschema_'); + expect(mockProvision).not.toHaveBeenCalled(); + }); + + it('rejects duplicate app modules before reading a bucket or calling S3', async () => { + createBucketProvisionerPlugin(createDefaultOptions({ + preloadedStorageModules: [ + storageModule({ id: 'app-a' }), + storageModule({ id: 'app-b', bucketsTableName: 'other_buckets' }), + ], + })); + const pgClient = createMockPgClient(); + const withPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); + + await expect(capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient, + pgSettings: { role: 'tenant_member' }, + })).rejects.toThrow('STORAGE_MODULE_AMBIGUOUS:app'); + expect(mockProvision).not.toHaveBeenCalled(); + }); + + it('probes only safely quoted preloaded entity tables and rejects ambiguous owners', async () => { + createBucketProvisionerPlugin(createDefaultOptions({ + preloadedStorageModules: [ + storageModule({ + id: 'team-a', + scope: 'team-a', + schemaName: 'team_a_public', + bucketsTableName: 'buckets', + entityTableId: 'entity-a', + entityQualifiedName: '"tenant-a"."teams"', + }), + storageModule({ + id: 'team-b', + scope: 'team-b', + schemaName: 'team_b_public', + bucketsTableName: 'buckets', + entityTableId: 'entity-b', + entityQualifiedName: '"tenant-b"."teams"', + }), + ], + })); + const pgClient = createMockPgClient({ + '"tenant-a".teams': { rows: [{ '?column?': 1 }] }, + '"tenant-b".teams': { rows: [{ '?column?': 1 }] }, + }); + const withPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); + + await expect(capturedLambdaCallback!({ + input: { bucketKey: 'private', ownerId: 'owner-a' }, + withPgClient, + pgSettings: { role: 'tenant_member' }, + })).rejects.toThrow('STORAGE_MODULE_AMBIGUOUS:owner'); + + const sql = pgClient.query.mock.calls.map((call: any[]) => call[0].text).join('\n'); + expect(sql).toContain('FROM "tenant-a".teams'); + expect(sql).toContain('FROM "tenant-b".teams'); + expect(sql).not.toContain('metaschema_'); + expect(mockProvision).not.toHaveBeenCalled(); + }); + + it('rejects an expression masquerading as an entity table at build time', () => { + expect(() => createBucketProvisionerPlugin(createDefaultOptions({ + preloadedStorageModules: [storageModule({ + id: 'malicious', + scope: 'team', + entityTableId: 'entity-a', + entityQualifiedName: 'safe.teams; SELECT pg_sleep(10)', + })], + }))).toThrow('STORAGE_MODULE_METADATA_INVALID:entity:malicious'); + }); + }); + describe('connection config resolution', () => { it('resolves static connection config', () => { const options = createDefaultOptions(); @@ -1110,7 +1217,11 @@ describe('CORS resolution hierarchy', () => { lifecycleRules: [], }); - createBucketProvisionerPlugin(createDefaultOptions()); + createBucketProvisionerPlugin(createDefaultOptions({ + preloadedStorageModules: [storageModule({ + allowedOrigins: ['https://db-default.example.com'], + })], + })); const pgClient = createMockPgClient({ app_public: { @@ -1122,17 +1233,6 @@ describe('CORS resolution hierarchy', () => { allowed_origins: ['*'], }], }, - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: ['https://db-default.example.com'], - }], - }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient), @@ -1167,7 +1267,11 @@ describe('CORS resolution hierarchy', () => { lifecycleRules: [], }); - createBucketProvisionerPlugin(createDefaultOptions()); + createBucketProvisionerPlugin(createDefaultOptions({ + preloadedStorageModules: [storageModule({ + allowedOrigins: ['https://db-default.example.com'], + })], + })); const pgClient = createMockPgClient({ app_public: { @@ -1179,17 +1283,6 @@ describe('CORS resolution hierarchy', () => { allowed_origins: null, // No bucket-level override }], }, - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: ['https://db-default.example.com'], - }], - }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient), @@ -1238,17 +1331,6 @@ describe('CORS resolution hierarchy', () => { allowed_origins: null, }], }, - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: null, - }], - }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient), @@ -1346,6 +1428,7 @@ describe('bucket name resolution', () => { secretAccessKey: 'test', }, allowedOrigins: ['https://app.example.com'], + preloadedStorageModules: [storageModule()], }); const pgClient = createMockPgClient({ @@ -1400,6 +1483,7 @@ describe('bucket name resolution', () => { secretAccessKey: 'test', }, allowedOrigins: ['https://app.example.com'], + preloadedStorageModules: [storageModule()], bucketNamePrefix: 'should-be-ignored', resolveBucketName: customResolver, }); diff --git a/graphile/graphile-bucket-provisioner-plugin/src/index.ts b/graphile/graphile-bucket-provisioner-plugin/src/index.ts index 77919ee720..f437e82c41 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/index.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/index.ts @@ -44,6 +44,7 @@ export type { BucketAccessType, BucketNameResolver, BucketProvisionerPluginOptions, + BucketProvisionerStorageModule, ConnectionConfigOrGetter, ProvisionBucketInput, ProvisionBucketPayload, diff --git a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts index 847d9cd6e8..4f3f898d9d 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts @@ -43,70 +43,95 @@ import { extendSchema, gql } from 'graphile-utils'; import type { BucketProvisionerPluginOptions, + BucketProvisionerStorageModule, } from './types'; const log = new Logger('graphile-bucket-provisioner:plugin'); -// --- Storage module queries --- +const QUALIFIED_IDENTIFIER = /^("(?:[^"]|"")+"|[a-z_][a-z0-9_$]*)\.("(?:[^"]|"")+"|[a-z_][a-z0-9_$]*)$/; -/** - * Resolve the app-level storage module (scope = 'app'). - */ -const APP_STORAGE_MODULE_QUERY = ` - SELECT - sm.id, - sm.scope, - sm.entity_table_id, - bs.schema_name AS buckets_schema, - bt.name AS buckets_table, - sm.endpoint, - sm.public_url_prefix, - sm.provider, - sm.allowed_origins - FROM metaschema_modules_public.storage_module sm - JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id - JOIN metaschema_public.schema bs ON bs.id = bt.schema_id - WHERE sm.database_id = $1 - AND sm.scope = 'app' - LIMIT 1 -`; +function decodeIdentifier(identifier: string): string { + return identifier.startsWith('"') + ? identifier.slice(1, -1).replace(/""/g, '"') + : identifier; +} /** - * Resolve ALL storage modules for a database (for ownerId-based resolution). + * Parse exactly two SQL identifiers, then quote both components again. This + * accepts the canonical quoted names emitted by the control-plane loader but + * rejects expressions, search paths, comments, and extra qualification. */ -const ALL_STORAGE_MODULES_QUERY = ` - SELECT - sm.id, - sm.scope, - sm.entity_table_id, - bs.schema_name AS buckets_schema, - bt.name AS buckets_table, - sm.endpoint, - sm.public_url_prefix, - sm.provider, - sm.allowed_origins, - es.schema_name AS entity_schema, - et.name AS entity_table - FROM metaschema_modules_public.storage_module sm - JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id - JOIN metaschema_public.schema bs ON bs.id = bt.schema_id - LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id - LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id - WHERE sm.database_id = $1 -`; - -interface StorageModuleRow { - id: string; - scope: string; - entity_table_id: string | null; - buckets_schema: string; - buckets_table: string; - endpoint: string | null; - public_url_prefix: string | null; - provider: string | null; - allowed_origins: string[] | null; - entity_schema?: string | null; - entity_table?: string | null; +function quotePreloadedQualifiedIdentifier(value: string, label: string): string { + const match = QUALIFIED_IDENTIFIER.exec(value); + if (!match) { + throw new Error(`STORAGE_MODULE_METADATA_INVALID:${label}`); + } + const schema = decodeIdentifier(match[1]); + const objectName = decodeIdentifier(match[2]); + if ( + schema.length === 0 || + objectName.length === 0 || + schema.includes('\0') || + objectName.includes('\0') || + Buffer.byteLength(schema, 'utf8') > 63 || + Buffer.byteLength(objectName, 'utf8') > 63 + ) { + throw new Error(`STORAGE_MODULE_METADATA_INVALID:${label}`); + } + return QuoteUtils.quoteQualifiedIdentifier(schema, objectName); +} + +function snapshotStorageModules( + modules: readonly BucketProvisionerStorageModule[] | undefined, +): readonly BucketProvisionerStorageModule[] | undefined { + if (modules === undefined) return undefined; + + for (const module of modules) { + if ( + !module || + typeof module.id !== 'string' || + module.id.length === 0 || + typeof module.scope !== 'string' || + module.scope.length === 0 || + typeof module.schemaName !== 'string' || + module.schemaName.length === 0 || + module.schemaName.includes('\0') || + Buffer.byteLength(module.schemaName, 'utf8') > 63 || + typeof module.bucketsTableName !== 'string' || + module.bucketsTableName.length === 0 || + module.bucketsTableName.includes('\0') || + Buffer.byteLength(module.bucketsTableName, 'utf8') > 63 + ) { + throw new Error('STORAGE_MODULE_METADATA_INVALID'); + } + QuoteUtils.quoteQualifiedIdentifier(module.schemaName, module.bucketsTableName); + if (module.scope === 'app') { + if (module.entityTableId !== null || module.entityQualifiedName !== null) { + throw new Error(`STORAGE_MODULE_METADATA_INVALID:${module.id}`); + } + } else if (!module.entityTableId || !module.entityQualifiedName) { + throw new Error(`STORAGE_MODULE_METADATA_INVALID:${module.id}`); + } else { + quotePreloadedQualifiedIdentifier(module.entityQualifiedName, `entity:${module.id}`); + } + } + + if ( + Object.isFrozen(modules) && + modules.every((module) => + Object.isFrozen(module) && + (module.allowedOrigins === null || Object.isFrozen(module.allowedOrigins)), + ) + ) { + return modules; + } + + return Object.freeze(modules.map((module) => Object.freeze({ + ...module, + allowedOrigins: module.allowedOrigins === null + ? null + : Object.freeze([...module.allowedOrigins]), + }))); } /** @@ -125,6 +150,18 @@ function runQuery( return pgClient.query(values === undefined ? { text } : { text, values }); } +function assertStorageRequestContext(withPgClient: unknown, pgSettings: unknown): asserts withPgClient is ( + settings: Record, + callback: (pgClient: any) => Promise, +) => Promise { + if (typeof withPgClient !== 'function') { + throw new Error('STORAGE_CONTEXT_UNAVAILABLE'); + } + if (typeof pgSettings !== 'object' || pgSettings === null || Array.isArray(pgSettings)) { + throw new Error('STORAGE_REQUEST_SETTINGS_UNAVAILABLE'); + } +} + /** * Resolve the storage module for a given scope. * If ownerId is provided, probes entity tables to find the matching module. @@ -132,33 +169,43 @@ function runQuery( */ async function resolveStorageModule( pgClient: any, - databaseId: string, + modules: readonly BucketProvisionerStorageModule[] | undefined, ownerId?: string, -): Promise { +): Promise { + if (modules === undefined) { + throw new Error('STORAGE_MODULE_SNAPSHOT_REQUIRED'); + } + if (!ownerId) { - // App-level resolution - const result = await runQuery(pgClient, APP_STORAGE_MODULE_QUERY, [databaseId]); - return (result.rows[0] as StorageModuleRow) ?? null; + const appModules = modules.filter((module) => module.scope === 'app'); + if (appModules.length > 1) { + throw new Error('STORAGE_MODULE_AMBIGUOUS:app'); + } + return appModules[0] ?? null; } - // Entity-scoped: load all modules and probe entity tables - const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]); - const modules = result.rows as StorageModuleRow[]; - const entityModules = modules.filter((m) => m.entity_schema && m.entity_table); + const entityModules = modules.filter((module) => module.scope !== 'app'); + const matches: BucketProvisionerStorageModule[] = []; for (const mod of entityModules) { - const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema!, mod.entity_table!); + const entityTable = quotePreloadedQualifiedIdentifier( + mod.entityQualifiedName!, + `entity:${mod.id}`, + ); const probe = await runQuery( pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId], ); if (probe.rows.length > 0) { - return mod; + matches.push(mod); } } - return null; + if (matches.length > 1) { + throw new Error('STORAGE_MODULE_AMBIGUOUS:owner'); + } + return matches[0] ?? null; } interface BucketRow { @@ -185,24 +232,47 @@ function storedPhysicalName(row: Pick): string | nul /** * Record the physical S3 bucket name on the source bucket row. * - * Runs in the system lane (`withPgClient(null, ...)`) — server bookkeeping, - * RLS-independent. Idempotent via the `physical_name IS NULL` guard so a - * re-provision never clobbers an already-recorded coordinate. + * Runs on the request's already-scoped client. The write must satisfy the same + * role, claims, and RLS policies as the bucket read that authorized + * provisioning; a policy denial fails closed. It is idempotent via the + * `physical_name IS NULL` guard so a re-provision never clobbers an + * already-recorded coordinate. */ async function recordPhysicalName( - withPgClient: (pgSettings: null, cb: (client: any) => Promise) => Promise, + pgClient: any, bucketsTable: string, bucketId: string, physicalName: string, -): Promise { - await withPgClient(null, (client: any) => - runQuery( - client, - `UPDATE ${bucketsTable} SET physical_name = $1 WHERE id = $2 AND physical_name IS NULL`, - [physicalName, bucketId], - ), +): Promise { + const updated = await runQuery( + pgClient, + `UPDATE ${bucketsTable} + SET physical_name = $1 + WHERE id = $2 AND physical_name IS NULL + RETURNING physical_name`, + [physicalName, bucketId], ); - log.info(`Recorded physical_name="${physicalName}" on bucket ${bucketId}`); + const written = updated.rows[0]?.physical_name; + if (updated.rows.length === 1 && typeof written === 'string') { + log.info(`Recorded physical_name="${written}" on bucket ${bucketId}`); + return written; + } + if (updated.rows.length > 1) { + throw new Error('BUCKET_COORDINATE_AMBIGUOUS'); + } + + // Another process may have won the first-provision race. Route to the + // durable value it recorded; never return a losing candidate. + const existing = await runQuery( + pgClient, + `SELECT physical_name FROM ${bucketsTable} WHERE id = $1 LIMIT 2`, + [bucketId], + ); + const authoritative = existing.rows[0]?.physical_name; + if (existing.rows.length !== 1 || typeof authoritative !== 'string') { + throw new Error('BUCKET_COORDINATE_WRITE_FAILED'); + } + return authoritative; } // --- Helpers --- @@ -259,16 +329,16 @@ async function resolveDatabaseId(pgClient: any): Promise { */ function resolveAllowedOrigins( bucketOrigins: string[] | null | undefined, - storageModuleOrigins: string[] | null | undefined, + storageModuleOrigins: readonly string[] | null | undefined, pluginOrigins: string[], ): string[] { if (bucketOrigins && bucketOrigins.length > 0) { - return bucketOrigins; + return [...bucketOrigins]; } if (storageModuleOrigins && storageModuleOrigins.length > 0) { - return storageModuleOrigins; + return [...storageModuleOrigins]; } - return pluginOrigins; + return [...pluginOrigins]; } /** @@ -276,14 +346,14 @@ function resolveAllowedOrigins( */ function buildProvisioner( options: BucketProvisionerPluginOptions, - storageModule: StorageModuleRow | null, + storageModule: BucketProvisionerStorageModule, effectiveOrigins: string[], ): BucketProvisioner { const connection = resolveConnection(options); const effectiveConnection: StorageConnectionConfig = { ...connection, - ...(storageModule?.endpoint ? { endpoint: storageModule.endpoint } : {}), - ...(storageModule?.provider + ...(storageModule.endpoint ? { endpoint: storageModule.endpoint } : {}), + ...(storageModule.provider ? { provider: storageModule.provider as StorageConnectionConfig['provider'] } : {}), }; @@ -299,23 +369,20 @@ function buildProvisioner( * auto-provisioning hook. */ async function provisionBucketForRow( - pgClient: any, databaseId: string, bucketKey: string, bucketType: string, bucketAllowedOrigins: string[] | null | undefined, options: BucketProvisionerPluginOptions, s3BucketName: string, + storageModule: BucketProvisionerStorageModule, ): Promise { const accessType = bucketType as 'public' | 'private' | 'temp'; - // Read storage module config to check for endpoint/provider/CORS overrides - const storageModule = await resolveStorageModule(pgClient, databaseId); - // Resolve CORS origins using the 3-tier hierarchy const effectiveOrigins = resolveAllowedOrigins( bucketAllowedOrigins, - storageModule?.allowed_origins, + storageModule.allowedOrigins, options.allowedOrigins, ); @@ -330,7 +397,7 @@ async function provisionBucketForRow( bucketName: s3BucketName, accessType, versioning: options.versioning ?? false, - publicUrlPrefix: storageModule?.public_url_prefix ?? undefined, + publicUrlPrefix: storageModule.publicUrlPrefix ?? undefined, allowedOrigins: effectiveOrigins, }); @@ -346,21 +413,19 @@ async function provisionBucketForRow( * Update CORS on an existing S3 bucket when allowed_origins changes. */ async function updateBucketCors( - pgClient: any, databaseId: string, bucketKey: string, bucketType: string, bucketAllowedOrigins: string[] | null | undefined, options: BucketProvisionerPluginOptions, s3BucketName: string, + storageModule: BucketProvisionerStorageModule, ): Promise { const accessType = bucketType as 'public' | 'private' | 'temp'; - const storageModule = await resolveStorageModule(pgClient, databaseId); - const effectiveOrigins = resolveAllowedOrigins( bucketAllowedOrigins, - storageModule?.allowed_origins, + storageModule.allowedOrigins, options.allowedOrigins, ); @@ -401,6 +466,9 @@ export function createBucketProvisionerPlugin( options: BucketProvisionerPluginOptions, ): GraphileConfig.Plugin { const autoProvision = options.autoProvision ?? true; + const preloadedStorageModules = snapshotStorageModules( + options.preloadedStorageModules, + ); // The extendSchema plugin adds the explicit provisionBucket mutation const mutationPlugin = extendSchema(() => ({ @@ -461,6 +529,8 @@ export function createBucketProvisionerPlugin( throw new Error('INVALID_BUCKET_KEY'); } + assertStorageRequestContext(withPgClient, pgSettings); + return withPgClient(pgSettings, async (pgClient: any) => { // Resolve database ID from JWT context const databaseId = await resolveDatabaseId(pgClient); @@ -469,7 +539,11 @@ export function createBucketProvisionerPlugin( } // Resolve storage module (app-level or entity-scoped via ownerId) - const storageModule = await resolveStorageModule(pgClient, databaseId, ownerId); + const storageModule = await resolveStorageModule( + pgClient, + preloadedStorageModules, + ownerId, + ); if (!storageModule) { throw new Error( ownerId @@ -480,24 +554,30 @@ export function createBucketProvisionerPlugin( // Look up the bucket row (RLS enforced via pgSettings) const hasOwner = ownerId && storageModule.scope !== 'app'; - const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table); + const bucketsTable = QuoteUtils.quoteQualifiedIdentifier( + storageModule.schemaName, + storageModule.bucketsTableName, + ); const bucketResult = await runQuery( pgClient, hasOwner ? `SELECT id, key, type, is_public, allowed_origins, physical_name FROM ${bucketsTable} WHERE key = $1 AND owner_id = $2 - LIMIT 1` + LIMIT 2` : `SELECT id, key, type, is_public, allowed_origins, physical_name FROM ${bucketsTable} WHERE key = $1 - LIMIT 1`, + LIMIT 2`, hasOwner ? [bucketKey, ownerId] : [bucketKey], ); if (bucketResult.rows.length === 0) { throw new Error('BUCKET_NOT_FOUND'); } + if (bucketResult.rows.length > 1) { + throw new Error('BUCKET_AMBIGUOUS'); + } const bucket = bucketResult.rows[0] as BucketRow; @@ -510,21 +590,26 @@ export function createBucketProvisionerPlugin( try { const result = await provisionBucketForRow( - pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName, + storageModule, ); // Record the exact provisioned name on the source row. - await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName); + const authoritativeBucketName = await recordPhysicalName( + pgClient, + bucketsTable, + bucket.id, + result.bucketName, + ); return { success: true, - bucketName: result.bucketName, + bucketName: authoritativeBucketName, accessType: result.accessType, provider: result.provider, endpoint: result.endpoint, @@ -538,7 +623,7 @@ export function createBucketProvisionerPlugin( accessType: bucket.type, provider: resolveConnection(options).provider, endpoint: resolveConnection(options).endpoint ?? null, - error: err.message, + error: 'BUCKET_PROVISIONING_FAILED', }; } }); @@ -622,10 +707,7 @@ export function createBucketProvisionerPlugin( const withPgClient = graphqlContext.withPgClient; const pgSettings = graphqlContext.pgSettings; - if (!withPgClient) { - log.warn(`${isCreate ? 'Auto-provision' : 'CORS update'} skipped: withPgClient not available in context`); - return result; - } + assertStorageRequestContext(withPgClient, pgSettings); if (isCreate) { // --- CREATE: full provisioning --- @@ -645,27 +727,49 @@ export function createBucketProvisionerPlugin( } // Newly-created row has no stored coordinate yet — mint on first provision. - const result = await provisionBucketForRow( + const storageModule = await resolveStorageModule( pgClient, + preloadedStorageModules, + ); + if (!storageModule) { + throw new Error('STORAGE_MODULE_NOT_PROVISIONED'); + } + + const result = await provisionBucketForRow( databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options), + storageModule, ); // Record the provisioned name on the just-created row. - const storageModule = await resolveStorageModule(pgClient, databaseId); - if (storageModule) { - const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table); - const idResult = await runQuery( + const bucketsTable = QuoteUtils.quoteQualifiedIdentifier( + storageModule.schemaName, + storageModule.bucketsTableName, + ); + const idResult = await runQuery( + pgClient, + `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 2`, + [bucketInput.key], + ); + if (idResult.rows.length !== 1) { + throw new Error( + idResult.rows.length === 0 + ? 'BUCKET_NOT_FOUND' + : 'BUCKET_AMBIGUOUS', + ); + } + const bucketId = idResult.rows[0]?.id; + if (bucketId) { + await recordPhysicalName( pgClient, - `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, - [bucketInput.key], + bucketsTable, + bucketId, + result.bucketName, ); - const bucketId = idResult.rows[0]?.id; - if (bucketId) await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName); } }); } else { @@ -686,7 +790,10 @@ export function createBucketProvisionerPlugin( } // Read the storage module config (app-level; auto-hook doesn't have ownerId context) - const storageModule = await resolveStorageModule(pgClient, databaseId); + const storageModule = await resolveStorageModule( + pgClient, + preloadedStorageModules, + ); if (!storageModule) { log.warn('CORS update skipped: storage module not provisioned'); return; @@ -705,13 +812,16 @@ export function createBucketProvisionerPlugin( } // Read the full bucket row (post-update) to get type + origins - const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table); + const bucketsTable = QuoteUtils.quoteQualifiedIdentifier( + storageModule.schemaName, + storageModule.bucketsTableName, + ); const bucketResult = await runQuery( pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name FROM ${bucketsTable} WHERE key = $1 - LIMIT 1`, + LIMIT 2`, [patchKey], ); @@ -719,6 +829,9 @@ export function createBucketProvisionerPlugin( log.warn(`CORS update skipped: bucket "${patchKey}" not found`); return; } + if (bucketResult.rows.length > 1) { + throw new Error('BUCKET_AMBIGUOUS'); + } const bucket = bucketResult.rows[0] as BucketRow; @@ -728,7 +841,6 @@ export function createBucketProvisionerPlugin( const recorded = storedPhysicalName(bucket); await updateBucketCors( - pgClient, databaseId, bucket.key, bucket.type, @@ -737,6 +849,7 @@ export function createBucketProvisionerPlugin( recorded === null ? resolveBucketName(bucket.key, databaseId, options) : recorded, + storageModule, ); }); } diff --git a/graphile/graphile-bucket-provisioner-plugin/src/types.ts b/graphile/graphile-bucket-provisioner-plugin/src/types.ts index 45a5c172ba..7003c308f6 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/types.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/types.ts @@ -36,6 +36,25 @@ export type ConnectionConfigOrGetter = */ export type BucketNameResolver = (bucketKey: string, databaseId: string) => string; +/** + * Immutable storage routing metadata resolved by the control plane for one + * exact Graphile build. This intentionally mirrors the subset consumed by the + * provisioner without making the provisioner depend on the presigned plugin. + */ +export interface BucketProvisionerStorageModule { + id: string; + bucketsQualifiedName: string; + schemaName: string; + bucketsTableName: string; + scope: string; + entityTableId: string | null; + entityQualifiedName: string | null; + endpoint: string | null; + publicUrlPrefix: string | null; + provider: string | null; + allowedOrigins: readonly string[] | null; +} + /** * Plugin options for the bucket provisioner plugin. */ @@ -53,6 +72,14 @@ export interface BucketProvisionerPluginOptions { */ allowedOrigins: string[]; + /** + * Exact-build storage metadata supplied by the control plane. An empty list + * is authoritative. If this is omitted, provisioning fails closed; the + * plugin never discovers tenant routing metadata from a request-time SQL + * query. + */ + preloadedStorageModules?: readonly BucketProvisionerStorageModule[]; + /** * Optional prefix for S3 bucket names. * When set, the S3 bucket name becomes `{prefix}-{bucketKey}`. diff --git a/graphile/graphile-bulk-mutations/__tests__/bulk-where-type.test.ts b/graphile/graphile-bulk-mutations/__tests__/bulk-where-type.test.ts new file mode 100644 index 0000000000..775692b809 --- /dev/null +++ b/graphile/graphile-bulk-mutations/__tests__/bulk-where-type.test.ts @@ -0,0 +1,38 @@ +import { GraphQLInputObjectType } from 'graphql'; + +import { resolveBulkWhereType } from '../src/plugins/BulkTypesPlugin'; + +describe('bulk mutation where type resolution', () => { + it('uses connection-filter without touching a disabled condition inflector', () => { + const filter = new GraphQLInputObjectType({ name: 'ItemFilter', fields: {} }); + const getTypeByName = jest.fn((name: string) => name === 'ItemFilter' ? filter : undefined); + const conditionType = jest.fn(() => { + throw new Error('disabled condition inflector must not be called'); + }); + + expect(resolveBulkWhereType({ getTypeByName } as any, { conditionType }, 'Item')).toBe(filter); + expect(conditionType).not.toHaveBeenCalled(); + expect(getTypeByName).toHaveBeenCalledTimes(1); + }); + + it('falls back to the built-in condition type when no filter exists', () => { + const condition = new GraphQLInputObjectType({ name: 'ItemCondition', fields: {} }); + const getTypeByName = jest.fn((name: string) => + name === 'ItemCondition' ? condition : undefined + ); + + expect(resolveBulkWhereType( + { getTypeByName } as any, + { conditionType: () => 'ItemCondition' }, + 'Item' + )).toBe(condition); + }); + + it('returns undefined when neither predicate plugin is enabled', () => { + expect(resolveBulkWhereType( + { getTypeByName: (): undefined => undefined } as any, + {}, + 'Item' + )).toBeUndefined(); + }); +}); diff --git a/graphile/graphile-bulk-mutations/__tests__/identifier-quoting.test.ts b/graphile/graphile-bulk-mutations/__tests__/identifier-quoting.test.ts new file mode 100644 index 0000000000..9c091f4da4 --- /dev/null +++ b/graphile/graphile-bulk-mutations/__tests__/identifier-quoting.test.ts @@ -0,0 +1,42 @@ +import { + buildBulkDeleteSQL, + buildBulkInsertSQL, + buildBulkUpdateSQL, +} from '../src/utils/sql-builder'; + +describe('bulk mutation catalog identifier quoting', () => { + const hostile = 'value" RETURNING secret --'; + const quoted = '"value"" RETURNING secret --"'; + + it('escapes insert, conflict, update, and returning identifiers', () => { + const [query] = buildBulkInsertSQL( + 'tenant_a.items', + [{ name: hostile, sqlType: 'text' }], + [{ [hostile]: 'safe-value' }], + [hostile], + { conflictColumns: [hostile], action: 'UPDATE', updateColumns: [hostile] } + ); + + expect(query.text).toContain(`(${quoted})`); + expect(query.text).toContain(`ON CONFLICT (${quoted})`); + expect(query.text).toContain(`${quoted} = EXCLUDED.${quoted}`); + expect(query.text).toContain(`RETURNING ${quoted}`); + expect(query.values).toEqual(['safe-value']); + }); + + it('escapes update and delete identifiers', () => { + const update = buildBulkUpdateSQL( + 'tenant_a.items', + { [hostile]: 'safe-value' }, + [{ name: hostile, sqlType: 'text' }], + [hostile], + 'TRUE', + [] + ); + const deletion = buildBulkDeleteSQL('tenant_a.items', [hostile], 'TRUE', []); + + expect(update.text).toContain(`${quoted} = $1::text`); + expect(update.text).toContain(`RETURNING ${quoted}`); + expect(deletion.text).toContain(`RETURNING ${quoted}`); + }); +}); diff --git a/graphile/graphile-bulk-mutations/__tests__/pg-client-query-contract.test.ts b/graphile/graphile-bulk-mutations/__tests__/pg-client-query-contract.test.ts new file mode 100644 index 0000000000..183b58c8e2 --- /dev/null +++ b/graphile/graphile-bulk-mutations/__tests__/pg-client-query-contract.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const plugins = [ + 'BulkInsertPlugin', + 'BulkUpsertPlugin', + 'BulkUpdatePlugin', + 'BulkDeletePlugin' +] as const; + +describe.each(plugins)('%s PgClient query contract', (plugin) => { + const source = readFileSync( + join(__dirname, '..', 'src', 'plugins', `${plugin}.ts`), + 'utf8' + ); + + it('types the callback as the @dataplan/pg PgClient', () => { + expect(source).toContain('pgClient: PgClient'); + }); + + it('uses object-form query arguments instead of node-postgres positional arguments', () => { + const queryCall = String.raw`pgClient\.query(?:<[^)]+>)?\(`; + expect(source).toMatch(new RegExp(`${queryCall}\\s*\\{`)); + expect(source).not.toMatch(new RegExp(`${queryCall}\\s*(?:\`|'|")`)); + expect(source).not.toMatch( + new RegExp(`${queryCall}\\s*[A-Za-z_$][\\w$]*\\s*,`) + ); + }); +}); diff --git a/graphile/graphile-bulk-mutations/package.json b/graphile/graphile-bulk-mutations/package.json index 6b39bf39d3..6a3b9d10de 100644 --- a/graphile/graphile-bulk-mutations/package.json +++ b/graphile/graphile-bulk-mutations/package.json @@ -41,6 +41,9 @@ "bugs": { "url": "https://github.com/constructive-io/constructive/issues" }, + "dependencies": { + "@pgsql/quotes": "^18.1.0" + }, "devDependencies": { "@types/node": "^22.19.11", "graphile-test": "workspace:^", diff --git a/graphile/graphile-bulk-mutations/src/plugins/BulkDeletePlugin.ts b/graphile/graphile-bulk-mutations/src/plugins/BulkDeletePlugin.ts index 4731702283..4bc8d6d384 100644 --- a/graphile/graphile-bulk-mutations/src/plugins/BulkDeletePlugin.ts +++ b/graphile/graphile-bulk-mutations/src/plugins/BulkDeletePlugin.ts @@ -1,10 +1,12 @@ import '../augmentations'; -import { sideEffectWithPgClient } from '@dataplan/pg'; +import { type PgClient,sideEffectWithPgClient } from '@dataplan/pg'; +import { QuoteUtils } from '@pgsql/quotes'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLInputType,GraphQLOutputType } from 'graphql'; const version = '0.1.0'; +const qi = (name: string): string => QuoteUtils.quoteIdentifier(name); /** * BulkDeletePlugin @@ -79,7 +81,7 @@ export const BulkDeletePlugin: GraphileConfig.Plugin = { // Extract primary key columns for RETURNING clause const primaryUnique = resource.uniques.find((u: any) => u.isPrimary) ?? resource.uniques[0]; const pkColumns: string[] = primaryUnique.attributes; - const pkReturning = pkColumns.map((c) => `"${c}"`).join(', '); + const pkReturning = pkColumns.map(qi).join(', '); const compiledFrom = sql.compile(resource.from).text; @@ -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.' @@ -125,11 +127,11 @@ export const BulkDeletePlugin: GraphileConfig.Plugin = { const sqlType = attrToSqlType[attrName]; if (spec === null) { - whereClauses.push(`"${attrName}" IS NULL`); + whereClauses.push(`${qi(attrName)} IS NULL`); } else if (spec !== undefined && typeof spec !== 'object') { // Simple equality (Condition type) values.push(spec); - whereClauses.push(`"${attrName}" = $${values.length}::${sqlType}`); + whereClauses.push(`${qi(attrName)} = $${values.length}::${sqlType}`); } else if (spec && typeof spec === 'object') { // Operator-based (Filter type) for (const [op, val] of Object.entries(spec) as [string, any][]) { @@ -137,22 +139,22 @@ export const BulkDeletePlugin: GraphileConfig.Plugin = { const paramRef = `$${values.length}::${sqlType}`; switch (op) { case 'equalTo': - whereClauses.push(`"${attrName}" = ${paramRef}`); + whereClauses.push(`${qi(attrName)} = ${paramRef}`); break; case 'notEqualTo': - whereClauses.push(`"${attrName}" != ${paramRef}`); + whereClauses.push(`${qi(attrName)} != ${paramRef}`); break; case 'greaterThan': - whereClauses.push(`"${attrName}" > ${paramRef}`); + whereClauses.push(`${qi(attrName)} > ${paramRef}`); break; case 'greaterThanOrEqualTo': - whereClauses.push(`"${attrName}" >= ${paramRef}`); + whereClauses.push(`${qi(attrName)} >= ${paramRef}`); break; case 'lessThan': - whereClauses.push(`"${attrName}" < ${paramRef}`); + whereClauses.push(`${qi(attrName)} < ${paramRef}`); break; case 'lessThanOrEqualTo': - whereClauses.push(`"${attrName}" <= ${paramRef}`); + whereClauses.push(`${qi(attrName)} <= ${paramRef}`); break; case 'in': if (Array.isArray(val)) { @@ -161,15 +163,15 @@ export const BulkDeletePlugin: GraphileConfig.Plugin = { return `$${values.length}::${sqlType}`; }); values.pop(); - whereClauses.push(`"${attrName}" IN (${placeholders.join(', ')})`); + whereClauses.push(`${qi(attrName)} IN (${placeholders.join(', ')})`); } break; case 'isNull': values.pop(); if (val) { - whereClauses.push(`"${attrName}" IS NULL`); + whereClauses.push(`${qi(attrName)} IS NULL`); } else { - whereClauses.push(`"${attrName}" IS NOT NULL`); + whereClauses.push(`${qi(attrName)} IS NOT NULL`); } break; default: @@ -194,7 +196,7 @@ 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 pgClient.query({ text, values }); const affectedCount = mutationResult.rowCount ?? 0; // For delete, rows no longer exist so we can't do a diff --git a/graphile/graphile-bulk-mutations/src/plugins/BulkInsertPlugin.ts b/graphile/graphile-bulk-mutations/src/plugins/BulkInsertPlugin.ts index 6b128024a1..ea2af5a74a 100644 --- a/graphile/graphile-bulk-mutations/src/plugins/BulkInsertPlugin.ts +++ b/graphile/graphile-bulk-mutations/src/plugins/BulkInsertPlugin.ts @@ -1,6 +1,7 @@ import '../augmentations'; -import { sideEffectWithPgClient } from '@dataplan/pg'; +import { type PgClient,sideEffectWithPgClient } from '@dataplan/pg'; +import { QuoteUtils } from '@pgsql/quotes'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLInputType, GraphQLOutputType } from 'graphql'; @@ -10,6 +11,7 @@ import type { ColumnSpec } from '../utils/sql-builder'; import { buildBulkInsertSQL } from '../utils/sql-builder'; const version = '0.1.0'; +const qi = (name: string): string => QuoteUtils.quoteIdentifier(name); /** * BulkInsertPlugin @@ -131,7 +133,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 +202,10 @@ 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 pgClient.query>({ + text: batch.text, + values: batch.values + }); totalAffected += result.rowCount ?? 0; if (result.rows) { allPkRows.push(...result.rows); @@ -259,10 +261,10 @@ export const BulkInsertPlugin: GraphileConfig.Plugin = { ); for (const batch of childBatches) { - const result = await pgClient.query( - batch.text, - batch.values - ); + const result = await pgClient.query({ + text: batch.text, + values: batch.values + }); totalAffected += result.rowCount ?? 0; } } @@ -275,18 +277,18 @@ export const BulkInsertPlugin: GraphileConfig.Plugin = { const pkConditions = allPkRows.map((pkRow, rowIdx) => { return pkColumns.map((col, colIdx) => { const paramIdx = rowIdx * pkColumns.length + colIdx + 1; - return `"${col}" = $${paramIdx}`; + return `${qi(col)} = $${paramIdx}`; }).join(' AND '); }); const whereClause = pkConditions.map((c) => `(${c})`).join(' OR '); const selectParams = allPkRows.flatMap((pkRow) => pkColumns.map((col) => pkRow[col]) ); - const selectResult = await pgClient.query( - `SELECT * FROM ${compiledFrom} WHERE ${whereClause}`, - selectParams - ); - returning = selectResult.rows || []; + const selectResult = await pgClient.query>({ + text: `SELECT * FROM ${compiledFrom} WHERE ${whereClause}`, + values: selectParams + }); + returning = [...selectResult.rows]; } return { diff --git a/graphile/graphile-bulk-mutations/src/plugins/BulkTypesPlugin.ts b/graphile/graphile-bulk-mutations/src/plugins/BulkTypesPlugin.ts index 18717fe73e..ecc34af7ed 100644 --- a/graphile/graphile-bulk-mutations/src/plugins/BulkTypesPlugin.ts +++ b/graphile/graphile-bulk-mutations/src/plugins/BulkTypesPlugin.ts @@ -25,6 +25,27 @@ function isBulkMutationCandidate(resource: any): boolean { ); } +/** + * Resolve the strongest available predicate input without assuming that the + * built-in condition plugin is enabled. Constructive disables that plugin when + * graphile-connection-filter supplies the richer `${typeName}Filter` type. + */ +export function resolveBulkWhereType( + build: Pick, + inflection: { conditionType?: (typeName: string) => string }, + typeName: string +): GraphQLInputType | undefined { + const filterType = build.getTypeByName(`${typeName}Filter`) as + | GraphQLInputType + | undefined; + if (filterType) return filterType; + + const conditionTypeName = inflection.conditionType?.(typeName); + return conditionTypeName + ? build.getTypeByName(conditionTypeName) as GraphQLInputType | undefined + : undefined; +} + /** * BulkTypesPlugin * @@ -465,16 +486,7 @@ export const BulkTypesPlugin: GraphileConfig.Plugin = { where: fieldWithHooks( { fieldName: 'where' }, () => { - // Try to use connection-filter type if available - const filterTypeName = `${typeName}Filter`; - const filterType = build.getTypeByName(filterTypeName) as GraphQLInputType | undefined; - // Fall back to PostGraphile's built-in condition type - const conditionTypeName = inflection.conditionType( - typeName - ); - const conditionType = - build.getTypeByName(conditionTypeName) as GraphQLInputType | undefined; - const whereType = filterType || conditionType; + const whereType = resolveBulkWhereType(build, inflection, typeName); return { description: 'Condition to select which rows to update.', @@ -511,14 +523,7 @@ export const BulkTypesPlugin: GraphileConfig.Plugin = { where: fieldWithHooks( { fieldName: 'where' }, () => { - const filterTypeName = `${typeName}Filter`; - const filterType = build.getTypeByName(filterTypeName) as GraphQLInputType | undefined; - const conditionTypeName = inflection.conditionType( - typeName - ); - const conditionType = - build.getTypeByName(conditionTypeName) as GraphQLInputType | undefined; - const whereType = filterType || conditionType; + const whereType = resolveBulkWhereType(build, inflection, typeName); return { description: 'Condition to select which rows to delete.', diff --git a/graphile/graphile-bulk-mutations/src/plugins/BulkUpdatePlugin.ts b/graphile/graphile-bulk-mutations/src/plugins/BulkUpdatePlugin.ts index 8ef1170067..e1fcdf3894 100644 --- a/graphile/graphile-bulk-mutations/src/plugins/BulkUpdatePlugin.ts +++ b/graphile/graphile-bulk-mutations/src/plugins/BulkUpdatePlugin.ts @@ -1,10 +1,12 @@ import '../augmentations'; -import { sideEffectWithPgClient } from '@dataplan/pg'; +import { type PgClient,sideEffectWithPgClient } from '@dataplan/pg'; +import { QuoteUtils } from '@pgsql/quotes'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLInputType, GraphQLOutputType } from 'graphql'; const version = '0.1.0'; +const qi = (name: string): string => QuoteUtils.quoteIdentifier(name); /** * BulkUpdatePlugin @@ -80,7 +82,7 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = { // Extract primary key columns for RETURNING clause const primaryUnique = resource.uniques.find((u: any) => u.isPrimary) ?? resource.uniques[0]; const pkColumns: string[] = primaryUnique.attributes; - const pkReturning = pkColumns.map((c) => `"${c}"`).join(', '); + const pkReturning = pkColumns.map(qi).join(', '); const compiledFrom = sql.compile(resource.from).text; @@ -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.' @@ -126,7 +128,7 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = { if (!attrName) continue; const sqlType = attrToSqlType[attrName]; values.push(val); - setClauses.push(`"${attrName}" = $${values.length}::${sqlType}`); + setClauses.push(`${qi(attrName)} = $${values.length}::${sqlType}`); } if (setClauses.length === 0) { @@ -144,11 +146,11 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = { const sqlType = attrToSqlType[attrName]; if (spec === null) { - whereClauses.push(`"${attrName}" IS NULL`); + whereClauses.push(`${qi(attrName)} IS NULL`); } else if (spec !== undefined && typeof spec !== 'object') { // Simple equality (Condition type) values.push(spec); - whereClauses.push(`"${attrName}" = $${values.length}::${sqlType}`); + whereClauses.push(`${qi(attrName)} = $${values.length}::${sqlType}`); } else if (spec && typeof spec === 'object') { // Operator-based (Filter type) for (const [op, val] of Object.entries(spec) as [string, any][]) { @@ -156,22 +158,22 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = { const paramRef = `$${values.length}::${sqlType}`; switch (op) { case 'equalTo': - whereClauses.push(`"${attrName}" = ${paramRef}`); + whereClauses.push(`${qi(attrName)} = ${paramRef}`); break; case 'notEqualTo': - whereClauses.push(`"${attrName}" != ${paramRef}`); + whereClauses.push(`${qi(attrName)} != ${paramRef}`); break; case 'greaterThan': - whereClauses.push(`"${attrName}" > ${paramRef}`); + whereClauses.push(`${qi(attrName)} > ${paramRef}`); break; case 'greaterThanOrEqualTo': - whereClauses.push(`"${attrName}" >= ${paramRef}`); + whereClauses.push(`${qi(attrName)} >= ${paramRef}`); break; case 'lessThan': - whereClauses.push(`"${attrName}" < ${paramRef}`); + whereClauses.push(`${qi(attrName)} < ${paramRef}`); break; case 'lessThanOrEqualTo': - whereClauses.push(`"${attrName}" <= ${paramRef}`); + whereClauses.push(`${qi(attrName)} <= ${paramRef}`); break; case 'in': if (Array.isArray(val)) { @@ -180,15 +182,15 @@ export const BulkUpdatePlugin: GraphileConfig.Plugin = { return `$${values.length}::${sqlType}`; }); values.pop(); - whereClauses.push(`"${attrName}" IN (${placeholders.join(', ')})`); + whereClauses.push(`${qi(attrName)} IN (${placeholders.join(', ')})`); } break; case 'isNull': values.pop(); if (val) { - whereClauses.push(`"${attrName}" IS NULL`); + whereClauses.push(`${qi(attrName)} IS NULL`); } else { - whereClauses.push(`"${attrName}" IS NOT NULL`); + whereClauses.push(`${qi(attrName)} IS NOT NULL`); } break; default: @@ -212,28 +214,31 @@ 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 pgClient.query>({ + 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; - return `"${col}" = $${paramIdx}`; + return `${qi(col)} = $${paramIdx}`; }).join(' AND '); }); const selectWhere = pkConditions.map((c) => `(${c})`).join(' OR '); const selectParams = pkRows.flatMap((pkRow) => pkColumns.map((col) => pkRow[col]) ); - const selectResult = await pgClient.query( - `SELECT * FROM ${compiledFrom} WHERE ${selectWhere}`, - selectParams - ); - returning = selectResult.rows || []; + const selectResult = await pgClient.query>({ + text: `SELECT * FROM ${compiledFrom} WHERE ${selectWhere}`, + values: selectParams + }); + 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..dcd894cb05 100644 --- a/graphile/graphile-bulk-mutations/src/plugins/BulkUpsertPlugin.ts +++ b/graphile/graphile-bulk-mutations/src/plugins/BulkUpsertPlugin.ts @@ -1,6 +1,7 @@ import '../augmentations'; -import { sideEffectWithPgClient } from '@dataplan/pg'; +import { type PgClient,sideEffectWithPgClient } from '@dataplan/pg'; +import { QuoteUtils } from '@pgsql/quotes'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLInputType, GraphQLOutputType } from 'graphql'; @@ -8,6 +9,7 @@ import type { ColumnSpec } from '../utils/sql-builder'; import { buildBulkInsertSQL } from '../utils/sql-builder'; const version = '0.1.0'; +const qi = (name: string): string => QuoteUtils.quoteIdentifier(name); /** * BulkUpsertPlugin @@ -118,7 +120,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 +179,10 @@ 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 pgClient.query>({ + text: batch.text, + values: batch.values + }); totalAffected += result.rowCount ?? 0; if (result.rows) { allPkRows.push(...result.rows); @@ -193,18 +195,18 @@ export const BulkUpsertPlugin: GraphileConfig.Plugin = { const pkConditions = allPkRows.map((pkRow, rowIdx) => { return pkColumns.map((col, colIdx) => { const paramIdx = rowIdx * pkColumns.length + colIdx + 1; - return `"${col}" = $${paramIdx}`; + return `${qi(col)} = $${paramIdx}`; }).join(' AND '); }); const whereClause = pkConditions.map((c) => `(${c})`).join(' OR '); const selectParams = allPkRows.flatMap((pkRow) => pkColumns.map((col) => pkRow[col]) ); - const selectResult = await pgClient.query( - `SELECT * FROM ${compiledFrom} WHERE ${whereClause}`, - selectParams - ); - returning = selectResult.rows || []; + const selectResult = await pgClient.query>({ + text: `SELECT * FROM ${compiledFrom} WHERE ${whereClause}`, + values: selectParams + }); + returning = [...selectResult.rows]; } return { diff --git a/graphile/graphile-bulk-mutations/src/utils/sql-builder.ts b/graphile/graphile-bulk-mutations/src/utils/sql-builder.ts index b649aa3cf6..906bb3ba4e 100644 --- a/graphile/graphile-bulk-mutations/src/utils/sql-builder.ts +++ b/graphile/graphile-bulk-mutations/src/utils/sql-builder.ts @@ -12,8 +12,12 @@ * See: https://github.com/pyramation/graphile-column-privileges-mutations */ +import { QuoteUtils } from '@pgsql/quotes'; + import { PG_MAX_PARAMS } from '../types'; +const qi = (name: string): string => QuoteUtils.quoteIdentifier(name); + export interface ColumnSpec { name: string; sqlType: string; @@ -45,12 +49,12 @@ export function buildBulkInsertSQL( updateColumns?: string[]; } ): InsertBatch[] { - const colNames = columns.map((c) => `"${c.name}"`); + const colNames = columns.map((c) => qi(c.name)); const colsPerRow = columns.length; const maxRowsPerBatch = Math.floor(PG_MAX_PARAMS / colsPerRow); const returningClause = returningColumns.length > 0 - ? returningColumns.map((c) => `"${c}"`).join(', ') + ? returningColumns.map(qi).join(', ') : '*'; const batches: InsertBatch[] = []; @@ -79,7 +83,7 @@ export function buildBulkInsertSQL( if (onConflict) { if (onConflict.conflictColumns && onConflict.conflictColumns.length > 0) { - const colList = onConflict.conflictColumns.map((c) => `"${c}"`).join(', '); + const colList = onConflict.conflictColumns.map(qi).join(', '); text += `\nON CONFLICT (${colList})`; } else { text += '\nON CONFLICT'; @@ -93,7 +97,7 @@ export function buildBulkInsertSQL( ? onConflict.updateColumns : columns.map((c) => c.name); const setClause = setCols - .map((c) => `"${c}" = EXCLUDED."${c}"`) + .map((c) => `${qi(c)} = EXCLUDED.${qi(c)}`) .join(', '); text += ` DO UPDATE SET ${setClause}`; } @@ -130,7 +134,7 @@ export function buildBulkUpdateSQL( if (value === undefined) continue; values.push(value); - setClauses.push(`"${col.name}" = $${values.length}::${col.sqlType}`); + setClauses.push(`${qi(col.name)} = $${values.length}::${col.sqlType}`); } if (setClauses.length === 0) { @@ -146,7 +150,7 @@ export function buildBulkUpdateSQL( values.push(...whereParams); const returningClause = returningColumns.length > 0 - ? returningColumns.map((c) => `"${c}"`).join(', ') + ? returningColumns.map(qi).join(', ') : '*'; const text = `UPDATE ${tableName}\nSET ${setClauses.join(', ')}\nWHERE ${renumberedWhere}\nRETURNING ${returningClause}`; @@ -167,7 +171,7 @@ export function buildBulkDeleteSQL( whereParams: unknown[] ): { text: string; values: unknown[] } { const returningClause = returningColumns.length > 0 - ? returningColumns.map((c) => `"${c}"`).join(', ') + ? returningColumns.map(qi).join(', ') : '*'; const text = `DELETE FROM ${tableName}\nWHERE ${whereClause}\nRETURNING ${returningClause}`; diff --git a/graphile/graphile-function-bindings/src/__tests__/preloaded-bindings.test.ts b/graphile/graphile-function-bindings/src/__tests__/preloaded-bindings.test.ts new file mode 100644 index 0000000000..b07ecdf452 --- /dev/null +++ b/graphile/graphile-function-bindings/src/__tests__/preloaded-bindings.test.ts @@ -0,0 +1,122 @@ +import { withPgClientFromPgService } from '@dataplan/pg'; +import type { GraphileConfig } from 'graphile-config'; + +import { createFunctionBindingsPlugin } from '../plugin'; +import type { + ComputeModuleNames, + PreloadedFunctionBinding +} from '../types'; + +jest.mock('@dataplan/pg', () => ({ + ...jest.requireActual('@dataplan/pg'), + withPgClientFromPgService: jest.fn() +})); + +const withPgClientMock = withPgClientFromPgService as unknown as jest.Mock; + +const moduleNames: ComputeModuleNames = { + computeSchema: 'compute_public', + bindingsTable: 'function_api_bindings', + definitionsTable: 'function_definitions', + invocationsSchema: 'compute_public', + invocationsTable: 'function_invocations', + invocationsEntityField: null +}; + +const binding = (): PreloadedFunctionBinding => ({ + bindingId: 'binding-1', + alias: 'send_email', + config: { + graphql: { enabled: true }, + schema: { + type: 'object', + properties: { to: { type: 'string' } } + } + }, + functionDefinitionId: 'definition-1', + taskIdentifier: 'app:send_email', + description: 'Send an email', + payloadArgs: [{ name: 'to', type: 'text' }], + module: { ...moduleNames } +}); + +async function runGather(plugin: GraphileConfig.Plugin, includePgService = false) { + const output: Record = {}; + const main = (plugin.gather as any).main as ( + output: Record, + info: Record + ) => Promise; + await main(output, { + resolvedPreset: { + pgServices: includePgService ? [{ name: 'main' }] : [] + } + }); + return (output.functionApiBindings as { + bindings: readonly PreloadedFunctionBinding[]; + }).bindings; +} + +describe('FunctionBindingsPlugin preloaded bindings', () => { + beforeEach(() => { + withPgClientMock.mockReset(); + }); + + it('treats an empty preloaded array as authoritative and performs zero SQL', async () => { + const plugin = createFunctionBindingsPlugin({ + apiId: 'api-1', + modules: [], + preloadedBindings: [] + }); + + await expect(runGather(plugin)).resolves.toEqual([]); + expect(withPgClientMock).not.toHaveBeenCalled(); + }); + + it('snapshots nonempty preloaded rows immutably and performs zero SQL', async () => { + const original = binding(); + const plugin = createFunctionBindingsPlugin({ + apiId: 'api-1', + modules: [moduleNames], + preloadedBindings: [original] + }); + + original.alias = 'mutated_after_plugin_creation'; + (original.config!.graphql as { enabled: boolean }).enabled = false; + original.payloadArgs![0].name = 'mutated'; + original.module.invocationsTable = 'mutated_invocations'; + + const loaded = await runGather(plugin); + + expect(withPgClientMock).not.toHaveBeenCalled(); + expect(loaded).toHaveLength(1); + expect(loaded[0]).toMatchObject({ + alias: 'send_email', + payloadArgs: [{ name: 'to', type: 'text' }], + module: { invocationsTable: 'function_invocations' } + }); + expect((loaded[0].config!.graphql as { enabled: boolean }).enabled).toBe(true); + expect(Object.isFrozen(loaded)).toBe(true); + expect(Object.isFrozen(loaded[0])).toBe(true); + expect(Object.isFrozen(loaded[0].config)).toBe(true); + expect(Object.isFrozen(loaded[0].payloadArgs)).toBe(true); + expect(Object.isFrozen(loaded[0].module)).toBe(true); + }); + + it('uses the generic SQL loader only when preloadedBindings is undefined', async () => { + const query = jest.fn().mockResolvedValue({ rows: [] }); + withPgClientMock.mockImplementation( + async (_pgService: unknown, settings: unknown, callback: (client: unknown) => unknown) => { + expect(settings).toBeNull(); + return callback({ query }); + } + ); + const plugin = createFunctionBindingsPlugin({ + apiId: 'api-1', + modules: [moduleNames] + }); + + await expect(runGather(plugin, true)).resolves.toEqual([]); + expect(withPgClientMock).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/graphile/graphile-function-bindings/src/index.ts b/graphile/graphile-function-bindings/src/index.ts index dc947b2c6c..663d9cc321 100644 --- a/graphile/graphile-function-bindings/src/index.ts +++ b/graphile/graphile-function-bindings/src/index.ts @@ -14,5 +14,6 @@ export type { FunctionBindingRow, FunctionBindingsPluginOptions, JsonSchemaNode, - PayloadArg + PayloadArg, + PreloadedFunctionBinding } from './types'; diff --git a/graphile/graphile-function-bindings/src/plugin.ts b/graphile/graphile-function-bindings/src/plugin.ts index 0881770327..750b167899 100644 --- a/graphile/graphile-function-bindings/src/plugin.ts +++ b/graphile/graphile-function-bindings/src/plugin.ts @@ -1,12 +1,12 @@ /** * PostGraphile v5 Function Bindings Plugin * - * Exposes API-bound compute functions as GraphQL mutations. At gather time - * the plugin queries the bindings table joined to the definitions table - * (schema/table names resolved from the constructive metaschema via the - * express-context compute module loader — never guessed or hard-coded) - * for the configured api_id and emits one mutation per graphql-enabled - * binding: + * Exposes API-bound compute functions as GraphQL mutations. A Constructive + * server can preload an authoritative control-plane snapshot, avoiding tenant + * runtime-pool metadata queries during gather. Generic callers may omit that + * snapshot and retain the bindings/definitions table query (schema/table names + * resolved from the constructive metaschema — never guessed or hard-coded). + * The plugin emits one mutation per graphql-enabled binding: * * (input: Input!): Payload * @@ -40,7 +40,12 @@ import { toCamelCase, toConstantCase, toPascalCase } from 'inflekt'; import type { DerivedField, DerivedInput } from './derive'; import { buildInvocationPayload, deriveInputFields, isGraphqlEnabled } from './derive'; -import type { ComputeModuleNames, FunctionBindingRow, FunctionBindingsPluginOptions } from './types'; +import type { + ComputeModuleNames, + FunctionBindingRow, + FunctionBindingsPluginOptions, + PreloadedFunctionBinding +} from './types'; const log = new Logger('graphile-function-bindings'); @@ -56,21 +61,63 @@ declare global { } } -/** A binding together with the module (scope) it was loaded from. */ -interface LoadedBinding extends FunctionBindingRow { - module: ComputeModuleNames; +interface FunctionBindingsBuildInput { + bindings: readonly PreloadedFunctionBinding[]; } -interface FunctionBindingsBuildInput { - bindings: LoadedBinding[]; +interface FunctionBindingsPluginOptionsSnapshot { + apiId: string; + modules: readonly ComputeModuleNames[]; + preloadedBindings: readonly PreloadedFunctionBinding[] | undefined; +} + +function deepFreeze(value: T): T { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) { + return value; + } + Object.freeze(value); + for (const child of Object.values(value as Record)) { + deepFreeze(child); + } + return value; +} + +function snapshotBinding(binding: PreloadedFunctionBinding): PreloadedFunctionBinding { + return deepFreeze({ + bindingId: binding.bindingId, + alias: binding.alias, + config: binding.config === null ? null : structuredClone(binding.config), + functionDefinitionId: binding.functionDefinitionId, + taskIdentifier: binding.taskIdentifier, + description: binding.description, + payloadArgs: binding.payloadArgs === null + ? null + : binding.payloadArgs.map((argument) => ({ ...argument })), + module: { ...binding.module } + }); +} + +function snapshotOptions( + options: FunctionBindingsPluginOptions +): FunctionBindingsPluginOptionsSnapshot { + const preloadedBindings = options.preloadedBindings === undefined + ? undefined + : options.preloadedBindings + .map(snapshotBinding) + .filter((binding) => isGraphqlEnabled(binding.config)); + return deepFreeze({ + apiId: options.apiId, + modules: options.modules.map((module) => ({ ...module })), + preloadedBindings + }); } async function loadBindings( pgService: GraphileConfig.PgServiceConfiguration, - options: FunctionBindingsPluginOptions + options: FunctionBindingsPluginOptionsSnapshot ): Promise { return withPgClientFromPgService(pgService, null, async (client) => { - const bindings: LoadedBinding[] = []; + const bindings: PreloadedFunctionBinding[] = []; for (const module of options.modules) { const { computeSchema, bindingsTable, definitionsTable } = module; const { text, values } = new QueryBuilder() @@ -124,6 +171,7 @@ async function loadBindings( export function createFunctionBindingsPlugin( options: FunctionBindingsPluginOptions ): GraphileConfig.Plugin { + const optionsSnapshot = snapshotOptions(options); return { name: 'FunctionBindingsPlugin', version: '0.1.0', @@ -136,20 +184,25 @@ export function createFunctionBindingsPlugin( namespace: 'functionBindings', helpers: {}, async main(output, info) { - const pgService = info.resolvedPreset.pgServices?.[0]; - if (!pgService) { - throw new Error('FunctionBindingsPlugin: no pgService configured'); - } - if (!options.apiId) { + if (!optionsSnapshot.apiId) { throw new Error('FunctionBindingsPlugin: apiId is required'); } - if (!options.modules?.length) { - throw new Error('FunctionBindingsPlugin: at least one compute module is required'); + let result: FunctionBindingsBuildInput; + if (optionsSnapshot.preloadedBindings !== undefined) { + result = { bindings: optionsSnapshot.preloadedBindings }; + } else { + const pgService = info.resolvedPreset.pgServices?.[0]; + if (!pgService) { + throw new Error('FunctionBindingsPlugin: no pgService configured'); + } + if (optionsSnapshot.modules.length === 0) { + throw new Error('FunctionBindingsPlugin: at least one compute module is required'); + } + result = await loadBindings(pgService, optionsSnapshot); } - const result = await loadBindings(pgService, options); (output as Record).functionApiBindings = result; log.debug( - `Loaded ${result.bindings.length} graphql-enabled function binding(s) for api ${options.apiId}` + `Loaded ${result.bindings.length} graphql-enabled function binding(s) for api ${optionsSnapshot.apiId}` ); } }, diff --git a/graphile/graphile-function-bindings/src/types.ts b/graphile/graphile-function-bindings/src/types.ts index 1fd3576eac..d73c09a216 100644 --- a/graphile/graphile-function-bindings/src/types.ts +++ b/graphile/graphile-function-bindings/src/types.ts @@ -23,7 +23,7 @@ export interface JsonSchemaNode { /** * A graphql-enabled function_api_bindings row joined to its - * function_definitions row, loaded at gather time. + * function_definitions row, either preloaded or loaded at gather time. */ export interface FunctionBindingRow { bindingId: string; @@ -60,6 +60,15 @@ export interface ComputeModuleNames { invocationsEntityField: string | null; } +/** + * A control-plane-resolved binding paired with the exact physical compute + * module used for invocation writes. Supplying these rows lets schema builds + * avoid querying tenant runtime pools for binding metadata. + */ +export interface PreloadedFunctionBinding extends FunctionBindingRow { + module: ComputeModuleNames; +} + export interface FunctionBindingsPluginOptions { /** Only bindings for this api are exposed as mutations. */ apiId: string; @@ -67,5 +76,12 @@ export interface FunctionBindingsPluginOptions { * One entry per provisioned function-module scope. Bindings from every * module are exposed; RLS on the underlying tables governs access. */ - modules: ComputeModuleNames[]; + modules: readonly ComputeModuleNames[]; + /** + * Authoritative control-plane-resolved bindings for this build. When this + * option is defined, including as an empty array, the plugin performs no + * gather-time binding metadata query. Omit it to retain the generic SQL + * loader for callers that do not have a control-plane snapshot. + */ + preloadedBindings?: readonly PreloadedFunctionBinding[]; } diff --git a/graphile/graphile-i18n/package.json b/graphile/graphile-i18n/package.json index 78745402f2..11c58c561d 100644 --- a/graphile/graphile-i18n/package.json +++ b/graphile/graphile-i18n/package.json @@ -29,6 +29,7 @@ "url": "https://github.com/constructive-io/constructive/issues" }, "dependencies": { + "@pgsql/quotes": "^18.1.0", "accept-language-parser": "^1.5.0" }, "peerDependencies": { 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..93c8c4f2aa --- /dev/null +++ b/graphile/graphile-i18n/src/__tests__/pg-query.test.ts @@ -0,0 +1,33 @@ +import type { PgClient } from '@dataplan/pg'; + +import { queryI18nRow } from '../pg-query'; + +describe('queryI18nRow', () => { + it('passes one query configuration object to the @dataplan/pg client', async () => { + const query = jest.fn().mockResolvedValue({ + rows: [{ lang_code: 'es', title: 'Hola' }], + rowCount: 1, + notices: [], + }); + const client = { query } as unknown as Pick; + const values = [1, ['es', 'en']]; + + await expect(queryI18nRow(client, 'SELECT $1, $2', values)).resolves.toEqual({ + lang_code: 'es', + title: 'Hola', + }); + expect(query).toHaveBeenCalledTimes(1); + expect(query.mock.calls[0]).toHaveLength(1); + expect(query).toHaveBeenCalledWith({ + text: 'SELECT $1, $2', + values, + }); + }); + + it('returns null when the translation query has no rows', async () => { + const query = jest.fn().mockResolvedValue({ rows: [], rowCount: 0, notices: [] }); + const client = { query } as unknown as Pick; + + await expect(queryI18nRow(client, 'SELECT 1', [])).resolves.toBeNull(); + }); +}); diff --git a/graphile/graphile-i18n/src/__tests__/plugin-isolation.test.ts b/graphile/graphile-i18n/src/__tests__/plugin-isolation.test.ts new file mode 100644 index 0000000000..4e577fdc8c --- /dev/null +++ b/graphile/graphile-i18n/src/__tests__/plugin-isolation.test.ts @@ -0,0 +1,81 @@ +import { + assertI18nRequestContext, + resolveI18nTableInfo, +} from '../plugin'; + +function fixture(duplicateSameSchema = false) { + const idCodec = { name: 'tenant_id', sqlType: { kind: 'tenant_id' } }; + const textCodec = { name: 'text', sqlType: { kind: 'text' } }; + const baseCodec = { + name: 'posts', + attributes: { + id: { codec: idCodec }, + title: { codec: textCodec }, + }, + extensions: { + pg: { serviceName: 'main', schemaName: 'tenant_a', name: 'posts' }, + tags: { i18n: 'posts_translations' }, + }, + }; + const translationCodec = (schemaName: string) => ({ + name: `${schemaName}PostsTranslations`, + attributes: { + posts_id: { codec: idCodec }, + lang_code: { codec: textCodec }, + title: { codec: textCodec, notNull: true }, + }, + extensions: { + pg: { serviceName: 'main', schemaName, name: 'posts_translations' }, + }, + }); + const tenantATranslation = translationCodec('tenant_a'); + const resources: Record = { + base: { + codec: baseCodec, + uniques: [{ isPrimary: true, attributes: ['id'] }], + }, + tenantATranslation: { codec: tenantATranslation }, + tenantBTranslation: { codec: translationCodec('tenant_b') }, + }; + if (duplicateSameSchema) { + resources.duplicateTenantATranslation = { codec: tenantATranslation }; + } + const build = { + input: { pgRegistry: { pgResources: resources } }, + inflection: { camelCase: (value: string) => value }, + sql: { + compile: (value: unknown) => ({ + text: value === idCodec.sqlType ? 'tenant_types.tenant_id' : 'text', + values: [] as unknown[], + }), + }, + }; + return { build, baseCodec }; +} + +describe('i18n exact-build isolation', () => { + it('resolves only the same-service, same-schema translation resource', () => { + const { build, baseCodec } = fixture(); + expect(resolveI18nTableInfo(build, baseCodec as any, 'lang_code', ['text'])) + .toMatchObject({ + schemaName: 'tenant_a', + baseTable: 'posts', + translationTable: 'posts_translations', + pkType: 'tenant_types.tenant_id', + }); + }); + + it('fails when the exact translation coordinate is ambiguous', () => { + const { build, baseCodec } = fixture(true); + expect(() => resolveI18nTableInfo(build, baseCodec as any, 'lang_code', ['text'])) + .toThrow(/matches=2/); + }); + + it.each([ + [undefined, {}, 1, 'I18N_PG_CLIENT_CONTEXT_UNAVAILABLE'], + [jest.fn(), null, 1, 'I18N_PG_SETTINGS_UNAVAILABLE'], + [jest.fn(), {}, undefined, 'I18N_PARENT_ID_UNAVAILABLE'], + ])('fails closed when request context is incomplete', (withPgClient, pgSettings, id, error) => { + expect(() => assertI18nRequestContext(withPgClient, pgSettings, id)).toThrow(error); + }); +}); diff --git a/graphile/graphile-i18n/src/pg-query.ts b/graphile/graphile-i18n/src/pg-query.ts new file mode 100644 index 0000000000..5eba921e2c --- /dev/null +++ b/graphile/graphile-i18n/src/pg-query.ts @@ -0,0 +1,10 @@ +import type { PgClient } from '@dataplan/pg'; + +export async function queryI18nRow( + client: Pick, + text: string, + values: any[] +): Promise | null> { + 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 0fb1af80d6..1a75736f00 100644 --- a/graphile/graphile-i18n/src/plugin.ts +++ b/graphile/graphile-i18n/src/plugin.ts @@ -20,11 +20,13 @@ import 'graphile-build'; import 'graphile-build-pg'; -import type { PgCodecWithAttributes } from '@dataplan/pg'; +import type { PgClient, PgCodecWithAttributes } from '@dataplan/pg'; import { TYPES } from '@dataplan/pg'; +import { QuoteUtils } from '@pgsql/quotes'; import { context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; +import { queryI18nRow } from './pg-query'; import type { I18nPluginOptions, I18nTableInfo, TranslatableField } from './types'; // ─── Namespace Augmentations ───────────────────────────────────────────────── @@ -47,15 +49,6 @@ function hasI18nTag(codec: PgCodecWithAttributes): string | false { return false; } -function resolvePgTypeName(codec: any): string { - if (codec === TYPES.uuid) return 'uuid'; - if (codec === TYPES.int) return 'int4'; - if (codec === TYPES.bigint) return 'int8'; - if (codec === TYPES.text) return 'text'; - if (codec === TYPES.varchar) return 'text'; - return codec?.name ?? 'text'; -} - function resolveAttrPgType(codec: any): string { if (codec === TYPES.text) return 'text'; if (codec === TYPES.varchar) return 'text'; @@ -63,6 +56,172 @@ function resolveAttrPgType(codec: any): string { return codec?.name ?? 'text'; } +function resourceIdentity(resource: any, label: string): { + serviceName: string; + schemaName: string; + name: string; +} { + const pg = resource?.codec?.extensions?.pg ?? resource?.extensions?.pg; + if (!pg?.serviceName || !pg?.schemaName || !pg?.name) { + throw new Error(`[graphile-i18n] ${label} is missing exact service/schema/table metadata`); + } + return pg; +} + +function compilePgType(build: any, codec: any, label: string): string { + if (!codec?.sqlType || typeof build?.sql?.compile !== 'function') { + throw new Error(`[graphile-i18n] ${label} has no compilable PostgreSQL type`); + } + const compiled = build.sql.compile(codec.sqlType); + if (!compiled?.text || (compiled.values?.length ?? 0) !== 0) { + throw new Error(`[graphile-i18n] ${label} PostgreSQL type did not compile to a static identifier`); + } + return compiled.text; +} + +/** Resolve one @i18n tag exclusively against this exact build registry. */ +export function resolveI18nTableInfo( + build: any, + codec: PgCodecWithAttributes, + langCodeColumn: string, + allowedTypes: readonly string[] +): I18nTableInfo | null { + const translationTableName = hasI18nTag(codec); + if (!translationTableName) return null; + + const resources = Object.values(build.input?.pgRegistry?.pgResources ?? {}) as any[]; + const baseMatches = resources.filter( + (resource) => !resource?.parameters && resource?.codec === codec + ); + if (baseMatches.length !== 1) { + throw new Error( + `[graphile-i18n] @i18n codec '${codec.name}' must resolve exactly one base resource ` + + `(matches=${baseMatches.length})` + ); + } + const baseResource = baseMatches[0]; + const base = resourceIdentity(baseResource, 'base resource'); + + const primaryKeys = (baseResource.uniques as Array<{ + attributes: string[]; + isPrimary?: boolean; + }> | undefined)?.filter((unique) => unique.isPrimary) ?? []; + if (primaryKeys.length !== 1 || primaryKeys[0].attributes.length !== 1) { + throw new Error( + `[graphile-i18n] @i18n base '${base.schemaName}.${base.name}' requires one ` + + 'single-column primary key' + ); + } + const pkColumn = primaryKeys[0].attributes[0]; + const pkAttr = codec.attributes?.[pkColumn] as any; + if (!pkAttr) { + throw new Error( + `[graphile-i18n] Primary key '${pkColumn}' is missing from ` + + `'${base.schemaName}.${base.name}'` + ); + } + const pkType = compilePgType(build, pkAttr.codec, `${base.schemaName}.${base.name}.${pkColumn}`); + + const translationMatches = resources.filter((resource) => { + if (resource?.parameters || !resource?.codec?.attributes) return false; + const pg = resource.codec.extensions?.pg ?? resource.extensions?.pg; + return pg?.serviceName === base.serviceName && + pg?.schemaName === base.schemaName && + pg?.name === translationTableName; + }); + if (translationMatches.length !== 1) { + throw new Error( + `[graphile-i18n] @i18n on '${base.schemaName}.${base.name}' must resolve exactly ` + + `one same-service, same-schema '${translationTableName}' resource ` + + `(matches=${translationMatches.length})` + ); + } + + const translationResource = translationMatches[0]; + const translation = resourceIdentity(translationResource, 'translation resource'); + const translationCodec = translationResource.codec as PgCodecWithAttributes; + if (!translationCodec.attributes?.[langCodeColumn]) { + throw new Error( + `[graphile-i18n] Translation table '${translation.schemaName}.${translation.name}' ` + + `is missing language column '${langCodeColumn}'` + ); + } + + const conventionalFk = `${base.name}_id`; + const matchingFkColumns = Object.entries(translationCodec.attributes) + .filter(([attrName, attr]) => + attrName !== 'id' && + attrName !== langCodeColumn && + (attr as any).codec === pkAttr.codec + ) + .map(([attrName]) => attrName); + const fkColumn = matchingFkColumns.includes(conventionalFk) + ? conventionalFk + : matchingFkColumns.length === 1 + ? matchingFkColumns[0] + : null; + if (!fkColumn) { + throw new Error( + `[graphile-i18n] Translation table '${translation.schemaName}.${translation.name}' ` + + `has ambiguous or missing FK metadata for '${base.schemaName}.${base.name}'` + ); + } + + const fields: Record = {}; + for (const [attrName, attr] of Object.entries(translationCodec.attributes)) { + if (attrName === langCodeColumn || attrName === fkColumn) continue; + if (attrName === 'id' || attrName === 'created_at' || attrName === 'updated_at') continue; + + const pgType = resolveAttrPgType((attr as any).codec); + if (!allowedTypes.includes(pgType)) continue; + if (!codec.attributes?.[attrName]) { + throw new Error( + `[graphile-i18n] Translation field '${translation.schemaName}.${translation.name}.` + + `${attrName}' has no matching base field on '${base.schemaName}.${base.name}'` + ); + } + + const gqlName = build.inflection.camelCase(attrName); + fields[gqlName] = { + column: attrName, + type: pgType, + isNotNull: !!(attr as any).notNull, + }; + } + if (Object.keys(fields).length === 0) { + throw new Error( + `[graphile-i18n] Translation table '${translation.schemaName}.${translation.name}' ` + + 'has no eligible translatable fields' + ); + } + + return { + baseTable: base.name, + translationTable: translation.name, + schemaName: base.schemaName, + fkColumn, + pkColumn, + pkType, + fields, + }; +} + +export function assertI18nRequestContext( + withPgClient: unknown, + pgSettings: unknown, + id: unknown +): void { + if (typeof withPgClient !== 'function') { + throw new Error('I18N_PG_CLIENT_CONTEXT_UNAVAILABLE'); + } + if (typeof pgSettings !== 'object' || pgSettings === null || Array.isArray(pgSettings)) { + throw new Error('I18N_PG_SETTINGS_UNAVAILABLE'); + } + if (id === null || id === undefined) { + throw new Error('I18N_PARENT_ID_UNAVAILABLE'); + } +} + // ─── Plugin Factory ────────────────────────────────────────────────────────── export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfig.Plugin { @@ -74,8 +233,8 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi } = options; // Closure-scoped state shared between init and field hooks - let i18nRegistry: Record = {}; - const localeTypeCache: Record = {}; + let i18nRegistry = new WeakMap(); + let localeTypeCache: Record = {}; return { name: 'I18nPlugin', @@ -85,119 +244,16 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi hooks: { init: { callback(_, build) { - i18nRegistry = {}; + i18nRegistry = new WeakMap(); + localeTypeCache = {}; for (const [, codec] of Object.entries(build.input.pgRegistry.pgCodecs)) { const c = codec as PgCodecWithAttributes; if (!c.attributes) continue; - const translationTableName = hasI18nTag(c); - if (!translationTableName) continue; - - // Get schema name from the codec's pg extensions - let schemaName = (c.extensions as any)?.pg?.schemaName ?? 'public'; - let pkColumn: string | null = null; - let pkType = 'text'; - for (const [, resource] of Object.entries(build.input.pgRegistry.pgResources)) { - const r = resource as any; - if (r.codec === c) { - // Try multiple sources for schema name - const rSchema = r.extensions?.pg?.schemaName ?? r.schemaName; - if (rSchema) schemaName = rSchema; - // Extract PK from the resource's uniques array - const uniques = r.uniques as Array<{ attributes: string[]; isPrimary?: boolean }> | undefined; - if (uniques) { - const pk = uniques.find((u: any) => u.isPrimary); - if (pk && pk.attributes.length === 1) { - pkColumn = pk.attributes[0]; - const pkAttr = c.attributes[pkColumn]; - if (pkAttr) { - pkType = resolvePgTypeName((pkAttr as any).codec); - } - } - } - break; - } - } - if (!pkColumn) continue; - - // Find the translation codec. The @i18n tag value is the SQL table name - // (e.g. 'posts_translations'), but PostGraphile inflects codec names - // to camelCase (e.g. 'postsTranslations'). Match via resource name. - let translationCodec: PgCodecWithAttributes | null = null; - for (const [, resource] of Object.entries(build.input.pgRegistry.pgResources)) { - const r = resource as any; - if (!r.codec?.attributes) continue; - // Match by the resource's SQL name (which preserves snake_case) - const sqlName = r.codec?.extensions?.pg?.name ?? r.name; - if (sqlName === translationTableName) { - translationCodec = r.codec as PgCodecWithAttributes; - break; - } - } - // Fallback: try matching the inflected codec name directly - if (!translationCodec) { - const inflectedName = build.inflection.camelCase(translationTableName); - for (const [, tCodec] of Object.entries(build.input.pgRegistry.pgCodecs)) { - const tc = tCodec as any; - if (!tc.attributes) continue; - if (tc.name === translationTableName || tc.name === inflectedName) { - translationCodec = tc; - break; - } - } - } - - if (!translationCodec) continue; - - // Find FK column on translation table — convention first, then type match - let fkColumn: string | null = null; - const conventionalFk = `${c.name}_id`; - if (translationCodec.attributes[conventionalFk]) { - fkColumn = conventionalFk; - } - if (!fkColumn) { - // Fallback: find a column with the same type as the PK, excluding - // common non-FK columns (id, lang_code) - for (const [attrName, attr] of Object.entries(translationCodec.attributes)) { - if (attrName === 'id' || attrName === langCodeColumn) continue; - const a = attr as any; - if (a.codec === (c.attributes[pkColumn] as any).codec) { - fkColumn = attrName; - break; - } - } - } - if (!fkColumn) continue; - - // Discover translatable fields - const fields: Record = {}; - for (const [attrName, attr] of Object.entries(translationCodec.attributes)) { - if (attrName === langCodeColumn || attrName === fkColumn) continue; - if (attrName === 'id' || attrName === 'created_at' || attrName === 'updated_at') continue; - - const pgType = resolveAttrPgType((attr as any).codec); - if (!allowedTypes.includes(pgType)) continue; - - const gqlName = build.inflection.camelCase(attrName); - fields[gqlName] = { - column: attrName, - type: pgType, - isNotNull: !!(attr as any).notNull, - }; - } - - if (Object.keys(fields).length === 0) continue; - - i18nRegistry[c.name] = { - baseTable: c.name, - translationTable: translationTableName, - schemaName, - fkColumn, - pkColumn, - pkType, - fields, - }; + if (!hasI18nTag(c)) continue; + const info = resolveI18nTableInfo(build, c, langCodeColumn, allowedTypes); + if (info) i18nRegistry.set(c, info); } return _; @@ -211,7 +267,7 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi if (!scope.pgCodec || !scope.isPgClassType) return fields; const codec = scope.pgCodec as PgCodecWithAttributes; - const info = i18nRegistry[codec.name]; + const info = i18nRegistry.get(codec); if (!info) return fields; const localeFieldsConfig: Record = { @@ -235,18 +291,22 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi const { schemaName, baseTable, translationTable, fkColumn, pkColumn, pkType, fields: i18nFields } = info; + const qi = (name: string): string => QuoteUtils.quoteIdentifier(name); const coalescedCols = Object.values(i18nFields) - .map(f => `coalesce(v."${f.column}", b."${f.column}") as "${f.column}"`) + .map(f => `coalesce(v.${qi(f.column)}, b.${qi(f.column)}) as ${qi(f.column)}`) .join(', '); + const baseTableRef = QuoteUtils.quoteQualifiedIdentifier(schemaName, baseTable); + const translationTableRef = QuoteUtils.quoteQualifiedIdentifier(schemaName, translationTable); + // Build the SQL query template - const sqlQuery = `SELECT v."${langCodeColumn}" AS "lang_code", ${coalescedCols} - FROM "${schemaName}"."${baseTable}" b - LEFT JOIN "${schemaName}"."${translationTable}" v - ON v."${fkColumn}" = b."${pkColumn}" - AND array_position($2::text[], v."${langCodeColumn}") IS NOT NULL - WHERE b."${pkColumn}" = $1::${pkType} - ORDER BY array_position($2::text[], v."${langCodeColumn}") ASC NULLS LAST + const sqlQuery = `SELECT v.${qi(langCodeColumn)} AS "lang_code", ${coalescedCols} + FROM ${baseTableRef} b + LEFT JOIN ${translationTableRef} v + ON v.${qi(fkColumn)} = b.${qi(pkColumn)} + AND array_position($2::text[], v.${qi(langCodeColumn)}) IS NOT NULL + WHERE b.${qi(pkColumn)} = $1::${pkType} + ORDER BY array_position($2::text[], v.${qi(langCodeColumn)}) ASC NULLS LAST LIMIT 1`; // Build column names list for mapping base values @@ -266,31 +326,26 @@ 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; - } + assertI18nRequestContext(withPgClient, pgSettings, id); - const row = await withPgClient(null, async (client: any) => { - const { rows } = await client.query(sqlQuery, [id, langs]); - return rows[0] ?? null; + const row = await withPgClient(pgSettings, async (client: PgClient) => { + return queryI18nRow(client, sqlQuery, [id, langs]); }); if (!row) { diff --git a/graphile/graphile-llm/package.json b/graphile/graphile-llm/package.json index d03cd9975d..01bc6fa3d9 100644 --- a/graphile/graphile-llm/package.json +++ b/graphile/graphile-llm/package.json @@ -32,6 +32,7 @@ "@agentic-kit/ollama": "workspace:*", "@constructive-io/express-context": "workspace:^", "@constructive-io/llm-env": "workspace:^", + "@pgsql/quotes": "^18.1.0", "graphile-cache": "workspace:^" }, "peerDependencies": { diff --git a/graphile/graphile-llm/src/__tests__/agent-discovery.test.ts b/graphile/graphile-llm/src/__tests__/agent-discovery.test.ts new file mode 100644 index 0000000000..85134b42e8 --- /dev/null +++ b/graphile/graphile-llm/src/__tests__/agent-discovery.test.ts @@ -0,0 +1,67 @@ +import { clearAgentDiscoveryCache, getAgentDiscovery } from '../plugins/agent-discovery-plugin'; + +const TENANT_A = '11111111-1111-1111-1111-111111111111'; +const TENANT_B = '22222222-2222-2222-2222-222222222222'; + +function makePool(schemaPrefix = 'tenant') { + const calls: Array<{ text: string; values?: unknown[] }> = []; + const rows: Record = { + [TENANT_A]: { + schema_name: `${schemaPrefix}_a_agent`, + thread_table_name: 'agent_thread', + message_table_name: 'agent_message', + task_table_name: 'agent_task' + }, + [TENANT_B]: { + schema_name: `${schemaPrefix}_b_agent`, + thread_table_name: 'agent_thread', + message_table_name: 'agent_message', + task_table_name: 'agent_task' + } + }; + return { + calls, + query: async (text: string, values?: unknown[]) => { + calls.push({ text, values }); + const row = rows[String(values?.[0])]; + return { rows: row ? [row] : [] }; + } + }; +} + +describe('agent discovery tenant isolation', () => { + beforeEach(() => clearAgentDiscoveryCache()); + + it('filters and caches discovery by database id', async () => { + const pool = makePool(); + const a = await getAgentDiscovery(pool as any, TENANT_A); + const cachedA = await getAgentDiscovery(pool as any, TENANT_A); + const b = await getAgentDiscovery(pool as any, TENANT_B); + + expect(pool.calls).toHaveLength(2); + expect(pool.calls[0].text).toContain('WHERE acm.database_id = $1'); + expect(pool.calls.map((call) => call.values)).toEqual([[TENANT_A], [TENANT_B]]); + expect(a).toEqual(cachedA); + expect(a?.thread?.schemaName).toBe('tenant_a_agent'); + expect(b?.thread?.schemaName).toBe('tenant_b_agent'); + }); + + it('fails closed when database id is absent', async () => { + const pool = makePool(); + await expect(getAgentDiscovery(pool as any, '')).rejects.toThrow(/databaseId is required/); + expect(pool.calls).toHaveLength(0); + }); + + it('does not share discovery across different physical pool identities', async () => { + const poolA = makePool('physical_a'); + const poolB = makePool('physical_b'); + + const a = await getAgentDiscovery(poolA as any, TENANT_A); + const b = await getAgentDiscovery(poolB as any, TENANT_A); + + expect(a?.thread?.schemaName).toBe('physical_a_a_agent'); + expect(b?.thread?.schemaName).toBe('physical_b_a_agent'); + expect(poolA.calls).toHaveLength(1); + expect(poolB.calls).toHaveLength(1); + }); +}); diff --git a/graphile/graphile-llm/src/__tests__/config-cache-isolation.test.ts b/graphile/graphile-llm/src/__tests__/config-cache-isolation.test.ts new file mode 100644 index 0000000000..7cf54f5875 --- /dev/null +++ b/graphile/graphile-llm/src/__tests__/config-cache-isolation.test.ts @@ -0,0 +1,66 @@ +import { + 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 isolation', () => { + beforeEach(() => invalidateLlmBillingConfig()); + + it('keys the same database UUID by opaque 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); + }); + + it('does not cache or downgrade incomplete configuration', async () => { + const client = { + query: jest.fn(async (text: string) => { + if (text.includes('information_schema.schemata')) return { rows: [{ exists: 1 }] }; + if (text.includes('billing_module')) { + return { rows: [{ private_schema: 'tenant_private' }] }; + } + return { rows: [] }; + }), + }; + + await expect(getLlmBillingConfig(client, 'database-a', {})) + .rejects.toThrow('LLM_BILLING_CONFIG_INCOMPLETE'); + }); + + it('requires an explicit exact-build cache scope', async () => { + await expect(getLlmBillingConfig(makeClient('tenant_private'), 'database-a', null as any)) + .rejects.toThrow('LLM_CONFIG_CACHE_SCOPE_UNAVAILABLE'); + }); +}); diff --git a/graphile/graphile-llm/src/__tests__/rag-sql.test.ts b/graphile/graphile-llm/src/__tests__/rag-sql.test.ts new file mode 100644 index 0000000000..79e99c0b70 --- /dev/null +++ b/graphile/graphile-llm/src/__tests__/rag-sql.test.ts @@ -0,0 +1,103 @@ +import { buildChunkSearchSql, discoverChunkTables } from '../plugins/rag-plugin'; +import type { ChunkTableInfo } from '../types'; + +const chunkTable = (overrides: Partial = {}): ChunkTableInfo => ({ + parentCodecName: 'articles', + chunksSchema: 'tenant-a-app-public', + vectorSchema: 'tenant-a-extensions', + chunksTableName: 'article_chunks', + parentFkField: 'article_id', + parentPkField: 'id', + embeddingField: 'embedding', + contentField: 'content', + ...overrides +}); + +describe('RAG SQL qualification', () => { + it('quotes tenant schemas and keeps parameter values separate', () => { + const query = buildChunkSearchSql(chunkTable(), '[1,0]', 7, 0.4); + expect(query.text).toContain('FROM "tenant-a-app-public".article_chunks'); + expect(query.text).toContain('$1::"tenant-a-extensions".vector'); + expect(query.text).toContain('OPERATOR("tenant-a-extensions".<=>)'); + expect(query.values).toEqual(['[1,0]', 0.4, 7]); + }); + + it('discovers the exact physical chunks resource and vector type schema', () => { + const vectorCodec = { + name: 'vector', + extensions: { + pg: { serviceName: 'main', schemaName: 'tenant-a-extensions', name: 'vector' } + } + }; + const chunkCodec = { + name: 'articleChunks', + attributes: { + article_id: {}, + content: {}, + embedding: { codec: vectorCodec } + }, + extensions: { + pg: { + serviceName: 'main', + schemaName: 'tenant-a-app-public', + name: 'article_chunks' + } + } + }; + const tables = discoverChunkTables({ + input: { + pgRegistry: { + pgCodecs: { + articles: { + name: 'articles', + attributes: { id: {} }, + extensions: { + pg: { + serviceName: 'main', + schemaName: 'tenant-a-app-public', + name: 'articles' + }, + tags: { + hasChunks: { + chunksTable: 'article_chunks', + parentFk: 'article_id' + } + } + } + } + }, + pgResources: { articleChunks: { codec: chunkCodec } } + } + }, + resolvedPreset: { + pgServices: [{ name: 'main', schemas: ['tenant-a-app-public'] }] + } + }); + expect(tables).toHaveLength(1); + expect(tables[0].chunksSchema).toBe('tenant-a-app-public'); + expect(tables[0].vectorSchema).toBe('tenant-a-extensions'); + }); + + it('rejects a chunks schema outside the exact service allowlist', () => { + expect(() => discoverChunkTables({ + input: { + pgRegistry: { + pgCodecs: { + articles: { + name: 'articles', + attributes: { id: {} }, + extensions: { + pg: { serviceName: 'main', schemaName: 'tenant_a', name: 'articles' }, + tags: { + hasChunks: { chunksSchema: 'tenant_b', chunksTable: 'article_chunks' } + } + } + } + }, + pgResources: {} + } + }, + resolvedPreset: { pgServices: [{ name: 'main', schemas: ['tenant_a'] }] } + })).toThrow(/outside service 'main'/); + }); +}); diff --git a/graphile/graphile-llm/src/config-cache.ts b/graphile/graphile-llm/src/config-cache.ts index c3a5ae82fb..a5d2b1db7d 100644 --- a/graphile/graphile-llm/src/config-cache.ts +++ b/graphile/graphile-llm/src/config-cache.ts @@ -1,7 +1,7 @@ /** * config-cache — Per-database LLM billing configuration cache * - * Caches resolved billing function names per database_id. + * Caches resolved billing function names per exact Graphile build and database_id. * Uses an LRU cache with TTL so config changes propagate within a bounded window * without requiring a server restart. * @@ -103,6 +103,21 @@ const billingCache = new ModuleConfigCache({ max: 50 }); +const billingScopeIds = new WeakMap(); +let nextBillingScopeId = 1; + +function scopedCacheKey(cacheScope: object, databaseId: string): string { + if ((typeof cacheScope !== 'object' && typeof cacheScope !== 'function') || cacheScope === null) { + throw new Error('LLM_CONFIG_CACHE_SCOPE_UNAVAILABLE'); + } + let scopeId = billingScopeIds.get(cacheScope); + if (scopeId === undefined) { + scopeId = nextBillingScopeId++; + billingScopeIds.set(cacheScope, scopeId); + } + return `${scopeId}\0${databaseId}`; +} + // ─── Resolution Functions ─────────────────────────────────────────────────── /** @@ -117,49 +132,46 @@ 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 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) 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; + // 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) 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 ───────────────────────────────────────────────────────────── @@ -170,12 +182,16 @@ async function resolveBillingConfig( * * @param pgClient - A client connected to the tenant database (from withPgClient) * @param databaseId - The database UUID + * @param cacheScope - Opaque identity of the exact Graphile build/pool contract */ export async function getLlmBillingConfig( pgClient: PgClient, - databaseId: string + databaseId: string, + cacheScope: object ): Promise { - const cached = billingCache.get(databaseId); + if (!databaseId) throw new Error('LLM_DATABASE_ID_UNAVAILABLE'); + const cacheKey = scopedCacheKey(cacheScope, databaseId); + const cached = billingCache.get(cacheKey); if (cached) return cached; const [billing, inferenceLog] = await Promise.all([ @@ -184,19 +200,21 @@ export async function getLlmBillingConfig( ]); const entry: LlmBillingCacheEntry = { billing, inferenceLog }; - billingCache.set(databaseId, entry); + billingCache.set(cacheKey, entry); return entry; } /** * Invalidate the cached config for a specific database (or all). */ -export function invalidateLlmBillingConfig(databaseId?: string): void { - if (databaseId) { - billingCache.delete(databaseId); - } else { - billingCache.clear(); +export function invalidateLlmBillingConfig(databaseId?: string, cacheScope?: object): void { + if (databaseId && cacheScope) { + billingCache.delete(scopedCacheKey(cacheScope, databaseId)); + return; } + // A database UUID alone is not a safe cache identity. Conservatively clear + // every exact-build entry when the caller cannot provide the matching scope. + billingCache.clear(); } /** diff --git a/graphile/graphile-llm/src/plugins/agent-discovery-plugin.ts b/graphile/graphile-llm/src/plugins/agent-discovery-plugin.ts index 15347c94d7..345c256b0a 100644 --- a/graphile/graphile-llm/src/plugins/agent-discovery-plugin.ts +++ b/graphile/graphile-llm/src/plugins/agent-discovery-plugin.ts @@ -34,14 +34,23 @@ export interface AgentDiscovery { // ─── Cache ────────────────────────────────────────────────────────────────── -const agentDiscoveryCache = new ModuleConfigCache({ - name: 'agent-discovery', - ttlMs: 60_000 -}); +let agentDiscoveryCaches = new WeakMap>(); + +function cacheForPool(pool: object): ModuleConfigCache { + let cache = agentDiscoveryCaches.get(pool); + if (!cache) { + cache = new ModuleConfigCache({ + 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 ──────────────────────────────────────────────────────── @@ -53,7 +62,9 @@ const DISCOVERY_SQL = ` acm.message_table_name, acm.task_table_name FROM metaschema_modules_public.agent_chat_module acm - JOIN metaschema_public.schema s ON s.id = acm.schema_id + JOIN metaschema_public.schema s + ON s.id = acm.schema_id + AND s.database_id = acm.database_id WHERE acm.database_id = $1 LIMIT 1 `; @@ -83,6 +94,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; diff --git a/graphile/graphile-llm/src/plugins/metering-plugin.ts b/graphile/graphile-llm/src/plugins/metering-plugin.ts index 754f4aabfd..40eb749138 100644 --- a/graphile/graphile-llm/src/plugins/metering-plugin.ts +++ b/graphile/graphile-llm/src/plugins/metering-plugin.ts @@ -26,7 +26,7 @@ * **Graceful behavior:** * - billing_module not provisioned → embedder passes through unmetered * - entity_id not available → embedder passes through unmetered - * - check_billing_quota throws → call is allowed (billing is opt-in) + * - check_billing_quota fails after billing is configured → call is denied * - record_usage throws → call succeeds, recording silently skipped * - quota exceeded → embedder returns null */ @@ -63,7 +63,8 @@ function defaultResolveEntityId(pgSettings: Record): string | nu async function buildMeteringContext( graphqlContext: any, - resolveEntityId: (pgSettings: Record) => string | null + resolveEntityId: (pgSettings: Record) => string | null, + cacheScope: object ): Promise { const pgSettings: Record = graphqlContext?.pgSettings ?? {}; const entityId = resolveEntityId(pgSettings); @@ -73,19 +74,15 @@ async function buildMeteringContext( if (!entityId || !databaseId) return null; const withPgClient: WithPgClient | undefined = graphqlContext?.withPgClient; - if (!withPgClient) return null; + if (!withPgClient) throw new Error('LLM_METERING_PG_CONTEXT_UNAVAILABLE'); let billingConfig = null; let inferenceLogConfig = null; - try { - await withPgClient(pgSettings, async (pgClient: PgClient) => { - const entry = await getLlmBillingConfig(pgClient, databaseId); - billingConfig = entry.billing; - inferenceLogConfig = entry.inferenceLog; - }); - } catch { - return null; - } + await withPgClient(pgSettings, async (pgClient: PgClient) => { + const entry = await getLlmBillingConfig(pgClient, databaseId, cacheScope); + billingConfig = entry.billing; + inferenceLogConfig = entry.inferenceLog; + }); if (!billingConfig) return null; @@ -210,12 +207,17 @@ export function createLlmMeteringPlugin( const defaultResolver = (obj: any) => obj[(context as any).scope.fieldName]; const { resolve: oldResolve = defaultResolver, ...rest } = field; + const exactBuildCacheScope = build as object; return { ...rest, async resolve(source: any, args: any, graphqlContext: any, info: any) { // Build the metering context for this request - const ctx = await buildMeteringContext(graphqlContext, resolveEntityId); + const ctx = await buildMeteringContext( + graphqlContext, + resolveEntityId, + exactBuildCacheScope + ); // Run the original resolver within the AsyncLocalStorage scope // so any embedder calls made by downstream plugins pick up the ctx diff --git a/graphile/graphile-llm/src/plugins/rag-plugin.ts b/graphile/graphile-llm/src/plugins/rag-plugin.ts index 3c1a3e15cf..e9870be67e 100644 --- a/graphile/graphile-llm/src/plugins/rag-plugin.ts +++ b/graphile/graphile-llm/src/plugins/rag-plugin.ts @@ -20,6 +20,7 @@ * 2. Falls back to error if not configured */ +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'; @@ -76,6 +77,7 @@ function parseHasChunksTag(raw: any, codec: any): ChunkTableInfo | null { return { parentCodecName: codec.name || 'unknown', chunksSchema, + vectorSchema: '', chunksTableName: parsed.chunksTable, parentFkField: parsed.parentFk || 'parent_id', parentPkField: parsed.parentPk || 'id', @@ -84,10 +86,60 @@ function parseHasChunksTag(raw: any, codec: any): ChunkTableInfo | null { }; } +function requirePgIdentity(value: any, label: string): { + serviceName: string; + schemaName: string; + name: string; +} { + const pg = value?.extensions?.pg; + if (!pg?.serviceName || !pg?.schemaName || !pg?.name) { + throw new Error(`[graphile-llm] ${label} is missing exact service/schema/table metadata`); + } + return pg; +} + +function configuredSchemas(build: any, serviceName: string): ReadonlySet | null { + const services = build?.resolvedPreset?.pgServices; + if (!Array.isArray(services)) return null; + const matches = services.filter( + (service: any) => (service?.name ?? 'main') === serviceName + ); + if (matches.length !== 1) { + throw new Error( + `[graphile-llm] @hasChunks cannot resolve exact service '${serviceName}' ` + + `(matches=${matches.length})` + ); + } + const service = matches[0]; + const schemas = service?.schemas; + if (!Array.isArray(schemas) || schemas.length === 0) { + throw new Error( + `[graphile-llm] @hasChunks service '${serviceName}' has no configured schema allowlist` + ); + } + const dependencySchemas = service?.introspectionAllowedDependencySchemas; + if (dependencySchemas !== undefined && !Array.isArray(dependencySchemas)) { + throw new Error( + `[graphile-llm] @hasChunks service '${serviceName}' has an invalid dependency schema allowlist` + ); + } + return new Set([...schemas, ...(dependencySchemas ?? [])]); +} + +function requireField(codec: any, fieldName: string, label: string, table: string): any { + const field = codec?.attributes?.[fieldName]; + if (!field) { + throw new Error( + `[graphile-llm] @hasChunks ${label} '${fieldName}' does not exist on '${table}'` + ); + } + return field; +} + /** * Discover all chunk-aware tables from the pgRegistry. */ -function discoverChunkTables(build: any): ChunkTableInfo[] { +export function discoverChunkTables(build: any): ChunkTableInfo[] { const chunkTables: ChunkTableInfo[] = []; const pgRegistry = build.input?.pgRegistry ?? build.pgRegistry; if (!pgRegistry) return chunkTables; @@ -101,9 +153,67 @@ function discoverChunkTables(build: any): ChunkTableInfo[] { if (!tags?.hasChunks) continue; const info = parseHasChunksTag(tags.hasChunks, c); - if (info) { - chunkTables.push(info); + if (!info) { + throw new Error(`[graphile-llm] @hasChunks on '${c.name}' must be a valid JSON object`); + } + + const parent = requirePgIdentity(c, 'parent codec'); + if (!info.chunksSchema) { + throw new Error(`[graphile-llm] @hasChunks on '${parent.name}' has no chunks schema`); + } + const allowedSchemas = configuredSchemas(build, parent.serviceName); + if (allowedSchemas && !allowedSchemas.has(info.chunksSchema)) { + throw new Error( + `[graphile-llm] @hasChunks on '${parent.schemaName}.${parent.name}' references ` + + `schema '${info.chunksSchema}' outside service '${parent.serviceName}'` + ); + } + + const matches = Object.values(pgRegistry.pgResources ?? {}).filter((resource: any) => { + if (resource?.parameters || !resource?.codec?.attributes) return false; + const pg = resource.codec.extensions?.pg; + return pg?.serviceName === parent.serviceName && + pg?.schemaName === info.chunksSchema && + pg?.name === info.chunksTableName; + }) as any[]; + if (matches.length !== 1) { + throw new Error( + `[graphile-llm] @hasChunks on '${parent.schemaName}.${parent.name}' must resolve ` + + `exactly one '${info.chunksSchema}.${info.chunksTableName}' resource ` + + `(matches=${matches.length})` + ); } + + const chunksCodec = matches[0].codec; + const chunks = requirePgIdentity(chunksCodec, 'chunks codec'); + requireField(c, info.parentPkField, 'parentPk', `${parent.schemaName}.${parent.name}`); + requireField(chunksCodec, info.parentFkField, 'parentFk', `${chunks.schemaName}.${chunks.name}`); + requireField(chunksCodec, info.contentField, 'contentField', `${chunks.schemaName}.${chunks.name}`); + const embedding = requireField( + chunksCodec, + info.embeddingField, + 'embeddingField', + `${chunks.schemaName}.${chunks.name}` + ); + const vectorPg = embedding.codec?.extensions?.pg; + if ( + vectorPg?.name !== 'vector' || + vectorPg?.serviceName !== parent.serviceName || + !vectorPg?.schemaName + ) { + throw new Error( + `[graphile-llm] @hasChunks embedding '${chunks.schemaName}.${chunks.name}.` + + `${info.embeddingField}' is not bound to an exact vector type for service ` + + `'${parent.serviceName}'` + ); + } + + chunkTables.push({ + ...info, + chunksSchema: chunks.schemaName, + chunksTableName: chunks.name, + vectorSchema: vectorPg.schemaName, + }); } return chunkTables; @@ -112,37 +222,43 @@ function discoverChunkTables(build: any): ChunkTableInfo[] { /** * Build a SQL query string to search a chunks table for similar embeddings. */ -function buildChunkSearchSql( +export function buildChunkSearchSql( table: ChunkTableInfo, vectorString: string, limit: number, maxDistance: number | null ): { text: string; values: any[] } { - const schema = table.chunksSchema; - const qualifiedTable = schema - ? `"${schema}"."${table.chunksTableName}"` - : `"${table.chunksTableName}"`; - - const embeddingCol = `"${table.embeddingField}"`; - const contentCol = `"${table.contentField}"`; - const parentFkCol = `"${table.parentFkField}"`; + const qualifiedTable = QuoteUtils.quoteQualifiedIdentifier( + table.chunksSchema || null, + table.chunksTableName + ); + + const embeddingCol = QuoteUtils.quoteIdentifier(table.embeddingField); + const contentCol = QuoteUtils.quoteIdentifier(table.contentField); + const parentFkCol = QuoteUtils.quoteIdentifier(table.parentFkField); + if (!table.vectorSchema) { + throw new Error('[graphile-llm] RAG chunk table is missing an exact vector schema'); + } + const vectorType = QuoteUtils.quoteQualifiedIdentifier(table.vectorSchema, 'vector'); + const vectorDistanceOperator = `OPERATOR(${QuoteUtils.quoteIdentifier(table.vectorSchema)}.<=>)`; let text = ` SELECT ${contentCol} AS content, ${parentFkCol}::text AS parent_id, - (${embeddingCol} <=> $1::vector) AS distance + (${embeddingCol} ${vectorDistanceOperator} $1::${vectorType}) AS distance FROM ${qualifiedTable} `; const values: any[] = [vectorString]; if (maxDistance !== null) { - text += ` WHERE (${embeddingCol} <=> $1::vector) <= $2`; + text += ` WHERE (${embeddingCol} ${vectorDistanceOperator} $1::${vectorType}) <= $2`; values.push(maxDistance); } - text += ` ORDER BY ${embeddingCol} <=> $1::vector LIMIT $${values.length + 1}`; + text += ` ORDER BY ${embeddingCol} ${vectorDistanceOperator} $1::${vectorType} ` + + `LIMIT $${values.length + 1}`; values.push(limit); return { text, values }; @@ -174,7 +290,7 @@ export function createLlmRagPlugin( let embedder: EmbedderFunction | null = null; let chatCompleter: ChatFunction | null = null; - const schemaExtension = extendSchema((build) => { + const schemaExtension = extendSchema((_build) => { return { typeDefs: gql` """A source chunk retrieved during RAG context assembly.""" @@ -307,6 +423,12 @@ export function createLlmRagPlugin( }> = []; if (chunkTables.length > 0) { + if (typeof withPgClient !== 'function') { + throw new Error('RAG_PG_CLIENT_CONTEXT_UNAVAILABLE'); + } + if (typeof pgSettings !== 'object' || pgSettings === null) { + throw new Error('RAG_PG_SETTINGS_UNAVAILABLE'); + } await withPgClient(pgSettings, async (pgClient: any) => { for (const table of chunkTables) { const query = buildChunkSearchSql(table, vectorString, limit, maxDistance); diff --git a/graphile/graphile-llm/src/types.ts b/graphile/graphile-llm/src/types.ts index c60d8e6bfe..78441627fa 100644 --- a/graphile/graphile-llm/src/types.ts +++ b/graphile/graphile-llm/src/types.ts @@ -174,6 +174,8 @@ export interface ChunkTableInfo { parentCodecName: string; /** Schema of the chunks table (or null for public/default) */ chunksSchema: string | null; + /** Exact schema containing the pgvector `vector` type for this service */ + vectorSchema: string; /** Name of the chunks table */ chunksTableName: string; /** FK column on chunks table pointing to parent */ diff --git a/graphile/graphile-ltree/src/__tests__/schema-qualified-sql.test.ts b/graphile/graphile-ltree/src/__tests__/schema-qualified-sql.test.ts new file mode 100644 index 0000000000..e6677a52d7 --- /dev/null +++ b/graphile/graphile-ltree/src/__tests__/schema-qualified-sql.test.ts @@ -0,0 +1,176 @@ +import sql from 'pg-sql2'; + +import { createLtreeOperatorFactory } from '../plugins/connection-filter-operators'; +import { + resolveLtreeExtensionInfo, + type LtreeExtensionInfo, +} from '../plugins/detect-ltree'; +import { createFolderOperatorFactory } from '../plugins/folder-filter-operators'; +import { LtreeCodecPlugin } from '../plugins/ltree-codec'; + +const codec = (name: string, schemaName = 'extension_tools') => ({ + name, + extensions: { + pg: { + serviceName: 'tenant_service', + schemaName, + name, + }, + }, +}); + +const helperResource = ( + name: 'to_path' | 'to_query', + returnCodec: any, + schemaName = 'tenant_helpers' +) => ({ + name: `resource_${name}`, + parameters: [{ codec: codec('text', 'pg_catalog') }], + codec: returnCodec, + extensions: { + pg: { + serviceName: 'tenant_service', + schemaName, + name, + }, + }, +}); + +const registryBuild = (options: { + includeHelpers?: boolean; + ltreeCodec?: any; + lqueryCodec?: any; + resources?: Record; +} = {}) => { + const ltreeCodec = options.ltreeCodec ?? codec('ltree'); + const lqueryCodec = options.lqueryCodec ?? codec('lquery'); + const includeHelpers = options.includeHelpers ?? false; + return { + input: { + pgRegistry: { + pgCodecs: { ltree: ltreeCodec, lquery: lqueryCodec }, + pgResources: options.resources ?? (includeHelpers ? { + toPath: helperResource('to_path', ltreeCodec), + toQuery: helperResource('to_query', lqueryCodec), + } : {}), + }, + }, + }; +}; + +const resolveSql = ( + info: LtreeExtensionInfo, + factory: ReturnType, + operatorName: string, + input: string +) => { + const registration = factory({ pgLtreeExtensionInfo: info } as any) + .find((entry) => entry.operatorName === operatorName)!; + const fragment = registration.spec.resolve!( + sql.identifier('path'), + sql.null, + input, + null, + { fieldName: 'path', operatorName } + ); + return sql.compile(fragment!); +}; + +describe('ltree extension identity', () => { + it('qualifies and annotates a native codec from gather introspection', async () => { + const gatherHook = (LtreeCodecPlugin as any).gather.hooks.pgCodecs_findPgCodec; + const event: any = { + pgCodec: { + name: 'ltree', + sqlType: sql.fragment`ltree`, + extensions: undefined, + }, + pgType: { typname: 'ltree', typnamespace: '910', _id: '911' }, + serviceName: 'tenant_service', + }; + const originalCodec = event.pgCodec; + await gatherHook({ + helpers: { + pgIntrospection: { + getNamespace: jest.fn().mockResolvedValue({ nspname: 'extension_tools' }), + }, + }, + }, event); + + expect(event.pgCodec).toBe(originalCodec); + expect(event.pgCodec.extensions).toMatchObject({ + oid: '911', + pg: { + serviceName: 'tenant_service', + schemaName: 'extension_tools', + name: 'ltree', + }, + }); + expect(sql.compile(event.pgCodec.sqlType).text).toBe('"extension_tools"."ltree"'); + }); + + it('derives codec and actual helper schemas from one service/build', () => { + const info = resolveLtreeExtensionInfo(registryBuild({ includeHelpers: true })); + expect(info).toMatchObject({ + serviceName: 'tenant_service', + schemaName: 'extension_tools', + helperSchemaName: 'tenant_helpers', + }); + }); + + it('fails closed on missing codec identity and incomplete helpers', () => { + expect(() => resolveLtreeExtensionInfo(registryBuild({ + ltreeCodec: { name: 'ltree', extensions: { pg: { name: 'ltree' } } }, + }))).toThrow(/missing exact service\/schema metadata/); + + const ltreeCodec = codec('ltree'); + expect(() => resolveLtreeExtensionInfo(registryBuild({ + ltreeCodec, + resources: { + onlyPath: helperResource('to_path', ltreeCodec), + }, + }))).toThrow(/incomplete or ambiguous/); + }); + + it('fails closed when ltree and lquery identities disagree', () => { + expect(() => resolveLtreeExtensionInfo(registryBuild({ + lqueryCodec: codec('lquery', 'other_extension_schema'), + }))).toThrow(/does not match/); + }); +}); + +describe('ltree SQL qualification', () => { + it('qualifies helper functions and operators in the folder factory', () => { + const info = resolveLtreeExtensionInfo(registryBuild({ includeHelpers: true }))!; + const within = resolveSql(info, createFolderOperatorFactory(), 'within', '/a/b'); + const glob = resolveSql(info, createFolderOperatorFactory(), 'glob', '/a/*'); + + expect(within.text).toContain('OPERATOR("extension_tools".<@)'); + expect(within.text).toContain('"tenant_helpers"."to_path"($1)'); + expect(glob.text).toContain('OPERATOR("extension_tools".~)'); + expect(glob.text).toContain('"tenant_helpers"."to_query"($1)'); + }); + + it('qualifies inline casts when helper functions are absent', () => { + const info = resolveLtreeExtensionInfo(registryBuild())!; + const within = resolveSql(info, createFolderOperatorFactory(), 'within', '/a/b'); + const glob = resolveSql(info, createFolderOperatorFactory(), 'glob', '/a/*'); + + expect(within.text).toContain('::"extension_tools"."ltree"'); + expect(within.text).toContain('OPERATOR("extension_tools".<@)'); + expect(glob.text).toContain('::"extension_tools"."lquery"'); + expect(glob.text).toContain('OPERATOR("extension_tools".~)'); + }); + + it('qualifies the deprecated duplicate operator factory too', () => { + const info = resolveLtreeExtensionInfo(registryBuild())!; + const result = resolveSql( + info, + createLtreeOperatorFactory() as ReturnType, + 'isDescendantOf', + '/a/b' + ); + expect(result.text).toContain('OPERATOR("extension_tools".@>)'); + expect(result.text).toContain('::"extension_tools"."ltree"'); + }); +}); diff --git a/graphile/graphile-ltree/src/plugins/connection-filter-operators.ts b/graphile/graphile-ltree/src/plugins/connection-filter-operators.ts index aa2b6114dd..6fca79d787 100644 --- a/graphile/graphile-ltree/src/plugins/connection-filter-operators.ts +++ b/graphile/graphile-ltree/src/plugins/connection-filter-operators.ts @@ -10,35 +10,13 @@ import type { import type { SQL } from 'pg-sql2'; import sql from 'pg-sql2'; +import type { LtreeExtensionInfo } from './detect-ltree'; import { LTREE_SCALAR_NAME } from './ltree-codec'; - -function hasLtreeHelpers(build: any): boolean { - const pgRegistry = build.input?.pgRegistry; - if (!pgRegistry) return false; - for (const resource of Object.values(pgRegistry.pgResources)) { - const r = resource as any; - if (r?.extensions?.pg?.schemaName === 'ltree_helpers') return true; - } - return false; -} - -function toPathExpr(value: SQL, useHelpers: boolean): SQL { - if (useHelpers) { - return sql.fragment`ltree_helpers.to_path(${value})`; - } - return sql.fragment`replace(ltrim(${value}, '/'), '/', '.')::ltree`; -} - -function toQueryExpr(value: SQL, useHelpers: boolean): SQL { - if (useHelpers) { - return sql.fragment`ltree_helpers.to_query(${value})`; - } - // Glob → lquery conversion: - // ** → * (0+ labels in lquery) - // * → *{1} (exactly 1 label) - // We use a placeholder to avoid ** being affected by the * → *{1} step. - return sql.fragment`replace(replace(replace(replace(ltrim(${value}, '/'), '**', '__DSTAR__'), '*', '*{1}'), '__DSTAR__', '*'), '/', '.')::lquery`; -} +import { + ltreeOperatorExpression, + ltreePathExpression, + ltreeQueryExpression, +} from './qualified-sql'; /** * Creates the ltree connection filter operator factory. @@ -55,10 +33,10 @@ function toQueryExpr(value: SQL, useHelpers: boolean): SQL { */ export function createLtreeOperatorFactory(): ConnectionFilterOperatorFactory { return (build) => { - const ltreeInfo = (build as any).pgLtreeExtensionInfo; + const ltreeInfo: LtreeExtensionInfo | undefined = + (build as any).pgLtreeExtensionInfo; if (!ltreeInfo) return []; - const useHelpers = hasLtreeHelpers(build); const registrations: ConnectionFilterOperatorRegistration[] = []; registrations.push({ @@ -78,7 +56,12 @@ export function createLtreeOperatorFactory(): ConnectionFilterOperatorFactory { _details: { fieldName: string | null; operatorName: string } ) { const pathVal = sql.value(String(input)); - return sql.fragment`${sqlIdentifier} <@ ${toPathExpr(pathVal, useHelpers)}`; + return ltreeOperatorExpression( + '<@', + sqlIdentifier, + ltreePathExpression(pathVal, ltreeInfo), + ltreeInfo + ); } } satisfies ConnectionFilterOperatorSpec }); @@ -100,7 +83,12 @@ export function createLtreeOperatorFactory(): ConnectionFilterOperatorFactory { _details: { fieldName: string | null; operatorName: string } ) { const pathVal = sql.value(String(input)); - return sql.fragment`${sqlIdentifier} @> ${toPathExpr(pathVal, useHelpers)}`; + return ltreeOperatorExpression( + '@>', + sqlIdentifier, + ltreePathExpression(pathVal, ltreeInfo), + ltreeInfo + ); } } satisfies ConnectionFilterOperatorSpec }); @@ -122,7 +110,12 @@ export function createLtreeOperatorFactory(): ConnectionFilterOperatorFactory { _details: { fieldName: string | null; operatorName: string } ) { const globVal = sql.value(String(input)); - return sql.fragment`${sqlIdentifier} ~ ${toQueryExpr(globVal, useHelpers)}`; + return ltreeOperatorExpression( + '~', + sqlIdentifier, + ltreeQueryExpression(globVal, ltreeInfo), + ltreeInfo + ); } } satisfies ConnectionFilterOperatorSpec }); diff --git a/graphile/graphile-ltree/src/plugins/detect-ltree.ts b/graphile/graphile-ltree/src/plugins/detect-ltree.ts index 4dd689f473..dff817593f 100644 --- a/graphile/graphile-ltree/src/plugins/detect-ltree.ts +++ b/graphile/graphile-ltree/src/plugins/detect-ltree.ts @@ -5,8 +5,12 @@ import type { PgCodec } from '@dataplan/pg'; import type { GraphileConfig } from 'graphile-config'; export interface LtreeExtensionInfo { + serviceName: string; + schemaName: string; ltreeCodec: PgCodec; lqueryCodec: PgCodec | null; + /** Exact schema containing both validated helper functions, when present. */ + helperSchemaName: string | null; } function isLtreeCodec(codec: any): boolean { @@ -23,6 +27,117 @@ function isLqueryCodec(codec: any): boolean { ); } +function codecIdentity(codec: any, typeName: string): { + serviceName: string; + schemaName: string; +} { + const pg = codec?.extensions?.pg; + if (!pg?.serviceName || !pg?.schemaName) { + throw new Error( + `[graphile-ltree] ${typeName} codec is missing exact service/schema metadata` + ); + } + return { serviceName: pg.serviceName, schemaName: pg.schemaName }; +} + +function helperSchemaName( + pgRegistry: any, + serviceName: string +): string | null { + const matches: Record<'to_path' | 'to_query', any[]> = { + to_path: [], + to_query: [], + }; + + for (const resource of Object.values(pgRegistry.pgResources ?? {}) as any[]) { + if (!Array.isArray(resource?.parameters)) continue; + const pg = resource?.extensions?.pg; + const rawFunctionName = pg?.name ?? resource?.name; + if (rawFunctionName !== 'to_path' && rawFunctionName !== 'to_query') continue; + const functionName: 'to_path' | 'to_query' = rawFunctionName; + + const returnMatches = functionName === 'to_path' + ? isLtreeCodec(resource.codec) + : isLqueryCodec(resource.codec); + const parameter = resource.parameters[0]; + const parameterName = parameter?.codec?.extensions?.pg?.name ?? parameter?.codec?.name; + const signatureMatches = + returnMatches && + resource.parameters.length === 1 && + (parameterName === 'text' || parameterName === 'varchar' || parameterName === 'bpchar'); + if (!signatureMatches) continue; + + if (!pg?.serviceName || !pg?.schemaName) { + throw new Error( + `[graphile-ltree] ${functionName} helper is missing exact service/schema metadata` + ); + } + if (pg.serviceName !== serviceName) continue; + matches[functionName].push(resource); + } + + const pathMatches = matches.to_path; + const queryMatches = matches.to_query; + if (pathMatches.length === 0 && queryMatches.length === 0) return null; + if (pathMatches.length !== 1 || queryMatches.length !== 1) { + throw new Error( + `[graphile-ltree] Helper functions for service '${serviceName}' are incomplete ` + + `or ambiguous (to_path=${pathMatches.length}, to_query=${queryMatches.length})` + ); + } + + const pathSchema = pathMatches[0].extensions.pg.schemaName; + const querySchema = queryMatches[0].extensions.pg.schemaName; + if (pathSchema !== querySchema) { + throw new Error( + `[graphile-ltree] Helper functions for service '${serviceName}' resolve to ` + + `different schemas ('${pathSchema}', '${querySchema}')` + ); + } + return pathSchema; +} + +/** Resolve one unambiguous ltree identity from this exact build registry. */ +export function resolveLtreeExtensionInfo(build: any): LtreeExtensionInfo | undefined { + const pgRegistry = build.input?.pgRegistry; + if (!pgRegistry) return undefined; + + const ltreeCodecs = Object.values(pgRegistry.pgCodecs).filter(isLtreeCodec) as PgCodec[]; + const lqueryCodecs = Object.values(pgRegistry.pgCodecs).filter(isLqueryCodec) as PgCodec[]; + if (ltreeCodecs.length === 0) return undefined; + if (ltreeCodecs.length !== 1) { + throw new Error( + `[graphile-ltree] Expected one ltree codec per build, found ${ltreeCodecs.length}` + ); + } + + const ltreeCodec = ltreeCodecs[0]; + const identity = codecIdentity(ltreeCodec, 'ltree'); + const matchingLquery = lqueryCodecs.filter((codec) => { + const candidate = codecIdentity(codec, 'lquery'); + return candidate.serviceName === identity.serviceName && + candidate.schemaName === identity.schemaName; + }); + if (lqueryCodecs.length > 0 && matchingLquery.length !== lqueryCodecs.length) { + throw new Error( + '[graphile-ltree] lquery codec service/schema does not match the ltree codec' + ); + } + if (matchingLquery.length > 1) { + throw new Error( + `[graphile-ltree] Expected at most one matching lquery codec, found ` + + `${matchingLquery.length}` + ); + } + + return { + ...identity, + ltreeCodec, + lqueryCodec: matchingLquery[0] ?? null, + helperSchemaName: helperSchemaName(pgRegistry, identity.serviceName), + }; +} + /** * LtreeExtensionDetectionPlugin * @@ -40,30 +155,8 @@ export const LtreeExtensionDetectionPlugin: GraphileConfig.Plugin = { schema: { hooks: { build(build) { - const pgRegistry = build.input?.pgRegistry; - if (!pgRegistry) { - return build; - } - - let ltreeCodec: PgCodec | null = null; - let lqueryCodec: PgCodec | null = null; - - for (const codec of Object.values(pgRegistry.pgCodecs)) { - if (isLtreeCodec(codec)) { - ltreeCodec = codec; - } else if (isLqueryCodec(codec)) { - lqueryCodec = codec; - } - } - - if (!ltreeCodec) { - return build; - } - - const ltreeInfo: LtreeExtensionInfo = { - ltreeCodec, - lqueryCodec - }; + const ltreeInfo = resolveLtreeExtensionInfo(build); + if (!ltreeInfo) return build; return build.extend( build, diff --git a/graphile/graphile-ltree/src/plugins/folder-filter-operators.ts b/graphile/graphile-ltree/src/plugins/folder-filter-operators.ts index 88f39c3c83..e58bce645e 100644 --- a/graphile/graphile-ltree/src/plugins/folder-filter-operators.ts +++ b/graphile/graphile-ltree/src/plugins/folder-filter-operators.ts @@ -10,31 +10,13 @@ import type { import type { SQL } from 'pg-sql2'; import sql from 'pg-sql2'; +import type { LtreeExtensionInfo } from './detect-ltree'; import { LTREE_SCALAR_NAME } from './ltree-codec'; - -function hasLtreeHelpers(build: any): boolean { - const pgRegistry = build.input?.pgRegistry; - if (!pgRegistry) return false; - for (const resource of Object.values(pgRegistry.pgResources)) { - const r = resource as any; - if (r?.extensions?.pg?.schemaName === 'ltree_helpers') return true; - } - return false; -} - -function slashToLtree(value: SQL, useHelpers: boolean): SQL { - if (useHelpers) { - return sql.fragment`ltree_helpers.to_path(${value})`; - } - return sql.fragment`replace(ltrim(${value}, '/'), '/', '.')::ltree`; -} - -function slashGlobToLquery(value: SQL, useHelpers: boolean): SQL { - if (useHelpers) { - return sql.fragment`ltree_helpers.to_query(${value})`; - } - return sql.fragment`replace(replace(replace(replace(ltrim(${value}, '/'), '**', '__DSTAR__'), '*', '*{1}'), '__DSTAR__', '*'), '/', '.')::lquery`; -} +import { + ltreeOperatorExpression, + ltreePathExpression, + ltreeQueryExpression, +} from './qualified-sql'; /** * Creates folder-oriented connection filter operators for the LTree scalar. @@ -56,10 +38,10 @@ function slashGlobToLquery(value: SQL, useHelpers: boolean): SQL { */ export function createFolderOperatorFactory(): ConnectionFilterOperatorFactory { return (build) => { - const ltreeInfo = (build as any).pgLtreeExtensionInfo; + const ltreeInfo: LtreeExtensionInfo | undefined = + (build as any).pgLtreeExtensionInfo; if (!ltreeInfo) return []; - const useHelpers = hasLtreeHelpers(build); const registrations: ConnectionFilterOperatorRegistration[] = []; registrations.push({ @@ -79,7 +61,12 @@ export function createFolderOperatorFactory(): ConnectionFilterOperatorFactory { _details: { fieldName: string | null; operatorName: string } ) { const pathVal = sql.value(String(input)); - return sql.fragment`${sqlIdentifier} <@ ${slashToLtree(pathVal, useHelpers)}`; + return ltreeOperatorExpression( + '<@', + sqlIdentifier, + ltreePathExpression(pathVal, ltreeInfo), + ltreeInfo + ); } } satisfies ConnectionFilterOperatorSpec }); @@ -101,7 +88,12 @@ export function createFolderOperatorFactory(): ConnectionFilterOperatorFactory { _details: { fieldName: string | null; operatorName: string } ) { const pathVal = sql.value(String(input)); - return sql.fragment`${sqlIdentifier} @> ${slashToLtree(pathVal, useHelpers)}`; + return ltreeOperatorExpression( + '@>', + sqlIdentifier, + ltreePathExpression(pathVal, ltreeInfo), + ltreeInfo + ); } } satisfies ConnectionFilterOperatorSpec }); @@ -124,7 +116,12 @@ export function createFolderOperatorFactory(): ConnectionFilterOperatorFactory { _details: { fieldName: string | null; operatorName: string } ) { const globVal = sql.value(String(input)); - return sql.fragment`${sqlIdentifier} ~ ${slashGlobToLquery(globVal, useHelpers)}`; + return ltreeOperatorExpression( + '~', + sqlIdentifier, + ltreeQueryExpression(globVal, ltreeInfo), + ltreeInfo + ); } } satisfies ConnectionFilterOperatorSpec }); diff --git a/graphile/graphile-ltree/src/plugins/ltree-codec.ts b/graphile/graphile-ltree/src/plugins/ltree-codec.ts index db39dcd0a9..ce800bc3ac 100644 --- a/graphile/graphile-ltree/src/plugins/ltree-codec.ts +++ b/graphile/graphile-ltree/src/plugins/ltree-codec.ts @@ -56,8 +56,6 @@ export const LtreeCodecPlugin: GraphileConfig.Plugin = { gather: { hooks: { async pgCodecs_findPgCodec(info, event) { - if (event.pgCodec) return; - const { pgType: type, serviceName } = event; const isLtree = type.typname === 'ltree'; @@ -70,7 +68,39 @@ export const LtreeCodecPlugin: GraphileConfig.Plugin = { serviceName, type.typnamespace ); - const schemaName = ns?.nspname || 'pg_catalog'; + if (!ns?.nspname) { + throw new Error( + `[graphile-ltree] Cannot resolve namespace for ${type.typname} ` + + `codec in service '${serviceName}'` + ); + } + const schemaName = ns.nspname; + + if (event.pgCodec) { + const existingPg = event.pgCodec.extensions?.pg; + if ( + (existingPg?.serviceName && existingPg.serviceName !== serviceName) || + (existingPg?.schemaName && existingPg.schemaName !== schemaName) + ) { + throw new Error( + `[graphile-ltree] Existing ${type.typname} codec identity conflicts with ` + + `introspection for service '${serviceName}'` + ); + } + const existingCodec = event.pgCodec as any; + existingCodec.sqlType = sql.identifier(schemaName, type.typname); + existingCodec.extensions = { + ...existingCodec.extensions, + oid: type._id, + pg: { + ...existingPg, + serviceName, + schemaName, + name: type.typname, + }, + }; + return; + } event.pgCodec = { name: type.typname, diff --git a/graphile/graphile-ltree/src/plugins/qualified-sql.ts b/graphile/graphile-ltree/src/plugins/qualified-sql.ts new file mode 100644 index 0000000000..1c1b584d0d --- /dev/null +++ b/graphile/graphile-ltree/src/plugins/qualified-sql.ts @@ -0,0 +1,39 @@ +import type { SQL } from 'pg-sql2'; +import sql from 'pg-sql2'; + +import type { LtreeExtensionInfo } from './detect-ltree'; + +export function ltreePathExpression(value: SQL, info: LtreeExtensionInfo): SQL { + if (info.helperSchemaName) { + const toPath = sql.identifier(info.helperSchemaName, 'to_path'); + return sql.fragment`${toPath}(${value})`; + } + const ltreeType = sql.identifier(info.schemaName, 'ltree'); + return sql.fragment`replace(ltrim(${value}, '/'), '/', '.')::${ltreeType}`; +} + +export function ltreeQueryExpression(value: SQL, info: LtreeExtensionInfo): SQL { + if (info.helperSchemaName) { + const toQuery = sql.identifier(info.helperSchemaName, 'to_query'); + return sql.fragment`${toQuery}(${value})`; + } + const lqueryType = sql.identifier(info.schemaName, 'lquery'); + return sql.fragment`replace(replace(replace(replace(ltrim(${value}, '/'), '**', '__DSTAR__'), '*', '*{1}'), '__DSTAR__', '*'), '/', '.')::${lqueryType}`; +} + +export function ltreeOperatorExpression( + operator: '<@' | '@>' | '~', + left: SQL, + right: SQL, + info: LtreeExtensionInfo +): SQL { + const schema = sql.identifier(info.schemaName); + switch (operator) { + case '<@': + return sql.fragment`${left} OPERATOR(${schema}.<@) ${right}`; + case '@>': + return sql.fragment`${left} OPERATOR(${schema}.@>) ${right}`; + case '~': + return sql.fragment`${left} OPERATOR(${schema}.~) ${right}`; + } +} diff --git a/graphile/graphile-postgis/__tests__/codec.test.ts b/graphile/graphile-postgis/__tests__/codec.test.ts index 4f1e19e145..bd94c15c93 100644 --- a/graphile/graphile-postgis/__tests__/codec.test.ts +++ b/graphile/graphile-postgis/__tests__/codec.test.ts @@ -1,4 +1,5 @@ import type { PgCodec } from '@dataplan/pg'; +import sql from 'pg-sql2'; import { GisSubtype } from '../src/constants'; import { PostgisCodecPlugin } from '../src/plugins/codec'; @@ -27,16 +28,33 @@ describe('PostgisCodecPlugin', () => { const gatherHook = (PostgisCodecPlugin as { gather: { hooks: { pgCodecs_findPgCodec: Function } } }) .gather.hooks.pgCodecs_findPgCodec; - it('should skip if pgCodec is already set', async () => { - const info = { helpers: { pgIntrospection: { getNamespace: jest.fn() } } }; - const event = { pgCodec: { name: 'existing' }, pgType: { typname: 'geometry' }, serviceName: 'main' }; + it('should bind exact identity when a native pgCodec is already set', async () => { + const info = { + helpers: { + pgIntrospection: { + getNamespace: jest.fn().mockResolvedValue({ _id: '123', nspname: 'postgis_ext' }) + } + } + }; + const event = { + pgCodec: { name: 'geometry' } as PgCodec, + pgType: { typname: 'geometry', typnamespace: '123', _id: '456' }, + serviceName: 'main' + }; + const originalCodec = event.pgCodec; await gatherHook(info, event); - // Should not have called getNamespace since pgCodec was already set - expect(info.helpers.pgIntrospection.getNamespace).not.toHaveBeenCalled(); + expect(event.pgCodec).toBe(originalCodec); + expect(info.helpers.pgIntrospection.getNamespace).toHaveBeenCalledWith('main', '123'); + expect(event.pgCodec.extensions?.pg).toEqual({ + serviceName: 'main', + schemaName: 'postgis_ext', + name: 'geometry' + }); + expect(sql.compile(event.pgCodec.sqlType!).text).toBe('"postgis_ext"."geometry"'); }); - it('should skip if namespace is not found', async () => { + it('should fail closed if namespace is not found', async () => { const info = { helpers: { pgIntrospection: { getNamespace: jest.fn().mockResolvedValue(null) } } }; @@ -46,8 +64,9 @@ describe('PostgisCodecPlugin', () => { serviceName: 'main' }; - await gatherHook(info, event); - expect(event.pgCodec).toBeNull(); + await expect(gatherHook(info, event)).rejects.toThrow( + /Cannot resolve namespace for geometry codec/ + ); }); it('should create geometry codec when type is geometry', async () => { diff --git a/graphile/graphile-postgis/__tests__/connection-filter-operators.test.ts b/graphile/graphile-postgis/__tests__/connection-filter-operators.test.ts index 0fe7ed1388..ccb1aba3be 100644 --- a/graphile/graphile-postgis/__tests__/connection-filter-operators.test.ts +++ b/graphile/graphile-postgis/__tests__/connection-filter-operators.test.ts @@ -343,31 +343,31 @@ describe('PostGIS operator factory (createPostgisOperatorFactory)', () => { it('generates correct SQL for = operator', () => { expect(runOp('exactlyEquals').text).toBe( - '"col" = "public"."st_geomfromgeojson"($1::text)' + '"col" OPERATOR("public".=) "public"."st_geomfromgeojson"($1::text)' ); }); it('generates correct SQL for && operator', () => { expect(runOp('bboxIntersects2D').text).toBe( - '"col" && "public"."st_geomfromgeojson"($1::text)' + '"col" OPERATOR("public".&&) "public"."st_geomfromgeojson"($1::text)' ); }); it('generates correct SQL for ~ operator', () => { expect(runOp('bboxContains').text).toBe( - '"col" ~ "public"."st_geomfromgeojson"($1::text)' + '"col" OPERATOR("public".~) "public"."st_geomfromgeojson"($1::text)' ); }); it('generates correct SQL for ~= operator', () => { expect(runOp('bboxEquals').text).toBe( - '"col" ~= "public"."st_geomfromgeojson"($1::text)' + '"col" OPERATOR("public".~=) "public"."st_geomfromgeojson"($1::text)' ); }); it('generates correct SQL for &&& operator', () => { expect(runOp('bboxIntersectsND').text).toBe( - '"col" &&& "public"."st_geomfromgeojson"($1::text)' + '"col" OPERATOR("public".&&&) "public"."st_geomfromgeojson"($1::text)' ); }); }); diff --git a/graphile/graphile-postgis/__tests__/detect-extension.test.ts b/graphile/graphile-postgis/__tests__/detect-extension.test.ts index cd29d9b90c..9416012dbd 100644 --- a/graphile/graphile-postgis/__tests__/detect-extension.test.ts +++ b/graphile/graphile-postgis/__tests__/detect-extension.test.ts @@ -40,7 +40,7 @@ describe('PostgisExtensionDetectionPlugin', () => { it('should detect PostGIS with only geometry codec (no geography)', () => { const geometryCodec = { name: 'geometry', - extensions: { pg: { name: 'geometry', schemaName: 'public' } } + extensions: { pg: { name: 'geometry', schemaName: 'public', serviceName: 'main' } } }; const build = { input: { @@ -57,17 +57,18 @@ describe('PostgisExtensionDetectionPlugin', () => { expect(result.pgGISExtensionInfo).toBeDefined(); expect(result.pgGISExtensionInfo.geometryCodec).toBe(geometryCodec); expect(result.pgGISExtensionInfo.geographyCodec).toBeNull(); + expect(result.pgGISExtensionInfo.serviceName).toBe('main'); expect(result.pgGISExtensionInfo.schemaName).toBe('public'); }); it('should detect PostGIS when both geometry and geography codecs exist', () => { const geometryCodec = { name: 'geometry', - extensions: { pg: { name: 'geometry', schemaName: 'public' } } + extensions: { pg: { name: 'geometry', schemaName: 'public', serviceName: 'main' } } }; const geographyCodec = { name: 'geography', - extensions: { pg: { name: 'geography', schemaName: 'public' } } + extensions: { pg: { name: 'geography', schemaName: 'public', serviceName: 'main' } } }; const build = { @@ -90,11 +91,11 @@ describe('PostgisExtensionDetectionPlugin', () => { it('should detect custom schema for PostGIS installation', () => { const geometryCodec = { name: 'geometry', - extensions: { pg: { name: 'geometry', schemaName: 'postgis' } } + extensions: { pg: { name: 'geometry', schemaName: 'postgis', serviceName: 'main' } } }; const geographyCodec = { name: 'geography', - extensions: { pg: { name: 'geography', schemaName: 'postgis' } } + extensions: { pg: { name: 'geography', schemaName: 'postgis', serviceName: 'main' } } }; const build = { @@ -113,11 +114,11 @@ describe('PostgisExtensionDetectionPlugin', () => { it('should skip codecs without pg extensions', () => { const geometryCodec = { name: 'geometry', - extensions: { pg: { name: 'geometry', schemaName: 'public' } } + extensions: { pg: { name: 'geometry', schemaName: 'public', serviceName: 'main' } } }; const geographyCodec = { name: 'geography', - extensions: { pg: { name: 'geography', schemaName: 'public' } } + extensions: { pg: { name: 'geography', schemaName: 'public', serviceName: 'main' } } }; const otherCodec = { name: 'custom', @@ -140,5 +141,44 @@ describe('PostgisExtensionDetectionPlugin', () => { const result = buildHook(build); expect(result.pgGISExtensionInfo).toBeDefined(); }); + + it('fails closed when codec identity is missing or inconsistent', () => { + const extend = (base: any, ext: any) => ({ ...base, ...ext }); + expect(() => buildHook({ + input: { + pgRegistry: { + pgCodecs: { + geometry: { + name: 'geometry', + extensions: { pg: { name: 'geometry', schemaName: 'postgis' } } + } + } + } + }, + extend + })).toThrow(/missing exact service\/schema metadata/); + + expect(() => buildHook({ + input: { + pgRegistry: { + pgCodecs: { + geometry: { + name: 'geometry', + extensions: { + pg: { name: 'geometry', schemaName: 'postgis_a', serviceName: 'main' } + } + }, + geography: { + name: 'geography', + extensions: { + pg: { name: 'geography', schemaName: 'postgis_b', serviceName: 'main' } + } + } + } + } + }, + extend + })).toThrow(/different service\/schema identities/); + }); }); }); diff --git a/graphile/graphile-postgis/__tests__/spatial-relations.test.ts b/graphile/graphile-postgis/__tests__/spatial-relations.test.ts index 8e29ba7d7b..441337e28f 100644 --- a/graphile/graphile-postgis/__tests__/spatial-relations.test.ts +++ b/graphile/graphile-postgis/__tests__/spatial-relations.test.ts @@ -1,6 +1,7 @@ import sql from 'pg-sql2'; import { + buildSpatialJoinFragment, collectSpatialRelations, OPERATOR_REGISTRY, parseSpatialRelationTag, @@ -130,6 +131,23 @@ describe('OPERATOR_REGISTRY', () => { } } }); + + it('schema-qualifies the PostGIS infix operator in relation SQL', () => { + const fragment = buildSpatialJoinFragment( + { + ownerAttributeName: 'location', + targetAttributeName: 'geom', + operator: OPERATOR_REGISTRY.st_bbox_intersects, + } as any, + 'postgis_ext', + sql.identifier('owner'), + sql.identifier('target'), + null + ); + expect(sql.compile(fragment).text).toBe( + '"owner"."location" OPERATOR("postgis_ext".&&) "target"."geom"' + ); + }); }); // --------------------------------------------------------------------------- diff --git a/graphile/graphile-postgis/src/plugins/codec.ts b/graphile/graphile-postgis/src/plugins/codec.ts index 59dfde1679..cb9b566b87 100644 --- a/graphile/graphile-postgis/src/plugins/codec.ts +++ b/graphile/graphile-postgis/src/plugins/codec.ts @@ -199,12 +199,12 @@ export const PostgisCodecPlugin: GraphileConfig.Plugin = { gather: { hooks: { async pgCodecs_findPgCodec(info, event) { - if (event.pgCodec) { + const { pgType: type, serviceName } = event; + + if (type.typname !== 'geometry' && type.typname !== 'geography') { return; } - const { pgType: type, serviceName } = event; - // Find the namespace for this type by its OID const typeNamespace = await info.helpers.pgIntrospection.getNamespace( serviceName, @@ -212,6 +212,35 @@ export const PostgisCodecPlugin: GraphileConfig.Plugin = { ); if (!typeNamespace) { + throw new Error( + `[graphile-postgis] Cannot resolve namespace for ${type.typname} ` + + `codec in service '${serviceName}'` + ); + } + + if (event.pgCodec) { + const existingPg = event.pgCodec.extensions?.pg; + if ( + (existingPg?.serviceName && existingPg.serviceName !== serviceName) || + (existingPg?.schemaName && existingPg.schemaName !== typeNamespace.nspname) + ) { + throw new Error( + `[graphile-postgis] Existing ${type.typname} codec identity conflicts ` + + `with introspection for service '${serviceName}'` + ); + } + const existingCodec = event.pgCodec as any; + existingCodec.sqlType = sql.identifier(typeNamespace.nspname, type.typname); + existingCodec.extensions = { + ...existingCodec.extensions, + oid: type._id, + pg: { + ...existingPg, + serviceName, + schemaName: typeNamespace.nspname, + name: type.typname, + }, + }; return; } diff --git a/graphile/graphile-postgis/src/plugins/connection-filter-operators.ts b/graphile/graphile-postgis/src/plugins/connection-filter-operators.ts index 5431c81fc3..d94f18f680 100644 --- a/graphile/graphile-postgis/src/plugins/connection-filter-operators.ts +++ b/graphile/graphile-postgis/src/plugins/connection-filter-operators.ts @@ -18,21 +18,22 @@ import type { PostgisExtensionInfo } from './detect-extension'; * Builds an infix operator SQL fragment from a validated operator string. * Uses explicit template literals for each operator to avoid sql.raw. */ -function buildOperatorExpr(op: string, i: SQL, v: SQL): SQL { +function buildOperatorExpr(schemaName: string, op: string, i: SQL, v: SQL): SQL { + const schema = sql.identifier(schemaName); switch (op) { - case '=': return sql.fragment`${i} = ${v}`; - case '&&': return sql.fragment`${i} && ${v}`; - case '&&&': return sql.fragment`${i} &&& ${v}`; - case '&<': return sql.fragment`${i} &< ${v}`; - case '&<|': return sql.fragment`${i} &<| ${v}`; - case '&>': return sql.fragment`${i} &> ${v}`; - case '|&>': return sql.fragment`${i} |&> ${v}`; - case '<<': return sql.fragment`${i} << ${v}`; - case '<<|': return sql.fragment`${i} <<| ${v}`; - case '>>': return sql.fragment`${i} >> ${v}`; - case '|>>': return sql.fragment`${i} |>> ${v}`; - case '~': return sql.fragment`${i} ~ ${v}`; - case '~=': return sql.fragment`${i} ~= ${v}`; + case '=': return sql.fragment`${i} OPERATOR(${schema}.=) ${v}`; + case '&&': return sql.fragment`${i} OPERATOR(${schema}.&&) ${v}`; + case '&&&': return sql.fragment`${i} OPERATOR(${schema}.&&&) ${v}`; + case '&<': return sql.fragment`${i} OPERATOR(${schema}.&<) ${v}`; + case '&<|': return sql.fragment`${i} OPERATOR(${schema}.&<|) ${v}`; + case '&>': return sql.fragment`${i} OPERATOR(${schema}.&>) ${v}`; + case '|&>': return sql.fragment`${i} OPERATOR(${schema}.|&>) ${v}`; + case '<<': return sql.fragment`${i} OPERATOR(${schema}.<<) ${v}`; + case '<<|': return sql.fragment`${i} OPERATOR(${schema}.<<|) ${v}`; + case '>>': return sql.fragment`${i} OPERATOR(${schema}.>>) ${v}`; + case '|>>': return sql.fragment`${i} OPERATOR(${schema}.|>>) ${v}`; + case '~': return sql.fragment`${i} OPERATOR(${schema}.~) ${v}`; + case '~=': return sql.fragment`${i} OPERATOR(${schema}.~=) ${v}`; default: throw new Error(`Unexpected PostGIS SQL operator: ${op}`); } @@ -289,7 +290,8 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory operatorName, description, baseType: baseType as 'geometry' | 'geography', - resolve: (i: SQL, v: SQL) => buildOperatorExpr(capturedOp, i, v) + resolve: (i: SQL, v: SQL) => + buildOperatorExpr(schemaName, capturedOp, i, v) }); } } diff --git a/graphile/graphile-postgis/src/plugins/detect-extension.ts b/graphile/graphile-postgis/src/plugins/detect-extension.ts index b91f0ea182..85dba50051 100644 --- a/graphile/graphile-postgis/src/plugins/detect-extension.ts +++ b/graphile/graphile-postgis/src/plugins/detect-extension.ts @@ -8,6 +8,62 @@ import type { PostgisExtensionInfo } from '../types'; export type { PostgisExtensionInfo } from '../types'; +function codecIdentity(codec: any, typeName: string): { + serviceName: string; + schemaName: string; +} { + const pg = codec?.extensions?.pg; + if (!pg?.serviceName || !pg?.schemaName) { + throw new Error( + `[graphile-postgis] ${typeName} codec is missing exact service/schema metadata` + ); + } + return { serviceName: pg.serviceName, schemaName: pg.schemaName }; +} + +/** Resolve one unambiguous PostGIS installation identity for this build. */ +export function resolvePostgisExtensionInfo(build: any): PostgisExtensionInfo | undefined { + const pgRegistry = build.input?.pgRegistry; + if (!pgRegistry) return undefined; + + const geometryCodecs: PgCodec[] = []; + const geographyCodecs: PgCodec[] = []; + for (const codec of Object.values(pgRegistry.pgCodecs) as PgCodec[]) { + const name = codec?.extensions?.pg?.name; + if (name === 'geometry') geometryCodecs.push(codec); + if (name === 'geography') geographyCodecs.push(codec); + } + if (geometryCodecs.length === 0 && geographyCodecs.length === 0) return undefined; + if (geometryCodecs.length > 1 || geographyCodecs.length > 1) { + throw new Error( + `[graphile-postgis] Ambiguous codecs in one build ` + + `(geometry=${geometryCodecs.length}, geography=${geographyCodecs.length})` + ); + } + + const geometryCodec = geometryCodecs[0] ?? null; + const geographyCodec = geographyCodecs[0] ?? null; + const primary = geometryCodec ?? geographyCodec!; + const identity = codecIdentity(primary, geometryCodec ? 'geometry' : 'geography'); + if (geometryCodec && geographyCodec) { + const geographyIdentity = codecIdentity(geographyCodec, 'geography'); + if ( + geographyIdentity.serviceName !== identity.serviceName || + geographyIdentity.schemaName !== identity.schemaName + ) { + throw new Error( + '[graphile-postgis] geometry/geography codecs resolve to different service/schema identities' + ); + } + } + + return { + ...identity, + geometryCodec, + geographyCodec, + }; +} + /** * PostgisExtensionDetectionPlugin * @@ -25,44 +81,8 @@ export const PostgisExtensionDetectionPlugin: GraphileConfig.Plugin = { schema: { hooks: { build(build) { - const pgRegistry = build.input?.pgRegistry; - if (!pgRegistry) { - return build; - } - - let geometryCodec: PgCodec | null = null; - let geographyCodec: PgCodec | null = null; - let schemaName: string = 'public'; - - // Search through codecs for geometry and geography types - for (const codec of Object.values(pgRegistry.pgCodecs)) { - const pg = codec?.extensions?.pg; - if (!pg) continue; - - if (pg.name === 'geometry') { - geometryCodec = codec; - schemaName = pg.schemaName || 'public'; - } else if (pg.name === 'geography') { - geographyCodec = codec; - if (!geometryCodec) { - schemaName = pg.schemaName || 'public'; - } - } - } - - // PostGIS is detected when at least one of geometry or geography - // codecs is present. Some databases use only geography columns - // (e.g. use_geography: true in SearchSpatial), so PostGraphile may - // introspect geography but not geometry. - if (!geometryCodec && !geographyCodec) { - return build; - } - - const postgisInfo: PostgisExtensionInfo = { - schemaName, - geometryCodec, - geographyCodec - }; + const postgisInfo = resolvePostgisExtensionInfo(build); + if (!postgisInfo) return build; return build.extend(build, { pgGISExtensionInfo: postgisInfo, diff --git a/graphile/graphile-postgis/src/plugins/spatial-relations.ts b/graphile/graphile-postgis/src/plugins/spatial-relations.ts index 20adc5132c..68ecb1ba8e 100644 --- a/graphile/graphile-postgis/src/plugins/spatial-relations.ts +++ b/graphile/graphile-postgis/src/plugins/spatial-relations.ts @@ -451,7 +451,7 @@ function spatialFilterTypeName(build: any, rel: SpatialRelationInfo): string { * Build the SQL fragment that joins the inner (target) row to the outer * (owner) row using the resolved PostGIS predicate. */ -function buildSpatialJoinFragment( +export function buildSpatialJoinFragment( rel: SpatialRelationInfo, schemaName: string, outerAlias: SQL, @@ -467,8 +467,8 @@ function buildSpatialJoinFragment( const ownerExpr = sql`${outerAlias}.${sql.identifier(rel.ownerAttributeName)}`; const targetExpr = sql`${innerAlias}.${sql.identifier(rel.targetAttributeName)}`; if (rel.operator.kind === 'infix') { - // Only `&&` today — simple inline (symmetric). - return sql`${ownerExpr} && ${targetExpr}`; + // Only `&&` today. Bind it to this build's exact PostGIS namespace. + return sql`${ownerExpr} OPERATOR(${sql.identifier(schemaName)}.&&) ${targetExpr}`; } const fn = sql.identifier(schemaName, rel.operator.pgToken); if (rel.operator.parametric) { diff --git a/graphile/graphile-postgis/src/types.ts b/graphile/graphile-postgis/src/types.ts index 60defc8ea8..aa56511f48 100644 --- a/graphile/graphile-postgis/src/types.ts +++ b/graphile/graphile-postgis/src/types.ts @@ -22,6 +22,8 @@ export interface GisFieldValue { * PostGIS extension detection result stored on the build object. */ export interface PostgisExtensionInfo { + /** Exact Graphile PostgreSQL service that owns these codecs. */ + serviceName: string; /** The schema name where PostGIS is installed (e.g. 'public') */ schemaName: string; /** The geometry codec from the registry (null if only geography columns are used) */ diff --git a/graphile/graphile-presigned-url-plugin/__tests__/cache-isolation.test.ts b/graphile/graphile-presigned-url-plugin/__tests__/cache-isolation.test.ts new file mode 100644 index 0000000000..46969196ca --- /dev/null +++ b/graphile/graphile-presigned-url-plugin/__tests__/cache-isolation.test.ts @@ -0,0 +1,761 @@ +import { + getBucketConfig, + getStorageModuleCacheScope, + getStorageModuleConfig, + getStorageModuleConfigForOwner, + isS3BucketProvisioned, + loadAllStorageModules, + markS3BucketProvisioned, + resolveStorageConfigFromCodec, +} from '../src/storage-module-cache'; +import { resolveDownloadStorageTarget } from '../src/download-url-field'; +import { mintPhysicalBucketName, resolveS3Config } from '../src/s3-config'; +import { + loadStorageModulesForBuild, + snapshotPreloadedStorageModules, + type StorageWithPgClient, +} from '../src/storage-module-source'; +import type { WithPgClient } from '../src/request-pg-client'; +import type { + PresignedUrlPluginOptions, + StorageModuleConfig, +} from '../src/types'; + +const DATABASE_ID = '00000000-0000-0000-0000-000000000001'; +const MODULE_ID = '00000000-0000-0000-0000-000000000002'; +const BUCKET_ID = '00000000-0000-0000-0000-000000000003'; +type QueryOptions = { text: string; values?: unknown[] }; + +/** + * Model Graphile's real request-client contract: acquire without settings, + * open one explicit transaction, then apply every request GUC with set_config + * before tenant-bound SQL is allowed to run. + */ +function requestPgHarness( + pgSettings: Record, + requestQuery: (query: QueryOptions) => Promise<{ rows: any[] }>, +) { + let transactionActive = false; + let appliedSettings: Record | null = null; + const pgClient: any = {}; + + const query = jest.fn(async (queryOptions: QueryOptions) => { + if (queryOptions.text.includes('SELECT set_config')) { + expect(transactionActive).toBe(true); + const entries = JSON.parse(String(queryOptions.values?.[0])) as Array<[string, string]>; + appliedSettings = Object.fromEntries(entries); + expect(appliedSettings).toEqual(pgSettings); + return { rows: [] }; + } + + expect(transactionActive).toBe(true); + expect(appliedSettings).toEqual(pgSettings); + return requestQuery(queryOptions); + }); + const withTransaction = jest.fn(async (callback: (tx: any) => Promise) => { + expect(transactionActive).toBe(false); + transactionActive = true; + try { + return await callback(pgClient); + } finally { + appliedSettings = null; + transactionActive = false; + } + }); + pgClient.query = query; + pgClient.withTransaction = withTransaction; + + const withPgClientMock = jest.fn(async ( + settings: Record | null, + callback: (client: any) => Promise, + ) => { + expect(settings).toBeNull(); + return callback(pgClient); + }); + + return { + withPgClient: withPgClientMock as unknown as WithPgClient & StorageWithPgClient, + withPgClientMock, + withTransaction, + query, + }; +} + +function storageRow(publicUrlPrefix: string, maxFileSize = 1024): Record { + return { + id: MODULE_ID, + database_id: DATABASE_ID, + scope: 'app', + entity_table_id: null, + buckets_database_id: DATABASE_ID, + buckets_schema: 'storage_public', + buckets_schema_database_id: DATABASE_ID, + buckets_table: 'app_buckets', + files_database_id: DATABASE_ID, + files_schema: 'storage_public', + files_schema_database_id: DATABASE_ID, + files_table: 'app_files', + endpoint: null, + public_url_prefix: publicUrlPrefix, + provider: 's3', + allowed_origins: null, + upload_url_expiry_seconds: 900, + download_url_expiry_seconds: 3600, + default_max_file_size: maxFileSize, + max_filename_length: 1024, + cache_ttl_seconds: 3600, + max_bulk_files: 100, + max_bulk_total_size: 1073741824, + has_path_shares: false, + entity_database_id: null, + entity_schema_database_id: null, + entity_schema: null, + entity_table: null, + }; +} + +function bucketRow(maxFileSize: number): Record { + return { + id: BUCKET_ID, + key: 'private', + type: 'private', + is_public: false, + owner_id: null, + allowed_mime_types: ['application/pdf'], + max_file_size: maxFileSize, + allow_custom_keys: false, + physical_name: 'persisted-test-bucket', + }; +} + +function options(bucket: string): PresignedUrlPluginOptions { + return { + s3: { + client: {} as any, + bucket, + publicUrlPrefix: `https://${bucket}.example`, + }, + }; +} + +function preloadedConfig(publicUrlPrefix: string): StorageModuleConfig { + return { + id: MODULE_ID, + bucketsQualifiedName: 'storage_public.app_buckets', + filesQualifiedName: 'storage_public.app_files', + schemaName: 'storage_public', + bucketsTableName: 'app_buckets', + filesTableName: 'app_files', + scope: 'app', + entityTableId: null, + entityQualifiedName: null, + endpoint: null, + publicUrlPrefix, + provider: 's3', + allowedOrigins: ['https://app.example'], + uploadUrlExpirySeconds: 900, + downloadUrlExpirySeconds: 1234, + defaultMaxFileSize: 1024, + maxFilenameLength: 1024, + cacheTtlSeconds: 3600, + hasPathShares: false, + maxBulkFiles: 100, + maxBulkTotalSize: 1073741824, + }; +} + +describe('build-local storage metadata caches', () => { + it('uses fixed-expiry caches rather than extending metadata lifetime on reads', () => { + const scope = getStorageModuleCacheScope({}); + expect(scope.storageModuleCache.updateAgeOnGet).toBe(false); + expect(scope.bucketCache.updateAgeOnGet).toBe(false); + }); + + it('binds every legacy metadata join to the requested physical database', async () => { + const scope = getStorageModuleCacheScope({}); + const client = { + query: jest.fn(async (_query: QueryOptions) => ({ + rows: [storageRow('https://tenant.example')], + })), + }; + + await getStorageModuleConfig(client, DATABASE_ID, scope); + + const query = client.query.mock.calls[0][0] as QueryOptions; + expect(query.values).toEqual([DATABASE_ID]); + expect(query.text).toContain('bt.database_id = sm.database_id'); + expect(query.text).toContain('bs.database_id = sm.database_id'); + expect(query.text).toContain('ft.database_id = sm.database_id'); + expect(query.text).toContain('fs.database_id = sm.database_id'); + expect(query.text).not.toContain('LIMIT 1'); + }); + + it('rejects wrong-database metadata rows and never caches them', async () => { + const scope = getStorageModuleCacheScope({}); + const client = { + query: jest.fn(async () => ({ + rows: [{ + ...storageRow('https://tenant.example'), + buckets_schema_database_id: '00000000-0000-0000-0000-000000000099', + }], + })), + }; + + await expect(getStorageModuleConfig(client, DATABASE_ID, scope)) + .rejects.toThrow(`STORAGE_MODULE_CROSS_DATABASE_METADATA:${MODULE_ID}`); + await expect(getStorageModuleConfig(client, DATABASE_ID, scope)) + .rejects.toThrow(`STORAGE_MODULE_CROSS_DATABASE_METADATA:${MODULE_ID}`); + expect(client.query).toHaveBeenCalledTimes(2); + }); + + it('rejects duplicate app metadata instead of selecting the first row', async () => { + const scope = getStorageModuleCacheScope({}); + const client = { + query: jest.fn(async () => ({ + rows: [ + storageRow('https://tenant-a.example'), + { ...storageRow('https://tenant-b.example'), id: 'module-b' }, + ], + })), + }; + + await expect(getStorageModuleConfig(client, DATABASE_ID, scope)) + .rejects.toThrow('STORAGE_MODULE_METADATA_AMBIGUOUS:app'); + }); + + it('rejects a codec mapped by more than one preloaded module', () => { + const first = preloadedConfig('https://tenant-a.example'); + const second = { ...preloadedConfig('https://tenant-b.example'), id: 'module-b' }; + + expect(() => resolveStorageConfigFromCodec({ + name: 'app_files', + extensions: { pg: { schemaName: 'storage_public', name: 'app_files' } }, + }, [first, second])).toThrow('STORAGE_MODULE_AMBIGUOUS:codec'); + }); + + it('isolates identical logical IDs and object names by exact Graphile build', async () => { + const firstBuild = {}; + const secondBuild = {}; + const firstScope = getStorageModuleCacheScope(firstBuild); + const secondScope = getStorageModuleCacheScope(secondBuild); + + expect(getStorageModuleCacheScope(firstBuild)).toBe(firstScope); + expect(secondScope).not.toBe(firstScope); + + const firstClient = { + query: jest.fn(async ({ text }: { text: string }) => ({ + rows: text.includes('metaschema_modules_public.storage_module') + ? [storageRow('https://tenant-a.example', 111)] + : /SELECT id\s+FROM/.test(text) + ? [{ id: BUCKET_ID }] + : [bucketRow(111)], + })), + }; + const secondClient = { + query: jest.fn(async ({ text }: { text: string }) => ({ + rows: text.includes('metaschema_modules_public.storage_module') + ? [storageRow('https://tenant-b.example', 222)] + : /SELECT id\s+FROM/.test(text) + ? [{ id: BUCKET_ID }] + : [bucketRow(222)], + })), + }; + + const [firstModules, secondModules] = await Promise.all([ + loadAllStorageModules(firstClient, DATABASE_ID, firstScope), + loadAllStorageModules(secondClient, DATABASE_ID, secondScope), + ]); + + expect(firstModules[0]).toMatchObject({ + id: MODULE_ID, + bucketsQualifiedName: 'storage_public.app_buckets', + publicUrlPrefix: 'https://tenant-a.example', + defaultMaxFileSize: 111, + }); + expect(secondModules[0]).toMatchObject({ + id: MODULE_ID, + bucketsQualifiedName: 'storage_public.app_buckets', + publicUrlPrefix: 'https://tenant-b.example', + defaultMaxFileSize: 222, + }); + + const firstBucket = await getBucketConfig( + firstClient, + firstModules[0], + DATABASE_ID, + 'private', + undefined, + firstScope, + ); + const secondBucket = await getBucketConfig( + secondClient, + secondModules[0], + DATABASE_ID, + 'private', + undefined, + secondScope, + ); + + expect(firstBucket).toMatchObject({ id: BUCKET_ID, key: 'private', max_file_size: 111 }); + expect(secondBucket).toMatchObject({ id: BUCKET_ID, key: 'private', max_file_size: 222 }); + + markS3BucketProvisioned('same-physical-bucket-name', firstScope); + expect(isS3BucketProvisioned('same-physical-bucket-name', firstScope)).toBe(true); + expect(isS3BucketProvisioned('same-physical-bucket-name', secondScope)).toBe(false); + }); + + it('negative-caches metadata inside one build without poisoning another build', async () => { + const missingScope = getStorageModuleCacheScope({}); + const presentScope = getStorageModuleCacheScope({}); + const missingClient = { + query: jest.fn(async (_opts: QueryOptions): Promise<{ rows: unknown[] }> => ({ rows: [] })), + }; + const presentClient = { + query: jest.fn(async () => ({ rows: [storageRow('https://present.example')] })), + }; + + await expect(getStorageModuleConfig(missingClient, DATABASE_ID, missingScope)).resolves.toBeNull(); + await expect(getStorageModuleConfig(missingClient, DATABASE_ID, missingScope)).resolves.toBeNull(); + expect(missingClient.query).toHaveBeenCalledTimes(1); + + await expect(getStorageModuleConfig(presentClient, DATABASE_ID, presentScope)).resolves.toMatchObject({ + id: MODULE_ID, + publicUrlPrefix: 'https://present.example', + }); + expect(presentClient.query).toHaveBeenCalledTimes(1); + }); + + it('negative-caches bucket metadata but rechecks RLS on every cache hit', async () => { + const scope = getStorageModuleCacheScope({}); + const moduleClient = { + query: jest.fn(async () => ({ rows: [storageRow('https://tenant.example')] })), + }; + const [storageConfig] = await loadAllStorageModules(moduleClient, DATABASE_ID, scope); + const bucketClient = { + query: jest.fn(async (_opts: QueryOptions): Promise<{ rows: unknown[] }> => ({ rows: [] })), + }; + + await expect( + getBucketConfig(bucketClient, storageConfig, DATABASE_ID, 'private', undefined, scope), + ).resolves.toBeNull(); + await expect( + getBucketConfig(bucketClient, storageConfig, DATABASE_ID, 'private', undefined, scope), + ).resolves.toBeNull(); + + expect(bucketClient.query).toHaveBeenCalledTimes(2); + const calls = bucketClient.query.mock.calls as unknown as Array<[QueryOptions]>; + expect(calls[0][0].text).toContain('allowed_mime_types'); + expect(calls[1][0].text).toMatch(/SELECT id\s+FROM/); + }); + + it('does not return a positive bucket cache hit when the current RLS context cannot see it', async () => { + const scope = getStorageModuleCacheScope({}); + const moduleClient = { + query: jest.fn(async () => ({ rows: [storageRow('https://tenant.example')] })), + }; + const [storageConfig] = await loadAllStorageModules(moduleClient, DATABASE_ID, scope); + const authorizedClient = { + query: jest.fn(async () => ({ rows: [bucketRow(512)] })), + }; + const unauthorizedClient = { + query: jest.fn(async (_opts: QueryOptions): Promise<{ rows: unknown[] }> => ({ rows: [] })), + }; + + await expect( + getBucketConfig(authorizedClient, storageConfig, DATABASE_ID, 'private', undefined, scope), + ).resolves.toMatchObject({ id: BUCKET_ID, max_file_size: 512 }); + await expect( + getBucketConfig(unauthorizedClient, storageConfig, DATABASE_ID, 'private', undefined, scope), + ).resolves.toBeNull(); + + const calls = unauthorizedClient.query.mock.calls as unknown as Array<[QueryOptions]>; + expect(calls).toHaveLength(1); + expect(calls[0][0].text).toMatch(/SELECT id\s+FROM/); + }); + + it('memoizes lazy S3 configuration per build instead of on shared preset options', () => { + const firstScope = getStorageModuleCacheScope({}); + const secondScope = getStorageModuleCacheScope({}); + let activeBucket = 'tenant-a-bucket'; + const getter = jest.fn(() => ({ + client: {} as any, + bucket: activeBucket, + })); + const sharedOptions: PresignedUrlPluginOptions = { s3: getter }; + + const first = resolveS3Config(sharedOptions, firstScope); + activeBucket = 'tenant-b-bucket'; + const second = resolveS3Config(sharedOptions, secondScope); + activeBucket = 'unrelated-later-value'; + + expect(resolveS3Config(sharedOptions, firstScope)).toBe(first); + expect(resolveS3Config(sharedOptions, secondScope)).toBe(second); + expect(first.bucket).toBe('tenant-a-bucket'); + expect(second.bucket).toBe('tenant-b-bucket'); + expect(getter).toHaveBeenCalledTimes(2); + expect(typeof sharedOptions.s3).toBe('function'); + }); + + it('matches the bucket provisioner resolver argument order on first provision', () => { + const cacheScope = getStorageModuleCacheScope({}); + const resolver = jest.fn((bucketKey: string, databaseId: string) => + `physical-${bucketKey}-${databaseId}`, + ); + + expect(mintPhysicalBucketName( + { ...options('fallback'), resolveBucketName: resolver }, + 'private', + DATABASE_ID, + cacheScope, + )).toBe(`physical-private-${DATABASE_ID}`); + expect(resolver).toHaveBeenCalledWith('private', DATABASE_ID); + }); + + it('does not cache an owner lookup across RLS principals', async () => { + const scope = getStorageModuleCacheScope({}); + const entityRow = { + ...storageRow('https://tenant.example'), + scope: 'team', + entity_table_id: '00000000-0000-0000-0000-000000000004', + entity_database_id: DATABASE_ID, + entity_schema_database_id: DATABASE_ID, + entity_schema: 'app_public', + entity_table: 'teams', + }; + const authorizedClient = { + query: jest.fn(async ({ text }: QueryOptions) => ({ + rows: text.includes('metaschema_modules_public.storage_module') + ? [entityRow] + : [{ '?column?': 1 }], + })), + }; + const unauthorizedClient = { + query: jest.fn(async (_opts: QueryOptions): Promise<{ rows: unknown[] }> => ({ rows: [] })), + }; + + await expect( + getStorageModuleConfigForOwner(authorizedClient, DATABASE_ID, 'owner-id', scope), + ).resolves.toMatchObject({ id: MODULE_ID, scope: 'team' }); + await expect( + getStorageModuleConfigForOwner(unauthorizedClient, DATABASE_ID, 'owner-id', scope), + ).resolves.toBeNull(); + + expect(unauthorizedClient.query).toHaveBeenCalledTimes(1); + const calls = unauthorizedClient.query.mock.calls as unknown as Array<[QueryOptions]>; + expect(calls[0][0].text).toContain('WHERE id = $1'); + }); +}); + +describe('download target fail-closed resolution', () => { + const codec = { + name: 'app_files', + extensions: { pg: { schemaName: 'storage_public', name: 'app_files' } }, + }; + + it('keeps identical logical tenants on their own build configuration', async () => { + async function resolveForBuild( + build: object, + publicUrlPrefix: string, + s3Bucket: string, + ) { + const pgSettings = { + role: 'member', + 'jwt.claims.api_id': 'api-a', + 'jwt.claims.database_id': DATABASE_ID, + 'jwt.claims.user_id': '', + }; + const requestQuery = jest.fn(async ({ text }: QueryOptions) => { + if (text.includes('current_database_id')) return { rows: [{ id: DATABASE_ID }] }; + if (text.includes('metaschema_modules_public.storage_module')) { + return { rows: [storageRow(publicUrlPrefix)] }; + } + if (text.includes('SELECT key, physical_name FROM')) { + return { rows: [{ key: 'private', physical_name: s3Bucket }] }; + } + throw new Error(`Unexpected query: ${text}`); + }); + const { withPgClient } = requestPgHarness(pgSettings, requestQuery); + + return resolveDownloadStorageTarget({ + options: options(s3Bucket), + preloadedStorageModules: undefined, + cacheScope: getStorageModuleCacheScope(build), + codec, + withPgClient, + pgSettings, + bucketId: BUCKET_ID, + }); + } + + const [first, second] = await Promise.all([ + resolveForBuild({}, 'https://tenant-a.example', 'tenant-a-bucket'), + resolveForBuild({}, 'https://tenant-b.example', 'tenant-b-bucket'), + ]); + + expect(first.s3).toMatchObject({ + bucket: 'tenant-a-bucket', + publicUrlPrefix: 'https://tenant-a.example', + }); + expect(second.s3).toMatchObject({ + bucket: 'tenant-b-bucket', + publicUrlPrefix: 'https://tenant-b.example', + }); + }); + + it('propagates lookup failures and never resolves fallback signing config', async () => { + const pgSettings = { + role: 'member', + 'jwt.claims.api_id': 'api-a', + 'jwt.claims.database_id': DATABASE_ID, + 'jwt.claims.user_id': '', + }; + const s3Getter = jest.fn(() => ({ + client: {} as any, + bucket: 'unsafe-global-fallback', + })); + const requestQuery = jest.fn(async ({ text }: QueryOptions) => { + if (text.includes('current_database_id')) return { rows: [{ id: DATABASE_ID }] }; + throw new Error('tenant metadata lookup failed'); + }); + const { withPgClient } = requestPgHarness(pgSettings, requestQuery); + + await expect(resolveDownloadStorageTarget({ + options: { s3: s3Getter }, + preloadedStorageModules: undefined, + cacheScope: getStorageModuleCacheScope({}), + codec, + withPgClient, + pgSettings, + bucketId: BUCKET_ID, + })).rejects.toThrow('tenant metadata lookup failed'); + expect(s3Getter).not.toHaveBeenCalled(); + }); + + it('does not consult global signing config when tenant metadata is absent', async () => { + const pgSettings = { + role: 'member', + 'jwt.claims.api_id': 'api-a', + 'jwt.claims.database_id': DATABASE_ID, + 'jwt.claims.user_id': '', + }; + const s3Getter = jest.fn(() => ({ + client: {} as any, + bucket: 'unsafe-global-fallback', + })); + const requestQuery = jest.fn(async ({ text }: QueryOptions) => { + if (text.includes('current_database_id')) return { rows: [{ id: DATABASE_ID }] }; + if (text.includes('metaschema_modules_public.storage_module')) return { rows: [] }; + throw new Error(`Unexpected query: ${text}`); + }); + const { withPgClient } = requestPgHarness(pgSettings, requestQuery); + + await expect(resolveDownloadStorageTarget({ + options: { s3: s3Getter }, + preloadedStorageModules: undefined, + cacheScope: getStorageModuleCacheScope({}), + codec, + withPgClient, + pgSettings, + bucketId: BUCKET_ID, + })).rejects.toThrow('STORAGE_MODULE_NOT_FOUND'); + expect(s3Getter).not.toHaveBeenCalled(); + }); + + it('rejects missing request settings before acquiring a metadata client or signing', async () => { + const s3Getter = jest.fn(() => ({ + client: {} as any, + bucket: 'unsafe-global-fallback', + })); + const withPgClient = jest.fn(); + + await expect(resolveDownloadStorageTarget({ + options: { s3: s3Getter }, + preloadedStorageModules: undefined, + cacheScope: getStorageModuleCacheScope({}), + codec, + withPgClient, + pgSettings: null, + bucketId: BUCKET_ID, + })).rejects.toThrow('STORAGE_REQUEST_SETTINGS_UNAVAILABLE'); + expect(withPgClient).not.toHaveBeenCalled(); + expect(s3Getter).not.toHaveBeenCalled(); + }); + + it('uses persisted physical_name verbatim without consulting the naming resolver', async () => { + const resolver = jest.fn(() => 'recomputed-and-wrong'); + const pgSettings = { role: 'member' }; + const requestQuery = jest.fn(async ({ text }: QueryOptions) => { + if (text.includes('current_database_id')) return { rows: [{ id: DATABASE_ID }] }; + if (text.includes('SELECT key, physical_name FROM')) { + return { rows: [{ key: 'private', physical_name: 'persisted-physical-bucket' }] }; + } + throw new Error(`Unexpected query: ${text}`); + }); + const { withPgClient } = requestPgHarness(pgSettings, requestQuery); + + await expect(resolveDownloadStorageTarget({ + options: { + ...options('global-fallback'), + resolveBucketName: resolver, + }, + preloadedStorageModules: snapshotPreloadedStorageModules([ + preloadedConfig('https://tenant.example'), + ]), + cacheScope: getStorageModuleCacheScope({}), + codec, + withPgClient, + pgSettings, + bucketId: BUCKET_ID, + })).resolves.toMatchObject({ + s3: { bucket: 'persisted-physical-bucket' }, + }); + expect(resolver).not.toHaveBeenCalled(); + }); +}); + +describe('preloaded storage-module configuration', () => { + const codec = { + name: 'app_files', + extensions: { pg: { schemaName: 'storage_public', name: 'app_files' } }, + }; + + it('takes an immutable snapshot and performs zero metadata SQL', async () => { + const source = [preloadedConfig('https://tenant.example')]; + const snapshot = snapshotPreloadedStorageModules(source)!; + source[0].publicUrlPrefix = 'https://mutated.example'; + source[0].allowedOrigins!.push('https://mutated.example'); + const withPgClient = jest.fn(async () => { + throw new Error('preloaded configuration must not acquire a metadata client'); + }); + + await expect(loadStorageModulesForBuild( + snapshot, + withPgClient, + { role: 'tenant_member' }, + DATABASE_ID, + getStorageModuleCacheScope({}), + )).resolves.toBe(snapshot); + + expect(withPgClient).not.toHaveBeenCalled(); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot[0])).toBe(true); + expect(Object.isFrozen(snapshot[0].allowedOrigins)).toBe(true); + expect(snapshotPreloadedStorageModules(snapshot)).toBe(snapshot); + expect(snapshot[0].publicUrlPrefix).toBe('https://tenant.example'); + expect(snapshot[0].allowedOrigins).toEqual(['https://app.example']); + }); + + it('rejects executable SQL and inconsistent object names in a preload', () => { + expect(() => snapshotPreloadedStorageModules([{ + ...preloadedConfig('https://tenant.example'), + bucketsQualifiedName: 'storage_public.app_buckets; SELECT pg_sleep(10)', + }])).toThrow(`STORAGE_MODULE_METADATA_INVALID:buckets:${MODULE_ID}`); + + expect(() => snapshotPreloadedStorageModules([{ + ...preloadedConfig('https://tenant.example'), + bucketsQualifiedName: 'other_schema.app_buckets', + }])).toThrow(`STORAGE_MODULE_METADATA_INCONSISTENT:${MODULE_ID}`); + }); + + it('rejects duplicate scope metadata in a preload', () => { + expect(() => snapshotPreloadedStorageModules([ + preloadedConfig('https://tenant-a.example'), + { ...preloadedConfig('https://tenant-b.example'), id: 'module-b' }, + ])).toThrow('STORAGE_MODULE_METADATA_AMBIGUOUS'); + }); + + it('treats an empty preloaded list as authoritative instead of falling back', async () => { + const snapshot = snapshotPreloadedStorageModules([])!; + const withPgClient = jest.fn(async () => { + throw new Error('empty preload must not fall back'); + }); + + await expect(loadStorageModulesForBuild( + snapshot, + withPgClient, + { role: 'tenant_member' }, + DATABASE_ID, + getStorageModuleCacheScope({}), + )).resolves.toEqual([]); + expect(withPgClient).not.toHaveBeenCalled(); + }); + + it('keeps request identity and bucket authorization under pgSettings', async () => { + const pgSettings = { role: 'tenant_member' }; + const requestQuery = jest.fn(async ({ text }: QueryOptions) => { + if (text.includes('current_database_id')) { + return { rows: [{ id: DATABASE_ID }] }; + } + if (text.includes('SELECT key, physical_name FROM storage_public.app_buckets')) { + return { rows: [{ key: 'private', physical_name: 'tenant-bucket' }] }; + } + if (text.includes('metaschema_')) { + throw new Error('metadata SQL must not run'); + } + throw new Error(`Unexpected query: ${text}`); + }); + const { + withPgClient, + withPgClientMock, + withTransaction, + query, + } = requestPgHarness(pgSettings, requestQuery); + const preloaded = snapshotPreloadedStorageModules([ + preloadedConfig('https://tenant.example'), + ]); + + await expect(resolveDownloadStorageTarget({ + options: options('tenant-bucket'), + preloadedStorageModules: preloaded, + cacheScope: getStorageModuleCacheScope({}), + codec, + withPgClient, + pgSettings, + bucketId: BUCKET_ID, + })).resolves.toMatchObject({ + s3: { + bucket: 'tenant-bucket', + publicUrlPrefix: 'https://tenant.example', + }, + downloadUrlExpirySeconds: 1234, + }); + + expect(withPgClientMock).toHaveBeenCalledTimes(1); + expect(withTransaction).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledTimes(3); + expect(requestQuery).toHaveBeenCalledTimes(2); + expect(requestQuery.mock.calls.map(([arg]) => arg.text).join('\n')).not.toContain('metaschema_'); + }); + + it('retains the metadata SQL fallback only when preloaded modules are undefined and applies request settings', async () => { + const pgSettings = { + role: 'tenant_member', + 'jwt.claims.api_id': 'api-a', + 'jwt.claims.database_id': DATABASE_ID, + 'jwt.claims.user_id': '', + 'transaction_read_only': 'off', + 'row_security': 'on', + }; + const pgClient = { + query: jest.fn(async () => ({ rows: [storageRow('https://generic.example')] })), + }; + const withPgClient = jest.fn(async ( + settings: unknown, + callback: (client: typeof pgClient) => Promise | unknown, + ) => { + expect(settings).toBe(pgSettings); + return callback(pgClient); + }) as unknown as StorageWithPgClient; + + await expect(loadStorageModulesForBuild( + undefined, + withPgClient, + pgSettings, + DATABASE_ID, + getStorageModuleCacheScope({}), + )).resolves.toHaveLength(1); + + expect(withPgClient).toHaveBeenCalledTimes(1); + expect(pgClient.query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/graphile/graphile-presigned-url-plugin/__tests__/upload-owner-scope.test.ts b/graphile/graphile-presigned-url-plugin/__tests__/upload-owner-scope.test.ts new file mode 100644 index 0000000000..34bd240a8e --- /dev/null +++ b/graphile/graphile-presigned-url-plugin/__tests__/upload-owner-scope.test.ts @@ -0,0 +1,82 @@ +import { resolveUploadOwnerScope } from '../src/plugin'; +import type { StorageModuleConfig } from '../src/types'; + +const moduleConfig = (scope: string): StorageModuleConfig => ({ + id: '00000000-0000-4000-8000-000000000001', + bucketsQualifiedName: 'storage_public.app_buckets', + filesQualifiedName: 'storage_public.app_files', + schemaName: 'storage_public', + bucketsTableName: 'app_buckets', + filesTableName: 'app_files', + scope, + entityTableId: scope === 'app' ? null : '00000000-0000-4000-8000-000000000002', + entityQualifiedName: scope === 'app' ? null : 'app_public.organizations', + endpoint: null, + publicUrlPrefix: null, + provider: 's3', + allowedOrigins: null, + uploadUrlExpirySeconds: 900, + downloadUrlExpirySeconds: 3600, + defaultMaxFileSize: 1024, + maxFilenameLength: 1024, + cacheTtlSeconds: 300, + hasPathShares: false, + maxBulkFiles: 100, + maxBulkTotalSize: 1024, +}); + +const filesCodec = { + name: 'app_files', + extensions: { + pg: { schemaName: 'storage_public', name: 'app_files' }, + }, +}; + +const bucketCodec = (withOwnerColumn = true) => ({ + name: 'app_buckets', + attributes: withOwnerColumn ? { owner_id: { codec: 'uuid-codec' } } : {}, + extensions: { + pg: { schemaName: 'storage_public', name: 'app_buckets' }, + }, +}); + +describe('upload owner scope', () => { + it('keeps ownerId optional for an authoritative app-scoped module', () => { + expect(resolveUploadOwnerScope( + filesCodec, + bucketCodec(), + [moduleConfig('app')], + )).toEqual({ hasOwnerId: false, ownerIdCodec: 'uuid-codec' }); + }); + + it('requires ownerId for an authoritative entity-scoped module', () => { + expect(resolveUploadOwnerScope( + filesCodec, + bucketCodec(), + [moduleConfig('organization')], + )).toEqual({ hasOwnerId: true, ownerIdCodec: 'uuid-codec' }); + }); + + it('fails closed when entity scope has no owner_id column', () => { + expect(() => resolveUploadOwnerScope( + filesCodec, + bucketCodec(false), + [moduleConfig('organization')], + )).toThrow('STORAGE_OWNER_COLUMN_REQUIRED:storage_public.app_buckets'); + }); + + it('omits upload fields when an authoritative snapshot has no matching module', () => { + expect(resolveUploadOwnerScope(filesCodec, bucketCodec(), [])).toBeNull(); + }); + + it('retains column inference only for generic consumers without a snapshot', () => { + expect(resolveUploadOwnerScope(filesCodec, bucketCodec(), undefined)).toEqual({ + hasOwnerId: true, + ownerIdCodec: 'uuid-codec', + }); + expect(resolveUploadOwnerScope(filesCodec, bucketCodec(false), undefined)).toEqual({ + hasOwnerId: false, + ownerIdCodec: null, + }); + }); +}); diff --git a/graphile/graphile-presigned-url-plugin/src/download-url-field.ts b/graphile/graphile-presigned-url-plugin/src/download-url-field.ts index b0d2bc9a73..7825825d2e 100644 --- a/graphile/graphile-presigned-url-plugin/src/download-url-field.ts +++ b/graphile/graphile-presigned-url-plugin/src/download-url-field.ts @@ -25,10 +25,27 @@ import { Logger } from '@pgpmjs/logger'; import { context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; -import { withRequestPgClient } from './request-pg-client'; +import { + type RequestPgClient, + type WithPgClient, + withRequestPgClient, +} from './request-pg-client'; import { generatePresignedGetUrl } from './s3-signer'; -import { loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache'; -import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types'; +import { resolveS3ConfigForPhysicalBucket } from './s3-config'; +import { + getStorageModuleCacheScope, + resolveStorageConfigFromCodec, + storedPhysicalName, + type StorageModuleCacheScope, +} from './storage-module-cache'; +import { + assertStorageRequestContext, + loadStorageModulesForBuild, + snapshotPreloadedStorageModules, + type PreloadedStorageModules, + type StorageWithPgClient, +} from './storage-module-source'; +import type { PresignedUrlPluginOptions, S3Config } from './types'; const log = new Logger('graphile-presigned-url:download-url'); @@ -41,48 +58,101 @@ const log = new Logger('graphile-presigned-url:download-url'); * the storage module's files table, which we discover at schema-build time * via the `@storageFiles` smart tag. */ -/** - * Resolve the S3 config from the options. If the option is a lazy getter - * function, call it (and cache the result). - */ -function resolveS3(options: PresignedUrlPluginOptions): S3Config { - if (typeof options.s3 === 'function') { - const resolved = options.s3(); - options.s3 = resolved; - return resolved; - } - return options.s3; +interface DownloadStorageTargetOptions { + options: PresignedUrlPluginOptions; + preloadedStorageModules: PreloadedStorageModules; + cacheScope: StorageModuleCacheScope; + codec: { + name: string; + extensions?: { pg?: { schemaName?: string; name?: string } }; + sqlType?: string; + }; + withPgClient: (WithPgClient & StorageWithPgClient) | null | undefined; + pgSettings: unknown; + bucketId: string | null | undefined; +} + +function withCurrentRequestPgClient( + pgClient: RequestPgClient, +): StorageWithPgClient { + return async (_pgSettings, callback) => callback(pgClient); } /** - * Build a per-database S3Config for a *known* physical bucket. `physicalName` - * is required — the stored coordinate on the bucket row is the only source; - * no name is ever recomputed here. Same logic as plugin.ts resolveS3ForDatabase. + * Resolve every tenant-bound input before choosing credentials or signing. + * Any missing metadata or database error rejects the field instead of signing + * the same key with process-global fallback configuration. */ -function resolveS3ForDatabase( - options: PresignedUrlPluginOptions, - storageConfig: StorageModuleConfig, - physicalName: string, -): S3Config { - const globalS3 = resolveS3(options); - const publicUrlPrefix = storageConfig.publicUrlPrefix != null - ? storageConfig.publicUrlPrefix - : globalS3.publicUrlPrefix; - - if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) { - return globalS3; - } +export async function resolveDownloadStorageTarget({ + options, + preloadedStorageModules, + cacheScope, + codec, + withPgClient, + pgSettings, + bucketId, +}: DownloadStorageTargetOptions): Promise<{ + s3: S3Config; + downloadUrlExpirySeconds: number; +}> { + assertStorageRequestContext(withPgClient, pgSettings); + const requestSettings = pgSettings as Record; - return { - ...globalS3, - bucket: physicalName, - ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}), - }; + return withRequestPgClient(withPgClient, requestSettings, async (pgClient) => { + const result = await pgClient.query({ + text: `SELECT jwt_private.current_database_id() AS id`, + }); + const databaseId = result.rows[0]?.id as string | null | undefined; + if (!databaseId) { + throw new Error('DATABASE_NOT_FOUND'); + } + + const allConfigs = await loadStorageModulesForBuild( + preloadedStorageModules, + withCurrentRequestPgClient(pgClient), + requestSettings, + databaseId, + cacheScope, + ); + const storageConfig = resolveStorageConfigFromCodec(codec, allConfigs); + if (!storageConfig) { + throw new Error('STORAGE_MODULE_NOT_FOUND'); + } + if (!bucketId) { + throw new Error('BUCKET_NOT_FOUND'); + } + + const bucketResult = await pgClient.query({ + text: `SELECT key, physical_name FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`, + values: [bucketId], + }); + const bucketRow = bucketResult.rows[0] as { key: string; physical_name?: string | null } | undefined; + if (!bucketRow) { + throw new Error('BUCKET_NOT_FOUND'); + } + const physicalName = storedPhysicalName(bucketRow); + if (physicalName === null) { + throw new Error('BUCKET_NOT_PROVISIONED'); + } + + return { + s3: resolveS3ConfigForPhysicalBucket( + options, + storageConfig, + physicalName, + cacheScope, + ), + downloadUrlExpirySeconds: storageConfig.downloadUrlExpirySeconds, + }; + }); } export function createDownloadUrlPlugin( options: PresignedUrlPluginOptions, ): GraphileConfig.Plugin { + const preloadedStorageModules = snapshotPreloadedStorageModules( + options.preloadedStorageModules, + ); return { name: 'PresignedUrlDownloadPlugin', @@ -108,6 +178,7 @@ export function createDownloadUrlPlugin( } log.debug(`Adding downloadUrl field to type: ${pgCodec.name} (has @storageFiles tag)`); + const cacheScope = getStorageModuleCacheScope(build); const { graphql: { GraphQLString }, @@ -146,56 +217,32 @@ export function createDownloadUrlPlugin( return lambda($combined, async ({ key, isPublic, filename, bucketId, withPgClient, pgSettings }: any) => { if (!key) return null; - let s3ForDb = resolveS3(options); - let downloadUrlExpirySeconds = 3600; + let target: Awaited>; try { - if (withPgClient && pgSettings) { - const databaseId = await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => { - const dbResult = await pgClient.query({ - text: `SELECT jwt_private.current_database_id() AS id`, - }); - return (dbResult.rows[0]?.id as string | undefined) ?? null; - }); - // Module registration is server config, not user data: - // resolve it without the request role's pgSettings. - const config = databaseId - ? resolveStorageConfigFromCodec( - capturedCodec, - await withPgClient(null, (pgClient: any) => loadAllStorageModules(pgClient, databaseId)), - ) - : null; - const resolved = config && bucketId - ? await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => { - // Look up the stored physical coordinate for scoped S3 resolution - const bucketResult = await pgClient.query({ - text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`, - values: [bucketId], - }); - const row = bucketResult.rows[0] as { key: string; physical_name?: string | null } | undefined; - return row ? { config, physicalName: storedPhysicalName(row) } : null; - }) - : null; - if (resolved) { - if (resolved.physicalName === null) { - // No physical bucket was ever provisioned — no object can exist. - return null; - } - downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds; - s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.physicalName); - } + target = await resolveDownloadStorageTarget({ + options, + preloadedStorageModules, + cacheScope, + codec: capturedCodec, + withPgClient, + pgSettings, + bucketId, + }); + } catch (error) { + if (error instanceof Error && error.message === 'BUCKET_NOT_PROVISIONED') { + return null; } - } catch { - // Fall back to global config if lookup fails + throw error; } - if (isPublic && s3ForDb.publicUrlPrefix) { - return `${s3ForDb.publicUrlPrefix}/${s3ForDb.bucket}/${key}`; + if (isPublic && target.s3.publicUrlPrefix) { + return `${target.s3.publicUrlPrefix}/${target.s3.bucket}/${key}`; } return generatePresignedGetUrl( - s3ForDb, + target.s3, key, - downloadUrlExpirySeconds, + target.downloadUrlExpirySeconds, filename || undefined, ); }); diff --git a/graphile/graphile-presigned-url-plugin/src/index.ts b/graphile/graphile-presigned-url-plugin/src/index.ts index 3d8ee4fe44..6cc338aa4f 100644 --- a/graphile/graphile-presigned-url-plugin/src/index.ts +++ b/graphile/graphile-presigned-url-plugin/src/index.ts @@ -28,10 +28,25 @@ */ export { createDownloadUrlPlugin } from './download-url-field'; -export { createPresignedUrlPlugin,PresignedUrlPlugin } from './plugin'; +export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin'; export { PresignedUrlPreset } from './preset'; +export { snapshotPreloadedStorageModules } from './storage-module-source'; export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer'; -export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned,resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache'; +export { + getStorageModuleConfig, + getStorageModuleConfigForOwner, + getBucketConfig, + resolveStorageModuleByFileId, + loadAllStorageModules, + resolveStorageConfigFromCodec, + clearStorageModuleCache, + clearBucketCache, + isS3BucketProvisioned, + markS3BucketProvisioned, + StorageModuleCacheScope, + getStorageModuleCacheScope, + storedPhysicalName, +} from './storage-module-cache'; export type { BucketConfig, BucketNameResolver, diff --git a/graphile/graphile-presigned-url-plugin/src/plugin.ts b/graphile/graphile-presigned-url-plugin/src/plugin.ts index 6d27b29b19..90d49840ff 100644 --- a/graphile/graphile-presigned-url-plugin/src/plugin.ts +++ b/graphile/graphile-presigned-url-plugin/src/plugin.ts @@ -23,10 +23,32 @@ import { Logger } from '@pgpmjs/logger'; import { access, context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; -import { type WithPgClient,withRequestPgClient } from './request-pg-client'; -import { deleteS3Object,generatePresignedPutUrl } from './s3-signer'; -import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned,resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache'; -import type { BucketConfig,PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types'; +import { + type RequestPgClient, + type WithPgClient, + withRequestPgClient, +} from './request-pg-client'; +import { + mintPhysicalBucketName, + resolveS3ConfigForPhysicalBucket, +} from './s3-config'; +import { deleteS3Object, generatePresignedPutUrl } from './s3-signer'; +import { + getBucketConfig, + getStorageModuleCacheScope, + isS3BucketProvisioned, + markS3BucketProvisioned, + resolveStorageConfigFromCodec, + type StorageModuleCacheScope, + storedPhysicalName, +} from './storage-module-cache'; +import { + assertStorageRequestContext, + loadStorageModulesForBuild, + snapshotPreloadedStorageModules, + type StorageWithPgClient, +} from './storage-module-source'; +import type { BucketConfig, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types'; const log = new Logger('graphile-presigned-url:plugin'); @@ -38,6 +60,49 @@ const MAX_CUSTOM_KEY_LENGTH = 1024; const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/; const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/; +type TaggedStorageCodec = { + name: string; + attributes?: Record; + extensions?: { pg?: { schemaName?: string; name?: string } }; +}; + +/** + * Resolve the upload input's owner scope at schema-build time. + * + * Exact-build preloaded metadata is authoritative: app-scoped modules must + * not expose a required ownerId merely because their canonical bucket table + * retains a nullable owner_id column. Generic consumers without a preloaded + * snapshot retain the legacy column-based inference because their module + * scope is available only at request time. + */ +export function resolveUploadOwnerScope( + filesCodec: TaggedStorageCodec, + bucketCodec: TaggedStorageCodec, + preloadedStorageModules: readonly StorageModuleConfig[] | undefined, +): { hasOwnerId: boolean; ownerIdCodec: unknown | null } | null { + const ownerIdCodec = bucketCodec.attributes?.owner_id?.codec ?? null; + if (preloadedStorageModules === undefined) { + return { + hasOwnerId: ownerIdCodec !== null, + ownerIdCodec, + }; + } + + const storageConfig = resolveStorageConfigFromCodec( + filesCodec, + preloadedStorageModules, + ); + if (!storageConfig) return null; + + const hasOwnerId = storageConfig.scope !== 'app'; + if (hasOwnerId && ownerIdCodec === null) { + throw new Error( + `STORAGE_OWNER_COLUMN_REQUIRED:${storageConfig.schemaName}.${storageConfig.bucketsTableName}`, + ); + } + return { hasOwnerId, ownerIdCodec }; +} + // --- Helpers --- function isValidSha256(hash: string): boolean { @@ -81,58 +146,39 @@ async function resolveDatabaseId(pgClient: any): Promise { return result.rows[0]?.id ?? null; } -function resolveS3(options: PresignedUrlPluginOptions): S3Config { - if (typeof options.s3 === 'function') { - const resolved = options.s3(); - options.s3 = resolved; - return resolved; - } - return options.s3; -} +type RequestStorageWithPgClient = WithPgClient & StorageWithPgClient; -/** - * Mint the physical S3 bucket name for a logical bucket's first provision. - * - * This is a naming *policy*, consulted exactly once per bucket — before the - * physical bucket exists. Once provisioned, the recorded `physical_name` on - * the row is authoritative and this function must not be consulted again. - */ -function mintPhysicalBucketName( - options: PresignedUrlPluginOptions, - databaseId: string, - bucketKey: string, -): string { - if (options.resolveBucketName) { - return options.resolveBucketName(databaseId, bucketKey); - } - // Single-bucket deployment: the globally configured bucket is the physical bucket. - return resolveS3(options).bucket; +function withCurrentRequestPgClient( + pgClient: RequestPgClient, +): StorageWithPgClient { + return async (_pgSettings, callback) => callback(pgClient); } -/** - * Build the S3 config for a *known* physical bucket. `physicalName` is - * required — callers must resolve the coordinate (stored row value, or a - * freshly provisioned name) before getting here. No name is ever recomputed. - */ -function resolveS3ForDatabase( - options: PresignedUrlPluginOptions, - storageConfig: StorageModuleConfig, - physicalName: string, -): S3Config { - const globalS3 = resolveS3(options); - const publicUrlPrefix = storageConfig.publicUrlPrefix != null - ? storageConfig.publicUrlPrefix - : globalS3.publicUrlPrefix; - - if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) { - return globalS3; - } - - return { - ...globalS3, - bucket: physicalName, - ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}), - }; +async function resolveRequestStorageModules( + preloadedStorageModules: readonly StorageModuleConfig[] | undefined, + cacheScope: StorageModuleCacheScope, + withPgClient: RequestStorageWithPgClient | null | undefined, + pgSettings: unknown, +): Promise<{ + databaseId: string; + allConfigs: readonly StorageModuleConfig[]; +}> { + assertStorageRequestContext(withPgClient, pgSettings); + const requestSettings = pgSettings as Record; + + return withRequestPgClient(withPgClient, requestSettings, async (pgClient) => { + const databaseId = await resolveDatabaseId(pgClient); + if (!databaseId) throw new Error('DATABASE_NOT_FOUND'); + + const allConfigs = await loadStorageModulesForBuild( + preloadedStorageModules, + withCurrentRequestPgClient(pgClient), + requestSettings, + databaseId, + cacheScope, + ); + return { databaseId, allConfigs }; + }); } /** @@ -144,48 +190,71 @@ function resolveS3ForDatabase( * value is the durable coordinate: route resolution and every later read use * it verbatim; nothing is recomputed. * - * The record write runs in the system lane (privileged role, so it bypasses the - * RLS that stops request roles from UPDATE-ing bucket rows) — it is server - * bookkeeping, not request data. It still carries the tenant `database_id` - * claim, because the buckets table's catalog-sync trigger calls - * `jwt_private.current_database_id()` and would otherwise raise - * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the - * write's transaction without switching off the privileged role. - * `bucket` (the cached config) is mutated in place so subsequent reads observe - * the recorded name without a DB round-trip. + * The record write reuses the complete request settings. It must satisfy the + * same role, claims, and RLS policies as the bucket read that authorized the + * upload; a policy denial fails closed. `bucket` (the cached config) is mutated + * in place so subsequent reads observe the recorded name without a DB round-trip. */ async function provisionAndRecordPhysicalBucket( options: PresignedUrlPluginOptions, withPgClient: WithPgClient, + pgSettings: Record, storageConfig: StorageModuleConfig, databaseId: string, bucket: BucketConfig, allowedOrigins: string[] | null, + cacheScope: StorageModuleCacheScope, ): Promise { - const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key); + const s3BucketName = mintPhysicalBucketName( + options, + bucket.key, + databaseId, + cacheScope, + ); - if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) { + if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName, cacheScope)) { log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`); await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins); - markS3BucketProvisioned(s3BucketName); + markS3BucketProvisioned(s3BucketName, cacheScope); log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`); } - // Record the physical coordinate on the source row. The `physical_name IS NULL` - // guard keeps this idempotent and race-safe across concurrent first uploads. - // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the - // write runs under the resolved database claim (privileged role preserved). - await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => - client.query({ - text: `UPDATE ${storageConfig.bucketsQualifiedName} + // Record the physical coordinate on the source row. If another process won + // the race, read its value and route to that authoritative coordinate rather + // than signing against this process's losing candidate. + // Keep the complete request role and claims active for both race-resolution + // statements; a policy denial must fail closed rather than use a system lane. + const recordedPhysicalName = await withRequestPgClient( + withPgClient, + pgSettings, + async (client) => { + const updated = await client.query({ + text: `UPDATE ${storageConfig.bucketsQualifiedName} SET physical_name = $1 - WHERE id = $2 AND physical_name IS NULL`, - values: [s3BucketName, bucket.id], - }), + WHERE id = $2 AND physical_name IS NULL + RETURNING physical_name`, + values: [s3BucketName, bucket.id], + }); + const updatedName = storedPhysicalName(updated.rows[0] ?? {}); + if (updatedName !== null) return updatedName; + + const existing = await client.query({ + text: `SELECT physical_name + FROM ${storageConfig.bucketsQualifiedName} + WHERE id = $1 + LIMIT 1`, + values: [bucket.id], + }); + return storedPhysicalName(existing.rows[0] ?? {}); + }, ); - bucket.physical_name = s3BucketName; - log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`); - return s3BucketName; + if (recordedPhysicalName === null) { + throw new Error('BUCKET_PHYSICAL_NAME_NOT_RECORDED'); + } + + bucket.physical_name = recordedPhysicalName; + log.info(`Using physical_name="${recordedPhysicalName}" for bucket ${bucket.id}`); + return recordedPhysicalName; } // --- Plugin factory --- @@ -193,6 +262,9 @@ async function provisionAndRecordPhysicalBucket( export function createPresignedUrlPlugin( options: PresignedUrlPluginOptions, ): GraphileConfig.Plugin { + const preloadedStorageModules = snapshotPreloadedStorageModules( + options.preloadedStorageModules, + ); return { name: 'PresignedUrlPlugin', @@ -259,11 +331,22 @@ export function createPresignedUrlPlugin( continue; } - const hasOwnerId = !!matchingBucketCodec.attributes.owner_id; + const ownerScope = resolveUploadOwnerScope( + filesCodec, + matchingBucketCodec, + preloadedStorageModules, + ); + if (!ownerScope) { + log.debug( + `Skipping upload mutation for ${filesCodec.name}: no authoritative preloaded storage module`, + ); + continue; + } + const { hasOwnerId, ownerIdCodec } = ownerScope; const mutationName = `upload${filesTypeName}`; const ownerIdGqlType = hasOwnerId - ? (build as any).getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input') + ? (build as any).getGraphQLTypeByPgCodec(ownerIdCodec, 'input') : null; const InputType = new GraphQLInputObjectType({ @@ -294,6 +377,7 @@ export function createPresignedUrlPlugin( }); const capturedFilesCodec = filesCodec; + const cacheScope = getStorageModuleCacheScope(build); log.debug(`Adding file upload mutation "${mutationName}" for ${filesTypeName} (entity-scoped=${hasOwnerId})`); @@ -330,34 +414,50 @@ export function createPresignedUrlPlugin( }); return lambda($combined, async (vals: any) => { - // Request-lane reads/writes run under the request role's pgSettings - // inside an explicit transaction so the jwt claims stay applied - // across every statement (see withRequestPgClient). - const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => - resolveDatabaseId(pgClient), - ); - if (!databaseId) throw new Error('DATABASE_NOT_FOUND'); - - // Module registration is server config, not user data: - // resolve it without the request role's pgSettings. - const allConfigs = await vals.withPgClient(null, (pgClient: any) => - loadAllStorageModules(pgClient, databaseId), + // Resolve the request tenant and exact build-scoped module + // metadata inside one transaction carrying every request GUC. + const { databaseId, allConfigs } = await resolveRequestStorageModules( + preloadedStorageModules, + cacheScope, + vals.withPgClient, + vals.pgSettings, ); const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs); if (!storageConfig) throw new Error('STORAGE_MODULE_NOT_FOUND'); // Bucket config read under the request role (RLS-gated visibility). const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => - getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined), + getBucketConfig( + pgClient, + storageConfig, + databaseId, + vals.bucketKey, + vals.ownerId || undefined, + cacheScope, + ), ); if (!bucket) throw new Error('BUCKET_NOT_FOUND'); // First provision mints + records the coordinate; afterwards the // stored physical_name is authoritative and nothing is recomputed. const physicalName = bucket.physical_name === null - ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins) + ? await provisionAndRecordPhysicalBucket( + options, + vals.withPgClient, + vals.pgSettings, + storageConfig, + databaseId, + bucket, + storageConfig.allowedOrigins, + cacheScope, + ) : bucket.physical_name; - const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName); + const s3ForDb = resolveS3ConfigForPhysicalBucket( + options, + storageConfig, + physicalName, + cacheScope, + ); // File row INSERT under the request role (RLS enforced). return withRequestPgClient(vals.withPgClient, vals.pgSettings, (txClient) => @@ -444,25 +544,27 @@ export function createPresignedUrlPlugin( }); return lambda($combined, async (vals: any) => { - // Request-lane reads/writes run under the request role's pgSettings - // inside an explicit transaction so the jwt claims stay applied - // across every statement (see withRequestPgClient). - const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => - resolveDatabaseId(pgClient), - ); - if (!databaseId) throw new Error('DATABASE_NOT_FOUND'); - - // Module registration is server config, not user data: - // resolve it without the request role's pgSettings. - const allConfigs = await vals.withPgClient(null, (pgClient: any) => - loadAllStorageModules(pgClient, databaseId), + // Resolve the request tenant and exact build-scoped module + // metadata inside one transaction carrying every request GUC. + const { databaseId, allConfigs } = await resolveRequestStorageModules( + preloadedStorageModules, + cacheScope, + vals.withPgClient, + vals.pgSettings, ); const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs); if (!storageConfig) throw new Error('STORAGE_MODULE_NOT_FOUND'); // Bucket config read under the request role (RLS-gated visibility). const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => - getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined), + getBucketConfig( + pgClient, + storageConfig, + databaseId, + vals.bucketKey, + vals.ownerId || undefined, + cacheScope, + ), ); if (!bucket) throw new Error('BUCKET_NOT_FOUND'); @@ -483,9 +585,23 @@ export function createPresignedUrlPlugin( // First provision mints + records the coordinate; afterwards the // stored physical_name is authoritative and nothing is recomputed. const physicalName = bucket.physical_name === null - ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins) + ? await provisionAndRecordPhysicalBucket( + options, + vals.withPgClient, + vals.pgSettings, + storageConfig, + databaseId, + bucket, + storageConfig.allowedOrigins, + cacheScope, + ) : bucket.physical_name; - const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName); + const s3ForDb = resolveS3ConfigForPhysicalBucket( + options, + storageConfig, + physicalName, + cacheScope, + ); // File row INSERTs under the request role (RLS enforced). return withRequestPgClient(vals.withPgClient, vals.pgSettings, async (txClient) => { @@ -548,6 +664,7 @@ export function createPresignedUrlPlugin( const defaultResolver = (obj: any) => obj[fieldName]; const { resolve: oldResolve = defaultResolver, ...rest } = field; const capturedCodec = pgCodec; + const cacheScope = getStorageModuleCacheScope(build); return { ...rest, @@ -567,12 +684,14 @@ export function createPresignedUrlPlugin( if (withPgClient) { try { - const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient)); - // Module registration is server config, not user data: - // resolve it without the request role's pgSettings. - const allConfigs = databaseId - ? await withPgClient(null, (pgClient: any) => loadAllStorageModules(pgClient, databaseId)) - : []; + // Prefer the exact-build control-plane snapshot; the SQL + // fallback exists only for generic package consumers. + const { allConfigs } = await resolveRequestStorageModules( + preloadedStorageModules, + cacheScope, + withPgClient, + pgSettings, + ); const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs); if (storageConfig) { @@ -603,12 +722,14 @@ export function createPresignedUrlPlugin( if (withPgClient) { try { - const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient)); - // Module registration is server config, not user data: - // resolve it without the request role's pgSettings. - const allConfigs = databaseId - ? await withPgClient(null, (pgClient: any) => loadAllStorageModules(pgClient, databaseId)) - : []; + // Prefer the exact-build control-plane snapshot; the SQL + // fallback exists only for generic package consumers. + const { allConfigs } = await resolveRequestStorageModules( + preloadedStorageModules, + cacheScope, + withPgClient, + pgSettings, + ); const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs); if (storageConfig) await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => { @@ -642,7 +763,12 @@ export function createPresignedUrlPlugin( log.warn(`Bucket ${fileRow!.bucket_id} has no physical_name; skipping S3 delete`); return; } - const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName); + const s3ForDb = resolveS3ConfigForPhysicalBucket( + options, + storageConfig, + physicalName, + cacheScope, + ); await deleteS3Object(s3ForDb, fileRow!.key); log.info(`Sync S3 delete succeeded for key=${fileRow!.key}`); }); diff --git a/graphile/graphile-presigned-url-plugin/src/preset.ts b/graphile/graphile-presigned-url-plugin/src/preset.ts index 3ab986721e..2cdbee629d 100644 --- a/graphile/graphile-presigned-url-plugin/src/preset.ts +++ b/graphile/graphile-presigned-url-plugin/src/preset.ts @@ -10,6 +10,7 @@ import type { GraphileConfig } from 'graphile-config'; import { createDownloadUrlPlugin } from './download-url-field'; import { createPresignedUrlPlugin } from './plugin'; +import { snapshotPreloadedStorageModules } from './storage-module-source'; import type { PresignedUrlPluginOptions } from './types'; /** @@ -38,10 +39,17 @@ import type { PresignedUrlPluginOptions } from './types'; export function PresignedUrlPreset( options: PresignedUrlPluginOptions, ): GraphileConfig.Preset { + const preloadedStorageModules = snapshotPreloadedStorageModules( + options.preloadedStorageModules, + ); + const buildOptions = preloadedStorageModules === undefined + ? options + : { ...options, preloadedStorageModules }; + return { plugins: [ - createPresignedUrlPlugin(options), - createDownloadUrlPlugin(options), + createPresignedUrlPlugin(buildOptions), + createDownloadUrlPlugin(buildOptions), ], }; } diff --git a/graphile/graphile-presigned-url-plugin/src/s3-config.ts b/graphile/graphile-presigned-url-plugin/src/s3-config.ts new file mode 100644 index 0000000000..87d9f7944f --- /dev/null +++ b/graphile/graphile-presigned-url-plugin/src/s3-config.ts @@ -0,0 +1,85 @@ +import type { + PresignedUrlPluginOptions, + S3Config, + StorageModuleConfig, +} from './types'; +import type { StorageModuleCacheScope } from './storage-module-cache'; + +const s3ConfigsByBuild = new WeakMap< + StorageModuleCacheScope, + WeakMap +>(); + +/** + * Resolve the current runtime's S3 configuration without mutating the shared + * preset options. A preset may be reused by several Graphile builds, so + * memoizing the first getter result here would bind later builds to it. + */ +export function resolveS3Config( + options: PresignedUrlPluginOptions, + cacheScope: StorageModuleCacheScope, +): S3Config { + if (typeof options.s3 !== 'function') { + return options.s3; + } + + let configsForBuild = s3ConfigsByBuild.get(cacheScope); + if (!configsForBuild) { + configsForBuild = new WeakMap(); + s3ConfigsByBuild.set(cacheScope, configsForBuild); + } + + const cached = configsForBuild.get(options); + if (cached) { + return cached; + } + + const resolved = options.s3(); + configsForBuild.set(options, resolved); + return resolved; +} + +/** + * Mint a physical bucket name for a bucket that has never been provisioned. + * + * This deliberately matches graphile-bucket-provisioner-plugin's resolver + * contract: logical bucket key first, database ID second. Callers must persist + * the result and use the stored physical_name for every later operation. + */ +export function mintPhysicalBucketName( + options: PresignedUrlPluginOptions, + bucketKey: string, + databaseId: string, + cacheScope: StorageModuleCacheScope, +): string { + const base = resolveS3Config(options, cacheScope); + return options.resolveBucketName + ? options.resolveBucketName(bucketKey, databaseId) + : base.bucket; +} + +/** + * Build an S3 config for a persisted physical coordinate. + * + * No naming resolver is consulted here: physical_name is authoritative once + * recorded, even if naming policy or environment configuration later changes. + */ +export function resolveS3ConfigForPhysicalBucket( + options: PresignedUrlPluginOptions, + storageConfig: StorageModuleConfig, + physicalBucketName: string, + cacheScope: StorageModuleCacheScope, +): S3Config { + const base = resolveS3Config(options, cacheScope); + const publicUrlPrefix = storageConfig.publicUrlPrefix ?? base.publicUrlPrefix; + + if (physicalBucketName === base.bucket && publicUrlPrefix === base.publicUrlPrefix) { + return base; + } + + return { + ...base, + bucket: physicalBucketName, + ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}), + }; +} diff --git a/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts b/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts index 12e48b6781..76ebc94ce7 100644 --- a/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts +++ b/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts @@ -18,21 +18,62 @@ const DEFAULT_MAX_BULK_TOTAL_SIZE = 1073741824; // 1GB const FIVE_MINUTES_MS = 1000 * 60 * 5; const ONE_HOUR_MS = 1000 * 60 * 60; +type StorageCacheEntry = + | { kind: 'config'; value: StorageModuleConfig | null } + | { kind: 'list'; value: StorageModuleConfig[] }; + +const CACHE_TTL_MS = process.env.NODE_ENV === 'development' + ? FIVE_MINUTES_MS + : ONE_HOUR_MS; + /** - * LRU cache for per-database StorageModuleConfig. - * - * Each PostGraphile instance serves a single database, but the presigned URL - * plugin needs to know the generated table names (buckets, files) - * and their schemas. This cache avoids re-querying metaschema - * on every request. + * Metadata owned by one exact Graphile build. * - * Pattern: same as graphile-cache's LRU with TTL-based eviction. + * Logical database/module identifiers are deliberately only keys inside this + * scope. They never select the scope itself, because separate physical pools + * may legitimately expose identical identifiers. + */ +export class StorageModuleCacheScope { + readonly storageModuleCache = new LRUCache({ + max: 100, + ttl: CACHE_TTL_MS, + updateAgeOnGet: false, + }); + + readonly bucketCache = new LRUCache({ + max: 500, + ttl: CACHE_TTL_MS, + updateAgeOnGet: false, + }); + + readonly provisionedBuckets = new Set(); + + clear(): void { + this.storageModuleCache.clear(); + this.bucketCache.clear(); + this.provisionedBuckets.clear(); + } +} + +/** + * Weak ownership ties cached metadata to the exact Graphile build object. + * Reusing a preset/plugin object for another build therefore cannot reuse the + * first build's tenant metadata, and releasing the build releases its cache. */ -const storageModuleCache = new LRUCache({ - max: 50, - ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS, - updateAgeOnGet: true, -}); +const cacheScopesByBuild = new WeakMap(); + +export function getStorageModuleCacheScope(build: object): StorageModuleCacheScope { + if ((typeof build !== 'object' || build === null) && typeof build !== 'function') { + throw new TypeError('A Graphile build object is required for storage cache isolation'); + } + + let scope = cacheScopesByBuild.get(build); + if (!scope) { + scope = new StorageModuleCacheScope(); + cacheScopesByBuild.set(build, scope); + } + return scope; +} /** * SQL query to resolve the app-level storage module config for a database. @@ -45,11 +86,16 @@ const storageModuleCache = new LRUCache({ const APP_STORAGE_MODULE_QUERY = ` SELECT sm.id, + sm.database_id, sm.scope, sm.entity_table_id, + bt.database_id AS buckets_database_id, bs.schema_name AS buckets_schema, + bs.database_id AS buckets_schema_database_id, bt.name AS buckets_table, + ft.database_id AS files_database_id, fs.schema_name AS files_schema, + fs.database_id AS files_schema_database_id, ft.name AS files_table, sm.endpoint, sm.public_url_prefix, @@ -63,16 +109,26 @@ const APP_STORAGE_MODULE_QUERY = ` sm.max_bulk_files, sm.max_bulk_total_size, sm.has_path_shares, + NULL AS entity_database_id, + NULL AS entity_schema_database_id, NULL AS entity_schema, NULL AS entity_table FROM metaschema_modules_public.storage_module sm - JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id - JOIN metaschema_public.schema bs ON bs.id = bt.schema_id - JOIN metaschema_public.table ft ON ft.id = sm.files_table_id - JOIN metaschema_public.schema fs ON fs.id = ft.schema_id + JOIN metaschema_public.table bt + ON bt.id = sm.buckets_table_id + AND bt.database_id = sm.database_id + JOIN metaschema_public.schema bs + ON bs.id = bt.schema_id + AND bs.database_id = sm.database_id + JOIN metaschema_public.table ft + ON ft.id = sm.files_table_id + AND ft.database_id = sm.database_id + JOIN metaschema_public.schema fs + ON fs.id = ft.schema_id + AND fs.database_id = sm.database_id WHERE sm.database_id = $1 AND sm.scope = 'app' - LIMIT 1 + ORDER BY sm.id `; /** @@ -84,11 +140,16 @@ const APP_STORAGE_MODULE_QUERY = ` const ALL_STORAGE_MODULES_QUERY = ` SELECT sm.id, + sm.database_id, sm.scope, sm.entity_table_id, + bt.database_id AS buckets_database_id, bs.schema_name AS buckets_schema, + bs.database_id AS buckets_schema_database_id, bt.name AS buckets_table, + ft.database_id AS files_database_id, fs.schema_name AS files_schema, + fs.database_id AS files_schema_database_id, ft.name AS files_table, sm.endpoint, sm.public_url_prefix, @@ -102,25 +163,45 @@ const ALL_STORAGE_MODULES_QUERY = ` sm.max_bulk_files, sm.max_bulk_total_size, sm.has_path_shares, + et.database_id AS entity_database_id, + es.database_id AS entity_schema_database_id, es.schema_name AS entity_schema, et.name AS entity_table FROM metaschema_modules_public.storage_module sm - JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id - JOIN metaschema_public.schema bs ON bs.id = bt.schema_id - JOIN metaschema_public.table ft ON ft.id = sm.files_table_id - JOIN metaschema_public.schema fs ON fs.id = ft.schema_id - LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id - LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id + JOIN metaschema_public.table bt + ON bt.id = sm.buckets_table_id + AND bt.database_id = sm.database_id + JOIN metaschema_public.schema bs + ON bs.id = bt.schema_id + AND bs.database_id = sm.database_id + JOIN metaschema_public.table ft + ON ft.id = sm.files_table_id + AND ft.database_id = sm.database_id + JOIN metaschema_public.schema fs + ON fs.id = ft.schema_id + AND fs.database_id = sm.database_id + LEFT JOIN metaschema_public.table et + ON et.id = sm.entity_table_id + AND et.database_id = sm.database_id + LEFT JOIN metaschema_public.schema es + ON es.id = et.schema_id + AND es.database_id = sm.database_id WHERE sm.database_id = $1 + ORDER BY sm.scope, sm.id `; interface StorageModuleRow { id: string; + database_id: string; scope: string; entity_table_id: string | null; + buckets_database_id: string; buckets_schema: string; + buckets_schema_database_id: string; buckets_table: string; + files_database_id: string; files_schema: string; + files_schema_database_id: string; files_table: string; endpoint: string | null; public_url_prefix: string | null; @@ -134,6 +215,8 @@ interface StorageModuleRow { max_bulk_files: number | null; max_bulk_total_size: number | null; has_path_shares: boolean; + entity_database_id: string | null; + entity_schema_database_id: string | null; entity_schema: string | null; entity_table: string | null; } @@ -141,19 +224,82 @@ interface StorageModuleRow { /** * Build a StorageModuleConfig from a raw DB row. */ -function buildConfig(row: StorageModuleRow): StorageModuleConfig { +function quoteMetadataIdentifier(schema: string, objectName: string, label: string): string { + if ( + typeof schema !== 'string' || + schema.length === 0 || + schema.includes('\0') || + Buffer.byteLength(schema, 'utf8') > 63 || + typeof objectName !== 'string' || + objectName.length === 0 || + objectName.includes('\0') || + Buffer.byteLength(objectName, 'utf8') > 63 + ) { + throw new Error(`STORAGE_MODULE_METADATA_INVALID:${label}`); + } + return QuoteUtils.quoteQualifiedIdentifier(schema, objectName); +} + +function buildConfig(row: StorageModuleRow, databaseId: string): StorageModuleConfig { + const objectDatabaseIds = [ + row.database_id, + row.buckets_database_id, + row.buckets_schema_database_id, + row.files_database_id, + row.files_schema_database_id, + ]; + if (objectDatabaseIds.some((id) => id !== databaseId)) { + throw new Error(`STORAGE_MODULE_CROSS_DATABASE_METADATA:${row.id}`); + } + if ( + typeof row.id !== 'string' || + row.id.length === 0 || + typeof row.scope !== 'string' || + row.scope.length === 0 + ) { + throw new Error('STORAGE_MODULE_METADATA_INVALID'); + } + + if (row.entity_table_id === null) { + if ( + row.scope !== 'app' || + row.entity_database_id !== null || + row.entity_schema_database_id !== null || + row.entity_schema !== null || + row.entity_table !== null + ) { + throw new Error(`STORAGE_MODULE_METADATA_INVALID:${row.id}`); + } + } else if ( + row.scope === 'app' || + row.entity_database_id !== databaseId || + row.entity_schema_database_id !== databaseId || + !row.entity_schema || + !row.entity_table + ) { + throw new Error(`STORAGE_MODULE_CROSS_DATABASE_METADATA:${row.id}`); + } + const cacheTtlSeconds = row.cache_ttl_seconds ?? DEFAULT_CACHE_TTL_SECONDS; return { id: row.id, - bucketsQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.buckets_schema, row.buckets_table), - filesQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.files_schema, row.files_table), + bucketsQualifiedName: quoteMetadataIdentifier( + row.buckets_schema, + row.buckets_table, + `buckets:${row.id}`, + ), + filesQualifiedName: quoteMetadataIdentifier( + row.files_schema, + row.files_table, + `files:${row.id}`, + ), schemaName: row.buckets_schema, bucketsTableName: row.buckets_table, filesTableName: row.files_table, scope: row.scope, entityTableId: row.entity_table_id, entityQualifiedName: row.entity_schema && row.entity_table - ? QuoteUtils.quoteQualifiedIdentifier(row.entity_schema, row.entity_table) + ? quoteMetadataIdentifier(row.entity_schema, row.entity_table, `entity:${row.id}`) : null, endpoint: row.endpoint, publicUrlPrefix: row.public_url_prefix, @@ -170,6 +316,28 @@ function buildConfig(row: StorageModuleRow): StorageModuleConfig { }; } +function assertUnambiguousModules(configs: readonly StorageModuleConfig[]): void { + const ids = new Set(); + const scopes = new Set(); + const buckets = new Set(); + const files = new Set(); + + for (const config of configs) { + if ( + ids.has(config.id) || + scopes.has(config.scope) || + buckets.has(config.bucketsQualifiedName) || + files.has(config.filesQualifiedName) + ) { + throw new Error('STORAGE_MODULE_METADATA_AMBIGUOUS'); + } + ids.add(config.id); + scopes.add(config.scope); + buckets.add(config.bucketsQualifiedName); + files.add(config.filesQualifiedName); + } +} + /** * Resolve the app-level storage module config for a database, using the LRU cache. * @@ -183,11 +351,16 @@ function buildConfig(row: StorageModuleRow): StorageModuleConfig { export async function getStorageModuleConfig( pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> }, databaseId: string, + cacheScope: StorageModuleCacheScope, ): Promise { + const { storageModuleCache } = cacheScope; const cacheKey = `storage:${databaseId}:app`; - const cached = storageModuleCache.get(cacheKey); - if (cached) { - return cached; + if (storageModuleCache.has(cacheKey)) { + const cached = storageModuleCache.get(cacheKey); + if (cached?.kind !== 'config') { + throw new Error('STORAGE_CACHE_INTEGRITY_ERROR'); + } + return cached.value; } log.debug(`Cache miss for app-level storage in database ${databaseId}, querying metaschema...`); @@ -196,11 +369,15 @@ export async function getStorageModuleConfig( if (result.rows.length === 0) { log.warn(`No app-level storage module found for database ${databaseId}`); + storageModuleCache.set(cacheKey, { kind: 'config', value: null }); return null; } + if (result.rows.length !== 1) { + throw new Error('STORAGE_MODULE_METADATA_AMBIGUOUS:app'); + } - const config = buildConfig(result.rows[0] as StorageModuleRow); - storageModuleCache.set(cacheKey, config); + const config = buildConfig(result.rows[0] as StorageModuleRow, databaseId); + storageModuleCache.set(cacheKey, { kind: 'config', value: config }); log.debug(`Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}`); return config; @@ -225,57 +402,35 @@ export async function getStorageModuleConfigForOwner( pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> }, databaseId: string, ownerId: string, + cacheScope: StorageModuleCacheScope, ): Promise { - // Check if we already have a cached mapping for this ownerId - const ownerCacheKey = `storage:${databaseId}:owner:${ownerId}`; - const cachedOwner = storageModuleCache.get(ownerCacheKey); - if (cachedOwner) { - return cachedOwner; - } + const allConfigs = await loadAllStorageModules(pgClient, databaseId, cacheScope); - // Load all storage modules for this database - const allModulesCacheKey = `storage:${databaseId}:all`; - let allConfigs: StorageModuleConfig[]; - const cachedAll = storageModuleCache.get(allModulesCacheKey); - if (cachedAll) { - // We stored a sentinel; re-derive from individual caches - // Actually, let's just query fresh — this is the cache-miss path - allConfigs = []; - } else { - allConfigs = []; - } - - if (allConfigs.length === 0) { - log.debug(`Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}`); - const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] }); - allConfigs = (result.rows as StorageModuleRow[]).map(buildConfig); - - // Cache each individual config by its scope - for (const config of allConfigs) { - const key = `storage:${databaseId}:scope:${config.scope}`; - storageModuleCache.set(key, config); - } - } - - // Find entity-scoped modules and probe their entity tables for the ownerId + // The module list is build-local configuration, but owner visibility is + // request/RLS-specific. Always probe it under the current pgClient instead + // of caching one principal's authorization decision for another principal. const entityModules = allConfigs.filter((c) => c.entityQualifiedName !== null); + const matches: StorageModuleConfig[] = []; for (const mod of entityModules) { const probeResult = await pgClient.query({ text: `SELECT 1 FROM ${mod.entityQualifiedName} WHERE id = $1 LIMIT 1`, values: [ownerId], }); if (probeResult.rows.length > 0) { - // Found the matching module — cache the ownerId→module mapping - storageModuleCache.set(ownerCacheKey, mod); log.debug( `Resolved ownerId ${ownerId} to storage module ${mod.id} ` + `(scope=${mod.scope}, table=${mod.bucketsQualifiedName})`, ); - return mod; + matches.push(mod); } } + if (matches.length > 1) { + throw new Error('STORAGE_MODULE_AMBIGUOUS:owner'); + } + if (matches.length === 1) return matches[0]; + log.warn(`No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}`); return null; } @@ -299,10 +454,15 @@ export async function resolveStorageModuleByFileId( log.debug(`Resolving file ${fileId} across all storage modules for database ${databaseId}`); const allConfigs = (await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] })).rows.map( - (row: unknown) => buildConfig(row as StorageModuleRow), + (row: unknown) => buildConfig(row as StorageModuleRow, databaseId), ); + assertUnambiguousModules(allConfigs); // Probe each module's files table for the fileId + const matches: Array<{ + storageConfig: StorageModuleConfig; + file: { id: string; key: string; mime_type: string; bucket_id: string }; + }> = []; for (const config of allConfigs) { const fileResult = await pgClient.query({ text: `SELECT id, key, mime_type, bucket_id @@ -313,10 +473,15 @@ export async function resolveStorageModuleByFileId( }); if (fileResult.rows.length > 0) { const file = fileResult.rows[0] as { id: string; key: string; mime_type: string; bucket_id: string }; - return { storageConfig: config, file }; + matches.push({ storageConfig: config, file }); } } + if (matches.length > 1) { + throw new Error('STORAGE_MODULE_AMBIGUOUS:file'); + } + if (matches.length === 1) return matches[0]; + return null; } @@ -329,28 +494,27 @@ export async function resolveStorageModuleByFileId( export async function loadAllStorageModules( pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> }, databaseId: string, + cacheScope: StorageModuleCacheScope, ): Promise { + const { storageModuleCache } = cacheScope; const cacheKey = `storage:${databaseId}:all-list`; - const cached = storageModuleCache.get(cacheKey); - if (cached) { - return (cached as any)._allConfigs as StorageModuleConfig[]; + if (storageModuleCache.has(cacheKey)) { + const cached = storageModuleCache.get(cacheKey); + if (cached?.kind !== 'list') { + throw new Error('STORAGE_CACHE_INTEGRITY_ERROR'); + } + return cached.value; } log.debug(`Loading all storage modules for database ${databaseId}`); const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] }); - const configs = (result.rows as StorageModuleRow[]).map(buildConfig); - - // Cache each individual config by its scope - for (const config of configs) { - const key = `storage:${databaseId}:scope:${config.scope}`; - storageModuleCache.set(key, config); - } - - // Store the full list under a sentinel key (only if non-empty to avoid caching failed lookups) - if (configs.length > 0) { - const sentinel = { ...configs[0], _allConfigs: configs } as any; - storageModuleCache.set(cacheKey, sentinel); - } + const configs = (result.rows as StorageModuleRow[]).map((row) => + buildConfig(row, databaseId), + ); + assertUnambiguousModules(configs); + // Empty results are intentional negative cache entries. Query failures are + // never cached, so a transient control-plane error cannot become a miss. + storageModuleCache.set(cacheKey, { kind: 'list', value: configs }); return configs; } @@ -367,17 +531,21 @@ export async function loadAllStorageModules( */ export function resolveStorageConfigFromCodec( pgCodec: { name: string; extensions?: { pg?: { schemaName?: string; name?: string } }; sqlType?: string }, - allConfigs: StorageModuleConfig[], + allConfigs: readonly StorageModuleConfig[], ): StorageModuleConfig | null { const schemaName = pgCodec.extensions?.pg?.schemaName; const tableName = pgCodec.extensions?.pg?.name ?? pgCodec.name; if (!schemaName || !tableName) return null; - return allConfigs.find((c) => + const matches = allConfigs.filter((c) => (c.filesTableName === tableName && c.schemaName === schemaName) || (c.bucketsTableName === tableName && c.schemaName === schemaName), - ) || null; + ); + if (matches.length > 1) { + throw new Error('STORAGE_MODULE_AMBIGUOUS:codec'); + } + return matches[0] ?? null; } // --- Bucket metadata cache --- @@ -386,21 +554,13 @@ export function resolveStorageConfigFromCodec( * LRU cache for per-database bucket metadata. * * Buckets are essentially static config — created once and rarely changed. - * Caching avoids a DB query on every requestUploadUrl call. The bucket - * lookup in the plugin runs under RLS, but since AuthzEntityMembership - * grants all org members access to all org buckets, and the cached data - * is just config (mime types, size limits), bypassing RLS on cache hits - * is safe. The important RLS is on the files table (INSERT/UPDATE), - * which is never cached. + * Cache hits still execute an exact ID lookup through the request's RLS + * context. The cached metadata is returned only when that authorized ID + * matches, so cached data never substitutes for row authorization. * - * Keys: `bucket:${databaseId}:${storageModuleId}:${bucketKey}` - * TTL: same as storage module cache (5min dev / 1hr prod) + * Keys are local to the exact build scope; database/module identifiers never + * select cache entries belonging to another physical Graphile build. */ -const bucketCache = new LRUCache({ - max: 500, // many buckets across many databases - ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS, - updateAgeOnGet: true, -}); /** * Normalize the recorded physical coordinate at the DB boundary. @@ -432,36 +592,71 @@ export async function getBucketConfig( storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string, - ownerId?: string, + ownerId: string | undefined, + cacheScope: StorageModuleCacheScope, ): Promise { + const { bucketCache } = cacheScope; const cacheKey = `bucket:${databaseId}:${storageConfig.id}:${bucketKey}${ownerId ? `:${ownerId}` : ''}`; - const cached = bucketCache.get(cacheKey); - if (cached) { - return cached; + // Entity-scoped buckets use (owner_id, key) composite lookup; + // app-level buckets just use key. + const isEntityScoped = storageConfig.scope !== 'app'; + if (isEntityScoped && !ownerId) { + throw new Error('STORAGE_OWNER_REQUIRED'); + } + const hasOwner = Boolean(ownerId && isEntityScoped); + const whereSql = hasOwner + ? 'key = $1 AND owner_id = $2' + : 'key = $1'; + const values = hasOwner ? [bucketKey, ownerId] : [bucketKey]; + + if (bucketCache.has(cacheKey)) { + // This query runs with the current request's pgSettings/RLS context. Do + // not return even immutable cached metadata without reauthorizing it. + const authorized = await pgClient.query({ + text: `SELECT id + FROM ${storageConfig.bucketsQualifiedName} + WHERE ${whereSql} + LIMIT 2`, + values, + }); + const authorizedId = (authorized.rows[0] as { id?: string } | undefined)?.id; + if (authorized.rows.length > 1) { + throw new Error('STORAGE_BUCKET_AMBIGUOUS'); + } + if (!authorizedId) { + return null; + } + + const cached = bucketCache.get(cacheKey); + if (cached?.id === authorizedId) { + return cached; + } + // A formerly missing bucket may now exist, or a bucket may have been + // replaced under the same key. Reload its immutable metadata below. } log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...`); - // Entity-scoped buckets use (owner_id, key) composite lookup; - // app-level buckets just use key. - const isEntityScoped = storageConfig.scope !== 'app'; - const hasOwner = ownerId && isEntityScoped; const result = await pgClient.query({ text: hasOwner ? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys, physical_name FROM ${storageConfig.bucketsQualifiedName} - WHERE key = $1 AND owner_id = $2 - LIMIT 1` + WHERE ${whereSql} + LIMIT 2` : `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys, physical_name FROM ${storageConfig.bucketsQualifiedName} - WHERE key = $1 - LIMIT 1`, - values: hasOwner ? [bucketKey, ownerId] : [bucketKey], + WHERE ${whereSql} + LIMIT 2`, + values, }); if (result.rows.length === 0) { + bucketCache.set(cacheKey, null); return null; } + if (result.rows.length > 1) { + throw new Error('STORAGE_BUCKET_AMBIGUOUS'); + } const row = result.rows[0] as { id: string; @@ -508,20 +703,24 @@ export async function getBucketConfig( * The set resets on server restart, which is fine because the * provisioner's createBucket is idempotent (handles "already exists"). */ -const provisionedBuckets = new Set(); - /** * Check whether an S3 bucket has already been provisioned (cached). */ -export function isS3BucketProvisioned(s3BucketName: string): boolean { - return provisionedBuckets.has(s3BucketName); +export function isS3BucketProvisioned( + s3BucketName: string, + cacheScope: StorageModuleCacheScope, +): boolean { + return cacheScope.provisionedBuckets.has(s3BucketName); } /** * Mark an S3 bucket as provisioned in the in-memory cache. */ -export function markS3BucketProvisioned(s3BucketName: string): void { - provisionedBuckets.add(s3BucketName); +export function markS3BucketProvisioned( + s3BucketName: string, + cacheScope: StorageModuleCacheScope, +): void { + cacheScope.provisionedBuckets.add(s3BucketName); log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`); } @@ -529,17 +728,19 @@ export function markS3BucketProvisioned(s3BucketName: string): void { * Clear the storage module cache AND bucket cache. * Useful for testing or schema changes. */ -export function clearStorageModuleCache(): void { - storageModuleCache.clear(); - bucketCache.clear(); - provisionedBuckets.clear(); +export function clearStorageModuleCache(cacheScope: StorageModuleCacheScope): void { + cacheScope.clear(); } /** * Clear cached bucket entries for a specific database. * Useful when bucket config changes are detected. */ -export function clearBucketCache(databaseId?: string): void { +export function clearBucketCache( + databaseId: string | undefined, + cacheScope: StorageModuleCacheScope, +): void { + const { bucketCache } = cacheScope; if (!databaseId) { bucketCache.clear(); return; diff --git a/graphile/graphile-presigned-url-plugin/src/storage-module-source.ts b/graphile/graphile-presigned-url-plugin/src/storage-module-source.ts new file mode 100644 index 0000000000..cd5ce25023 --- /dev/null +++ b/graphile/graphile-presigned-url-plugin/src/storage-module-source.ts @@ -0,0 +1,185 @@ +import { QuoteUtils } from '@pgsql/quotes'; + +import type { StorageModuleConfig } from './types'; +import { + loadAllStorageModules, + type StorageModuleCacheScope, +} from './storage-module-cache'; + +export type StoragePgClient = { + query: (opts: { + text: string; + values?: unknown[]; + }) => Promise<{ rows: any[] }>; +}; + +export type StorageWithPgClient = ( + pgSettings: unknown, + callback: (pgClient: StoragePgClient) => Promise | T, +) => Promise; + +export function assertStorageRequestContext( + withPgClient: StorageWithPgClient | null | undefined, + pgSettings: unknown, +): asserts withPgClient is StorageWithPgClient { + if (typeof withPgClient !== 'function') { + throw new Error('STORAGE_CONTEXT_UNAVAILABLE'); + } + if (typeof pgSettings !== 'object' || pgSettings === null || Array.isArray(pgSettings)) { + throw new Error('STORAGE_REQUEST_SETTINGS_UNAVAILABLE'); + } +} + +export type PreloadedStorageModules = readonly StorageModuleConfig[] | undefined; + +const QUALIFIED_IDENTIFIER = /^("(?:[^"]|"")+"|[a-z_][a-z0-9_$]*)\.("(?:[^"]|"")+"|[a-z_][a-z0-9_$]*)$/; + +function decodeIdentifier(identifier: string): string { + return identifier.startsWith('"') + ? identifier.slice(1, -1).replace(/""/g, '"') + : identifier; +} + +function normalizeQualifiedIdentifier( + value: string, + label: string, +): { schema: string; objectName: string; sql: string } { + const match = QUALIFIED_IDENTIFIER.exec(value); + if (!match) throw new Error(`STORAGE_MODULE_METADATA_INVALID:${label}`); + + const schema = decodeIdentifier(match[1]); + const objectName = decodeIdentifier(match[2]); + if ( + schema.length === 0 || + objectName.length === 0 || + schema.includes('\0') || + objectName.includes('\0') || + Buffer.byteLength(schema, 'utf8') > 63 || + Buffer.byteLength(objectName, 'utf8') > 63 + ) { + throw new Error(`STORAGE_MODULE_METADATA_INVALID:${label}`); + } + return { + schema, + objectName, + sql: QuoteUtils.quoteQualifiedIdentifier(schema, objectName), + }; +} + +/** + * Capture an immutable per-build snapshot. `undefined` deliberately remains + * distinct from an empty list: only the former enables the generic SQL path. + */ +export function snapshotPreloadedStorageModules( + modules: readonly StorageModuleConfig[] | undefined, +): PreloadedStorageModules { + if (modules === undefined) { + return undefined; + } + + const ids = new Set(); + const scopes = new Set(); + const buckets = new Set(); + const files = new Set(); + const normalized = modules.map((module) => { + if ( + !module || + typeof module.id !== 'string' || + module.id.length === 0 || + typeof module.scope !== 'string' || + module.scope.length === 0 || + typeof module.schemaName !== 'string' || + typeof module.bucketsTableName !== 'string' || + typeof module.filesTableName !== 'string' + ) { + throw new Error('STORAGE_MODULE_METADATA_INVALID'); + } + + const bucketName = normalizeQualifiedIdentifier( + module.bucketsQualifiedName, + `buckets:${module.id}`, + ); + const fileName = normalizeQualifiedIdentifier( + module.filesQualifiedName, + `files:${module.id}`, + ); + if ( + bucketName.schema !== module.schemaName || + bucketName.objectName !== module.bucketsTableName || + fileName.schema !== module.schemaName || + fileName.objectName !== module.filesTableName + ) { + throw new Error(`STORAGE_MODULE_METADATA_INCONSISTENT:${module.id}`); + } + + let entityQualifiedName: string | null = null; + if (module.scope === 'app') { + if (module.entityTableId !== null || module.entityQualifiedName !== null) { + throw new Error(`STORAGE_MODULE_METADATA_INVALID:${module.id}`); + } + } else { + if (!module.entityTableId || !module.entityQualifiedName) { + throw new Error(`STORAGE_MODULE_METADATA_INVALID:${module.id}`); + } + entityQualifiedName = normalizeQualifiedIdentifier( + module.entityQualifiedName, + `entity:${module.id}`, + ).sql; + } + + if ( + ids.has(module.id) || + scopes.has(module.scope) || + buckets.has(bucketName.sql) || + files.has(fileName.sql) + ) { + throw new Error('STORAGE_MODULE_METADATA_AMBIGUOUS'); + } + ids.add(module.id); + scopes.add(module.scope); + buckets.add(bucketName.sql); + files.add(fileName.sql); + + return Object.freeze({ + ...module, + bucketsQualifiedName: bucketName.sql, + filesQualifiedName: fileName.sql, + entityQualifiedName, + allowedOrigins: module.allowedOrigins + ? Object.freeze([...module.allowedOrigins]) + : null, + }) as StorageModuleConfig; + }); + + const unchanged = Object.isFrozen(modules) && modules.every((module, index) => + Object.isFrozen(module) && + (module.allowedOrigins === null || Object.isFrozen(module.allowedOrigins)) && + module.bucketsQualifiedName === normalized[index].bucketsQualifiedName && + module.filesQualifiedName === normalized[index].filesQualifiedName && + module.entityQualifiedName === normalized[index].entityQualifiedName, + ); + return unchanged ? modules : Object.freeze(normalized); +} + +/** + * Preloaded configuration is authoritative and never acquires a metadata + * client. The database branch exists only for generic package consumers that + * did not supply a control-plane snapshot; because callers reach this helper + * from request execution, that fallback must carry the exact request settings. + */ +export async function loadStorageModulesForBuild( + preloaded: PreloadedStorageModules, + withPgClient: StorageWithPgClient, + pgSettings: unknown, + databaseId: string, + cacheScope: StorageModuleCacheScope, +): Promise { + if (preloaded !== undefined) { + return preloaded; + } + + assertStorageRequestContext(withPgClient, pgSettings); + return withPgClient(pgSettings, (pgClient) => + loadAllStorageModules(pgClient, databaseId, cacheScope), + ); +} diff --git a/graphile/graphile-presigned-url-plugin/src/types.ts b/graphile/graphile-presigned-url-plugin/src/types.ts index 79fe686af1..2adc3182d8 100644 --- a/graphile/graphile-presigned-url-plugin/src/types.ts +++ b/graphile/graphile-presigned-url-plugin/src/types.ts @@ -149,24 +149,25 @@ export interface S3Config { /** * S3 configuration or a lazy getter that returns it on first use. - * When a function is provided, it will only be called when the first - * mutation or resolver actually needs the S3 client — avoiding eager - * env-var reads and S3Client creation at module import time. + * When a function is provided, it is called lazily once per exact Graphile + * build — avoiding eager env-var reads while keeping credentials isolated + * when a shared preset is used for multiple physical pools. */ export type S3ConfigOrGetter = S3Config | (() => S3Config); /** - * Function to derive the actual S3 bucket name for a given database and bucket key. + * Function to derive the actual S3 bucket name for a logical bucket on its + * first provision. The returned name is persisted as physical_name and is not + * recomputed for later operations. * - * When provided, the presigned URL plugin calls this on every request - * to determine which S3 bucket to use — enabling per-(database, bucketKey) - * isolation. If not provided, falls back to `s3Config.bucket` (global). + * When provided, the presigned URL plugin calls this only when physical_name + * is absent. If not provided, first provision uses `s3Config.bucket`. * - * @param databaseId - The metaschema database UUID * @param bucketKey - The logical bucket key (e.g., "public", "private") + * @param databaseId - The metaschema database UUID * @returns The S3 bucket name for this database + bucket key */ -export type BucketNameResolver = (databaseId: string, bucketKey: string) => string; +export type BucketNameResolver = (bucketKey: string, databaseId: string) => string; /** * Callback to lazily provision an S3 bucket on first use. @@ -196,6 +197,16 @@ export interface PresignedUrlPluginOptions { /** S3 configuration (concrete or lazy getter) */ s3: S3ConfigOrGetter; + /** + * Storage-module configuration resolved by the control plane for this exact + * Graphile build. Supplying this option, including an empty array, disables + * runtime metaschema SQL; the plugin treats an immutable snapshot of this + * list as authoritative for the build. + * + * Omit only for generic integrations that need the legacy database lookup. + */ + preloadedStorageModules?: readonly StorageModuleConfig[]; + /** * Optional function to resolve S3 bucket name per-database. * When set, each database gets its own S3 bucket instead of sharing diff --git a/graphile/graphile-search/package.json b/graphile/graphile-search/package.json index 59e743a856..8d2dbaa793 100644 --- a/graphile/graphile-search/package.json +++ b/graphile/graphile-search/package.json @@ -29,6 +29,7 @@ "url": "https://github.com/constructive-io/constructive/issues" }, "dependencies": { + "@pgsql/quotes": "^18.1.0", "graphile-plugin-utils": "workspace:^" }, "devDependencies": { diff --git a/graphile/graphile-search/src/__tests__/bm25-codec.test.ts b/graphile/graphile-search/src/__tests__/bm25-codec.test.ts new file mode 100644 index 0000000000..ad49436a45 --- /dev/null +++ b/graphile/graphile-search/src/__tests__/bm25-codec.test.ts @@ -0,0 +1,353 @@ +import { createBm25Adapter } from '../adapters/bm25'; +import { collectBm25Indexes } from '../codecs/bm25-codec'; + +interface MockIndexOptions { + schema: string; + classId: string; + table?: string; + column?: string; + attributeNumber?: number; + index: string; + accessMethod?: string; + valid?: boolean | null; + ready?: boolean | null; + live?: boolean | null; +} + +const mockIndex = ({ + schema, + classId, + table = 'documents', + column = 'body', + attributeNumber = 2, + index, + accessMethod = 'bm25', + valid = true, + ready = true, + live = true +}: MockIndexOptions) => { + const namespace = { nspname: schema }; + const tableClass = { + _id: classId, + relname: table, + relnamespace: schema, + getNamespace: () => namespace + }; + const indexClass = { + relname: index, + getAccessMethod: () => ({ amname: accessMethod }) + }; + const attribute = { attnum: attributeNumber, attname: column }; + return { + indisvalid: valid, + indisready: ready, + indislive: live, + indnkeyatts: 1, + indkey: [attributeNumber], + getIndexClass: () => indexClass, + getClass: () => tableClass, + getKeys: () => [attribute] + }; +}; + +const introspectionWith = (...indexes: ReturnType[]) => ({ + indexes, + extensions: [{ extname: 'pg_textsearch', extnamespace: '900' }], + getNamespace: ({ id }: { id: string }) => id === '900' + ? { _id: '900', nspname: 'extension_tools' } + : { _id: id, nspname: id } +} as any); + +describe('BM25 introspection binding', () => { + it('keeps identical tenant table names isolated by the configured schema', () => { + const introspection = introspectionWith( + mockIndex({ + schema: 'tenant_a', + classId: '100', + index: 'tenant_a_documents_body_bm25_idx' + }), + mockIndex({ + schema: 'tenant_b', + classId: '200', + index: 'tenant_b_documents_body_bm25_idx' + }) + ); + + const tenantA = collectBm25Indexes(introspection, ['tenant_a'], 'service_a'); + const tenantB = collectBm25Indexes(introspection, ['tenant_b'], 'service_b'); + + expect([...tenantA.values()]).toEqual([ + { + serviceName: 'service_a', + extensionSchema: 'extension_tools', + schemaName: 'tenant_a', + tableName: 'documents', + columnName: 'body', + indexName: 'tenant_a_documents_body_bm25_idx' + } + ]); + expect([...tenantB.values()]).toEqual([ + { + serviceName: 'service_b', + extensionSchema: 'extension_tools', + schemaName: 'tenant_b', + tableName: 'documents', + columnName: 'body', + indexName: 'tenant_b_documents_body_bm25_idx' + } + ]); + }); + + it('does not retain indexes across rebuilds', () => { + const first = collectBm25Indexes( + introspectionWith( + mockIndex({ schema: 'tenant_a', classId: '100', index: 'first_idx' }) + ), + ['tenant_a'], + 'main' + ); + const rebuilt = collectBm25Indexes(introspectionWith(), ['tenant_a'], 'main'); + + expect(first.size).toBe(1); + expect(rebuilt.size).toBe(0); + }); + + it('skips invalid and non-BM25 indexes', () => { + const result = collectBm25Indexes( + introspectionWith( + mockIndex({ + schema: 'tenant_a', + classId: '100', + index: 'invalid_idx', + valid: false + }), + mockIndex({ + schema: 'tenant_a', + classId: '100', + index: 'unknown_state_idx', + live: null + }), + mockIndex({ + schema: 'tenant_a', + classId: '100', + index: 'btree_idx', + accessMethod: 'btree' + }) + ), + ['tenant_a'], + 'main' + ); + + expect(result.size).toBe(0); + }); + + it('fails deterministically when two BM25 indexes target one attribute', () => { + const introspection = introspectionWith( + mockIndex({ schema: 'tenant_a', classId: '100', index: 'z_idx' }), + mockIndex({ schema: 'tenant_a', classId: '100', index: 'a_idx' }) + ); + + expect(() => collectBm25Indexes(introspection, ['tenant_a'], 'main')).toThrow( + 'Multiple BM25 indexes target tenant_a.documents.body: a_idx, z_idx' + ); + }); + + it('lets the adapter consume metadata bound to the exact codec attribute', () => { + const adapter = createBm25Adapter(); + const codec = { + attributes: { + body: { + codec: { name: 'text' }, + extensions: { + bm25Index: { + serviceName: 'main', + extensionSchema: 'extension_tools', + schemaName: 'tenant_a', + tableName: 'documents', + columnName: 'body', + indexName: 'tenant_a_documents_body_bm25_idx' + } + } + } + } + }; + + expect(adapter.detectColumns(codec, {})).toEqual([ + { + attributeName: 'body', + adapterData: { + bm25Index: codec.attributes.body.extensions.bm25Index, + chunksInfo: undefined + } + } + ]); + }); + + it('binds chunk search to its introspected physical index name', () => { + const adapter = createBm25Adapter(); + const parentIndex = { + serviceName: 'service_a', + extensionSchema: 'extension_tools', + schemaName: 'tenant_a', + tableName: 'documents', + columnName: 'body', + indexName: 'parent_idx' + }; + const chunkIndex = { + serviceName: 'service_a', + extensionSchema: 'extension_tools', + schemaName: 'tenant_a', + tableName: 'document_chunks', + columnName: 'content', + indexName: 'nonconventional_exact_chunk_idx' + }; + const parentCodec = { + attributes: { + id: { codec: { name: 'uuid' } }, + body: { + codec: { name: 'text' }, + extensions: { bm25Index: parentIndex } + } + }, + extensions: { + pg: { serviceName: 'service_a', schemaName: 'tenant_a', name: 'documents' }, + tags: { + hasChunks: { + chunksTable: 'document_chunks', + contentField: 'content', + searchIndexes: ['bm25'] + } + } + } + }; + const chunksCodec = { + attributes: { + parent_id: {}, + embedding: {}, + content: { extensions: { bm25Index: chunkIndex } } + }, + extensions: { + pg: { + serviceName: 'service_a', + schemaName: 'tenant_a', + name: 'document_chunks' + } + } + }; + const build = { + input: { + pgRegistry: { + pgCodecs: { + wrongServiceChunks: { + attributes: { + content: { + extensions: { + bm25Index: { + ...chunkIndex, + serviceName: 'service_b', + indexName: 'wrong_service_idx' + } + } + } + } + }, + chunks: chunksCodec + }, + pgResources: { chunks: { codec: chunksCodec } } + } + }, + resolvedPreset: { + pgServices: [{ name: 'service_a', schemas: ['tenant_a'] }] + } + }; + + const [column] = adapter.detectColumns(parentCodec, build); + expect(column.adapterData).toEqual({ + bm25Index: parentIndex, + chunksInfo: { + chunksSchema: 'tenant_a', + chunksTableName: 'document_chunks', + parentFkField: 'parent_id', + parentPkField: 'id', + embeddingField: 'embedding', + contentField: 'content', + searchField: null, + searchIndexes: ['bm25'] + }, + chunkBm25Index: chunkIndex + }); + + const sql = Object.assign( + (strings: TemplateStringsArray, ...values: any[]) => + strings.reduce((text, part, index) => text + part + (values[index] ?? ''), ''), + { + identifier: (...names: string[]) => names.map((name) => `"${name}"`).join('.'), + value: (value: any) => `'${value}'` + } + ); + const result = adapter.buildFilterApply( + sql, + 'docs' as any, + column, + { query: 'tenant memory' }, + build + ); + expect(String(result?.scoreExpression)).toContain( + 'tenant_a.nonconventional_exact_chunk_idx' + ); + expect(String(result?.scoreExpression)).toContain( + 'OPERATOR("extension_tools".<@>)' + ); + expect(String(result?.scoreExpression)).not.toContain('wrong_service_idx'); + }); + + it('fails closed when chunk BM25 metadata cannot be bound', () => { + const adapter = createBm25Adapter(); + const codec = { + attributes: { + id: { codec: { name: 'uuid' } }, + body: { + codec: { name: 'text' }, + extensions: { + bm25Index: { + serviceName: 'main', + extensionSchema: 'extension_tools', + schemaName: 'tenant_a', + tableName: 'documents', + columnName: 'body', + indexName: 'parent_idx' + } + } + } + }, + extensions: { + pg: { serviceName: 'main', schemaName: 'tenant_a', name: 'documents' }, + tags: { + hasChunks: { + chunksTable: 'document_chunks', + searchIndexes: ['bm25'] + } + } + } + }; + const chunksCodec = { + attributes: { parent_id: {}, embedding: {}, content: {} }, + extensions: { + pg: { serviceName: 'main', schemaName: 'tenant_a', name: 'document_chunks' } + } + }; + + expect(() => adapter.detectColumns(codec, { + input: { + pgRegistry: { + pgCodecs: { chunks: chunksCodec }, + pgResources: { chunks: { codec: chunksCodec } } + } + }, + resolvedPreset: { pgServices: [{ name: 'main', schemas: ['tenant_a'] }] } + })).toThrow( + 'BM25 chunk search could not bind an introspected index for ' + + 'tenant_a.document_chunks.content' + ); + }); +}); diff --git a/graphile/graphile-search/src/__tests__/chunks-isolation.test.ts b/graphile/graphile-search/src/__tests__/chunks-isolation.test.ts new file mode 100644 index 0000000000..e4e38fab02 --- /dev/null +++ b/graphile/graphile-search/src/__tests__/chunks-isolation.test.ts @@ -0,0 +1,92 @@ +import { getChunksInfo } from '../adapters/chunks'; + +function fixture(overrides: { + chunksSchema?: string; + duplicate?: boolean; + dependencySchemas?: string[]; +} = {}) { + const parentCodec = { + name: 'documents', + attributes: { id: {} }, + extensions: { + pg: { serviceName: 'main', schemaName: 'tenant_a', name: 'documents' }, + tags: { + hasChunks: { + chunksSchema: overrides.chunksSchema ?? 'tenant_a', + chunksTable: 'documents_chunks', + parentFk: 'document_id', + }, + }, + }, + }; + const chunkCodec = { + name: 'documentsChunks', + attributes: { + document_id: {}, + embedding: {}, + content: {}, + }, + extensions: { + pg: { + serviceName: 'main', + schemaName: overrides.chunksSchema ?? 'tenant_a', + name: 'documents_chunks', + }, + }, + }; + const resource = { codec: chunkCodec }; + return { + parentCodec, + build: { + input: { + pgRegistry: { + pgResources: overrides.duplicate + ? { first: resource, second: { codec: chunkCodec } } + : { chunks: resource }, + }, + }, + resolvedPreset: { + pgServices: [{ + name: 'main', + schemas: ['tenant_a'], + introspectionAllowedDependencySchemas: overrides.dependencySchemas ?? [], + }], + }, + }, + }; +} + +describe('@hasChunks exact-build isolation', () => { + it('resolves one exact resource inside the service schema allowlist', () => { + const { parentCodec, build } = fixture(); + expect(getChunksInfo(parentCodec, build)).toMatchObject({ + chunksSchema: 'tenant_a', + chunksTableName: 'documents_chunks', + parentFkField: 'document_id', + }); + }); + + it('rejects a cross-schema resource even when it exists in the registry', () => { + const { parentCodec, build } = fixture({ chunksSchema: 'tenant_b' }); + expect(() => getChunksInfo(parentCodec, build)).toThrow(/outside service 'main'/); + }); + + it('accepts a resource only when its dependency schema is explicitly allowlisted', () => { + const { parentCodec, build } = fixture({ + chunksSchema: 'tenant_chunks', + dependencySchemas: ['tenant_chunks'], + }); + expect(getChunksInfo(parentCodec, build)?.chunksSchema).toBe('tenant_chunks'); + }); + + it('rejects ambiguous exact resource matches', () => { + const { parentCodec, build } = fixture({ duplicate: true }); + expect(() => getChunksInfo(parentCodec, build)).toThrow(/matches=2/); + }); + + it('rejects malformed tags instead of silently disabling chunk routing', () => { + const { parentCodec, build } = fixture(); + parentCodec.extensions.tags.hasChunks = 'not-json' as any; + expect(() => getChunksInfo(parentCodec, build)).toThrow(/valid JSON/); + }); +}); diff --git a/graphile/graphile-search/src/__tests__/extension-schema-qualification.test.ts b/graphile/graphile-search/src/__tests__/extension-schema-qualification.test.ts new file mode 100644 index 0000000000..95141bf70d --- /dev/null +++ b/graphile/graphile-search/src/__tests__/extension-schema-qualification.test.ts @@ -0,0 +1,322 @@ +import sql from 'pg-sql2'; + +import { createPgvectorAdapter } from '../adapters/pgvector'; +import { createTrgmAdapter } from '../adapters/trgm'; +import { createTrgmOperatorFactories } from '../codecs/operator-factories'; +import { VectorCodecPlugin } from '../codecs/vector-codec'; +import { + collectSearchExtensionSchemas, + extensionSchemasByService, + requireBuildExtensionSchema, + resolveBuildExtensionSchema, + type SearchExtensionSchemas, +} from '../extension-metadata'; + +const extensionBinding = ( + overrides: Partial = {} +): SearchExtensionSchemas => ({ + serviceName: 'tenant_service', + pgTrgmSchema: 'extension_tools', + pgvectorSchema: 'extension_tools', + ...overrides, +}); + +const introspection = (extensions: any[]) => ({ + extensions, + getNamespace: ({ id }: { id: string }) => + id === '910' ? { nspname: 'extension_tools' } : undefined, +} as any); + +describe('search extension schema binding', () => { + it('collects exact pg_trgm and pgvector schemas from one service introspection', () => { + expect(collectSearchExtensionSchemas( + introspection([ + { extname: 'pg_trgm', extnamespace: '910' }, + { extname: 'vector', extnamespace: '910' }, + ]), + 'tenant_service' + )).toEqual(extensionBinding()); + }); + + it('fails closed on ambiguous or unresolved extension metadata', () => { + expect(() => collectSearchExtensionSchemas( + introspection([ + { extname: 'pg_trgm', extnamespace: '910' }, + { extname: 'pg_trgm', extnamespace: '910' }, + ]), + 'tenant_service' + )).toThrow(/ambiguous pg_trgm/); + + expect(() => collectSearchExtensionSchemas( + introspection([{ extname: 'pg_trgm', extnamespace: '999' }]), + 'tenant_service' + )).toThrow(/cannot resolve the namespace/); + }); + + it('requires one complete build-wide schema for shared operator factories', () => { + expect(requireBuildExtensionSchema({ + pgSearchExtensionSchemasByService: new Map([ + ['tenant_service', extensionBinding()], + ]), + }, 'pg_trgm')).toBe('extension_tools'); + + expect(() => requireBuildExtensionSchema({ + pgSearchExtensionSchemasByService: new Map([ + ['a', extensionBinding({ serviceName: 'a', pgTrgmSchema: 'ext_a' })], + ['b', extensionBinding({ serviceName: 'b', pgTrgmSchema: 'ext_b' })], + ]), + }, 'pg_trgm')).toThrow(/ambiguous schemas/); + + const absentBuild = { + pgSearchExtensionSchemasByService: new Map([ + ['tenant_service', extensionBinding({ pgTrgmSchema: null })], + ]), + }; + expect(resolveBuildExtensionSchema(absentBuild, 'pg_trgm')).toBeNull(); + expect(() => requireBuildExtensionSchema(absentBuild, 'pg_trgm')).toThrow( + /required by this feature but is not installed/ + ); + }); + + it('disables optional operators for an empty service and still fails on missing record metadata', () => { + const emptyBuild = { + input: { pgRegistry: { pgCodecs: { text: { name: 'text' } } } }, + }; + expect(resolveBuildExtensionSchema(emptyBuild, 'pg_trgm')).toBeNull(); + expect(createTrgmOperatorFactories()(emptyBuild as any)).toEqual([]); + + const recordWithoutBinding = { + input: { + pgRegistry: { + pgCodecs: { + animals: { name: 'animals', attributes: {} }, + }, + }, + }, + }; + expect(() => resolveBuildExtensionSchema( + recordWithoutBinding, + 'pg_trgm' + )).toThrow(/requires service-bound extension metadata/); + }); + + it('retains service identity on a record codec even without attributes', () => { + const binding = extensionBinding(); + const build = { + input: { + pgRegistry: { + pgCodecs: { + emptyRecord: { + name: 'empty_record', + extensions: { searchExtensionSchemas: binding }, + }, + }, + }, + }, + }; + expect(extensionSchemasByService(build).get('tenant_service')).toBe(binding); + }); +}); + +describe('pg_trgm SQL qualification', () => { + const adapter = createTrgmAdapter({ requireIntentionalSearch: false }); + + it('binds metadata to the eligible attribute and qualifies similarity', () => { + const codec = { + name: 'documents', + attributes: { + title: { + codec: { name: 'text' }, + extensions: { searchExtensionSchemas: extensionBinding() }, + }, + }, + }; + const [column] = adapter.detectColumns(codec, {}); + const result = adapter.buildFilterApply( + sql, + sql.identifier('documents'), + column, + { value: 'memory density', threshold: 0.2 }, + {} + ); + const compiled = sql.compile(result!.whereClause!); + expect(compiled.text).toContain( + '"extension_tools"."similarity"("documents"."title", $1)' + ); + expect(compiled.text).not.toMatch(/(^|[^."])similarity\(/); + }); + + it('qualifies similarity and word_similarity in operator factories', () => { + const registrations = createTrgmOperatorFactories()({ + sql, + pgSearchExtensionSchemasByService: new Map([ + ['tenant_service', extensionBinding()], + ]), + getTypeByName: () => ({ name: 'TrgmSearchInput' }), + } as any); + const similar = registrations.find((entry) => entry.operatorName === 'similarTo')!; + const word = registrations.find((entry) => entry.operatorName === 'wordSimilarTo')!; + + const similarSql = similar.spec.resolve!( + sql.identifier('title'), + sql.null, + { value: 'memory', threshold: 0.3 }, + null, + { fieldName: 'title', operatorName: 'similarTo' } + ); + const wordSql = word.spec.resolve!( + sql.identifier('title'), + sql.null, + { value: 'memory', threshold: 0.3 }, + null, + { fieldName: 'title', operatorName: 'wordSimilarTo' } + ); + expect(sql.compile(similarSql!).text).toContain( + '"extension_tools"."similarity"' + ); + expect(sql.compile(wordSql!).text).toContain( + '"extension_tools"."word_similarity"' + ); + }); + + it('fails closed when an eligible attribute has no bound schema', () => { + expect(() => adapter.detectColumns({ + name: 'documents', + attributes: { title: { codec: { name: 'text' } } }, + }, {})).toThrow(/missing service-bound extension schema/); + }); + + it('disables the adapter and operator factory when pg_trgm is absent', () => { + const absent = extensionBinding({ pgTrgmSchema: null }); + const defaultAdapter = createTrgmAdapter(); + expect(defaultAdapter.detectColumns({ + name: 'documents', + attributes: { + title: { + codec: { name: 'text' }, + extensions: { searchExtensionSchemas: absent }, + }, + }, + }, {})).toEqual([]); + + expect(createTrgmOperatorFactories()({ + sql, + pgSearchExtensionSchemasByService: new Map([ + ['tenant_service', absent], + ]), + } as any)).toEqual([]); + }); + + it('fails closed when an explicit trgm feature requires an absent extension', () => { + expect(() => adapter.detectColumns({ + name: 'documents', + attributes: { + title: { + codec: { name: 'text' }, + extensions: { + searchExtensionSchemas: extensionBinding({ pgTrgmSchema: null }), + }, + }, + }, + }, {})).toThrow(/required .* but is not installed/); + }); +}); + +describe('pgvector SQL qualification', () => { + const vectorCodec = { + name: 'vector', + extensions: { + pg: { + serviceName: 'tenant_service', + schemaName: 'extension_tools', + name: 'vector', + }, + }, + }; + + it('qualifies and annotates a native vector codec during gather', async () => { + const gatherHook = (VectorCodecPlugin as any).gather.hooks.pgCodecs_findPgCodec; + const event: any = { + pgCodec: { + name: 'vector', + sqlType: sql.fragment`vector`, + extensions: undefined, + }, + pgType: { typname: 'vector', typnamespace: '910', _id: '912' }, + serviceName: 'tenant_service', + }; + const originalCodec = event.pgCodec; + await gatherHook({ + helpers: { + pgIntrospection: { + getNamespace: jest.fn().mockResolvedValue({ nspname: 'extension_tools' }), + }, + }, + }, event); + + expect(event.pgCodec).toBe(originalCodec); + expect(event.pgCodec.extensions).toMatchObject({ + oid: '912', + pg: { + serviceName: 'tenant_service', + schemaName: 'extension_tools', + name: 'vector', + }, + }); + expect(sql.compile(event.pgCodec.sqlType).text).toBe('"extension_tools"."vector"'); + }); + + it('fails closed when the vector type namespace cannot be resolved', async () => { + const gatherHook = (VectorCodecPlugin as any).gather.hooks.pgCodecs_findPgCodec; + await expect(gatherHook({ + helpers: { + pgIntrospection: { + getNamespace: jest.fn().mockResolvedValue(undefined), + }, + }, + }, { + pgType: { typname: 'vector', typnamespace: '999', _id: '912' }, + serviceName: 'tenant_service', + })).rejects.toThrow(/cannot resolve the vector type namespace/i); + }); + + it('qualifies the vector cast and distance operator', () => { + const adapter = createPgvectorAdapter(); + const [column] = adapter.detectColumns({ + name: 'documents', + attributes: { + embedding: { + codec: vectorCodec, + extensions: { searchExtensionSchemas: extensionBinding() }, + }, + }, + }, {}); + const result = adapter.buildFilterApply( + sql, + sql.identifier('documents'), + column, + { vector: [1, 0, 0], metric: 'COSINE' }, + {} + ); + const compiled = sql.compile(result!.scoreExpression); + expect(compiled.text).toContain('::"extension_tools"."vector"'); + expect(compiled.text).toContain('OPERATOR("extension_tools".<=>)'); + }); + + it('fails closed when codec and extension identities disagree', () => { + const adapter = createPgvectorAdapter(); + expect(() => adapter.detectColumns({ + name: 'documents', + attributes: { + embedding: { + codec: vectorCodec, + extensions: { + searchExtensionSchemas: extensionBinding({ + pgvectorSchema: 'other_extension_schema', + }), + }, + }, + }, + }, {})).toThrow(/does not match extension/); + }); +}); diff --git a/graphile/graphile-search/src/__tests__/plugin-cache-isolation.test.ts b/graphile/graphile-search/src/__tests__/plugin-cache-isolation.test.ts new file mode 100644 index 0000000000..07848e9977 --- /dev/null +++ b/graphile/graphile-search/src/__tests__/plugin-cache-isolation.test.ts @@ -0,0 +1,66 @@ +import type { SearchAdapter } from '../types'; +import { createUnifiedSearchPlugin } from '../plugin'; + +describe('UnifiedSearchPlugin build isolation', () => { + it('does not reuse discovery results across same-named codecs from different builds', () => { + const detectColumns = jest.fn((codec: any, build: any) => [ + { attributeName: build.tenantColumn ?? codec.extensions.tenantColumn }, + ]); + const adapter: SearchAdapter = { + name: 'tenant-test', + filterPrefix: 'tenantTest', + scoreSemantics: { metric: 'score', lowerIsBetter: false, range: null }, + detectColumns, + registerTypes: jest.fn(), + getFilterTypeName: jest.fn(() => 'TenantTestInput'), + buildFilterApply: jest.fn(), + }; + const plugin = createUnifiedSearchPlugin({ + adapters: [adapter], + enableSearchScore: false, + enableUnifiedSearch: false, + }); + const callback = (plugin.schema!.entityBehavior!.pgCodecAttribute as any) + .inferred.callback; + const buildA = { tenantColumn: 'tenant_a_search' }; + const buildB = { tenantColumn: 'tenant_b_search' }; + const codecA = { + name: 'documents', + attributes: { tenant_a_search: {} }, + extensions: { tenantColumn: 'tenant_a_search' }, + }; + const codecB = { + name: 'documents', + attributes: { tenant_b_search: {} }, + extensions: { tenantColumn: 'tenant_b_search' }, + }; + + expect(callback('default', [codecA, 'tenant_a_search'], buildA)).toContain( + 'unifiedSearch:select' + ); + expect(callback('default', [codecB, 'tenant_b_search'], buildB)).toContain( + 'unifiedSearch:select' + ); + expect(callback('default', [codecB, 'tenant_a_search'], buildB)).toBe('default'); + expect(detectColumns).toHaveBeenCalledTimes(2); + + // Reusing the same codec object within one build should still hit the cache. + callback('default', [codecB, 'tenant_b_search'], buildB); + expect(detectColumns).toHaveBeenCalledTimes(2); + + // Discovery is build-dependent, so even a reused codec object must not + // carry adapter metadata across builds. + const sharedCodec = { + name: 'shared_documents', + attributes: { tenant_a_search: {}, tenant_b_search: {} }, + extensions: { tenantColumn: 'unused' }, + }; + expect(callback('default', [sharedCodec, 'tenant_a_search'], buildA)).toContain( + 'unifiedSearch:select' + ); + expect(callback('default', [sharedCodec, 'tenant_b_search'], buildB)).toContain( + 'unifiedSearch:select' + ); + expect(detectColumns).toHaveBeenCalledTimes(4); + }); +}); diff --git a/graphile/graphile-search/src/__tests__/search-config.test.ts b/graphile/graphile-search/src/__tests__/search-config.test.ts index fd7a559161..7132cb0547 100644 --- a/graphile/graphile-search/src/__tests__/search-config.test.ts +++ b/graphile/graphile-search/src/__tests__/search-config.test.ts @@ -13,6 +13,31 @@ import { createPgvectorAdapter } from '../adapters/pgvector'; import { createTsvectorAdapter } from '../adapters/tsvector'; import { createUnifiedSearchPlugin } from '../plugin'; +const VECTOR_ADAPTER_IDENTITY = { + serviceName: 'main', + extensionSchema: 'extension_tools', +}; + +const vectorAttribute = () => ({ + codec: { + name: 'vector', + extensions: { + pg: { + serviceName: 'main', + schemaName: 'extension_tools', + name: 'vector', + }, + }, + }, + extensions: { + searchExtensionSchemas: { + serviceName: 'main', + pgTrgmSchema: 'extension_tools', + pgvectorSchema: 'extension_tools', + }, + }, +}); + // ─── pgvector adapter: chunk detection ──────────────────────────────────────── describe('pgvector adapter — chunk querying (Phase E)', () => { @@ -24,7 +49,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { name: 'documents', attributes: { id: { codec: { name: 'uuid' } }, - embedding: { codec: { name: 'vector' } }, + embedding: vectorAttribute(), }, extensions: { tags: {} }, }; @@ -32,7 +57,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { const columns = adapter.detectColumns(codec, {}); expect(columns).toHaveLength(1); expect(columns[0].attributeName).toBe('embedding'); - expect(columns[0].adapterData).toBeUndefined(); + expect(columns[0].adapterData).toEqual(VECTOR_ADAPTER_IDENTITY); }); it('includes chunksInfo when @hasChunks smart tag has metadata', () => { @@ -40,7 +65,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { name: 'documents', attributes: { id: { codec: { name: 'uuid' } }, - embedding: { codec: { name: 'vector' } }, + embedding: vectorAttribute(), }, extensions: { tags: { @@ -58,6 +83,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { expect(columns).toHaveLength(1); expect(columns[0].attributeName).toBe('embedding'); expect(columns[0].adapterData).toEqual({ + ...VECTOR_ADAPTER_IDENTITY, chunksInfo: { chunksSchema: 'app_public', chunksTableName: 'documents_chunks', @@ -75,7 +101,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { const codec = { name: 'documents', attributes: { - embedding: { codec: { name: 'vector' } }, + embedding: vectorAttribute(), }, extensions: { tags: { @@ -93,6 +119,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { const columns = adapter.detectColumns(codec, {}); expect(columns).toHaveLength(1); expect(columns[0].adapterData).toEqual({ + ...VECTOR_ADAPTER_IDENTITY, chunksInfo: { chunksSchema: 'private_schema', chunksTableName: 'doc_chunks', @@ -106,11 +133,11 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { }); }); - it('uses default parentFk, parentPk, and embeddingField when not specified', () => { + it('fails closed when neither the tag nor parent codec identifies a schema', () => { const codec = { name: 'documents', attributes: { - embedding: { codec: { name: 'vector' } }, + embedding: vectorAttribute(), }, extensions: { tags: { @@ -119,26 +146,14 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { }, }; - const columns = adapter.detectColumns(codec, {}); - expect(columns[0].adapterData).toEqual({ - chunksInfo: { - chunksSchema: null, - chunksTableName: 'my_chunks', - parentFkField: 'parent_id', - parentPkField: 'id', - embeddingField: 'embedding', - contentField: 'content', - searchField: null, - searchIndexes: [], - }, - }); + expect(() => adapter.detectColumns(codec, {})).toThrow(/chunksSchema/); }); it('inherits schema from parent codec when not explicitly set', () => { const codec = { name: 'documents', attributes: { - embedding: { codec: { name: 'vector' } }, + embedding: vectorAttribute(), }, extensions: { tags: { @@ -150,6 +165,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { const columns = adapter.detectColumns(codec, {}); expect(columns[0].adapterData).toEqual({ + ...VECTOR_ADAPTER_IDENTITY, chunksInfo: { chunksSchema: 'my_schema', chunksTableName: 'my_chunks', @@ -163,36 +179,32 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { }); }); - it('ignores boolean true @hasChunks (no metadata to resolve)', () => { + it('rejects boolean true @hasChunks (no metadata to resolve)', () => { const codec = { name: 'documents', attributes: { - embedding: { codec: { name: 'vector' } }, + embedding: vectorAttribute(), }, extensions: { tags: { hasChunks: true }, }, }; - const columns = adapter.detectColumns(codec, {}); - expect(columns).toHaveLength(1); - expect(columns[0].adapterData).toBeUndefined(); + expect(() => adapter.detectColumns(codec, {})).toThrow(/JSON object/); }); - it('ignores invalid JSON in @hasChunks string', () => { + it('rejects invalid JSON in @hasChunks string', () => { const codec = { name: 'documents', attributes: { - embedding: { codec: { name: 'vector' } }, + embedding: vectorAttribute(), }, extensions: { tags: { hasChunks: 'not-valid-json' }, }, }; - const columns = adapter.detectColumns(codec, {}); - expect(columns).toHaveLength(1); - expect(columns[0].adapterData).toBeUndefined(); + expect(() => adapter.detectColumns(codec, {})).toThrow(/valid JSON/); }); it('does not detect chunks when enableChunkQuerying is false', () => { @@ -200,7 +212,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { const codec = { name: 'documents', attributes: { - embedding: { codec: { name: 'vector' } }, + embedding: vectorAttribute(), }, extensions: { tags: { @@ -211,7 +223,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { const columns = noChunksAdapter.detectColumns(codec, {}); expect(columns).toHaveLength(1); - expect(columns[0].adapterData).toBeUndefined(); + expect(columns[0].adapterData).toEqual(VECTOR_ADAPTER_IDENTITY); }); }); @@ -220,7 +232,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { // Mock sql object that mimics pg-sql2 behavior const mockSql = { - identifier: (name: string) => `"${name}"`, + identifier: (...names: string[]) => names.map((name) => `"${name}"`).join('.'), value: (val: any) => `'${val}'`, raw: (s: string) => s, fragment: (strings: TemplateStringsArray, ...values: any[]) => { @@ -251,7 +263,10 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { const result = adapter.buildFilterApply( sql, 'tbl' as any, - { attributeName: 'embedding' }, + { + attributeName: 'embedding', + adapterData: VECTOR_ADAPTER_IDENTITY, + }, { vector: [1, 0, 0], metric: 'COSINE' }, {}, ); @@ -269,6 +284,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { { attributeName: 'embedding', adapterData: { + ...VECTOR_ADAPTER_IDENTITY, chunksInfo: { chunksSchema: null, chunksTableName: 'documents_chunks', @@ -296,6 +312,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { { attributeName: 'embedding', adapterData: { + ...VECTOR_ADAPTER_IDENTITY, chunksInfo: { chunksSchema: null, chunksTableName: 'documents_chunks', @@ -323,6 +340,7 @@ describe('pgvector adapter — chunk querying (Phase E)', () => { { attributeName: 'embedding', adapterData: { + ...VECTOR_ADAPTER_IDENTITY, chunksInfo: { chunksSchema: 'app_private', chunksTableName: 'doc_chunks', @@ -423,3 +441,44 @@ describe('VectorNearbyInput includeChunks field (Phase E)', () => { expect(fields.includeChunks.description).toContain('chunks'); }); }); + +describe('BM25 index qualification', () => { + const sql = Object.assign( + (strings: TemplateStringsArray, ...values: any[]) => + strings.reduce((text, part, index) => text + part + (values[index] ?? ''), ''), + { + identifier: (name: string) => `"${name}"`, + value: (value: any) => `'${value}'` + } + ); + + it('passes the physical schema-qualified index name as the regclass-like value', () => { + const adapter = createBm25Adapter(); + const result = adapter.buildFilterApply( + sql, + 'docs' as any, + { + attributeName: 'body', + adapterData: { + bm25Index: { + serviceName: 'main', + extensionSchema: 'extension_tools', + schemaName: 'tenant-a-app-public', + tableName: 'documents', + columnName: 'body', + indexName: 'documents_body_bm25_idx' + } + } + }, + { query: 'memory density' }, + {} + ); + + expect(String(result?.scoreExpression)).toContain( + '"tenant-a-app-public".documents_body_bm25_idx' + ); + expect(String(result?.scoreExpression)).toContain( + 'OPERATOR("extension_tools".<@>)' + ); + }); +}); diff --git a/graphile/graphile-search/src/adapters/bm25.ts b/graphile/graphile-search/src/adapters/bm25.ts index d5ebd22254..25aa6f338f 100644 --- a/graphile/graphile-search/src/adapters/bm25.ts +++ b/graphile/graphile-search/src/adapters/bm25.ts @@ -4,8 +4,8 @@ * Detects text columns with BM25 indexes (via pg_textsearch) and generates * BM25 relevance scoring. Wraps the same SQL logic as graphile-bm25. * - * Requires the Bm25CodecPlugin to be loaded first (for index discovery). - * The adapter reads from the bm25IndexStore populated during the gather phase. + * Requires the Bm25CodecPlugin to be loaded first. The adapter reads index + * metadata attached to the exact codec attribute during the same gather. * * Supports chunk-aware querying via @hasChunks smart tag: when the parent * table has chunks with a BM25 index, the adapter includes a lateral @@ -13,26 +13,20 @@ * LEAST(parent_score, chunk_score) (lower = better for BM25). */ +import { QuoteUtils } from '@pgsql/quotes'; import type { SQL } from 'pg-sql2'; -import { bm25IndexStore as moduleBm25IndexStore } from '../codecs/bm25-codec'; -import type { FilterApplyResult,SearchableColumn, SearchAdapter } from '../types'; +import type { Bm25IndexInfo } from '../codecs/bm25-codec'; +import type { FilterApplyResult, SearchableColumn, SearchAdapter } from '../types'; import { type ChunksInfo,getChunksInfo } from './chunks'; -/** - * BM25 index info discovered during gather phase. - */ -export interface Bm25IndexInfo { - schemaName: string; - tableName: string; - columnName: string; - indexName: string; -} +export type { Bm25IndexInfo } from '../codecs/bm25-codec'; /** Combined adapter data for a BM25-searchable column */ interface Bm25ColumnData { bm25Index: Bm25IndexInfo; chunksInfo?: ChunksInfo; + chunkBm25Index?: Bm25IndexInfo; } function isTextCodec(codec: any): boolean { @@ -46,42 +40,44 @@ export interface Bm25AdapterOptions { * @default 'bm25' */ filterPrefix?: string; - - /** - * External BM25 index store. If not provided, the adapter will attempt - * to read from the build object's `pgBm25IndexStore`. - */ - bm25IndexStore?: Map; } export function createBm25Adapter( options: Bm25AdapterOptions = {} ): SearchAdapter { - const { filterPrefix = 'bm25', bm25IndexStore } = options; - - function getIndexStore(build: any): Map | undefined { - if (bm25IndexStore) return bm25IndexStore; - // Try build.pgBm25IndexStore (set by standalone Bm25SearchPlugin's build hook) - const buildStore = build.pgBm25IndexStore as Map | undefined; - if (buildStore && buildStore.size > 0) return buildStore; - // Fall back to module-level store populated by Bm25CodecPlugin's gather phase - if (moduleBm25IndexStore && moduleBm25IndexStore.size > 0) return moduleBm25IndexStore; - return undefined; - } + const { filterPrefix = 'bm25' } = options; function getBm25IndexForAttribute( codec: any, - attributeName: string, - build: any, + attributeName: string ): Bm25IndexInfo | undefined { - const store = getIndexStore(build); - if (!store) return undefined; - - const pg = codec?.extensions?.pg; - if (!pg) return undefined; + const bound = codec.attributes?.[attributeName]?.extensions?.bm25Index; + return bound as Bm25IndexInfo | undefined; + } - const key = `${pg.schemaName}.${pg.name}.${attributeName}`; - return store.get(key); + function findBoundBm25Index( + build: any, + serviceName: string, + schemaName: string, + tableName: string, + columnName: string + ): Bm25IndexInfo | undefined { + for (const codec of Object.values( + build?.input?.pgRegistry?.pgCodecs ?? {} + ) as any[]) { + for (const attribute of Object.values(codec?.attributes ?? {}) as any[]) { + const index = attribute?.extensions?.bm25Index as Bm25IndexInfo | undefined; + if ( + index?.serviceName === serviceName + && index.schemaName === schemaName + && index.tableName === tableName + && index.columnName === columnName + ) { + return index; + } + } + } + return undefined; } return { @@ -110,17 +106,45 @@ export function createBm25Adapter( codec.attributes as Record )) { if (!isTextCodec(attribute.codec)) continue; - const bm25Index = getBm25IndexForAttribute(codec, attributeName, build); + const bm25Index = getBm25IndexForAttribute(codec, attributeName); if (!bm25Index) continue; // Check for chunk-aware BM25 - const chunksInfo = getChunksInfo(codec); - const hasChunkBm25 = chunksInfo?.searchIndexes.includes('bm25'); + const chunksInfo = getChunksInfo(codec, build); + const hasChunkBm25 = chunksInfo?.searchIndexes.includes('bm25') === true; + let chunkBm25Index: Bm25IndexInfo | undefined; + if (hasChunkBm25) { + if (!bm25Index.serviceName) { + throw new Error('BM25 chunk search requires a bound PostgreSQL service identity'); + } + if (!chunksInfo?.chunksSchema) { + throw new Error( + `BM25 chunk search for '${chunksInfo?.chunksTableName}' requires a physical schema` + ); + } + chunkBm25Index = findBoundBm25Index( + build, + bm25Index.serviceName, + chunksInfo.chunksSchema, + chunksInfo.chunksTableName, + chunksInfo.contentField + ); + if (!chunkBm25Index) { + throw new Error( + 'BM25 chunk search could not bind an introspected index for ' + + `${chunksInfo.chunksSchema}.${chunksInfo.chunksTableName}.` + + chunksInfo.contentField + ); + } + } const columnData: Bm25ColumnData = { - bm25Index, - chunksInfo: hasChunkBm25 ? chunksInfo : undefined, + bm25Index }; + if (hasChunkBm25) { + columnData.chunksInfo = chunksInfo; + columnData.chunkBm25Index = chunkBm25Index; + } columns.push({ attributeName, adapterData: columnData }); } return columns; @@ -181,14 +205,25 @@ export function createBm25Adapter( const bm25Index = columnData.bm25Index; const columnExpr = sql`${alias}.${sql.identifier(column.attributeName)}`; - // Use quoteQualifiedIdentifier to produce the qualified index name - const qualifiedIndexName = `"${bm25Index.schemaName}"."${bm25Index.indexName}"`; - const bm25queryExpr = sql`to_bm25query(${sql.value(query)}, ${sql.value(qualifiedIndexName)})`; - const scoreExpr = sql`(${columnExpr} <@> ${bm25queryExpr})`; + const qualifiedIndexName = QuoteUtils.quoteQualifiedIdentifier( + bm25Index.schemaName, + bm25Index.indexName + ); + const bm25queryExpr = sql`${sql.identifier( + bm25Index.extensionSchema, + 'to_bm25query' + )}(${sql.value(query)}, ${sql.value(qualifiedIndexName)})`; + const scoreExpr = sql`(${columnExpr} OPERATOR(${sql.identifier( + bm25Index.extensionSchema + )}.<@>) ${bm25queryExpr})`; // Check for chunk-aware querying const chunksInfo = columnData.chunksInfo; if (chunksInfo && chunksInfo.searchIndexes.includes('bm25') && (includeChunks !== false)) { + const chunkBm25Index = columnData.chunkBm25Index; + if (!chunkBm25Index) { + throw new Error('BM25 chunk search is missing its bound introspected index'); + } const chunksTableRef = chunksInfo.chunksSchema ? sql`${sql.identifier(chunksInfo.chunksSchema)}.${sql.identifier(chunksInfo.chunksTableName)}` : sql`${sql.identifier(chunksInfo.chunksTableName)}`; @@ -197,12 +232,17 @@ export function createBm25Adapter( const parentId = sql`${alias}.${sql.identifier(chunksInfo.parentPkField)}`; const chunksAlias = sql.identifier('__bm25_chunks'); - // BM25 on chunks requires an index name on the chunks table. - // We construct it from the chunks table schema + a conventional index name. - // The BM25 index on chunks is named: {chunks_table}_{content_field}_bm25_idx - const chunksIndexName = `"${chunksInfo.chunksSchema || bm25Index.schemaName}"."${chunksInfo.chunksTableName}_${chunksInfo.contentField}_bm25_idx"`; - const chunkBm25queryExpr = sql`to_bm25query(${sql.value(query)}, ${sql.value(chunksIndexName)})`; - const chunkScoreExpr = sql`(${chunksAlias}.${chunkContentField} <@> ${chunkBm25queryExpr})`; + const chunksIndexName = QuoteUtils.quoteQualifiedIdentifier( + chunkBm25Index.schemaName, + chunkBm25Index.indexName + ); + const chunkBm25queryExpr = sql`${sql.identifier( + chunkBm25Index.extensionSchema, + 'to_bm25query' + )}(${sql.value(query)}, ${sql.value(chunksIndexName)})`; + const chunkScoreExpr = sql`(${chunksAlias}.${chunkContentField} OPERATOR(${sql.identifier( + chunkBm25Index.extensionSchema + )}.<@>) ${chunkBm25queryExpr})`; // Subquery: MIN(bm25_score) across chunks (lower = better for BM25) const chunkScoreSubquery = sql`( diff --git a/graphile/graphile-search/src/adapters/chunks.ts b/graphile/graphile-search/src/adapters/chunks.ts index 7c9bb47968..4f1f60db71 100644 --- a/graphile/graphile-search/src/adapters/chunks.ts +++ b/graphile/graphile-search/src/adapters/chunks.ts @@ -23,6 +23,77 @@ export interface ChunksInfo { searchIndexes: string[]; } +interface PgIdentity { + serviceName: string; + schemaName: string; + name: string; +} + +function requireNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) { + throw new Error(`[graphile-search] @hasChunks ${label} must be a non-empty PostgreSQL identifier`); + } + return value; +} + +function pgIdentity(value: any, label: string): PgIdentity { + const pg = value?.extensions?.pg; + if (!pg?.serviceName || !pg?.schemaName || !pg?.name) { + throw new Error( + `[graphile-search] ${label} is missing exact service/schema/table metadata` + ); + } + return { + serviceName: pg.serviceName, + schemaName: pg.schemaName, + name: pg.name, + }; +} + +function configuredSchemas(build: any, serviceName: string): ReadonlySet | null { + const services = build?.resolvedPreset?.pgServices; + if (!Array.isArray(services)) return null; + const matches = services.filter( + (service: any) => (service?.name ?? 'main') === serviceName + ); + if (matches.length !== 1) { + throw new Error( + `[graphile-search] @hasChunks cannot resolve exact service '${serviceName}' ` + + `(matches=${matches.length})` + ); + } + const service = matches[0]; + const schemas = service?.schemas; + if (!Array.isArray(schemas) || schemas.length === 0) { + throw new Error( + `[graphile-search] @hasChunks service '${serviceName}' has no configured schema allowlist` + ); + } + const dependencySchemas = service?.introspectionAllowedDependencySchemas; + if (dependencySchemas !== undefined && !Array.isArray(dependencySchemas)) { + throw new Error( + `[graphile-search] @hasChunks service '${serviceName}' has an invalid dependency schema allowlist` + ); + } + return new Set([...schemas, ...(dependencySchemas ?? [])]); +} + +function parseSearchIndexes(value: unknown): string[] { + let parsed = value; + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed); + } catch { + throw new Error('[graphile-search] @hasChunks searchIndexes must be a JSON string array'); + } + } + if (parsed == null) return []; + if (!Array.isArray(parsed)) { + throw new Error('[graphile-search] @hasChunks searchIndexes must be an array'); + } + return parsed.map((entry) => requireNonEmptyString(entry, 'searchIndexes entry')); +} + /** * Read @hasChunks smart tag from codec extensions. * @@ -38,7 +109,7 @@ export interface ChunksInfo { * "searchIndexes": ["fulltext","bm25"] // optional, defaults to [] * } */ -export function getChunksInfo(codec: any): ChunksInfo | undefined { +export function getChunksInfo(codec: any, build?: any): ChunksInfo | undefined { const tags = codec?.extensions?.tags; if (!tags) return undefined; const raw = tags.hasChunks; @@ -49,43 +120,89 @@ export function getChunksInfo(codec: any): ChunksInfo | undefined { try { parsed = JSON.parse(raw); } catch { - return undefined; + throw new Error('[graphile-search] @hasChunks must contain valid JSON'); } - } else if (typeof raw === 'object') { + } else if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) { parsed = raw; - } else if (raw === true) { - return undefined; } else { - return undefined; + throw new Error('[graphile-search] @hasChunks must be a JSON object'); } - if (!parsed.chunksTable) return undefined; + const chunksTableName = requireNonEmptyString(parsed.chunksTable, 'chunksTable'); + const parentPg = codec?.extensions?.pg; + const chunksSchema = requireNonEmptyString( + parsed.chunksSchema || parentPg?.schemaName, + 'chunksSchema' + ); + const parentFkField = requireNonEmptyString(parsed.parentFk || 'parent_id', 'parentFk'); + const parentPkField = requireNonEmptyString(parsed.parentPk || 'id', 'parentPk'); + const embeddingField = requireNonEmptyString( + parsed.embeddingField || 'embedding', + 'embeddingField' + ); + const contentField = requireNonEmptyString(parsed.contentField || 'content', 'contentField'); + const searchField = parsed.searchField == null + ? null + : requireNonEmptyString(parsed.searchField, 'searchField'); + const searchIndexes = parseSearchIndexes(parsed.searchIndexes); - const chunksSchema = parsed.chunksSchema - || codec?.extensions?.pg?.schemaName - || null; + const pgRegistry = build?.input?.pgRegistry ?? build?.pgRegistry; + if (pgRegistry) { + const parentIdentity = pgIdentity(codec, 'parent codec'); + const allowedSchemas = configuredSchemas(build, parentIdentity.serviceName); + if (allowedSchemas && !allowedSchemas.has(chunksSchema)) { + throw new Error( + `[graphile-search] @hasChunks on '${parentIdentity.schemaName}.${parentIdentity.name}' ` + + `references schema '${chunksSchema}' outside service '${parentIdentity.serviceName}'` + ); + } - // Parse searchIndexes from tag (may be array or JSON string) - let searchIndexes: string[] = []; - if (Array.isArray(parsed.searchIndexes)) { - searchIndexes = parsed.searchIndexes; - } else if (typeof parsed.searchIndexes === 'string') { - try { - const arr = JSON.parse(parsed.searchIndexes); - if (Array.isArray(arr)) searchIndexes = arr; - } catch { - // ignore + const matches = Object.values(pgRegistry.pgResources ?? {}).filter((resource: any) => { + if (resource?.parameters || !resource?.codec?.attributes) return false; + const pg = resource.codec.extensions?.pg; + return pg?.serviceName === parentIdentity.serviceName && + pg?.schemaName === chunksSchema && + pg?.name === chunksTableName; + }) as any[]; + if (matches.length !== 1) { + throw new Error( + `[graphile-search] @hasChunks on '${parentIdentity.schemaName}.${parentIdentity.name}' ` + + `must resolve exactly one '${chunksSchema}.${chunksTableName}' resource ` + + `(matches=${matches.length})` + ); + } + + const chunkIdentity = pgIdentity(matches[0].codec, 'chunks codec'); + const chunkAttributes = matches[0].codec.attributes; + for (const [field, label] of [ + [parentFkField, 'parentFk'], + [embeddingField, 'embeddingField'], + [contentField, 'contentField'], + ...(searchField ? [[searchField, 'searchField']] : []), + ] as Array<[string, string]>) { + if (!chunkAttributes[field]) { + throw new Error( + `[graphile-search] @hasChunks ${label} '${field}' does not exist on ` + + `'${chunkIdentity.schemaName}.${chunkIdentity.name}'` + ); + } + } + if (!codec?.attributes?.[parentPkField]) { + throw new Error( + `[graphile-search] @hasChunks parentPk '${parentPkField}' does not exist on ` + + `'${parentIdentity.schemaName}.${parentIdentity.name}'` + ); } } return { chunksSchema, - chunksTableName: parsed.chunksTable, - parentFkField: parsed.parentFk || 'parent_id', - parentPkField: parsed.parentPk || 'id', - embeddingField: parsed.embeddingField || 'embedding', - contentField: parsed.contentField || 'content', - searchField: parsed.searchField || null, + chunksTableName, + parentFkField, + parentPkField, + embeddingField, + contentField, + searchField, searchIndexes, }; } diff --git a/graphile/graphile-search/src/adapters/pgvector.ts b/graphile/graphile-search/src/adapters/pgvector.ts index 038509bfab..7b0f97c0ed 100644 --- a/graphile/graphile-search/src/adapters/pgvector.ts +++ b/graphile/graphile-search/src/adapters/pgvector.ts @@ -8,9 +8,16 @@ import type { SQL } from 'pg-sql2'; +import type { SearchExtensionSchemas } from '../extension-metadata'; import type { FilterApplyResult,SearchableColumn, SearchAdapter } from '../types'; import { type ChunksInfo,getChunksInfo } from './chunks'; +interface PgvectorColumnData { + serviceName: string; + extensionSchema: string; + chunksInfo?: ChunksInfo; +} + /** * Build a distance expression for the given metric. * Uses explicit SQL template literals for each operator to avoid sql.raw. @@ -20,15 +27,16 @@ function buildDistanceExpr( columnExpr: SQL, vectorExpr: SQL, metric: string, + extensionSchema: string, ): SQL { switch (metric) { case 'L2': - return sql`(${columnExpr} <-> ${vectorExpr})`; + return sql`(${columnExpr} OPERATOR(${sql.identifier(extensionSchema)}.<->) ${vectorExpr})`; case 'IP': - return sql`(${columnExpr} <#> ${vectorExpr})`; + return sql`(${columnExpr} OPERATOR(${sql.identifier(extensionSchema)}.<#>) ${vectorExpr})`; case 'COSINE': default: - return sql`(${columnExpr} <=> ${vectorExpr})`; + return sql`(${columnExpr} OPERATOR(${sql.identifier(extensionSchema)}.<=>) ${vectorExpr})`; } } @@ -82,19 +90,43 @@ export function createPgvectorAdapter( supportsTextSearch: false, // pgvector requires a vector array, not plain text — no buildTextSearchInput - detectColumns(codec: any, _build: any): SearchableColumn[] { + detectColumns(codec: any, build: any): SearchableColumn[] { if (!codec?.attributes) return []; const columns: SearchableColumn[] = []; - const chunksInfo = enableChunkQuerying ? getChunksInfo(codec) : undefined; + const chunksInfo = enableChunkQuerying ? getChunksInfo(codec, build) : undefined; for (const [attributeName, attribute] of Object.entries( codec.attributes as Record )) { if (isVectorCodec(attribute.codec)) { + const binding: SearchExtensionSchemas | undefined = + attribute?.extensions?.searchExtensionSchemas; + const codecPg = attribute.codec?.extensions?.pg; + if (!binding?.pgvectorSchema || !codecPg?.schemaName || !codecPg?.serviceName) { + const tableName = codec?.extensions?.pg?.name ?? codec?.name ?? ''; + throw new Error( + `[graphile-search] pgvector column '${tableName}.${attributeName}' is ` + + 'missing exact codec/service extension metadata' + ); + } + if ( + codecPg.schemaName !== binding.pgvectorSchema || + codecPg.serviceName !== binding.serviceName + ) { + throw new Error( + `[graphile-search] pgvector column '${attributeName}' codec identity ` + + `'${codecPg.serviceName}/${codecPg.schemaName}' does not match extension ` + + `'${binding.serviceName}/${binding.pgvectorSchema}'` + ); + } columns.push({ attributeName, - adapterData: chunksInfo ? { chunksInfo } : undefined, + adapterData: { + serviceName: binding.serviceName, + extensionSchema: binding.pgvectorSchema, + ...(chunksInfo ? { chunksInfo } : {}), + } satisfies PgvectorColumnData, }); } } @@ -195,12 +227,21 @@ export function createPgvectorAdapter( const { vector, metric, distance, includeChunks } = filterValue; if (!vector || !Array.isArray(vector) || vector.length === 0) return null; + const adapterData = column.adapterData as PgvectorColumnData | undefined; + if (!adapterData?.extensionSchema || !adapterData.serviceName) { + throw new Error( + `[graphile-search] pgvector column '${column.attributeName}' has no bound ` + + 'extension schema' + ); + } const resolvedMetric = metric || defaultMetric; const vectorString = `[${vector.join(',')}]`; - const vectorExpr = sql`${sql.value(vectorString)}::vector`; + const vectorExpr = sql`${sql.value(vectorString)}::${sql.identifier( + adapterData.extensionSchema, + 'vector' + )}`; // Check if this column has chunks info and chunk querying is requested - const adapterData = column.adapterData as { chunksInfo?: ChunksInfo } | undefined; const chunksInfo = adapterData?.chunksInfo; if (chunksInfo && (includeChunks !== false)) { @@ -217,7 +258,13 @@ export function createPgvectorAdapter( const chunksAlias = sql.identifier('__chunks'); // Subquery: SELECT MIN(distance) FROM chunks WHERE chunks.parent_fk = parent.pk - const chunkDistanceExpr = buildDistanceExpr(sql, sql`${chunksAlias}.${chunkEmbedding}`, vectorExpr, resolvedMetric); + const chunkDistanceExpr = buildDistanceExpr( + sql, + sql`${chunksAlias}.${chunkEmbedding}`, + vectorExpr, + resolvedMetric, + adapterData.extensionSchema + ); const chunkDistanceSubquery = sql`( SELECT MIN(${chunkDistanceExpr}) FROM ${chunksTableRef} AS ${chunksAlias} @@ -226,7 +273,13 @@ export function createPgvectorAdapter( // Also compute direct parent distance if the parent has an embedding const parentColumnExpr = sql`${alias}.${sql.identifier(column.attributeName)}`; - const parentDistanceExpr = buildDistanceExpr(sql, parentColumnExpr, vectorExpr, resolvedMetric); + const parentDistanceExpr = buildDistanceExpr( + sql, + parentColumnExpr, + vectorExpr, + resolvedMetric, + adapterData.extensionSchema + ); // Use LEAST of parent distance and closest chunk distance // COALESCE handles cases where parent or chunks may not have embeddings @@ -248,7 +301,13 @@ export function createPgvectorAdapter( // Standard (non-chunk) query const columnExpr = sql`${alias}.${sql.identifier(column.attributeName)}`; - const distanceExpr = buildDistanceExpr(sql, columnExpr, vectorExpr, resolvedMetric); + const distanceExpr = buildDistanceExpr( + sql, + columnExpr, + vectorExpr, + resolvedMetric, + adapterData.extensionSchema + ); let whereClause: SQL | null = null; if (distance !== undefined && distance !== null) { diff --git a/graphile/graphile-search/src/adapters/trgm.ts b/graphile/graphile-search/src/adapters/trgm.ts index 103e6c7bd4..b7eff47744 100644 --- a/graphile/graphile-search/src/adapters/trgm.ts +++ b/graphile/graphile-search/src/adapters/trgm.ts @@ -12,6 +12,7 @@ import type { SQL } from 'pg-sql2'; +import type { SearchExtensionSchemas } from '../extension-metadata'; import type { FilterApplyResult,SearchableColumn, SearchAdapter } from '../types'; import { type ChunksInfo,getChunksInfo } from './chunks'; @@ -48,6 +49,12 @@ export interface TrgmAdapterOptions { requireIntentionalSearch?: boolean; } +interface TrgmColumnData { + serviceName: string; + extensionSchema: string; + chunksInfo?: ChunksInfo; +} + export function createTrgmAdapter( options: TrgmAdapterOptions = {} ): SearchAdapter { @@ -81,7 +88,7 @@ export function createTrgmAdapter( return { value: text }; }, - detectColumns(codec: any, _build: any): SearchableColumn[] { + detectColumns(codec: any, build: any): SearchableColumn[] { if (!codec?.attributes) return []; const columns: SearchableColumn[] = []; @@ -89,12 +96,41 @@ export function createTrgmAdapter( codec.attributes as Record )) { if (isTextCodec(attribute.codec)) { + const binding: SearchExtensionSchemas | undefined = + attribute?.extensions?.searchExtensionSchemas; + if (!binding) { + const tableName = codec?.extensions?.pg?.name ?? codec?.name ?? ''; + throw new Error( + `[graphile-search] pg_trgm column '${tableName}.${attributeName}' is ` + + 'missing service-bound extension schema metadata' + ); + } + if (!binding.pgTrgmSchema) { + const explicitlyRequired = + requireIntentionalSearch === false || + codec?.extensions?.tags?.trgmSearch === true || + attribute?.extensions?.tags?.trgmSearch === true; + if (explicitlyRequired) { + const tableName = codec?.extensions?.pg?.name ?? codec?.name ?? ''; + throw new Error( + `[graphile-search] pg_trgm is required for '${tableName}.${attributeName}' ` + + `but is not installed for service '${binding.serviceName}'` + ); + } + // The adapter is enabled in the preset, but this service does not + // install pg_trgm. Leave the attribute untouched. + continue; + } // Store chunks info if available and chunks have trigram search - const chunksInfo = getChunksInfo(codec); + const chunksInfo = getChunksInfo(codec, build); const hasChunkTrgm = chunksInfo?.searchIndexes.includes('trigram'); columns.push({ attributeName, - adapterData: hasChunkTrgm ? chunksInfo : undefined, + adapterData: { + serviceName: binding.serviceName, + extensionSchema: binding.pgTrgmSchema, + ...(hasChunkTrgm ? { chunksInfo } : {}), + } satisfies TrgmColumnData, }); } } @@ -152,12 +188,20 @@ export function createTrgmAdapter( const { value, threshold, includeChunks } = filterValue; if (!value || typeof value !== 'string' || value.trim().length === 0) return null; + const columnData = column.adapterData as TrgmColumnData | undefined; + if (!columnData?.extensionSchema || !columnData.serviceName) { + throw new Error( + `[graphile-search] pg_trgm column '${column.attributeName}' has no bound ` + + 'extension schema' + ); + } const th = threshold != null ? threshold : defaultThreshold; const columnExpr = sql`${alias}.${sql.identifier(column.attributeName)}`; - const similarityExpr = sql`similarity(${columnExpr}, ${sql.value(value)})`; + const similarity = sql.identifier(columnData.extensionSchema, 'similarity'); + const similarityExpr = sql`${similarity}(${columnExpr}, ${sql.value(value)})`; // Check for chunk-aware querying - const chunksInfo = column.adapterData as ChunksInfo | undefined; + const chunksInfo = columnData.chunksInfo; if (chunksInfo && chunksInfo.searchIndexes.includes('trigram') && (includeChunks !== false)) { const chunksTableRef = chunksInfo.chunksSchema ? sql`${sql.identifier(chunksInfo.chunksSchema)}.${sql.identifier(chunksInfo.chunksTableName)}` @@ -169,10 +213,10 @@ export function createTrgmAdapter( // Subquery: MAX(similarity) across chunks (higher = better for trgm) const chunkSimilaritySubquery = sql`( - SELECT MAX(similarity(${chunksAlias}.${chunkContentField}, ${sql.value(value)})) + SELECT MAX(${similarity}(${chunksAlias}.${chunkContentField}, ${sql.value(value)})) FROM ${chunksTableRef} AS ${chunksAlias} WHERE ${chunksAlias}.${parentFk} = ${parentId} - AND similarity(${chunksAlias}.${chunkContentField}, ${sql.value(value)}) > ${sql.value(th)} + AND ${similarity}(${chunksAlias}.${chunkContentField}, ${sql.value(value)}) > ${sql.value(th)} )`; // Combined: GREATEST of parent similarity and best chunk similarity diff --git a/graphile/graphile-search/src/adapters/tsvector.ts b/graphile/graphile-search/src/adapters/tsvector.ts index 308086bb68..d7eb51dbb8 100644 --- a/graphile/graphile-search/src/adapters/tsvector.ts +++ b/graphile/graphile-search/src/adapters/tsvector.ts @@ -63,7 +63,7 @@ export function createTsvectorAdapter( return text; }, - detectColumns(codec: any, _build: any): SearchableColumn[] { + detectColumns(codec: any, build: any): SearchableColumn[] { if (!codec?.attributes) return []; const columns: SearchableColumn[] = []; @@ -72,7 +72,7 @@ export function createTsvectorAdapter( )) { if (isTsvectorCodec(attribute.codec)) { // Store chunks info if available and chunks have fulltext search - const chunksInfo = getChunksInfo(codec); + const chunksInfo = getChunksInfo(codec, build); const hasChunkFulltext = chunksInfo?.searchField && chunksInfo.searchIndexes.includes('fulltext'); columns.push({ diff --git a/graphile/graphile-search/src/codecs/bm25-codec.ts b/graphile/graphile-search/src/codecs/bm25-codec.ts index b48beceeda..ba3d018e35 100644 --- a/graphile/graphile-search/src/codecs/bm25-codec.ts +++ b/graphile/graphile-search/src/codecs/bm25-codec.ts @@ -2,25 +2,37 @@ * Bm25CodecPlugin * * Teaches PostGraphile v5 how to handle the pg_textsearch `bm25query` type - * and discovers all BM25 indexes in the database. + * and discovers BM25 indexes from Graphile's scoped introspection payload. * * This plugin: * 1. Creates a codec for bm25query via gather.hooks.pgCodecs_findPgCodec - * 2. Discovers all BM25 indexes via gather.hooks.pgIntrospection_introspection - * by querying pg_index + pg_am + pg_class + pg_attribute - * 3. Stores discovered BM25 index info in a module-level Map for use by - * the BM25 adapter during the schema build phase + * 2. Discovers requested-schema BM25 indexes without issuing side-channel SQL + * 3. Attaches index metadata to the exact codec attribute for this build */ import 'graphile-build-pg'; import type { GraphileConfig } from 'graphile-config'; +import { gatherConfig } from 'graphile-build'; import sql from 'pg-sql2'; +type Introspection = Parameters< + GraphileConfig.GatherHooks['pgIntrospection_introspection'] +>[0]['introspection']; + +interface ScopedPgService { + name?: string; + schemas?: readonly string[]; +} + /** * Represents a discovered BM25 index in the database. */ export interface Bm25IndexInfo { + /** Graphile PostgreSQL service that owns this index. */ + serviceName: string; + /** Schema containing pg_textsearch's functions and operators. */ + extensionSchema: string; /** Schema name (e.g. 'public') */ schemaName: string; /** Table name (e.g. 'documents') */ @@ -31,46 +43,105 @@ export interface Bm25IndexInfo { indexName: string; } -/** - * Module-level store for discovered BM25 indexes. - * Populated during the gather phase, read during the schema build phase. - * - * Key: "schemaName.tableName.columnName" - * Value: Bm25IndexInfo - */ -export const bm25IndexStore = new Map(); - -/** - * Whether pg_textsearch extension was detected in the database. - */ -export let bm25ExtensionDetected = false; +declare global { + namespace GraphileConfig { + interface GatherHelpers { + bm25Codec: Record; + } + } + + namespace DataplanPg { + interface PgCodecAttributeExtensions { + /** Exact physical BM25 index bound during this gather generation. */ + bm25Index?: Bm25IndexInfo; + } + } +} -/** - * The SQL query that discovers BM25 indexes in the database. - * Joins pg_index -> pg_class -> pg_am to find all indexes using the 'bm25' - * access method, then resolves the schema, table, column, and index names. - */ -const BM25_DISCOVERY_SQL = ` - SELECT - n.nspname AS schema_name, - c.relname AS table_name, - a.attname AS column_name, - i.relname AS index_name - FROM pg_index ix - JOIN pg_class i ON i.oid = ix.indexrelid - JOIN pg_am am ON am.oid = i.relam - JOIN pg_class c ON c.oid = ix.indrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(ix.indkey) - WHERE am.amname = 'bm25' -`; +const attributeKey = (classId: string, attributeNumber: number): string => + `${classId}:${attributeNumber}`; + +/** Collect exact requested-schema indexes from this build's own introspection. */ +export const collectBm25Indexes = ( + introspection: Introspection, + schemas: readonly string[], + serviceName: string +): Map => { + const allowedSchemas = new Set(schemas); + const discovered = new Map(); + const extension = introspection.extensions.find( + (candidate) => candidate.extname === 'pg_textsearch' + ); + const extensionNamespace = extension?.extnamespace + ? introspection.getNamespace({ id: extension.extnamespace }) + : undefined; + const indexes = [...introspection.indexes].sort((left, right) => { + const leftName = left.getIndexClass()?.relname ?? ''; + const rightName = right.getIndexClass()?.relname ?? ''; + return leftName.localeCompare(rightName); + }); + + for (const index of indexes) { + if (index.indisvalid !== true || index.indisready !== true || index.indislive !== true) { + continue; + } + const indexClass = index.getIndexClass(); + const tableClass = index.getClass(); + const namespace = tableClass + ? introspection.getNamespace({ id: tableClass.relnamespace }) + : undefined; + if ( + !indexClass + || indexClass.getAccessMethod()?.amname !== 'bm25' + || !tableClass + || !namespace + || !allowedSchemas.has(namespace.nspname) + ) { + continue; + } + + const keyCount = index.indnkeyatts ?? index.indkey.length; + for (const attribute of index.getKeys().slice(0, keyCount)) { + if (!attribute) continue; + const key = attributeKey(tableClass._id, attribute.attnum); + if (!extensionNamespace) { + throw new Error( + `BM25 index ${namespace.nspname}.${indexClass.relname} has no ` + + 'introspected pg_textsearch extension schema' + ); + } + const indexInfo: Bm25IndexInfo = { + serviceName, + extensionSchema: extensionNamespace.nspname, + schemaName: namespace.nspname, + tableName: tableClass.relname, + columnName: attribute.attname, + indexName: indexClass.relname + }; + const existing = discovered.get(key); + if (existing && existing.indexName !== indexInfo.indexName) { + throw new Error( + `Multiple BM25 indexes target ${indexInfo.schemaName}.${indexInfo.tableName}.` + + `${indexInfo.columnName}: ${existing.indexName}, ${indexInfo.indexName}` + ); + } + discovered.set(key, indexInfo); + } + } + return discovered; +}; export const Bm25CodecPlugin: GraphileConfig.Plugin = { name: 'Bm25CodecPlugin', version: '1.0.0', description: 'Registers a codec for the pg_textsearch bm25query type and discovers BM25 indexes', - gather: { + gather: gatherConfig({ + namespace: 'bm25Codec', + initialState: () => ({ + indexesByService: new Map>() + }), + helpers: {}, hooks: { /** * Register the bm25query codec when detected during type introspection. @@ -112,65 +183,34 @@ export const Bm25CodecPlugin: GraphileConfig.Plugin = { }; }, - /** - * After introspection completes, query for all BM25 indexes. - * Uses the pgService's adaptorSettings to create a direct pg.Pool - * connection and runs the BM25 discovery query. - */ - async pgIntrospection_introspection(info, event) { - const { serviceName } = event; - - // Get the pgService from the resolved preset - const pgService = info.resolvedPreset?.pgServices?.find( - (s: { name?: string }) => (s.name ?? 'main') === serviceName + pgIntrospection_introspection(info, event) { + const { introspection, serviceName } = event; + const pgServices = info.resolvedPreset.pgServices as + | readonly ScopedPgService[] + | undefined; + const pgService = pgServices?.find( + (service) => (service.name ?? 'main') === serviceName ); - if (!pgService) return; - - // Clear previous entries for this introspection run - bm25IndexStore.clear(); - - try { - const adaptorSettings = (pgService as any).adaptorSettings; - if (!adaptorSettings?.connectionString && !adaptorSettings?.pool) { - return; - } - - // Import pg dynamically for the discovery query - const { Pool } = await import('pg'); - const existingPool = adaptorSettings.pool; - const pool = existingPool ?? new Pool({ - connectionString: adaptorSettings.connectionString, - max: 1, - }); - const isOwnPool = !existingPool; - - try { - const result = await pool.query(BM25_DISCOVERY_SQL); - - if (result.rows && result.rows.length > 0) { - bm25ExtensionDetected = true; - for (const row of result.rows) { - const key = `${row.schema_name}.${row.table_name}.${row.column_name}`; - bm25IndexStore.set(key, { - schemaName: row.schema_name, - tableName: row.table_name, - columnName: row.column_name, - indexName: row.index_name, - }); - } - } - } finally { - if (isOwnPool) { - await pool.end(); - } - } - } catch { - // pg_textsearch not installed or query failed — gracefully skip - bm25ExtensionDetected = false; + if (!pgService) throw new Error(`BM25 gather could not find service '${serviceName}'`); + if (!pgService.schemas?.length) { + throw new Error(`BM25 gather requires configured schemas for service '${serviceName}'`); } + info.state.indexesByService.set( + serviceName, + collectBm25Indexes(introspection, pgService.schemas, serviceName) + ); + }, + + pgCodecs_attribute(info, event) { + const indexInfo = info.state.indexesByService + .get(event.serviceName) + ?.get(attributeKey(event.pgClass._id, event.pgAttribute.attnum)); + if (!indexInfo) return; + event.attribute.extensions ??= Object.create(null); + event.attribute.extensions.bm25Index = indexInfo; }, }, - }, + }), schema: { hooks: { diff --git a/graphile/graphile-search/src/codecs/index.ts b/graphile/graphile-search/src/codecs/index.ts index 41a283597d..3cc733771a 100644 --- a/graphile/graphile-search/src/codecs/index.ts +++ b/graphile/graphile-search/src/codecs/index.ts @@ -10,8 +10,6 @@ export type { Bm25IndexInfo } from './bm25-codec'; export { Bm25CodecPlugin, Bm25CodecPreset, - bm25ExtensionDetected, - bm25IndexStore, } from './bm25-codec'; export type { TsvectorCodecPluginOptions } from './tsvector-codec'; export { diff --git a/graphile/graphile-search/src/codecs/operator-factories.ts b/graphile/graphile-search/src/codecs/operator-factories.ts index a8e8a80b0f..f884c3f579 100644 --- a/graphile/graphile-search/src/codecs/operator-factories.ts +++ b/graphile/graphile-search/src/codecs/operator-factories.ts @@ -11,6 +11,8 @@ import type { ConnectionFilterOperatorFactory } from 'graphile-connection-filter'; import type { SQL } from 'pg-sql2'; +import { resolveBuildExtensionSchema } from '../extension-metadata'; + /** * Creates the `matches` filter operator factory for full-text search. * Declared here so it's registered via the declarative @@ -59,6 +61,10 @@ export function createMatchesOperatorFactory( export function createTrgmOperatorFactories(): ConnectionFilterOperatorFactory { return (build) => { const { sql } = build; + const extensionSchema = resolveBuildExtensionSchema(build, 'pg_trgm'); + if (!extensionSchema) return []; + const similarity = sql.identifier(extensionSchema, 'similarity'); + const wordSimilarity = sql.identifier(extensionSchema, 'word_similarity'); return [ { @@ -82,7 +88,7 @@ export function createTrgmOperatorFactories(): ConnectionFilterOperatorFactory { return null; } const th = threshold != null ? threshold : 0.3; - return sql`similarity(${sqlIdentifier}, ${sql.value(value)}) > ${sql.value(th)}`; + return sql`${similarity}(${sqlIdentifier}, ${sql.value(value)}) > ${sql.value(th)}`; }, }, }, @@ -107,7 +113,7 @@ export function createTrgmOperatorFactories(): ConnectionFilterOperatorFactory { return null; } const th = threshold != null ? threshold : 0.3; - return sql`word_similarity(${sql.value(value)}, ${sqlIdentifier}) > ${sql.value(th)}`; + return sql`${wordSimilarity}(${sql.value(value)}, ${sqlIdentifier}) > ${sql.value(th)}`; }, }, }, diff --git a/graphile/graphile-search/src/codecs/vector-codec.ts b/graphile/graphile-search/src/codecs/vector-codec.ts index e764ba1238..539f22dc87 100644 --- a/graphile/graphile-search/src/codecs/vector-codec.ts +++ b/graphile/graphile-search/src/codecs/vector-codec.ts @@ -26,8 +26,6 @@ export const VectorCodecPlugin: GraphileConfig.Plugin = { gather: { hooks: { async pgCodecs_findPgCodec(info, event) { - if (event.pgCodec) return; - const { pgType: type, serviceName } = event; if (type.typname !== 'vector') return; @@ -35,10 +33,41 @@ export const VectorCodecPlugin: GraphileConfig.Plugin = { serviceName, type.typnamespace ); - if (!typeNamespace) return; + if (!typeNamespace?.nspname) { + throw new Error( + `[graphile-search] Cannot resolve the vector type namespace for ` + + `service '${serviceName}'` + ); + } const schemaName = typeNamespace.nspname; + if (event.pgCodec) { + const existingPg = event.pgCodec.extensions?.pg; + if ( + (existingPg?.serviceName && existingPg.serviceName !== serviceName) || + (existingPg?.schemaName && existingPg.schemaName !== schemaName) + ) { + throw new Error( + `[graphile-search] Existing vector codec identity conflicts with ` + + `introspection for service '${serviceName}'` + ); + } + const existingCodec = event.pgCodec as any; + existingCodec.sqlType = sql.identifier(schemaName, 'vector'); + existingCodec.extensions = { + ...existingCodec.extensions, + oid: type._id, + pg: { + ...existingPg, + serviceName, + schemaName, + name: 'vector', + }, + }; + return; + } + event.pgCodec = { name: 'vector', sqlType: sql.identifier(schemaName, 'vector'), diff --git a/graphile/graphile-search/src/extension-metadata.ts b/graphile/graphile-search/src/extension-metadata.ts new file mode 100644 index 0000000000..1317a54f46 --- /dev/null +++ b/graphile/graphile-search/src/extension-metadata.ts @@ -0,0 +1,245 @@ +import 'graphile-build'; +import 'graphile-build-pg'; + +import type { GraphileConfig } from 'graphile-config'; +import { gatherConfig } from 'graphile-build'; + +type Introspection = Parameters< + GraphileConfig.GatherHooks['pgIntrospection_introspection'] +>[0]['introspection']; + +/** Extension namespaces discovered for one exact Graphile PostgreSQL service. */ +export interface SearchExtensionSchemas { + serviceName: string; + pgTrgmSchema: string | null; + pgvectorSchema: string | null; +} + +declare global { + namespace GraphileConfig { + interface GatherHelpers { + unifiedSearchExtensionMetadata: Record; + } + } + + namespace DataplanPg { + interface PgCodecExtensions { + /** Exact extension schemas for the service that owns this record codec. */ + searchExtensionSchemas?: SearchExtensionSchemas; + } + + interface PgCodecAttributeExtensions { + /** Exact extension schemas bound from this service's introspection generation. */ + searchExtensionSchemas?: SearchExtensionSchemas; + } + } + + namespace GraphileBuild { + interface Build { + /** Per-service extension schemas for this build only. */ + pgSearchExtensionSchemasByService?: ReadonlyMap; + } + } +} + +function extensionSchema( + introspection: Introspection, + extensionName: string, + serviceName: string +): string | null { + const matches = introspection.extensions.filter( + (extension) => extension.extname === extensionName + ); + if (matches.length > 1) { + throw new Error( + `[graphile-search] Service '${serviceName}' has ambiguous ${extensionName} ` + + `extension metadata (${matches.length} entries)` + ); + } + if (matches.length === 0) return null; + + const extension = matches[0]; + if (extension.extnamespace == null) { + throw new Error( + `[graphile-search] Service '${serviceName}' has ${extensionName} without an ` + + 'introspected extension namespace' + ); + } + const namespace = introspection.getNamespace({ id: extension.extnamespace }); + if (!namespace?.nspname) { + throw new Error( + `[graphile-search] Service '${serviceName}' cannot resolve the namespace for ` + + `${extensionName}` + ); + } + return namespace.nspname; +} + +/** Resolve extension schemas exclusively from the current service introspection. */ +export function collectSearchExtensionSchemas( + introspection: Introspection, + serviceName: string +): SearchExtensionSchemas { + return Object.freeze({ + serviceName, + pgTrgmSchema: extensionSchema(introspection, 'pg_trgm', serviceName), + pgvectorSchema: extensionSchema(introspection, 'vector', serviceName), + }); +} + +/** + * Gather configuration used by UnifiedSearchPlugin. + * + * Metadata is attached to every attribute while its service identity is still + * explicit. Adapters later retain only the exact binding for eligible columns. + */ +export const SearchExtensionMetadataGather = gatherConfig({ + namespace: 'unifiedSearchExtensionMetadata', + initialState: () => ({ + schemasByService: new Map(), + }), + helpers: {}, + hooks: { + pgIntrospection_introspection(info, event) { + const { introspection, serviceName } = event; + info.state.schemasByService.set( + serviceName, + collectSearchExtensionSchemas(introspection, serviceName) + ); + }, + + pgCodecs_PgCodec(info, event) { + // Record codecs are service-local, unlike built-in scalar codecs that may + // be shared. Keeping one carrier per exposed class also covers builds + // whose attributes are later reduced from the registry. + if (!event.pgClass) return; + const binding = info.state.schemasByService.get(event.serviceName); + if (!binding) { + throw new Error( + `[graphile-search] No extension metadata was gathered for service ` + + `'${event.serviceName}'` + ); + } + event.pgCodec.extensions ??= Object.create(null); + event.pgCodec.extensions.searchExtensionSchemas = binding; + }, + + pgCodecs_attribute(info, event) { + const binding = info.state.schemasByService.get(event.serviceName); + if (!binding) { + throw new Error( + `[graphile-search] No extension metadata was gathered for service ` + + `'${event.serviceName}'` + ); + } + event.attribute.extensions ??= Object.create(null); + event.attribute.extensions.searchExtensionSchemas = binding; + }, + }, +}); + +/** Build an immutable, consistency-checked service map from bound attributes. */ +export function extensionSchemasByService(build: any): ReadonlyMap { + const schemasByService = new Map(); + const codecs = build.input?.pgRegistry?.pgCodecs; + if (!codecs) return schemasByService; + + const addBinding = (binding: SearchExtensionSchemas): void => { + const existing = schemasByService.get(binding.serviceName); + if ( + existing && + (existing.pgTrgmSchema !== binding.pgTrgmSchema || + existing.pgvectorSchema !== binding.pgvectorSchema) + ) { + throw new Error( + `[graphile-search] Conflicting extension metadata for service ` + + `'${binding.serviceName}' in one build` + ); + } + schemasByService.set(binding.serviceName, binding); + }; + + for (const codec of Object.values(codecs) as any[]) { + const codecBinding: SearchExtensionSchemas | undefined = + codec?.extensions?.searchExtensionSchemas; + if (codecBinding) { + addBinding(codecBinding); + } + if (!codec?.attributes) continue; + for (const attribute of Object.values(codec.attributes) as any[]) { + const binding: SearchExtensionSchemas | undefined = + attribute?.extensions?.searchExtensionSchemas; + if (!binding) continue; + addBinding(binding); + } + } + return schemasByService; +} + +/** + * Resolve one extension namespace for a build-wide operator factory. + * Shared GraphQL filter types cannot safely route to different schemas, so a + * multi-service build with partial or differing namespaces is rejected. A + * build where every service lacks the optional extension resolves to null. + */ +export function resolveBuildExtensionSchema( + build: any, + extension: 'pg_trgm' | 'vector' +): string | null { + const schemasByService: ReadonlyMap = + build.pgSearchExtensionSchemasByService ?? extensionSchemasByService(build); + if (schemasByService.size === 0) { + const codecs = build.input?.pgRegistry?.pgCodecs; + const hasServiceBoundCodec = codecs && Object.values(codecs).some( + (codec: any) => + codec?.attributes != null || codec?.extensions?.pg?.serviceName != null + ); + if (!hasServiceBoundCodec) { + // An empty exposed schema has no service-local codec on which gather can + // carry optional extension metadata. No operator is registered, so no + // unqualified SQL path is created. + return null; + } + throw new Error( + `[graphile-search] ${extension} requires service-bound extension metadata` + ); + } + + const field = extension === 'pg_trgm' ? 'pgTrgmSchema' : 'pgvectorSchema'; + const schemas = new Set(); + let missingCount = 0; + for (const binding of schemasByService.values()) { + const schemaName = binding[field]; + if (!schemaName) { + missingCount++; + continue; + } + schemas.add(schemaName); + } + if (schemas.size === 0) return null; + if (missingCount > 0) { + throw new Error( + `[graphile-search] ${extension} is present for only part of this multi-service build` + ); + } + if (schemas.size !== 1) { + throw new Error( + `[graphile-search] ${extension} has ambiguous schemas across this build: ` + + [...schemas].sort().join(', ') + ); + } + return schemas.values().next().value!; +} + +export function requireBuildExtensionSchema( + build: any, + extension: 'pg_trgm' | 'vector' +): string { + const schemaName = resolveBuildExtensionSchema(build, extension); + if (!schemaName) { + throw new Error( + `[graphile-search] ${extension} is required by this feature but is not installed` + ); + } + return schemaName; +} diff --git a/graphile/graphile-search/src/index.ts b/graphile/graphile-search/src/index.ts index b28afee219..de01a0e8f1 100644 --- a/graphile/graphile-search/src/index.ts +++ b/graphile/graphile-search/src/index.ts @@ -33,6 +33,14 @@ // Core plugin export { createUnifiedSearchPlugin } from './plugin'; +// Exact per-service extension namespace binding +export type { SearchExtensionSchemas } from './extension-metadata'; +export { + collectSearchExtensionSchemas, + requireBuildExtensionSchema, + resolveBuildExtensionSchema, +} from './extension-metadata'; + // Preset export type { UnifiedSearchPresetOptions } from './preset'; export { UnifiedSearchPreset } from './preset'; @@ -68,7 +76,6 @@ export type { export { Bm25CodecPlugin, Bm25CodecPreset, - bm25IndexStore, createTsvectorCodecPlugin, TsvectorCodecPlugin, TsvectorCodecPreset, diff --git a/graphile/graphile-search/src/plugin.ts b/graphile/graphile-search/src/plugin.ts index c67f796be3..6673a47927 100644 --- a/graphile/graphile-search/src/plugin.ts +++ b/graphile/graphile-search/src/plugin.ts @@ -26,6 +26,10 @@ import { TYPES } from '@dataplan/pg'; import type { GraphileConfig } from 'graphile-config'; import { getQueryBuilder } from 'graphile-plugin-utils'; +import { + extensionSchemasByService, + SearchExtensionMetadataGather, +} from './extension-metadata'; import type { SearchableColumn, SearchAdapter, UnifiedSearchOptions } from './types'; // ─── TypeScript Namespace Augmentations ────────────────────────────────────── @@ -171,8 +175,14 @@ export function createUnifiedSearchPlugin( ): GraphileConfig.Plugin { const { adapters, enableSearchScore = true, enableUnifiedSearch = true, rrfK = 60 } = options; - // Per-codec cache of discovered columns, keyed by codec name - const codecCache = new Map(); + // Adapter discovery may depend on both the codec and the surrounding build + // registry. Object-identity keys prevent a long-lived preset from reusing + // tenant A's result for tenant B, even if a caller reuses a codec object. + // Weak keys also allow disposed builds and codecs to be collected. + const buildCodecCache = new WeakMap< + object, + WeakMap + >(); // Bridge between orderBy enum apply and filter apply. // The orderBy enum runs on the PgSelectStep while the filter runs on @@ -195,9 +205,14 @@ export function createUnifiedSearchPlugin( * count as intentional search. */ function getAdapterColumns(codec: PgCodecWithAttributes, build: any): AdapterColumnCache[] { - const cacheKey = codec.name; - if (codecCache.has(cacheKey)) { - return codecCache.get(cacheKey)!; + let codecCache = buildCodecCache.get(build); + if (!codecCache) { + codecCache = new WeakMap(); + buildCodecCache.set(build, codecCache); + } + const cached = codecCache.get(codec); + if (cached) { + return cached; } const primaryAdapters = adapters.filter((a) => !a.isSupplementary); @@ -238,7 +253,7 @@ export function createUnifiedSearchPlugin( } } - codecCache.set(cacheKey, results); + codecCache.set(codec, results); return results; } @@ -260,6 +275,8 @@ export function createUnifiedSearchPlugin( 'VectorCodecPlugin', ], + gather: SearchExtensionMetadataGather, + // ─── Custom Inflection Methods ───────────────────────────────────── inflection: { add: { @@ -328,6 +345,17 @@ export function createUnifiedSearchPlugin( }, hooks: { + /** Publish only this build's consistency-checked extension bindings. */ + build(build) { + return build.extend( + build, + { + pgSearchExtensionSchemasByService: extensionSchemasByService(build), + }, + 'UnifiedSearchPlugin adding per-service extension schemas' + ); + }, + /** * Register all adapter-specific GraphQL types during init. */ @@ -375,7 +403,7 @@ export function createUnifiedSearchPlugin( inflection, sql, graphql: { GraphQLFloat }, - grafast: { lambda }, + grafast: { constant, lambda }, } = build; const { scope: { isPgClassType, pgCodec: rawPgCodec }, @@ -424,7 +452,7 @@ export function createUnifiedSearchPlugin( const $select = typeof $row.getClassStep === 'function' ? $row.getClassStep() : null; - if (!$select) return build.grafast.constant(null); + if (!$select) return constant(null); if (typeof $select.setInliningForbidden === 'function') { $select.setInliningForbidden(); @@ -522,7 +550,7 @@ export function createUnifiedSearchPlugin( const $select = typeof $row.getClassStep === 'function' ? $row.getClassStep() : null; - if (!$select) return build.grafast.constant(null); + if (!$select) return constant(null); if (typeof $select.setInliningForbidden === 'function') { $select.setInliningForbidden(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc6de2bdd4..06e9c6501a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,6 +378,9 @@ importers: '@dataplan/pg': specifier: 1.0.3 version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@pgsql/quotes': + specifier: ^18.1.0 + version: 18.2.1 grafast: specifier: 1.0.2 version: 1.0.2(graphql@16.13.0) @@ -601,6 +604,9 @@ importers: '@dataplan/pg': specifier: 1.0.3 version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@pgsql/quotes': + specifier: ^18.1.0 + version: 18.2.1 accept-language-parser: specifier: ^1.5.0 version: 1.5.0 @@ -663,6 +669,9 @@ importers: '@dataplan/pg': specifier: 1.0.3 version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@pgsql/quotes': + specifier: ^18.1.0 + version: 18.2.1 grafast: specifier: 1.0.2 version: 1.0.2(graphql@16.13.0) @@ -1132,6 +1141,9 @@ importers: '@dataplan/pg': specifier: 1.0.3 version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@pgsql/quotes': + specifier: ^18.1.0 + version: 18.2.1 graphile-build: specifier: 5.0.2 version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)