@ultimat3/core 24.0.0 → 25.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 (76) hide show
  1. package/CLAUDE.md +24 -27
  2. package/README.md +71 -34
  3. package/package.json +4 -7
  4. package/src/actor.ts +9 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +18 -11
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +1 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +0 -33
  29. package/src/config.ts +71 -94
  30. package/src/context.ts +16 -12
  31. package/src/cookie.ts +267 -3
  32. package/src/core-error-codes.ts +2 -0
  33. package/src/cursor-page.ts +41 -0
  34. package/src/cursor.ts +23 -5
  35. package/src/deprecation.ts +77 -0
  36. package/src/dev-secrets.ts +18 -7
  37. package/src/drain-deadline.ts +43 -0
  38. package/src/env-example.ts +9 -29
  39. package/src/errors.ts +18 -9
  40. package/src/exports/error-contract.ts +0 -1
  41. package/src/exports/observability.ts +1 -1
  42. package/src/exports/secrets.ts +3 -0
  43. package/src/finite-option.ts +1 -1
  44. package/src/flight-gate.ts +29 -14
  45. package/src/generation-fence.ts +1 -1
  46. package/src/ids.ts +7 -7
  47. package/src/image/canvas.ts +76 -5
  48. package/src/image/pipeline.ts +17 -5
  49. package/src/image/raster.ts +24 -1
  50. package/src/index.ts +89 -35
  51. package/src/iso-date.ts +1 -1
  52. package/src/lifecycle-errors.ts +1 -1
  53. package/src/lifecycle-readiness.ts +60 -2
  54. package/src/lifecycle-signals.ts +27 -2
  55. package/src/lifecycle-types.ts +96 -0
  56. package/src/lifecycle.ts +44 -133
  57. package/src/locale-direction.ts +1 -1
  58. package/src/logger.ts +16 -7
  59. package/src/mcp-exposure.ts +70 -8
  60. package/src/measurement-actor.ts +16 -1
  61. package/src/metric-errors.ts +32 -0
  62. package/src/metric-registry.ts +151 -0
  63. package/src/metric-series.ts +94 -0
  64. package/src/metrics.ts +9 -255
  65. package/src/page.ts +4 -2
  66. package/src/registrar.ts +1 -0
  67. package/src/retry.ts +25 -5
  68. package/src/secrets-key-file.ts +139 -0
  69. package/src/secrets-store.ts +32 -16
  70. package/src/service.ts +5 -5
  71. package/src/single-flight.ts +1 -1
  72. package/src/telemetry.ts +1 -1
  73. package/src/theme-storage.ts +12 -0
  74. package/src/type-pins.ts +51 -1
  75. package/src/image/fixtures.ts +0 -263
  76. package/src/time-zone-name.ts +0 -14
@@ -11,6 +11,33 @@ import type { ErrorRetry } from './error-retry';
11
11
  import { UltimateError } from './errors';
12
12
  import { isJsonObject } from './json-object';
13
13
 
14
+ /**
15
+ * The longest remote title a client renders. Every framework title is well under it; a body that
16
+ * sends more is not a title, and the overflow is cut rather than trusted.
17
+ */
18
+ export const MAX_REMOTE_TITLE_LENGTH = 120;
19
+
20
+ /**
21
+ * A problem document's `title`, as untrusted DISPLAY text: a non-blank string of at most
22
+ * `MAX_REMOTE_TITLE_LENGTH` code points, control and format characters removed, or nothing. It reaches every renderer as text — `<ErrorState>` writes
23
+ * it as a JSX text node, the terminal and `--json` as a string — and `UltimateError` makes it one
24
+ * line, so no markup or second log line rides in on it. Used only where this realm registered no
25
+ * title for the code (`UltimateErrorInit.remoteTitle`).
26
+ */
27
+ export function remoteTitleOf(value: unknown): string | undefined {
28
+ if (typeof value !== 'string') return undefined;
29
+ // Control characters become a space and format characters (`\p{Cf}`: bidi overrides, isolates,
30
+ // zero-width joiners) are dropped BEFORE the cap, so the cap is the length shown — escaping a
31
+ // newline after cutting rendered two characters for one — and the cut is by CODE POINT, so it
32
+ // never leaves half a surrogate pair.
33
+ const shown = value
34
+ .replace(/\p{Cc}+/gu, ' ')
35
+ .replace(/\p{Cf}/gu, '')
36
+ .replace(/ {2,}/g, ' ');
37
+ const title = Array.from(shown.trim()).slice(0, MAX_REMOTE_TITLE_LENGTH).join('').trim();
38
+ return title === '' ? undefined : title;
39
+ }
40
+
14
41
  /** An absolute HTTP(S) link, or nothing: a server's `docs` is data and may be `javascript:`. */
15
42
  const HTTP_URL = /^https?:\/\/[^\s]+$/;
