diff --git a/packages/express-context/package.json b/packages/express-context/package.json index 9bca55616..3b60b6271 100644 --- a/packages/express-context/package.json +++ b/packages/express-context/package.json @@ -34,6 +34,7 @@ "@pgpmjs/logger": "workspace:^", "@pgpmjs/server-utils": "workspace:^", "@pgpmjs/types": "workspace:^", + "lru-cache": "^11.2.7", "pg": "^8.21.0", "pg-cache": "workspace:^", "pg-query-context": "workspace:^" diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index 95a35ad3d..02dc81348 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -8,6 +8,7 @@ * - Tenant database pool (via pg-cache) * - withPgClient (transaction-scoped RLS helper) * - Convenience fields (userId, databaseId, requestId) + * - useModule (lazy, on-demand per-database module resolution) * * The result is a single `req.constructive` object that any downstream * route handler can use for tenant-scoped database operations. @@ -18,13 +19,34 @@ import type { Pool } from 'pg'; import { getPgPool } from 'pg-cache'; import type { PgpmOptions } from '@pgpmjs/types'; +import type { LoaderRegistry } from './loaders/registry'; +import type { LoaderContext } from './loaders/types'; import { withPgClient as withPgClientFn } from './pg-client'; import { buildPgSettings } from './pg-settings'; -import type { ConstructiveContext } from './types'; +import type { BuiltinModuleMap, ConstructiveContext } from './types'; export interface ContextMiddlewareOptions { /** Base PG options for pool creation (host, port, user, password) */ pg?: PgpmOptions['pg']; + /** Module loader registry for per-database cached lookups */ + loaders?: LoaderRegistry; +} + +/** + * Create a `useModule` function bound to the given loader context. + * + * Calling `useModule('rlsModule')` lazily resolves the RLS loader, + * hitting the DB only on cache miss. The function is a no-op (returns + * undefined) when no registry is configured. + */ +function createUseModule( + registry: LoaderRegistry | undefined, + loaderCtx: LoaderContext | null, +): ConstructiveContext['useModule'] { + return (async (name: K | string) => { + if (!registry || !loaderCtx) return undefined; + return registry.resolve(name as string, loaderCtx); + }) as ConstructiveContext['useModule']; } /** @@ -32,6 +54,10 @@ export interface ContextMiddlewareOptions { * * Requires `req.api` and `req.requestId` to be set by upstream middleware. * `req.token` is optional (anonymous requests get null). + * + * Module loaders are NOT resolved eagerly. Instead, `ctx.useModule(name)` + * resolves them on demand — only the modules that middleware actually + * needs will fire SQL queries. */ export function buildContext( req: Request, @@ -50,11 +76,24 @@ export function buildContext( clientIp: req.clientIp, }); - const pool: Pool = getPgPool({ + const tenantPool: Pool = getPgPool({ ...opts.pg, database: api.dbname, }); + // Build loader context (if registry provided and databaseId known) + let loaderCtx: LoaderContext | null = null; + if (opts.loaders && api.databaseId) { + const servicesPool: Pool = getPgPool(opts.pg); + loaderCtx = { + servicesPool, + tenantPool, + databaseId: api.databaseId, + apiId: api.apiId, + dbname: api.dbname, + }; + } + return { api, token, @@ -62,9 +101,10 @@ export function buildContext( databaseId: api.databaseId ?? null, userId: token?.user_id ?? null, requestId, - pool, + pool: tenantPool, withPgClient: (fn: (client: any) => Promise) => - withPgClientFn(pool, pgSettings, fn), + withPgClientFn(tenantPool, pgSettings, fn), + useModule: createUseModule(opts.loaders, loaderCtx), }; } @@ -75,9 +115,21 @@ export function buildContext( * Mount AFTER the API resolver and auth middleware: * * ```typescript + * import { createContextMiddleware, createDefaultRegistry } from '@constructive-io/express-context'; + * + * const loaders = createDefaultRegistry(); + * * app.use(apiMiddleware); // sets req.api * app.use(authMiddleware); // sets req.token - * app.use(contextMiddleware()); // sets req.constructive + * app.use(createContextMiddleware({ loaders })); + * + * // Downstream middleware/routes call useModule on demand: + * app.post('/v1/chat', async (req, res) => { + * const ctx = req.constructive; + * const rls = await ctx.useModule('rlsModule'); // only fires if not cached + * const auth = await ctx.useModule('authSettings'); // only fires if not cached + * // webauthnSettings loader never fires if nobody asks for it + * }); * ``` */ export function createContextMiddleware( diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index f0fb656fb..64834f7bb 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -9,22 +9,28 @@ * - withPgClient (tenant-scoped RLS transaction helper) * - requestId middleware (UUID correlation ID) * - Context middleware (composes all of the above into req.constructive) + * - Module loaders (pluggable per-database cached lookups) * * @example * ```typescript * import { * createContextMiddleware, - * requestIdMiddleware + * requestIdMiddleware, + * createDefaultRegistry, * } from '@constructive-io/express-context'; * + * const loaders = createDefaultRegistry(); + * * app.use(requestIdMiddleware()); * app.use(apiMiddleware); // sets req.api (your domain resolver) * app.use(authMiddleware); // sets req.token (your JWT verifier) - * app.use(createContextMiddleware()); // builds req.constructive + * app.use(createContextMiddleware({ loaders })); // builds req.constructive * - * app.post('/v1/chat', (req, res) => { - * const { withPgClient, pgSettings, userId, databaseId } = req.constructive; - * // Full tenant-scoped database access + * app.post('/v1/chat', async (req, res) => { + * const ctx = req.constructive; + * const rls = await ctx.useModule('rlsModule'); // only fires if not cached + * const auth = await ctx.useModule('authSettings'); // only fires if not cached + * // webauthnSettings loader never fires if nobody asks for it * }); * ``` */ @@ -43,6 +49,7 @@ export type { GenericModuleData, PublicKeyChallengeData, PubkeyChallengeSettings, + BuiltinModuleMap, RlsModule, WebauthnSettings, WithPgClient, @@ -62,5 +69,24 @@ export { requestIdMiddleware } from './request-id'; export type { ContextMiddlewareOptions } from './context'; export { buildContext, createContextMiddleware } from './context'; +// Module loaders +export type { + CreateLoaderOptions, + LoaderContext, + LoaderRegistry, + ModuleLoader, +} from './loaders'; +export { + authSettingsLoader, + corsLoader, + createDefaultRegistry, + createLoaderRegistry, + createModuleLoader, + databaseSettingsLoader, + pubkeyLoader, + rlsLoader, + webauthnLoader, +} from './loaders'; + // Side-effect: Express type augmentation import './types'; diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts new file mode 100644 index 000000000..ff5fcfb93 --- /dev/null +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -0,0 +1,89 @@ +/** + * Auth Settings Loader (Tier 2 — tenant DB) + * + * Two-step discovery: + * 1. Query metaschema_modules_public.sessions_module to find the + * schema name and table name for auth settings + * 2. Query the discovered . in the tenant DB + * + * This is the pattern for any module whose config lives in the tenant + * database rather than the services database. + */ + +import type { AuthSettings } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const AUTH_SETTINGS_DISCOVERY_SQL = ` + SELECT s.schema_name, sm.auth_settings_table AS table_name + FROM metaschema_modules_public.sessions_module sm + JOIN metaschema_public.schema s ON s.id = sm.schema_id + LIMIT 1 +`; + +const buildAuthSettingsQuery = (schemaName: string, tableName: string) => ` + SELECT + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age, + cookie_path, + remember_me_duration, + enable_captcha, + captcha_site_key + FROM "${schemaName}"."${tableName}" + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface AuthSettingsRow { + cookie_secure: boolean; + cookie_samesite: string; + cookie_domain: string | null; + cookie_httponly: boolean; + cookie_max_age: string | null; + cookie_path: string; + remember_me_duration: string | null; + enable_captcha: boolean; + captcha_site_key: string | null; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const authSettingsLoader: ModuleLoader = createModuleLoader({ + name: 'authSettings', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool } = ctx; + + // Step 1: Discover schema + table from sessions_module + const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + ); + const resolved = discovery.rows[0]; + if (!resolved) return undefined; + + // Step 2: Query the actual auth settings table + const result = await tenantPool.query( + buildAuthSettingsQuery(resolved.schema_name, resolved.table_name), + ); + const row = result.rows[0]; + if (!row) return undefined; + + return { + cookieSecure: row.cookie_secure, + cookieSamesite: row.cookie_samesite, + cookieDomain: row.cookie_domain, + cookieHttponly: row.cookie_httponly, + cookieMaxAge: row.cookie_max_age, + cookiePath: row.cookie_path, + rememberMeDuration: row.remember_me_duration, + enableCaptcha: row.enable_captcha, + captchaSiteKey: row.captcha_site_key, + }; + }, +}); diff --git a/packages/express-context/src/loaders/cors.ts b/packages/express-context/src/loaders/cors.ts new file mode 100644 index 000000000..2fe0e43aa --- /dev/null +++ b/packages/express-context/src/loaders/cors.ts @@ -0,0 +1,73 @@ +/** + * CORS Origins Loader + * + * Resolves allowed CORS origins for a database+API combination. + * Checks per-API settings first, falls back to database-level default, + * then to the legacy api_modules approach. + */ + +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const CORS_SETTINGS_SQL = ` + SELECT allowed_origins + FROM services_public.cors_settings + WHERE database_id = $1 AND api_id = $2 + LIMIT 1 +`; + +const CORS_SETTINGS_DB_DEFAULT_SQL = ` + SELECT allowed_origins + FROM services_public.cors_settings + WHERE database_id = $1 AND api_id IS NULL + LIMIT 1 +`; + +const CORS_MODULE_SQL = ` + SELECT data + FROM services_public.api_modules + WHERE api_id = $1 AND name = 'cors' + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface CorsSettingsRow { + allowed_origins: string[]; +} + +interface CorsModuleRow { + data: { urls: string[] } | null; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const corsLoader: ModuleLoader = createModuleLoader({ + name: 'corsOrigins', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { servicesPool, databaseId, apiId } = ctx; + + // Try per-API cors_settings first + try { + if (apiId) { + const perApi = await servicesPool.query(CORS_SETTINGS_SQL, [databaseId, apiId]); + if (perApi.rows[0]) return perApi.rows[0].allowed_origins; + } + const dbDefault = await servicesPool.query(CORS_SETTINGS_DB_DEFAULT_SQL, [databaseId]); + if (dbDefault.rows[0]) return dbDefault.rows[0].allowed_origins; + } catch { + // Table may not exist yet + } + + // Fall back to legacy api_modules + if (apiId) { + const result = await servicesPool.query(CORS_MODULE_SQL, [apiId]); + return result.rows[0]?.data?.urls; + } + + return undefined; + }, +}); diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts new file mode 100644 index 000000000..379055cad --- /dev/null +++ b/packages/express-context/src/loaders/create-loader.ts @@ -0,0 +1,73 @@ +/** + * create-loader — Factory for building cached ModuleLoader instances. + * + * Wraps a raw resolve function with an LRU cache keyed by databaseId. + * Each loader gets its own independent cache with configurable TTL and + * max entries. + */ + +import { LRUCache } from 'lru-cache'; +import { Logger } from '@pgpmjs/logger'; + +import type { LoaderContext, ModuleLoader } from './types'; + +export interface CreateLoaderOptions { + /** Unique loader name (used in log prefix and modules map key) */ + name: string; + /** TTL in milliseconds (default: 60_000 — 1 minute) */ + ttlMs?: number; + /** Max cache entries before LRU eviction (default: 100) */ + max?: number; + /** The actual resolution function. Called on cache miss. */ + resolve: (ctx: LoaderContext) => Promise; +} + +const DEFAULT_TTL_MS = 60_000; +const DEFAULT_MAX = 100; + +export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoader { + const log = new Logger(`loader:${opts.name}`); + const cache = new LRUCache({ + max: opts.max ?? DEFAULT_MAX, + ttl: opts.ttlMs ?? DEFAULT_TTL_MS, + updateAgeOnGet: true, + allowStale: false, + }); + + return { + name: opts.name, + + async resolve(ctx: LoaderContext): Promise { + const key = ctx.databaseId; + + if (cache.has(key)) { + log.debug(`Cache HIT databaseId=${key}`); + return cache.get(key); + } + + log.debug(`Cache MISS databaseId=${key}, resolving`); + try { + const value = await opts.resolve(ctx); + cache.set(key, value); + return value; + } catch (e: any) { + log.warn(`Failed to resolve databaseId=${key}: ${e.message}`); + return undefined; + } + }, + + invalidate(databaseId?: string): void { + if (databaseId) { + cache.delete(databaseId); + log.debug(`Invalidated databaseId=${databaseId}`); + } else { + cache.clear(); + log.debug(`Invalidated all entries (was size=${cache.size})`); + } + }, + + get cacheSize(): number { + return cache.size; + }, + }; +} diff --git a/packages/express-context/src/loaders/database-settings.ts b/packages/express-context/src/loaders/database-settings.ts new file mode 100644 index 000000000..50baa7a30 --- /dev/null +++ b/packages/express-context/src/loaders/database-settings.ts @@ -0,0 +1,79 @@ +/** + * Database Settings Loader + * + * Resolves per-database feature flags (aggregates, postgis, search, + * uploads, many-to-many, connection filters, ltree, llm, realtime, bulk). + * Merges database-level defaults with optional per-API overrides. + */ + +import type { DatabaseSettings } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const DATABASE_SETTINGS_SQL = ` + SELECT + COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates, + COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis, + COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search, + COALESCE(aps.enable_direct_uploads, ds.enable_direct_uploads) AS resolved_enable_direct_uploads, + COALESCE(aps.enable_presigned_uploads, ds.enable_presigned_uploads) AS resolved_enable_presigned_uploads, + COALESCE(aps.enable_many_to_many, ds.enable_many_to_many) AS resolved_enable_many_to_many, + COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter, + COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree, + COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm, + COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime, + COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk + FROM services_public.database_settings ds + LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2 + WHERE ds.database_id = $1 + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface DatabaseSettingsRow { + resolved_enable_aggregates: boolean; + resolved_enable_postgis: boolean; + resolved_enable_search: boolean; + resolved_enable_direct_uploads: boolean; + resolved_enable_presigned_uploads: boolean; + resolved_enable_many_to_many: boolean; + resolved_enable_connection_filter: boolean; + resolved_enable_ltree: boolean; + resolved_enable_llm: boolean; + resolved_enable_realtime: boolean; + resolved_enable_bulk: boolean; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const databaseSettingsLoader: ModuleLoader = createModuleLoader({ + name: 'databaseSettings', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { servicesPool, databaseId, apiId } = ctx; + + const result = await servicesPool.query( + DATABASE_SETTINGS_SQL, + [databaseId, apiId ?? null], + ); + const row = result.rows[0]; + if (!row) return undefined; + + return { + enableAggregates: row.resolved_enable_aggregates, + enablePostgis: row.resolved_enable_postgis, + enableSearch: row.resolved_enable_search, + enableDirectUploads: row.resolved_enable_direct_uploads, + enablePresignedUploads: row.resolved_enable_presigned_uploads, + enableManyToMany: row.resolved_enable_many_to_many, + enableConnectionFilter: row.resolved_enable_connection_filter, + enableLtree: row.resolved_enable_ltree, + enableLlm: row.resolved_enable_llm, + enableRealtime: row.resolved_enable_realtime, + enableBulk: row.resolved_enable_bulk, + }; + }, +}); diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts new file mode 100644 index 000000000..0222c508a --- /dev/null +++ b/packages/express-context/src/loaders/index.ts @@ -0,0 +1,68 @@ +/** + * Module Loaders — pluggable per-database cached lookups. + * + * Each loader encapsulates a SQL query + type transform + LRU cache + * for one piece of per-database configuration. Register loaders in + * a LoaderRegistry and pass it to createContextMiddleware(). + * + * Built-in loaders cover the standard Constructive modules: + * - rlsModule (services_public.rls_settings) + * - corsOrigins (services_public.cors_settings) + * - databaseSettings(services_public.database_settings) + * - pubkeyChallengeSettings (services_public.pubkey_settings) + * - webauthnSettings(services_public.webauthn_settings) + * - authSettings (metaschema_modules_public.sessions_module → tenant DB) + * + * To add a new per-db lookup, implement a ModuleLoader and register it: + * + * const myLoader = createModuleLoader({ + * name: 'myModule', + * ttlMs: 60_000, + * async resolve(ctx) { + * const { rows } = await ctx.tenantPool.query(MY_SQL, [ctx.databaseId]); + * return rows[0] ? transform(rows[0]) : undefined; + * }, + * }); + * registry.register(myLoader); + */ + +// Core types +export type { LoaderContext, ModuleLoader } from './types'; + +// Factory +export type { CreateLoaderOptions } from './create-loader'; +export { createModuleLoader } from './create-loader'; + +// Registry +export type { LoaderRegistry } from './registry'; +export { createLoaderRegistry } from './registry'; + +// Built-in loaders +export { rlsLoader } from './rls'; +export { corsLoader } from './cors'; +export { databaseSettingsLoader } from './database-settings'; +export { pubkeyLoader } from './pubkey'; +export { webauthnLoader } from './webauthn'; +export { authSettingsLoader } from './auth-settings'; + +/** + * Convenience: create a registry pre-loaded with all built-in loaders. + */ +import { createLoaderRegistry } from './registry'; +import { rlsLoader } from './rls'; +import { corsLoader } from './cors'; +import { databaseSettingsLoader } from './database-settings'; +import { pubkeyLoader } from './pubkey'; +import { webauthnLoader } from './webauthn'; +import { authSettingsLoader } from './auth-settings'; + +export function createDefaultRegistry() { + const registry = createLoaderRegistry(); + registry.register(rlsLoader); + registry.register(corsLoader); + registry.register(databaseSettingsLoader); + registry.register(pubkeyLoader); + registry.register(webauthnLoader); + registry.register(authSettingsLoader); + return registry; +} diff --git a/packages/express-context/src/loaders/pubkey.ts b/packages/express-context/src/loaders/pubkey.ts new file mode 100644 index 000000000..f8af1d552 --- /dev/null +++ b/packages/express-context/src/loaders/pubkey.ts @@ -0,0 +1,94 @@ +/** + * Pubkey Challenge Settings Loader + * + * Resolves public-key challenge auth config (crypto network, sign-in/sign-up + * function names). Tries the new pubkey_settings table first, falls back + * to the legacy api_modules approach. + */ + +import type { PubkeyChallengeSettings } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const PUBKEY_SETTINGS_SQL = ` + SELECT + s.schema_name AS schema, + ps.crypto_network, + sign_up_fn.name AS sign_up_with_key, + sign_in_req_fn.name AS sign_in_request_challenge, + sign_in_fail_fn.name AS sign_in_record_failure, + sign_in_fn.name AS sign_in_with_challenge + FROM services_public.pubkey_settings ps + LEFT JOIN metaschema_public.schema s ON ps.schema_id = s.id + LEFT JOIN metaschema_public.function sign_up_fn ON ps.sign_up_with_key_function_id = sign_up_fn.id + LEFT JOIN metaschema_public.function sign_in_req_fn ON ps.sign_in_request_challenge_function_id = sign_in_req_fn.id + LEFT JOIN metaschema_public.function sign_in_fail_fn ON ps.sign_in_record_failure_function_id = sign_in_fail_fn.id + LEFT JOIN metaschema_public.function sign_in_fn ON ps.sign_in_with_challenge_function_id = sign_in_fn.id + WHERE ps.database_id = $1 + LIMIT 1 +`; + +const PUBKEY_MODULE_SQL = ` + SELECT data + FROM services_public.api_modules + WHERE api_id = $1 AND name = 'pubkey_challenge' + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface PubkeySettingsRow { + schema: string; + crypto_network: string; + sign_up_with_key: string; + sign_in_request_challenge: string; + sign_in_record_failure: string; + sign_in_with_challenge: string; +} + +interface PubkeyModuleRow { + data: PubkeySettingsRow | null; +} + +// ─── Transforms ───────────────────────────────────────────────────────────── + +function fromRow(row: PubkeySettingsRow | null): PubkeyChallengeSettings | undefined { + if (!row?.schema || !row?.sign_up_with_key) return undefined; + return { + schema: row.schema, + cryptoNetwork: row.crypto_network, + signUpWithKey: row.sign_up_with_key, + signInRequestChallenge: row.sign_in_request_challenge, + signInRecordFailure: row.sign_in_record_failure, + signInWithChallenge: row.sign_in_with_challenge, + }; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const pubkeyLoader: ModuleLoader = createModuleLoader({ + name: 'pubkeyChallengeSettings', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { servicesPool, databaseId, apiId } = ctx; + + // Try new pubkey_settings table first + try { + const result = await servicesPool.query(PUBKEY_SETTINGS_SQL, [databaseId]); + const resolved = fromRow(result.rows[0] ?? null); + if (resolved) return resolved; + } catch { + // Table may not exist yet + } + + // Fall back to legacy api_modules + if (apiId) { + const result = await servicesPool.query(PUBKEY_MODULE_SQL, [apiId]); + return fromRow(result.rows[0]?.data ?? null); + } + + return undefined; + }, +}); diff --git a/packages/express-context/src/loaders/registry.ts b/packages/express-context/src/loaders/registry.ts new file mode 100644 index 000000000..d4d8701ef --- /dev/null +++ b/packages/express-context/src/loaders/registry.ts @@ -0,0 +1,114 @@ +/** + * Loader Registry — manages a set of ModuleLoader instances. + * + * Supports two resolution modes: + * - Lazy (preferred): `resolve(name, ctx)` resolves a single loader + * on-demand. Only fires the SQL query if the cache misses. Middleware + * requests only the loaders it actually needs. + * - Eager: `resolveAll(ctx)` resolves every registered loader in + * parallel. Useful for pre-warming or migration from the monolithic + * svcCache pattern. + * + * Each loader's result is independently cached per databaseId — resolving + * one module never invalidates another. + */ + +import { Logger } from '@pgpmjs/logger'; + +import type { LoaderContext, ModuleLoader } from './types'; + +const log = new Logger('loader-registry'); + +export interface LoaderRegistry { + /** Register a loader. Throws if a loader with the same name already exists. */ + register(loader: ModuleLoader): void; + + /** + * Resolve a single loader by name (lazy, on-demand). + * Returns undefined if the loader isn't registered or the module + * isn't provisioned for this database. Results are cached per databaseId + * inside the loader's own LRU — repeated calls are cheap. + */ + resolve(name: string, ctx: LoaderContext): Promise; + + /** Resolve all registered loaders in parallel (eager, for migration/pre-warm). */ + resolveAll(ctx: LoaderContext): Promise>; + + /** Get a specific loader by name (for direct access / advanced usage). */ + get(name: string): ModuleLoader | undefined; + + /** Check whether a loader is registered. */ + has(name: string): boolean; + + /** Invalidate caches for one database (or all databases if omitted). */ + invalidate(databaseId?: string): void; + + /** List all registered loader names. */ + readonly names: string[]; +} + +export function createLoaderRegistry(): LoaderRegistry { + const loaders = new Map(); + + return { + register(loader: ModuleLoader): void { + if (loaders.has(loader.name)) { + throw new Error(`Loader "${loader.name}" is already registered`); + } + loaders.set(loader.name, loader); + log.debug(`Registered loader: ${loader.name}`); + }, + + async resolve(name: string, ctx: LoaderContext): Promise { + const loader = loaders.get(name) as ModuleLoader | undefined; + if (!loader) { + log.debug(`Loader "${name}" not registered, returning undefined`); + return undefined; + } + return loader.resolve(ctx); + }, + + async resolveAll(ctx: LoaderContext): Promise> { + if (loaders.size === 0) return {}; + + const entries = Array.from(loaders.entries()); + const results = await Promise.all( + entries.map(async ([name, loader]) => { + const value = await loader.resolve(ctx); + return [name, value] as const; + }), + ); + + const modules: Record = {}; + for (const [name, value] of results) { + if (value !== undefined) { + modules[name] = value; + } + } + return modules; + }, + + get(name: string): ModuleLoader | undefined { + return loaders.get(name) as ModuleLoader | undefined; + }, + + has(name: string): boolean { + return loaders.has(name); + }, + + invalidate(databaseId?: string): void { + for (const loader of loaders.values()) { + loader.invalidate(databaseId); + } + log.debug( + databaseId + ? `Invalidated all loaders for databaseId=${databaseId}` + : 'Invalidated all loaders', + ); + }, + + get names(): string[] { + return Array.from(loaders.keys()); + }, + }; +} diff --git a/packages/express-context/src/loaders/rls.ts b/packages/express-context/src/loaders/rls.ts new file mode 100644 index 000000000..e0a0090ab --- /dev/null +++ b/packages/express-context/src/loaders/rls.ts @@ -0,0 +1,119 @@ +/** + * RLS Module Loader + * + * Resolves RLS authentication function names and schema references for + * a given database. Tries the new rls_settings table first, falls back + * to the legacy api_modules approach. + */ + +import type { RlsModule } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const RLS_SETTINGS_SQL = ` + SELECT + auth_schema.schema_name AS authenticate_schema, + role_schema.schema_name AS role_schema, + auth_fn.name AS authenticate, + auth_strict_fn.name AS authenticate_strict, + role_fn.name AS current_role, + role_id_fn.name AS current_role_id, + ua_fn.name AS current_user_agent, + ip_fn.name AS current_ip_address + FROM services_public.rls_settings rs + LEFT JOIN metaschema_public.schema auth_schema ON rs.authenticate_schema_id = auth_schema.id + LEFT JOIN metaschema_public.schema role_schema ON rs.role_schema_id = role_schema.id + LEFT JOIN metaschema_public.function auth_fn ON rs.authenticate_function_id = auth_fn.id + LEFT JOIN metaschema_public.function auth_strict_fn ON rs.authenticate_strict_function_id = auth_strict_fn.id + LEFT JOIN metaschema_public.function role_fn ON rs.current_role_function_id = role_fn.id + LEFT JOIN metaschema_public.function role_id_fn ON rs.current_role_id_function_id = role_id_fn.id + LEFT JOIN metaschema_public.function ua_fn ON rs.current_user_agent_function_id = ua_fn.id + LEFT JOIN metaschema_public.function ip_fn ON rs.current_ip_address_function_id = ip_fn.id + WHERE rs.database_id = $1 + LIMIT 1 +`; + +const RLS_MODULE_SQL = ` + SELECT data + FROM services_public.api_modules + WHERE api_id = $1 AND name = 'rls_module' + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface RlsSettingsRow { + authenticate: string; + authenticate_strict: string; + authenticate_schema: string; + role_schema: string; + current_role: string; + current_role_id: string; + current_ip_address: string; + current_user_agent: string; +} + +interface RlsModuleRow { + data: RlsSettingsRow | null; +} + +// ─── Transforms ───────────────────────────────────────────────────────────── + +function fromSettings(row: RlsSettingsRow | null): RlsModule | undefined { + if (!row) return undefined; + if (!row.authenticate || !row.authenticate_schema) return undefined; + return { + authenticate: row.authenticate, + authenticateStrict: row.authenticate_strict, + privateSchema: { schemaName: row.authenticate_schema }, + publicSchema: { schemaName: row.role_schema }, + currentRole: row.current_role, + currentRoleId: row.current_role_id, + currentIpAddress: row.current_ip_address, + currentUserAgent: row.current_user_agent, + }; +} + +function fromModule(row: RlsModuleRow | null): RlsModule | undefined { + if (!row?.data) return undefined; + const d = row.data; + return { + authenticate: d.authenticate, + authenticateStrict: d.authenticate_strict, + privateSchema: { schemaName: d.authenticate_schema }, + publicSchema: { schemaName: d.role_schema }, + currentRole: d.current_role, + currentRoleId: d.current_role_id, + currentIpAddress: d.current_ip_address, + currentUserAgent: d.current_user_agent, + }; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const rlsLoader: ModuleLoader = createModuleLoader({ + name: 'rlsModule', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { servicesPool, databaseId, apiId } = ctx; + + // Try new rls_settings table first + try { + const result = await servicesPool.query(RLS_SETTINGS_SQL, [databaseId]); + const resolved = fromSettings(result.rows[0] ?? null); + if (resolved) return resolved; + } catch { + // Table may not exist yet + } + + // Fall back to legacy api_modules + if (apiId) { + const result = await servicesPool.query(RLS_MODULE_SQL, [apiId]); + return fromModule(result.rows[0] ?? null); + } + + return undefined; + }, +}); diff --git a/packages/express-context/src/loaders/types.ts b/packages/express-context/src/loaders/types.ts new file mode 100644 index 000000000..432eaacd8 --- /dev/null +++ b/packages/express-context/src/loaders/types.ts @@ -0,0 +1,46 @@ +/** + * Module Loader Types + * + * A ModuleLoader is a per-database cached lookup that resolves config + * from the services DB or tenant DB. Each loader owns its own LRU cache + * keyed by databaseId, with independent TTL and eviction. + * + * Loaders are registered in a LoaderRegistry and resolved in parallel + * during context building. The result is a typed modules map on + * `req.constructive.modules`. + */ + +import type { Pool } from 'pg'; + +/** + * Context passed to every loader's resolve function. + * Provides both pool references so the loader can query whichever + * database tier it needs. + */ +export interface LoaderContext { + /** Services database pool (for services_public.* lookups) */ + servicesPool: Pool; + /** Tenant database pool (for metaschema_modules_public.* lookups) */ + tenantPool: Pool; + /** UUID of the database being resolved */ + databaseId: string; + /** UUID of the API (if resolved from domain/api-name lookup) */ + apiId?: string; + /** Tenant database name */ + dbname: string; +} + +/** + * A single module loader. Encapsulates the SQL query, type transform, + * and per-databaseId LRU cache for one piece of per-database config. + */ +export interface ModuleLoader { + /** Unique name (used in log prefix and as the key in the modules map) */ + readonly name: string; + /** Resolve the module config for a given database. Returns undefined if not provisioned. */ + resolve(ctx: LoaderContext): Promise; + /** Invalidate the cache for one database (or all databases if omitted) */ + invalidate(databaseId?: string): void; + /** Current number of cached entries */ + readonly cacheSize: number; +} diff --git a/packages/express-context/src/loaders/webauthn.ts b/packages/express-context/src/loaders/webauthn.ts new file mode 100644 index 000000000..b09dcc37b --- /dev/null +++ b/packages/express-context/src/loaders/webauthn.ts @@ -0,0 +1,78 @@ +/** + * WebAuthn Settings Loader + * + * Resolves WebAuthn/passkey configuration for a database — RP identity, + * schema references, attestation policy, and challenge expiry. + */ + +import type { WebauthnSettings } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const WEBAUTHN_SETTINGS_SQL = ` + SELECT + s.schema_name AS schema, + cred_s.schema_name AS credentials_schema, + sess_s.schema_name AS sessions_schema, + sec_s.schema_name AS session_secrets_schema, + ws.rp_id, + ws.rp_name, + ws.origin_allowlist, + ws.attestation_type, + ws.require_user_verification, + ws.resident_key, + ws.challenge_expiry_seconds + FROM services_public.webauthn_settings ws + LEFT JOIN metaschema_public.schema s ON ws.schema_id = s.id + LEFT JOIN metaschema_public.schema cred_s ON ws.credentials_schema_id = cred_s.id + LEFT JOIN metaschema_public.schema sess_s ON ws.sessions_schema_id = sess_s.id + LEFT JOIN metaschema_public.schema sec_s ON ws.session_secrets_schema_id = sec_s.id + WHERE ws.database_id = $1 + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface WebauthnSettingsRow { + schema: string; + credentials_schema: string; + sessions_schema: string; + session_secrets_schema: string; + rp_id: string; + rp_name: string; + origin_allowlist: string[]; + attestation_type: string; + require_user_verification: boolean; + resident_key: string; + challenge_expiry_seconds: number; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const webauthnLoader: ModuleLoader = createModuleLoader({ + name: 'webauthnSettings', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { servicesPool, databaseId } = ctx; + + const result = await servicesPool.query(WEBAUTHN_SETTINGS_SQL, [databaseId]); + const row = result.rows[0]; + if (!row?.schema) return undefined; + + return { + schema: row.schema, + credentialsSchema: row.credentials_schema, + sessionsSchema: row.sessions_schema, + sessionSecretsSchema: row.session_secrets_schema, + rpId: row.rp_id, + rpName: row.rp_name, + originAllowlist: row.origin_allowlist, + attestationType: row.attestation_type, + requireUserVerification: row.require_user_verification, + residentKey: row.resident_key, + challengeExpirySeconds: row.challenge_expiry_seconds, + }; + }, +}); diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 777471d32..a05be93a2 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -129,6 +129,25 @@ export type ConstructiveAPIToken = { [key: string]: unknown; }; +// ─── Module Types Map ─────────────────────────────────────────────────────── + +/** + * Maps built-in loader names to their resolved types. + * + * This enables typed access via `useModule`: + * const rls = await ctx.useModule('rlsModule'); // typed as RlsModule | undefined + * + * Custom loaders (not in this map) return `unknown`. + */ +export interface BuiltinModuleMap { + rlsModule: RlsModule; + corsOrigins: string[]; + databaseSettings: DatabaseSettings; + authSettings: AuthSettings; + pubkeyChallengeSettings: PubkeyChallengeSettings; + webauthnSettings: WebauthnSettings; +} + // ─── Constructive Context ─────────────────────────────────────────────────── /** @@ -160,6 +179,29 @@ export interface ConstructiveContext { pool: Pool; /** Execute a function within a tenant-scoped RLS transaction */ withPgClient: WithPgClient; + + /** + * Resolve a per-database module on demand (lazy, cached). + * + * Only fires the SQL query on the first call per databaseId per TTL window. + * Subsequent calls return the cached result instantly. + * + * Built-in modules are typed: + * const rls = await ctx.useModule('rlsModule'); // RlsModule | undefined + * const auth = await ctx.useModule('authSettings'); // AuthSettings | undefined + * + * Custom modules return unknown: + * const custom = await ctx.useModule('myModule'); // unknown + * + * Returns undefined if: + * - No loader registry was provided to the middleware + * - The named loader isn't registered + * - The module isn't provisioned for this database + */ + useModule: { + (name: K): Promise; + (name: string): Promise; + }; } // ─── Express Augmentation ─────────────────────────────────────────────────── diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 576eb9dc1..3edbe4d1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,7 +260,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) + version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) graphile-realtime-subscriptions: specifier: workspace:^ version: link:../graphile-realtime-subscriptions/dist @@ -272,7 +272,7 @@ importers: version: link:../../postgres/pg-cache/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(ad3b1ddbbeba5ca9cd3b71263258e931) + version: 5.0.3(4080119c6ab3f2725faab12a7cbc5738) devDependencies: '@types/express': specifier: ^5.0.6 @@ -285,7 +285,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphile/graphile-connection-filter: @@ -731,7 +731,7 @@ importers: version: 0.3.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphile/graphile-search: @@ -833,7 +833,7 @@ importers: version: 1.0.2(graphql@16.13.0) grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) + version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) graphile-bucket-provisioner-plugin: specifier: workspace:* version: link:../graphile-bucket-provisioner-plugin/dist @@ -899,7 +899,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(ad3b1ddbbeba5ca9cd3b71263258e931) + version: 5.0.3(4080119c6ab3f2725faab12a7cbc5738) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -930,7 +930,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphile/graphile-sql-expression-validator: @@ -1178,7 +1178,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) + version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) graphile-cache: specifier: workspace:^ version: link:../../graphile/graphile-cache/dist @@ -1199,7 +1199,7 @@ importers: version: link:../../postgres/pg-env/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(ad3b1ddbbeba5ca9cd3b71263258e931) + version: 5.0.3(4080119c6ab3f2725faab12a7cbc5738) devDependencies: '@types/express': specifier: ^5.0.6 @@ -1212,7 +1212,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphql/gql-ast: @@ -1517,7 +1517,7 @@ importers: version: 1.0.2(graphql@16.13.0) grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) + version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) graphile-build: specifier: 5.0.2 version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) @@ -1568,7 +1568,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(ad3b1ddbbeba5ca9cd3b71263258e931) + version: 5.0.3(4080119c6ab3f2725faab12a7cbc5738) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -1611,7 +1611,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphql/server-test: @@ -2039,7 +2039,7 @@ importers: version: 7.2.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist jobs/knative-job-worker: @@ -2273,6 +2273,9 @@ importers: '@pgpmjs/types': specifier: workspace:^ version: link:../../pgpm/types/dist + lru-cache: + specifier: ^11.2.7 + version: 11.2.7 pg: specifier: ^8.21.0 version: 8.21.0 @@ -2458,7 +2461,7 @@ importers: version: 0.3.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist packages/smtppostmaster: @@ -2487,7 +2490,7 @@ importers: version: 3.18.4 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist packages/upload-client: @@ -11284,6 +11287,20 @@ snapshots: - immer - use-sync-external-store + '@graphiql/plugin-doc-explorer@0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.15)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + dependencies: + '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + graphql: 16.13.0 + react: 19.2.4 + react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) + react-dom: 19.2.4(react@19.2.4) + zustand: 5.0.11(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + transitivePeerDependencies: + - '@types/react' + - immer + - use-sync-external-store + '@graphiql/plugin-doc-explorer@0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.15)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -11330,6 +11347,22 @@ snapshots: - immer - use-sync-external-store + '@graphiql/plugin-history@0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@22.19.19)(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + dependencies: + '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/toolkit': 0.11.3(@types/node@22.19.19)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) + react: 19.2.4 + react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) + react-dom: 19.2.4(react@19.2.4) + zustand: 5.0.11(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + transitivePeerDependencies: + - '@types/node' + - '@types/react' + - graphql + - graphql-ws + - immer + - use-sync-external-store + '@graphiql/plugin-history@0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.9.1)(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -11408,6 +11441,37 @@ snapshots: - immer - use-sync-external-store + '@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + dependencies: + '@graphiql/toolkit': 0.11.3(@types/node@22.19.19)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + clsx: 1.2.1 + framer-motion: 12.36.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + get-value: 3.0.1 + graphql: 16.13.0 + graphql-language-service: 5.5.0(graphql@16.13.0) + jsonc-parser: 3.3.1 + markdown-it: 14.1.1 + monaco-editor: 0.52.2 + monaco-graphql: 1.7.3(graphql@16.13.0)(monaco-editor@0.52.2)(prettier@3.8.1) + prettier: 3.8.1 + react: 19.2.4 + react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) + react-dom: 19.2.4(react@19.2.4) + set-value: 4.1.0 + zustand: 5.0.11(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/node' + - '@types/react' + - '@types/react-dom' + - graphql-ws + - immer + - use-sync-external-store + '@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: '@graphiql/toolkit': 0.11.3(@types/node@25.9.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) @@ -11459,6 +11523,16 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@graphiql/toolkit@0.11.3(@types/node@22.19.19)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)': + dependencies: + '@n1ru4l/push-pull-async-iterable-iterator': 3.2.0 + graphql: 16.13.0 + meros: 1.3.2(@types/node@22.19.19) + optionalDependencies: + graphql-ws: 6.0.8(graphql@16.13.0)(ws@8.20.1) + transitivePeerDependencies: + - '@types/node' + '@graphiql/toolkit@0.11.3(@types/node@25.9.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)': dependencies: '@n1ru4l/push-pull-async-iterable-iterator': 3.2.0 @@ -14974,6 +15048,31 @@ snapshots: - supports-color - use-sync-external-store + grafserv@1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1): + dependencies: + '@graphile/lru': 5.0.0 + debug: 4.4.3(supports-color@5.5.0) + eventemitter3: 5.0.4 + grafast: 1.0.2(graphql@16.13.0) + graphile-config: 1.0.1 + graphql: 16.13.0 + graphql-ws: 6.0.8(graphql@16.13.0)(ws@8.20.1) + ruru: 2.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(debug@4.4.3)(graphile-config@1.0.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + tslib: 2.8.1 + optionalDependencies: + ws: 8.20.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - '@types/react' + - '@types/react-dom' + - crossws + - immer + - react + - react-dom + - supports-color + - use-sync-external-store + grafserv@1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1): dependencies: '@graphile/lru': 5.0.0 @@ -15151,6 +15250,24 @@ snapshots: - immer - use-sync-external-store + graphiql@5.2.2(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + dependencies: + '@graphiql/plugin-doc-explorer': 0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.15)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/plugin-history': 0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@22.19.19)(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + graphql: 16.13.0 + react: 19.2.4 + react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/node' + - '@types/react' + - '@types/react-dom' + - graphql-ws + - immer + - use-sync-external-store + graphiql@5.2.2(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): dependencies: '@graphiql/plugin-doc-explorer': 0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.15)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -16337,6 +16454,10 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 + meros@1.3.2(@types/node@22.19.19): + optionalDependencies: + '@types/node': 22.19.19 + meros@1.3.2(@types/node@25.9.1): optionalDependencies: '@types/node': 25.9.1 @@ -17416,6 +17537,33 @@ snapshots: - supports-color - utf-8-validate + postgraphile@5.0.3(4080119c6ab3f2725faab12a7cbc5738): + dependencies: + '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) + '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@graphile/lru': 5.0.0 + '@types/node': 22.19.19 + '@types/pg': 8.20.0 + debug: 4.4.3(supports-color@5.5.0) + grafast: 1.0.2(graphql@16.13.0) + grafserv: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) + graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + graphile-config: 1.0.1 + graphile-utils: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) + graphql: 16.13.0 + iterall: 1.3.0 + jsonwebtoken: 9.0.3 + pg: 8.21.0 + pg-sql2: 5.0.1 + tamedevil: 0.1.1 + tslib: 2.8.1 + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + postgraphile@5.0.3(7bbb0860e34b6a7498373453b4dbfe21): dependencies: '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) @@ -17880,6 +18028,23 @@ snapshots: - immer - use-sync-external-store + ruru-types@2.0.0(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + dependencies: + '@graphiql/toolkit': 0.11.3(@types/node@22.19.19)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) + graphiql: 5.2.2(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + graphql: 16.13.0 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/node' + - '@types/react' + - '@types/react-dom' + - graphql-ws + - immer + - use-sync-external-store + ruru-types@2.0.0(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): dependencies: '@graphiql/toolkit': 0.11.3(@types/node@25.9.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) @@ -17939,6 +18104,27 @@ snapshots: - immer - use-sync-external-store + ruru@2.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(debug@4.4.3)(graphile-config@1.0.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + dependencies: + '@emotion/is-prop-valid': 1.4.0 + graphile-config: 1.0.1 + graphql: 16.13.0 + http-proxy: 1.18.1(debug@4.4.3) + ruru-types: 2.0.0(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + tslib: 2.8.1 + yargs: 17.7.2 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/node' + - '@types/react' + - '@types/react-dom' + - debug + - graphql-ws + - immer + - use-sync-external-store + ruru@2.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(debug@4.4.3)(graphile-config@1.0.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): dependencies: '@emotion/is-prop-valid': 1.4.0 @@ -18429,14 +18615,14 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3): + ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.9.1 + '@types/node': 22.19.19 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3