@ultimat3/core 20.2.1 → 22.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.
Files changed (59) hide show
  1. package/CLAUDE.md +189 -523
  2. package/README.md +62 -1
  3. package/package.json +6 -3
  4. package/src/actor.ts +14 -0
  5. package/src/address-class.ts +143 -0
  6. package/src/async-state.ts +19 -0
  7. package/src/canonical-json.ts +24 -1
  8. package/src/client-dispatch.ts +163 -0
  9. package/src/client-flight.ts +22 -3
  10. package/src/client-paths.ts +79 -0
  11. package/src/client-problem.ts +94 -0
  12. package/src/client-scope-error.ts +18 -0
  13. package/src/client-scope.ts +59 -0
  14. package/src/client-transport.ts +86 -0
  15. package/src/config-count.ts +19 -0
  16. package/src/config-fixes.ts +23 -0
  17. package/src/config-merge.ts +36 -0
  18. package/src/config.ts +122 -65
  19. package/src/conflict-policy.ts +48 -0
  20. package/src/context.ts +12 -1
  21. package/src/core-error-codes.ts +78 -0
  22. package/src/dev-secrets.ts +45 -0
  23. package/src/error-codes.ts +17 -66
  24. package/src/error-retry.ts +3 -0
  25. package/src/exports/error-contract.ts +2 -2
  26. package/src/exports/secrets.ts +1 -0
  27. package/src/generation-fence.ts +9 -2
  28. package/src/host-rules.ts +71 -0
  29. package/src/image/exif-orientation.ts +40 -0
  30. package/src/image/probe.ts +13 -1
  31. package/src/in-process-fetch.ts +39 -0
  32. package/src/index.ts +80 -30
  33. package/src/iso-date.ts +5 -0
  34. package/src/lifecycle-grace.ts +44 -0
  35. package/src/lifecycle-signals.ts +35 -0
  36. package/src/lifecycle.ts +44 -36
  37. package/src/logger.ts +22 -3
  38. package/src/measurement-actor.ts +52 -0
  39. package/src/metrics-text.ts +10 -2
  40. package/src/otlp-metric-exporter.ts +39 -12
  41. package/src/otlp-span-exporter.ts +38 -15
  42. package/src/outbound-headers.ts +16 -0
  43. package/src/outbox-drain.ts +14 -0
  44. package/src/page-meta.ts +41 -0
  45. package/src/page.ts +52 -0
  46. package/src/pending-records.ts +58 -0
  47. package/src/record-envelope-openapi.ts +41 -0
  48. package/src/record-envelope.ts +98 -0
  49. package/src/record-sink.ts +129 -0
  50. package/src/schema-error-codes.ts +1 -1
  51. package/src/secrets-errors.ts +1 -1
  52. package/src/secrets-store.ts +51 -5
  53. package/src/service.ts +9 -0
  54. package/src/source-mask.ts +30 -0
  55. package/src/telemetry.ts +3 -0
  56. package/src/type-pins.ts +9 -0
  57. package/src/write-digest.ts +29 -0
  58. package/src/write-origin.ts +31 -0
  59. package/src/result.ts +0 -78
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The ONE URL rule for the two HTTP primitives: an action's export name derives `POST
3
+ * /api/<resource>/<verb>`, a query's derives `GET /_x/query/<kebab>`. Tier 0 and pure string math,
4
+ * so `action`, `query` and `realtime` (all tier 3) derive the same URL with no sideways import and
5
+ * a browser bundle pays a few hundred bytes for it. Moved verbatim from both packages' `naming.ts`.
6
+ */
7
+
8
+ /** Irregular plurals we actually hit in domain models. A `Map`: the key is a caller's word. */
9
+ const IRREGULAR: ReadonlyMap<string, string> = new Map([
10
+ ['person', 'people'],
11
+ ['child', 'children'],
12
+ ['man', 'men'],
13
+ ['woman', 'women'],
14
+ ['datum', 'data'],
15
+ ['index', 'indexes'],
16
+ ['entry', 'entries'],
17
+ ]);
18
+
19
+ /** Every read is served under one prefix, so a router can claim it in one rule. */
20
+ export const QUERY_PATH_PREFIX = '/_x/query';
21
+
22
+ export interface ActionRoute {
23
+ /** First camelCase word, kebab-cased. `publishPost` -> `publish`. */
24
+ readonly verb: string;
25
+ /** Remaining words, last one pluralized, kebab-cased. `publishPost` -> `posts`. */
26
+ readonly resource: string;
27
+ /** `/api/<resource>/<verb>`. */
28
+ readonly path: string;
29
+ }
30
+
31
+ /** camelCase / PascalCase / SCREAMING_SNAKE -> lowercase words. */
32
+ export function splitWords(name: string): string[] {
33
+ return name
34
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
35
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
36
+ .split(/[\s_-]+/)
37
+ .filter((word) => word.length > 0)
38
+ .map((word) => word.toLowerCase());
39
+ }
40
+
41
+ /**
42
+ * Naive-on-purpose English pluralizer. A word that already ends in `s` is left alone, so
43
+ * `publishPosts` and `publishPost` agree on the `posts` resource.
44
+ */
45
+ export function pluralize(word: string): string {
46
+ const irregular = IRREGULAR.get(word);
47
+ if (irregular !== undefined) return irregular;
48
+ if (word.endsWith('s')) return word;
49
+ if (/(x|z|ch|sh)$/.test(word)) return `${word}es`;
50
+ if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
51
+ return `${word}s`;
52
+ }
53
+
54
+ /**
55
+ * `publishPost` -> `/api/posts/publish`, `updateUserProfile` -> `/api/user-profiles/update`,
56
+ * `checkout` -> `/api/checkouts/invoke` (single-word fallback).
57
+ */
58
+ export function actionRoute(name: string): ActionRoute {
59
+ const words = splitWords(name);
60
+ const head = words[0] ?? 'invoke';
61
+ if (words.length < 2) {
62
+ const resource = pluralize(head);
63
+ return { verb: 'invoke', resource, path: `/api/${resource}/invoke` };
64
+ }
65
+ const nouns = words.slice(1);
66
+ const last = nouns[nouns.length - 1] ?? head;
67
+ const resource = [...nouns.slice(0, -1), pluralize(last)].join('-');
68
+ return { verb: head, resource, path: `/api/${resource}/${head}` };
69
+ }
70
+
71
+ /** The path an action is POSTed to — `actionRoute(name).path`. */
72
+ export function actionPath(name: string): string {
73
+ return actionRoute(name).path;
74
+ }
75
+
76
+ /** `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. */
77
+ export function queryPath(name: string): string {
78
+ return `${QUERY_PATH_PREFIX}/${splitWords(name).join('-')}`;
79
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A browser request's failure, as the error it is: a non-2xx `problem+json` answer back into the
3
+ * `UltimateError` the server threw, and a dispatch that produced no usable answer into
4
+ * `X_CLIENT_TRANSPORT_FAILED`. The one decoder `clientTransport` owns, so every client surface
5
+ * reads a failure off the wire the same way.
6
+ */
7
+
8
+ import { FRAMEWORK_CODE, problemOf, retryForStatus } from './client-wire';
9
+ import { renderFixShellArg } from './error-render';
10
+ import type { ErrorRetry } from './error-retry';
11
+ import { UltimateError } from './errors';
12
+ import { isJsonObject } from './json-object';
13
+
14
+ /** An absolute HTTP(S) link, or nothing: a server's `docs` is data and may be `javascript:`. */
15
+ const HTTP_URL = /^https?:\/\/[^\s]+$/;
16
+
17
+ /**
18
+ * A non-2xx answer. A body naming a framework code IS the server's error, carried verbatim and
19
+ * marked `origin: 'remote'` — the code may be one this bundle never registered. Anything else is
20
+ * a proxy or a gateway answering instead of the app.
21
+ */
22
+ export function problemError(status: number, text: string, url: string): UltimateError {
23
+ const body = problemOf(text);
24
+ const code = body['code'];
25
+ if (typeof code !== 'string' || !FRAMEWORK_CODE.test(code)) {
26
+ return transportFailed(
27
+ 'status',
28
+ `${url} answered HTTP ${status} without a problem+json body naming a framework code`,
29
+ retryForStatus('X_CLIENT_TRANSPORT_FAILED', status),
30
+ { url, status },
31
+ );
32
+ }
33
+ const docs = [body['docs'], body['type']].find(
34
+ (value): value is string => typeof value === 'string' && HTTP_URL.test(value),
35
+ );
36
+ return new UltimateError({
37
+ code,
38
+ cause: text1(body['cause']) ?? text1(body['detail']) ?? `${url} failed with HTTP ${status}`,
39
+ fix: text1(body['fix']) ?? `x errors explain ${renderFixShellArg(code, '<code>')} --json`,
40
+ retry: retryForStatus(code, status),
41
+ // The server's declared keys FIRST, so the three this decoder owns win a collision.
42
+ meta: { ...serverMeta(body['meta'], body['issues']), origin: 'remote', status, url },
43
+ ...(docs === undefined ? {} : { docs }),
44
+ });
45
+ }
46
+
47
+ /**
48
+ * WHICH way no usable answer came back, on `meta.failure` — what a caller branches on, since the
49
+ * code is the same for all three: `network` (no response at all — the one an offline outbox
50
+ * queues), `status` (a non-2xx no framework code explained) and `body` (a 2xx that is not JSON).
51
+ */
52
+ export type TransportFailure = 'network' | 'status' | 'body';
53
+
54
+ /**
55
+ * No usable answer: the network refused, the body stream broke, or a 2xx body was not JSON.
56
+ * `retry` is the caller's to state — a read is always safe to send again, an unkeyed write never.
57
+ */
58
+ export function transportFailed(
59
+ failure: TransportFailure,
60
+ cause: string,
61
+ retry: ErrorRetry | undefined,
62
+ meta: Readonly<Record<string, unknown>>,
63
+ sourceError?: unknown,
64
+ ): UltimateError {
65
+ return new UltimateError({
66
+ code: 'X_CLIENT_TRANSPORT_FAILED',
67
+ cause,
68
+ fix: 'check the network and the gateway in front of the app with x doctor --json, then retry — a write with no idempotencyKey may already have landed',
69
+ ...(retry === undefined ? {} : { retry }),
70
+ meta: { ...meta, failure },
71
+ ...(sourceError === undefined ? {} : { sourceError }),
72
+ });
73
+ }
74
+
75
+ /**
76
+ * The document's `meta`, copied own key by own key: `JSON.parse` mints `__proto__` as a real own
77
+ * key, and assigned onto a plain object it would replace the prototype. `issues` rides along
78
+ * unparsed for the caller whose schema can read it.
79
+ */
80
+ function serverMeta(meta: unknown, issues: unknown): Record<string, unknown> {
81
+ const out: Record<string, unknown> = {};
82
+ if (isJsonObject(meta)) {
83
+ for (const [key, member] of Object.entries(meta)) {
84
+ if (key === '__proto__') continue;
85
+ Object.defineProperty(out, key, { value: member, writable: true, enumerable: true });
86
+ }
87
+ }
88
+ if (Array.isArray(issues)) out['issues'] = issues;
89
+ return out;
90
+ }
91
+
92
+ function text1(value: unknown): string | undefined {
93
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
94
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The refusal a read in flight across `rescope()` rejects with — `X_CLIENT_SCOPE_CHANGED`. Its own
3
+ * module, imported only by the transport: `client-scope.ts` is what `pageClient()` and every
4
+ * `onRescope` subscriber reach, and constructing an `UltimateError` there put core's error registry
5
+ * into a store-only island that never makes a request.
6
+ */
7
+
8
+ import { UltimateError } from './errors';
9
+
10
+ /** Never a UI error: `isSuperseded(error)` answers true for it, and a caller renders nothing. */
11
+ export function scopeChanged(subject: string, issued: number, current: number): UltimateError {
12
+ return new UltimateError({
13
+ code: 'X_CLIENT_SCOPE_CHANGED',
14
+ cause: `${subject} was issued for client scope epoch ${issued} and the page is now at ${current}, so its answer belongs to the previous principal`,
15
+ fix: 'discard this answer and read again — test it with isSuperseded(error) from @ultimat3/core and render nothing for it',
16
+ meta: { subject, issued, current },
17
+ });
18
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The principal fence: which principal the page is acting for, and an epoch that moves exactly
3
+ * when that changes. A response, frame or persisted row from the previous principal must never
4
+ * land in the next one's store, so every client layer reads this one signal.
5
+ *
6
+ * Subscriber contract (`onRescope`): called SYNCHRONOUSLY, once per real change, in registration
7
+ * order, before `rescope()` returns — so by the time a sign-in action's caller continues, every
8
+ * read was aborted, the store wiped its non-persisted records, the socket dropped its channels
9
+ * and the persister switched scope. A throwing subscriber does not stop the others; the first
10
+ * throw is re-raised after all have run. The TRIGGER is not core's: the page bootstrap calls
11
+ * `rescope()` with the principal the server rendered, and sign-in / sign-out call it on success.
12
+ */
13
+
14
+ import type { ScopeCell } from './record-sink';
15
+ import { heldRecords, scopeCell } from './record-sink';
16
+
17
+ export interface ClientScope {
18
+ /**
19
+ * An opaque principal id; `null` for a page rendered for an anonymous visitor; `undefined` for
20
+ * an UNSCOPED page — one rendered for nobody (a shared, cacheable document carries no scope
21
+ * meta). Nothing is persisted while unscoped (plan 101 slice 12): there is no principal to key
22
+ * the rows by, and guessing one is how one visitor's cache restores into another's.
23
+ */
24
+ readonly principal: string | null | undefined;
25
+ /** Bumped once per principal change. Work captured at another epoch is not this scope's. */
26
+ readonly epoch: number;
27
+ }
28
+
29
+ /** Move the page to `principal`. Same principal = no-op: no epoch bump, no notification. */
30
+ export function rescope(principal: string | null): void {
31
+ const cell: ScopeCell = scopeCell();
32
+ const prev = cell.current;
33
+ if (prev.principal === principal) return;
34
+ const next: ClientScope = Object.freeze({ principal, epoch: prev.epoch + 1 });
35
+ cell.current = next;
36
+ // Held early records belong to the principal that just left; they never reach the next store.
37
+ heldRecords()?.clear();
38
+ let failure: { readonly error: unknown } | undefined;
39
+ // A snapshot: a subscriber that unsubscribes (or subscribes) mid-notify changes the NEXT round.
40
+ for (const listener of [...cell.listeners]) {
41
+ try {
42
+ listener(next, prev);
43
+ } catch (error) {
44
+ failure ??= { error };
45
+ }
46
+ }
47
+ if (failure !== undefined) throw failure.error;
48
+ }
49
+
50
+ /** Subscribe to principal changes. Returns the unsubscribe. */
51
+ export function onRescope(fn: (next: ClientScope, prev: ClientScope) => void): () => void {
52
+ const listeners = scopeCell().listeners;
53
+ // Wrapped, so the same function registered twice is two subscriptions with two unsubscribes.
54
+ const entry = (next: ClientScope, prev: ClientScope): void => fn(next, prev);
55
+ listeners.add(entry);
56
+ return (): void => {
57
+ listeners.delete(entry);
58
+ };
59
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The ONE browser HTTP function. Every client surface — `rpc()`, `queryClient()`, a signed upload,
3
+ * the store's refetch — sends through here, so credentials, headers, the error decode, the records
4
+ * envelope and the principal fence are decided once. A GET is a read: abortable on `rescope()`,
5
+ * deduped when a `ClientFlight` is supplied. Anything else is a write: never deduped, never
6
+ * aborted by the fence, and its records never adopted across one.
7
+ *
8
+ * `createClientFlight` is NOT imported here at value level — a caller passes one, and only then
9
+ * does its graph enter the bundle. Neither is `traceHeaders()`: the trace and budget headers come
10
+ * from an outbound slot that `runWithContext`/`startSpan` fill SERVER-side (`outbound-headers.ts`),
11
+ * so a browser, which never has either, carries zero bytes of telemetry, context or logger.
12
+ *
13
+ * Measured `bun build --target=browser --minify` through the barrel, As of 2026-09-22 (before →
14
+ * after the outbound slot): `rpc` 23,164 → 18,119 B; `queryClient` 24,100 → 22,897 B (the rest is
15
+ * `@ultimat3/query`'s anchored `registry.ts`); `clientTransport` 13,571 B; `pageClient` 8,139 B;
16
+ * `UltimateError` 7,679 B. ~7.6 kB of every barrel import is the error-code registry that the
17
+ * anchored `schema-error-codes.ts` registers into — `pageClient` straight from its module is 332 B.
18
+ */
19
+
20
+ import type { Answer, TransportRequest } from './client-dispatch';
21
+ import { dispatch } from './client-dispatch';
22
+ import { transportFailed } from './client-problem';
23
+ import { scopeChanged } from './client-scope-error';
24
+ import type { RecordEnvelope } from './record-envelope';
25
+ import { decodeRecordEnvelope } from './record-envelope';
26
+ import { pageClient, recordSink } from './record-sink';
27
+
28
+ const ONCE = { attempts: 1 } as const;
29
+
30
+ export async function clientTransport<T = unknown>(req: TransportRequest): Promise<T> {
31
+ const read = req.method === 'GET';
32
+ const issued = pageClient().scope.epoch;
33
+ const flight = req.flight;
34
+ const answer: Answer =
35
+ flight === undefined
36
+ ? await dispatch(req, read, issued, undefined)
37
+ : await flight.run({
38
+ // A mutation never joins another mutation, idempotency key or not: the key is for the
39
+ // server's replay, and sharing one dispatch would hide the second intent from it.
40
+ key: read ? flight.keyFor(req.url, { signal: req.signal, fresh: req.fresh }) : undefined,
41
+ abortable: read,
42
+ // A stream is read once, so a second attempt re-sends a body that is already spent —
43
+ // and fails as the network would, until the attempts run out. One attempt, always.
44
+ retry: req.rawBody instanceof ReadableStream ? ONCE : req.retry,
45
+ run: (signal) => dispatch(req, read, issued, signal),
46
+ // `dispatch` makes every wire failure `X_CLIENT_TRANSPORT_FAILED`; a bare throw that
47
+ // reaches the flight is a caller hook's, never the network's.
48
+ classified: true,
49
+ });
50
+ const current = pageClient().scope.epoch;
51
+ // A read that raced the abort still belongs to the previous principal.
52
+ if (read && current !== issued) throw scopeChanged(req.url, issued, current);
53
+ if (req.rawBody !== undefined) return undefined as T;
54
+ const envelope = unwrap(answer, req.url, read);
55
+ // A write that crossed a rescope HAS landed, so its caller is told — but its rows are the
56
+ // previous principal's, and the new scope's store never sees them.
57
+ if (current === issued) {
58
+ adopt(envelope);
59
+ if (answer.enveloped) req.onEnvelope?.(envelope);
60
+ }
61
+ return envelope.data as T;
62
+ }
63
+
64
+ function unwrap(answer: Answer, url: string, read: boolean): RecordEnvelope {
65
+ let body: unknown;
66
+ try {
67
+ body = answer.text === '' ? undefined : (JSON.parse(answer.text) as unknown);
68
+ } catch (error) {
69
+ throw transportFailed(
70
+ 'body',
71
+ `${url} answered 2xx with a body that is not JSON — a proxy answered instead of the app`,
72
+ read ? 'retryable' : undefined,
73
+ { url },
74
+ error,
75
+ );
76
+ }
77
+ return answer.enveloped ? decodeRecordEnvelope(body) : { data: body };
78
+ }
79
+
80
+ /** Adopt, then remove: a key in both is gone, never resurrected by the same answer. */
81
+ function adopt(envelope: RecordEnvelope): void {
82
+ const sink = recordSink();
83
+ if (sink === undefined) return;
84
+ for (const [type, rows] of Object.entries(envelope.records ?? {})) sink.adopt(type, rows);
85
+ for (const [type, keys] of Object.entries(envelope.removed ?? {})) sink.remove(type, keys);
86
+ }
@@ -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
+ }