-
Notifications
You must be signed in to change notification settings - Fork 0
Configuration
One file: app.config.ts at the repo root. There is no per-environment config directory, no config/production.ts, no .env.local cascade. Environment differences are env vars, validated once at boot.
As of 2026-08-22. Stable API — semver from here (Upgrading). Field names are covered by semver: renaming or removing one needs a major, and the entry in Upgrading names the edit. There is no codemod — x upgrade is a PLANNED_COMMANDS entry (packages/cli/src/cmd-planned.ts), so every migration below is a hand edit.
import { defineConfig, defineEnv } from '@ultimat3/core';
// Module scope, in `app.config.ts` itself: the file is imported at boot, so the env gate runs
// before any listener binds. There is no separate `env.ts` — one config file means one.
export const env = defineEnv({
APP_URL: { type: 'url' },
DATABASE_URL: { type: 'url' },
SESSION_SECRET: { type: 'string', secret: true },
});
export const config = defineConfig({
name: 'postly',
locales: ['en'],
defaultLocale: 'en',
defaultTimeZone: 'UTC',
defaultCurrency: 'USD',
// No connection string and no pool size: both are env (`DATABASE_URL`, `DATABASE_POOL_MAX`),
// read where the client is built, so the same image deploys to every environment.
database: { ssl: true },
cache: { driver: 'redis', urlEnv: 'REDIS_URL', tiers: ['memo', 'lru', 'shared'] },
jobs: { queues: ['postly-default'], concurrency: 8 },
pwa: { enabled: true, offline: 'runtime' },
});This example compiles. It did not until 2026-08-22 — it passed database: { urlEnv, poolSize }, two keys deleted from DatabaseConfig, so the first thing an agent copied off this page was TS2353. Every table below is now derived from the interfaces in packages/core/src/config.ts rather than curated beside them.
Everything derivable from code is not in this file — routes, actions, policies, jobs, tags all live in the generated x.manifest.json. Inspect the resolved config with x config show --json.
AppConfigInput (packages/core/src/config.ts) carries exactly fourteen keys As of 2026-08-22: name, locales, defaultLocale, defaultTimeZone, defaultCurrency, theme, auth, pwa, roles, database, cache, jobs, realtime, ai. That type is the contract, and every table below names only its members — a block with no key here (seo, budgets, mail, storage, otel) is not an app.config.ts field and its section says where the real knob is instead.
| field | type | default | notes |
|---|---|---|---|
name |
string |
required |
^[a-z][a-z0-9-]{1,63}$. Names the dev DB, the image, the queue prefix |
locales |
string[] |
['en'] |
BCP-47. Every locale needs a complete catalog or X_CATALOG_MISSING_KEYS
|
defaultLocale |
string |
'en' |
must appear in locales
|
defaultTimeZone |
IANA zone | 'UTC' |
display default only; a user's own tz column always wins |
defaultCurrency |
ISO 4217 | 'USD' |
default for Money formatting. Never a conversion rate |
theme.defaultMode |
'light' | 'dark' | 'system' |
'system' |
theme.tokens is the semantic token map; raw hex is a lint error in components |
roles |
Role[] |
every ROLE
|
which runtime roles this app runs. Empty is X_CONFIG_INVALID
|
There is no url field. The canonical origin is an env key the app reads at its point of use (APP_URL), so the same image deploys to every environment.
DatabaseConfig is two fields, deliberately — the connection itself is env, not config.
| field | type | default | notes |
|---|---|---|---|
database.driver |
'postgres' |
'postgres' |
one driver; postgresDriver() from @ultimat3/entity is its only implementation |
database.ssl |
boolean |
false |
true on managed Postgres |
database.urlEnv |
— | — |
Deleted 2026-08. @ultimat3/db's client.ts reads process.env['DATABASE_URL'] as a hardcoded literal, so a different key here could never be honoured. Migration: delete the key; the connection string is DATABASE_URL
|
database.poolSize |
— | — |
Deleted 2026-08. A second, non-functioning spelling of DATABASE_POOL_MAX, which baseClient() layers over the role profile and which works. Migration: delete the key; set DATABASE_POOL_MAX. The sizing rule is unchanged — the pool is per PROCESS, so keep replicas × poolMax under Postgres max_connections
|
database.schema |
— | — |
Deleted 2026-08. Nothing emits SET search_path. Migration: delete the key; there is no replacement, and entity() tables live in public
|
Wiring the three instead would have needed a tier-0 → tier-1 read the tier table forbids. Deleting is axiom 3 applied to configuration: a value that produces neither a build error nor a runtime effect is worse than no field, because an SRE sets poolSize: 3, redeploys, and nothing changes.
AuthConfig on app.config.ts is one field. Authorization is not here — it is Policies and authz, and the authentication policy is defineAuth(), below.
| field | type | default | notes |
|---|---|---|---|
auth.signInPath |
string | null |
null |
where a browser that failed auth: 'required' is sent. null keeps the redirect off, and that is the default on purpose: the framework may not invent one of its app's routes, and guessing /login would send every unauthenticated visitor to a 404. Null means the visitor gets the problem document — right for an agent, and what a browser got in production until this existed |
auth.afterSignInPath |
— | — |
Deleted in 8.0.0. Declared, defaulted and merged, and read by no file — an app that set /dashboard got whatever its own sign-in route already did. Migration: delete the key, and send the visitor where you mean from the sign-in route itself, which is the only code that can honour it |
The rest of authentication is defineAuth(), not app.config.ts (packages/auth/src/auth.ts). It takes an adapter and the policies, because each of them needs a value — a session store, a limiter, a clock — that a serialisable config field cannot carry:
defineAuth key |
shape | notes |
|---|---|---|
adapter |
AuthAdapter |
required; where users and sessions are read and written |
session |
Partial<SessionPolicy> |
absoluteTtlMs 30d, idleTtlMs 7d, cookieName '__Host-x_session', rotateOnPrivilegeChange true. The __Host- prefix is a browser-enforced contract — Secure, Path=/, no Domain — so a subdomain (or an XSS on one) cannot overwrite it |
password |
Partial<PasswordPolicy> |
|
rateLimit |
Partial<AuthRateLimitPolicy> |
scope: 'shared' must be matched by a limiter that says the same, or defineAuth refuses at boot rather than at 3am on the first spray |
limiter / orgLimiter
|
AuthLimiter |
omitted means one process' worth of state, i.e. maxAttempts × N for N replicas |
mfa |
Partial<AuthMfaPolicy> |
required is typed false and cannot be set true: both credential paths branch on user.mfaSecret, so a user who never enrolled would be locked out for good |
providers |
OAuthProviderId[] |
|
link |
OAuthLinkPolicy |
defaults to 'verified-email'
|
There is no auth.passkeys and no auth.trustedOrigins in either place As of 2026-08-22.
| field | type | default | notes |
|---|---|---|---|
jobs.driver |
— | — |
Deleted in 5.0.0. It accepted 'postgres' | 'redis' | 'nats' and was read by nothing: boot always built createPgDriver, so jobs: { driver: 'redis' } did not throw, did not warn, and silently gave you Postgres. Which driver runs is setJobDriver(driver) and only that — setJobDriver(createPgDriver({ executor })), or setJobDriver(createMemoryDriver()) in a test (Jobs and workflows) |
jobs.queues |
string[] |
['<name>-default'] |
derived from name, not the literal ['default']. A worker runs one pool per queue in WORKER_QUEUES. Empty is X_CONFIG_INVALID
|
jobs.concurrency |
number |
8 |
per pool, per process. Below 1 is X_CONFIG_INVALID
|
jobs.maxAttempts |
number |
5 |
per-job retry overrides it |
jobs.backoff |
'exponential' | 'fixed' |
'exponential' |
two values, not three — there is no 'linear'
|
jobs.visibilityTimeoutMs |
number |
30000 |
milliseconds, not a duration string. Lease length; expiry is how a killed worker's job resumes |
There is no jobs.retry object, no jobs.visibilityTimeout and no jobs.retention block As of 2026-08-22 — JobsConfig is { queues, concurrency, maxAttempts, backoff, visibilityTimeoutMs } and the flat spellings above are the whole surface.
RealtimeConfig is four fields and no more As of 2026-08-19 (packages/core/src/config.ts:86):
| field | type | default | notes |
|---|---|---|---|
realtime.enabled |
boolean |
false |
off unless the app turns it on |
realtime.tier |
'channels' | 'live-queries' | 'local-first' |
'channels' |
names, not numbers. channels and live-queries ship; local-first is not in 4.0.0 (Realtime) |
realtime.transport |
'memory' | 'nats' | 'redis' |
'memory' |
memory = in-process, single node, dev and small deploys. redis type-checks and is never built — selectTransport resolves in-process or NATS only |
realtime.urlEnv |
string |
— | the env key name, never a URL. Required unless memory; missing → X_CONFIG_INVALID
|
At runtime the transport is chosen by NATS_URL rather than by this field — the config documents intent, the env decides (17-scale-ladder.md).
realtime.heartbeatMs is gone, As of 2026-08-19. It was declared here with a default of
15 000 and read by nothing; the socket beat is the client's new LiveClient({ heartbeatMs }) —
browser code, which cannot read server config — and the presence beat is derived
(PresenceRegistry.heartbeatMs is max(1000, floor(ttlMs / 3))). Removing the key from your
config is a typecheck fix, not a runtime one: section() copies every own key of the patch and
validate() checks only the fields it names, so a leftover heartbeatMs is silently kept at
runtime. It fails at x verify's typecheck step as TS2353, excess property on
Input<RealtimeConfig> — and an app that builds its config into a variable before passing it loses
excess-property checking and gets no error at all → Known gaps.
realtime.limits.*, realtime.changeBuffer.* and realtime.drain.* are not app.config.ts fields As of 2026-08-19, and never were — RealtimeConfig is { enabled, tier, transport, urlEnv } (packages/core/src/config.ts). Writing one is a typecheck failure, not a silent no-op, because the input type is Input<RealtimeConfig> and an unknown key is an excess property. The caps and the ring are constructor options, passed where the node is built:
| Option | Where | Default | Effect |
|---|---|---|---|
maxPerSocket |
new LiveQueryRegistry({ … }) |
128 |
subscriptions per socket. Exceeded → X_SUBSCRIPTION_LIMIT, scope socket. Always applies
|
maxPerTenant |
same | none | subscriptions per tenant. Exceeded → X_SUBSCRIPTION_LIMIT, scope tenant
|
tenantOf |
same | none |
(actor) => tenantId | null. Required for maxPerTenant to do anything — assertCapacity returns early when either is absent |
maxEntries |
same | 10000 |
distinct (query, input) pairs this node will hold — a qid derives from client-chosen input, so each one is a matcher and a row window. Exceeded → X_SUBSCRIPTION_LIMIT, scope node
|
maxTopicsPerSocket |
new ChannelHub({ … }) |
64 |
channel topics one socket may join. Exceeded → X_SUBSCRIPTION_LIMIT, scope socket
|
maxTopicsPerNode |
same | 10000 |
distinct topics this node bridges, one transport subscription each. Exceeded → X_SUBSCRIPTION_LIMIT, scope node
|
maxBufferedBytes |
createSyncNode({ … }) |
1 MiB, exported as DEFAULT_MAX_BUFFERED_BYTES
|
outbound bytes queued on one socket before this node starts dropping its frames. A live-query patch is re-snapshotted; a channel frame is lost → Realtime |
maxDroppedFrames |
same | 32 |
drops one socket may take before it is closed with 1013 (overloaded), reason backpressure
|
capacity |
new RingChangeBuffer({ … }) |
1024 |
retained patches per query hash; a reconnect inside the window is a delta, not a snapshot |
maxQueries |
same | 4096 |
retained query hashes, least-recently-written dropped first |
The per-tenant cap is reachable but not wired As of 2026-08. It reads socket.actor, which was hardcoded null until WebSocket authentication landed, so it could not fire at all; and the boot (dev-roles.ts) passes source: new RingChangeBuffer() and no caps. So a per-tenant limit is not protecting you today whatever you configure — set maxPerTenant and tenantOf on the registry yourself if you need one. The per-socket cap needs nothing and is enforced at its default.
CacheConfig is four flat fields. See Caching and invalidation.
| field | type | default | notes |
|---|---|---|---|
cache.driver |
'memory' | 'redis' |
'memory' |
'redis' requires cache.urlEnv, or X_CONFIG_INVALID at boot |
cache.urlEnv |
string |
— | the env key name, never a URL |
cache.defaultTtlMs |
number |
60000 |
milliseconds, not a duration string |
cache.tiers |
CacheTier[] |
['memo', 'lru'] |
'memo' | 'lru' | 'shared' | 'isr' | 'cdn'; order is fixed regardless of listing order |
The per-tier byte caps and TTLs are constructor options, not config — the same shape as the realtime caps above. cache.memo.maxBytes, cache.lru.maxBytes, cache.redis.* and cache.ttl.* are not fields and never were; writing one is TS2353, excess property on Input<CacheConfig>.
| Option | Where | Default | Effect |
|---|---|---|---|
maxBytes |
createLruTier({ … }) |
64 MiB | byte budget for the whole tier; a single value over it is X_CACHE_TOO_LARGE rather than a silent drop |
defaultTtlMs |
same | 60_000 |
applied when a set omits ttlMs
|
jitterFraction |
same | DEFAULT_TTL_JITTER_FRACTION |
TTL spread in [0, 1); 0 disables it, which is how a stampede is reproduced in a test |
clock / rng
|
same | system | injected so a jittered expiry is deterministic |
Two vocabularies name the tiers and no code maps one onto the other, As of 2026-08-22. CacheTier — the config union this table documents — is memo | lru | shared | isr | cdn (packages/core/src/config.ts). TIER_ORDER, which is what sortTiers actually orders a stack by, is request-memo | lru | redis | cdn (packages/cache/src/tiers.ts). memo/request-memo and shared/redis are the same rung spelled twice, and isr appears in the config union and in no TierName. Read the order off TIER_ORDER; the config union is what defineConfig accepts.
The purge driver is selected from the environment, not from a config field — the same law the
mail transports follow, and for the same reason: nothing loads app.config.ts's contents at
runtime, so one image deploys to every environment.
| Key | Selects | Notes |
|---|---|---|
FASTLY_API_TOKEN + FASTLY_SERVICE_ID
|
Fastly | batch surrogate-key purge, 256 keys per call |
CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE_ID
|
Cloudflare | cache-tag purge, 30 tags per call, Enterprise zones |
The surrogate keys are the tags — post, post:1 — so the edge purges exactly what
invalidates: [tag.post] busts.
| Failure | Code | Raised by | Lands |
|---|---|---|---|
| both pairs set — two CDNs claim one purge | X_CONFIG_INVALID |
selectPurgeDriver |
boot |
| half a pair — a token with no id, or an id with no token | X_CONFIG_INVALID |
selectPurgeDriver |
boot |
the provider refused — 401, 429, success: false
|
X_CACHE_PURGE_FAILED |
the purge driver |
report.errors, never the write |
Half a pair is refused because "no CDN" is the one wrong reading — a deployment then ships believing it purges. Both refusals name the keys that are actually set, never their values.
X_CONFIG_INVALID covers a configuration that cannot boot, env or app.config.ts
(Env vars). X_CACHE_PURGE_FAILED is provider refusal only — never a configuration
problem, and never fatal to the write that triggered the bust.
x dev prints which one it installed — cdn=none, or cdn=external(fastly via FASTLY_API_TOKEN). The env key is reported, never its value.
offline is an OfflineStrategy string, not an object. Three booleans, one strategy — every field is optional and every default is off.
pwa: { enabled: true, offline: 'runtime' },| field | type | default | notes |
|---|---|---|---|
pwa.enabled |
boolean |
false |
off means no service worker is generated at all |
pwa.offline |
'precache' | 'runtime' | 'network-only' |
'network-only' |
the app-wide strategy; a route may narrow its own |
pwa.backgroundSync |
boolean |
false |
wires the SW sync event to the mutator queue |
pwa.push |
boolean |
false |
generates the SW handler, the subscription action and the send job |
pwa.installPrompt |
— | — |
Deleted in 8.0.0. Declared, defaulted and merged, and read by nothing — @ultimat3/pwa's createInstallController is real and complete and no code ever threaded this flag into it, so both tracked apps and every scaffolded app carried a switch with no wire. Migration: delete the key and call createInstallController from your own affordance (PWA and offline) |
Not an app.config.ts block. There is no seo key on AppConfigInput. @ultimat3/seo's builders take their options at the call site, from the route that renders them:
| Call | Options | Notes |
|---|---|---|
buildRobots(config) |
baseUrl, environment?, groups?, sitemaps?, extra?
|
fail-closed: only the exact string production opts a deploy into indexing, so staging, a laptop, a typo and an unset ULTIMATE_ENV all emit Disallow: / — a branch deploy that gets indexed outranks and cannibalises the real site. environment omitted resolves from ULTIMATE_ENV, and an unreadable one falls back to core's default rather than 500ing a robots.txt
|
buildSitemap(routes, options) |
baseUrl, locales?, localizePath?, defaultLocale?, maxUrls?, lastmod?
|
splits into an index past SITEMAP_MAX_URLS (50,000). maxUrls must be a positive integer — 0 never advances the chunk cursor and used to allocate empty slices until the box ran out of memory |
baseUrl is the argument every one of them takes, so the canonical origin stays an env key the app reads (APP_URL) and never a config field. There is no seo.lighthouse gate and no seo.ogImage renderer As of 2026-08-22.
Not an app.config.ts block. There is no budgets key on AppConfigInput and no per-surface default table. A budget is declared per route, as budget on defineRoute — RouteBudget in packages/render/src/route.ts:
| field | type | notes |
|---|---|---|
budget.js |
string |
'40kb' — measured from the real bundle graph, not the source size |
budget.css |
string |
|
budget.lcp |
number |
milliseconds, median of N headless runs |
budget.cls |
number |
|
budget.tbt |
number |
Every field is optional, and a declared budget with no measurement is itself a failure — the finding names the import chain that blew it, because "your bundle got bigger" is not actionable for a human or an agent. The 0 kb JS baseline on site/ is not a default in a table: a site/ route off hydrate: 'never' with no budget.js is refused at registration, which is structural rather than aspirational.
The precache warning is its own number and not a budget: DEFAULT_PRECACHE_WARN_BYTES is 5 MiB, overridable per build as warnBytes (packages/pwa/src/precache.ts).
Not an app.config.ts block. The transport is selected by environment, like every other
external service — an unset variable means the embedded default, so the same image deploys
everywhere and no credential is ever committed.
| env key | selects | notes |
|---|---|---|
| (none set) | memory | caught, never sent; the /_x mail panel reads this outbox |
SMTP_URL |
SMTP |
smtps://user:pass@host:465, or smtp://host:587 for STARTTLS |
RESEND_API_KEY |
Resend | one POST /emails per message, with an Idempotency-Key
|
MAIL_FROM |
— | required by both transports. Name <addr>; also the envelope sender and the Message-ID domain |
MAIL_POOL_SIZE |
— | SMTP connections open at once. Default 4, whole number ≥ 1 |
Setting SMTP_URL and RESEND_API_KEY is X_CONFIG_INVALID: a process delivers through
exactly one transport, and picking a winner would send half of an operator's mail the wrong way.
A transport without MAIL_FROM is refused at boot rather than on the first send.
x dev prints which one it installed — mail=embedded, or mail=external(smtp via SMTP_URL).
The env key is reported, never its value, because SMTP_URL carries a password.
Not an app.config.ts block. There is no storage key on AppConfigInput. Storage is defineStorage(), called from app.config.ts, and it declares named disks (Laravel's model) rather than a driver and a bucket — so disk('uploads').put(…) never changes when local becomes s3:
defineStorage({ disks: { uploads: localDriver({ root: '.storage/uploads' }) } });
defineStorage key |
shape | notes |
|---|---|---|
disks |
Record<string, StorageDriver> |
required and non-empty. Drivers are localDriver({ root }) and s3Driver({ … }) (Bun.s3) |
default |
string |
the disk disk() resolves with no name. Defaults to the first declared disk; naming one that is not declared is X_CONFIG_INVALID
|
Two disks may not share one driver instance: a driver learns its disk name at boot (registerAs) and mints signed URLs under it, so one instance told two names would 404 every URL it wrote under the first. Two disks over one root are two localDriver() calls. There is no storage.dir and no storage.bucket field.
Not an app.config.ts block. Tracing is always on, not a flag, and the wire is configured by the standard OpenTelemetry environment variables — which is what lets a collector be attached to a running image without a rebuild:
| var | notes |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP/HTTP JSON only, port :4318. Absent = spans still recorded, exported nowhere. Invalid → X_OTLP_ENDPOINT_INVALID
|
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT / ..._METRICS_ENDPOINT
|
per-signal override |
OTEL_EXPORTER_OTLP_PROTOCOL |
anything but http/json → X_OTLP_PROTOCOL_UNSUPPORTED, naming :4318. gRPC (:4317) needs HTTP/2 and protobuf and is out of scope |
OTEL_EXPORTER_OTLP_HEADERS |
percent-decoded, so %zz is X_OTLP_HEADERS_INVALID rather than a bare URIError at exporter construction. The header key appears in the cause and the fix; the value never does — it is the collector's credential |
OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG
|
read at the first span, never at module scope. configureTelemetry({ sampler }) is the programmatic form |
An empty spanId means "no inbound decision" and every reader honours it: a synthesised parent used to make the ratio sampler inherit a bit nobody sent, which exported every HTTP root span at every ratio.
Two fields, and ai.mcp is where the app's own MCP surface is configured — there is no top-level mcp block.
| field | type | default | notes |
|---|---|---|---|
ai.mcp.expose |
boolean |
true |
the app's own MCP surface. Actions still opt in per action mcp.expose
|
ai.mcp.path |
string |
'/mcp' |
where the HTTP transport mounts. Never bound in ROLE=web
|
ai.modelEnv |
— | — |
Deleted in 8.0.0. It named the env key holding the model id "so no model string is baked into the image", and its only reader was defineConfig's own merge: @ultimat3/ai reads env for API keys only and the model is request.model ?? DEFAULT_MODEL, a compile-time constant. The one thing the key existed to prevent is what it delivered. Migration: delete the key and pass model on the request, reading your own env key if you want one |
ai.models, ai.fallback, ai.cache and ai.budget are per-llm() declarations, not config (MCP and AI). i18n has no config block either: top-level locales and defaultLocale are the whole surface.
One typed schema, declared with defineEnv at module scope in app.config.ts, validated at boot. There is no env.ts — the one config file is also the one env gate. A missing or malformed key fails in ~40ms with X_ENV_MISSING, every offender named in one error — never a 500 an hour later.
| var | roles | required | notes |
|---|---|---|---|
ROLE |
all | no — default web
|
exactly web | sync | worker | scheduler | migrate | replicator. There is no all. Invalid → X_ROLE_UNKNOWN from the production boot path, X_ROLE_INVALID from assertRole() inside the framework |
PORT |
web, sync
|
no — default 3000
|
the TCP port the role binds. Empty, non-numeric or outside 0–65535 → X_PORT_INVALID, refused rather than defaulted past, because a web role that quietly bound 3000 fails the platform's health probe with nothing in the log that names the cause |
ULTIMATE_ENV |
all | no — default development
|
development | test | staging | production, read by resolveEnvironment(). NODE_ENV is a fallback only, and is never policed |
DATABASE_URL |
all | yes | |
APP_URL |
web, sync
|
yes | the canonical origin. An app-read key, not a config field — declare it in defineEnv
|
SESSION_SECRET |
web, sync
|
yes | >=32 chars |
WORKER_QUEUES |
worker |
no — default default
|
comma-separated; one pool per name |
NATS_URL |
sync, replicator
|
no — unset is in-process fanout | one node only; a second replica shares nothing. Unreachable → X_TRANSPORT_UNAVAILABLE at boot, not at readiness |
NATS_KV_BUCKET |
sync |
no — default x_presence
|
the JetStream KV bucket presence lives in. [a-zA-Z0-9_-]+; anything else is X_TRANSPORT_PROTOCOL at boot |
REPLICATION_URL |
replicator |
no — defaults to DATABASE_URL
|
the connection the WAL is read from; this role must have REPLICATION privilege |
REPLICATION_SLOT |
replicator |
no — default x_replicator
|
logical replication slot name |
REPLICATION_PUBLICATION |
replicator |
no — default x_changes
|
the pgoutput publication the slot decodes |
REDIS_URL |
any tier-3 cache user | if redis in cache.tiers
|
|
BUILD_ID |
all | set by x build
|
content hash. Never a timestamp, never latest
|
DRAIN_TIMEOUT |
all | no — default 30s
|
must be <= the orchestrator's stop_grace_period
|
LOG_LEVEL |
all | no — default info
|
debug | info | warn | error |
OTEL_EXPORTER_OTLP_ENDPOINT |
all | no | the OTLP collector. There is no otel config block for it to override — see otel
|
Rules:
| Rule | Detail |
|---|---|
| Secrets are env or a mounted file | the framework never talks to a vendor secret API (axiom 7) |
env.X reads through defineEnv's schema |
a declared key that is missing or malformed is X_ENV_MISSING at boot, every offender in one error. A process.env read outside the schema is a lint error, never a runtime one |
X_CONFIG_INVALID is env and app.config.ts
|
one code for a configuration that cannot boot: what defineConfig's own validation throws — a bad locale, an unknown time zone, jobs.concurrency < 1, a realtime.transport other than memory with no realtime.urlEnv, cache.driver: 'redis' with no cache.urlEnv — and any env combination no boot can resolve, thrown by the selector that reads it. Both CDN pairs or half a pair (selectPurgeDriver), SMTP_URL + RESEND_API_KEY or a transport with no MAIL_FROM (selectMailDriver), or REPLICATION_URL naming a different host, port or database than DATABASE_URL (selectChangeFeed) |
X_ENV_MISSING is one key, X_CONFIG_INVALID is the shape |
absent or malformed key → X_ENV_MISSING at the defineEnv gate. Keys that each parse but contradict each other → X_CONFIG_INVALID. The two never overlap |
| No runtime mutation | config is frozen after defineConfig; there is no setConfig
|
| Same image, all environments | only env differs. That is what makes staging a real rehearsal (Deployment) |
Four names, one env var, and a spelling the framework does not use is a refusal rather than a guess.
import { isLocal, isProduction, resolveEnvironment, tryResolveEnvironment } from '@ultimat3/core';
resolveEnvironment(); // 'development' | 'test' | 'staging' | 'production'
resolveEnvironment({ fallback: 'production' });
resolveEnvironment({ env: someRecord });
tryResolveEnvironment() ?? 'development'; // the same, `undefined` instead of the throw| Concern | Behaviour |
|---|---|
| Precedence |
ULTIMATE_ENV if set and non-empty → NODE_ENV only if it is one of the four → fallback → 'development'
|
Invalid ULTIMATE_ENV
|
throws X_ENVIRONMENT_INVALID — prod, dev and preview are all refusals |
Invalid NODE_ENV
|
never throws. CI images legitimately set it to anything, so it is a fallback that is silently ignored, not a second gate |
isProduction() |
=== 'production' |
isLocal() |
development or test. staging is deliberately excluded — staging is a real rehearsal |
tryResolveEnvironment() |
the same resolution, undefined instead of the throw — and undefined means exactly one thing, an unrecognised ULTIMATE_ENV. For a caller that must answer rather than fail: ULTIMATE_ENV is not in the env schema, so nothing validates it at boot and a robots.txt render is routinely its first reader, where a typo would 500 the one response whose body was already going to be Disallow: /. It names no fallback of its own; the caller does |
@ultimat3/core is the only reader of ULTIMATE_ENV and Environment is the only spelling of a
deploy, As of 2026-08. @ultimat3/seo exported a second resolveEnvironment with its own union
through 1.2.0; as of 2.0.0 it exports neither that nor SeoEnvironment, and its 'preview' is
'staging' → Known gaps.
import { renderEnvExample, assertEnvExample, ENV_EXAMPLE_PATH } from '@ultimat3/core';
await Bun.write(ENV_EXAMPLE_PATH, renderEnvExample(schema)); // '.env.example'
assertEnvExample(schema, await Bun.file(ENV_EXAMPLE_PATH).text()); // throws X_ENV_EXAMPLE_DRIFTrenderEnvExample(schema, { extras }) returns the whole file, deterministic in declaration order. Per key it writes the description, then an annotation line — required|optional · <type or enum values> · secret · role a/b — then KEY=<example>. A secret: true key's example is always empty, even when the declaration has a default. extras are appended as commented # NAME= lines.
assertEnvExample throws X_ENV_EXAMPLE_DRIFT when the file is missing a declared key. Extra keys are reported but never fatal — an example may document more than the schema requires.
Nothing calls it for you. There is no verify step, CLI command or boot hook that checks the example; call it from a test of your own → Known gaps.
Which files are read at boot: .env and .env.<mode> always, plus .env.local unless the mode is test. Mode is production or test verbatim, otherwise development — there is no .env.staging.
A value that redacts by value, so it stays redacted under a key nobody thought to add to a deny-list.
import { secret, revealSecret, isSecret } from '@ultimat3/core';
const token = secret(process.env.API_TOKEN, 'API_TOKEN');
`${token}`; // '[redacted]'
JSON.stringify({ token }); // '{"token":"[redacted]"}'
logger.info('boot', { token }); // token=[redacted]
revealSecret(token); // the real string — the one call that unwraps| Surface | Redacted |
|---|---|
toString() |
✅ |
toJSON() |
✅ |
Symbol.toPrimitive |
✅ — template literals and coercion |
| the Node inspect symbol | ✅ — console.log, util.inspect
|
| the framework logger | ✅ — checked before every other branch, at any depth, under any key |
spread / Object.entries / structured clone |
✅ — only label is enumerable, so the value cannot ride out |
Frozen and non-configurable. revealSecret(value) and revealOptionalSecret(value) are the only ways out; isSecret(value) is a structural brand check, so it survives two copies of @ultimat3/core in one tree.
Key-name redaction still exists and is separate: defineEnv() registers every secret: true key with the logger unless you pass { redact: false }. And checkEnv() returns real values in report.values — anything that prints a report must pass them through maskedEnvValues(schema, values) first. That is the bug 1.1.0 fixed: { dsn: 'postgres://user:pw@host/db' } printed the credential, because redaction was by key name and dsn was not on the list.
x doctor --json # env + connectivity + version checks
x verify --json # the gate
x config show is planned, not shipped — it throws X_NOT_IMPLEMENTED naming x manifest --json as the closest thing today → CLI reference.
Error shapes: Error codes. Symptom-first fixes: Troubleshooting. Metrics, spans and logs: Observability.
Ultimate — v8.0.0 As of 2026-08. Stable API, semver from here. MIT licensed. What npm serves is npm view @ultimat3/core version, never this line.
This footer is the only page that stamps a version. It renders under every wiki page, so one release bumps one line; a stamp on a second page is 46 hand-copies of one fact, and every one of them goes stale on the next tag.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Building your own base
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
- Realtime
- Caching and invalidation
- Batching and preloading
- N+1 detection
- PWA and offline
- MCP and AI
- Agents
- Admin dashboard
- Scraping
Cross-cutting
- I18n
- Theming
- UI components
- Timezones and dates
- Money
- Resource management
- Migrations and backfills
- Testing
Reference