16
43
 
@@ -19,15 +46,23 @@ const HTTP_URL = /^https?:\/\/[^\s]+$/;
19
46
  * marked `origin: 'remote'` — the code may be one this bundle never registered. Anything else is
20
47
  * a proxy or a gateway answering instead of the app.
21
48
  */
22
- export function problemError(status: number, text: string, url: string): UltimateError {
49
+ export function problemError(
50
+ status: number,
51
+ text: string,
52
+ url: string,
53
+ retryAfterSeconds?: number,
54
+ ): UltimateError {
23
55
  const body = problemOf(text);
24
56
  const code = body['code'];
57
+ // The delay a 429 or 503 named, under core's ONE spelling (`statedDelayMs` reads it), so the
58
+ // flight waits what the responder said whether or not this realm registered the code.
59
+ const stated = retryAfterSeconds === undefined ? {} : { retryAfterSeconds };
25
60
  if (typeof code !== 'string' || !FRAMEWORK_CODE.test(code)) {
26
61
  return transportFailed(
27
62
  'status',
28
63
  `${url} answered HTTP ${status} without a problem+json body naming a framework code`,
29
- retryForStatus('X_CLIENT_TRANSPORT_FAILED', status),
30
- { url, status },
64
+ retryForStatus('X_CLIENT_TRANSPORT_FAILED', status, retryAfterSeconds),
65
+ { url, status, ...stated },
31
66
  );
32
67
  }
33
68
  const docs = [body['docs'], body['type']].find(
@@ -37,13 +72,34 @@ export function problemError(status: number, text: string, url: string): Ultimat
37
72
  code,
38
73
  cause: text1(body['cause']) ?? text1(body['detail']) ?? `${url} failed with HTTP ${status}`,
39
74
  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 },
75
+ remoteTitle: remoteTitleOf(body['title']),
76
+ retry: retryForStatus(code, status, retryAfterSeconds),
77
+ // The server's declared keys FIRST, so the ones this decoder owns win a collision.
78
+ meta: withStatedDelay(
79
+ { ...serverMeta(body['meta'], body['issues']), origin: 'remote', status, url },
80
+ retryAfterSeconds,
81
+ ),
43
82
  ...(docs === undefined ? {} : { docs }),
44
83
  });
45
84
  }
46
85
 
86
+ /**
87
+ * A remote error's `meta` with the delay the HEADER stated, and only that: a `retryAfterSeconds`
88
+ * copied off the problem body is dropped first. The framework's server never writes one there —
89
+ * `registerProblemMeta` refuses framework codes, and an error carrying the key gets a
90
+ * `Retry-After` header from `@ultimat3/http` (exposed cross-origin by its CORS default) — so a body
91
+ * value is never the server's statement, and it must not drive a wait `statedDelayMs` reads. The
92
+ * one rule for both decoders: this one and `@ultimat3/action`'s `RemoteActionError`.
93
+ */
94
+ export function withStatedDelay(
95
+ meta: Readonly<Record<string, unknown>>,
96
+ retryAfterSeconds: number | undefined,
97
+ ): Record<string, unknown> {
98
+ // Object rest copies own keys with CreateDataProperty, so a parsed `__proto__` stays a plain key.
99
+ const { retryAfterSeconds: _fromTheBody, ...kept } = meta;
100
+ return retryAfterSeconds === undefined ? kept : { ...kept, retryAfterSeconds };
101
+ }
102
+
47
103
  /**
48
104
  * WHICH way no usable answer came back, on `meta.failure` — what a caller branches on, since the
49
105
  * code is the same for all three: `network` (no response at all — the one an offline outbox
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A `Retry-After` header off the wire, as the delay a responder named — the one reader core has.
3
+ * `clientTransport` hands it to the decoders, so a browser waits what a 429 or 503 said even when
4
+ * it never loaded the package that registered the code's `retry-after` class.
5
+ */
6
+
7
+ /**
8
+ * The longest stated delay a client will take at its word: one day. A responder naming a year is a
9
+ * misconfigured proxy, not a schedule, and `retryDecision` still clamps to the policy's own `max`.
10
+ */
11
+ export const MAX_RETRY_AFTER_SECONDS = 86_400;
12
+
13
+ /** RFC 9110 delta-seconds: `1*DIGIT`, nothing else — no sign, no fraction, no exponent. */
14
+ const DELTA_SECONDS = /^\d+$/;
15
+
16
+ /**
17
+ * Seconds to wait, or `undefined` when the header is absent or is neither form. A date is measured
18
+ * against the SAME response's `Date` header — the responder's clock against the responder's clock,
19
+ * so a browser whose clock is an hour off still waits what was meant — and a date with no `Date`
20
+ * beside it (or one a cross-origin response does not expose) is ignored, as is `0` or a past date.
21
+ */
22
+ export function retryAfterSecondsOf(
23
+ header: string | null,
24
+ date: string | null,
25
+ ): number | undefined {
26
+ if (header === null) return undefined;
27
+ const value = header.trim();
28
+ if (DELTA_SECONDS.test(value)) return stated(Number(value));
29
+ // RFC 9110's preferred HTTP-date, IMF-fixdate (`Wed, 21 Oct 2015 07:28:00 GMT`), is exactly what
30
+ // `toUTCString()` writes — so the round trip IS the format check, and it also refuses a day
31
+ // `Date.parse` rolled forward (`31 Feb`) and the two obsolete forms, whose parse is engine-defined.
32
+ const at = Date.parse(value);
33
+ const now = date === null ? Number.NaN : Date.parse(date);
34
+ if (!Number.isFinite(at) || !Number.isFinite(now) || new Date(at).toUTCString() !== value) {
35
+ return undefined;
36
+ }
37
+ return stated(Math.ceil((at - now) / 1_000));
38
+ }
39
+
40
+ /**
41
+ * Only a POSITIVE delay is a statement — the rule `@ultimat3/http`'s `retryAfterOf` applies
42
+ * outbound and realtime's sync protocol applies to `held`. A `0` or a date already past reads as
43
+ * "retry now", which is the stampede: every attempt fires at once and a webhook dead-letters in
44
+ * seconds. Unstated, the caller falls back to its own jittered curve instead.
45
+ */
46
+ const stated = (seconds: number): number | undefined =>
47
+ seconds > 0 ? Math.min(seconds, MAX_RETRY_AFTER_SECONDS) : undefined;
@@ -5,7 +5,7 @@
5
5
  * deduped when a `ClientFlight` is supplied. Anything else is a write: never deduped, never
