@ultimat3/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # 🧱 @ultimat3/core
2
+
3
+ Tier 0. The foundation every other Ultimate package imports and none of them may bypass.
4
+ Zero dependencies, zero `@ultimat3/*` imports.
5
+
6
+ | Owns | Module |
7
+ |---|---|
8
+ | `UltimateError`, the 3-line rendering, `--json` shape | `errors.ts` |
9
+ | code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` |
10
+ | `Result<T, E>` for boundaries where throwing is wrong | `result.ts` |
11
+ | request context on `AsyncLocalStorage` | `context.ts` |
12
+ | `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
13
+ | typed env validated at boot | `env.ts` |
14
+ | `defineConfig()` for `app.config.ts` | `config.ts` |
15
+ | runtime roles + `ROLE` resolution | `roles.ts` |
16
+ | `Clock` — the only source of "now" | `clock.ts` |
17
+ | UUIDv7, nanoid, branded ids | `ids.ts` |
18
+ | structured JSON logging + redaction | `logger.ts` |
19
+ | OTel-shaped spans, always on, no-op by default | `telemetry.ts` |
20
+ | graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
21
+ | the sockets this process opened, so a self-request is not egress | `listeners.ts` |
22
+ | `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
23
+ | the registrar table one same-tier package reaches another through | `registrar.ts` |
24
+ | decode → resize → encode, the one image pipeline | `image/` |
25
+ | `assertNever`, `invariant` | `assert.ts` |
26
+
27
+ ## Errors are instructions
28
+
29
+ ```ts
30
+ throw new UltimateError({
31
+ code: 'X_DB_DRIFT',
32
+ cause: 'table "posts" has column "publish_at" not present in any migration',
33
+ fix: 'x db gen "add publish_at"',
34
+ });
35
+ ```
36
+
37
+ ```text
38
+ X_DB_DRIFT: schema differs from migrations
39
+ cause: table "posts" has column "publish_at" not present in any migration
40
+ fix: x db gen "add publish_at"
41
+ ```
42
+
43
+ `format()` is always 3 lines (`format({ docs: true })` adds a 4th). `toJSON()` is the `--json`
44
+ form: `{ code, title, cause, fix, docs, meta, stack }`. The title comes from the registry, so
45
+ the terminal, the browser overlay and `--json` cannot drift.
46
+
47
+ | Code | Subclass |
48
+ |---|---|
49
+ | `X_CONFIG_INVALID` | `ConfigInvalidError` |
50
+ | `X_ENV_MISSING` | `EnvMissingError` |
51
+ | `X_NOT_IMPLEMENTED` | `NotImplementedError` |
52
+ | `X_INTERNAL` | `InternalError` |
53
+
54
+ Your package declares its own codes in `src/errors.ts` and registers them once:
55
+ `registerErrorCodes({ X_DB_DRIFT: { title: 'schema differs from migrations' } })`.
56
+ Registering a code twice throws `X_ERROR_CODE_DUPLICATE`.
57
+
58
+ `isUltimateError()` is duck-typed on `Symbol.for('ultimate.error')`, not `instanceof` — that is
59
+ how `@ultimat3/schema` (tier 0, cannot import core) still produces matching errors.
60
+
61
+ ## Context
62
+
63
+ ```ts
64
+ const ctx = createContext({ actor: agentActor({ id: 'mcp-1', scopes: ['post:publish'] }) });
65
+ await runWithContext(ctx, async () => {
66
+ const { actor, locale, tz, logger } = useContext(); // throws X_NO_CONTEXT outside
67
+ await withChildContext({ locale: 'es' }, () => render());
68
+ });
69
+ ```
70
+
71
+ Concurrent requests never leak into each other. `ctx.logger` carries `requestId` + `traceId`
72
+ automatically; so does the root `logger` while a context is active. Add typed services by
73
+ augmenting `CtxServices`; reach late-bound ones with `useService<T>('mail')`.
74
+
75
+ A service that reads the actor (`ctx.posts`, scoped to `ctx.actor.orgId`) registers once with
76
+ `defineService('posts', (ctx) => ({ ... }))`, at import time. `createContext` and
77
+ `withChildContext` then build it fresh, bound to whichever actor they are constructing a ctx
78
+ for — importing the module that calls `defineService` is the registration, the same convention
79
+ `registerActions` uses. Passing `services: { posts: ... }` to `createContext` still works and
80
+ wins over a registered factory of the same name, for a test that wants to hand in a mock.
81
+
82
+ A factory runs again on **every** `createContext` / `withChildContext` call and is never cached,
83
+ because it closes over the ctx (actor, clock, tz) it was built for. `withChildContext` drops a
84
+ factory-managed name from what it carries forward on purpose: only an ad hoc service nobody
85
+ registered survives an actor swap unrebuilt.
86
+
87
+ ## Env fails once, completely
88
+
89
+ ```ts
90
+ export const env = defineEnv({
91
+ DATABASE_URL: { type: 'url', secret: true },
92
+ PORT: { type: 'port', default: 3000 },
93
+ STAGE: { type: 'enum', values: ['dev', 'staging', 'prod'] },
94
+ SENTRY_DSN: { type: 'url', required: false },
95
+ NATS_URL: { type: 'url', role: 'sync' }, // only required for ROLE=sync
96
+ });
97
+ ```
98
+
99
+ Every missing or malformed key is listed in one `X_ENV_MISSING`. `secret: true` keys are
100
+ redacted in logs and masked in `checkEnv()` output; `describeEnv()` emits declarations only,
101
+ safe for `x.manifest.json`. Omit `required` for required — `required: false` is the only
102
+ loosening.
103
+
104
+ ## Time, ids, telemetry, drain
105
+
106
+ - Never call `Date.now()`. Take a `Clock`; tests pass `frozenClock('2026-07-26T10:00:00Z')`.
107
+ - `uuid()` is UUIDv7: time-prefixed, monotonic within a millisecond, never backwards on clock
108
+ skew. `typedId<'post'>()` brands it so a post id cannot be passed where a user id is wanted.
109
+ - `withSpan('action.publishPost', fn)` is free until `configureTelemetry({ exporter })`.
110
+ Traces cross process boundaries via `traceparent()` / `parseTraceparent()` — Sentry, Honeycomb
111
+ and OTLP all plug in as a `SpanExporter`.
112
+ - `onShutdown(name, hook, { phase })` with phases `accept → inflight → close` under one
113
+ deadline; `readyzPayload()` flips to 503 the moment draining starts, `healthzPayload()` stays
114
+ 200 until stopped.
115
+ - Anything that opens a socket calls `markListening(server.url.origin)` and releases it on close.
116
+ That is what tells the sealed test network a loopback request is this process, not egress.
117
+
118
+ ## One cursor, everywhere
119
+
120
+ ```ts
121
+ encodeCursor({ scope, key: ['2026-01-01T00:00:00.000Z'], id: 'p_9' }); // base64url(body).hmac
122
+ decodeCursor(cursor, scope); // or X_CURSOR_INVALID
123
+ ```
124
+
125
+ Keyset pagination is the repo's, the read primitive's and the admin's — so the codec is here,
126
+ signed once and verified once, and a second one anywhere is the regression `cursor.ts` exists to
127
+ prevent. `scope` binds a cursor to one read: the entity plus its filters and sort order for a repo
128
+ page, `queryHash(name, input)` for a `query`, the resource for the admin. It is a **required**
129
+ argument to `decodeCursor` on purpose — an optional check is one a call site can forget, and a
130
+ forgotten one pages a listing with another read's cursor. Replaying one is `X_CURSOR_INVALID`,
131
+ never a silently wrong page.
132
+
133
+ | | |
134
+ |---|---|
135
+ | Signature | truncated HMAC-SHA256, compared in constant time |
136
+ | Secret | `ULTIMATE_CURSOR_SECRET`, or `configureCursorSigning()` at boot. Rotating it invalidates every open cursor |
137
+ | Signed, not encrypted | the client already has these rows; what it must not do is *invent* a position |
138
+ | `usesDevCursorSecret()` | true while the shipped dev key is in use |
139
+
140
+ ## One image pipeline, everywhere
141
+
142
+ ```ts
143
+ probeImage(bytes); // { format, width, height, mimeType }
144
+ transformImageBytes(bytes, { width: 640, format: 'jpeg', quality: 80 });
145
+ blurDataUrl(bytes); // 16px PNG data: URI, the LQIP
146
+ ```
147
+
148
+ `storage` variants, `seo` `<picture>` sources and `pwa` icons are the same three steps —
149
+ decode, resize, encode — with different numbers, so there is one implementation and no second
150
+ scaler for an icon to grow a halo in. Zero dependencies: no `sharp`, no native module.
151
+
152
+ | | |
153
+ |---|---|
154
+ | Decode / encode | PNG and JPEG. `canDecode()` / `canEncode()` publish the real list |
155
+ | Probe only | WebP, AVIF, GIF, SVG — measured from the header so `width`/`height` still inline and CLS stays 0 |
156
+ | Anything else | `X_IMAGE_UNSUPPORTED`, naming the format and pointing at an `ImageTransformDriver` |
157
+ | Ceiling | `MAX_IMAGE_PIXELS` (64MP), checked from the header **before** a byte is allocated |
158
+ | Determinism | same bytes + same spec → same output bytes. No clock, no randomness |
159
+
160
+ Adding a format is a decoder plus an entry in `DECODABLE_FORMATS` / `ENCODABLE_FORMATS` — never a
161
+ second dispatch. An unencodable `format` is refused from the spec alone, before the source is
162
+ decoded, so a request nothing can write never expands 64 megapixels first.
163
+
164
+ `image/` is the one place in core allowed past the 200-line target, and only there: a JPEG or PNG
165
+ codec is a single algorithm that does not split into smaller responsibilities without inventing
166
+ seams. Nothing else in it qualifies, which is why the segment headers (`jpeg-headers.ts`), the SVG
167
+ text parse (`probe-svg.ts`) and the colour grammar (`color.ts`) are their own files. The 500-line
168
+ hard ceiling applies to all of them.
169
+
170
+ `image/fixtures.ts` is byte-exact output from Pillow and ffmpeg on purpose: a codec that only round
171
+ trips against itself proves nothing. Never regenerate a fixture with our own encoder.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@ultimat3/core",
3
+ "version": "1.0.0",
4
+ "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/core"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {}
33
+ }
package/src/actor.ts ADDED
@@ -0,0 +1,81 @@
1
+ // Single responsibility: who is making the request. `agent` is a first-class kind because
2
+ // every action is also an MCP tool, and MCP callers go through the same authz as humans.
3
+
4
+ /** `agent` = an MCP/LLM caller acting on behalf of a user or a service. */
5
+ export type ActorKind = 'user' | 'service' | 'agent' | 'anonymous';
6
+
7
+ export const ACTOR_KINDS = ['user', 'service', 'agent', 'anonymous'] as const;
8
+
9
+ export interface Actor {
10
+ readonly kind: ActorKind;
11
+ readonly id: string;
12
+ readonly orgId?: string | undefined;
13
+ /** Application roles (`admin`, `editor`). Unrelated to the runtime `Role`. */
14
+ readonly roles: readonly string[];
15
+ /** Capability strings a policy can require (`post:publish`). */
16
+ readonly scopes: readonly string[];
17
+ }
18
+
19
+ export interface ActorInit {
20
+ readonly id: string;
21
+ readonly orgId?: string | undefined;
22
+ readonly roles?: readonly string[] | undefined;
23
+ readonly scopes?: readonly string[] | undefined;
24
+ }
25
+
26
+ const ANONYMOUS: Actor = Object.freeze({
27
+ kind: 'anonymous',
28
+ id: 'anonymous',
29
+ roles: Object.freeze([]),
30
+ scopes: Object.freeze([]),
31
+ });
32
+
33
+ function build(kind: ActorKind, init: ActorInit): Actor {
34
+ return Object.freeze({
35
+ kind,
36
+ id: init.id,
37
+ orgId: init.orgId,
38
+ roles: Object.freeze([...(init.roles ?? [])]),
39
+ scopes: Object.freeze([...(init.scopes ?? [])]),
40
+ });
41
+ }
42
+
43
+ export function userActor(init: ActorInit): Actor {
44
+ return build('user', init);
45
+ }
46
+
47
+ export function serviceActor(init: ActorInit): Actor {
48
+ return build('service', init);
49
+ }
50
+
51
+ /** An MCP or LLM caller. `orgId` and `scopes` are mandatory in practice — authz is identical. */
52
+ export function agentActor(init: ActorInit): Actor {
53
+ return build('agent', init);
54
+ }
55
+
56
+ export function anonymousActor(): Actor {
57
+ return ANONYMOUS;
58
+ }
59
+
60
+ export function isActorKind(value: unknown): value is ActorKind {
61
+ return typeof value === 'string' && (ACTOR_KINDS as readonly string[]).includes(value);
62
+ }
63
+
64
+ export function isAnonymous(actor: Actor): boolean {
65
+ return actor.kind === 'anonymous';
66
+ }
67
+
68
+ export function hasRole(actor: Actor, role: string): boolean {
69
+ return actor.roles.includes(role);
70
+ }
71
+
72
+ export function hasScope(actor: Actor, scope: string): boolean {
73
+ return actor.scopes.includes(scope);
74
+ }
75
+
76
+ /** Log/trace-safe identity — no email, no token, stable across surfaces. */
77
+ export function actorLabel(actor: Actor): string {
78
+ return actor.orgId === undefined
79
+ ? `${actor.kind}:${actor.id}`
80
+ : `${actor.kind}:${actor.id}@${actor.orgId}`;
81
+ }
package/src/assert.ts ADDED
@@ -0,0 +1,44 @@
1
+ // Single responsibility: compile-time-backed runtime assertions. Exhaustive switches and
2
+ // invariants both fail with a real error code, never a bare Error.
3
+
4
+ import { UltimateError } from './errors';
5
+
6
+ export interface InvariantOptions {
7
+ readonly docs?: string | undefined;
8
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
9
+ }
10
+
11
+ /**
12
+ * Put in the `default` branch of a switch over a union. Adding a union member becomes a
13
+ * type error at the call site instead of a silent fallthrough at runtime.
14
+ */
15
+ export function assertNever(value: never, fix?: string): never {
16
+ throw new UltimateError({
17
+ code: 'X_UNREACHABLE',
18
+ cause: `unhandled variant: ${JSON.stringify(value) ?? String(value)}`,
19
+ fix: fix ?? 'add a case for the variant named in cause',
20
+ meta: { value },
21
+ });
22
+ }
23
+
24
+ export function invariant(
25
+ condition: unknown,
26
+ code: string,
27
+ cause: string,
28
+ fix: string,
29
+ options?: InvariantOptions,
30
+ ): asserts condition {
31
+ if (condition) return;
32
+ throw new UltimateError({
33
+ code,
34
+ cause,
35
+ fix,
36
+ docs: options?.docs,
37
+ meta: options?.meta,
38
+ });
39
+ }
40
+
41
+ /** `invariant` with the generic code, for checks that have no dedicated code yet. */
42
+ export function assert(condition: unknown, cause: string, fix: string): asserts condition {
43
+ invariant(condition, 'X_INVARIANT', cause, fix);
44
+ }
package/src/clock.ts ADDED
@@ -0,0 +1,51 @@
1
+ // Single responsibility: the only source of "now" in the framework. Everything time-related
2
+ // takes a `Clock` so tests freeze time instead of sleeping. Nothing else may call `Date.now()`.
3
+
4
+ export interface Clock {
5
+ /** Wall-clock instant. Always UTC-backed; format at the edge with an explicit IANA tz. */
6
+ now(): Date;
7
+ /** Monotonic milliseconds — safe for durations, unaffected by wall-clock jumps. */
8
+ monotonic(): number;
9
+ }
10
+
11
+ export interface FrozenClock extends Clock {
12
+ /** Move wall-clock and monotonic time forward by `ms`. */
13
+ advance(ms: number): void;
14
+ set(at: Date | number | string): void;
15
+ }
16
+
17
+ export const systemClock: Clock = Object.freeze({
18
+ now(): Date {
19
+ return new Date();
20
+ },
21
+ monotonic(): number {
22
+ return performance.now();
23
+ },
24
+ });
25
+
26
+ function toEpochMs(at: Date | number | string): number {
27
+ if (at instanceof Date) return at.getTime();
28
+ if (typeof at === 'number') return at;
29
+ return new Date(at).getTime();
30
+ }
31
+
32
+ /** A clock stuck at `at` until `advance()` is called. Monotonic starts at 0. */
33
+ export function frozenClock(at: Date | number | string = 0): FrozenClock {
34
+ let epochMs = toEpochMs(at);
35
+ let mono = 0;
36
+ return {
37
+ now(): Date {
38
+ return new Date(epochMs);
39
+ },
40
+ monotonic(): number {
41
+ return mono;
42
+ },
43
+ advance(ms: number): void {
44
+ epochMs += ms;
45
+ mono += ms;
46
+ },
47
+ set(next: Date | number | string): void {
48
+ epochMs = toEpochMs(next);
49
+ },
50
+ };
51
+ }
package/src/config.ts ADDED
@@ -0,0 +1,265 @@
1
+ // Single responsibility: `app.config.ts` — the one config file. Deeply optional with real
2
+ // defaults, validated eagerly, and composable so a big app can split it across `config/*.ts`
3
+ // without inventing a second config mechanism.
4
+
5
+ import { ConfigInvalidError } from './errors';
6
+ import { ROLES, type Role } from './roles';
7
+
8
+ export type ThemeMode = 'light' | 'dark' | 'system';
9
+ export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
10
+ export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn';
11
+ export type JobsDriver = 'postgres' | 'redis' | 'nats';
12
+ export type RealtimeTier = 'channels' | 'live-queries' | 'local-first';
13
+ export type RealtimeTransport = 'memory' | 'nats' | 'redis';
14
+
15
+ export interface ThemeConfig {
16
+ readonly defaultMode: ThemeMode;
17
+ /** Semantic design tokens. Raw hex is a lint error in components, never here. */
18
+ readonly tokens: Readonly<Record<string, string>>;
19
+ }
20
+
21
+ export interface PwaConfig {
22
+ readonly enabled: boolean;
23
+ readonly offline: OfflineStrategy;
24
+ readonly installPrompt: boolean;
25
+ readonly backgroundSync: boolean;
26
+ readonly push: boolean;
27
+ }
28
+
29
+ export interface DatabaseConfig {
30
+ readonly driver: 'postgres';
31
+ /** Env key holding the connection string — never the string itself. */
32
+ readonly urlEnv: string;
33
+ readonly poolSize: number;
34
+ readonly ssl: boolean;
35
+ readonly schema: string;
36
+ }
37
+
38
+ export interface CacheConfig {
39
+ readonly driver: 'memory' | 'redis';
40
+ readonly urlEnv: string | undefined;
41
+ readonly defaultTtlMs: number;
42
+ readonly tiers: readonly CacheTier[];
43
+ }
44
+
45
+ export interface JobsConfig {
46
+ readonly driver: JobsDriver;
47
+ readonly queues: readonly string[];
48
+ readonly concurrency: number;
49
+ readonly maxAttempts: number;
50
+ readonly backoff: 'exponential' | 'fixed';
51
+ readonly visibilityTimeoutMs: number;
52
+ }
53
+
54
+ export interface RealtimeConfig {
55
+ readonly enabled: boolean;
56
+ readonly tier: RealtimeTier;
57
+ readonly transport: RealtimeTransport;
58
+ readonly urlEnv: string | undefined;
59
+ readonly heartbeatMs: number;
60
+ }
61
+
62
+ export interface McpConfig {
63
+ readonly expose: boolean;
64
+ readonly path: string;
65
+ }
66
+
67
+ export interface AiConfig {
68
+ readonly mcp: McpConfig;
69
+ /** Env key for the model id, so no model string is baked into the image. */
70
+ readonly modelEnv: string | undefined;
71
+ }
72
+
73
+ export interface AppConfig {
74
+ readonly name: string;
75
+ readonly locales: readonly string[];
76
+ readonly defaultLocale: string;
77
+ readonly defaultTimeZone: string;
78
+ readonly defaultCurrency: string;
79
+ readonly theme: ThemeConfig;
80
+ readonly pwa: PwaConfig;
81
+ readonly roles: readonly Role[];
82
+ readonly database: DatabaseConfig;
83
+ readonly cache: CacheConfig;
84
+ readonly jobs: JobsConfig;
85
+ readonly realtime: RealtimeConfig;
86
+ readonly ai: AiConfig;
87
+ }
88
+
89
+ type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
90
+
91
+ export interface AiConfigInput extends Input<Omit<AiConfig, 'mcp'>> {
92
+ readonly mcp?: Input<McpConfig> | undefined;
93
+ }
94
+
95
+ export interface AppConfigInput {
96
+ readonly name: string;
97
+ readonly locales?: readonly string[] | undefined;
98
+ readonly defaultLocale?: string | undefined;
99
+ readonly defaultTimeZone?: string | undefined;
100
+ readonly defaultCurrency?: string | undefined;
101
+ readonly theme?: Input<ThemeConfig> | undefined;
102
+ readonly pwa?: Input<PwaConfig> | undefined;
103
+ readonly roles?: readonly Role[] | undefined;
104
+ readonly database?: Input<DatabaseConfig> | undefined;
105
+ readonly cache?: Input<CacheConfig> | undefined;
106
+ readonly jobs?: Input<JobsConfig> | undefined;
107
+ readonly realtime?: Input<RealtimeConfig> | undefined;
108
+ readonly ai?: AiConfigInput | undefined;
109
+ }
110
+
111
+ /** An overlay from `config/<concern>.ts`. No `name` — the base owns it. */
112
+ export type AppConfigOverlay = Omit<AppConfigInput, 'name'> & { readonly name?: string };
113
+
114
+ /**
115
+ * Apply a partial section over its defaults. Explicit `undefined` never wins — that is what
116
+ * makes every config field deeply optional without `exactOptionalPropertyTypes` fighting back.
117
+ */
118
+ function section<T extends object>(base: T, patch: Input<T> | undefined): T {
119
+ if (patch === undefined) return base;
120
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
121
+ for (const [key, value] of Object.entries(patch)) {
122
+ if (value !== undefined) out[key] = value;
123
+ }
124
+ return out as T;
125
+ }
126
+
127
+ const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
128
+ const CURRENCY_RE = /^[A-Z]{3}$/;
129
+
130
+ function isTimeZone(value: string): boolean {
131
+ try {
132
+ new Intl.DateTimeFormat('en', { timeZone: value });
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ function isLocale(value: string): boolean {
140
+ try {
141
+ return Intl.getCanonicalLocales(value).length === 1;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ function defaults(name: string): Omit<AppConfig, 'name'> {
148
+ return {
149
+ locales: ['en'],
150
+ defaultLocale: 'en',
151
+ defaultTimeZone: 'UTC',
152
+ defaultCurrency: 'USD',
153
+ theme: { defaultMode: 'system', tokens: {} },
154
+ pwa: {
155
+ enabled: false,
156
+ offline: 'network-only',
157
+ installPrompt: false,
158
+ backgroundSync: false,
159
+ push: false,
160
+ },
161
+ roles: [...ROLES],
162
+ database: {
163
+ driver: 'postgres',
164
+ urlEnv: 'DATABASE_URL',
165
+ poolSize: 10,
166
+ ssl: false,
167
+ schema: 'public',
168
+ },
169
+ cache: { driver: 'memory', urlEnv: undefined, defaultTtlMs: 60_000, tiers: ['memo', 'lru'] },
170
+ jobs: {
171
+ driver: 'postgres',
172
+ queues: [`${name}-default`],
173
+ concurrency: 8,
174
+ maxAttempts: 5,
175
+ backoff: 'exponential',
176
+ visibilityTimeoutMs: 30_000,
177
+ },
178
+ realtime: {
179
+ enabled: false,
180
+ tier: 'channels',
181
+ transport: 'memory',
182
+ urlEnv: undefined,
183
+ heartbeatMs: 15_000,
184
+ },
185
+ ai: { mcp: { expose: true, path: '/mcp' }, modelEnv: undefined },
186
+ };
187
+ }
188
+
189
+ function validate(config: AppConfig): void {
190
+ const issues: string[] = [];
191
+
192
+ if (!NAME_RE.test(config.name)) {
193
+ issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
194
+ }
195
+ if (config.locales.length === 0) issues.push('locales must list at least one locale');
196
+ for (const locale of config.locales) {
197
+ if (!isLocale(locale)) issues.push(`locales contains "${locale}", not a BCP-47 tag`);
198
+ }
199
+ if (!config.locales.includes(config.defaultLocale)) {
200
+ issues.push(`defaultLocale "${config.defaultLocale}" is not in locales`);
201
+ }
202
+ if (!isTimeZone(config.defaultTimeZone)) {
203
+ issues.push(`defaultTimeZone "${config.defaultTimeZone}" is not an IANA time zone`);
204
+ }
205
+ if (!CURRENCY_RE.test(config.defaultCurrency)) {
206
+ issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
207
+ }
208
+ if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
209
+ if (config.database.poolSize < 1) issues.push('database.poolSize must be >= 1');
210
+ if (config.jobs.concurrency < 1) issues.push('jobs.concurrency must be >= 1');
211
+ if (config.jobs.queues.length === 0) issues.push('jobs.queues must list at least one queue');
212
+ if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
213
+ issues.push(`realtime.transport "${config.realtime.transport}" requires realtime.urlEnv`);
214
+ }
215
+ if (config.cache.driver === 'redis' && config.cache.urlEnv === undefined) {
216
+ issues.push('cache.driver "redis" requires cache.urlEnv');
217
+ }
218
+
219
+ if (issues.length > 0) {
220
+ throw new ConfigInvalidError({
221
+ cause: issues.join('; '),
222
+ fix: 'edit app.config.ts to fix the fields named in cause, then run: x verify',
223
+ meta: { issues },
224
+ });
225
+ }
226
+ }
227
+
228
+ /**
229
+ * The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
230
+ * touching `app.config.ts`.
231
+ */
232
+ export function defineConfig(
233
+ input: AppConfigInput,
234
+ ...overlays: readonly AppConfigOverlay[]
235
+ ): AppConfig {
236
+ const base = defaults(input.name);
237
+ // One `Object.assign` over all overlays rather than a spread per overlay: `reduce` with a
238
+ // spread copies every key again on each step, and config is merged at boot on every start.
239
+ // `name` is applied last because it identifies the app — an overlay may not rename it.
240
+ const merged: AppConfigInput = Object.assign({}, input, ...overlays, {
241
+ name: input.name,
242
+ }) as AppConfigInput;
243
+
244
+ const config: AppConfig = {
245
+ name: merged.name,
246
+ locales: merged.locales ?? base.locales,
247
+ defaultLocale: merged.defaultLocale ?? base.defaultLocale,
248
+ defaultTimeZone: merged.defaultTimeZone ?? base.defaultTimeZone,
249
+ defaultCurrency: merged.defaultCurrency ?? base.defaultCurrency,
250
+ theme: section(base.theme, merged.theme),
251
+ pwa: section(base.pwa, merged.pwa),
252
+ roles: merged.roles ?? base.roles,
253
+ database: section(base.database, merged.database),
254
+ cache: section(base.cache, merged.cache),
255
+ jobs: section(base.jobs, merged.jobs),
256
+ realtime: section(base.realtime, merged.realtime),
257
+ ai: {
258
+ mcp: section(base.ai.mcp, merged.ai?.mcp),
259
+ modelEnv: merged.ai?.modelEnv ?? base.ai.modelEnv,
260
+ },
261
+ };
262
+
263
+ validate(config);
264
+ return Object.freeze(config);
265
+ }