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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* Unit tests for resolveStorageConfigFromCodec.
*
* These are pure (no DB) tests covering both the single-tenant path (exact
* physical schema match) and the blueprint-pooling path, where the codec's
* build-time schema belongs to the REPRESENTATIVE tenant but the request's
* configs carry a DIFFERENT tenant's actual (differently-hashed) schema. In
* that case the resolver must fall back to matching on the LOGICAL schema
* name (the tenant hash prefix stripped from both sides).
*/

import { resolveStorageConfigFromCodec } from '../src/storage-module-cache';
import type { StorageModuleConfig } from '../src/types';

// --- Fixtures -------------------------------------------------------------

// Two real-shaped hashed tenant schemas that share the SAME logical suffix
// (`storage-public`). Under pooling, the shared instance is built against the
// representative tenant's schema, while requests route to other tenants.
const REPRESENTATIVE_SCHEMA = 'marketplace-db-tenant1-5e6b13b2-storage-public';
const TENANT_SCHEMA = 'marketplace-db-tenant2-35a03232-storage-public';

function makeConfig(
overrides: Partial<StorageModuleConfig> &
Pick<StorageModuleConfig, 'id' | 'schemaName' | 'bucketsTableName' | 'filesTableName'>,
): StorageModuleConfig {
return {
bucketsQualifiedName: `"${overrides.schemaName}"."${overrides.bucketsTableName}"`,
filesQualifiedName: `"${overrides.schemaName}"."${overrides.filesTableName}"`,
scope: 'app',
entityTableId: null,
entityQualifiedName: null,
endpoint: null,
publicUrlPrefix: null,
provider: null,
allowedOrigins: null,
uploadUrlExpirySeconds: 900,
downloadUrlExpirySeconds: 3600,
defaultMaxFileSize: 200 * 1024 * 1024,
maxFilenameLength: 1024,
cacheTtlSeconds: 3600,
hasPathShares: false,
maxBulkFiles: 100,
maxBulkTotalSize: 1073741824,
...overrides,
};
}

function makeCodec(schemaName: string | undefined, name: string | undefined) {
return { name: name as string, extensions: { pg: { schemaName, name } } };
}

// --- Tests ----------------------------------------------------------------