6
6
  * aborted by the fence, and its records never adopted across one.
7
7
  *
8
- * `createClientFlight` is NOT imported here at value level — a caller passes one, and only then
8
+ * `clientFlight` is NOT imported here at value level — a caller passes one, and only then
9
9
  * does its graph enter the bundle. Neither is `traceHeaders()`: the trace and budget headers come
10
10
  * from an outbound slot that `runWithContext`/`startSpan` fill SERVER-side (`outbound-headers.ts`),
11
11
  * so a browser, which never has either, carries zero bytes of telemetry, context or logger.
@@ -44,6 +44,8 @@ export async function clientTransport<T = unknown>(req: TransportRequest): Promi
44
44
  ? flight.keyFor(req.url, { signal: req.signal, fresh: req.fresh })
45
45
  : undefined,
46
46
  abortable: read,
47
+ // The caller's own signal ends a WAIT between attempts; each attempt already reads it.
48
+ signal: req.signal,
47
49
  // A stream is read once, so a second attempt re-sends a body that is already spent —
48
50
  // and fails as the network would, until the attempts run out. One attempt, always.
49
51
  retry: req.rawBody instanceof ReadableStream ? ONCE : req.retry,
@@ -81,9 +81,22 @@ export function problemOf(text: string): Record<string, unknown> {
81
81
  return isJsonObject(body) ? body : {};
82
82
  }
83
83
 
84
+ /** The statuses whose `Retry-After` names a delay to wait (RFC 9110 §10.2.3), and no others. */
85
+ const RETRY_AFTER_STATUSES: ReadonlySet<number> = new Set([429, 503]);
86
+
84
87
  /**
85
88
  * The classification a failure off the wire carries, or `undefined` to leave the code's own
86
- * standing. The STATUS decides only when nobody has declared one for the code: a 503 is the
89
+ * standing.
90
+ *
91
+ * A 429 or 503 that STATED a delay (`retryAfterSeconds`, read off `Retry-After` by
92
+ * `retryAfterSecondsOf`) is `retry-after` by status: the header is the responder's own word, and
93
+ * a browser often never loaded the package that registered the code's class — before this, a 429
94
+ * from `@ultimat3/http` read as `retryable` in an island and was retried on the backoff curve
95
+ * instead of after the delay it named. A registered class yields to it, because `retryable` says
96
+ * less than a stated delay — except `terminal`, which is somebody's decision that the same call
97
+ * fails forever, and a header does not overrule that.
98
+ *
99
+ * Otherwise the STATUS decides only when nobody has declared one for the code: a 503 is the
87
100
  * canonical "send it again", but `X_NOT_IMPLEMENTED` behind a 501 and a config fault behind a 500
88
101
  * are permanent answers somebody already gave, and a status that overrode them would have a client
89
102
  * hammer a service that will refuse it identically forever.
@@ -92,7 +105,18 @@ export function problemOf(text: string): Record<string, unknown> {
92
105
  * so before this every 502 out of a typed client read as "never try again", on the one field the
93
106
  * framework promises a client never has to infer.
94
107
  */
95
- export function retryForStatus(code: string, status: number): ErrorRetry | undefined {
96
- if (declaredErrorRetry(code) !== undefined) return undefined;
108
+ export function retryForStatus(
109
+ code: string,
110
+ status: number,
111
+ retryAfterSeconds?: number,
112
+ ): ErrorRetry | undefined {
113
+ const declared = declaredErrorRetry(code);
114
+ if (
115
+ retryAfterSeconds !== undefined &&
116
+ RETRY_AFTER_STATUSES.has(status) &&
117
+ declared !== 'terminal'
118
+ )
119
+ return 'retry-after';
120
+ if (declared !== undefined) return undefined;
97
121
  return isRetryableStatus(status) ? 'retryable' : undefined;
98
122
  }
@@ -0,0 +1,32 @@
1
+ // Single responsibility: the `ai` block of `app.config.ts` — whether the app's MCP endpoints are
2
+ // mounted. Split from `config.ts`, which sits at its 500-line ceiling; the merge and the
3
+ // screen stay there, as `jobs` and `cache` do.
4
+
5
+ import type { Input } from './config-merge';
6
+
7
+ /**
8
+ * No `path` (deleted in 25.0.0). It moved endpoint #0 off its own `defineAppMcp({ path })` while
9
+ * that endpoint's RFC 9728 metadata and advertised resource still named the defineAppMcp path —
10
+ * with OAuth on, a client was told of a resource at a URL nothing served. Where an endpoint mounts
11
+ * is `defineAppMcp`'s one fact; a written `ai.mcp.path` is refused (`config-removed.ts`).
12
+ */
13
+ export interface McpConfig {
14
+ readonly expose: boolean;
15
+ }
16
+
17
+ /**
18
+ * No `modelEnv` (deleted in 8.0.0; refused by name since 25.0.0, `config-removed.ts`). It named the
19
+ * env KEY holding the model id, "so no model string is baked into the image" — and its only reader
20
+ * was `config.ts`'s own merge, copying input to output, so `modelEnv: 'ANTHROPIC_MODEL'` selected
21
+ * no model: `@ultimat3/ai` reads env for API KEYS only. There is no framework default model either
22
+ * (since 25.0.0): an app names the model on the prompt or on `llm({ model })` — read your own env
23
+ * key and pass it there.
24
+ */
25
+ export interface AiConfig {
26
+ readonly mcp: McpConfig;
27
+ }
28
+
29
+ /** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
30
+ export interface AiConfigInput {
31
+ readonly mcp?: Input<McpConfig> | undefined;
32
+ }
@@ -2,19 +2,17 @@
2
2
  // Split from `config.ts`, which sits at its 500-line ceiling; literals only, so it reads no key.
3
3
 
4
4
  import type { AppConfig } from './config';
5
+ import { JOBS_CONCURRENCY_DEFAULT } from './config-jobs';
6
+ import { DRAIN_DEADLINE_DEFAULT_MS } from './drain-deadline';
5
7
  import { defaultReadinessGraceMs } from './lifecycle-grace';
6
8
  import { ROLES } from './roles';
7
9
 
8
- /** The keys `config-site.ts`, `config-navigation.ts` and `config-islands.ts` default themselves. */
9
- type Sectioned = 'name' | 'site' | 'seo' | 'navigation' | 'islands';
10
+ /** The keys their own files default: `config-site.ts`, `-navigation`, `-islands`, `-mail`. */
11
+ type Sectioned = 'name' | 'site' | 'seo' | 'navigation' | 'islands' | 'mail';
10
12
 
11
13
  export function configDefaults(name: string): Omit<AppConfig, Sectioned> {
12
14
  return {
13
- locales: ['en'],
14
- defaultLocale: 'en',
15
- defaultTimeZone: 'UTC',
16
- defaultCurrency: 'USD',
17
- theme: { defaultMode: 'system', tokens: {} },
15
+ theme: { defaultMode: 'system' },
18
16
  auth: { signInPath: null },
19
17
  pwa: {
20
18
  enabled: false,
@@ -29,18 +27,27 @@ export function configDefaults(name: string): Omit<AppConfig, Sectioned> {
29
27
  cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
30
28
  jobs: {
31
29
  queues: [`${name}-default`],
32
- concurrency: 8,
30
+ concurrency: JOBS_CONCURRENCY_DEFAULT,
33
31
  maxAttempts: 5,
34
32
  backoff: 'exponential',
35
33
  visibilityTimeoutMs: 30_000,
36
34
  },
37
35
  // ON by default since 22.0.0, when the boot began obeying the key: an app with no section
38
36
  // keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
39
- realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
37
+ // `maxSubscriptionsPerActor` unset means the sync node's own default, `DEFAULT_MAX_PER_ACTOR`
38
+ // (1,000) in `@ultimat3/realtime` — a tenth of its 10,000 windows, so no one actor fills it.
39
+ realtime: {
40
+ enabled: true,
41
+ transport: 'memory',
42
+ urlEnv: undefined,
43
+ maxSubscriptionsPerActor: undefined,
44
+ // Unset: the sync node's `DEFAULT_MAX_SOCKETS_PER_ACTOR` (16).
45
+ maxSocketsPerActor: undefined,
46
+ },
40
47
  notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
41
- ai: { mcp: { expose: true, path: '/mcp' } },
48
+ ai: { mcp: { expose: true } },
42
49
  // Read from the process env when the config is DEFINED — the same env the drain will run in.
43
- drain: { readinessGraceMs: defaultReadinessGraceMs() },
50
+ drain: { readinessGraceMs: defaultReadinessGraceMs(), deadlineMs: DRAIN_DEADLINE_DEFAULT_MS },
44
51
  health: { readiness: 'dependencies' },
45
52
  };
46
53
  }
@@ -4,16 +4,6 @@
4
4
 
5
5
  export const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
6
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
7
  /**
18
8
  * Appended only when a tier name is what failed, and it names the rename rather than the rule: the
19
9
  * three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
@@ -25,7 +25,7 @@ export interface HealthConfig {
25
25
  }
26
26
 
27
27
  /**
28
- * How a SIGTERM'd process leaves the load balancer. Read by `@ultimat3/http`'s `createServer`
28
+ * How a SIGTERM'd process leaves the load balancer. Read by `@ultimat3/http`'s `httpServer`
29
29
  * (`ServerOptions.drain`), which hands it to core's `configureLifecycle`.
30
30
  */
31
31
  export interface DrainConfig {
@@ -33,9 +33,16 @@ export interface DrainConfig {
33
33
  * `/readyz` answers 503 for this long before the listener closes, so endpoints stop routing here
34
34
  * first. Default 0 in development/test and 5000 everywhere else — a process naming NO environment
35
35
  * included. A whole number, 0–60000. The chart's `terminationGracePeriodSeconds` must exceed it
36
- * plus the drain budget.
36
+ * plus `deadlineMs`.
37
37
  */
38
38
  readonly readinessGraceMs: number;
39
+ /**
40
+ * The drain budget: how long a SIGTERM'd process has to finish what it holds — in-flight requests,
41
+ * a running job — after the grace, before the lifecycle abandons the rest. Applied to EVERY role,
42
+ * so this is the knob that gives a long job room to finish on a deploy. Default 25000. A whole
43
+ * number, 1–3600000. The ONE drain budget, the web role's included.
44
+ */
45
+ readonly deadlineMs: number;
39
46
  }
40
47
 
41
48
  /** Why a value is not a readiness mode, or `undefined` when it is one. */
@@ -0,0 +1,51 @@
1
+ // Single responsibility: `jobs.concurrency`'s domain — one slot count for every queue a worker
2
+ // serves, or a table of slots per queue (issue #676). Takes the value as `unknown` and names no
3
+ // other key, so `config.ts` keeps its one-line-per-key rule list.
4
+
5
+ import { countIssue } from './config-count';
6
+ import { describeValue } from './error-render';
7
+ import { isJsonObject } from './json-object';
8
+
9
+ /**
10
+ * `jobs.concurrency` when no layer says, and what a queue the table does NOT name gets: the
11
+ * number an app that never wrote `concurrency` runs every queue at. ONE constant, read by
12
+ * `@ultimat3/jobs`' `jobWorker` for its own default too — it kept a 5 until 25.0.0, so a
13
+ * `concurrency: { banks: 4 }` left `default` at 5 beside an unset config's 8.
14
+ */
15
+ export const JOBS_CONCURRENCY_DEFAULT = 8;
16
+
17
+ /** One slot count for every queue, or slots per queue name — `jobWorker`'s own `concurrency`. */
18
+ export type JobsConcurrency = number | Readonly<Record<string, number>>;
19
+
20
+ const KEY = 'jobs.concurrency';
21
+
22
+ /** A queue name is shown quoted: it is the typo, and a blank one is otherwise invisible. */
23
+ const quoted = (name: string): string => `"${name}"`;
24
+
25
+ /**
26
+ * Why `value` is not a `jobs.concurrency`, one issue per wrong entry. A table must name at least
27
+ * one queue: `{}` reads as "every queue at the default", which the number form already says — an
28
+ * empty table is a config whose author meant to write something and did not.
29
+ */
30
+ export function jobsConcurrencyIssues(value: unknown): readonly string[] {
31
+ if (typeof value === 'number') {
32
+ const count = countIssue(KEY, value, 1);
33
+ return count === undefined ? [] : [count];
34
+ }
35
+ if (!isJsonObject(value)) {
36
+ return [
37
+ `${KEY} must be a whole number of at least 1, or a table of slots per queue like { mail: 4 }, not ${describeValue(value)}`,
38
+ ];
39
+ }
40
+ const entries = Object.entries(value);
41
+ if (entries.length === 0) {
42
+ return [`${KEY} must name at least one queue, or be a number for every queue`];
43
+ }
44
+ const issues: string[] = [];
45
+ for (const [queue, slots] of entries) {
46
+ if (queue.trim() === '') issues.push(`${KEY} names ${quoted(queue)}, not a queue`);
47
+ const count = countIssue(`${KEY}.${queue}`, slots, 1);
48
+ if (count !== undefined) issues.push(count);
49
+ }
50
+ return issues;
51
+ }
@@ -0,0 +1,170 @@
1
+ // Single responsibility: `app.config.ts` is a CLOSED shape — a key the framework does not declare,
2
+ // at any depth and in any layer, is refused by path with the key it most likely meant. A key that
3
+ // is merged and read by nothing is the switch with no wire, and a typo (`drian`) is the same defect.
4
+
5
+ import { BASE_FIX } from './config-fixes';
6
+ import type { MailRetainMimeConfig } from './config-mail';
7
+ import type { NavigationConfig, SpeculationConfig } from './config-navigation';
8
+ import type {
9
+ PwaColors,
10
+ PwaConfig,
11
+ PwaImage,
12
+ PwaSchemeColors,
13
+ PwaScreenshot,
14
+ PwaShortcut,
15
+ } from './config-pwa';
16
+ import { ConfigInvalidError } from './errors';
17
+ import { isJsonObject } from './json-object';
18
+ import { nearestName } from './nearest-name';
19
+
20
+ type Unlisted<T, K extends readonly string[]> = Exclude<keyof T & string, K[number]>;
21
+
22
+ /**
23
+ * Every key of `T`, or a type error naming the one left out: a member added to an interface below
24
+ * without a row here would be a key the screen refuses, so the omission is `tsc`'s, not an app's.
25
+ */
26
+ const keysOf =
27
+ <T>() =>
28
+ <const K extends readonly (keyof T & string)[]>(
29
+ keys: K & ([Unlisted<T, K>] extends [never] ? unknown : { readonly unlisted: Unlisted<T, K> }),
30
+ ): K =>
31
+ keys;
32
+
33
+ /**
34
+ * The sections the DEFAULTS cannot show: optional members with no default (`pwa.id`), blocks that
35
+ * default to `undefined` or `false` (`pwa.colors`, `mail.retainMime`), list elements (`[]`), and
36
+ * `navigation`, which `defineConfig` leaves out of its shape reference. Everywhere else the known
37
+ * keys are the defaults' own — derived, never a second hand list.
38
+ */
39
+ export const SECTION_KEYS: Readonly<Record<string, readonly string[]>> = Object.freeze({
40
+ pwa: keysOf<PwaConfig>()([
41
+ 'enabled',
42
+ 'offline',
43
+ 'backgroundSync',
44
+ 'push',
45
+ 'name',
46
+ 'colors',
47
+ 'id',
48
+ 'description',
49
+ 'categories',
50
+ 'shortcuts',
51
+ 'screenshots',
52
+ ]),
53
+ 'pwa.colors': keysOf<PwaColors>()(['light', 'dark']),
54
+ 'pwa.colors.light': keysOf<PwaSchemeColors>()(['themeColor', 'backgroundColor']),
55
+ 'pwa.colors.dark': keysOf<PwaSchemeColors>()(['themeColor', 'backgroundColor']),
56
+ 'pwa.shortcuts[]': keysOf<PwaShortcut>()(['name', 'shortName', 'description', 'url', 'icons']),
57
+ 'pwa.shortcuts[].icons[]': keysOf<PwaImage>()(['src', 'sizes', 'type', 'purpose']),
58
+ 'pwa.screenshots[]': keysOf<PwaScreenshot>()(['src', 'sizes', 'type', 'formFactor', 'label']),
59
+ 'mail.retainMime': keysOf<MailRetainMimeConfig>()(['maxBytes']),
60
+ navigation: keysOf<NavigationConfig>()(['client', 'speculation']),
61
+ 'navigation.speculation': keysOf<SpeculationConfig>()(['prefetch', 'exclude']),
62
+ });
63
+
64
+ /**
65
+ * The positions whose KEYS the app chooses, never walked: each is a `PwaText`, one string or a
66
+ * record keyed by locale tag (`{ en: …, 'es-co': … }`), and a locale is not a key this file knows.
67
+ */
68
+ export const OPEN_CONFIG_PATHS: ReadonlySet<string> = new Set([
69
+ 'pwa.description',
70
+ 'pwa.shortcuts[].name',
71
+ 'pwa.shortcuts[].shortName',
72
+ 'pwa.shortcuts[].description',
73
+ 'pwa.screenshots[].src',
74
+ 'pwa.screenshots[].label',
75
+ ]);
76
+
77
+ export interface UnknownConfigKey {
78
+ /** As an app reads it: `pwa.shortcuts[0].href`. */
79
+ readonly path: string;
80
+ /** The keys its section does hold, in declared order. */
81
+ readonly known: readonly string[];
82
+ /** The nearest of `known`, when one is close enough to be the typo. */
83
+ readonly near: string | undefined;
84
+ }
85
+
86
+ const join = (path: string, key: string): string => (path === '' ? key : `${path}.${key}`);
87
+
88
+ /**
89
+ * CLOSED BY DEFAULT: an object is walked wherever it is written, its known keys the reference's
90
+ * plus `SECTION_KEYS`' — so an object at a key whose default is `undefined` (`realtime.urlEnv`)
91
+ * knows no keys and every one is refused. Two exceptions: `OPEN_CONFIG_PATHS`, and a position the
92
+ * defaults hold a VALUE at (`site.origin: null`), whose own rule names the wrong value in better
93
+ * words than "unknown key".
94
+ */
95
+ /** `SECTION_KEYS`' row for `path`, own keys only: a layer key `constructor` must not read the prototype. */
96
+ const sectionKeysAt = (path: string): readonly string[] | undefined =>
97
+ Object.hasOwn(SECTION_KEYS, path) ? SECTION_KEYS[path] : undefined;
98
+
99
+ function walk(
100
+ reference: unknown,
101
+ layer: unknown,
102
+ at: { readonly schema: string; readonly shown: string },
103
+ found: UnknownConfigKey[],
104
+ ): void {
105
+ if (OPEN_CONFIG_PATHS.has(at.schema)) return;
106
+ if (Array.isArray(layer)) {
107
+ // A list's elements are walked only where an element is a declared section (`pwa.shortcuts`);
108
+ // `jobs.queues` holds names, and a wrong element there is the name screen's to refuse.
109
+ const element = `${at.schema}[]`;
110
+ if (sectionKeysAt(element) === undefined) return;
111
+ for (const [index, item] of layer.entries()) {
112
+ walk(undefined, item, { schema: element, shown: `${at.shown}[${index}]` }, found);
113
+ }
114
+ return;
115
+ }
116
+ if (!isJsonObject(layer)) return;
117
+ const declared = sectionKeysAt(at.schema);
118
+ if (declared === undefined && reference !== undefined && !isJsonObject(reference)) return;
119
+ const ref = isJsonObject(reference) ? reference : {};
120
+ const known = [...new Set([...Object.keys(ref), ...(declared ?? [])])];
121
+ for (const [key, value] of Object.entries(layer)) {
122
+ if (value === undefined) continue;
123
+ const next = { schema: join(at.schema, key), shown: join(at.shown, key) };
124
+ if (!known.includes(key)) {
125
+ found.push({ path: next.shown, known, near: nearestName(key, known) });
126
+ continue;
127
+ }
128
+ walk(ref[key], value, next, found);
129
+ }
130
+ }
131
+
132
+ /** Every key one layer writes that `reference` (the defaults) and `SECTION_KEYS` do not declare. */
133
+ export function unknownConfigKeys(reference: unknown, layer: unknown): readonly UnknownConfigKey[] {
134
+ const found: UnknownConfigKey[] = [];
135
+ walk(reference, layer, { schema: '', shown: '' }, found);
136
+ return found;
137
+ }
138
+
139
+ const parentOf = (path: string): string => {
140
+ const cut = path.lastIndexOf('.');
141
+ return cut === -1 ? '' : path.slice(0, cut);
142
+ };
143
+
144
+ const issueOf = (key: UnknownConfigKey): string =>
145
+ `${key.path} is not an app.config.ts key${key.near === undefined ? '' : ` — did you mean ${key.near}?`}`;
146
+
147
+ const holds = (key: UnknownConfigKey): string =>
148
+ key.known.length === 0
149
+ ? `${parentOf(key.path)} is a value, not a section`
150
+ : `${parentOf(key.path) || 'the top level'} holds ${key.known.join(', ')}`;
151
+
152
+ const fixOf = (key: UnknownConfigKey): string =>
153
+ key.near === undefined
154
+ ? `delete ${key.path} from app.config.ts — ${holds(key)}`
155
+ : `rename ${key.path} to ${join(parentOf(key.path), key.near)} in app.config.ts`;
156
+
157
+ /**
158
+ * Every layer asked, so a stale `config/realtime.ts` overlay is caught as surely as the base, and
159
+ * every unknown key named in ONE refusal rather than one per restart.
160
+ */
161
+ export function refuseUnknownKeys(reference: unknown, layers: readonly unknown[]): void {
162
+ const unknown = layers.flatMap((layer) => unknownConfigKeys(reference, layer));
163
+ if (unknown.length === 0) return;
164
+ const issues = unknown.map(issueOf);
165
+ throw new ConfigInvalidError({
166
+ cause: issues.join('; '),
167
+ fix: [...new Set(unknown.map(fixOf)), BASE_FIX].join('. '),
168
+ meta: { issues, unknown: [...new Set(unknown.map((key) => key.path))] },
169
+ });
170
+ }
@@ -0,0 +1,73 @@
1
+ // Single responsibility: the `mail` block of `app.config.ts` — what the boot hands
2
+ // `@ultimat3/mail`'s `selectMailDriver` that an environment cannot say. Its own file for the
3
+ // reason `config-islands.ts` is: shape, merge and screen are one subject, and `config.ts` is at its
4
+ // 500-line ceiling.
5
+
6
+ import { describeValue } from './error-render';
7
+ import { isJsonObject } from './json-object';
8
+
9
+ /**
10
+ * Keep the exact MIME bytes the SMTP and SES transports hand their provider, bounded — the audit
11
+ * half of a send (`SendResult.mime`). `false` keeps nothing, the default. `maxBytes` unset is
12
+ * mail's own default cap; its ceiling is mail's to enforce, at driver selection
13
+ * (`X_CONFIG_INVALID`), because tier 0 cannot import the package that owns the number. Resend
14
+ * builds the MIME on its own side, so asking for it with `RESEND_API_KEY` set refuses the boot.
15
+ *
16
+ * Data only: the durable `onRetained` callback is a function and lives with the code that writes
17
+ * the audit row, not in a config value.
18
+ */
19
+ export interface MailRetainMimeConfig {
20
+ readonly maxBytes: number | undefined;
21
+ }
22
+
23
+ export interface MailConfig {
24
+ readonly retainMime: MailRetainMimeConfig | false;
25
+ }
26
+
27
+ export interface MailSection {
28
+ readonly mail: MailConfig;
29
+ }
30
+
31
+ export interface MailSectionInput {
32
+ readonly mail?:
33
+ | {
34
+ /** `true` is `{}` — retained under mail's default cap. */
35
+ readonly retainMime?: boolean | { readonly maxBytes?: number | undefined } | undefined;
36
+ }
37
+ | undefined;
38
+ }
39
+
40
+ /** The last layer that said it wins, as every scalar does. `true` is the default cap. */
41
+ export function mergeMail(layers: readonly MailSectionInput[]): MailSection {
42
+ let retainMime: MailConfig['retainMime'] = false;
43
+ for (const layer of layers) {
44
+ const said = layer.mail?.retainMime;
45
+ if (said === undefined) continue;
46
+ if (said === true) retainMime = { maxBytes: undefined };
47
+ else if (isJsonObject(said)) retainMime = { maxBytes: said['maxBytes'] as number | undefined };
48
+ // `false`, or a wrong value from an untyped file — `'yes'`, `[]` — carried through AS WRITTEN
49
+ // for `mailIssues` to refuse: read as an object it became `{ maxBytes: undefined }`, retention
50
+ // switched on. `isJsonObject`, never `typeof`: a list is an object to `typeof`.
51
+ else retainMime = said as MailConfig['retainMime'];
52
+ }
53
+ return { mail: { retainMime } };
54
+ }
55
+
56
+ /** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
57
+ export function mailIssues(config: MailSection, issues: string[]): void {
58
+ // `unknown`: an untyped config file reaches this validator with whatever it wrote.
59
+ const retain: unknown = config.mail.retainMime;
60
+ if (retain === false) return;
61
+ if (!isJsonObject(retain)) {
62
+ issues.push(
63
+ `mail.retainMime must be true, false or { maxBytes }, not ${describeValue(retain)}`,
64
+ );
65
+ return;
66
+ }
67
+ const maxBytes: unknown = retain['maxBytes'];
68
+ if (maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || (maxBytes as number) < 1)) {
69
+ issues.push(
70
+ `mail.retainMime.maxBytes must be a whole number of bytes above 0, not ${describeValue(maxBytes)}`,
71
+ );
72
+ }
73
+ }
@@ -36,7 +36,7 @@ export function layered<T extends object>(base: T, patches: readonly (Input<T> |
36
36
  return patches.reduce<T>((out, patch) => section(out, patch), base);
37
37
  }
38
38
 
39
- /** A whole-value key (`locales`, `roles`): the last layer that said something wins. */
39
+ /** A whole-value key (`roles`, `jobs.queues`): the last layer that said something wins. */
40
40
  export function lastSaid<T>(base: T, values: readonly (T | undefined)[]): T {
41
41
  let out = base;
42
42
  for (const value of values) if (value !== undefined) out = value;