diff --git a/.gitignore b/.gitignore
index 60d96a6d7e..0f22ee4a51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,8 @@ postgres/pgsql-test/output/
.env.local
graphql/server/logs/
graphql/server/*.heapsnapshot
+graphile-density-artifacts/
+/research/graphile-density/artifacts/
# Ephemeral pgpm modules installed by `pnpm fixtures:install` (pgpm install)
/extensions/
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 d0257abee5..f3e641b890 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-cache/README.md b/graphile/graphile-cache/README.md
index 5eb5c674ba..7582dd6db9 100644
--- a/graphile/graphile-cache/README.md
+++ b/graphile/graphile-cache/README.md
@@ -13,7 +13,8 @@
-PostGraphile instance LRU cache with automatic cleanup when PostgreSQL pools are disposed.
+Heap-budgeted PostGraphile v5 instance cache with request draining, serialized
+build admission, and explicit PostgreSQL pool ownership.
## Installation
@@ -21,119 +22,134 @@ PostGraphile instance LRU cache with automatic cleanup when PostgreSQL pools are
npm install graphile-cache pg-cache
```
-Note: This package depends on `pg-cache` for the PostgreSQL pool management.
+`graphile-cache` uses `pg-cache` leases to keep each resident instance's exact
+runtime pool alive until the instance has fully drained and shut down.
## Features
-- LRU cache for PostGraphile instances
-- Automatic cleanup when associated PostgreSQL pools are disposed
-- Integrates seamlessly with `pg-cache`
-- Service cache re-exported for convenience
-- TypeScript support
+- Heap-derived residency limits plus an optional process-RSS admission ceiling
+- Request-aware eviction that never tears down an instance in use
+- Awaited HTTP, realtime, PostGraphile, and pool-lease teardown
+- Memory-pressure refusal and eviction counters
+- Exact pool identities protected by reference-counted `pg-cache` leases
## How It Works
-When you import this package, it automatically registers a cleanup callback with `pg-cache`. When a PostgreSQL pool is disposed, any PostGraphile instances using that pool are automatically removed from the cache.
+Long-lived callers acquire a `PgPoolLease`, configure PostGraphile with the
+lease's pool, and pass the same lease to `createGraphileInstance()`. Ownership
+transfers to the returned entry only when that promise resolves. If creation
+rejects, the caller still owns the lease and must release it.
+
+Eviction marks the entry as disposing, waits for its requests to drain, closes
+the HTTP server, stops realtime delivery, attempts `pgl.release()`, and finally
+releases the pool lease. Every teardown stage is attempted even when an earlier
+stage fails, and duplicate disposal calls share one promise.
## Usage
-### Basic Usage
+### Creating a leased instance
```typescript
-import { graphileCache, GraphileCache } from 'graphile-cache';
-import { getPgPool } from 'pg-cache';
-import { postgraphile } from 'postgraphile';
-
-// Create a PostGraphile instance
-const pgPool = getPgPool({ database: 'mydb' });
-const handler = postgraphile(pgPool, 'public', {
- // PostGraphile options
-});
-
-// Cache it
-const cacheEntry: GraphileCache = {
- pgPool,
- pgPoolKey: 'mydb',
- handler
-};
-
-graphileCache.set('mydb.public', cacheEntry);
+import {
+ createGraphileInstance,
+ disposeUncachedEntry,
+ graphileCache
+} from 'graphile-cache';
+import { acquirePgPool } from 'pg-cache';
+
+const cacheKey = 'tenant-id:api-id:build-contract-hash';
+const lease = acquirePgPool(
+ { database: 'tenant_database' },
+ { purpose: 'runtime', sanitizeOnCheckout: true }
+);
+
+// Application code builds this preset with makePgService({ pool: lease.pool,
+// schemas: ['tenant_api'] }) and its exact plugin/settings contract.
+const preset = makePreset(lease.pool);
+let entry;
+try {
+ entry = await createGraphileInstance({
+ preset,
+ cacheKey,
+ poolLease: lease,
+ poolIdentity: lease.identity,
+ enableRealtime: true,
+ realtimeSchema: 'tenant_a_realtime',
+ realtimeSourceSchemas: ['tenant_api']
+ });
+} catch (error) {
+ // Creation rejected before ownership transfer.
+ lease.release();
+ throw error;
+}
-// Retrieve it later
-const cached = graphileCache.get('mydb.public');
-if (cached) {
- // Use cached.handler
+// Creation resolved, so disposal must now release the entry-owned lease if
+// admission or publication fails.
+try {
+ graphileCache.set(cacheKey, entry);
+} catch (error) {
+ await disposeUncachedEntry(entry, cacheKey);
+ throw error;
}
```
-### Automatic Cleanup
+`poolIdentity` is optional when `poolLease` is present because the lease identity
+becomes the entry's authoritative identity. Supplying both with different values
+fails before ownership transfers.
-The cleanup happens automatically:
+### Serving and eviction
```typescript
-import { pgCache } from 'pg-cache';
-import { graphileCache } from 'graphile-cache';
-
-// Add entries
-graphileCache.set('mydb.public', { pgPoolKey: 'mydb', ... });
-graphileCache.set('mydb.private', { pgPoolKey: 'mydb', ... });
-
-// When the pool is removed...
-pgCache.delete('mydb');
-
-// Both graphile entries are automatically cleaned up!
-console.log(graphileCache.has('mydb.public')); // false
-console.log(graphileCache.has('mydb.private')); // false
-```
-
-### Complete Example
-
-```typescript
-import { graphileCache, GraphileCache } from 'graphile-cache';
-import { getPgPool } from 'pg-cache';
-import { postgraphile } from 'postgraphile';
-
-function getGraphileInstance(database: string, schema: string): GraphileCache {
- const key = `${database}.${schema}`;
-
- // Check cache first
- const cached = graphileCache.get(key);
- if (cached) {
- return cached;
- }
-
- // Create new instance
- const pgPool = getPgPool({ database });
- const handler = postgraphile(pgPool, schema, {
- graphqlRoute: '/graphql',
- graphiqlRoute: '/graphiql',
- // other options...
- });
-
- const entry: GraphileCache = {
- pgPool,
- pgPoolKey: database,
- handler
- };
-
- // Cache it
- graphileCache.set(key, entry);
- return entry;
+import {
+ deleteGraphileCacheEntry,
+ graphileCache,
+ invokeEntryHandler
+} from 'graphile-cache';
+
+const entry = graphileCache.get(cacheKey);
+if (entry && invokeEntryHandler(entry, req, res, next)) {
+ return;
}
-// Use in Express
-app.use((req, res, next) => {
- const { handler } = getGraphileInstance('mydb', 'public');
- handler(req, res, next);
-});
+// Resolves only after teardown and pool-lease release complete.
+await deleteGraphileCacheEntry(cacheKey, 'manual');
```
+Use `invokeEntryHandler()` for resident traffic so disposal can observe in-flight
+requests. A false return means the entry has started draining; route the request
+through normal cache-miss/build admission instead.
+
+### Shared exact-topic realtime
+
+`sharedRealtime` is an opt-in build-time seam. The caller installs one
+`ActivatableGenerationScopedRealtimeSubscriber` in the PostGraphile service,
+collects the exact physical `@realtime` topics during schema construction, and
+supplies a dedicated least-privilege listener login. Instance creation audits
+that login on the broker's pinned client, acquires only those topics, and
+activates the subscriber before the entry can be published. Audit and LISTEN
+therefore remain safe when the notification pool has `max: 1`.
+
+One canonical host/port/database target may have only one active opaque listener
+identity and role. TLS remains part of that listener identity, so a TLS,
+credential, or pool-contract change fails closed while the old generation is
+resident instead of opening a second listener and silently reducing density;
+rotate by invalidating and draining the old generations first. Resolver output
+must use stable canonical connection target values, because two DNS aliases for
+the same server cannot be proven to name one physical database in-process.
+
+Successful role audits have an explicit TTL. One unref'ed timer per exact
+listener identity proactively re-audits idle subscriptions, while HTTP and
+WebSocket operation boundaries use the same coalesced refresh as an immediate
+gate. Broker termination and privilege drift latch every affected generation
+unavailable. The timer is cancelled after the last generation releases. The
+default realtime mode remains the dedicated PostGraphile subscriber.
+
### Graceful Shutdown
```typescript
import { closeAllCaches } from 'graphile-cache';
-// This closes all caches including pg pools
+// Drains Graphile entries first, then closes the remaining pg-cache pools.
process.on('SIGTERM', async () => {
await closeAllCaches();
process.exit(0);
@@ -142,39 +158,64 @@ process.on('SIGTERM', async () => {
## API Reference
-### graphileCache
-
-The main PostGraphile instance cache.
-
-- `get(key: string): GraphileCache | undefined` - Get a cached instance
-- `set(key: string, value: GraphileCache): void` - Cache an instance
-- `has(key: string): boolean` - Check if an instance is cached
-- `delete(key: string): void` - Remove an instance
-- `clear(): void` - Remove all instances
-
-### GraphileCache Interface
-
-```typescript
-interface GraphileCache {
- pgPool: pg.Pool;
- pgPoolKey: string;
- handler: HttpRequestHandler;
-}
-```
-
-### closeAllCaches()
-
-Closes all caches including the service cache, graphile cache, and all PostgreSQL pools.
-
-### svcCache
-
-Re-exported from `pg-cache` for convenience.
-
-## Integration Details
-
-The integration with `pg-cache` happens automatically when this module is imported. The cleanup callback is registered immediately, ensuring that PostGraphile instances are cleaned up whenever their associated PostgreSQL pools are disposed.
-
-This design ensures:
-- No memory leaks from orphaned PostGraphile instances
-- Automatic cleanup without manual intervention
-- Loose coupling between packages
+### Main lifecycle APIs
+
+- `createGraphileInstance(options)` creates a ready PostGraphile entry and
+ accepts an optional retained `PgPoolLease`. Realtime callers may provide the
+ exact cursor-function schema through `realtimeSchema`; omission preserves the
+ `realtime_public` compatibility default. Realtime also requires exact
+ `realtimeSourceSchemas`; a foreign cursor row stops delivery before any row
+ in that batch is emitted. Cursor node IDs combine a process-unique replica
+ identity with the exact cache contract so replicas cannot share cursor state.
+ A fatal delivery-integrity failure latches that exact generation unhealthy;
+ the next request receives `503 GRAPHILE_REALTIME_UNAVAILABLE`, never enters
+ its Graphile handler, and identity-checks the generation before retiring it
+ so a later request can rebuild without risking a healthy replacement.
+- `invokeEntryHandler(entry, req, res, next)` tracks a request against an exact
+ resident entry.
+- `deleteGraphileCacheEntry(key, reason)` evicts and awaits teardown.
+- `clearGraphileCache()` evicts and awaits every resident entry.
+- `closeAllCaches()` drains Graphile entries, then closes `pg-cache`.
+
+### Capacity and observability
+
+- `prepareCacheForBuild()` serializes admission with awaited eviction.
+- `getCacheConfig()` reports the heap-derived capacity and calibration sources.
+- `getCacheStats()` reports residency, realtime-unhealthy generations,
+ aggregate credential-free listener-role attestation health, unique active
+ broker identities, and monotonic catalog-audit attempts/failures. Generation
+ references are reported separately, so three API surfaces sharing one role
+ audit don't triple-count its database QPS.
+- `getCacheCounters()` reports monotonic admitted/completed HTTP and WebSocket
+ lifecycles alongside evictions, disposal failures, and build refusals. The
+ lifecycle counters make short-lived work observable even when both ends fall
+ between two state snapshots.
+- `startMemoryGovernor()` starts pressure-driven idle eviction and returns an
+ idempotent stop callback.
+
+`GRAPHILE_CACHE_MAX` caps Graphile build contracts by heap budget.
+`GRAPHILE_CACHE_ADMISSION_MODE=preserve-resident` makes that ceiling a strict
+admission boundary: a new contract receives `resident_capacity` without
+evicting an existing resident. The default, `evict-idle`, retains the ordinary
+LRU replacement behavior.
+`GRAPHILE_CACHE_RSS_LIMIT_BYTES` adds a fail-closed process-RSS ceiling, and
+admission reserves `GRAPHILE_CACHE_RSS_BUILD_RESERVE_BYTES` (768 MiB by
+default) above current RSS before starting a build. When the RSS ceiling is not
+set, RSS remains present in cache pressure telemetry but does not constrain
+admission. `PG_CACHE_MAX`
+caps PostgreSQL connection identities, which may include runtime, control-plane,
+listener, and diagnostic pools. They are independent limits: a resident entry's
+lease prevents ordinary pool LRU or TTL eviction, and acquiring a new identity
+fails closed when every registry slot is leased.
+
+The LRU's internal ceiling scales with the configured V8 heap (one sparse slot
+per 256 KiB, bounded from 1,024 to 65,536). It is only a backing-structure
+limit; measured instance cost, server/build reserves, and live pressure still
+decide how many entries may become resident.
+
+## Pool disposal integration
+
+The package still registers a `pg-cache` cleanup callback as a fail-safe for
+legacy unleased entries and explicit process-wide shutdown. Normal resident
+lifetime is lease-driven: Graphile disposal releases the lease, after which
+`pg-cache` may evict or expire the now-idle pool identity.
diff --git a/graphile/graphile-cache/package.json b/graphile/graphile-cache/package.json
index d8f4ac6080..ea625078d1 100644
--- a/graphile/graphile-cache/package.json
+++ b/graphile/graphile-cache/package.json
@@ -2,7 +2,7 @@
"name": "graphile-cache",
"version": "4.10.1",
"author": "Constructive ",
- "description": "PostGraphile v5 LRU cache with automatic pool cleanup integration",
+ "description": "Heap-aware PostGraphile v5 cache with leased PostgreSQL pool lifecycle",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
diff --git a/graphile/graphile-cache/src/__tests__/build-readiness.test.ts b/graphile/graphile-cache/src/__tests__/build-readiness.test.ts
new file mode 100644
index 0000000000..c054217bd3
--- /dev/null
+++ b/graphile/graphile-cache/src/__tests__/build-readiness.test.ts
@@ -0,0 +1,73 @@
+import { awaitGraphileBuildReadiness } from '../build-readiness';
+
+interface Deferred {
+ promise: Promise;
+ resolve(value: T): void;
+ reject(error: Error): void;
+}
+
+const deferred = (): Deferred => {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+const flushPromises = (): Promise => new Promise((resolve) => setImmediate(resolve));
+
+describe('awaitGraphileBuildReadiness', () => {
+ it('does not resolve before the schema build completes', async () => {
+ const schemaResult = deferred();
+ const release = jest.fn().mockResolvedValue(undefined);
+ let resolved = false;
+ const buildPromise = awaitGraphileBuildReadiness({
+ schemaResult: schemaResult.promise,
+ addTo: jest.fn().mockResolvedValue(undefined),
+ ready: jest.fn().mockResolvedValue(undefined),
+ release
+ }).then(() => {
+ resolved = true;
+ });
+
+ await flushPromises();
+ expect(resolved).toBe(false);
+
+ schemaResult.resolve({});
+ await buildPromise;
+ expect(release).not.toHaveBeenCalled();
+ });
+
+ it('releases the failed generation before rejecting', async () => {
+ const schemaResult = deferred();
+ const release = jest.fn().mockResolvedValue(undefined);
+ const buildPromise = awaitGraphileBuildReadiness({
+ schemaResult: schemaResult.promise,
+ addTo: jest.fn().mockResolvedValue(undefined),
+ ready: jest.fn().mockResolvedValue(undefined),
+ release
+ });
+ const failure = new Error('schema build failed');
+ schemaResult.reject(failure);
+
+ await expect(buildPromise).rejects.toBe(failure);
+ expect(release).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves the build failure if cleanup also fails', async () => {
+ const failure = new Error('schema build failed');
+ const cleanupFailure = new Error('release failed');
+ const onReleaseError = jest.fn();
+
+ await expect(awaitGraphileBuildReadiness({
+ schemaResult: Promise.reject(failure),
+ addTo: jest.fn().mockResolvedValue(undefined),
+ ready: jest.fn().mockResolvedValue(undefined),
+ release: jest.fn().mockRejectedValue(cleanupFailure),
+ onReleaseError
+ })).rejects.toBe(failure);
+ expect(onReleaseError).toHaveBeenCalledWith(cleanupFailure);
+ });
+});
diff --git a/graphile/graphile-cache/src/__tests__/governor.test.ts b/graphile/graphile-cache/src/__tests__/governor.test.ts
new file mode 100644
index 0000000000..54d40b7842
--- /dev/null
+++ b/graphile/graphile-cache/src/__tests__/governor.test.ts
@@ -0,0 +1,634 @@
+import { EventEmitter } from 'node:events';
+
+import type { NextFunction, Request, Response } from 'express';
+import type { PgPoolLease } from 'pg-cache';
+
+import {
+ computeBackingCacheMax,
+ computeCapacityFromBudget,
+ disposeUncachedEntry,
+ evaluateBuildAdmission,
+ getCacheConfig,
+ getCacheCounters,
+ getCacheStats,
+ getInstanceHeapEstimate,
+ getMemoryPressure,
+ graphileCache,
+ type GraphileCacheEntry,
+ invokeEntryHandler,
+ prepareCacheForBuild,
+ raceWithClearedTimeout,
+ recordInstanceHeapSample,
+ resetInstanceHeapSamples,
+ waitForEntryDisposal
+} from '../graphile-cache';
+import { GRAPHILE_REALTIME_UNAVAILABLE_CODE } from '../realtime-readiness';
+
+const MB = 1024 * 1024;
+
+const makeEntry = (releaseDelayMs = 0): GraphileCacheEntry => ({
+ pgl: {
+ release: jest.fn(() => new Promise((resolve) => setTimeout(resolve, releaseDelayMs)))
+ } as unknown as GraphileCacheEntry['pgl'],
+ serv: {} as GraphileCacheEntry['serv'],
+ handler: jest.fn() as unknown as GraphileCacheEntry['handler'],
+ httpServer: null,
+ cacheKey: 'test',
+ createdAt: Date.now()
+});
+
+const makePoolLease = (onRelease?: () => void): PgPoolLease => ({
+ pool: {} as PgPoolLease['pool'],
+ identity: 'pg:v1:test-runtime',
+ release: jest.fn(() => onRelease?.())
+});
+
+describe('heap budget capacity', () => {
+ const calibrationEnv = [
+ 'GRAPHILE_CACHE_MAX',
+ 'GRAPHILE_CACHE_ADMISSION_MODE',
+ 'GRAPHILE_CACHE_INSTANCE_HEAP_BYTES',
+ 'GRAPHILE_CACHE_SERVER_RESERVE_BYTES',
+ 'GRAPHILE_CACHE_BUILD_RESERVE_BYTES',
+ 'GRAPHILE_CACHE_RSS_LIMIT_BYTES',
+ 'GRAPHILE_CACHE_RSS_BUILD_RESERVE_BYTES',
+ 'GRAPHILE_CACHE_CALIBRATION_ID'
+ ] as const;
+ let previousEnv: Record;
+
+ beforeEach(() => {
+ previousEnv = Object.fromEntries(
+ calibrationEnv.map((name) => [name, process.env[name]])
+ );
+ for (const name of calibrationEnv) delete process.env[name];
+ resetInstanceHeapSamples();
+ });
+
+ afterEach(() => {
+ resetInstanceHeapSamples();
+ for (const name of calibrationEnv) {
+ const value = previousEnv[name];
+ if (value === undefined) delete process.env[name];
+ else process.env[name] = value;
+ }
+ });
+
+ it('fits residency and one serialized build transient', () => {
+ expect(computeCapacityFromBudget(3584 * MB, 1450 * MB, 256 * MB, 768 * MB)).toBe(2);
+ expect(computeCapacityFromBudget(2048 * MB, 1450 * MB, 256 * MB, 768 * MB)).toBe(1);
+ });
+
+ it('returns zero when even the server and build reserves cannot fit', () => {
+ expect(computeCapacityFromBudget(512 * MB, 64 * MB, 256 * MB, 768 * MB)).toBe(0);
+ });
+
+ it('does not hide validated density behind a fixed backing-cache ceiling', () => {
+ expect(computeCapacityFromBudget(238 * MB, MB, MB, MB)).toBe(237);
+ expect(computeBackingCacheMax(1024 * MB)).toBe(4096);
+ expect(computeBackingCacheMax(4096 * MB)).toBe(16_384);
+ expect(graphileCache.max).toBeGreaterThanOrEqual(4096);
+ });
+
+ it('derives the backing ceiling from the modeled heap rather than this process', () => {
+ expect(computeCapacityFromBudget(1024 * MB, 1, 1, 1)).toBe(4096);
+ });
+
+ it('treats runtime samples as a safety floor rather than an unsafe downsize', () => {
+ recordInstanceHeapSample(30 * MB);
+ recordInstanceHeapSample(32 * MB);
+ recordInstanceHeapSample(34 * MB);
+ expect(getInstanceHeapEstimate()).toBe(512 * MB);
+ expect(getCacheConfig().calibration).toMatchObject({
+ instanceHeapSource: 'default',
+ instanceHeapSampleCount: 3
+ });
+ });
+
+ it('lets runtime samples raise an explicit calibrated floor', () => {
+ process.env.GRAPHILE_CACHE_INSTANCE_HEAP_BYTES = String(32 * MB);
+ recordInstanceHeapSample(40 * MB);
+ recordInstanceHeapSample(50 * MB);
+ recordInstanceHeapSample(60 * MB);
+ expect(getInstanceHeapEstimate()).toBe(60 * MB);
+ expect(getCacheConfig().calibration.instanceHeapSource).toBe(
+ 'runtime-safety-floor'
+ );
+ });
+
+ it('reports explicit calibration provenance and respects the operator ceiling', () => {
+ process.env.GRAPHILE_CACHE_INSTANCE_HEAP_BYTES = String(MB);
+ process.env.GRAPHILE_CACHE_SERVER_RESERVE_BYTES = String(MB);
+ process.env.GRAPHILE_CACHE_BUILD_RESERVE_BYTES = String(MB);
+ process.env.GRAPHILE_CACHE_MAX = '128';
+ process.env.GRAPHILE_CACHE_CALIBRATION_ID = 'cperf:fixture:sha256';
+ const config = getCacheConfig();
+ expect(config.max).toBe(128);
+ expect(config.calibration).toEqual({
+ id: 'cperf:fixture:sha256',
+ instanceHeapSource: 'environment',
+ instanceHeapSampleCount: 0,
+ serverReserveSource: 'environment',
+ buildReserveSource: 'environment'
+ });
+ });
+
+ it('defaults to idle eviction and strictly validates preserve-resident admission', () => {
+ expect(getCacheConfig().admissionMode).toBe('evict-idle');
+ process.env.GRAPHILE_CACHE_ADMISSION_MODE = 'preserve-resident';
+ expect(getCacheConfig().admissionMode).toBe('preserve-resident');
+ process.env.GRAPHILE_CACHE_ADMISSION_MODE = 'preserve';
+ expect(() => getCacheConfig()).toThrow(
+ 'GRAPHILE_CACHE_ADMISSION_MODE must be evict-idle or preserve-resident'
+ );
+ });
+
+ it('reports an explicit RSS ceiling and transient reservation', () => {
+ process.env.GRAPHILE_CACHE_RSS_LIMIT_BYTES = String(3 * 1024 * MB);
+ process.env.GRAPHILE_CACHE_RSS_BUILD_RESERVE_BYTES = String(96 * MB);
+
+ const config = getCacheConfig();
+ const pressure = getMemoryPressure();
+ const stats = getCacheStats();
+
+ expect(config).toMatchObject({
+ rssLimitBytes: 3 * 1024 * MB,
+ rssBuildReserveBytes: 96 * MB
+ });
+ expect(pressure).toMatchObject({
+ rssLimitBytes: 3 * 1024 * MB,
+ rssBytes: expect.any(Number),
+ rssRatio: expect.any(Number)
+ });
+ expect(stats).toMatchObject({
+ rssLimitBytes: 3 * 1024 * MB,
+ rssBuildReserveBytes: 96 * MB
+ });
+ });
+
+ it('keeps RSS observable but unbounded unless an operator sets a ceiling', () => {
+ expect(getMemoryPressure()).toMatchObject({
+ rssLimitBytes: null,
+ rssRatio: null,
+ rssLevel: 'unbounded',
+ rssBytes: expect.any(Number)
+ });
+ });
+
+ it('refuses a build whose live RSS plus transient reserve crosses the ceiling', () => {
+ const rssBytes = process.memoryUsage().rss;
+ process.env.GRAPHILE_CACHE_RSS_LIMIT_BYTES = String(rssBytes * 4);
+ process.env.GRAPHILE_CACHE_RSS_BUILD_RESERVE_BYTES = String(rssBytes * 5);
+
+ expect(evaluateBuildAdmission(0)).toMatchObject({
+ admit: false,
+ reason: 'rss_budget_exceeded',
+ rssLimitBytes: rssBytes * 4
+ });
+ });
+
+ it.each([
+ ['GRAPHILE_CACHE_INSTANCE_HEAP_BYTES', '0'],
+ ['GRAPHILE_CACHE_SERVER_RESERVE_BYTES', '-1'],
+ ['GRAPHILE_CACHE_BUILD_RESERVE_BYTES', '1.5'],
+ ['GRAPHILE_CACHE_RSS_LIMIT_BYTES', '0'],
+ ['GRAPHILE_CACHE_RSS_BUILD_RESERVE_BYTES', '-10'],
+ ['GRAPHILE_CACHE_MAX', '12entries'],
+ ['GRAPHILE_CACHE_MAX', String(Number.MAX_SAFE_INTEGER + 1)]
+ ])('rejects invalid explicit calibration %s=%s', (name, value) => {
+ process.env[name] = value;
+ expect(() => getCacheConfig()).toThrow('must be a positive safe integer');
+ });
+
+ it('rejects an operator ceiling above the heap-scaled backing cache', () => {
+ process.env.GRAPHILE_CACHE_MAX = String(graphileCache.max + 1);
+ expect(() => getCacheConfig()).toThrow('exceeds heap-scaled backing ceiling');
+ });
+});
+
+describe('entry-scoped awaited disposal', () => {
+ afterEach(async () => {
+ graphileCache.clear();
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ });
+
+ it('disposes distinct rebuilt entries with the same key exactly once each', async () => {
+ const first = makeEntry();
+ const second = makeEntry();
+ await Promise.all([
+ disposeUncachedEntry(first, 'same-key'),
+ disposeUncachedEntry(second, 'same-key')
+ ]);
+ expect(first.pgl.release).toHaveBeenCalledTimes(1);
+ expect(second.pgl.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('waits for a resident request before releasing the instance', async () => {
+ const entry = makeEntry();
+ const response = new EventEmitter() as unknown as Response;
+ invokeEntryHandler(
+ entry,
+ {} as Request,
+ response,
+ (() => undefined) as NextFunction
+ );
+ graphileCache.set('drain', entry);
+ graphileCache.delete('drain');
+
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ expect(entry.pgl.release).not.toHaveBeenCalled();
+ (response as unknown as EventEmitter).emit('finish');
+ await expect(waitForEntryDisposal(entry, 100)).resolves.toBe(true);
+ expect(entry.pgl.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not enter an instance after the request has already closed', () => {
+ const entry = makeEntry();
+ const request = Object.assign(new EventEmitter(), {
+ aborted: true,
+ destroyed: true,
+ socket: { destroyed: true }
+ }) as unknown as Request;
+ const response = Object.assign(new EventEmitter(), {
+ destroyed: true,
+ writableEnded: true
+ }) as unknown as Response;
+
+ expect(invokeEntryHandler(
+ entry,
+ request,
+ response,
+ (() => undefined) as NextFunction
+ )).toBe(false);
+ expect(entry.handler).not.toHaveBeenCalled();
+ expect(entry.inflight ?? 0).toBe(0);
+ });
+
+ it('does enter after a JSON body parser consumed the request stream', () => {
+ const entry = makeEntry();
+ const countersBefore = getCacheCounters();
+ const request = Object.assign(new EventEmitter(), {
+ aborted: false,
+ // Express/raw-body may destroy the readable request stream after fully
+ // consuming it while the underlying keep-alive socket remains healthy.
+ destroyed: true,
+ socket: { destroyed: false }
+ }) as unknown as Request;
+ const response = Object.assign(new EventEmitter(), {
+ destroyed: false,
+ writableEnded: false
+ }) as unknown as Response;
+
+ expect(invokeEntryHandler(
+ entry,
+ request,
+ response,
+ (() => undefined) as NextFunction
+ )).toBe(true);
+ expect(entry.handler).toHaveBeenCalledTimes(1);
+ expect(entry.inflight).toBe(1);
+ expect(getCacheCounters().httpRequestsStarted).toBe(
+ countersBefore.httpRequestsStarted + 1
+ );
+ expect(getCacheCounters().httpRequestsCompleted).toBe(
+ countersBefore.httpRequestsCompleted
+ );
+ (response as unknown as EventEmitter).emit('finish');
+ // Express can emit close after finish; the completion counter remains
+ // monotonic and records this request exactly once.
+ (response as unknown as EventEmitter).emit('close');
+ expect(entry.inflight).toBe(0);
+ expect(getCacheCounters().httpRequestsCompleted).toBe(
+ countersBefore.httpRequestsCompleted + 1
+ );
+ });
+
+ it('returns a stable 503 and retires the exact realtime-unhealthy generation', async () => {
+ const entry = makeEntry();
+ entry.cacheKey = 'realtime-unhealthy';
+ entry.realtimeHealth = {
+ status: 'failed',
+ failureCode: 'REALTIME_SOURCE_SCHEMA_VIOLATION',
+ failedAt: 1_000
+ };
+ graphileCache.set('realtime-unhealthy', entry);
+ expect(getCacheStats().realtimeUnhealthy).toBe(1);
+ const response = Object.assign(new EventEmitter(), {
+ destroyed: false,
+ writableEnded: false,
+ headersSent: false,
+ setHeader: jest.fn(),
+ status: jest.fn(),
+ json: jest.fn()
+ });
+ response.status.mockReturnValue(response);
+
+ expect(invokeEntryHandler(
+ entry,
+ {} as Request,
+ response as unknown as Response,
+ (() => undefined) as NextFunction
+ )).toBe(true);
+
+ expect(entry.handler).not.toHaveBeenCalled();
+ expect(entry.inflight ?? 0).toBe(0);
+ expect(response.setHeader).toHaveBeenCalledWith('Retry-After', '15');
+ expect(response.status).toHaveBeenCalledWith(503);
+ expect(response.json).toHaveBeenCalledWith({
+ error: {
+ code: GRAPHILE_REALTIME_UNAVAILABLE_CODE,
+ message: 'Realtime delivery is unavailable for this GraphQL instance'
+ }
+ });
+ expect(graphileCache.has('realtime-unhealthy')).toBe(false);
+ await expect(waitForEntryDisposal(entry, 100)).resolves.toBe(true);
+ expect(entry.pgl.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('fails closed when a listener-role attestation expires before invocation', () => {
+ const entry = makeEntry();
+ entry.realtimeRoleAttestation = {
+ snapshot: jest.fn(() => ({
+ version: 1,
+ mode: 'shared-exact',
+ listenerIdentity: 'opaque-listener-identity',
+ auditVersion: 'pg-notification-role:v1',
+ role: 'listener',
+ database: 'tenant_a',
+ lastAttestedAt: 1,
+ validUntil: 2,
+ checks: 1,
+ status: 'healthy',
+ failureCode: null as string | null,
+ failedAt: null as number | null
+ })),
+ revalidateIfDue: jest.fn(async () => true),
+ release: jest.fn()
+ };
+ const response = Object.assign(new EventEmitter(), {
+ destroyed: false,
+ writableEnded: false,
+ headersSent: false,
+ setHeader: jest.fn(),
+ status: jest.fn(),
+ json: jest.fn()
+ });
+ response.status.mockReturnValue(response);
+
+ expect(invokeEntryHandler(
+ entry,
+ {} as Request,
+ response as unknown as Response,
+ (() => undefined) as NextFunction
+ )).toBe(true);
+
+ expect(entry.handler).not.toHaveBeenCalled();
+ expect(response.status).toHaveBeenCalledWith(503);
+ expect(response.json).toHaveBeenCalledWith({
+ error: {
+ code: GRAPHILE_REALTIME_UNAVAILABLE_CODE,
+ message: 'Realtime delivery is unavailable for this GraphQL instance'
+ }
+ });
+ });
+
+ it('never lets a stale realtime generation evict a healthy replacement', () => {
+ const stale = makeEntry();
+ stale.cacheKey = 'shared-contract';
+ stale.realtimeHealth = {
+ status: 'failed',
+ failureCode: 'REALTIME_SOURCE_SCHEMA_VIOLATION',
+ failedAt: 1_000
+ };
+ const replacement = makeEntry();
+ replacement.cacheKey = 'shared-contract';
+ graphileCache.set('shared-contract', replacement);
+ const response = Object.assign(new EventEmitter(), {
+ destroyed: false,
+ writableEnded: false,
+ headersSent: false,
+ setHeader: jest.fn(),
+ status: jest.fn(),
+ json: jest.fn()
+ });
+ response.status.mockReturnValue(response);
+
+ expect(invokeEntryHandler(
+ stale,
+ {} as Request,
+ response as unknown as Response,
+ (() => undefined) as NextFunction
+ )).toBe(true);
+
+ expect(graphileCache.peek('shared-contract')).toBe(replacement);
+ expect(replacement.disposing).not.toBe(true);
+ expect(stale.disposing).not.toBe(true);
+ expect(stale.handler).not.toHaveBeenCalled();
+ expect(response.status).toHaveBeenCalledWith(503);
+ });
+
+ it('releases if the response closes while terminal listeners are attached', () => {
+ const entry = makeEntry();
+ const request = new EventEmitter() as unknown as Request;
+ const response = new EventEmitter() as unknown as Response;
+ let terminalChecks = 0;
+ Object.defineProperty(response, 'writableEnded', {
+ get: () => ++terminalChecks >= 2
+ });
+
+ expect(invokeEntryHandler(
+ entry,
+ request,
+ response,
+ (() => undefined) as NextFunction
+ )).toBe(false);
+ expect(entry.handler).not.toHaveBeenCalled();
+ expect(entry.inflight).toBe(0);
+ expect((response as unknown as EventEmitter).listenerCount('finish')).toBe(0);
+ expect((response as unknown as EventEmitter).listenerCount('close')).toBe(0);
+ });
+
+ it('releases the pool lease after the complete teardown sequence', async () => {
+ const events: string[] = [];
+ const entry = makeEntry();
+ entry.httpServer = {
+ close: (callback: () => void) => {
+ events.push('http-close');
+ callback();
+ }
+ } as unknown as GraphileCacheEntry['httpServer'];
+ entry.realtimeManager = {
+ stop: jest.fn(async () => {
+ events.push('realtime-stop');
+ })
+ };
+ entry.pgl = {
+ release: jest.fn(async () => {
+ events.push('postgraphile-release');
+ })
+ } as unknown as GraphileCacheEntry['pgl'];
+ entry.releasePresetServices = jest.fn(async () => {
+ events.push('preset-services-release');
+ });
+ entry.poolLease = makePoolLease(() => events.push('pool-lease-release'));
+
+ const response = new EventEmitter() as unknown as Response;
+ invokeEntryHandler(entry, {} as Request, response, (() => undefined) as NextFunction);
+ const disposal = disposeUncachedEntry(entry, 'ordered');
+
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(events).toEqual([]);
+
+ (response as unknown as EventEmitter).emit('finish');
+ await disposal;
+ expect(events).toEqual([
+ 'http-close',
+ 'postgraphile-release',
+ 'preset-services-release',
+ 'realtime-stop',
+ 'pool-lease-release'
+ ]);
+ });
+
+ it('releases the pool lease exactly once under duplicate disposal', async () => {
+ const entry = makeEntry();
+ entry.poolLease = makePoolLease();
+
+ await Promise.all([
+ disposeUncachedEntry(entry, 'duplicate'),
+ disposeUncachedEntry(entry, 'duplicate'),
+ disposeUncachedEntry(entry, 'duplicate')
+ ]);
+
+ expect(entry.pgl.release).toHaveBeenCalledTimes(1);
+ expect(entry.poolLease.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('awaits released memory before admitting the next build', async () => {
+ const previousMax = process.env.GRAPHILE_CACHE_MAX;
+ const previousMode = process.env.GRAPHILE_CACHE_ADMISSION_MODE;
+ process.env.GRAPHILE_CACHE_MAX = '1';
+ process.env.GRAPHILE_CACHE_ADMISSION_MODE = 'evict-idle';
+ const entry = makeEntry(20);
+ graphileCache.set('resident', entry);
+ const startedAt = Date.now();
+ try {
+ const result = await prepareCacheForBuild(200);
+ expect(result.evicted).toBe(1);
+ expect(Date.now() - startedAt).toBeGreaterThanOrEqual(15);
+ expect(entry.pgl.release).toHaveBeenCalledTimes(1);
+ } finally {
+ if (previousMax === undefined) delete process.env.GRAPHILE_CACHE_MAX;
+ else process.env.GRAPHILE_CACHE_MAX = previousMax;
+ if (previousMode === undefined) delete process.env.GRAPHILE_CACHE_ADMISSION_MODE;
+ else process.env.GRAPHILE_CACHE_ADMISSION_MODE = previousMode;
+ }
+ });
+
+ it('refuses at preserve-resident capacity before evicting an idle resident', async () => {
+ const previousMax = process.env.GRAPHILE_CACHE_MAX;
+ const previousMode = process.env.GRAPHILE_CACHE_ADMISSION_MODE;
+ process.env.GRAPHILE_CACHE_MAX = '1';
+ process.env.GRAPHILE_CACHE_ADMISSION_MODE = 'preserve-resident';
+ const entry = makeEntry();
+ graphileCache.set('preserved', entry);
+ try {
+ expect(evaluateBuildAdmission()).toMatchObject({
+ admit: false,
+ reason: 'resident_capacity'
+ });
+ await expect(prepareCacheForBuild(100)).rejects.toMatchObject({
+ reason: 'resident_capacity'
+ });
+ expect(graphileCache.peek('preserved')).toBe(entry);
+ expect(entry.pgl.release).not.toHaveBeenCalled();
+ } finally {
+ graphileCache.delete('preserved');
+ await waitForEntryDisposal(entry, 100);
+ if (previousMax === undefined) delete process.env.GRAPHILE_CACHE_MAX;
+ else process.env.GRAPHILE_CACHE_MAX = previousMax;
+ if (previousMode === undefined) delete process.env.GRAPHILE_CACHE_ADMISSION_MODE;
+ else process.env.GRAPHILE_CACHE_ADMISSION_MODE = previousMode;
+ }
+ });
+
+ it('refuses admission without evicting the only busy resident', async () => {
+ const previousMax = process.env.GRAPHILE_CACHE_MAX;
+ const previousMode = process.env.GRAPHILE_CACHE_ADMISSION_MODE;
+ process.env.GRAPHILE_CACHE_MAX = '1';
+ process.env.GRAPHILE_CACHE_ADMISSION_MODE = 'evict-idle';
+ const entry = makeEntry();
+ const response = new EventEmitter() as unknown as Response;
+ invokeEntryHandler(entry, {} as Request, response, (() => undefined) as NextFunction);
+ graphileCache.set('busy', entry);
+ try {
+ await expect(prepareCacheForBuild(10)).rejects.toMatchObject({
+ reason: 'resident_busy'
+ });
+ expect(graphileCache.has('busy')).toBe(true);
+ } finally {
+ (response as unknown as EventEmitter).emit('finish');
+ await waitForEntryDisposal(entry, 100);
+ if (previousMax === undefined) delete process.env.GRAPHILE_CACHE_MAX;
+ else process.env.GRAPHILE_CACHE_MAX = previousMax;
+ if (previousMode === undefined) delete process.env.GRAPHILE_CACHE_ADMISSION_MODE;
+ else process.env.GRAPHILE_CACHE_ADMISSION_MODE = previousMode;
+ }
+ });
+
+ it('releases the pool lease when PostGraphile release fails', async () => {
+ const releaseFailure = new Error('PostGraphile release failed');
+ const events: string[] = [];
+ const entry = makeEntry();
+ entry.pgl = {
+ release: jest.fn(async () => {
+ events.push('postgraphile-release');
+ throw releaseFailure;
+ })
+ } as unknown as GraphileCacheEntry['pgl'];
+ entry.poolLease = makePoolLease(() => events.push('pool-lease-release'));
+
+ await expect(disposeUncachedEntry(entry, 'release-failure')).rejects.toBe(
+ releaseFailure
+ );
+ expect(events).toEqual(['postgraphile-release', 'pool-lease-release']);
+ expect(entry.poolLease.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('continues realtime and pool cleanup after a PostGraphile release failure', async () => {
+ const releaseFailure = new Error('PostGraphile release failed');
+ const realtimeFailure = new Error('Realtime stop failed');
+ const entry = makeEntry();
+ entry.pgl = {
+ release: jest.fn(async () => {
+ throw releaseFailure;
+ })
+ } as unknown as GraphileCacheEntry['pgl'];
+ entry.realtimeManager = {
+ stop: jest.fn(async () => {
+ throw realtimeFailure;
+ })
+ };
+ entry.poolLease = makePoolLease();
+
+ await expect(Promise.all([
+ disposeUncachedEntry(entry, 'aggregate-release-failure'),
+ disposeUncachedEntry(entry, 'aggregate-release-failure')
+ ])).rejects.toBe(releaseFailure);
+ expect(entry.pgl.release).toHaveBeenCalledTimes(1);
+ expect(entry.realtimeManager.stop).toHaveBeenCalledTimes(1);
+ expect(entry.poolLease.release).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('timer cleanup', () => {
+ it('clears the timeout when work settles first', async () => {
+ jest.useFakeTimers();
+ try {
+ const result = await raceWithClearedTimeout(Promise.resolve('done'), 60_000);
+ expect(result).toEqual({ timedOut: false, value: 'done' });
+ expect(jest.getTimerCount()).toBe(0);
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+});
diff --git a/graphile/graphile-cache/src/__tests__/http-adapter.test.ts b/graphile/graphile-cache/src/__tests__/http-adapter.test.ts
new file mode 100644
index 0000000000..9212215a61
--- /dev/null
+++ b/graphile/graphile-cache/src/__tests__/http-adapter.test.ts
@@ -0,0 +1,123 @@
+import type { Server } from 'node:http';
+
+import express, { type Express } from 'express';
+
+import {
+ disposeUncachedEntry,
+ type GraphileCacheEntry
+} from '../graphile-cache';
+import {
+ attachGraphileHttpHandler,
+ createGraphileHttpHandler
+} from '../http-adapter';
+
+const closeServer = (server: Server): Promise =>
+ new Promise((resolve, reject) => {
+ server.close((error) => error ? reject(error) : resolve());
+ });
+
+describe('lean Graphile HTTP adapter', () => {
+ it('matches the pinned Grafserv HTTP-only runtime contract', async () => {
+ // Use require so ts-jest's legacy resolver does not reject Grafserv's
+ // conditional `./express/v4` export, which the package build resolves.
+ const { grafserv } = require('grafserv/express/v4');
+ const serv = grafserv({
+ preset: { grafserv: { graphqlPath: '/graphql' } },
+ schema: null
+ });
+ const handler = createGraphileHttpHandler();
+
+ try {
+ await attachGraphileHttpHandler(serv, handler, serv.getPreset());
+ expect((handler as any).stack).toHaveLength(1);
+ expect((handler as any).listen).toBeUndefined();
+ } finally {
+ await serv.release();
+ }
+ });
+
+ it('mounts Grafserv on a router with websocket/server allocation disabled', async () => {
+ const handler = createGraphileHttpHandler();
+ const serv = {
+ addTo: jest.fn(async (app: Express) => {
+ app.use('/graphql', (_req, res) => {
+ res.status(200).json({ data: { adapter: 'router' } });
+ });
+ })
+ };
+
+ await attachGraphileHttpHandler(serv, handler, { grafserv: {} });
+ expect(serv.addTo).toHaveBeenCalledWith(handler, null, false);
+ expect((handler as any).listen).toBeUndefined();
+
+ const outerApp = express();
+ outerApp.use(handler);
+ const outerServer = await new Promise((resolve, reject) => {
+ const server = outerApp.listen(0, '127.0.0.1', () => resolve(server));
+ server.once('error', reject);
+ });
+ try {
+ const address = outerServer.address();
+ if (!address || typeof address === 'string') {
+ throw new Error('Expected an address for the test HTTP server');
+ }
+ const response = await fetch(`http://127.0.0.1:${address.port}/graphql`);
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({
+ data: { adapter: 'router' }
+ });
+ } finally {
+ await closeServer(outerServer);
+ }
+ });
+
+ it('fails closed instead of silently disabling configured WebSockets', () => {
+ const handler = createGraphileHttpHandler();
+ const serv = { addTo: jest.fn() };
+
+ expect(() => attachGraphileHttpHandler(serv, handler, {
+ grafserv: { websockets: true }
+ })).toThrow(/tenant-aware upgrade handler on the shared server/);
+ expect(serv.addTo).not.toHaveBeenCalled();
+ });
+
+ it('mounts HTTP without an exclusive listener when shared routing is explicit', async () => {
+ const handler = createGraphileHttpHandler();
+ const serv = { addTo: jest.fn() };
+
+ await attachGraphileHttpHandler(serv, handler, {
+ grafserv: { websockets: true }
+ }, {
+ sharedWebsocketRouting: true
+ });
+
+ expect(serv.addTo).toHaveBeenCalledWith(handler, null, false);
+ });
+
+ it('disposes a serverless adapter and its realtime manager in order', async () => {
+ const events: string[] = [];
+ const entry: GraphileCacheEntry = {
+ pgl: {
+ release: jest.fn(async () => {
+ events.push('postgraphile-release');
+ })
+ } as unknown as GraphileCacheEntry['pgl'],
+ serv: {} as GraphileCacheEntry['serv'],
+ handler: createGraphileHttpHandler(),
+ httpServer: null,
+ cacheKey: 'lean-adapter',
+ createdAt: Date.now(),
+ realtimeManager: {
+ stop: jest.fn(async () => {
+ events.push('realtime-stop');
+ })
+ }
+ };
+
+ await disposeUncachedEntry(entry);
+
+ expect(events).toEqual(['postgraphile-release', 'realtime-stop']);
+ expect(entry.realtimeManager?.stop).toHaveBeenCalledTimes(1);
+ expect(entry.pgl.release).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/graphile/graphile-cache/src/__tests__/preset-services.test.ts b/graphile/graphile-cache/src/__tests__/preset-services.test.ts
new file mode 100644
index 0000000000..5807f04f72
--- /dev/null
+++ b/graphile/graphile-cache/src/__tests__/preset-services.test.ts
@@ -0,0 +1,29 @@
+import { createPresetServicesReleaser } from '../preset-services';
+
+describe('preset service ownership', () => {
+ it('releases services in reverse order exactly once under concurrent teardown', async () => {
+ const events: string[] = [];
+ const first = { release: jest.fn(async () => { events.push('first'); }) };
+ const second = { release: jest.fn(async () => { events.push('second'); }) };
+ const release = createPresetServicesReleaser({
+ pgServices: [first, second, first]
+ });
+
+ await Promise.all([release(), release(), release()]);
+
+ expect(events).toEqual(['second', 'first']);
+ expect(first.release).toHaveBeenCalledTimes(1);
+ expect(second.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('continues releasing services and preserves the first cleanup error', async () => {
+ const firstFailure = new Error('second failed');
+ const first = { release: jest.fn(async (): Promise => undefined) };
+ const second = { release: jest.fn(async () => { throw firstFailure; }) };
+ const release = createPresetServicesReleaser({ pgServices: [first, second] });
+
+ await expect(release()).rejects.toBe(firstFailure);
+ expect(first.release).toHaveBeenCalledTimes(1);
+ expect(second.release).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/graphile/graphile-cache/src/__tests__/realtime-readiness.test.ts b/graphile/graphile-cache/src/__tests__/realtime-readiness.test.ts
new file mode 100644
index 0000000000..897289a189
--- /dev/null
+++ b/graphile/graphile-cache/src/__tests__/realtime-readiness.test.ts
@@ -0,0 +1,204 @@
+import {
+ createGraphileRealtimeHealth,
+ createGraphileRealtimeNodeId,
+ DEFAULT_GRAPHILE_REALTIME_SCHEMA,
+ GraphileRealtimeStartupError,
+ startConfiguredRealtime,
+ withGraphileRealtimeFailure
+} from '../realtime-readiness';
+
+const makeManager = () => {
+ const start = jest.fn().mockResolvedValue(undefined);
+ const stop = jest.fn().mockResolvedValue(undefined);
+ const constructor = jest.fn().mockImplementation(() => ({ start, stop }));
+ return { constructor, start, stop };
+};
+
+describe('configured realtime instance readiness', () => {
+ it('fails closed and releases PostGraphile when the subscriber is missing', async () => {
+ const manager = makeManager();
+ const releasePostGraphile = jest.fn().mockResolvedValue(undefined);
+
+ await expect(startConfiguredRealtime({
+ cacheKey: 'missing-subscriber',
+ resolvedPreset: {
+ pgServices: [{ adaptorSettings: { pool: {} } }]
+ },
+ allowedSourceSchemas: ['tenant_a'],
+ releasePostGraphile,
+ loadManager: async () => manager.constructor
+ })).rejects.toBeInstanceOf(GraphileRealtimeStartupError);
+
+ expect(manager.constructor).not.toHaveBeenCalled();
+ expect(releasePostGraphile).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops a partially started manager and releases PostGraphile', async () => {
+ const manager = makeManager();
+ const startupFailure = new Error('realtime startup failed');
+ manager.start.mockRejectedValue(startupFailure);
+ const releasePostGraphile = jest.fn().mockResolvedValue(undefined);
+
+ await expect(startConfiguredRealtime({
+ cacheKey: 'start-failure',
+ resolvedPreset: {
+ pgServices: [{
+ pgSubscriber: {},
+ adaptorSettings: { pool: {} }
+ }]
+ },
+ allowedSourceSchemas: ['tenant_a'],
+ releasePostGraphile,
+ loadManager: async () => manager.constructor
+ })).rejects.toMatchObject({
+ code: 'GRAPHILE_REALTIME_STARTUP_FAILED',
+ cause: startupFailure
+ });
+
+ expect(manager.stop).toHaveBeenCalledTimes(1);
+ expect(releasePostGraphile).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns a started manager without releasing a healthy generation', async () => {
+ const manager = makeManager();
+ const releasePostGraphile = jest.fn().mockResolvedValue(undefined);
+
+ const result = await startConfiguredRealtime({
+ cacheKey: 'ready',
+ resolvedPreset: {
+ pgServices: [{
+ pgSubscriber: { eventEmitter: { emit: jest.fn() } },
+ adaptorSettings: { pool: {} }
+ }]
+ },
+ allowedSourceSchemas: ['tenant_a'],
+ releasePostGraphile,
+ loadManager: async () => manager.constructor,
+ replicaIdentity: 'replica-a'
+ });
+
+ expect(result).toEqual({ start: manager.start, stop: manager.stop });
+ expect(manager.constructor).toHaveBeenCalledWith(expect.objectContaining({
+ schema: DEFAULT_GRAPHILE_REALTIME_SCHEMA,
+ allowedSourceSchemas: ['tenant_a'],
+ nodeId: 'graphile-cache:replica-a:ready'
+ }));
+ expect(manager.start).toHaveBeenCalledTimes(1);
+ expect(releasePostGraphile).not.toHaveBeenCalled();
+ });
+
+ it('passes an exact custom cursor schema to the manager', async () => {
+ const manager = makeManager();
+ const releasePostGraphile = jest.fn().mockResolvedValue(undefined);
+ const onFatalError = jest.fn();
+
+ await startConfiguredRealtime({
+ cacheKey: 'tenant-a',
+ resolvedPreset: {
+ pgServices: [{
+ pgSubscriber: { eventEmitter: { emit: jest.fn() } },
+ adaptorSettings: { pool: {} }
+ }]
+ },
+ realtimeSchema: 'ctf_a_realtime',
+ allowedSourceSchemas: ['ctf_a'],
+ onFatalError,
+ releasePostGraphile,
+ loadManager: async () => manager.constructor,
+ replicaIdentity: 'replica-a'
+ });
+
+ expect(manager.constructor).toHaveBeenCalledWith({
+ pgSubscriber: { eventEmitter: { emit: expect.any(Function) } },
+ pool: {},
+ nodeId: 'graphile-cache:replica-a:tenant-a',
+ schema: 'ctf_a_realtime',
+ allowedSourceSchemas: ['ctf_a'],
+ onFatalError
+ });
+ expect(releasePostGraphile).not.toHaveBeenCalled();
+ });
+
+ it('uses an explicit generation publisher and configured cursor intervals', async () => {
+ const manager = makeManager();
+ const releasePostGraphile = jest.fn().mockResolvedValue(undefined);
+ const publisher = {
+ assertTopics: jest.fn(),
+ publish: jest.fn()
+ };
+
+ await startConfiguredRealtime({
+ cacheKey: 'shared-exact',
+ resolvedPreset: {
+ pgServices: [{ adaptorSettings: { pool: {} } }]
+ },
+ publisher,
+ pollIntervalMs: 30_000,
+ heartbeatIntervalMs: 90_000,
+ allowedSourceSchemas: ['tenant_a'],
+ releasePostGraphile,
+ loadManager: async () => manager.constructor
+ });
+
+ expect(manager.constructor).toHaveBeenCalledWith(expect.objectContaining({
+ publisher,
+ pollIntervalMs: 30_000,
+ heartbeatIntervalMs: 90_000
+ }));
+ expect(manager.constructor.mock.calls[0][0]).not.toHaveProperty('pgSubscriber');
+ });
+
+ it('fails closed before loading a manager when no source schema is allowed', async () => {
+ const manager = makeManager();
+ const releasePostGraphile = jest.fn().mockResolvedValue(undefined);
+
+ await expect(startConfiguredRealtime({
+ cacheKey: 'no-sources',
+ resolvedPreset: {
+ pgServices: [{
+ pgSubscriber: { eventEmitter: { emit: jest.fn() } },
+ adaptorSettings: { pool: {} }
+ }]
+ },
+ allowedSourceSchemas: [],
+ releasePostGraphile,
+ loadManager: async () => manager.constructor
+ })).rejects.toMatchObject({
+ code: 'GRAPHILE_REALTIME_STARTUP_FAILED'
+ });
+
+ expect(manager.constructor).not.toHaveBeenCalled();
+ expect(releasePostGraphile).toHaveBeenCalledTimes(1);
+ });
+
+ it('separates replica cursor identities while retaining the exact contract key', () => {
+ const cacheKey = 'graphile:v1:contract-a';
+ const first = createGraphileRealtimeNodeId(cacheKey, 'replica-a');
+ const second = createGraphileRealtimeNodeId(cacheKey, 'replica-b');
+
+ expect(first).not.toBe(second);
+ expect(first).toBe(`graphile-cache:replica-a:${cacheKey}`);
+ expect(second).toBe(`graphile-cache:replica-b:${cacheKey}`);
+ });
+
+ it('latches the first fatal delivery failure for one exact generation', () => {
+ const health = createGraphileRealtimeHealth();
+ const first = Object.assign(new Error('foreign source'), {
+ code: 'REALTIME_SOURCE_SCHEMA_VIOLATION'
+ });
+ const second = Object.assign(new Error('emitter missing'), {
+ code: 'REALTIME_SUBSCRIBER_UNAVAILABLE'
+ });
+
+ const failed = withGraphileRealtimeFailure(health, first, 1_000);
+ const stillFailed = withGraphileRealtimeFailure(failed, second, 2_000);
+
+ expect(health).toEqual({ status: 'healthy' });
+ expect(failed).toEqual({
+ status: 'failed',
+ failureCode: 'REALTIME_SOURCE_SCHEMA_VIOLATION',
+ failedAt: 1_000
+ });
+ expect(stillFailed).toBe(failed);
+ });
+});
diff --git a/graphile/graphile-cache/src/__tests__/shared-realtime.test.ts b/graphile/graphile-cache/src/__tests__/shared-realtime.test.ts
new file mode 100644
index 0000000000..1d360a3875
--- /dev/null
+++ b/graphile/graphile-cache/src/__tests__/shared-realtime.test.ts
@@ -0,0 +1,479 @@
+const acquirePgNotificationBroker = jest.fn();
+const getPgNotificationBrokerStats = jest.fn();
+const getPgNotificationBrokerIdentity = jest.fn((config: { password?: string }) =>
+ config.password === 'rotated-secret'
+ ? 'broker:v1:rotated'
+ : 'broker:v1:expected');
+const getPgNotificationDatabaseIdentity = jest.fn(() => 'database-target:v1:tenant-a');
+
+jest.mock('pg-cache', () => ({
+ acquirePgNotificationBroker,
+ getPgNotificationBrokerStats,
+ getPgNotificationBrokerIdentity,
+ getPgNotificationDatabaseIdentity,
+ PG_NOTIFICATION_LEASE_RELEASED_ERROR_CODE: 'PG_NOTIFICATION_LEASE_RELEASED'
+}));
+
+import {
+ ActivatableGenerationScopedRealtimeSubscriber,
+ RealtimeTopicCollector
+} from 'graphile-realtime-subscriptions';
+
+import {
+ activateGraphileSharedRealtime,
+ getGraphileRealtimeRoleAuditStats,
+ GraphileSharedRealtimeDatabaseConflictError,
+ GraphileSharedRealtimeIdentityError
+} from '../shared-realtime';
+
+interface Deferred {
+ promise: Promise;
+ resolve(value: T): void;
+ reject(reason: unknown): void;
+}
+
+const deferred = (): Deferred => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+const listenerConfig = {
+ host: 'db.internal',
+ port: 5432,
+ database: 'tenant_a',
+ user: 'tenant_a_notify',
+ password: 'never-log-this'
+};
+
+const successfulAudit = {
+ version: 'pg-notification-role:v1' as const,
+ role: 'tenant_a_notify',
+ database: 'tenant_a',
+ safe: true,
+ violations: [] as const
+};
+
+let brokerAuditAttempts = 0;
+let brokerAuditFailures = 0;
+
+const makeCollector = (): RealtimeTopicCollector => {
+ const collector = new RealtimeTopicCollector();
+ collector.collect([{
+ topic: 'realtime:tenant_a.contacts',
+ schema: 'tenant_a',
+ table: 'contacts'
+ }]);
+ return collector;
+};
+
+const makeBrokerLease = (
+ revalidate = async () => successfulAudit
+) => {
+ const termination = deferred();
+ const release = jest.fn(async (): Promise => {
+ termination.resolve(null);
+ });
+ const revalidateRole = jest.fn(async () => {
+ brokerAuditAttempts++;
+ try {
+ return await revalidate();
+ } catch (error) {
+ brokerAuditFailures++;
+ throw error;
+ }
+ });
+ return {
+ identity: 'broker:v1:expected',
+ topics: ['realtime:tenant_a.contacts'],
+ terminated: termination.promise,
+ roleAudit: successfulAudit,
+ revalidateRole,
+ subscribe: jest.fn(() => {
+ const iterator: AsyncIterableIterator = {
+ [Symbol.asyncIterator]: () => iterator,
+ next: () => new Promise(() => undefined),
+ return: async (): Promise> => ({
+ done: true,
+ value: undefined
+ })
+ };
+ return iterator;
+ }),
+ release,
+ termination
+ };
+};
+
+const useBrokerLeases = (...leases: ReturnType[]): void => {
+ const pending = [...leases];
+ acquirePgNotificationBroker.mockImplementation(async () => {
+ brokerAuditAttempts++;
+ const lease = pending.shift();
+ if (!lease) throw new Error('No mocked notification broker lease remains');
+ return lease;
+ });
+};
+
+describe('shared exact realtime activation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ brokerAuditAttempts = 0;
+ brokerAuditFailures = 0;
+ getPgNotificationBrokerStats.mockImplementation(() => ({
+ roleAuditAttempts: brokerAuditAttempts,
+ roleAuditFailures: brokerAuditFailures
+ }));
+ acquirePgNotificationBroker.mockImplementation(async () => {
+ brokerAuditAttempts++;
+ return makeBrokerLease();
+ });
+ });
+
+ it('installs exact topics only after the broker returns its pinned-client audit', async () => {
+ const order: string[] = [];
+ const broker = makeBrokerLease();
+ acquirePgNotificationBroker.mockImplementation(async () => {
+ brokerAuditAttempts++;
+ order.push('broker');
+ return broker;
+ });
+ const subscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const onFatalError = jest.fn();
+
+ const attestation = await activateGraphileSharedRealtime({
+ subscriber,
+ topicCollector: makeCollector(),
+ listenerPgConfig: listenerConfig,
+ listenerIdentity: 'broker:v1:expected',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000,
+ onFatalError
+ });
+
+ expect(order).toEqual(['broker']);
+ expect(acquirePgNotificationBroker).toHaveBeenCalledWith(listenerConfig, {
+ topics: ['realtime:tenant_a.contacts']
+ });
+ expect(attestation.snapshot()).toMatchObject({
+ mode: 'shared-exact',
+ listenerIdentity: 'broker:v1:expected',
+ auditVersion: 'pg-notification-role:v1',
+ role: 'tenant_a_notify',
+ database: 'tenant_a',
+ status: 'healthy',
+ checks: 1
+ });
+
+ attestation.release();
+ await subscriber.release();
+ expect(broker.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('latches broker termination into the exact generation health callback', async () => {
+ const broker = makeBrokerLease();
+ useBrokerLeases(broker);
+ const subscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const onFatalError = jest.fn();
+ const attestation = await activateGraphileSharedRealtime({
+ subscriber,
+ topicCollector: makeCollector(),
+ listenerPgConfig: listenerConfig,
+ listenerIdentity: 'broker:v1:expected',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000,
+ onFatalError
+ });
+ const failure = Object.assign(new Error('listener ended'), {
+ code: 'PG_NOTIFICATION_BROKER_FAILED'
+ });
+
+ broker.termination.resolve(failure);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(onFatalError).toHaveBeenCalledWith(failure);
+
+ attestation.release();
+ await subscriber.release();
+ });
+
+ it('proactively revalidates once per identity and cancels its unref timer', async () => {
+ jest.useFakeTimers();
+ try {
+ jest.setSystemTime(1_000);
+ const firstBroker = makeBrokerLease();
+ const secondBroker = makeBrokerLease();
+ useBrokerLeases(firstBroker, secondBroker);
+ const firstSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const secondSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const common = {
+ topicCollector: makeCollector(),
+ listenerPgConfig: listenerConfig,
+ listenerIdentity: 'broker:v1:expected',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 100,
+ onFatalError: jest.fn()
+ };
+ const first = await activateGraphileSharedRealtime({
+ ...common,
+ subscriber: firstSubscriber
+ });
+ const second = await activateGraphileSharedRealtime({
+ ...common,
+ subscriber: secondSubscriber
+ });
+
+ expect(jest.getTimerCount()).toBe(1);
+ await jest.advanceTimersByTimeAsync(99);
+ expect(firstBroker.revalidateRole).not.toHaveBeenCalled();
+ expect(secondBroker.revalidateRole).not.toHaveBeenCalled();
+ await jest.advanceTimersByTimeAsync(1);
+ expect(firstBroker.revalidateRole).toHaveBeenCalledTimes(1);
+ expect(secondBroker.revalidateRole).not.toHaveBeenCalled();
+ expect(first.snapshot()).toMatchObject({
+ lastAttestedAt: 1_100,
+ checks: 3,
+ status: 'healthy'
+ });
+ expect(second.snapshot()).toMatchObject({ checks: 3, status: 'healthy' });
+ expect(jest.getTimerCount()).toBe(1);
+
+ first.release();
+ expect(jest.getTimerCount()).toBe(1);
+ second.release();
+ expect(jest.getTimerCount()).toBe(0);
+ await Promise.all([firstSubscriber.release(), secondSubscriber.release()]);
+ await jest.advanceTimersByTimeAsync(100);
+ expect(firstBroker.revalidateRole).toHaveBeenCalledTimes(1);
+ expect(secondBroker.revalidateRole).not.toHaveBeenCalled();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('retries another generation when the selected revalidator is released', async () => {
+ const now = jest.spyOn(Date, 'now').mockReturnValue(1_000);
+ const selectedAudit = deferred();
+ const firstBroker = makeBrokerLease(() => selectedAudit.promise);
+ const secondBroker = makeBrokerLease();
+ const thirdBroker = makeBrokerLease();
+ useBrokerLeases(firstBroker, secondBroker, thirdBroker);
+ const firstSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const secondSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const thirdSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const firstFailure = jest.fn();
+ const secondFailure = jest.fn();
+ const thirdFailure = jest.fn();
+ const common = {
+ topicCollector: makeCollector(),
+ listenerPgConfig: listenerConfig,
+ listenerIdentity: 'broker:v1:expected',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000
+ };
+ const first = await activateGraphileSharedRealtime({
+ ...common,
+ subscriber: firstSubscriber,
+ onFatalError: firstFailure
+ });
+ const second = await activateGraphileSharedRealtime({
+ ...common,
+ subscriber: secondSubscriber,
+ onFatalError: secondFailure
+ });
+ const third = await activateGraphileSharedRealtime({
+ ...common,
+ subscriber: thirdSubscriber,
+ onFatalError: thirdFailure
+ });
+
+ now.mockReturnValue(61_001);
+ const refreshing = second.revalidateIfDue();
+ await Promise.resolve();
+ expect(firstBroker.revalidateRole).toHaveBeenCalledTimes(1);
+
+ first.release();
+ await firstSubscriber.release();
+ selectedAudit.reject(Object.assign(new Error('lease released'), {
+ code: 'PG_NOTIFICATION_LEASE_RELEASED'
+ }));
+
+ await expect(refreshing).resolves.toBe(true);
+ expect(secondBroker.revalidateRole).toHaveBeenCalledTimes(1);
+ expect(thirdBroker.revalidateRole).not.toHaveBeenCalled();
+ expect(second.snapshot()).toMatchObject({ status: 'healthy', checks: 4 });
+ expect(third.snapshot()).toMatchObject({ status: 'healthy', checks: 4 });
+ expect(firstFailure).not.toHaveBeenCalled();
+ expect(secondFailure).not.toHaveBeenCalled();
+ expect(thirdFailure).not.toHaveBeenCalled();
+
+ second.release();
+ third.release();
+ await Promise.all([secondSubscriber.release(), thirdSubscriber.release()]);
+ now.mockRestore();
+ });
+
+ it('coalesces TTL refresh and fails every sharing generation closed on drift', async () => {
+ const statsBefore = getGraphileRealtimeRoleAuditStats();
+ const now = jest.spyOn(Date, 'now').mockReturnValue(1_000);
+ const drift = Object.assign(new Error('role drift'), {
+ code: 'PG_NOTIFICATION_ROLE_UNSAFE'
+ });
+ const firstBroker = makeBrokerLease(async () => {
+ throw drift;
+ });
+ const secondBroker = makeBrokerLease();
+ useBrokerLeases(firstBroker, secondBroker);
+ const firstSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const secondSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const firstFailure = jest.fn();
+ const secondFailure = jest.fn();
+ const common = {
+ topicCollector: makeCollector(),
+ listenerPgConfig: listenerConfig,
+ listenerIdentity: 'broker:v1:expected',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000
+ };
+ const first = await activateGraphileSharedRealtime({
+ ...common,
+ subscriber: firstSubscriber,
+ onFatalError: firstFailure
+ });
+ const second = await activateGraphileSharedRealtime({
+ ...common,
+ subscriber: secondSubscriber,
+ onFatalError: secondFailure
+ });
+ expect(acquirePgNotificationBroker).toHaveBeenCalledTimes(2);
+
+ now.mockReturnValue(61_001);
+ await expect(Promise.all([
+ first.revalidateIfDue(),
+ second.revalidateIfDue()
+ ])).resolves.toEqual([false, false]);
+
+ expect(firstBroker.revalidateRole).toHaveBeenCalledTimes(1);
+ expect(secondBroker.revalidateRole).not.toHaveBeenCalled();
+ expect(firstFailure).toHaveBeenCalledWith(drift);
+ expect(secondFailure).toHaveBeenCalledWith(drift);
+ expect(first.snapshot()).toMatchObject({
+ status: 'failed',
+ failureCode: 'PG_NOTIFICATION_ROLE_UNSAFE',
+ failedAt: 61_001
+ });
+ expect(getGraphileRealtimeRoleAuditStats()).toMatchObject({
+ identities: 1,
+ failed: 1,
+ activeIdentityAuditAttempts: 3,
+ catalogAuditAttempts: statsBefore.catalogAuditAttempts + 3,
+ catalogAuditFailures: statsBefore.catalogAuditFailures + 1,
+ activeDatabaseTargets: 1
+ });
+
+ first.release();
+ second.release();
+ await Promise.all([firstSubscriber.release(), secondSubscriber.release()]);
+ now.mockRestore();
+ });
+
+ it('rejects a second active listener identity for one physical database', async () => {
+ const firstBroker = makeBrokerLease();
+ const rotatedBroker = makeBrokerLease();
+ useBrokerLeases(firstBroker, rotatedBroker);
+ const firstSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const first = await activateGraphileSharedRealtime({
+ subscriber: firstSubscriber,
+ topicCollector: makeCollector(),
+ listenerPgConfig: listenerConfig,
+ listenerIdentity: 'broker:v1:expected',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000,
+ onFatalError: jest.fn()
+ });
+ const rotatedConfig = {
+ ...listenerConfig,
+ password: 'rotated-secret'
+ };
+ const rotatedSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+
+ await expect(activateGraphileSharedRealtime({
+ subscriber: rotatedSubscriber,
+ topicCollector: makeCollector(),
+ listenerPgConfig: rotatedConfig,
+ listenerIdentity: 'broker:v1:rotated',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000,
+ onFatalError: jest.fn()
+ })).rejects.toBeInstanceOf(GraphileSharedRealtimeDatabaseConflictError);
+ expect(acquirePgNotificationBroker).toHaveBeenCalledTimes(1);
+
+ first.release();
+ await firstSubscriber.release();
+ const rotated = await activateGraphileSharedRealtime({
+ subscriber: rotatedSubscriber,
+ topicCollector: makeCollector(),
+ listenerPgConfig: rotatedConfig,
+ listenerIdentity: 'broker:v1:rotated',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000,
+ onFatalError: jest.fn()
+ });
+ expect(acquirePgNotificationBroker).toHaveBeenCalledTimes(2);
+
+ rotated.release();
+ await rotatedSubscriber.release();
+ });
+
+ it('releases the physical-database reservation when the initial audit fails', async () => {
+ const auditFailure = new Error('catalog unavailable');
+ acquirePgNotificationBroker.mockRejectedValueOnce(auditFailure);
+ const failedSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ await expect(activateGraphileSharedRealtime({
+ subscriber: failedSubscriber,
+ topicCollector: makeCollector(),
+ listenerPgConfig: listenerConfig,
+ listenerIdentity: 'broker:v1:expected',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000,
+ onFatalError: jest.fn()
+ })).rejects.toBe(auditFailure);
+ await failedSubscriber.release();
+
+ const rotatedBroker = makeBrokerLease();
+ useBrokerLeases(rotatedBroker);
+ const rotatedSubscriber = new ActivatableGenerationScopedRealtimeSubscriber();
+ const rotated = await activateGraphileSharedRealtime({
+ subscriber: rotatedSubscriber,
+ topicCollector: makeCollector(),
+ listenerPgConfig: {
+ ...listenerConfig,
+ password: 'rotated-secret'
+ },
+ listenerIdentity: 'broker:v1:rotated',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000,
+ onFatalError: jest.fn()
+ });
+
+ rotated.release();
+ await rotatedSubscriber.release();
+ });
+
+ it('rejects a caller-supplied listener identity mismatch before audit', async () => {
+ await expect(activateGraphileSharedRealtime({
+ subscriber: new ActivatableGenerationScopedRealtimeSubscriber(),
+ topicCollector: makeCollector(),
+ listenerPgConfig: listenerConfig,
+ listenerIdentity: 'broker:v1:wrong',
+ allowedSourceSchemas: ['tenant_a'],
+ roleRevalidationMs: 60_000,
+ onFatalError: jest.fn()
+ })).rejects.toBeInstanceOf(GraphileSharedRealtimeIdentityError);
+ expect(acquirePgNotificationBroker).not.toHaveBeenCalled();
+ });
+});
diff --git a/graphile/graphile-cache/src/__tests__/websocket-lifecycle.test.ts b/graphile/graphile-cache/src/__tests__/websocket-lifecycle.test.ts
new file mode 100644
index 0000000000..abdf2734df
--- /dev/null
+++ b/graphile/graphile-cache/src/__tests__/websocket-lifecycle.test.ts
@@ -0,0 +1,263 @@
+import { once } from 'node:events';
+import { PassThrough } from 'node:stream';
+
+import type { IncomingMessage } from 'http';
+
+import {
+ disposeUncachedEntry,
+ getCacheCounters,
+ GRAPHILE_WEBSOCKET_UNAVAILABLE_CODE,
+ graphileCache,
+ type GraphileCacheEntry,
+ invokeEntryUpgradeHandler,
+ retireGraphileCacheEntry,
+ waitForEntryDisposal
+} from '../graphile-cache';
+import { createGraphileHttpHandler } from '../http-adapter';
+import { GRAPHILE_REALTIME_UNAVAILABLE_CODE } from '../realtime-readiness';
+
+const makeEntry = (
+ overrides: Partial = {}
+): GraphileCacheEntry => ({
+ pgl: {
+ release: jest.fn(async (): Promise => undefined)
+ } as unknown as GraphileCacheEntry['pgl'],
+ serv: {} as GraphileCacheEntry['serv'],
+ handler: createGraphileHttpHandler(),
+ httpServer: null,
+ cacheKey: 'websocket-lifecycle',
+ createdAt: Date.now(),
+ ...overrides
+});
+
+const request = (): IncomingMessage => ({
+ aborted: false
+}) as IncomingMessage;
+
+describe('cached Graphile WebSocket lifecycle', () => {
+ it('retains an exact entry until its accepted socket closes', async () => {
+ const socket = new PassThrough();
+ const upgradeHandler = jest.fn();
+ const entry = makeEntry({ upgradeHandler });
+ const countersBefore = getCacheCounters();
+
+ expect(invokeEntryUpgradeHandler(entry, request(), socket, Buffer.alloc(0))).toBe(true);
+ expect(upgradeHandler).toHaveBeenCalledWith(
+ expect.anything(),
+ socket,
+ expect.any(Buffer)
+ );
+ expect(entry.inflight).toBe(1);
+ expect(entry.websocketSockets?.has(socket)).toBe(true);
+ expect(getCacheCounters().websocketUpgradesStarted).toBe(
+ countersBefore.websocketUpgradesStarted + 1
+ );
+ expect(getCacheCounters().websocketUpgradesCompleted).toBe(
+ countersBefore.websocketUpgradesCompleted
+ );
+
+ socket.destroy();
+ await once(socket, 'close');
+
+ expect(entry.inflight).toBe(0);
+ expect(entry.websocketSockets?.size).toBe(0);
+ expect(getCacheCounters().websocketUpgradesCompleted).toBe(
+ countersBefore.websocketUpgradesCompleted + 1
+ );
+ });
+
+ it('transfers the outer transport only after the exact generation is retained', () => {
+ const socket = new PassThrough();
+ const events: string[] = [];
+ const entry = makeEntry({
+ upgradeHandler: jest.fn(() => events.push('grafserv'))
+ });
+
+ expect(invokeEntryUpgradeHandler(
+ entry,
+ request(),
+ socket,
+ Buffer.from('head'),
+ { onAccepted: () => events.push('accepted') }
+ )).toBe(true);
+
+ expect(events).toEqual(['accepted', 'grafserv']);
+ expect(entry.inflight).toBe(1);
+ socket.destroy();
+ });
+
+ it('terminates long-lived sockets before disposing their generation', async () => {
+ const socket = new PassThrough();
+ const entry = makeEntry({ upgradeHandler: jest.fn() });
+ expect(invokeEntryUpgradeHandler(entry, request(), socket, Buffer.alloc(0))).toBe(true);
+
+ await disposeUncachedEntry(entry);
+
+ expect(socket.destroyed).toBe(true);
+ expect(entry.inflight).toBe(0);
+ expect(entry.pgl.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('retires the exact resident generation and its sockets on a fatal audit', async () => {
+ const socket = new PassThrough();
+ const entry = makeEntry({
+ cacheKey: 'websocket-fatal-audit',
+ upgradeHandler: jest.fn()
+ });
+ graphileCache.set(entry.cacheKey, entry);
+ expect(invokeEntryUpgradeHandler(
+ entry,
+ request(),
+ socket,
+ Buffer.alloc(0)
+ )).toBe(true);
+
+ expect(retireGraphileCacheEntry(
+ entry,
+ Object.assign(new Error('listener role changed'), {
+ code: 'INSUFFICIENT_PRIVILEGE'
+ })
+ )).toBe(true);
+
+ await once(socket, 'close');
+ await expect(waitForEntryDisposal(entry, 100)).resolves.toBe(true);
+ expect(graphileCache.peek(entry.cacheKey)).toBeUndefined();
+ expect(entry.realtimeHealth).toMatchObject({
+ status: 'failed',
+ failureCode: 'INSUFFICIENT_PRIVILEGE'
+ });
+ expect(entry.inflight).toBe(0);
+ expect(entry.pgl.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('releases PgSubscriber before cursor cleanup in a saturated max=2 pool', async () => {
+ const socket = new PassThrough();
+ const events: string[] = [];
+ // Model the two production runtime slots while a subscription is live:
+ // one PgSubscriber LISTEN checkout and one cursor-tracker checkout.
+ let occupiedSlots = 2;
+ const entry = makeEntry({
+ upgradeHandler: jest.fn(),
+ pgl: {
+ release: jest.fn(async () => {
+ events.push('postgraphile-release');
+ })
+ } as unknown as GraphileCacheEntry['pgl'],
+ releasePresetServices: jest.fn(async () => {
+ events.push('preset-services-release');
+ occupiedSlots -= 1;
+ }),
+ realtimeManager: {
+ stop: jest.fn(async () => {
+ events.push('realtime-stop');
+ if (occupiedSlots >= 2) {
+ throw new Error('timeout exceeded when trying to connect');
+ }
+ occupiedSlots -= 1;
+ })
+ }
+ });
+ expect(invokeEntryUpgradeHandler(entry, request(), socket, Buffer.alloc(0))).toBe(true);
+
+ await expect(disposeUncachedEntry(entry, 'max-2-live-subscription')).resolves.toBeUndefined();
+
+ expect(socket.destroyed).toBe(true);
+ expect(occupiedSlots).toBe(0);
+ expect(events).toEqual([
+ 'postgraphile-release',
+ 'preset-services-release',
+ 'realtime-stop'
+ ]);
+ });
+
+ it('releases a caller-owned shared subscriber and attestation exactly once', async () => {
+ const realtimeSubscriber = {
+ release: jest.fn(async (): Promise => undefined)
+ };
+ const realtimeRoleAttestation = {
+ snapshot: jest.fn(),
+ revalidateIfDue: jest.fn(async () => true),
+ release: jest.fn()
+ };
+ const entry = makeEntry({
+ realtimeSubscriber,
+ realtimeRoleAttestation
+ });
+
+ const first = disposeUncachedEntry(entry, 'shared-owner');
+ const second = disposeUncachedEntry(entry, 'shared-owner');
+ expect(first).toBe(second);
+ await first;
+
+ expect(realtimeRoleAttestation.release).toHaveBeenCalledTimes(1);
+ expect(realtimeSubscriber.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('fails closed with a stable response when no upgrade handler exists', async () => {
+ const socket = new PassThrough();
+ let response = '';
+ socket.on('data', (chunk) => {
+ response += chunk.toString();
+ });
+ const ended = once(socket, 'end');
+
+ const rejected = jest.fn();
+ expect(invokeEntryUpgradeHandler(
+ makeEntry(),
+ request(),
+ socket,
+ Buffer.alloc(0),
+ { onRejected: rejected }
+ )).toBe(true);
+ await ended;
+
+ expect(response).toContain('HTTP/1.1 503');
+ expect(response).toContain(GRAPHILE_WEBSOCKET_UNAVAILABLE_CODE);
+ expect(rejected).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects a WebSocket upgrade when its listener-role attestation is stale', async () => {
+ const socket = new PassThrough();
+ let response = '';
+ socket.on('data', (chunk) => {
+ response += chunk.toString();
+ });
+ const ended = once(socket, 'end');
+ const entry = makeEntry({
+ upgradeHandler: jest.fn(),
+ realtimeRoleAttestation: {
+ snapshot: jest.fn(() => ({
+ version: 1,
+ mode: 'shared-exact',
+ listenerIdentity: 'opaque-listener-identity',
+ auditVersion: 'pg-notification-role:v1',
+ role: 'listener',
+ database: 'tenant_a',
+ lastAttestedAt: 1,
+ validUntil: 2,
+ checks: 1,
+ status: 'healthy',
+ failureCode: null as string | null,
+ failedAt: null as number | null
+ })),
+ revalidateIfDue: jest.fn(async () => true),
+ release: jest.fn()
+ }
+ });
+
+ const rejected = jest.fn();
+ expect(invokeEntryUpgradeHandler(
+ entry,
+ request(),
+ socket,
+ Buffer.alloc(0),
+ { onRejected: rejected }
+ )).toBe(true);
+ await ended;
+
+ expect(entry.upgradeHandler).not.toHaveBeenCalled();
+ expect(rejected).toHaveBeenCalledTimes(1);
+ expect(response).toContain('HTTP/1.1 503');
+ expect(response).toContain(GRAPHILE_REALTIME_UNAVAILABLE_CODE);
+ });
+});
diff --git a/graphile/graphile-cache/src/build-readiness.ts b/graphile/graphile-cache/src/build-readiness.ts
new file mode 100644
index 0000000000..4b3d1a54c5
--- /dev/null
+++ b/graphile/graphile-cache/src/build-readiness.ts
@@ -0,0 +1,27 @@
+export interface GraphileBuildReadiness {
+ schemaResult: PromiseLike | unknown;
+ addTo(): PromiseLike | unknown;
+ ready(): PromiseLike | unknown;
+ release(): PromiseLike | unknown;
+ onReleaseError?(error: unknown): void;
+}
+
+/**
+ * Keep the build coordinator occupied until both schema gathering and the
+ * HTTP adapter are ready. Failed generations are released before returning.
+ */
+export const awaitGraphileBuildReadiness = async (
+ build: GraphileBuildReadiness
+): Promise => {
+ try {
+ await build.addTo();
+ await Promise.all([build.schemaResult, build.ready()]);
+ } catch (error) {
+ try {
+ await build.release();
+ } catch (releaseError) {
+ build.onReleaseError?.(releaseError);
+ }
+ throw error;
+ }
+};
diff --git a/graphile/graphile-cache/src/create-instance.ts b/graphile/graphile-cache/src/create-instance.ts
index 575b767589..df973a4d48 100644
--- a/graphile/graphile-cache/src/create-instance.ts
+++ b/graphile/graphile-cache/src/create-instance.ts
@@ -1,23 +1,80 @@
-import { createServer } from 'node:http';
-
import { Logger } from '@pgpmjs/logger';
-import express from 'express';
import { grafserv } from 'grafserv/express/v4';
+import {
+ ActivatableGenerationScopedRealtimeSubscriber,
+ type RealtimeTopicCollector
+} from 'graphile-realtime-subscriptions';
+import type { PgNotificationListenerConfig, PgPoolLease } from 'pg-cache';
import { postgraphile } from 'postgraphile';
-import type { GraphileCacheEntry } from './graphile-cache';
+import { awaitGraphileBuildReadiness } from './build-readiness';
+import type {
+ GraphileCacheEntry,
+ GraphileUpgradeHandler
+} from './graphile-cache';
+import { retireGraphileCacheEntry } from './graphile-cache';
+import {
+ attachGraphileHttpHandler,
+ createGraphileHttpHandler
+} from './http-adapter';
+import { createPresetServicesReleaser } from './preset-services';
+import {
+ createGraphileRealtimeHealth,
+ GraphileRealtimeStartupError,
+ startConfiguredRealtime
+} from './realtime-readiness';
+import {
+ activateGraphileSharedRealtime,
+ type GraphileRealtimeRoleAttestation
+} from './shared-realtime';
const log = new Logger('graphile-cache:create');
-interface GraphileInstanceOptions {
+export interface GraphileInstanceOptions {
preset: any;
cacheKey: string;
+ poolIdentity?: string;
+ /**
+ * Lease protecting the runtime pool for the lifetime of this instance.
+ *
+ * The caller owns the lease until `createGraphileInstance()` resolves. Once
+ * it resolves, ownership transfers to the returned cache entry and its
+ * disposal lifecycle releases the lease after PostGraphile teardown.
+ */
+ poolLease?: PgPoolLease;
+ serviceKey?: string;
+ databaseId?: string | null;
/**
* When true, a RealtimeManager is created and started alongside the
* PostGraphile instance. The pool is extracted from the preset's
* pgServices (managed by pg-cache) rather than passed separately.
*/
enableRealtime?: boolean;
+ /**
+ * Build a no-server Grafserv upgrade handler for an outer tenant-aware
+ * router. The preset must explicitly enable `grafserv.websockets`; the
+ * cached instance still never attaches its own upgrade listener.
+ */
+ enableWebsockets?: boolean;
+ /**
+ * Physical schema containing this instance's realtime cursor functions.
+ * Omit to use the compatibility default `realtime_public`.
+ */
+ realtimeSchema?: string;
+ /** Exact physical source schemas allowed to produce realtime events. */
+ realtimeSourceSchemas?: readonly string[];
+ /** Cursor recovery polling interval; defaults to RealtimeManager's 5s. */
+ realtimeCursorPollIntervalMs?: number;
+ /** Cursor listener heartbeat interval; defaults to RealtimeManager's 30s. */
+ realtimeCursorHeartbeatIntervalMs?: number;
+ /** Opt-in exact-topic shared notification transport. */
+ sharedRealtime?: {
+ subscriber: ActivatableGenerationScopedRealtimeSubscriber;
+ topicCollector: RealtimeTopicCollector;
+ listenerPgConfig: PgNotificationListenerConfig;
+ listenerIdentity: string;
+ roleRevalidationMs: number;
+ };
}
/**
@@ -29,6 +86,8 @@ interface GraphileInstanceOptions {
*
* Callers are responsible for building the `GraphileConfig.Preset` (including
* pgServices, grafserv options, grafast context, etc.) before passing it here.
+ * When `poolLease` is supplied, ownership transfers only when this promise
+ * resolves. If instance creation rejects, the caller must release the lease.
*
* When `enableRealtime` is true, a RealtimeManager is created that bridges
* cursor-tracked events from `drain_changes()` into the PostGraphile
@@ -39,56 +98,182 @@ interface GraphileInstanceOptions {
export const createGraphileInstance = async (
opts: GraphileInstanceOptions
): Promise => {
- const { preset, cacheKey, enableRealtime = false } = opts;
+ const {
+ preset,
+ cacheKey,
+ poolIdentity,
+ poolLease,
+ serviceKey,
+ databaseId,
+ enableRealtime = false,
+ enableWebsockets = false,
+ realtimeSchema,
+ realtimeSourceSchemas,
+ realtimeCursorPollIntervalMs,
+ realtimeCursorHeartbeatIntervalMs,
+ sharedRealtime
+ } = opts;
+
+ if (poolLease && poolIdentity && poolLease.identity !== poolIdentity) {
+ throw new Error(
+ `PostGraphile[${cacheKey}] pool identity does not match its retained lease`
+ );
+ }
const pgl = postgraphile(preset);
+ const resolvedPreset = pgl.getResolvedPreset();
+ const releasePresetServices = createPresetServicesReleaser(resolvedPreset);
const serv = pgl.createServ(grafserv);
+ const handler = createGraphileHttpHandler();
+ let upgradeHandler: GraphileUpgradeHandler | null = null;
+ let startupAttestation: GraphileRealtimeRoleAttestation | undefined;
+ let startupReleasePromise: Promise | null = null;
+ const releaseFailedGeneration = (): Promise => {
+ if (startupReleasePromise) return startupReleasePromise;
+ startupReleasePromise = (async () => {
+ let firstError: unknown;
+ try {
+ await pgl.release();
+ } catch (error) {
+ firstError = error;
+ }
+ try {
+ await releasePresetServices();
+ } catch (error) {
+ firstError ??= error;
+ }
+ try {
+ startupAttestation?.release();
+ } catch (error) {
+ firstError ??= error;
+ }
+ try {
+ await sharedRealtime?.subscriber.release();
+ } catch (error) {
+ firstError ??= error;
+ }
+ if (firstError) throw firstError;
+ })();
+ return startupReleasePromise;
+ };
- const handler = express();
- const httpServer = createServer(handler);
- await serv.addTo(handler, httpServer);
- await serv.ready();
+ // Start the schema build before wiring grafserv, but do not let this
+ // factory resolve until both are ready. `serv.ready()` alone does not
+ // guarantee that PostGraphile's gather/build phase has completed.
+ await awaitGraphileBuildReadiness({
+ schemaResult: pgl.getSchemaResult(),
+ addTo: async () => {
+ const presetWebsockets = resolvedPreset.grafserv?.websockets === true;
+ if (presetWebsockets !== enableWebsockets) {
+ throw new Error(
+ `PostGraphile[${cacheKey}] websocket preset and shared routing must agree`
+ );
+ }
+ await attachGraphileHttpHandler(serv, handler, resolvedPreset, {
+ sharedWebsocketRouting: enableWebsockets
+ });
+ if (enableWebsockets) {
+ upgradeHandler = await serv.getUpgradeHandler();
+ if (!upgradeHandler) {
+ throw new Error(
+ `PostGraphile[${cacheKey}] websocket upgrade handler is unavailable`
+ );
+ }
+ }
+ },
+ ready: () => serv.ready(),
+ release: releaseFailedGeneration,
+ onReleaseError: (releaseError) => {
+ log.error(`Failed to release PostGraphile[${cacheKey}] after build failure:`, releaseError);
+ }
+ });
const entry: GraphileCacheEntry = {
pgl,
serv,
handler,
- httpServer,
+ upgradeHandler,
+ httpServer: null,
cacheKey,
+ poolIdentity: poolLease?.identity ?? poolIdentity,
+ poolLease,
+ releasePresetServices,
+ serviceKey,
+ databaseId,
createdAt: Date.now(),
+ ...(sharedRealtime ? { realtimeSubscriber: sharedRealtime.subscriber } : {})
};
if (enableRealtime) {
- try {
- const { RealtimeManager } = await import('graphile-realtime-subscriptions');
-
- // Extract PgSubscriber and pool from the resolved preset's pgServices.
- // The pool is the same instance managed by pg-cache (via getPgPool)
- // and threaded into the preset by makePgService({ pool, schemas }).
- const resolvedPreset = pgl.getResolvedPreset();
- const pgService = (resolvedPreset as any).pgServices?.[0];
- const pgSubscriber = pgService?.pgSubscriber ?? null;
- const pool = pgService?.adaptorSettings?.pool ?? null;
-
- if (!pgSubscriber) {
- log.warn(`PostGraphile[${cacheKey}] has no pgSubscriber — RealtimeManager will not be started`);
- } else if (!pool) {
- log.warn(`PostGraphile[${cacheKey}] has no pool in pgService — RealtimeManager will not be started`);
- } else {
- const manager = new RealtimeManager({
- pgSubscriber,
- pool,
- nodeId: `graphile-cache:${cacheKey}`,
- schema: 'realtime_public',
+ const realtimeHealth = createGraphileRealtimeHealth();
+ entry.realtimeHealth = realtimeHealth;
+ const onFatalError = (error: Error): void => {
+ const alreadyFailed = entry.realtimeHealth?.status === 'failed';
+ retireGraphileCacheEntry(entry, error);
+ if (!alreadyFailed) {
+ log.error(
+ `PostGraphile[${cacheKey}] realtime delivery became unavailable:`,
+ error
+ );
+ }
+ };
+ if (sharedRealtime) {
+ const pgService = (resolvedPreset as any)?.pgServices?.[0];
+ if (pgService?.pgSubscriber !== sharedRealtime.subscriber) {
+ await releaseFailedGeneration();
+ throw new GraphileRealtimeStartupError(
+ cacheKey,
+ new Error('Resolved pgService did not retain the provided generation subscriber')
+ );
+ }
+ try {
+ startupAttestation = await activateGraphileSharedRealtime({
+ ...sharedRealtime,
+ allowedSourceSchemas: realtimeSourceSchemas ?? [],
+ onFatalError
});
-
- await manager.start();
- entry.realtimeManager = manager;
- log.info(`RealtimeManager started for PostGraphile[${cacheKey}]`);
+ entry.realtimeRoleAttestation = startupAttestation;
+ } catch (error) {
+ try {
+ await releaseFailedGeneration();
+ } catch (releaseError) {
+ log.error(
+ `Failed to release PostGraphile[${cacheKey}] after shared realtime activation failure:`,
+ releaseError
+ );
+ }
+ throw error instanceof GraphileRealtimeStartupError
+ ? error
+ : new GraphileRealtimeStartupError(cacheKey, error);
+ }
+ }
+ entry.realtimeManager = await startConfiguredRealtime({
+ cacheKey,
+ resolvedPreset,
+ realtimeSchema,
+ allowedSourceSchemas: realtimeSourceSchemas ?? [],
+ ...(sharedRealtime ? { publisher: sharedRealtime.subscriber } : {}),
+ ...(realtimeCursorPollIntervalMs === undefined
+ ? {}
+ : { pollIntervalMs: realtimeCursorPollIntervalMs }),
+ ...(realtimeCursorHeartbeatIntervalMs === undefined
+ ? {}
+ : { heartbeatIntervalMs: realtimeCursorHeartbeatIntervalMs }),
+ onFatalError,
+ releasePostGraphile: releaseFailedGeneration
+ });
+ if (entry.realtimeHealth.status === 'failed') {
+ try {
+ await entry.realtimeManager.stop();
+ } finally {
+ await releaseFailedGeneration();
}
- } catch (err) {
- log.error(`Failed to start RealtimeManager for PostGraphile[${cacheKey}]:`, err);
+ throw new GraphileRealtimeStartupError(
+ cacheKey,
+ new Error('Realtime delivery failed during generation activation')
+ );
}
+ log.info(`RealtimeManager started for PostGraphile[${cacheKey}]`);
}
return entry;
diff --git a/graphile/graphile-cache/src/graphile-cache.ts b/graphile/graphile-cache/src/graphile-cache.ts
index 83782c6a21..bcd7a2a009 100644
--- a/graphile/graphile-cache/src/graphile-cache.ts
+++ b/graphile/graphile-cache/src/graphile-cache.ts
@@ -1,23 +1,111 @@
+import type { Duplex } from 'node:stream';
+import { getHeapStatistics } from 'node:v8';
+
import { Logger } from '@pgpmjs/logger';
-import { parseEnvNumber } from '12factor-env';
import { EventEmitter } from 'events';
-import type { Express } from 'express';
+import type { NextFunction, Request, Response, Router } from 'express';
import type { GrafservBase } from 'grafserv';
-import type { Server as HttpServer } from 'http';
+import type { IncomingMessage, Server as HttpServer } from 'http';
import { LRUCache } from 'lru-cache';
-import { pgCache } from 'pg-cache';
+import { pgCache, type PgPoolLease } from 'pg-cache';
import type { PostGraphileInstance } from 'postgraphile';
+import {
+ GRAPHILE_REALTIME_UNAVAILABLE_CODE,
+ type GraphileRealtimeHealth,
+ withGraphileRealtimeFailure
+} from './realtime-readiness';
+import {
+ getGraphileRealtimeRoleAuditStats,
+ type GraphileRealtimeRoleAttestation
+} from './shared-realtime';
+
const log = new Logger('graphile-cache');
+export const GRAPHILE_WEBSOCKET_UNAVAILABLE_CODE =
+ 'GRAPHILE_WEBSOCKET_UNAVAILABLE';
+
+export type GraphileUpgradeHandler = (
+ request: IncomingMessage,
+ socket: Duplex,
+ head: Buffer
+) => void;
+
// --- Time Constants ---
export const ONE_HOUR_MS = 1000 * 60 * 60;
export const FIVE_MINUTES_MS = 1000 * 60 * 5;
const ONE_DAY = ONE_HOUR_MS * 24;
-const ONE_YEAR = ONE_DAY * 366;
+const SIX_HOURS_MS = ONE_DAY / 4;
// --- Eviction Types ---
-export type EvictionReason = 'lru' | 'ttl' | 'manual';
+export type EvictionReason =
+ | 'lru'
+ | 'ttl'
+ | 'manual'
+ | 'governor'
+ | 'admission'
+ | 'realtime';
+
+export interface CacheCounters {
+ /** Transient HTTP requests admitted to an exact resident handler. */
+ httpRequestsStarted: number;
+ /** Admitted HTTP requests that reached a terminal response state. */
+ httpRequestsCompleted: number;
+ /** WebSocket upgrades admitted to an exact resident upgrade handler. */
+ websocketUpgradesStarted: number;
+ /** Admitted WebSocket lifecycles that closed or errored. */
+ websocketUpgradesCompleted: number;
+ evictions: Record;
+ disposalsStarted: number;
+ disposalsCompleted: number;
+ disposalFailures: number;
+ drainTimeouts: number;
+ disposalTimeouts: number;
+ buildRefusals: Record;
+}
+
+const cacheCounters: CacheCounters = {
+ httpRequestsStarted: 0,
+ httpRequestsCompleted: 0,
+ websocketUpgradesStarted: 0,
+ websocketUpgradesCompleted: 0,
+ evictions: {
+ lru: 0,
+ ttl: 0,
+ manual: 0,
+ governor: 0,
+ admission: 0,
+ realtime: 0
+ },
+ disposalsStarted: 0,
+ disposalsCompleted: 0,
+ disposalFailures: 0,
+ drainTimeouts: 0,
+ disposalTimeouts: 0,
+ buildRefusals: {
+ critical_pressure: 0,
+ insufficient_budget: 0,
+ rss_budget_exceeded: 0,
+ disposal_timeout: 0,
+ resident_busy: 0,
+ resident_capacity: 0,
+ disposal_failed: 0
+ }
+};
+
+export const getCacheCounters = (): CacheCounters => ({
+ httpRequestsStarted: cacheCounters.httpRequestsStarted,
+ httpRequestsCompleted: cacheCounters.httpRequestsCompleted,
+ websocketUpgradesStarted: cacheCounters.websocketUpgradesStarted,
+ websocketUpgradesCompleted: cacheCounters.websocketUpgradesCompleted,
+ evictions: { ...cacheCounters.evictions },
+ disposalsStarted: cacheCounters.disposalsStarted,
+ disposalsCompleted: cacheCounters.disposalsCompleted,
+ disposalFailures: cacheCounters.disposalFailures,
+ drainTimeouts: cacheCounters.drainTimeouts,
+ disposalTimeouts: cacheCounters.disposalTimeouts,
+ buildRefusals: { ...cacheCounters.buildRefusals }
+});
// --- Cache Event Emitter ---
export interface CacheEvictionEvent {
@@ -42,30 +130,243 @@ export const cacheEvents = new CacheEventEmitter();
export interface CacheConfig {
max: number;
ttl: number;
+ admissionMode: CacheAdmissionMode;
+ heapLimitBytes: number;
+ /** Explicit process-RSS ceiling. Null leaves RSS observable but unbounded. */
+ rssLimitBytes: number | null;
+ instanceHeapBytes: number;
+ serverReserveBytes: number;
+ buildReserveBytes: number;
+ /** Transient RSS reserved before admitting one serialized build. */
+ rssBuildReserveBytes: number;
+ budgetCapacity: number;
+ calibration: CacheCalibrationProvenance;
+}
+
+export type CacheAdmissionMode = 'evict-idle' | 'preserve-resident';
+
+export type CacheCalibrationSource =
+ | 'default'
+ | 'environment'
+ | 'runtime-safety-floor';
+
+export interface CacheCalibrationProvenance {
+ id: string | null;
+ instanceHeapSource: CacheCalibrationSource;
+ instanceHeapSampleCount: number;
+ serverReserveSource: Exclude;
+ buildReserveSource: Exclude;
}
+const DEFAULT_INSTANCE_HEAP_BYTES = 512 * 1024 * 1024;
+const DEFAULT_SERVER_RESERVE_BYTES = 256 * 1024 * 1024;
+const DEFAULT_BUILD_RESERVE_BYTES = 768 * 1024 * 1024;
+const DEFAULT_RSS_BUILD_RESERVE_BYTES = DEFAULT_BUILD_RESERVE_BYTES;
+const MIN_BACKING_CACHE_ENTRIES = 1024;
+const MAX_BACKING_CACHE_ENTRIES = 65_536;
+// This is only a sparse-LRU allocation budget, never an estimate of a real
+// Graphile instance. Keep it comfortably below every measured instance cost so
+// the backing data structure cannot become the density limit before heap
+// admission does.
+const BACKING_CACHE_BYTES_PER_ENTRY = 256 * 1024;
+
+export const computeBackingCacheMax = (heapLimitBytes: number): number => {
+ if (!Number.isFinite(heapLimitBytes) || heapLimitBytes <= 0) {
+ return MIN_BACKING_CACHE_ENTRIES;
+ }
+ return Math.max(
+ MIN_BACKING_CACHE_ENTRIES,
+ Math.min(
+ MAX_BACKING_CACHE_ENTRIES,
+ Math.floor(heapLimitBytes / BACKING_CACHE_BYTES_PER_ENTRY)
+ )
+ );
+};
+
+const BACKING_CACHE_MAX = computeBackingCacheMax(
+ getHeapStatistics().heap_size_limit
+);
+
+const parsePositiveInt = (value: string | undefined, fallback: number): number => {
+ const parsed = value ? Number.parseInt(value, 10) : Number.NaN;
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+};
+
+const parseExplicitPositiveInt = (
+ name: string,
+ value: string | undefined
+): number | undefined => {
+ if (value === undefined) return undefined;
+ const parsed = Number(value);
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
+ throw new Error(`${name} must be a positive safe integer`);
+ }
+ return parsed;
+};
+
+const parseAdmissionMode = (value: string | undefined): CacheAdmissionMode => {
+ if (value === undefined || value === 'evict-idle') return 'evict-idle';
+ if (value === 'preserve-resident') return 'preserve-resident';
+ throw new Error(
+ 'GRAPHILE_CACHE_ADMISSION_MODE must be evict-idle or preserve-resident'
+ );
+};
+
+const resolveCalibrationValue = (
+ name: string,
+ fallback: number
+): { bytes: number; source: 'default' | 'environment' } => {
+ const configured = parseExplicitPositiveInt(name, process.env[name]);
+ return configured === undefined
+ ? { bytes: fallback, source: 'default' }
+ : { bytes: configured, source: 'environment' };
+};
+
+const measuredInstanceSamples: number[] = [];
+
+/** Record a retained-heap sample from a validated warm instance. */
+export const recordInstanceHeapSample = (bytes: number): void => {
+ if (!Number.isFinite(bytes) || bytes <= 0) return;
+ measuredInstanceSamples.push(Math.round(bytes));
+ if (measuredInstanceSamples.length > 31) measuredInstanceSamples.shift();
+};
+
+export const resetInstanceHeapSamples = (): void => {
+ measuredInstanceSamples.length = 0;
+};
+
+const median = (values: number[]): number => {
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[Math.floor(sorted.length / 2)];
+};
+
+const resolveInstanceHeapEstimate = (): {
+ bytes: number;
+ source: CacheCalibrationSource;
+} => {
+ const configured = resolveCalibrationValue(
+ 'GRAPHILE_CACHE_INSTANCE_HEAP_BYTES',
+ DEFAULT_INSTANCE_HEAP_BYTES
+ );
+ if (measuredInstanceSamples.length === 0) return configured;
+ const measuredWithReserve = Math.ceil(median(measuredInstanceSamples) * 1.2);
+ if (measuredWithReserve <= configured.bytes) return configured;
+ return { bytes: measuredWithReserve, source: 'runtime-safety-floor' };
+};
+
+export const getInstanceHeapEstimate = (): number =>
+ resolveInstanceHeapEstimate().bytes;
+
+/**
+ * Return the number of resident instances for which both steady-state and
+ * one-build-transient budgets fit. Zero means a build cannot be admitted.
+ */
+export const computeCapacityFromBudget = (
+ heapLimitBytes: number,
+ instanceHeapBytes: number,
+ serverReserveBytes = DEFAULT_SERVER_RESERVE_BYTES,
+ buildReserveBytes = DEFAULT_BUILD_RESERVE_BYTES
+): number => {
+ if (
+ heapLimitBytes <= 0 ||
+ instanceHeapBytes <= 0 ||
+ serverReserveBytes + buildReserveBytes > heapLimitBytes
+ ) {
+ return 0;
+ }
+ const byResidency = Math.floor(
+ (heapLimitBytes - serverReserveBytes) / instanceHeapBytes
+ );
+ const byRebuild = Math.floor(
+ (heapLimitBytes - serverReserveBytes - buildReserveBytes) / instanceHeapBytes
+ ) + 1;
+ return Math.max(
+ 0,
+ Math.min(computeBackingCacheMax(heapLimitBytes), byResidency, byRebuild)
+ );
+};
+
/**
* Get cache configuration from environment variables
*
* Supports:
- * - GRAPHILE_CACHE_MAX: Maximum number of entries (default: 50)
+ * - GRAPHILE_CACHE_MAX: Operator ceiling (default: heap-budget-derived)
+ * - GRAPHILE_CACHE_ADMISSION_MODE: evict-idle (default) or preserve-resident
+ * - GRAPHILE_CACHE_RSS_LIMIT_BYTES: Optional absolute process-RSS ceiling
+ * - GRAPHILE_CACHE_RSS_BUILD_RESERVE_BYTES: RSS reserved for one build
* - GRAPHILE_CACHE_TTL_MS: TTL in milliseconds
* - Production default: ONE_YEAR
* - Development default: FIVE_MINUTES_MS
*
- * NOTE: This value should be <= PG_CACHE_MAX (also default: 50) so that
- * every cached PostGraphile instance has a live pool backing it.
+ * Resident instances protect their exact runtime pools with `PgPoolLease`, so
+ * pool capacity and Graphile heap capacity are independent limits. Pool
+ * exhaustion fails closed when every registry identity is leased.
*/
export function getCacheConfig(): CacheConfig {
const isDevelopment = process.env.NODE_ENV === 'development';
+ const heapLimitBytes = getHeapStatistics().heap_size_limit;
+ const instanceHeap = resolveInstanceHeapEstimate();
+ const serverReserve = resolveCalibrationValue(
+ 'GRAPHILE_CACHE_SERVER_RESERVE_BYTES',
+ DEFAULT_SERVER_RESERVE_BYTES
+ );
+ const buildReserve = resolveCalibrationValue(
+ 'GRAPHILE_CACHE_BUILD_RESERVE_BYTES',
+ DEFAULT_BUILD_RESERVE_BYTES
+ );
+ const rssLimitBytes = parseExplicitPositiveInt(
+ 'GRAPHILE_CACHE_RSS_LIMIT_BYTES',
+ process.env.GRAPHILE_CACHE_RSS_LIMIT_BYTES
+ ) ?? null;
+ const rssBuildReserveBytes = parseExplicitPositiveInt(
+ 'GRAPHILE_CACHE_RSS_BUILD_RESERVE_BYTES',
+ process.env.GRAPHILE_CACHE_RSS_BUILD_RESERVE_BYTES
+ ) ?? DEFAULT_RSS_BUILD_RESERVE_BYTES;
+ const instanceHeapBytes = instanceHeap.bytes;
+ const serverReserveBytes = serverReserve.bytes;
+ const buildReserveBytes = buildReserve.bytes;
+ const budgetCapacity = computeCapacityFromBudget(
+ heapLimitBytes,
+ instanceHeapBytes,
+ serverReserveBytes,
+ buildReserveBytes
+ );
+ const requestedMax = parseExplicitPositiveInt(
+ 'GRAPHILE_CACHE_MAX',
+ process.env.GRAPHILE_CACHE_MAX
+ ) ?? (budgetCapacity || 1);
+ if (requestedMax > BACKING_CACHE_MAX) {
+ throw new Error(
+ `GRAPHILE_CACHE_MAX exceeds heap-scaled backing ceiling ${BACKING_CACHE_MAX}`
+ );
+ }
+ // The backing LRU requires at least one slot. Admission still fails closed
+ // when budgetCapacity is zero, so the synthetic slot is never built into.
+ const max = Math.max(1, Math.min(requestedMax, budgetCapacity || 1));
+ const ttl = parsePositiveInt(
+ process.env.GRAPHILE_CACHE_TTL_MS,
+ isDevelopment ? FIVE_MINUTES_MS : SIX_HOURS_MS
+ );
- const max = parseEnvNumber(process.env.GRAPHILE_CACHE_MAX) ?? 50;
-
- const ttl =
- parseEnvNumber(process.env.GRAPHILE_CACHE_TTL_MS) ??
- (isDevelopment ? FIVE_MINUTES_MS : ONE_YEAR);
-
- return { max, ttl };
+ return {
+ max,
+ ttl,
+ admissionMode: parseAdmissionMode(process.env.GRAPHILE_CACHE_ADMISSION_MODE),
+ heapLimitBytes,
+ rssLimitBytes,
+ instanceHeapBytes,
+ serverReserveBytes,
+ buildReserveBytes,
+ rssBuildReserveBytes,
+ budgetCapacity,
+ calibration: {
+ id: process.env.GRAPHILE_CACHE_CALIBRATION_ID?.trim() || null,
+ instanceHeapSource: instanceHeap.source,
+ instanceHeapSampleCount: measuredInstanceSamples.length,
+ serverReserveSource: serverReserve.source,
+ buildReserveSource: buildReserve.source
+ }
+ };
}
/**
@@ -74,79 +375,230 @@ export function getCacheConfig(): CacheConfig {
* Each entry contains:
* - pgl: The PostGraphile instance (manages schema, plugins, etc.)
* - serv: The Grafserv server instance (handles HTTP/WS)
- * - handler: Express app for routing requests
- * - httpServer: Node HTTP server (required by grafserv)
+ * - handler: Lean Express router for routing requests
+ * - httpServer: Optional legacy/custom server; cached instances use the shared
+ * outer server and leave this null
* - cacheKey: Unique identifier for this entry
* - createdAt: Timestamp when this entry was created
*/
export interface GraphileCacheEntry {
pgl: PostGraphileInstance;
serv: GrafservBase;
- handler: Express;
- httpServer: HttpServer;
+ handler: Router;
+ /** No-server Grafserv handler selected only after exact tenant routing. */
+ upgradeHandler?: GraphileUpgradeHandler | null;
+ /** Raw sockets retained so disposal can terminate long-lived subscriptions. */
+ websocketSockets?: Set;
+ httpServer: HttpServer | null;
cacheKey: string;
+ /** Opaque pg-cache identity used by this instance. */
+ poolIdentity?: string;
+ /**
+ * Runtime pool ownership transferred from `createGraphileInstance()`.
+ * Disposal releases it only after requests and long-lived resources drain.
+ */
+ poolLease?: PgPoolLease;
+ /** Idempotent release for pgServices owned by this exact preset generation. */
+ releasePresetServices?: () => Promise;
+ /** Routing label for diagnostics and targeted invalidation only. */
+ serviceKey?: string;
+ /** Tenant database id for targeted invalidation. */
+ databaseId?: string | null;
createdAt: number;
/** Optional RealtimeManager for cursor-tracked subscription delivery */
realtimeManager?: { stop(): Promise } | null;
+ /** Caller-provided shared subscriber; preset services do not own it. */
+ realtimeSubscriber?: { release(): Promise } | null;
+ /** Credential-free role-audit provenance plus coalesced TTL refresh. */
+ realtimeRoleAttestation?: GraphileRealtimeRoleAttestation;
+ /** Fatal delivery failures latch this generation unavailable until rebuilt. */
+ realtimeHealth?: GraphileRealtimeHealth;
+ /** Requests currently executing through this exact instance. */
+ inflight?: number;
+ /** Once true, no new request may enter this instance. */
+ disposing?: boolean;
+ /** Optional retained-heap measurement supplied by the validation harness. */
+ retainedHeapBytes?: number;
}
-// Track disposed entries to prevent double-disposal
-const disposedKeys = new Set();
+const disposalPromises = new WeakMap>();
+const activeDisposals = new Set>();
+let failedDisposalCount = 0;
+const drainWaiters = new WeakMap void>>();
+const pendingEvictionReasons = new Map();
-// Track keys that are being manually evicted for accurate eviction reason
-const manualEvictionKeys = new Set();
+export const getDrainingCount = (): number => activeDisposals.size;
+
+const notifyDrained = (entry: GraphileCacheEntry): void => {
+ if ((entry.inflight ?? 0) > 0) return;
+ const waiters = drainWaiters.get(entry);
+ if (!waiters) return;
+ drainWaiters.delete(entry);
+ for (const resolve of waiters) resolve();
+};
+
+const waitForEntryDrain = (entry: GraphileCacheEntry): Promise => {
+ if ((entry.inflight ?? 0) === 0) return Promise.resolve();
+ return new Promise((resolve) => {
+ const waiters = drainWaiters.get(entry) ?? new Set<() => void>();
+ waiters.add(resolve);
+ drainWaiters.set(entry, waiters);
+ });
+};
+
+export const raceWithClearedTimeout = async (
+ promise: Promise,
+ timeoutMs: number
+): Promise<{ timedOut: false; value: T } | { timedOut: true }> => {
+ let timer: ReturnType | undefined;
+ const timeout = new Promise<{ timedOut: true }>((resolve) => {
+ timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
+ timer.unref?.();
+ });
+ try {
+ return await Promise.race([
+ promise.then((value) => ({ timedOut: false as const, value })),
+ timeout
+ ]);
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+};
/**
* Dispose a PostGraphile v5 cache entry
*
* Properly releases resources by:
- * 1. Closing the HTTP server if listening
- * 2. Releasing the PostGraphile instance (which internally releases grafserv)
+ * 1. Waiting for resident requests to drain
+ * 2. Closing the HTTP server and releasing PostGraphile/Grafserv
+ * 3. Releasing the generation's preset services and PgSubscriber checkout
+ * 4. Stopping cursor-tracked realtime delivery
+ * 4. Releasing the retained runtime-pool lease
*
- * Uses disposedKeys set to prevent double-disposal when closeAllCaches()
- * explicitly disposes entries and then clear() triggers the dispose callback.
+ * The promise is keyed by entry identity, so two generations with the same
+ * cache key both release exactly once and duplicate teardown is coalesced.
*/
-const disposeEntry = async (entry: GraphileCacheEntry, key: string): Promise => {
- // Prevent double-disposal
- if (disposedKeys.has(key)) {
- return;
- }
- disposedKeys.add(key);
+const scheduleDisposal = (entry: GraphileCacheEntry, key: string): Promise => {
+ const existing = disposalPromises.get(entry);
+ if (existing) return existing;
- log.debug(`Disposing PostGraphile[${key}]`);
- try {
- // Close HTTP server if it's listening
- if (entry.httpServer?.listening) {
- await new Promise((resolve) => {
- entry.httpServer.close(() => resolve());
- });
+ entry.disposing = true;
+ // WebSocket subscriptions are deliberately long-lived. Waiting for clients
+ // to leave voluntarily would make LRU eviction and shutdown unbounded, so a
+ // retiring generation terminates only its own exact sockets before draining.
+ for (const socket of entry.websocketSockets ?? []) socket.destroy();
+ cacheCounters.disposalsStarted++;
+ const pending = (async () => {
+ const drainTimeoutMs = parsePositiveInt(
+ process.env.GRAPHILE_CACHE_DRAIN_TIMEOUT_MS,
+ 30_000
+ );
+ const initialDrain = await raceWithClearedTimeout(waitForEntryDrain(entry), drainTimeoutMs);
+ if (initialDrain.timedOut) {
+ cacheCounters.drainTimeouts++;
+ log.warn(
+ `PostGraphile[${key}] still has ${entry.inflight ?? 0} request(s) after ` +
+ `${drainTimeoutMs}ms; teardown remains deferred until they finish`
+ );
+ // Correctness wins over reclaim speed: never release an instance while a
+ // resident request is still executing through it.
+ await waitForEntryDrain(entry);
}
- // Stop RealtimeManager if present (before releasing PostGraphile)
- if (entry.realtimeManager) {
- try {
- await entry.realtimeManager.stop();
- } catch (err) {
- log.error(`Error stopping RealtimeManager for PostGraphile[${key}]:`, err);
+
+ log.debug(`Disposing PostGraphile[${key}]`);
+ let firstError: unknown;
+ try {
+ if (entry.httpServer) {
+ await new Promise((resolve) => entry.httpServer.close(() => resolve()));
}
+ } catch (error) {
+ firstError = error;
}
- // Release PostGraphile instance (this also releases grafserv internally)
- if (entry.pgl) {
+ try {
await entry.pgl.release();
+ } catch (error) {
+ firstError ??= error;
}
- } catch (err) {
- log.error(`Error disposing PostGraphile[${key}]:`, err);
- } finally {
- disposedKeys.delete(key);
- }
+ try {
+ await entry.releasePresetServices?.();
+ } catch (error) {
+ firstError ??= error;
+ }
+ try {
+ // A live GraphQL subscription may hold the PgSubscriber checkout while
+ // cursor tracking uses the other slot in the minimum max=2 runtime pool.
+ // Release Grafserv and the preset services first so cursor cleanup cannot
+ // deadlock waiting for a checkout that only PgSubscriber teardown returns.
+ if (entry.realtimeManager) await entry.realtimeManager.stop();
+ } catch (error) {
+ firstError ??= error;
+ }
+ try {
+ entry.realtimeRoleAttestation?.release();
+ } catch (error) {
+ firstError ??= error;
+ }
+ try {
+ await entry.realtimeSubscriber?.release();
+ } catch (error) {
+ firstError ??= error;
+ }
+ try {
+ entry.poolLease?.release();
+ } catch (error) {
+ firstError ??= error;
+ }
+ if (firstError) throw firstError;
+ cacheCounters.disposalsCompleted++;
+ })();
+
+ disposalPromises.set(entry, pending);
+ activeDisposals.add(pending);
+ void pending
+ .catch((error) => {
+ failedDisposalCount++;
+ cacheCounters.disposalFailures++;
+ log.error(`Failed to dispose PostGraphile[${key}]:`, error);
+ })
+ .finally(() => activeDisposals.delete(pending));
+ return pending;
+};
+
+/** Dispose an instance that finished building after its contract was invalidated. */
+export const disposeUncachedEntry = (
+ entry: GraphileCacheEntry,
+ key = entry.cacheKey
+): Promise => scheduleDisposal(entry, key);
+
+export const waitForEntryDisposal = async (
+ entry: GraphileCacheEntry,
+ timeoutMs = 20_000
+): Promise => {
+ const pending = disposalPromises.get(entry);
+ if (!pending) return true;
+ const result = await raceWithClearedTimeout(pending, timeoutMs);
+ if (result.timedOut) cacheCounters.disposalTimeouts++;
+ return !result.timedOut;
+};
+
+export const waitForActiveDisposals = async (timeoutMs = 20_000): Promise => {
+ if (activeDisposals.size === 0) return true;
+ const result = await raceWithClearedTimeout(
+ Promise.allSettled([...activeDisposals]),
+ timeoutMs
+ );
+ if (result.timedOut) cacheCounters.disposalTimeouts++;
+ return !result.timedOut;
};
/**
* Determine the eviction reason for a cache entry
*/
const getEvictionReason = (key: string, entry: GraphileCacheEntry): EvictionReason => {
- if (manualEvictionKeys.has(key)) {
- manualEvictionKeys.delete(key);
- return 'manual';
+ const explicit = pendingEvictionReasons.get(key);
+ if (explicit) {
+ pendingEvictionReasons.delete(key);
+ return explicit;
}
// Check if TTL expired
@@ -164,32 +616,597 @@ const initialConfig = getCacheConfig();
// --- Graphile Cache ---
export const graphileCache = new LRUCache({
- max: initialConfig.max,
+ // Admission enforces the dynamic heap-derived maximum. Keep the backing LRU
+ // at the hard ceiling so validated lower per-instance measurements can raise
+ // density without reconstructing the cache object.
+ max: BACKING_CACHE_MAX,
ttl: initialConfig.ttl,
updateAgeOnGet: true,
dispose: (entry, key) => {
- // Determine eviction reason before disposal
const reason = getEvictionReason(key, entry);
+ cacheCounters.evictions[reason]++;
// Emit eviction event
cacheEvents.emitEviction({ key, reason, entry });
log.debug(`Evicting PostGraphile[${key}] (reason: ${reason})`);
- // LRU dispose is synchronous, but v5 disposal is async
- // Fire and forget the async cleanup
- disposeEntry(entry, key).catch((err) => {
- log.error(`Failed to dispose PostGraphile[${key}]:`, err);
- });
+ scheduleDisposal(entry, key);
}
});
+/**
+ * The server normally refreshes an expired role attestation before invoking a
+ * resident entry. Keep the cache boundary fail-closed too: direct consumers
+ * and synchronous WebSocket upgrades must not serve through an expired or
+ * failed listener-role proof.
+ */
+export const isEntryRealtimeUnavailable = (entry: GraphileCacheEntry): boolean => {
+ if (entry.realtimeHealth?.status === 'failed') return true;
+ const attestation = entry.realtimeRoleAttestation?.snapshot();
+ return Boolean(
+ attestation
+ && (attestation.status === 'failed' || Date.now() >= attestation.validUntil)
+ );
+};
+
+/**
+ * Permanently retire one exact generation after a fail-closed safety check.
+ * Marking the entry unavailable happens before cache removal so no concurrent
+ * HTTP request or WebSocket operation can enter between the failure and the
+ * disposal callback. Only the same resident object may be evicted; a healthy
+ * replacement with the same deterministic contract key is never touched.
+ */
+export const retireGraphileCacheEntry = (
+ entry: GraphileCacheEntry,
+ error: unknown,
+ reason: EvictionReason = 'realtime'
+): boolean => {
+ entry.realtimeHealth = withGraphileRealtimeFailure(
+ entry.realtimeHealth ?? { status: 'healthy' },
+ error
+ );
+ const resident = graphileCache.peek(entry.cacheKey, { allowStale: true });
+ if (resident === entry) {
+ entry.disposing = true;
+ pendingEvictionReasons.set(entry.cacheKey, reason);
+ graphileCache.delete(entry.cacheKey);
+ return true;
+ }
+
+ // An unpublished failed candidate must be rejected by publication. A stale
+ // object racing a healthy replacement is already detached and must not alter
+ // that replacement's lifecycle or masquerade as its disposal.
+ if (!resident) entry.disposing = true;
+
+ // Cache removal normally destroys these through scheduleDisposal(). Keep the
+ // boundary fail-closed for an entry racing publication/removal as well.
+ for (const socket of entry.websocketSockets ?? []) socket.destroy();
+ return false;
+};
+
+/** Enter an instance only while it is resident and not being torn down. */
+export const invokeEntryHandler = (
+ entry: GraphileCacheEntry,
+ req: Request,
+ res: Response,
+ next: NextFunction
+): boolean => {
+ const requestEnded = (): boolean =>
+ Boolean(
+ req.aborted
+ || req.socket?.destroyed
+ || res.destroyed
+ || res.writableEnded
+ );
+ if (requestEnded()) return false;
+ if (isEntryRealtimeUnavailable(entry)) {
+ // Retire only this exact resident generation. A delayed fatal callback or
+ // stale in-flight waiter must never evict a healthy replacement that uses
+ // the same deterministic build-contract key.
+ retireGraphileCacheEntry(
+ entry,
+ new Error('Graphile realtime generation is unavailable')
+ );
+ if (!res.headersSent) {
+ res.setHeader('Retry-After', '15');
+ res.status(503).json({
+ error: {
+ code: GRAPHILE_REALTIME_UNAVAILABLE_CODE,
+ message: 'Realtime delivery is unavailable for this GraphQL instance'
+ }
+ });
+ }
+ return true;
+ }
+ if (entry.disposing) return false;
+ cacheCounters.httpRequestsStarted++;
+ entry.inflight = (entry.inflight ?? 0) + 1;
+ let released = false;
+ const release = (): void => {
+ if (released) return;
+ released = true;
+ cacheCounters.httpRequestsCompleted++;
+ entry.inflight = Math.max(0, (entry.inflight ?? 1) - 1);
+ notifyDrained(entry);
+ };
+ res.once('finish', release);
+ res.once('close', release);
+ // The response can close between the initial check and listener attachment.
+ // Rechecking after attachment turns that race into an ordinary release.
+ if (requestEnded()) {
+ res.removeListener('finish', release);
+ res.removeListener('close', release);
+ release();
+ return false;
+ }
+ try {
+ entry.handler(req, res, next);
+ } catch (error) {
+ release();
+ throw error;
+ }
+ return true;
+};
+
+/**
+ * Refresh an expired shared-listener role audit before serving through a
+ * resident generation. A failed refresh latches realtimeHealth via the
+ * activation observer, so the normal invocation boundary returns 503.
+ */
+export const revalidateEntryRealtimeRole = async (
+ entry: GraphileCacheEntry
+): Promise => {
+ if (!entry.realtimeRoleAttestation) return true;
+ return entry.realtimeRoleAttestation.revalidateIfDue();
+};
+
+export interface GraphileUpgradeInvocationOptions {
+ /** Transfer the outer transport after this exact generation is retained. */
+ onAccepted?: () => void;
+ /** Retire outer admission state before a stable cache-level rejection. */
+ onRejected?: () => void;
+}
+
+const writeUpgradeError = (
+ socket: Duplex,
+ status: number,
+ code: string,
+ retryAfter?: number
+): void => {
+ if (socket.destroyed) return;
+ const body = JSON.stringify({ error: { code } });
+ const headers = [
+ `HTTP/1.1 ${status} Service Unavailable`,
+ 'Connection: close',
+ 'Content-Type: application/json; charset=utf-8',
+ `Content-Length: ${Buffer.byteLength(body)}`,
+ ...(retryAfter == null ? [] : [`Retry-After: ${retryAfter}`]),
+ '',
+ body
+ ].join('\r\n');
+ try {
+ socket.end(headers);
+ } catch {
+ socket.destroy();
+ }
+};
+
+/**
+ * Route one already-authorized WebSocket upgrade into an exact cache entry.
+ * The outer server owns host/path/API selection; this function owns generation
+ * health, drain accounting, and bounded teardown of the accepted socket.
+ */
+export const invokeEntryUpgradeHandler = (
+ entry: GraphileCacheEntry,
+ request: IncomingMessage,
+ socket: Duplex,
+ head: Buffer,
+ options: GraphileUpgradeInvocationOptions = {}
+): boolean => {
+ if (request.aborted || socket.destroyed) return false;
+ if (isEntryRealtimeUnavailable(entry)) {
+ retireGraphileCacheEntry(
+ entry,
+ new Error('Graphile realtime generation is unavailable')
+ );
+ try {
+ options.onRejected?.();
+ writeUpgradeError(socket, 503, GRAPHILE_REALTIME_UNAVAILABLE_CODE, 15);
+ } catch (error) {
+ socket.destroy();
+ throw error;
+ }
+ return true;
+ }
+ if (entry.disposing) return false;
+ if (!entry.upgradeHandler) {
+ try {
+ options.onRejected?.();
+ writeUpgradeError(socket, 503, GRAPHILE_WEBSOCKET_UNAVAILABLE_CODE, 15);
+ } catch (error) {
+ socket.destroy();
+ throw error;
+ }
+ return true;
+ }
+
+ cacheCounters.websocketUpgradesStarted++;
+ entry.inflight = (entry.inflight ?? 0) + 1;
+ const sockets = entry.websocketSockets ?? new Set();
+ entry.websocketSockets = sockets;
+ sockets.add(socket);
+ let released = false;
+ const release = (): void => {
+ if (released) return;
+ released = true;
+ cacheCounters.websocketUpgradesCompleted++;
+ socket.removeListener('close', release);
+ socket.removeListener('error', release);
+ sockets.delete(socket);
+ entry.inflight = Math.max(0, (entry.inflight ?? 1) - 1);
+ notifyDrained(entry);
+ };
+ socket.once('close', release);
+ socket.once('error', release);
+ if (request.aborted || socket.destroyed || entry.disposing) {
+ release();
+ return false;
+ }
+ try {
+ // The outer router may own a synthetic HTTP response while it runs tenant
+ // routing, authentication, and build admission. Transfer that transport
+ // only after this exact generation has passed every fail-closed check and
+ // is already accounted as in-flight.
+ options.onAccepted?.();
+ entry.upgradeHandler(request, socket, head);
+ } catch (error) {
+ release();
+ socket.destroy();
+ throw error;
+ }
+ return true;
+};
+
+export type MemoryPressureLevel = 'ok' | 'elevated' | 'critical';
+
+export interface MemoryPressure {
+ level: MemoryPressureLevel;
+ heapLevel: MemoryPressureLevel;
+ rssLevel: MemoryPressureLevel | 'unbounded';
+ heapUsed: number;
+ heapLimit: number;
+ available: number;
+ ratio: number;
+ rssBytes: number;
+ rssLimitBytes: number | null;
+ rssRatio: number | null;
+}
+
+const parseFraction = (value: string | undefined, fallback: number): number => {
+ const parsed = value ? Number.parseFloat(value) : Number.NaN;
+ return Number.isFinite(parsed) && parsed > 0 && parsed < 1 ? parsed : fallback;
+};
+
+const pressureLevel = (
+ ratio: number,
+ elevatedAt: number,
+ criticalAt: number
+): MemoryPressureLevel => ratio >= criticalAt
+ ? 'critical'
+ : ratio >= elevatedAt
+ ? 'elevated'
+ : 'ok';
+
+export const getMemoryPressure = (): MemoryPressure => {
+ const stats = getHeapStatistics();
+ const memory = process.memoryUsage();
+ const heapUsed = memory.heapUsed;
+ const available = stats.total_available_size ?? Math.max(0, stats.heap_size_limit - heapUsed);
+ const exhaustible = heapUsed + available;
+ const ratio = exhaustible > 0 ? heapUsed / exhaustible : 0;
+ const elevatedAt = parseFraction(
+ process.env.GRAPHILE_MEMORY_GOVERNOR_ELEVATED,
+ 0.85
+ );
+ const criticalAt = parseFraction(
+ process.env.GRAPHILE_MEMORY_GOVERNOR_CRITICAL,
+ 0.92
+ );
+ const heapLevel = pressureLevel(ratio, elevatedAt, criticalAt);
+ const rssLimitBytes = getCacheConfig().rssLimitBytes;
+ const rssRatio = rssLimitBytes == null ? null : memory.rss / rssLimitBytes;
+ const rssLevel = rssRatio == null
+ ? 'unbounded' as const
+ : pressureLevel(rssRatio, elevatedAt, criticalAt);
+ const level: MemoryPressureLevel = heapLevel === 'critical' || rssLevel === 'critical'
+ ? 'critical'
+ : heapLevel === 'elevated' || rssLevel === 'elevated'
+ ? 'elevated'
+ : 'ok';
+ return {
+ level,
+ heapLevel,
+ rssLevel,
+ heapUsed,
+ heapLimit: stats.heap_size_limit,
+ available,
+ ratio,
+ rssBytes: memory.rss,
+ rssLimitBytes,
+ rssRatio
+ };
+};
+
+export type BuildRefusalReason =
+ | 'critical_pressure'
+ | 'insufficient_budget'
+ | 'rss_budget_exceeded'
+ | 'disposal_timeout'
+ | 'resident_busy'
+ | 'resident_capacity'
+ | 'disposal_failed';
+
+export interface BuildAdmissionDecision {
+ admit: boolean;
+ reason?: BuildRefusalReason;
+ pressure: MemoryPressure;
+ projectedBytes: number;
+ heapLimitBytes: number;
+ projectedRssBytes: number;
+ rssLimitBytes: number | null;
+}
+
+export const evaluateBuildAdmission = (
+ residentCount = graphileCache.size
+): BuildAdmissionDecision => {
+ const config = getCacheConfig();
+ const pressure = getMemoryPressure();
+ const projectedBytes =
+ config.serverReserveBytes +
+ residentCount * config.instanceHeapBytes +
+ config.buildReserveBytes;
+ const projectedRssBytes = pressure.rssBytes + config.rssBuildReserveBytes;
+ if (pressure.level === 'critical') {
+ return {
+ admit: false,
+ reason: 'critical_pressure',
+ pressure,
+ projectedBytes,
+ heapLimitBytes: config.heapLimitBytes,
+ projectedRssBytes,
+ rssLimitBytes: config.rssLimitBytes
+ };
+ }
+ if (failedDisposalCount > 0) {
+ return {
+ admit: false,
+ reason: 'disposal_failed',
+ pressure,
+ projectedBytes,
+ heapLimitBytes: config.heapLimitBytes,
+ projectedRssBytes,
+ rssLimitBytes: config.rssLimitBytes
+ };
+ }
+ // The preserve-resident mode turns the calibrated ceiling into a hard
+ // admission boundary. Check it before the transient-build calculation: the
+ // default mode deliberately evaluates a full cache, evicts one idle entry,
+ // and then evaluates the transient budget again.
+ if (config.admissionMode === 'preserve-resident' && residentCount >= config.max) {
+ return {
+ admit: false,
+ reason: 'resident_capacity',
+ pressure,
+ projectedBytes,
+ heapLimitBytes: config.heapLimitBytes,
+ projectedRssBytes,
+ rssLimitBytes: config.rssLimitBytes
+ };
+ }
+ if (config.budgetCapacity === 0 || projectedBytes > config.heapLimitBytes) {
+ return {
+ admit: false,
+ reason: 'insufficient_budget',
+ pressure,
+ projectedBytes,
+ heapLimitBytes: config.heapLimitBytes,
+ projectedRssBytes,
+ rssLimitBytes: config.rssLimitBytes
+ };
+ }
+ if (
+ config.rssLimitBytes != null
+ && projectedRssBytes > config.rssLimitBytes
+ ) {
+ return {
+ admit: false,
+ reason: 'rss_budget_exceeded',
+ pressure,
+ projectedBytes,
+ heapLimitBytes: config.heapLimitBytes,
+ projectedRssBytes,
+ rssLimitBytes: config.rssLimitBytes
+ };
+ }
+ return {
+ admit: true,
+ pressure,
+ projectedBytes,
+ heapLimitBytes: config.heapLimitBytes,
+ projectedRssBytes,
+ rssLimitBytes: config.rssLimitBytes
+ };
+};
+
+export const recordBuildRefusal = (reason: BuildRefusalReason): void => {
+ cacheCounters.buildRefusals[reason]++;
+};
+
+export class CacheBuildAdmissionError extends Error {
+ readonly retryAfterSeconds = 15;
+
+ constructor(readonly reason: BuildRefusalReason) {
+ super(`Graphile build admission refused: ${reason}`);
+ this.name = 'CacheBuildAdmissionError';
+ }
+}
+
+const evictEntry = (
+ key: string,
+ reason: EvictionReason
+): GraphileCacheEntry | undefined => {
+ const entry = graphileCache.peek(key);
+ if (!entry) return undefined;
+ pendingEvictionReasons.set(key, reason);
+ graphileCache.delete(key);
+ return entry;
+};
+
+export const deleteGraphileCacheEntry = async (
+ key: string,
+ reason: EvictionReason = 'manual'
+): Promise => {
+ const entry = evictEntry(key, reason);
+ if (!entry) return false;
+ await (disposalPromises.get(entry) ?? Promise.resolve());
+ return true;
+};
+
+/**
+ * Make one build slot and wait until every evicted instance has truly released.
+ * This runs inside the global build coordinator, so the size check and eviction
+ * cannot race another large build.
+ */
+export const prepareCacheForBuild = async (
+ timeoutMs = 20_000
+): Promise<{ evicted: number; decision: BuildAdmissionDecision }> => {
+ const initial = evaluateBuildAdmission();
+ if (
+ !initial.admit &&
+ (initial.reason === 'critical_pressure'
+ || initial.reason === 'disposal_failed'
+ || initial.reason === 'resident_capacity')
+ ) {
+ recordBuildRefusal(initial.reason);
+ throw new CacheBuildAdmissionError(initial.reason);
+ }
+
+ const startedAt = Date.now();
+ if (!await waitForActiveDisposals(timeoutMs)) {
+ recordBuildRefusal('disposal_timeout');
+ throw new CacheBuildAdmissionError('disposal_timeout');
+ }
+ const targetSize = Math.max(0, getCacheConfig().max - 1);
+ let evicted = 0;
+ while (graphileCache.size > targetSize) {
+ const keys = [...graphileCache.rkeys()];
+ const idleKey = keys.find((key) => {
+ const entry = graphileCache.peek(key);
+ return entry && !entry.disposing && (entry.inflight ?? 0) === 0;
+ });
+ if (!idleKey) {
+ recordBuildRefusal('resident_busy');
+ throw new CacheBuildAdmissionError('resident_busy');
+ }
+ const victimKey = idleKey;
+ const entry = evictEntry(victimKey, 'admission');
+ if (!entry) continue;
+ evicted++;
+
+ const remainingMs = Math.max(1, timeoutMs - (Date.now() - startedAt));
+ let disposed = false;
+ try {
+ disposed = await waitForEntryDisposal(entry, remainingMs);
+ } catch (error) {
+ log.error(`PostGraphile[${victimKey}] disposal failed during build admission`, error);
+ }
+ if (!disposed) {
+ recordBuildRefusal('disposal_timeout');
+ throw new CacheBuildAdmissionError('disposal_timeout');
+ }
+ }
+
+ const decision = evaluateBuildAdmission(graphileCache.size);
+ if (!decision.admit && decision.reason) {
+ recordBuildRefusal(decision.reason);
+ throw new CacheBuildAdmissionError(decision.reason);
+ }
+ return { evicted, decision };
+};
+
+let governorTimer: ReturnType | null = null;
+let governorUsers = 0;
+
+export const startMemoryGovernor = (intervalMs = 10_000): (() => void) => {
+ if (process.env.GRAPHILE_MEMORY_GOVERNOR === '0') return () => {};
+ governorUsers++;
+ if (!governorTimer) {
+ governorTimer = setInterval(() => {
+ const pressure = getMemoryPressure();
+ if (pressure.level === 'ok') return;
+ for (const key of graphileCache.rkeys()) {
+ const entry = graphileCache.peek(key);
+ // A pressure governor must not interrupt a resident request.
+ if (entry && !entry.disposing && (entry.inflight ?? 0) === 0) {
+ log.warn(
+ `Memory governor evicting PostGraphile[${key}] at ${pressure.level} pressure`
+ );
+ evictEntry(key, 'governor');
+ break;
+ }
+ }
+ }, intervalMs);
+ governorTimer.unref?.();
+ }
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ governorUsers = Math.max(0, governorUsers - 1);
+ if (governorUsers === 0 && governorTimer) {
+ clearInterval(governorTimer);
+ governorTimer = null;
+ }
+ };
+};
+
+export const stopMemoryGovernor = (): void => {
+ governorUsers = 0;
+ if (!governorTimer) return;
+ clearInterval(governorTimer);
+ governorTimer = null;
+};
+
// --- Cache Stats ---
export interface CacheStats {
size: number;
max: number;
ttl: number;
+ admissionMode: CacheAdmissionMode;
keys: string[];
+ realtimeUnhealthy: number;
+ realtimeRoleAttestations: {
+ generations: number;
+ identities: number;
+ healthy: number;
+ failed: number;
+ stale: number;
+ activeIdentityAuditAttempts: number;
+ catalogAuditAttempts: number;
+ catalogAuditFailures: number;
+ activeDatabaseTargets: number;
+ databaseConfigurationConflicts: number;
+ oldestLastAttestedAt: number | null;
+ };
+ draining: number;
+ budgetCapacity: number;
+ instanceHeapBytes: number;
+ heapLimitBytes: number;
+ rssLimitBytes: number | null;
+ rssBuildReserveBytes: number;
+ calibration: CacheCalibrationProvenance;
+ pressure: MemoryPressure;
}
/**
@@ -197,11 +1214,30 @@ export interface CacheStats {
*/
export function getCacheStats(): CacheStats {
const config = getCacheConfig();
+ const realtimeRoleAttestationGenerations = [...graphileCache.values()]
+ .filter((entry) => Boolean(entry.realtimeRoleAttestation)).length;
+ const realtimeRoleAuditStats = getGraphileRealtimeRoleAuditStats();
return {
size: graphileCache.size,
max: config.max,
ttl: config.ttl,
- keys: [...graphileCache.keys()]
+ admissionMode: config.admissionMode,
+ keys: [...graphileCache.keys()],
+ realtimeUnhealthy: [...graphileCache.values()].filter(
+ (entry) => entry.realtimeHealth?.status === 'failed'
+ ).length,
+ realtimeRoleAttestations: {
+ generations: realtimeRoleAttestationGenerations,
+ ...realtimeRoleAuditStats
+ },
+ draining: getDrainingCount(),
+ budgetCapacity: config.budgetCapacity,
+ instanceHeapBytes: config.instanceHeapBytes,
+ heapLimitBytes: config.heapLimitBytes,
+ rssLimitBytes: config.rssLimitBytes,
+ rssBuildReserveBytes: config.rssBuildReserveBytes,
+ calibration: config.calibration,
+ pressure: getMemoryPressure()
};
}
@@ -217,8 +1253,7 @@ export function clearMatchingEntries(pattern: RegExp): number {
for (const key of graphileCache.keys()) {
if (pattern.test(key)) {
- // Mark as manual eviction before deleting
- manualEvictionKeys.add(key);
+ pendingEvictionReasons.set(key, 'manual');
graphileCache.delete(key);
cleared++;
}
@@ -227,16 +1262,17 @@ export function clearMatchingEntries(pattern: RegExp): number {
return cleared;
}
-// Register cleanup callback with pgCache
-// When a pg pool is disposed, clean up any graphile instances using it
-const unregister = pgCache.registerCleanupCallback((pgPoolKey: string) => {
+// A retained lease prevents ordinary pg-cache eviction while an entry is
+// resident. This callback remains a fail-safe for legacy unleased entries and
+// explicit process-wide pg-cache shutdown, which is allowed to override leases.
+pgCache.registerCleanupCallback((pgPoolKey: string) => {
log.debug(`pgPool[${pgPoolKey}] disposed - checking graphile entries`);
// Remove graphile entries that reference this pool key
graphileCache.forEach((entry, k) => {
- if (entry.cacheKey.includes(pgPoolKey)) {
+ if (entry.poolIdentity === pgPoolKey) {
log.debug(`Removing graphileCache[${k}] due to pgPool[${pgPoolKey}] disposal`);
- manualEvictionKeys.add(k);
+ pendingEvictionReasons.set(k, 'manual');
graphileCache.delete(k);
}
});
@@ -245,6 +1281,18 @@ const unregister = pgCache.registerCleanupCallback((pgPoolKey: string) => {
// Enhanced close function that handles all caches
const closePromise: { promise: Promise | null } = { promise: null };
+export const clearGraphileCache = async (): Promise => {
+ const entries = [...graphileCache.entries()];
+ for (const [key] of entries) pendingEvictionReasons.set(key, 'manual');
+ graphileCache.clear();
+ const disposePromises = entries.map(([, entry]) => disposalPromises.get(entry));
+ await Promise.allSettled([
+ ...disposePromises.filter((promise): promise is Promise => Boolean(promise)),
+ ...activeDisposals
+ ]);
+ pendingEvictionReasons.clear();
+};
+
/**
* Close all caches and release resources
*
@@ -262,28 +1310,9 @@ export const closeAllCaches = async (verbose = false): Promise => {
closePromise.promise = (async () => {
try {
if (verbose) log.info('Closing all server caches...');
+ stopMemoryGovernor();
- // Collect all entries and dispose them properly
- const entries = [...graphileCache.entries()];
-
- // Mark all as manual evictions
- for (const [key] of entries) {
- manualEvictionKeys.add(key);
- }
-
- const disposePromises = entries.map(([key, entry]) =>
- disposeEntry(entry, key)
- );
-
- // Wait for all disposals to complete
- await Promise.allSettled(disposePromises);
-
- // Clear the cache after disposal (dispose callback will no-op due to disposedKeys)
- graphileCache.clear();
-
- // Clear disposed keys tracking after full cleanup
- disposedKeys.clear();
- manualEvictionKeys.clear();
+ await clearGraphileCache();
// Close pg pools
await pgCache.close();
diff --git a/graphile/graphile-cache/src/http-adapter.ts b/graphile/graphile-cache/src/http-adapter.ts
new file mode 100644
index 0000000000..7b9e53ddc0
--- /dev/null
+++ b/graphile/graphile-cache/src/http-adapter.ts
@@ -0,0 +1,52 @@
+import type { Server as HttpServer } from 'node:http';
+import type { Server as HttpsServer } from 'node:https';
+
+import express, { type Express, type Router } from 'express';
+
+/** The narrow part of ExpressGrafserv used by a cached HTTP-only instance. */
+export interface GrafservExpressAttachment {
+ addTo(
+ app: Express,
+ server: HttpServer | HttpsServer | null,
+ addExclusiveWebsocketHandler?: boolean
+ ): PromiseLike | void;
+}
+
+export interface GraphileHttpAttachmentOptions {
+ /**
+ * The caller will route upgrades to this exact cached instance from the
+ * shared outer HTTP server. Grafserv must never install an exclusive
+ * listener for a tenant instance because that listener would reject every
+ * other tenant's path.
+ */
+ sharedWebsocketRouting?: boolean;
+}
+
+/** Allocate only the middleware router that the shared outer server invokes. */
+export const createGraphileHttpHandler = (): Router => express.Router();
+
+/**
+ * Attach Grafserv's HTTP middleware without a private Node server.
+ *
+ * With exclusive websocket handling disabled, Grafserv's Express adapter only
+ * calls `app.use(...)`; Router implements that exact runtime contract. A cached
+ * per-tenant server never listens, so websocket upgrades must be owned by the
+ * shared outer server rather than retained on an unreachable dummy server.
+ */
+export const attachGraphileHttpHandler = (
+ serv: GrafservExpressAttachment,
+ handler: Router,
+ resolvedPreset: unknown,
+ options: GraphileHttpAttachmentOptions = {}
+): PromiseLike | void => {
+ if (
+ (resolvedPreset as any)?.grafserv?.websockets === true
+ && options.sharedWebsocketRouting !== true
+ ) {
+ throw new Error(
+ '[graphile-cache] Cached Grafserv instances cannot own WebSocket ' +
+ 'upgrades; configure a tenant-aware upgrade handler on the shared server'
+ );
+ }
+ return serv.addTo(handler as unknown as Express, null, false);
+};
diff --git a/graphile/graphile-cache/src/index.ts b/graphile/graphile-cache/src/index.ts
index 9a845fafe3..2f38bf4abf 100644
--- a/graphile/graphile-cache/src/index.ts
+++ b/graphile/graphile-cache/src/index.ts
@@ -1,29 +1,96 @@
// Main exports from graphile-cache package
export {
+ BuildAdmissionDecision,
+ BuildRefusalReason,
+ CacheAdmissionMode,
+ CacheBuildAdmissionError,
+ CacheCalibrationProvenance,
+ CacheCalibrationSource,
// Cache configuration
CacheConfig,
+ // Process counters
+ CacheCounters,
// Event emitter for cache events
CacheEventEmitter,
cacheEvents,
CacheEvictionEvent,
// Cache stats
CacheStats,
+ clearGraphileCache,
// Clear matching entries
clearMatchingEntries,
closeAllCaches,
+ // Capacity model and measured instance cost
+ computeBackingCacheMax,
+ computeCapacityFromBudget,
+ deleteGraphileCacheEntry,
+ disposeUncachedEntry,
+ evaluateBuildAdmission,
// Eviction tracking
EvictionReason,
FIVE_MINUTES_MS,
getCacheConfig,
+ getCacheCounters,
getCacheStats,
+ getDrainingCount,
+ getInstanceHeapEstimate,
+ // Memory pressure governor
+ getMemoryPressure,
+ GRAPHILE_WEBSOCKET_UNAVAILABLE_CODE,
// Cache instance and entry type
graphileCache,
GraphileCacheEntry,
+ GraphileUpgradeHandler,
+ // Request draining and build admission
+ invokeEntryHandler,
+ invokeEntryUpgradeHandler,
+ isEntryRealtimeUnavailable,
+ MemoryPressure,
+ MemoryPressureLevel,
// Time constants
- ONE_HOUR_MS} from './graphile-cache';
+ ONE_HOUR_MS,
+ prepareCacheForBuild,
+ raceWithClearedTimeout,
+ recordBuildRefusal,
+ recordInstanceHeapSample,
+ resetInstanceHeapSamples,
+ retireGraphileCacheEntry,
+ revalidateEntryRealtimeRole,
+ startMemoryGovernor,
+ stopMemoryGovernor,
+ waitForActiveDisposals,
+ waitForEntryDisposal
+} from './graphile-cache';
// Factory for creating PostGraphile v5 instances
+export type { GraphileInstanceOptions } from './create-instance';
export { createGraphileInstance } from './create-instance';
+export type {
+ GraphileRealtimeHealth,
+ GraphileRealtimeManager
+} from './realtime-readiness';
+export {
+ createGraphileRealtimeHealth,
+ createGraphileRealtimeNodeId,
+ DEFAULT_GRAPHILE_REALTIME_SCHEMA,
+ GRAPHILE_REALTIME_UNAVAILABLE_CODE,
+ GraphileRealtimeStartupError,
+ startConfiguredRealtime,
+ withGraphileRealtimeFailure
+} from './realtime-readiness';
+export type {
+ ActivateGraphileSharedRealtimeOptions,
+ GraphileRealtimeRoleAttestation,
+ GraphileRealtimeRoleAttestationSnapshot,
+ GraphileRealtimeRoleAuditStats} from './shared-realtime';
+export {
+ activateGraphileSharedRealtime,
+ getGraphileRealtimeRoleAuditStats,
+ GRAPHILE_SHARED_REALTIME_DATABASE_CONFLICT_ERROR_CODE,
+ GRAPHILE_SHARED_REALTIME_IDENTITY_ERROR_CODE,
+ GraphileSharedRealtimeDatabaseConflictError,
+ GraphileSharedRealtimeIdentityError
+} from './shared-realtime';
// Generic module config cache for plugin lookups
export { ModuleConfigCache, ModuleConfigCacheOptions } from './module-config-cache';
diff --git a/graphile/graphile-cache/src/preset-services.ts b/graphile/graphile-cache/src/preset-services.ts
new file mode 100644
index 0000000000..c2210828b3
--- /dev/null
+++ b/graphile/graphile-cache/src/preset-services.ts
@@ -0,0 +1,33 @@
+interface ReleasablePresetService {
+ release?: () => void | Promise;
+}
+
+/**
+ * Own the pgServices created for one resolved PostGraphile preset.
+ *
+ * PostGraphile 5.0.3 releases Grafserv but does not release pgServices. Cached
+ * generations therefore have to do this explicitly or an evicted
+ * PgSubscriber can retain its LISTEN checkout in the next generation's pool.
+ */
+export const createPresetServicesReleaser = (
+ resolvedPreset: { pgServices?: readonly ReleasablePresetService[] }
+): (() => Promise) => {
+ const services = [...new Set(resolvedPreset.pgServices ?? [])];
+ let releasePromise: Promise | null = null;
+
+ return (): Promise => {
+ if (releasePromise) return releasePromise;
+ releasePromise = (async () => {
+ let firstError: unknown;
+ for (const service of [...services].reverse()) {
+ try {
+ await service.release?.();
+ } catch (error) {
+ firstError ??= error;
+ }
+ }
+ if (firstError) throw firstError;
+ })();
+ return releasePromise;
+ };
+};
diff --git a/graphile/graphile-cache/src/realtime-readiness.ts b/graphile/graphile-cache/src/realtime-readiness.ts
new file mode 100644
index 0000000000..81294d3716
--- /dev/null
+++ b/graphile/graphile-cache/src/realtime-readiness.ts
@@ -0,0 +1,186 @@
+import { randomUUID } from 'node:crypto';
+
+import { Logger } from '@pgpmjs/logger';
+import type { RealtimePublisher } from 'graphile-realtime-subscriptions';
+
+const log = new Logger('graphile-cache:realtime');
+
+export const DEFAULT_GRAPHILE_REALTIME_SCHEMA = 'realtime_public';
+export const GRAPHILE_REALTIME_UNAVAILABLE_CODE = 'GRAPHILE_REALTIME_UNAVAILABLE';
+
+// One module instance represents one Node.js process/worker runtime. A random
+// component prevents two replicas serving the same exact build contract from
+// sharing a database cursor identity and cleaning up each other's state.
+const GRAPHILE_REALTIME_PROCESS_ID = `${process.pid}-${randomUUID()}`;
+
+export const createGraphileRealtimeNodeId = (
+ cacheKey: string,
+ replicaIdentity = GRAPHILE_REALTIME_PROCESS_ID
+): string => `graphile-cache:${replicaIdentity}:${cacheKey}`;
+
+export type GraphileRealtimeHealth =
+ | { readonly status: 'healthy' }
+ | {
+ readonly status: 'failed';
+ readonly failureCode: string | null;
+ readonly failedAt: number;
+ };
+
+export const createGraphileRealtimeHealth = (): GraphileRealtimeHealth => ({
+ status: 'healthy'
+});
+
+const errorCode = (error: unknown): string | null => {
+ if (!error || typeof error !== 'object') return null;
+ const code = (error as { code?: unknown }).code;
+ return typeof code === 'string' && code.length > 0 ? code : null;
+};
+
+/** Return the first fatal delivery state; a failed generation stays failed. */
+export const withGraphileRealtimeFailure = (
+ health: GraphileRealtimeHealth,
+ error: unknown,
+ failedAt = Date.now()
+): GraphileRealtimeHealth => health.status === 'failed'
+ ? health
+ : {
+ status: 'failed',
+ failureCode: errorCode(error),
+ failedAt
+ };
+
+export class GraphileRealtimeStartupError extends Error {
+ readonly code = 'GRAPHILE_REALTIME_STARTUP_FAILED';
+
+ constructor(cacheKey: string, readonly cause?: unknown) {
+ super(`PostGraphile[${cacheKey}] realtime was configured but could not start`);
+ this.name = 'GraphileRealtimeStartupError';
+ }
+}
+
+export interface GraphileRealtimeManager {
+ start(): Promise;
+ stop(): Promise;
+}
+
+export interface GraphileRealtimeManagerConstructor {
+ new(options: {
+ pgSubscriber?: any;
+ publisher?: RealtimePublisher;
+ pool: any;
+ nodeId: string;
+ schema: string;
+ allowedSourceSchemas: readonly string[];
+ pollIntervalMs?: number;
+ heartbeatIntervalMs?: number;
+ onFatalError?: (error: Error) => void;
+ }): GraphileRealtimeManager;
+}
+
+export interface StartConfiguredRealtimeOptions {
+ cacheKey: string;
+ resolvedPreset: unknown;
+ /**
+ * Physical schema containing the cursor functions for this exact runtime
+ * identity. Omit to preserve the historical `realtime_public` behavior.
+ */
+ realtimeSchema?: string;
+ /** Exact physical schemas exposed by this Graphile instance. */
+ allowedSourceSchemas: readonly string[];
+ /** Explicit generation-local publisher used by shared-exact mode. */
+ publisher?: RealtimePublisher;
+ /** Cursor recovery polling interval. */
+ pollIntervalMs?: number;
+ /** Cursor listener heartbeat interval. */
+ heartbeatIntervalMs?: number;
+ /** Synchronous fatal-delivery callback used to remove the owner from service. */
+ onFatalError?: (error: Error) => void;
+ releasePostGraphile(): PromiseLike | void;
+ loadManager?: () => Promise;
+ /** @internal Deterministic injection for replica-identity tests. */
+ replicaIdentity?: string;
+}
+
+const defaultLoadManager = async (): Promise => {
+ const { RealtimeManager } = await import('graphile-realtime-subscriptions');
+ return RealtimeManager;
+};
+
+/**
+ * Realtime is part of readiness when configured. Any missing dependency or
+ * startup failure releases the PostGraphile generation before rejecting.
+ */
+export const startConfiguredRealtime = async (
+ options: StartConfiguredRealtimeOptions
+): Promise => {
+ const {
+ cacheKey,
+ resolvedPreset,
+ realtimeSchema = DEFAULT_GRAPHILE_REALTIME_SCHEMA,
+ allowedSourceSchemas,
+ publisher,
+ pollIntervalMs,
+ heartbeatIntervalMs,
+ onFatalError,
+ releasePostGraphile,
+ loadManager = defaultLoadManager,
+ replicaIdentity
+ } = options;
+ let manager: GraphileRealtimeManager | undefined;
+ try {
+ const pgService = (resolvedPreset as any)?.pgServices?.[0];
+ const pgSubscriber = pgService?.pgSubscriber ?? null;
+ const pool = pgService?.adaptorSettings?.pool ?? null;
+ if (!publisher && !pgSubscriber) {
+ throw new Error(`PostGraphile[${cacheKey}] resolved without a pgSubscriber`);
+ }
+ if (!pool) {
+ throw new Error(`PostGraphile[${cacheKey}] resolved without a runtime pool`);
+ }
+ const exactSourceSchemas = [...new Set(allowedSourceSchemas ?? [])];
+ if (
+ exactSourceSchemas.length === 0
+ || exactSourceSchemas.some(
+ (schema) => typeof schema !== 'string' || schema.length === 0
+ )
+ ) {
+ throw new Error(
+ `PostGraphile[${cacheKey}] realtime requires at least one allowed source schema`
+ );
+ }
+
+ const RealtimeManager = await loadManager();
+ manager = new RealtimeManager({
+ ...(publisher ? { publisher } : { pgSubscriber }),
+ pool,
+ nodeId: createGraphileRealtimeNodeId(cacheKey, replicaIdentity),
+ schema: realtimeSchema,
+ allowedSourceSchemas: exactSourceSchemas,
+ ...(pollIntervalMs === undefined ? {} : { pollIntervalMs }),
+ ...(heartbeatIntervalMs === undefined ? {} : { heartbeatIntervalMs }),
+ ...(onFatalError ? { onFatalError } : {})
+ });
+ await manager.start();
+ return manager;
+ } catch (error) {
+ if (manager) {
+ try {
+ await manager.stop();
+ } catch (stopError) {
+ log.error(
+ `Failed to stop partially started RealtimeManager for PostGraphile[${cacheKey}]:`,
+ stopError
+ );
+ }
+ }
+ try {
+ await releasePostGraphile();
+ } catch (releaseError) {
+ log.error(
+ `Failed to release PostGraphile[${cacheKey}] after realtime startup failure:`,
+ releaseError
+ );
+ }
+ throw new GraphileRealtimeStartupError(cacheKey, error);
+ }
+};
diff --git a/graphile/graphile-cache/src/shared-realtime.ts b/graphile/graphile-cache/src/shared-realtime.ts
new file mode 100644
index 0000000000..51b3bdd788
--- /dev/null
+++ b/graphile/graphile-cache/src/shared-realtime.ts
@@ -0,0 +1,490 @@
+import {
+ ActivatableGenerationScopedRealtimeSubscriber,
+ type RealtimeTopicCollector
+} from 'graphile-realtime-subscriptions';
+import {
+ acquirePgNotificationBroker,
+ getPgNotificationBrokerIdentity,
+ getPgNotificationBrokerStats,
+ getPgNotificationDatabaseIdentity,
+ PG_NOTIFICATION_LEASE_RELEASED_ERROR_CODE,
+ type PgAttestedNotificationBrokerLease,
+ type PgNotificationListenerConfig,
+ type PgNotificationRoleAudit
+} from 'pg-cache';
+
+export const GRAPHILE_SHARED_REALTIME_IDENTITY_ERROR_CODE =
+ 'GRAPHILE_SHARED_REALTIME_IDENTITY_MISMATCH';
+export const GRAPHILE_SHARED_REALTIME_DATABASE_CONFLICT_ERROR_CODE =
+ 'GRAPHILE_SHARED_REALTIME_DATABASE_CONFLICT';
+
+export class GraphileSharedRealtimeIdentityError extends Error {
+ readonly code = GRAPHILE_SHARED_REALTIME_IDENTITY_ERROR_CODE;
+
+ constructor() {
+ super('Shared realtime listener identity does not match its connection contract');
+ this.name = 'GraphileSharedRealtimeIdentityError';
+ }
+}
+
+export class GraphileSharedRealtimeDatabaseConflictError extends Error {
+ readonly code = GRAPHILE_SHARED_REALTIME_DATABASE_CONFLICT_ERROR_CODE;
+
+ constructor(database: string) {
+ super(
+ `Physical database ${JSON.stringify(database)} already has a different active `
+ + 'shared realtime listener contract'
+ );
+ this.name = 'GraphileSharedRealtimeDatabaseConflictError';
+ }
+}
+
+export interface GraphileRealtimeRoleAttestationSnapshot {
+ readonly version: 1;
+ readonly mode: 'shared-exact';
+ readonly listenerIdentity: string;
+ readonly auditVersion: string;
+ readonly role: string;
+ readonly database: string;
+ readonly lastAttestedAt: number;
+ readonly validUntil: number;
+ readonly checks: number;
+ readonly status: 'healthy' | 'failed';
+ readonly failureCode: string | null;
+ readonly failedAt: number | null;
+}
+
+export interface GraphileRealtimeRoleAttestation {
+ snapshot(): Readonly;
+ /** Re-audit once this generation's explicit validity window has elapsed. */
+ revalidateIfDue(): Promise;
+ release(): void;
+}
+
+interface SharedAttestationRecord {
+ readonly identity: string;
+ readonly role: string;
+ readonly database: string;
+ audit: PgNotificationRoleAudit;
+ lastAttestedAt: number;
+ revalidationMs: number;
+ checks: number;
+ refreshPromise: Promise | null;
+ refreshTimer: ReturnType | null;
+ failure: { code: string | null; failedAt: number } | null;
+ bindings: Set;
+}
+
+interface SharedAttestationBinding {
+ readonly revalidationMs: number;
+ readonly onFailure: (error: Error) => void;
+ readonly revalidateRole: () => Promise;
+}
+
+interface ActiveDatabaseListenerContract {
+ readonly listenerIdentity: string;
+ readonly role: string;
+ references: number;
+}
+
+const attestationRecords = new Map();
+const activeDatabaseListenerContracts = new Map<
+string,
+ActiveDatabaseListenerContract
+>();
+let databaseConfigurationConflicts = 0;
+
+export interface GraphileRealtimeRoleAuditStats {
+ readonly identities: number;
+ readonly healthy: number;
+ readonly failed: number;
+ readonly stale: number;
+ readonly activeIdentityAuditAttempts: number;
+ readonly catalogAuditAttempts: number;
+ readonly catalogAuditFailures: number;
+ readonly activeDatabaseTargets: number;
+ readonly databaseConfigurationConflicts: number;
+ readonly oldestLastAttestedAt: number | null;
+}
+
+/** Process-level unique identity counts plus monotonic catalog-audit counters. */
+export const getGraphileRealtimeRoleAuditStats = (
+ now = Date.now()
+): Readonly => {
+ const records = [...attestationRecords.values()];
+ const brokerStats = getPgNotificationBrokerStats();
+ return Object.freeze({
+ identities: records.length,
+ healthy: records.filter(({ failure }) => !failure).length,
+ failed: records.filter(({ failure }) => Boolean(failure)).length,
+ stale: records.filter(
+ ({ lastAttestedAt, revalidationMs }) => now >= lastAttestedAt + revalidationMs
+ ).length,
+ activeIdentityAuditAttempts: records.reduce(
+ (sum, { checks }) => sum + checks,
+ 0
+ ),
+ catalogAuditAttempts: brokerStats.roleAuditAttempts,
+ catalogAuditFailures: brokerStats.roleAuditFailures,
+ activeDatabaseTargets: activeDatabaseListenerContracts.size,
+ databaseConfigurationConflicts,
+ oldestLastAttestedAt: records.length === 0
+ ? null
+ : Math.min(...records.map(({ lastAttestedAt }) => lastAttestedAt))
+ });
+};
+
+const errorCode = (error: unknown): string | null => {
+ if (!error || typeof error !== 'object') return null;
+ const code = (error as { code?: unknown }).code;
+ return typeof code === 'string' && code.length > 0 ? code : null;
+};
+
+const reserveDatabaseListenerContract = (options: {
+ databaseIdentity: string;
+ listenerIdentity: string;
+ role: string;
+ database: string;
+}): (() => void) => {
+ const { databaseIdentity, listenerIdentity, role, database } = options;
+ let record = activeDatabaseListenerContracts.get(databaseIdentity);
+ if (
+ record
+ && (record.listenerIdentity !== listenerIdentity || record.role !== role)
+ ) {
+ databaseConfigurationConflicts++;
+ throw new GraphileSharedRealtimeDatabaseConflictError(database);
+ }
+ if (record) {
+ record.references++;
+ } else {
+ record = { listenerIdentity, role, references: 1 };
+ activeDatabaseListenerContracts.set(databaseIdentity, record);
+ }
+ let released = false;
+ return (): void => {
+ if (released) return;
+ released = true;
+ record!.references--;
+ if (
+ record!.references === 0
+ && activeDatabaseListenerContracts.get(databaseIdentity) === record
+ ) {
+ activeDatabaseListenerContracts.delete(databaseIdentity);
+ }
+ };
+};
+
+const withDatabaseContractReservation = (
+ source: PgAttestedNotificationBrokerLease,
+ releaseReservation: () => void
+): PgAttestedNotificationBrokerLease => {
+ let releasePromise: Promise | null = null;
+ return Object.freeze({
+ identity: source.identity,
+ topics: source.topics,
+ terminated: source.terminated,
+ get roleAudit(): PgNotificationRoleAudit {
+ return source.roleAudit;
+ },
+ revalidateRole(): Promise {
+ return source.revalidateRole();
+ },
+ subscribe(topic: string): AsyncIterableIterator {
+ return source.subscribe(topic);
+ },
+ release(): Promise {
+ if (releasePromise) return releasePromise;
+ releasePromise = (async () => {
+ try {
+ await source.release();
+ } finally {
+ releaseReservation();
+ }
+ })();
+ return releasePromise;
+ }
+ });
+};
+
+const MAX_TIMER_DELAY_MS = 2_147_483_647;
+
+const clearRefreshTimer = (record: SharedAttestationRecord): void => {
+ if (!record.refreshTimer) return;
+ clearTimeout(record.refreshTimer);
+ record.refreshTimer = null;
+};
+
+function scheduleRefresh(record: SharedAttestationRecord): void {
+ clearRefreshTimer(record);
+ if (record.failure || record.bindings.size === 0) return;
+ const dueAt = record.lastAttestedAt + record.revalidationMs;
+ const delay = Math.max(
+ 0,
+ Math.min(MAX_TIMER_DELAY_MS, dueAt - Date.now())
+ );
+ record.refreshTimer = setTimeout(() => {
+ record.refreshTimer = null;
+ if (record.failure || record.bindings.size === 0) return;
+ // Very large TTLs are scheduled in safe setTimeout-sized chunks.
+ if (Date.now() < record.lastAttestedAt + record.revalidationMs) {
+ scheduleRefresh(record);
+ return;
+ }
+ void refreshRecord(record);
+ }, delay);
+ record.refreshTimer.unref?.();
+}
+
+const revalidateWithActiveBinding = async (
+ record: SharedAttestationRecord
+): Promise => {
+ const attempted = new Set();
+ for (;;) {
+ const binding = [...record.bindings].find((candidate) => !attempted.has(candidate));
+ if (!binding) {
+ throw new Error('Shared realtime role attestation has no active broker lease');
+ }
+ attempted.add(binding);
+ try {
+ return await binding.revalidateRole();
+ } catch (error) {
+ if (
+ errorCode(error) === PG_NOTIFICATION_LEASE_RELEASED_ERROR_CODE
+ && !record.bindings.has(binding)
+ ) {
+ continue;
+ }
+ throw error;
+ }
+ }
+};
+
+function refreshRecord(record: SharedAttestationRecord): Promise {
+ if (record.failure) return Promise.resolve(false);
+ if (record.refreshPromise) return record.refreshPromise;
+ record.checks++;
+ const pending = (async (): Promise => {
+ try {
+ const audit = await revalidateWithActiveBinding(record);
+ record.audit = audit;
+ record.lastAttestedAt = Date.now();
+ return true;
+ } catch (reason) {
+ const error = reason instanceof Error ? reason : new Error(String(reason));
+ record.failure = {
+ code: errorCode(error),
+ failedAt: Date.now()
+ };
+ clearRefreshTimer(record);
+ for (const binding of [...record.bindings]) {
+ try {
+ binding.onFailure(error);
+ } catch {
+ // Every observer is advisory; the failed record remains latched.
+ }
+ }
+ return false;
+ }
+ })();
+ record.refreshPromise = pending;
+ void pending.then(() => {
+ if (record.refreshPromise === pending) record.refreshPromise = null;
+ if (record.bindings.size === 0) {
+ clearRefreshTimer(record);
+ if (attestationRecords.get(record.identity) === record) {
+ attestationRecords.delete(record.identity);
+ }
+ } else if (!record.failure) {
+ scheduleRefresh(record);
+ }
+ });
+ return pending;
+}
+
+const registerAttestation = (options: {
+ identity: string;
+ audit: PgNotificationRoleAudit;
+ attestedAt: number;
+ revalidationMs: number;
+ onFailure(error: Error): void;
+ revalidateRole(): Promise;
+}): GraphileRealtimeRoleAttestation => {
+ const {
+ identity,
+ audit,
+ attestedAt,
+ revalidationMs,
+ onFailure,
+ revalidateRole
+ } = options;
+ let record = attestationRecords.get(identity);
+ if (!record) {
+ record = {
+ identity,
+ role: audit.role,
+ database: audit.database,
+ audit,
+ lastAttestedAt: attestedAt,
+ revalidationMs,
+ checks: 1,
+ refreshPromise: null,
+ refreshTimer: null,
+ failure: null,
+ bindings: new Set()
+ };
+ attestationRecords.set(identity, record);
+ } else {
+ // Broker identity covers credentials, database, pool, TLS, and driver.
+ // A freshly successful acquisition audit supersedes older provenance.
+ record.audit = audit;
+ record.lastAttestedAt = attestedAt;
+ record.checks++;
+ record.failure = null;
+ }
+ const binding: SharedAttestationBinding = {
+ revalidationMs,
+ onFailure,
+ revalidateRole
+ };
+ record.bindings.add(binding);
+ record.revalidationMs = Math.min(
+ ...[...record.bindings].map((active) => active.revalidationMs)
+ );
+ scheduleRefresh(record);
+ let released = false;
+
+ return Object.freeze({
+ snapshot(): Readonly {
+ const failure = record!.failure;
+ return Object.freeze({
+ version: 1,
+ mode: 'shared-exact',
+ listenerIdentity: identity,
+ auditVersion: record!.audit.version,
+ role: record!.role,
+ database: record!.database,
+ lastAttestedAt: record!.lastAttestedAt,
+ validUntil: record!.lastAttestedAt + revalidationMs,
+ checks: record!.checks,
+ status: failure ? 'failed' : 'healthy',
+ failureCode: failure?.code ?? null,
+ failedAt: failure?.failedAt ?? null
+ });
+ },
+ async revalidateIfDue(): Promise {
+ if (released || record!.failure) return false;
+ if (Date.now() < record!.lastAttestedAt + revalidationMs) return true;
+ return refreshRecord(record!);
+ },
+ release(): void {
+ if (released) return;
+ released = true;
+ record!.bindings.delete(binding);
+ if (record!.bindings.size === 0) {
+ clearRefreshTimer(record!);
+ if (!record!.refreshPromise) attestationRecords.delete(identity);
+ } else {
+ record!.revalidationMs = Math.min(
+ ...[...record!.bindings].map((active) => active.revalidationMs)
+ );
+ scheduleRefresh(record!);
+ }
+ }
+ });
+};
+
+export interface ActivateGraphileSharedRealtimeOptions {
+ subscriber: ActivatableGenerationScopedRealtimeSubscriber;
+ topicCollector: RealtimeTopicCollector;
+ listenerPgConfig: PgNotificationListenerConfig;
+ listenerIdentity: string;
+ allowedSourceSchemas: readonly string[];
+ roleRevalidationMs: number;
+ onFatalError(error: Error): void;
+}
+
+/**
+ * Cross the shared-listener publication boundary. Topic validation and a fresh
+ * role audit finish before the broker lease is installed into PostGraphile.
+ */
+export const activateGraphileSharedRealtime = async (
+ options: ActivateGraphileSharedRealtimeOptions
+): Promise => {
+ const {
+ subscriber,
+ topicCollector,
+ listenerPgConfig,
+ listenerIdentity,
+ allowedSourceSchemas,
+ roleRevalidationMs,
+ onFatalError
+ } = options;
+ const expectedIdentity = getPgNotificationBrokerIdentity(listenerPgConfig);
+ if (expectedIdentity !== listenerIdentity) {
+ throw new GraphileSharedRealtimeIdentityError();
+ }
+ if (!Number.isSafeInteger(roleRevalidationMs) || roleRevalidationMs <= 0) {
+ throw new Error('Shared realtime role revalidation interval must be positive');
+ }
+ const topics = topicCollector.exactTopics(allowedSourceSchemas);
+ const role = listenerPgConfig.user;
+ const database = listenerPgConfig.database;
+ const databaseIdentity = getPgNotificationDatabaseIdentity(listenerPgConfig);
+ const releaseDatabaseReservation = reserveDatabaseListenerContract({
+ databaseIdentity,
+ listenerIdentity,
+ role,
+ database
+ });
+
+ // This audit is intentionally fresh for every generation acquisition. The
+ // role may have drifted since an older generation joined the same broker.
+ let brokerLease: Awaited>;
+ try {
+ // Broker admission serializes this generation's fresh role audit and LISTEN
+ // on the same pinned client, which remains safe with pool max=1.
+ brokerLease = await acquirePgNotificationBroker(listenerPgConfig, { topics });
+ } catch (error) {
+ releaseDatabaseReservation();
+ throw error;
+ }
+
+ const reservedBrokerLease = withDatabaseContractReservation(
+ brokerLease,
+ releaseDatabaseReservation
+ );
+
+ const reportBrokerTermination = (failure: Error): void => {
+ try {
+ onFatalError(failure);
+ } catch {
+ // The subscriber still fails all streams even if an observer throws.
+ }
+ };
+ void reservedBrokerLease.terminated.then((failure) => {
+ if (failure) reportBrokerTermination(failure);
+ });
+
+ try {
+ await subscriber.activate({
+ source: reservedBrokerLease,
+ allowedTopics: topics
+ });
+ } catch (error) {
+ try {
+ await reservedBrokerLease.release();
+ } catch {
+ // Preserve the activation failure; reservation release runs in finally.
+ }
+ throw error;
+ }
+ return registerAttestation({
+ identity: listenerIdentity,
+ audit: reservedBrokerLease.roleAudit,
+ attestedAt: Date.now(),
+ revalidationMs: roleRevalidationMs,
+ onFailure: reportBrokerTermination,
+ revalidateRole: () => reservedBrokerLease.revalidateRole()
+ });
+};
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 81447e3a9e..95c5cd72d9 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