describe('resolveStorageConfigFromCodec', () => {
describe('single-tenant (exact physical schema match)', () => {
it('matches a files codec by exact schema + files table name', () => {
const config = makeConfig({
id: 'sm-1',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = makeCodec(TENANT_SCHEMA, 'app_files');

expect(resolveStorageConfigFromCodec(codec, [config])).toBe(config);
});

it('matches a buckets codec by exact schema + buckets table name', () => {
const config = makeConfig({
id: 'sm-1',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = makeCodec(TENANT_SCHEMA, 'app_buckets');

expect(resolveStorageConfigFromCodec(codec, [config])).toBe(config);
});

it('matches plain (non-hashed) schemas exactly, unaffected by hash stripping', () => {
const config = makeConfig({
id: 'sm-1',
schemaName: 'app_public',
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = makeCodec('app_public', 'app_files');

expect(resolveStorageConfigFromCodec(codec, [config])).toBe(config);
});
});

describe('blueprint pooling (logical schema match across tenants)', () => {
it('matches a files codec built for the representative tenant against another tenant config', () => {
// Build-time codec: representative tenant. Request config: a DIFFERENT tenant.
const tenantConfig = makeConfig({
id: 'sm-tenant2',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = makeCodec(REPRESENTATIVE_SCHEMA, 'app_files');

// Physical schemas differ; logical suffix (`storage-public`) is shared.
expect(REPRESENTATIVE_SCHEMA).not.toEqual(TENANT_SCHEMA);
expect(resolveStorageConfigFromCodec(codec, [tenantConfig])).toBe(tenantConfig);
});

it('matches a buckets codec built for the representative tenant against another tenant config', () => {
const tenantConfig = makeConfig({
id: 'sm-tenant2',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = makeCodec(REPRESENTATIVE_SCHEMA, 'app_buckets');

expect(resolveStorageConfigFromCodec(codec, [tenantConfig])).toBe(tenantConfig);
});

it('does NOT match when the logical schema suffix differs', () => {
// A codec whose logical schema is `analytics-public` must not resolve to a
// `storage-public` config even though both are hashed tenant schemas.
const tenantConfig = makeConfig({
id: 'sm-tenant2',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = makeCodec('marketplace-db-tenant1-5e6b13b2-analytics-public', 'app_files');

expect(resolveStorageConfigFromCodec(codec, [tenantConfig])).toBeNull();
});
});

describe('exact-physical priority', () => {
it('prefers an exact physical match over a logical-only match', () => {
const exactConfig = makeConfig({
id: 'sm-exact',
schemaName: REPRESENTATIVE_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const logicalOnlyConfig = makeConfig({
id: 'sm-logical',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = makeCodec(REPRESENTATIVE_SCHEMA, 'app_files');

// exactConfig listed AFTER the logical-only one to prove priority is by
// match quality, not array order.
const result = resolveStorageConfigFromCodec(codec, [logicalOnlyConfig, exactConfig]);
expect(result).toBe(exactConfig);
});
});

describe('non-matches and guards', () => {
it('returns null when the table name matches nothing', () => {
const config = makeConfig({
id: 'sm-1',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = makeCodec(REPRESENTATIVE_SCHEMA, 'some_unrelated_table');

expect(resolveStorageConfigFromCodec(codec, [config])).toBeNull();
});

it('returns null when the codec has no schemaName', () => {
const config = makeConfig({
id: 'sm-1',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = makeCodec(undefined, 'app_files');

expect(resolveStorageConfigFromCodec(codec, [config])).toBeNull();
});

it('returns null when the codec has no resolvable table name', () => {
const config = makeConfig({
id: 'sm-1',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = { name: undefined as unknown as string, extensions: { pg: { schemaName: REPRESENTATIVE_SCHEMA } } };

expect(resolveStorageConfigFromCodec(codec, [config])).toBeNull();
});

it('falls back to codec.name for the table name when extensions.pg.name is absent', () => {
const config = makeConfig({
id: 'sm-1',
schemaName: TENANT_SCHEMA,
bucketsTableName: 'app_buckets',
filesTableName: 'app_files',
});
const codec = { name: 'app_files', extensions: { pg: { schemaName: REPRESENTATIVE_SCHEMA } } };

expect(resolveStorageConfigFromCodec(codec, [config])).toBe(config);
});
});
});
52 changes: 50 additions & 2 deletions graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,12 +354,46 @@ export async function loadAllStorageModules(
return configs;
}

/**
* Strip a tenant hash prefix from a physical schema name, returning the
* logical schema name.
*
* Hashed multi-tenant schemas are named like
* `marketplace-db-tenant1-5e6b13b2-app-public`, where `-5e6b13b2-` is an
* 8-hex-char tenant hash separating the tenant prefix from the logical schema
* suffix (`app-public`). This removes everything up to and including the first
* such hash segment. If no hash segment is present (e.g. a plain schema like
* `app_public` or a control-plane schema like `metaschema_public`), the name
* is returned unchanged.
*
* (Duplicated locally rather than imported to avoid a cross-package dependency
* for a one-line helper.)
*/
function stripSchemaHashPrefix(name: string): string {
const match = /-[0-9a-f]{8}-/.exec(name);
return match ? name.slice(match.index + match[0].length) : name;
}

/**
* Resolve the storage module config from a PostGraphile pgCodec.
*
* Matches the codec's schema + table name against cached storage modules.
* Works for both files codecs (@storageFiles) and buckets codecs (@storageBuckets).
*
* Matching is two-tier:
* 1. Exact physical schema match — used for single-tenant / non-pooled
* instances where the codec's build-time schema equals the request
* tenant's actual schema. Keeps existing behavior unchanged.
* 2. Logical schema match — strip the tenant hash prefix from BOTH the
* codec's schema and each config's schema before comparing. Under
* blueprint pooling a single shared instance serves every tenant of a
* given schema-shape, so the codec's build-time `schemaName` belongs to
* the representative tenant while `allConfigs` carry the request tenant's
* actual (differently-hashed) schema. Both share the same logical suffix
* (e.g. `app-public`). Safe because `allConfigs` is already filtered to a
* single databaseId, so (tableName, logicalSchema) uniquely identifies a
* module.
*
* @param pgCodec - The PostGraphile codec (has extensions.pg.schemaName, name)
* @param allConfigs - All storage module configs for this database
* @returns The matching StorageModuleConfig or null
Expand All @@ -373,10 +407,24 @@ export function resolveStorageConfigFromCodec(

if (!schemaName || !tableName) return null;

return allConfigs.find((c) =>
// Priority 1: exact physical schema match (non-pooled / single-tenant).
const exact = allConfigs.find((c) =>
(c.filesTableName === tableName && c.schemaName === schemaName) ||
(c.bucketsTableName === tableName && c.schemaName === schemaName),
) || null;
);
if (exact) return exact;

// Priority 2: logical schema match (blueprint pooling — the codec's
// build-time schema belongs to the representative tenant; strip the tenant
// hash from both sides so the shared logical suffix lines up).
const logicalSchema = stripSchemaHashPrefix(schemaName);
return allConfigs.find((c) => {
const configLogicalSchema = stripSchemaHashPrefix(c.schemaName);
return (
(c.filesTableName === tableName && configLogicalSchema === logicalSchema) ||
(c.bucketsTableName === tableName && configLogicalSchema === logicalSchema)
);
}) || null;
}

// --- Bucket metadata cache ---
Expand Down
12 changes: 12 additions & 0 deletions graphile/graphile-search/src/codecs/bm25-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ export interface Bm25IndexInfo {
*
* Key: "schemaName.tableName.columnName"
* Value: Bm25IndexInfo
*
* NOTE (concurrency + blueprint pooling): this store is process-global and is
* cleared + repopulated per introspection run. Concurrent gathers in one
* process are serialized by the server's build semaphore
* (GRAPHILE_BUILD_CONCURRENCY, default 1), so a single global store is
* tolerated for now; if that guarantee ever moves, key this store by build
* object like meta-schema's per-build cache. The adapter embeds the
* schema-qualified index name from these entries as a bind VALUE at plan
* time; the pooling rewrite seam maps schema identifiers in SQL TEXT only and
* never rewrites values, so shapes carrying bm25 indexes are structurally
* excluded from pooling (see graphql/server pooling-decision.ts) and always
* get per-tenant instances.
*/
export const bm25IndexStore = new Map<string, Bm25IndexInfo>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,28 @@ function buildTableMeta(
};
}

/**
* Strip the tenant hash prefix (`<dashed-dbname>-<8hex>-`) from a physical
* schema name, returning the logical name; names without a hash segment are
* returned unchanged. Local duplicate of the server-side helper to avoid a
* cross-package dependency.
*/
function stripSchemaHashPrefix(name: string): string {
const match = /-[0-9a-f]{8}-/.exec(name);
if (!match) return name;
return name.slice(match.index + match[0].length);
}

export function collectTablesMeta(build: MetaBuild): TableMeta[] {
const configuredSchemas = getConfiguredSchemas(build);
const context = createBuildContext(build);
const seenCodecs = new Set<PgCodec>();
const tablesMeta: TableMeta[] = [];
// Shared (pooled) instances serve many tenants: reporting the build-time
// PHYSICAL schema name would leak the representative tenant's hashed schema
// identifier to every other tenant via _meta. When schema.constructivePooled
// is set, report the logical (hash-stripped) name instead.
const pooled = !!(build.options as any)?.constructivePooled;

for (const resource of Object.values(build.input.pgRegistry.pgResources || {})) {
if (!isTableResource(resource)) continue;
Expand All @@ -137,7 +154,9 @@ export function collectTablesMeta(build: MetaBuild): TableMeta[] {
continue;
}

tablesMeta.push(buildTableMeta(resource, schemaName, context));
tablesMeta.push(
buildTableMeta(resource, pooled ? stripSchemaHashPrefix(schemaName) : schemaName, context)
);
}

return tablesMeta;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe('collectMetricsSample', () => {
expect(typeof sample.counters.disposals).toBe('number');
expect(typeof sample.counters.evictions.lru).toBe('number');
expect(typeof sample.counters.builds).toBe('number');
expect(typeof sample.counters.poolingAttaches).toBe('number');
expect(typeof sample.counters.buildQueueDepth).toBe('number');
expect(typeof sample.counters.rewritePool.rewrittenQueries).toBe('number');
expect(typeof sample.counters.introspectionFilter.swaps).toBe('number');
Expand Down
2 changes: 2 additions & 0 deletions graphql/server/src/middleware/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getPgPool } from 'pg-cache';
import errorPage50x from '../errors/50x';
import errorPage404Message from '../errors/404-message';
import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, DatabaseSettings, PubkeyChallengeSettings, RlsModule, WebauthnSettings } from '../types';
import { stripSchemaHashPrefix } from './blueprint';
import './types';

const log = new Logger('api');
Expand Down Expand Up @@ -266,6 +267,7 @@ const toApiStructure = (row: ApiRow, opts: ApiOptions, settings: ResolvedModuleS
anonRole: row.anon_role || 'anon',
roleName: row.role_name || 'authenticated',
schema: row.schemas || [],
logicalSchemas: (row.schemas || []).map(stripSchemaHashPrefix),
apiModules: [],
rlsModule: settings.rlsModule,
domains: [],
Expand Down
Loading
Loading