@ultimat3/core 21.0.0 → 22.1.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/README.md CHANGED
@@ -8,7 +8,6 @@ Zero dependencies, zero `@ultimat3/*` imports.
8
8
  | `UltimateError`, the 3-line rendering, `--json` shape | `errors.ts` |
9
9
  | rendering an app's value into a `cause` / `fix` without throwing | `error-render.ts` |
10
10
  | code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` |
11
- | `Result<T, E>` for boundaries where throwing is wrong | `result.ts` |
12
11
  | the one lazy `AsyncLocalStorage`, every ambient scope in the framework | `async-context.ts` |
13
12
  | request context on that seam | `context.ts` |
14
13
  | `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
@@ -33,10 +32,12 @@ Zero dependencies, zero `@ultimat3/*` imports.
33
32
  | typed env validated at boot | `env.ts` |
34
33
  | `.env.example` rendered from that schema, and its drift check | `env-example.ts` |
35
34
  | named environments + `ULTIMATE_ENV` resolution | `environment.ts` |
35
+ | the boot refusal of a shipped dev signing secret outside development/test — `X_CURSOR_SECRET_DEV` | `dev-secrets.ts` |
36
36
  | a value that cannot be printed by accident | `secret.ts` |
37
37
  | the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
38
38
  | the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
39
39
  | `defineConfig()` for `app.config.ts` | `config.ts` |
40
+ | how overlays layer onto it — per section, key by key | `config-merge.ts` |
40
41
  | the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
41
42
  | the closed route vocabulary every renderer names | `route-vocabulary.ts` |
42
43
  | runtime roles + `ROLE` resolution | `roles.ts` |
@@ -54,6 +55,9 @@ Zero dependencies, zero `@ultimat3/*` imports.
54
55
  | the `/metrics` scrape body | `metrics-text.ts` |
55
56
  | the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
56
57
  | graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
58
+ | the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
59
+ | SIGTERM/SIGINT → the one drain | `lifecycle-signals.ts` |
60
+ | which network an IP literal belongs to — `classifyAddress`, for SSRF screens | `address-class.ts` |
57
61
  | the sockets this process opened, so a self-request is not egress | `listeners.ts` |
58
62
  | `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
59
63
  | the registrar table one same-tier package reaches another through | `registrar.ts` |
@@ -164,6 +168,40 @@ Enforced, not documented: `x verify`'s `errors` step fails with `X_ERROR_RENDER_
164
168
  parameter typed `unknown` reaches a `cause:` or `fix:` through `JSON.stringify`, `String()` or a
165
169
  bare interpolation (`scripts/error-render.ts`).
166
170
 
171
+ ### Error classes
172
+
173
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
174
+ a job boundary the class is gone and the `code` is what survives — match on that.
175
+
176
+ | Class | Code | Declared in |
177
+ |---|---|---|
178
+ | `ConfigInvalidError` | `X_CONFIG_INVALID` | `src/errors.ts` |
179
+ | `CursorInvalidError` | `X_CURSOR_INVALID` | `src/cursor.ts` |
180
+ | `CursorSecretDevError` | `X_CURSOR_SECRET_DEV` | `src/dev-secrets.ts` |
181
+ | `EnvExampleDriftError` | `X_ENV_EXAMPLE_DRIFT` | `src/env-example.ts` |
182
+ | `EnvironmentInvalidError` | `X_ENVIRONMENT_INVALID` | `src/environment.ts` |
183
+ | `EnvMissingError` | `X_ENV_MISSING` | `src/errors.ts` |
184
+ | `ErrorReporterDsnInvalidError` | `X_ERROR_REPORTER_DSN_INVALID` | `src/error-reporter-sentry.ts` |
185
+ | `ImageDecodeFailedError` | `X_IMAGE_DECODE_FAILED` | `src/image/errors.ts` |
186
+ | `ImageTooLargeError` | `X_IMAGE_TOO_LARGE` | `src/image/errors.ts` |
187
+ | `ImageUnsupportedError` | `X_IMAGE_UNSUPPORTED` | `src/image/errors.ts` |
188
+ | `InternalError` | `X_INTERNAL` | `src/errors.ts` |
189
+ | `MetricCardinalityError` | `X_METRIC_CARDINALITY` | `src/metrics.ts` |
190
+ | `MetricNameInvalidError` | `X_METRIC_NAME_INVALID` | `src/metric-names.ts` |
191
+ | `MetricValueInvalidError` | `X_METRIC_VALUE_INVALID` | `src/metrics.ts` |
192
+ | `NotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
193
+ | `OtlpEndpointInvalidError` | `X_OTLP_ENDPOINT_INVALID` | `src/otlp.ts` |
194
+ | `OtlpHeadersInvalidError` | `X_OTLP_HEADERS_INVALID` | `src/otlp.ts` |
195
+ | `OtlpProtocolUnsupportedError` | `X_OTLP_PROTOCOL_UNSUPPORTED` | `src/otlp.ts` |
196
+ | `SecretsFileInvalidError` | `X_SECRETS_FILE_INVALID` | `src/secrets-errors.ts` |
197
+ | `SecretsFileMissingError` | `X_SECRETS_FILE_MISSING` | `src/secrets-errors.ts` |
198
+ | `SecretsKeyInvalidError` | `X_SECRETS_KEY_INVALID` | `src/secrets-errors.ts` |
199
+ | `SecretsKeyMismatchError` | `X_SECRETS_KEY_MISMATCH` | `src/secrets-errors.ts` |
200
+ | `SecretsKeyMissingError` | `X_SECRETS_KEY_MISSING` | `src/secrets-errors.ts` |
201
+ | `SecretsPlaintextInvalidError` | `X_SECRETS_PLAINTEXT_INVALID` | `src/secrets-errors.ts` |
202
+ | `SecretsTamperedError` | `X_SECRETS_TAMPERED` | `src/secrets-errors.ts` |
203
+ | `UltimateError` | any registered code — every class in the framework extends it | `src/errors.ts` |
204
+
167
205
  ## Context
168
206
 
169
207
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "21.0.0",
3
+ "version": "22.1.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,6 +40,6 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/schema": "21.0.0"
43
+ "@ultimat3/schema": "22.1.0"
44
44
  }
45
45
  }
@@ -0,0 +1,143 @@
1
+ // Single responsibility: which kind of network an IP address literal belongs to. Tier 0 because the
2
+ // SSRF screens that need it sit in packages that may not import each other — `@ultimat3/jobs`'
3
+ // webhook delivery (tier 3) and `@ultimat3/scraping` (tier 5) — and a copy per screen is how one
4
+ // of them comes to miss `::ffff:127.0.0.1`.
5
+
6
+ /**
7
+ * `reserved` is every range that is neither a host network nor public: multicast, broadcast,
8
+ * `240.0.0.0/4`, the documentation and benchmarking nets. A screen allowing only `public` refuses
9
+ * all of them, which is the point of naming them rather than calling them public.
10
+ */
11
+ export type AddressClass =
12
+ | 'loopback'
13
+ | 'private'
14
+ | 'link-local'
15
+ | 'ula'
16
+ | 'cgnat'
17
+ | 'unspecified'
18
+ | 'reserved'
19
+ | 'public';
20
+
21
+ /** `[network, prefixBits]` over the 32-bit value; ordered, first match wins. */
22
+ const V4_RULES: readonly (readonly [number, number, AddressClass])[] = [
23
+ [0x00000000, 8, 'unspecified'],
24
+ [0x7f000000, 8, 'loopback'],
25
+ [0x0a000000, 8, 'private'],
26
+ [0xac100000, 12, 'private'],
27
+ [0xc0a80000, 16, 'private'],
28
+ [0xa9fe0000, 16, 'link-local'],
29
+ [0x64400000, 10, 'cgnat'],
30
+ [0xc0000000, 24, 'reserved'], // 192.0.0.0/24, IETF protocol assignments
31
+ [0xc0000200, 24, 'reserved'], // 192.0.2.0/24, TEST-NET-1
32
+ [0xc6120000, 15, 'reserved'], // 198.18.0.0/15, benchmarking
33
+ [0xc6336400, 24, 'reserved'], // 198.51.100.0/24, TEST-NET-2
34
+ [0xcb007100, 24, 'reserved'], // 203.0.113.0/24, TEST-NET-3
35
+ [0xe0000000, 4, 'reserved'], // multicast
36
+ [0xf0000000, 4, 'reserved'], // 240.0.0.0/4 and the broadcast address inside it
37
+ ];
38
+
39
+ /**
40
+ * Strict dotted quad: four decimal octets, no leading zero. `010.0.0.1` is octal to some parsers
41
+ * and decimal to others, so it is not an address this module will vouch for — a screen that
42
+ * refuses what it cannot classify is the only safe reader of it.
43
+ */
44
+ function parseV4(text: string): number | undefined {
45
+ const parts = text.split('.');
46
+ if (parts.length !== 4) return undefined;
47
+ let value = 0;
48
+ for (const part of parts) {
49
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(part)) return undefined;
50
+ const octet = Number(part);
51
+ if (octet > 255) return undefined;
52
+ value = value * 256 + octet;
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function classifyV4(value: number): AddressClass {
58
+ for (const [network, bits, kind] of V4_RULES) {
59
+ const size = 2 ** (32 - bits);
60
+ if (value >= network && value < network + size) return kind;
61
+ }
62
+ return 'public';
63
+ }
64
+
65
+ /** Eight 16-bit groups, or `undefined`. A trailing dotted quad fills the last two. */
66
+ function parseV6(text: string): readonly number[] | undefined {
67
+ let body = text;
68
+ let tail: number[] = [];
69
+ const lastColon = body.lastIndexOf(':');
70
+ if (body.includes('.', lastColon)) {
71
+ const v4 = parseV4(body.slice(lastColon + 1));
72
+ if (v4 === undefined) return undefined;
73
+ tail = [Math.floor(v4 / 0x10000), v4 % 0x10000];
74
+ // `::ffff:1.2.3.4` keeps `::ffff`; `::1.2.3.4` keeps its `::`, the one colon that is syntax.
75
+ const head = body.slice(0, lastColon + 1);
76
+ body = head.endsWith('::') ? head : head.slice(0, -1);
77
+ }
78
+ const halves = body.split('::');
79
+ if (halves.length > 2) return undefined;
80
+ const groups = (half: string): number[] | undefined => {
81
+ if (half === '') return [];
82
+ const out: number[] = [];
83
+ for (const group of half.split(':')) {
84
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return undefined;
85
+ out.push(Number.parseInt(group, 16));
86
+ }
87
+ return out;
88
+ };
89
+ const head = groups(halves[0] ?? '');
90
+ const rest = halves.length === 2 ? groups(halves[1] ?? '') : [];
91
+ if (head === undefined || rest === undefined) return undefined;
92
+ const width = head.length + rest.length + tail.length;
93
+ if (halves.length === 1) return width === 8 ? [...head, ...tail] : undefined;
94
+ if (width > 7) return undefined;
95
+ return [...head, ...new Array<number>(8 - width).fill(0), ...rest, ...tail];
96
+ }
97
+
98
+ const embeddedV4 = (g: readonly number[]): number => (g[6] ?? 0) * 0x10000 + (g[7] ?? 0);
99
+
100
+ function classifyV6(g: readonly number[]): AddressClass {
101
+ const zeroUpTo = (n: number): boolean => g.slice(0, n).every((group) => group === 0);
102
+ if (zeroUpTo(8)) return 'unspecified';
103
+ if (zeroUpTo(7) && g[7] === 1) return 'loopback';
104
+ // IPv4-mapped `::ffff:a.b.c.d`, IPv4-compatible `::a.b.c.d` and NAT64 `64:ff9b::/96`: each is
105
+ // routed to the IPv4 address it carries, so that address is what gets classified.
106
+ if (zeroUpTo(5) && g[5] === 0xffff) return classifyV4(embeddedV4(g));
107
+ if (zeroUpTo(6)) return classifyV4(embeddedV4(g));
108
+ if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((group) => group === 0)) {
109
+ return classifyV4(embeddedV4(g));
110
+ }
111
+ const first = g[0] ?? 0;
112
+ if ((first & 0xffc0) === 0xfe80) return 'link-local';
113
+ if ((first & 0xffc0) === 0xfec0) return 'private'; // deprecated site-local
114
+ if ((first & 0xfe00) === 0xfc00) return 'ula';
115
+ if ((first & 0xff00) === 0xff00) return 'reserved'; // multicast
116
+ if (first === 0x2001 && g[1] === 0x0db8) return 'reserved'; // documentation
117
+ if (first === 0x0100 && g.slice(1, 4).every((group) => group === 0)) return 'reserved'; // 100::/64
118
+
119
+ return 'public';
120
+ }
121
+
122
+ /**
123
+ * The class of an IP address LITERAL — IPv4, IPv6, bracketed `[::1]`, a zone id `fe80::1%eth0`,
124
+ * and every IPv6 form carrying an IPv4 address. `undefined` means "not an address literal": a
125
+ * hostname must be resolved first and each resolved address classified, never this string.
126
+ */
127
+ export function classifyAddress(address: string): AddressClass | undefined {
128
+ let text = address.trim();
129
+ if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
130
+ const zone = text.indexOf('%');
131
+ if (zone !== -1 && text.includes(':')) text = text.slice(0, zone);
132
+ if (text.includes(':')) {
133
+ const groups = parseV6(text);
134
+ return groups === undefined ? undefined : classifyV6(groups);
135
+ }
136
+ const v4 = parseV4(text);
137
+ return v4 === undefined ? undefined : classifyV4(v4);
138
+ }
139
+
140
+ /** Fails CLOSED: anything but a literal classified `public` — a hostname included — is `false`. */
141
+ export function isPublicAddress(address: string): boolean {
142
+ return classifyAddress(address) === 'public';
143
+ }
@@ -50,6 +50,27 @@ function hashCollection(value: Map<unknown, unknown> | Set<unknown>): string {
50
50
  return `${value instanceof Map ? 'Map' : 'Set'}(${entries.sort().join(',')})`;
51
51
  }
52
52
 
53
+ /** Lowercase hex, two digits a byte. Not `toBase64`: this module ships to browsers that lack it. */
54
+ function hexOf(bytes: Uint8Array): string {
55
+ let out = '';
56
+ for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Tagged, because a typed array's indices ARE own enumerable keys: `Uint8Array([1])` rendered
62
+ * `{"0":1}`, one key with the plain object `{ 0: 1 }`. The view's OWN window is read, never the
63
+ * whole backing buffer, and any view but a `Uint8Array` carries its type name — a `Uint16Array([1])`
64
+ * and a `Uint8Array([1, 0])` hold the same bytes and are different payloads.
65
+ */
66
+ function hashBytes(value: ArrayBuffer | ArrayBufferView): string {
67
+ if (value instanceof ArrayBuffer) return `ArrayBuffer(${hexOf(new Uint8Array(value))})`;
68
+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
69
+ const tag =
70
+ value instanceof Uint8Array ? 'Bytes' : Object.prototype.toString.call(value).slice(8, -1);
71
+ return `${tag}(${hexOf(bytes)})`;
72
+ }
73
+
53
74
  /**
54
75
  * JSON with object keys sorted at every depth, and every value a JSON document would fold onto
55
76
  * `null` or `{}` given a token of its own. No timestamps, no insertion-order leaks.
@@ -64,7 +85,8 @@ export function canonicalJson(value: unknown): string {
64
85
  case 'boolean':
65
86
  return String(value);
66
87
  case 'bigint':
67
- return JSON.stringify(`${value}n`);
88
+ // A bare token like `Date(…)`: quoted as `"5n"`, it was one key with the STRING `'5n'`.
89
+ return `BigInt(${value})`;
68
90
  case 'undefined':
69
91
  case 'function':
70
92
  case 'symbol':
@@ -77,6 +99,7 @@ export function canonicalJson(value: unknown): string {
77
99
  // every set alike.
78
100
  if (value instanceof Date) return hashDate(value);
79
101
  if (value instanceof Map || value instanceof Set) return hashCollection(value);
102
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return hashBytes(value);
80
103
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
81
104
  const record = value as Record<string, unknown>;
82
105
  const keys = Object.keys(record)
@@ -275,8 +275,11 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
275
275
  const work = (): Promise<T> =>
276
276
  gate === undefined ? dispatch(plan) : gate.run(() => dispatch(plan));
277
277
  // The single flight sits OUTSIDE the gate: a joiner takes no slot, so dedup relieves the
278
- // ceiling instead of queueing behind it.
279
- return settle(plan.key === undefined ? work() : flights.run(plan.key, work), issued);
278
+ // ceiling instead of queueing behind it. Keyed by GENERATION too: `bump()` aborts the old
279
+ // flights, but each holds its key until its rejection settles, and a same-key read issued
280
+ // in that window joined the aborted one and was answered with its AbortError.
281
+ const key = plan.key === undefined ? undefined : JSON.stringify([issued, plan.key]);
282
+ return settle(key === undefined ? work() : flights.run(key, work), issued);
280
283
  },
281
284
 
282
285
  get inflight(): number {
@@ -0,0 +1,19 @@
1
+ // Single responsibility: the numeric domain a config count or window must sit in. Takes the key as
2
+ // a string and the value as `unknown`, because `validate` is the boundary an untyped JS config
3
+ // crosses — a `'60s'` arrives here however the interface types the field.
4
+
5
+ import { describeValue } from './error-render';
6
+
7
+ /**
8
+ * Why `value` is not a whole number ≥ `min`, or `undefined` when it is one. `NaN`, `Infinity` and
9
+ * `2.5` each passed `concurrency < 1` — every comparison with `NaN` is false — and a fraction or an
10
+ * infinity then reaches `Array.from({ length })`, a `setTimeout` or a loop bound.
11
+ */
12
+ export function countIssue(key: string, value: unknown, min: 0 | 1): string | undefined {
13
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= min) return undefined;
14
+ // A number is printed as itself — `describeValue` answers "a number" for 2.5 and -3 alike.
15
+ const shown = typeof value === 'number' ? numberText(value) : describeValue(value);
16
+ return `${key} must be a whole number ${min === 1 ? 'of at least 1' : 'of 0 or more'}, not ${shown}`;
17
+ }
18
+
19
+ const numberText = (value: number): string => `${value}`;
@@ -0,0 +1,23 @@
1
+ // Single responsibility: the remedies `app.config.ts`'s validator appends to X_CONFIG_INVALID. Split
2
+ // from `config.ts` so the validator stays under its line ceiling; each string is carried only when
3
+ // its own key is what failed.
4
+
5
+ export const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
6
+
7
+ /**
8
+ * Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
9
+ * spelling to write, and the two refused classes have different remedies — a single-label legacy
10
+ * name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
11
+ * no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
12
+ * two refuse the same strings and an operator may meet either first.
13
+ */
14
+ export const TIMEZONE_FIX =
15
+ "set defaultTimeZone to an Area/Location name, or UTC — list every accepted one with bun -e \"console.log(Intl.supportedValuesOf('timeZone').join('\\n'))\" — where a legacy single-label name swaps mechanically (Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC), while an abbreviation or numeric offset (CET, EST5EDT, +01:00) carries no DST rule and has no replacement, so name the city whose clock you mean (Europe/Paris, America/New_York)";
16
+
17
+ /**
18
+ * Appended only when a tier name is what failed, and it names the rename rather than the rule: the
19
+ * three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
20
+ * replacement while `isr` has none — it is a `RenderMode`, and no cache tier ever served it.
21
+ */
22
+ export const CACHE_TIER_FIX =
23
+ "in app.config.ts, rewrite cache.tiers with the rung names the ladder serves — request-memo, lru, redis, cdn — where memo becomes request-memo and shared becomes redis, and isr is dropped: it is a render mode, so move it to render: 'isr' on the routes that want it";
@@ -0,0 +1,36 @@
1
+ // Single responsibility: how `defineConfig` layers `app.config.ts` and its `config/*.ts` overlays —
2
+ // per section and key by key. Carries no config KEY on purpose: `config-readers` counts a property
3
+ // access outside `config.ts` as a reader, so this file only ever sees sections as opaque records.
4
+
5
+ /** A section's patch: every key optional, and an explicit `undefined` meaning "not said". */
6
+ export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
7
+
8
+ /**
9
+ * Apply a partial section over its defaults. Explicit `undefined` never wins — that is what
10
+ * makes every config field deeply optional without `exactOptionalPropertyTypes` fighting back.
11
+ */
12
+ export function section<T extends object>(base: T, patch: Input<T> | undefined): T {
13
+ if (patch === undefined) return base;
14
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
15
+ for (const [key, value] of Object.entries(patch)) {
16
+ if (value !== undefined) out[key] = value;
17
+ }
18
+ return out as T;
19
+ }
20
+
21
+ /**
22
+ * Every layer's patch applied in order, each one KEY BY KEY. The input and the overlays used to be
23
+ * `Object.assign`ed first and the section merged once, so `{ jobs: { maxAttempts: 9 } }` in an
24
+ * overlay replaced the base's whole `jobs` patch — its `queues` and `concurrency` fell back to the
25
+ * framework defaults — and an overlay's `{ realtime: undefined }` erased the base's section.
26
+ */
27
+ export function layered<T extends object>(base: T, patches: readonly (Input<T> | undefined)[]): T {
28
+ return patches.reduce<T>((out, patch) => section(out, patch), base);
29
+ }
30
+
31
+ /** A whole-value key (`locales`, `roles`): the last layer that said something wins. */
32
+ export function lastSaid<T>(base: T, values: readonly (T | undefined)[]): T {
33
+ let out = base;
34
+ for (const value of values) if (value !== undefined) out = value;
35
+ return out;
36
+ }
package/src/config.ts CHANGED
@@ -7,15 +7,24 @@ import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
7
7
  // them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
8
8
  // drift into two vocabularies with no map between them (issue #293).
9
9
  import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
10
+ import { countIssue } from './config-count';
11
+ import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
12
+ import { type Input, lastSaid, layered } from './config-merge';
10
13
  import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
11
14
  import { PWA_FIX, pwaIssues } from './config-pwa';
12
15
  import { describeValue } from './error-render';
13
16
  import { ConfigInvalidError } from './errors';
17
+ import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
14
18
  import { ROLES, type Role } from './roles';
15
19
  import { isIanaZoneName } from './time-zone-name';
16
20
 
17
21
  export type ThemeMode = 'light' | 'dark' | 'system';
18
- export type RealtimeTransport = 'memory' | 'nats' | 'redis';
22
+ /**
23
+ * The buses `@ultimat3/realtime`'s `selectTransport` builds, and nothing else. `'redis'` was in
24
+ * this union until 22.0.0 with no Redis transport anywhere: it booted whatever `NATS_URL` chose.
25
+ */
26
+ export const REALTIME_TRANSPORTS = ['memory', 'nats'] as const;
27
+ export type RealtimeTransport = (typeof REALTIME_TRANSPORTS)[number];
19
28
 
20
29
  export interface ThemeConfig {
21
30
  readonly defaultMode: ThemeMode;
@@ -172,6 +181,20 @@ export interface AiConfig {
172
181
  readonly mcp: McpConfig;
173
182
  }
174
183
 
184
+ /**
185
+ * How a SIGTERM'd process leaves the load balancer. Read by `@ultimat3/http`'s `createServer`
186
+ * (`ServerOptions.drain`), which hands it to core's `configureLifecycle`.
187
+ */
188
+ export interface DrainConfig {
189
+ /**
190
+ * `/readyz` answers 503 for this long before the listener closes, so endpoints stop routing here
191
+ * first. Default 0 in development/test and 5000 everywhere else — a process naming NO environment
192
+ * included. A whole number, 0–60000. The chart's `terminationGracePeriodSeconds` must exceed it
193
+ * plus the drain budget.
194
+ */
195
+ readonly readinessGraceMs: number;
196
+ }
197
+
175
198
  export interface AppConfig {
176
199
  readonly name: string;
177
200
  readonly locales: readonly string[];
@@ -188,10 +211,9 @@ export interface AppConfig {
188
211
  readonly realtime: RealtimeConfig;
189
212
  readonly notify: NotifyConfig;
190
213
  readonly ai: AiConfig;
214
+ readonly drain: DrainConfig;
191
215
  }
192
216
 
193
- type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
194
-
195
217
  /** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
196
218
  export interface AiConfigInput {
197
219
  readonly mcp?: Input<McpConfig> | undefined;
@@ -224,24 +246,12 @@ export interface AppConfigInput {
224
246
  readonly realtime?: Input<RealtimeConfig> | undefined;
225
247
  readonly notify?: Input<NotifyConfig> | undefined;
226
248
  readonly ai?: AiConfigInput | undefined;
249
+ readonly drain?: Input<DrainConfig> | undefined;
227
250
  }
228
251
 
229
252
  /** An overlay from `config/<concern>.ts`. No `name` — the base owns it. */
230
253
  export type AppConfigOverlay = Omit<AppConfigInput, 'name'> & { readonly name?: string };
231
254
 
232
- /**
233
- * Apply a partial section over its defaults. Explicit `undefined` never wins — that is what
234
- * makes every config field deeply optional without `exactOptionalPropertyTypes` fighting back.
235
- */
236
- function section<T extends object>(base: T, patch: Input<T> | undefined): T {
237
- if (patch === undefined) return base;
238
- const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
239
- for (const [key, value] of Object.entries(patch)) {
240
- if (value !== undefined) out[key] = value;
241
- }
242
- return out as T;
243
- }
244
-
245
255
  const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
246
256
 
247
257
  /**
@@ -287,32 +297,16 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
287
297
  backoff: 'exponential',
288
298
  visibilityTimeoutMs: 30_000,
289
299
  },
290
- realtime: { enabled: false, transport: 'memory', urlEnv: undefined },
300
+ // ON by default since 22.0.0, when the boot began obeying the key: an app with no section
301
+ // keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
302
+ realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
291
303
  notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
292
304
  ai: { mcp: { expose: true, path: '/mcp' } },
305
+ // Read from the process env when the config is DEFINED — the same env the drain will run in.
306
+ drain: { readinessGraceMs: defaultReadinessGraceMs() },
293
307
  };
294
308
  }
295
309
 
296
- const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
297
-
298
- /**
299
- * Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
300
- * spelling to write, and the two refused classes have different remedies — a single-label legacy
301
- * name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
302
- * no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
303
- * two refuse the same strings and an operator may meet either first.
304
- */
305
- const TIMEZONE_FIX =
306
- "set defaultTimeZone to an Area/Location name, or UTC — list every accepted one with bun -e \"console.log(Intl.supportedValuesOf('timeZone').join('\\n'))\" — where a legacy single-label name swaps mechanically (Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC), while an abbreviation or numeric offset (CET, EST5EDT, +01:00) carries no DST rule and has no replacement, so name the city whose clock you mean (Europe/Paris, America/New_York)";
307
-
308
- /**
309
- * Appended only when a tier name is what failed, and it names the rename rather than the rule: the
310
- * three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
311
- * replacement while `isr` has none — it is a `RenderMode`, and no cache tier ever served it.
312
- */
313
- const CACHE_TIER_FIX =
314
- "in app.config.ts, rewrite cache.tiers with the rung names the ladder serves — request-memo, lru, redis, cdn — where memo becomes request-memo and shared becomes redis, and isr is dropped: it is a render mode, so move it to render: 'isr' on the routes that want it";
315
-
316
310
  function validate(config: AppConfig): void {
317
311
  const issues: string[] = [];
318
312
  // Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
@@ -346,10 +340,25 @@ function validate(config: AppConfig): void {
346
340
  issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
347
341
  }
348
342
  if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
349
- if (config.jobs.concurrency < 1) issues.push('jobs.concurrency must be >= 1');
343
+ // A domain per numeric key, never a bare `< 1`: every comparison with `NaN` is false, so the old
344
+ // `concurrency < 1` passed `NaN`, `2.5` and `Infinity`, and nothing screened the other three.
345
+ const counts: readonly (string | undefined)[] = [
346
+ countIssue('jobs.concurrency', config.jobs.concurrency, 1),
347
+ countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
348
+ countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
349
+ countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
350
+ readinessGraceIssue(config.drain.readinessGraceMs),
351
+ ];
352
+ for (const issue of counts) if (issue !== undefined) issues.push(issue);
350
353
  if (config.jobs.queues.length === 0) issues.push('jobs.queues must list at least one queue');
351
- if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
352
- issues.push(`realtime.transport "${config.realtime.transport}" requires realtime.urlEnv`);
354
+ // An untyped config reaches here with whatever it wrote: a string is a name worth echoing, and
355
+ // anything else goes through `describeValue` rather than `${…}`.
356
+ const transport: unknown = config.realtime.transport;
357
+ if (!REALTIME_TRANSPORTS.some((known) => known === transport)) {
358
+ const said = typeof transport === 'string' ? `"${transport}"` : describeValue(transport);
359
+ issues.push(`realtime.transport ${said} is not one of ${REALTIME_TRANSPORTS.join(', ')}`);
360
+ } else if (transport === 'nats' && config.realtime.urlEnv === undefined) {
361
+ issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
353
362
  }
354
363
  // BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
355
364
  // only other legal one is a positive, finite count of milliseconds. Zero is refused rather than
@@ -401,35 +410,83 @@ export function defineConfig(
401
410
  ...overlays: readonly AppConfigOverlay[]
402
411
  ): AppConfig {
403
412
  const base = defaults(input.name);
404
- // One `Object.assign` over all overlays rather than a spread per overlay: `reduce` with a
405
- // spread copies every key again on each step, and config is merged at boot on every start.
406
- // `name` is applied last because it identifies the app — an overlay may not rename it.
407
- const merged: AppConfigInput = Object.assign({}, input, ...overlays, {
408
- name: input.name,
409
- }) as AppConfigInput;
413
+ // Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from
414
+ // the input alone because it identifies the app: an overlay may not rename it.
415
+ const layers: readonly AppConfigOverlay[] = [input, ...overlays];
410
416
 
411
417
  const config: AppConfig = {
412
- name: merged.name,
413
- locales: merged.locales ?? base.locales,
414
- defaultLocale: merged.defaultLocale ?? base.defaultLocale,
415
- defaultTimeZone: merged.defaultTimeZone ?? base.defaultTimeZone,
416
- defaultCurrency: merged.defaultCurrency ?? base.defaultCurrency,
417
- theme: section(base.theme, merged.theme),
418
- auth: section(base.auth, merged.auth),
419
- // Two `section` calls, one per level: the outer one may not see `offline` at all, or it would
420
- // drop the nested defaults `PwaConfigInput` exists to keep. Hence the cast — the outer patch is
421
- // this block minus the key the inner call owns.
418
+ name: input.name,
419
+ locales: lastSaid(
420
+ base.locales,
421
+ layers.map((layer) => layer.locales),
422
+ ),
423
+ defaultLocale: lastSaid(
424
+ base.defaultLocale,
425
+ layers.map((layer) => layer.defaultLocale),
426
+ ),
427
+ defaultTimeZone: lastSaid(
428
+ base.defaultTimeZone,
429
+ layers.map((layer) => layer.defaultTimeZone),
430
+ ),
431
+ defaultCurrency: lastSaid(
432
+ base.defaultCurrency,
433
+ layers.map((layer) => layer.defaultCurrency),
434
+ ),
435
+ theme: layered(
436
+ base.theme,
437
+ layers.map((layer) => layer.theme),
438
+ ),
439
+ auth: layered(
440
+ base.auth,
441
+ layers.map((layer) => layer.auth),
442
+ ),
443
+ // Two merges, one per level: the outer one may not see `offline` at all, or it would drop the
444
+ // nested defaults `PwaConfigInput` exists to keep. Hence the cast — the outer patch is this
445
+ // block minus the key the inner merge owns.
422
446
  pwa: {
423
- ...section(base.pwa, { ...merged.pwa, offline: undefined } as Input<PwaConfig>),
424
- offline: section(base.pwa.offline, merged.pwa?.offline),
447
+ ...layered(
448
+ base.pwa,
449
+ layers.map((layer) => ({ ...layer.pwa, offline: undefined }) as Input<PwaConfig>),
450
+ ),
451
+ offline: layered(
452
+ base.pwa.offline,
453
+ layers.map((layer) => layer.pwa?.offline),
454
+ ),
455
+ },
456
+ roles: lastSaid(
457
+ base.roles,
458
+ layers.map((layer) => layer.roles),
459
+ ),
460
+ database: layered(
461
+ base.database,
462
+ layers.map((layer) => layer.database),
463
+ ),
464
+ cache: layered(
465
+ base.cache,
466
+ layers.map((layer) => layer.cache),
467
+ ),
468
+ jobs: layered(
469
+ base.jobs,
470
+ layers.map((layer) => layer.jobs),
471
+ ),
472
+ realtime: layered(
473
+ base.realtime,
474
+ layers.map((layer) => layer.realtime),
475
+ ),
476
+ notify: layered(
477
+ base.notify,
478
+ layers.map((layer) => layer.notify),
479
+ ),
480
+ ai: {
481
+ mcp: layered(
482
+ base.ai.mcp,
483
+ layers.map((layer) => layer.ai?.mcp),
484
+ ),
425
485
  },
426
- roles: merged.roles ?? base.roles,
427
- database: section(base.database, merged.database),
428
- cache: section(base.cache, merged.cache),
429
- jobs: section(base.jobs, merged.jobs),
430
- realtime: section(base.realtime, merged.realtime),
431
- notify: section(base.notify, merged.notify),
432
- ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
486
+ drain: layered(
487
+ base.drain,
488
+ layers.map((layer) => layer.drain),
489
+ ),
433
490
  };
434
491
 
435
492
  validate(config);
@@ -16,6 +16,7 @@ const CORE_CODE_TITLES = {
16
16
  X_CLIENT_TRANSPORT_FAILED: 'a browser request got no answer from the app',
17
17
  X_CONFIG_INVALID: 'app.config.ts is invalid',
18
18
  X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
19
+ // `x doctor` reports it; `assertNoDevSecretsOutsideLocal()` throws it at boot.
19
20
  X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
20
21
  X_DRAINING: 'process is draining and refuses new work',
21
22
  X_ENV_EXAMPLE_DRIFT: '.env.example does not declare every variable the schema requires',