@quilla-be-kit/execution-context
v0.2.2
Published
Execution context: per-operation actor / scope / correlation, AsyncLocalStorage-backed provider, factory, and logger enricher.
Maintainers
Readme
@quilla-be-kit/execution-context
Per-operation execution context: ExecutionContext type,
ExecutionContextProvider interface, AsyncExecutionContextProvider
(AsyncLocalStorage-backed), executionContextFactory, and
ExecutionContextEnricher for bridging into @quilla-be-kit/observability.
Why this exists
The execution context carries the actor (who), scope (tenant / workspace / project / whatever the consumer's isolation boundary is), user (when authenticated), and correlation id (tracing) for a single logical operation. Two quilla-be-kit invariants rely on it:
- Persistence uses it to populate audit fields (
inserted_by,updated_by) without requiring callers to pass them. - Observability uses it to enrich every log line emitted during the operation.
Install
pnpm add @quilla-be-kit/execution-contextQuick start
import {
AsyncExecutionContextProvider,
executionContextFactory,
ExecutionContextEnricher,
} from '@quilla-be-kit/execution-context';
import { createLoggerFactory } from '@quilla-be-kit/observability';
// Composition root — one instance per process.
const provider = new AsyncExecutionContextProvider();
const loggerFactory = createLoggerFactory({
config: { level: 'info', mode: 'json' },
enrichers: [new ExecutionContextEnricher(provider)],
});
// Any code path that wants a log with context goes through runWithContext:
const ctx = executionContextFactory.createSystemContext('system');
await provider.runWithContext(ctx, async () => {
const logger = loggerFactory.create('startup');
logger.info('server booting');
});API
Types
ExecutionContext— the base shape (actorType,correlationId, and an optionalsessionof typeAuthSession).sessionis present iff the operation ran inside an authenticated scope; anonymous, system, and job contexts leave it undefined.AuthSession— the authenticated-caller identity ({ scopeId, userId }). Extensible by intersection for richer session data (roles, session id, authenticatedAt, etc.).
Interfaces
ExecutionContextProvider—getContext()+runWithContext(ctx, fn)+ readonlyfactory(the pairedExecutionContextFactory).getContext()throws if called outside arunWithContextscope.runWithContext(fn)is async-only — synchronous code cannot establish a scope; wrap it inasync () => {...}at the boundary.ExecutionContextFactory—createSystemContext(actorType),createBaselineContext,createFromEventMetadata. Reach it viaprovider.factoryso consumers take only one injectable (the provider) and stay internally consistent.createSystemContextandcreateBaselineContextauto-generatecorrelationIdvianode:crypto.randomUUID()when not supplied — so a context established at process boot or at a background-job tick carries a traceable id without the caller minting one. Pass an explicitcorrelationIdto propagate one inbound from HTTP/events.createSystemContextaccepts'system'or'job'asactorType:'system'— process-level operations with no scheduled-job framing: startup tasks, health-check callbacks, migration runners.'job'— a background-job tick.@quilla-be-kit/jobscalls this automatically for eachInProcessJobRunnertick; pass it explicitly when you implement a customJobRunneror drive job ticks by hand.
Classes
AsyncExecutionContextProvider— Node-nativeAsyncLocalStorage-backed provider. Owns its own storage instance; intended one-per-process. Takes an optional{ factory }in its constructor — defaults toexecutionContextFactoryif omitted. Pass a custom factory when you've extendedExecutionContextwith new fields.ExecutionContextEnricher—LogEntryEnricherthat reads from a provider and returns the current context's fields as a log contribution. Returns an empty contribution when the provider is outside a scope (bootstrap logs, pre-request logs) — never throws.
Values
executionContextFactory— defaultExecutionContextFactoryimplementation. Stateless; import and call its methods directly, or inject via theExecutionContextFactoryinterface for testable composition.
Session presence is the auth signal
The toolkit treats ctx.session as the single source of truth for "this
operation is authenticated." Either session is present (authenticated) or
it isn't (anonymous / system / job) — never half-populated. Every toolkit
surface that reads auth-derived identity does this consistently:
@ValidateRequestinjectsscopeId/userIdinto validated payloads only whenctx.sessionis defined and the schema declares those keys.BaseWriteDaoreadsctx.session?.userIdforinserted_by/updated_byaudit columns; writes under system contexts land withundefinedaudit.ExecutionContextEnricherflattensctx.sessiontoscopeId/userIdfields on log entries — log shape stays flat even though the context groups, so dashboards and log queries keep their field names.
Consumer code applies the same discipline: check ctx.session once, then
read scopeId / userId off it. Avoid reconstituting half-states
(ctx.session?.scopeId && !ctx.session?.userId) — they can't happen by
construction.
Extension pattern
The base AuthSession is deliberately minimal (scopeId + userId). If
you need roles, permissions, a session id, an authenticated-at timestamp,
or any other product-shaped fields, extend by intersection in your
consumer project:
import type { AuthSession, ExecutionContext } from '@quilla-be-kit/execution-context';
// Pick whatever session shape fits your project.
type AppAuthSession = AuthSession & {
readonly sessionId: string;
readonly displayName: string;
readonly roles: readonly string[];
readonly authenticatedAt: Date;
};
type AppExecutionContext = ExecutionContext & {
readonly session?: AppAuthSession;
};
// Auth middleware constructs the enriched context:
const ctx: AppExecutionContext = {
...executionContextFactory.createBaselineContext({ correlationId }),
actorType: 'user',
session: {
scopeId: jwt.scope,
userId: jwt.sub,
sessionId: jwt.sid,
displayName: jwt.name,
roles: jwt.roles,
authenticatedAt: new Date(),
},
};
await provider.runWithContext(ctx, handler);Read sites cast once:
const ctx = provider.getContext() as AppExecutionContext;
if (ctx.session?.roles.includes('admin')) { /* ... */ }If your project has many read sites, wrap the provider once:
// Consumer-side helper
export function getAppContext(): AppExecutionContext {
return provider.getContext() as AppExecutionContext;
}Then the rest of the codebase uses getAppContext() with full typing.
Why not ship an opinionated full session type? Because sessions beyond
scopeId + userId vary too widely across services (displayName vs.
email vs. userType vs. tenant-role vs. scope-based permissions, etc.).
Picking a richer base nudges every consumer toward a shape most of them
don't need. The toolkit ships the minimal AuthSession as a contract for
its own surfaces (audit, validation, enrichment) and lets consumers own
the rest.
Design notes
- Throws on missing context, not silent fallback. Masking "forgot to run
inside
runWithContext" bugs with a default anonymous context is a substrate-grade anti-pattern. Callers that legitimately don't have one establish it explicitly viacreateBaselineContext()orcreateSystemContext(...). - No
ActorSession/permissionsin the base type. See extension pattern above. - Enricher returns
{}silently when the provider throws. Logs emitted outside a scope (bootstrap, scheduler, pre-auth middleware) should still succeed — they just don't carry execution-context fields. ActorTypecomes from@quilla-be-kit/ddd— same extensible union used byEventMetadata. Consistent vocabulary across the toolkit.
