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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ jobs:
- batch: pg-core
packages: 'pgpm/ast pgpm/traverse pgpm/bundle pgpm/core pgpm/cli pgpm/portability pgpm/export packages/client packages/safegres postgres/pg-codegen postgres/query-builder'
- batch: pg-postgres
packages: 'postgres/pgsql-test postgres/drizzle-orm-test postgres/introspectron graphile/graphile-test graphile/graphile-connection-filter graphile/graphile-postgis'
packages: 'postgres/pgsql-test postgres/drizzle-orm-test postgres/introspectron graphile/graphile-test graphile/graphile-scoped-introspection graphile/graphile-connection-filter graphile/graphile-postgis'
- batch: pg-graphql
packages: 'graphile/graphile-search graphile/graphile-ltree graphile/graphile-bulk-mutations graphile/graphile-function-bindings graphile/graphile-history graphile/graphile-meta graphile/graphile-schema graphql/orm-test graphql/test graphql/playwright-test'
- batch: pg-graphile-extras
Expand Down
31 changes: 31 additions & 0 deletions graphile/graphile-scoped-introspection/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# graphile-scoped-introspection

An opt-in Graphile plugin that scopes PostgreSQL catalog introspection to the
configured service schemas and their required dependency closure.

```ts
import { ScopedIntrospectionPreset } from 'graphile-scoped-introspection';

const preset = {
extends: [ScopedIntrospectionPreset],
pgServices: [
{
// standard Graphile PgService fields
introspectionMode: 'scoped-required',
introspectionScopedCatalogTypes: 'dependency-closure',
},
],
};
```

The package atomically replaces `PgIntrospectionPlugin` only when its preset
is installed. Stock-only services delegate to the upstream helper unchanged;
mixed stock/scoped services select their catalog query independently.

The scoped SQL is CNC-owned and parameterized. It is adapted from the MIT
licensed `[email protected]` query and does not patch, import private
subpaths from, or rewrite the installed upstream package.

Database clients use the normal `@dataplan/pg` checkout lifecycle and return
to the pool after each query. Applications remain responsible for calling
`PgService.release()` during final shutdown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { watchGather } from 'graphile-build';
import type { GraphileConfig } from 'graphile-config';

import { ConstructivePgIntrospectionPlugin } from '../src';

const SCHEMA = 'tenant_a';

const introspectionText = JSON.stringify({
database: { datdba: '10', datacl: null },
namespaces: [
{
_id: '2200',
oid: '2200',
nspname: SCHEMA,
nspowner: '10',
nspacl: null,
},
],
classes: [],
attributes: [],
constraints: [],
procs: [],
roles: [
{
_id: '10',
oid: '10',
rolname: 'postgres',
rolsuper: true,
rolinherit: true,
rolcreaterole: true,
rolcreatedb: true,
rolcanlogin: true,
rolreplication: true,
rolconnlimit: -1,
rolpassword: null,
rolvaliduntil: null,
rolbypassrls: true,
rolconfig: null,
},
],
auth_members: [],
types: [],
enums: [],
extensions: [],
indexes: [],
languages: [],
ranges: [],
depends: [],
descriptions: [],
inherits: [],
am: [],
catalog_by_oid: {
2615: 'pg_namespace',
1259: 'pg_class',
1255: 'pg_proc',
1247: 'pg_type',
2606: 'pg_constraint',
3079: 'pg_extension',
},
current_user: 'postgres',
server_version_num: 180004,
});
const missingSchemaIntrospectionText = JSON.stringify({
...JSON.parse(introspectionText),
namespaces: [],
});
const unapprovedSchemaIntrospectionText = JSON.stringify({
...JSON.parse(introspectionText),
namespaces: [
...JSON.parse(introspectionText).namespaces,
{
_id: '2201',
oid: '2201',
nspname: 'unexpected_dependency',
nspowner: '10',
nspacl: null,
},
],
});

interface GatherResult {
input: Record<string, unknown> | null;
error?: Error;
}

function makeResultQueue() {
const queued: GatherResult[] = [];
const waiters: Array<(result: GatherResult) => void> = [];

return {
push(result: GatherResult) {
const waiter = waiters.shift();
if (waiter) waiter(result);
else queued.push(result);
},
next(): Promise<GatherResult> {
const result = queued.shift();
if (result) return Promise.resolve(result);
return new Promise((resolve) => waiters.push(resolve));
},
};
}

describe('scoped introspection raw-text lifecycle', () => {
it('releases raw text, re-queries fresh data, and fails closed on regather errors', async () => {
let cache: { introspectionResultsPromise: Promise<unknown> | null } | null =
null;
let triggerRegather: (() => void) | null = null;
let queryError: Error | null = null;
let nextIntrospectionText = introspectionText;
const seenNamespaceNames: string[] = [];
const query = jest.fn(async () => {
if (queryError) {
const error = queryError;
queryError = null;
throw error;
}
return { rows: [{ introspection: nextIntrospectionText }] };
});
const withPgClient = Object.assign(
async (
_settings: Record<string, string> | null,
callback: (client: { query: typeof query }) => unknown
) => callback({ query }),
{ release: jest.fn() }
);
const adaptor = {
createWithPgClient: jest.fn(async () => withPgClient),
};

const originalGather = ConstructivePgIntrospectionPlugin.gather!;
const capturingIntrospectionPlugin = {
...ConstructivePgIntrospectionPlugin,
gather: {
...originalGather,
initialCache(info: never) {
cache = originalGather.initialCache!(info) as typeof cache;
return cache;
},
// A deterministic test trigger drives the same persistent gather cache
// without needing a live LISTEN/NOTIFY subscriber.
watch: undefined,
},
} as unknown as GraphileConfig.Plugin;
const observerPlugin = {
name: 'ScopedIntrospectionCacheObserverPlugin',
gather: {
namespace: 'scopedIntrospectionCacheObserver',
async main(output: Record<string, unknown>, info: any) {
const first = info.helpers.pgIntrospection.getIntrospection();
const second = info.helpers.pgIntrospection.getIntrospection();
expect(second).toBe(first);
const [firstResults, secondResults] = await Promise.all([
first,
second,
]);
expect(secondResults).toBe(firstResults);
const [result] = firstResults;
const namespace = result.introspection.namespaces[0];
seenNamespaceNames.push(namespace.nspname);
output.namespaceName = namespace.nspname;
// Graphile plugins may mutate their gather-local parsed graph. A later
// gather must never observe this mutation.
namespace.nspname = 'mutated_by_plugin';
},
watch(_info: never, callback: () => void) {
triggerRegather = callback;
return (): void => undefined;
},
},
} as unknown as GraphileConfig.Plugin;
const pgService = {
name: 'main',
schemas: [SCHEMA],
introspectionMode: 'scoped-required',
introspectionAllowedDependencySchemas: [] as readonly string[],
adaptor,
adaptorSettings: {},
withPgClientKey: 'withPgClient',
pgSettingsKey: 'pgSettings',
};
const results = makeResultQueue();

const stopWatching = await watchGather(
{
plugins: [capturingIntrospectionPlugin, observerPlugin],
pgServices: [pgService as never],
},
undefined,
(input, error) => {
results.push({
input: input as unknown as Record<string, unknown> | null,
error: error as Error | undefined,
});
}
);

try {
const first = await results.next();
expect(first.error).toBeUndefined();
expect(first.input).toMatchObject({ namespaceName: SCHEMA });
expect(query).toHaveBeenCalledTimes(1);
expect(cache!.introspectionResultsPromise).toBeNull();

triggerRegather!();
const second = await results.next();
expect(second.error).toBeUndefined();
expect(second.input).toMatchObject({ namespaceName: SCHEMA });
expect(query).toHaveBeenCalledTimes(2);
expect(seenNamespaceNames).toEqual([SCHEMA, SCHEMA]);
expect(cache!.introspectionResultsPromise).toBeNull();

nextIntrospectionText = unapprovedSchemaIntrospectionText;
triggerRegather!();
const invalid = await results.next();
expect(invalid.input).toBeNull();
expect(invalid.error?.message).toContain(
'crossed into unapproved dependency schema(s): unexpected_dependency'
);
expect(query).toHaveBeenCalledTimes(3);
expect(cache!.introspectionResultsPromise).toBeNull();

nextIntrospectionText = missingSchemaIntrospectionText;
triggerRegather!();
const missing = await results.next();
expect(missing.input).toBeNull();
expect(missing.error?.message).toContain(
`did not find required schema(s): ${SCHEMA}`
);
expect(query).toHaveBeenCalledTimes(4);
expect(cache!.introspectionResultsPromise).toBeNull();

nextIntrospectionText = introspectionText;
triggerRegather!();
const recovered = await results.next();
expect(recovered.error).toBeUndefined();
expect(recovered.input).toMatchObject({ namespaceName: SCHEMA });
expect(query).toHaveBeenCalledTimes(5);

const marker = new Error('scoped introspection re-query failed');
queryError = marker;
triggerRegather!();
const failed = await results.next();
expect(failed.input).toBeNull();
expect(failed.error).toBe(marker);
expect(query).toHaveBeenCalledTimes(6);
expect(cache!.introspectionResultsPromise).toBeNull();
} finally {
stopWatching();
}
});
});
Loading
Loading