Skip to content

feat(express-context): add modular per-database cached lookup system#1215

Merged
pyramation merged 2 commits into
mainfrom
feat/module-loaders
May 23, 2026
Merged

feat(express-context): add modular per-database cached lookup system#1215
pyramation merged 2 commits into
mainfrom
feat/module-loaders

Conversation

@pyramation
Copy link
Copy Markdown
Contributor

@pyramation pyramation commented May 23, 2026

Summary

Introduces a pluggable ModuleLoader registry in @constructive-io/express-context with lazy, on-demand resolution — loaders only fire SQL queries when middleware actually requests them.

Problem

All per-db config lookups (rlsModule, authSettings, databaseSettings, corsOrigins, pubkeyChallengeSettings, webauthnSettings) are hardcoded in api.ts, fetched eagerly in parallel on every request (even if the request never needs them), and cached as a single monolithic blob in svcCache. Adding a new per-db lookup means editing api.ts, extending ApiStructure, and wiring into the Promise.all.

Solution

Lazy useModule pattern — middleware requests only what it needs:

app.post('/v1/chat', async (req, res) => {
  const ctx = req.constructive;
  const rls = await ctx.useModule('rlsModule');       // fires SQL only on cache miss
  const auth = await ctx.useModule('authSettings');    // fires SQL only on cache miss
  // webauthnSettings loader never fires if nobody asks for it
});

Architecture:

  • ModuleLoader<T> — encapsulates SQL query + type transform + per-databaseId LRU cache
  • createModuleLoader() factory — wraps a resolve function with LRU caching (independent TTL per loader)
  • LoaderRegistry — manages loaders, supports both lazy resolve(name, ctx) and eager resolveAll(ctx)
  • LoaderContext — provides servicesPool (Tier 1) + tenantPool (Tier 2) + databaseId + apiId
  • useModule(name) on ConstructiveContext — typed lazy accessor bound to the request's loader context
  • BuiltinModuleMap — maps built-in loader names to their types for full type safety

6 built-in loaders:

Loader Source Pattern
rlsLoader services_public.rls_settings Settings table + legacy api_modules fallback
corsLoader services_public.cors_settings Per-API → db-default → legacy fallback
databaseSettingsLoader services_public.database_settings DB defaults + per-API override merge
pubkeyLoader services_public.pubkey_settings Settings table + legacy fallback
webauthnLoader services_public.webauthn_settings Direct query with schema JOINs
authSettingsLoader metaschema_modules_public.sessions_module Two-step: discover schema → query tenant DB

Zero breaking changes

  • ApiStructure fields (rlsModule, authSettings, etc.) remain untouched
  • req.api.rlsModule still works exactly as before
  • The new useModule path coexists alongside the old one
  • buildContext and createContextMiddleware remain sync (no async middleware needed)

Adding a new per-db lookup

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);
// Then: await ctx.useModule('myModule')

What's next

  • Wire api.ts to use loaders internally (remove inline SQL, keep ApiStructure populated)
  • Migrate existing middleware from req.api.rlsModuleawait ctx.useModule('rlsModule')
  • Eventually retire svcCache (each loader has its own cache)

Review & Testing Checklist for Human

  • Type safety: Verify BuiltinModuleMap covers all the types downstream middleware expects — the overloaded useModule signature gives typed returns for built-in names and unknown for custom
  • Data flow integrity: The auth middleware chain (api.tsauth.ts → pgSettings) still reads from req.api.rlsModule — confirm this path is unmodified
  • LRU defaults: Each loader defaults to 5min TTL / 100 max entries — verify these are reasonable for production
  • Loader SQL: The SQL in each built-in loader mirrors what's in api.ts — worth a diff to confirm no query divergence

Notes

  • Build passes cleanly. ESLint has a pre-existing v9 config migration issue (not from this PR).
  • lru-cache added as a direct dep to avoid pulling in heavy PostGraphile transitive deps from graphile-cache.
  • The resolveAll() method is kept for backwards compat / pre-warming, but the recommended pattern is useModule(name) for lazy access.

Link to Devin session: https://app.devin.ai/sessions/151753dc357b48528652af31a63decc0
Requested by: @pyramation

Introduces a pluggable ModuleLoader registry that replaces the hardcoded
per-db lookups currently baked into graphql/server's api.ts.

Key additions:

- LoaderContext: provides both services and tenant pools to loaders
- createModuleLoader: factory wrapping resolve functions with LRU caching
- LoaderRegistry: manages loaders, resolves all in parallel
- Built-in loaders for all existing module types:
  - rlsModule (services_public.rls_settings + legacy fallback)
  - corsOrigins (services_public.cors_settings + legacy fallback)
  - databaseSettings (services_public.database_settings + api_settings merge)
  - pubkeyChallengeSettings (services_public.pubkey_settings + legacy fallback)
  - webauthnSettings (services_public.webauthn_settings)
  - authSettings (two-step metaschema discovery -> tenant DB query)
- ResolvedModules type on ConstructiveContext
- createDefaultRegistry() convenience function
- Async context middleware with loader resolution

Each loader owns its own LRU cache (per databaseId, independent TTL),
replacing the monolithic svcCache blob approach. New per-db lookups can
be added by implementing a ModuleLoader and registering it — no changes
to api.ts or ApiStructure required.
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

…dule

Replace eager resolveAll() pattern with lazy useModule(name):

- Registry gains resolve(name, ctx) for single-loader on-demand access
- ConstructiveContext.useModule replaces the modules map
- buildContext is sync again (no eager SQL at middleware time)
- createContextMiddleware is sync again (no async needed)
- BuiltinModuleMap provides typed overloads for built-in module names
- Registry.has(name) added for checking loader availability

Middleware only pays for what it uses — if a request never touches
webauthn, the webauthn loader never fires. Results are cached per
databaseId in each loader's own LRU, so repeated calls are free.
@pyramation pyramation merged commit 52cbcd6 into main May 23, 2026
37 checks passed
@pyramation pyramation deleted the feat/module-loaders branch May 23, 2026 02:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant