@ultimat3/core 29.3.2 → 30.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 (89) hide show
  1. package/CLAUDE.md +45 -81
  2. package/README.md +137 -10
  3. package/package.json +19 -3
  4. package/src/address-class.ts +53 -12
  5. package/src/aws-sigv4.ts +104 -8
  6. package/src/breaker.ts +177 -0
  7. package/src/client-dispatch.ts +76 -17
  8. package/src/client-flight.ts +4 -4
  9. package/src/client-paths-pins.ts +92 -0
  10. package/src/client-problem.ts +25 -1
  11. package/src/client-scope.ts +3 -1
  12. package/src/client-stream.ts +106 -0
  13. package/src/client-transport.ts +16 -2
  14. package/src/config-assets.ts +64 -0
  15. package/src/config-client-errors.ts +132 -0
  16. package/src/config-defaults.ts +18 -4
  17. package/src/config-failure-capture.ts +70 -0
  18. package/src/config-health.ts +8 -0
  19. package/src/config-islands.ts +1 -1
  20. package/src/config-keys.ts +2 -0
  21. package/src/config-site.ts +16 -0
  22. package/src/config-time.ts +40 -0
  23. package/src/config.ts +37 -26
  24. package/src/context.ts +92 -10
  25. package/src/core-error-codes.ts +19 -0
  26. package/src/cursor.ts +7 -3
  27. package/src/deadline-signal.ts +57 -0
  28. package/src/declared-errors-openapi.ts +70 -0
  29. package/src/declared-errors-scan.ts +79 -0
  30. package/src/declared-errors.ts +185 -0
  31. package/src/define-error.ts +200 -0
  32. package/src/deprecation.ts +14 -3
  33. package/src/drain-deadline.ts +22 -0
  34. package/src/env.ts +8 -3
  35. package/src/error-fingerprint.ts +95 -0
  36. package/src/error-reporter-chain.ts +45 -0
  37. package/src/error-reporter-sentry.ts +4 -0
  38. package/src/error-reporter.ts +14 -1
  39. package/src/error-status-declared.ts +82 -0
  40. package/src/exercise-errors.ts +41 -0
  41. package/src/exercise-run.ts +176 -0
  42. package/src/exports/error-contract.ts +7 -0
  43. package/src/exports/observability.ts +26 -0
  44. package/src/failure-capture-flush.ts +93 -0
  45. package/src/failure-capture-mask.ts +54 -0
  46. package/src/failure-capture.ts +205 -0
  47. package/src/fill-wire.ts +30 -0
  48. package/src/finite-option.ts +7 -0
  49. package/src/health-disclosure.ts +2 -2
  50. package/src/hmac.ts +47 -0
  51. package/src/index.ts +172 -9
  52. package/src/json-storable.ts +21 -0
  53. package/src/keyed-fingerprint.ts +3 -7
  54. package/src/lifecycle.ts +5 -1
  55. package/src/logger.ts +1 -1
  56. package/src/otlp-metric-exporter.ts +7 -1
  57. package/src/otlp-post.ts +147 -0
  58. package/src/otlp-span-exporter.ts +7 -1
  59. package/src/otlp.ts +4 -37
  60. package/src/outbound-attempt.ts +235 -0
  61. package/src/outbound-dial.ts +103 -0
  62. package/src/outbound-errors.ts +193 -0
  63. package/src/outbound-hops.ts +135 -0
  64. package/src/outbound-read.ts +103 -0
  65. package/src/outbound-subpath.ts +26 -0
  66. package/src/outbound-types.ts +108 -0
  67. package/src/outbound.ts +223 -0
  68. package/src/page-meta.ts +8 -0
  69. package/src/page.ts +9 -0
  70. package/src/record-envelope-openapi.ts +6 -0
  71. package/src/record-envelope.ts +33 -9
  72. package/src/record-sink.ts +13 -0
  73. package/src/redis-client.ts +78 -0
  74. package/src/registrar.ts +11 -0
  75. package/src/render-node.ts +13 -0
  76. package/src/render-nodes.ts +137 -0
  77. package/src/retry-loop.ts +197 -0
  78. package/src/retry.ts +47 -145
  79. package/src/roles.ts +2 -2
  80. package/src/route-path-merge.ts +55 -0
  81. package/src/route-path-template.ts +124 -0
  82. package/src/runtime-metrics.ts +16 -1
  83. package/src/same-origin.ts +18 -2
  84. package/src/session-lost.ts +115 -0
  85. package/src/single-flight.ts +75 -40
  86. package/src/source-mask.ts +36 -2
  87. package/src/tool-description.ts +29 -0
  88. package/src/visible-interval.ts +153 -0
  89. package/src/webhook-signature.ts +9 -11
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The pinned URLs (`http: { path, method }`) a server stamps into its document, and the browser's
3
+ * read of them: `{"a":{"<action>":"<METHOD> <path>"},"q":{"<query>":"<path>"}}` under
4
+ * `<meta name="ultimate-pins">`. One format, written and read here, so a by-name browser caller
5
+ * goes where the declaration put the primitive and no browser ref restates the pin.
6
+ */
7
+
8
+ import { CLIENT_PINS_META } from './page-meta';
9
+
10
+ export type PinnedActionMethod = 'POST' | 'PUT' | 'PATCH' | 'DELETE';
11
+
12
+ const METHODS: readonly PinnedActionMethod[] = ['POST', 'PUT', 'PATCH', 'DELETE'];
13
+
14
+ export interface ActionPin {
15
+ readonly path: string;
16
+ readonly method: PinnedActionMethod;
17
+ }
18
+
19
+ /** What a server pins, by registered name: an action's path and verb, a query's path. */
20
+ export interface ClientPins {
21
+ readonly actions: Readonly<Record<string, ActionPin>>;
22
+ readonly queries: Readonly<Record<string, string>>;
23
+ }
24
+
25
+ const sorted = <T>(record: Readonly<Record<string, T>>): [string, T][] =>
26
+ Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
27
+
28
+ /**
29
+ * The meta's content, or `undefined` when nothing is pinned — no tag, so an app that pins nothing
30
+ * renders the bytes it always did. Sorted, so one registry is one byte string whatever its order.
31
+ */
32
+ export function encodeClientPins(pins: ClientPins): string | undefined {
33
+ const actions = sorted(pins.actions);
34
+ const queries = sorted(pins.queries);
35
+ if (actions.length === 0 && queries.length === 0) return undefined;
36
+ return JSON.stringify({
37
+ ...(actions.length === 0
38
+ ? {}
39
+ : {
40
+ a: Object.fromEntries(actions.map(([name, pin]) => [name, `${pin.method} ${pin.path}`])),
41
+ }),
42
+ ...(queries.length === 0 ? {} : { q: Object.fromEntries(queries) }),
43
+ });
44
+ }
45
+
46
+ /** The last stamp parsed, by its content: a page asks per call, and parses once per document. */
47
+ let parsed: { readonly content: string; readonly table: unknown } | undefined;
48
+
49
+ /**
50
+ * `table[kind][name]` of the document's stamp, or `undefined`: no document (a server, a worker),
51
+ * no stamp (nothing pinned), or content this build cannot read. Only the server writes the tag, so
52
+ * an unreadable one is a mismatched build — and the derived URL is the one it would have answered.
53
+ */
54
+ function stamped(kind: 'a' | 'q', name: string): unknown {
55
+ const doc: { querySelector?: (selector: string) => { content?: unknown } | null } | undefined =
56
+ Reflect.get(globalThis, 'document');
57
+ const content = doc?.querySelector?.(`meta[name="${CLIENT_PINS_META}"]`)?.content;
58
+ if (typeof content !== 'string') return undefined;
59
+ if (parsed?.content !== content) {
60
+ let table: unknown;
61
+ try {
62
+ table = JSON.parse(content);
63
+ } catch {
64
+ table = undefined;
65
+ }
66
+ parsed = { content, table };
67
+ }
68
+ const section: unknown =
69
+ typeof parsed.table === 'object' && parsed.table !== null
70
+ ? Reflect.get(parsed.table, kind)
71
+ : undefined;
72
+ if (typeof section !== 'object' || section === null || !Object.hasOwn(section, name)) {
73
+ return undefined;
74
+ }
75
+ return Reflect.get(section, name);
76
+ }
77
+
78
+ /** The action `name`'s pinned path and verb, as this document's server serves it. */
79
+ export function renderedActionPin(name: string): ActionPin | undefined {
80
+ const value = stamped('a', name);
81
+ if (typeof value !== 'string') return undefined;
82
+ const space = value.indexOf(' ');
83
+ const method = METHODS.find((known) => known === value.slice(0, space));
84
+ const path = value.slice(space + 1);
85
+ return method === undefined || !path.startsWith('/') ? undefined : { path, method };
86
+ }
87
+
88
+ /** The query `name`'s pinned path, as this document's server serves it. */
89
+ export function renderedQueryPin(name: string): string | undefined {
90
+ const value = stamped('q', name);
91
+ return typeof value === 'string' && value.startsWith('/') ? value : undefined;
92
+ }
@@ -76,7 +76,10 @@ export function problemError(
76
76
  retry: retryForStatus(code, status, retryAfterSeconds),
77
77
  // The server's declared keys FIRST, so the ones this decoder owns win a collision.
78
78
  meta: withStatedDelay(
79
- { ...serverMeta(body['meta'], body['issues']), origin: 'remote', status, url },
79
+ withRequestId(
80
+ { ...serverMeta(body['meta'], body['issues']), origin: 'remote', status, url },
81
+ body['requestId'],
82
+ ),
80
83
  retryAfterSeconds,
81
84
  ),
82
85
  ...(docs === undefined ? {} : { docs }),
@@ -100,6 +103,27 @@ export function withStatedDelay(
100
103
  return retryAfterSeconds === undefined ? kept : { ...kept, retryAfterSeconds };
101
104
  }
102
105
 
106
+ /** A longer `requestId` is not an id the server minted, and it would ride into every report. */
107
+ const MAX_REQUEST_ID_LENGTH = 127;
108
+
109
+ /**
110
+ * A remote error's `meta` with the document's ROOT `requestId`, and only that: one copied off the
111
+ * body's `meta` is dropped first, so a key a server declared cannot pose as the id support is
112
+ * given. Absent unless it is a non-empty string this long at most. The rule for both decoders.
113
+ */
114
+ export function withRequestId(
115
+ meta: Readonly<Record<string, unknown>>,
116
+ requestId: unknown,
117
+ ): Record<string, unknown> {
118
+ // Object rest copies own keys with CreateDataProperty, so a parsed `__proto__` stays a plain key.
119
+ const { requestId: _fromTheMeta, ...kept } = meta;
120
+ const carried =
121
+ typeof requestId === 'string' &&
122
+ requestId.length > 0 &&
123
+ requestId.length <= MAX_REQUEST_ID_LENGTH;
124
+ return carried ? { ...kept, requestId } : kept;
125
+ }
126
+
103
127
  /**
104
128
  * WHICH way no usable answer came back, on `meta.failure` — what a caller branches on, since the
105
129
  * code is the same for all three: `network` (no response at all — the one an offline outbox
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { ScopeCell } from './record-sink';
15
- import { heldRecords, scopeCell } from './record-sink';
15
+ import { heldRecords, scopeCell, sessionLostCell } from './record-sink';
16
16
 
17
17
  export interface ClientScope {
18
18
  /**
@@ -35,6 +35,8 @@ export function rescope(principal: string | null): void {
35
35
  cell.current = next;
36
36
  // Held early records belong to the principal that just left; they never reach the next store.
37
37
  heldRecords()?.clear();
38
+ // A new principal has a session of its own: the loss was the previous one's.
39
+ sessionLostCell().lost = false;
38
40
  let failure: { readonly error: unknown } | undefined;
39
41
  // A snapshot: a subscriber that unsubscribes (or subscribes) mid-notify changes the NEXT round.
40
42
  for (const listener of [...cell.listeners]) {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The browser's streamed read of a stream-declaring action: `POST`, `accept: text/event-stream`,
3
+ * through `client-dispatch.ts`'s `dispatchStream`, never `EventSource` (cannot POST). Each `chunk`
4
+ * frame yields as it arrives, `done` is the return, `error` throws the problem it carries.
5
+ */
6
+
7
+ import type { StreamDispatch } from './client-dispatch';
8
+ import { dispatchStream } from './client-dispatch';
9
+ import { problemError, transportFailed } from './client-problem';
10
+ import { settleAnswer } from './client-transport';
11
+ import { notifyClientWrite } from './client-writes';
12
+ import { RECORDS_HEADER } from './record-envelope';
13
+ import { pageClient } from './record-sink';
14
+ import { DATES_HEADER } from './wire-dates';
15
+
16
+ /** What `clientStream` reads: a write's request, never a raw body or a flight. */
17
+ export type StreamRequest = StreamDispatch;
18
+
19
+ interface Frame {
20
+ readonly event: string;
21
+ readonly data: string;
22
+ }
23
+
24
+ /** Splits a growing buffer into complete frames; the incomplete tail is handed back. */
25
+ export function takeFrames(buffer: string): { readonly frames: Frame[]; readonly rest: string } {
26
+ const frames: Frame[] = [];
27
+ const blocks = buffer.split(/\r?\n\r?\n/);
28
+ const rest = blocks.pop() ?? '';
29
+ for (const block of blocks) {
30
+ let event = 'message';
31
+ const data: string[] = [];
32
+ for (const line of block.split(/\r?\n/)) {
33
+ // A `:` line is a comment — the server's keep-alive.
34
+ if (line.startsWith(':')) continue;
35
+ if (line.startsWith('event:')) event = line.slice(6).trim();
36
+ else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
37
+ }
38
+ if (data.length > 0) frames.push({ event, data: data.join('\n') });
39
+ }
40
+ return { frames, rest };
41
+ }
42
+
43
+ export async function* clientStream<TChunk = unknown, TOutput = unknown>(
44
+ req: StreamRequest,
45
+ ): AsyncGenerator<TChunk, TOutput, undefined> {
46
+ const issued = pageClient().scope.epoch;
47
+ try {
48
+ const response = await dispatchStream(req);
49
+ // A server that answered JSON (no stream declared, or a proxy) gave the whole answer at once —
50
+ // `call()`'s answer, so it is settled exactly as `call()` settles it.
51
+ if (!(response.headers.get('content-type') ?? '').startsWith('text/event-stream')) {
52
+ const answer = {
53
+ text: await response.text(),
54
+ enveloped: response.headers.get(RECORDS_HEADER) === '1',
55
+ dates: response.headers.get(DATES_HEADER),
56
+ };
57
+ return settleAnswer(answer, req, false, issued) as TOutput;
58
+ }
59
+ const reader = response.body?.getReader();
60
+ if (reader === undefined)
61
+ throw transportFailed('body', `${req.url} answered a stream with no body`, undefined, {
62
+ url: req.url,
63
+ });
64
+ const decoder = new TextDecoder();
65
+ let buffer = '';
66
+ try {
67
+ for (;;) {
68
+ const { done, value } = await reader.read();
69
+ buffer += decoder.decode(value, { stream: !done });
70
+ const taken = takeFrames(done ? `${buffer}\n\n` : buffer);
71
+ buffer = taken.rest;
72
+ for (const frame of taken.frames) {
73
+ if (frame.event === 'chunk') yield JSON.parse(frame.data) as TChunk;
74
+ else if (frame.event === 'done') return JSON.parse(frame.data) as TOutput;
75
+ else if (frame.event === 'error') {
76
+ const status = problemStatus(frame.data);
77
+ throw (
78
+ req.decodeError?.(status, frame.data) ?? problemError(status, frame.data, req.url)
79
+ );
80
+ }
81
+ }
82
+ if (done) break;
83
+ }
84
+ } finally {
85
+ // A consumer that stopped early releases the body: the server sees the disconnect and aborts.
86
+ await reader.cancel().catch(() => undefined);
87
+ }
88
+ throw transportFailed(
89
+ 'body',
90
+ `${req.url} closed its stream before a done or error frame`,
91
+ undefined,
92
+ { url: req.url },
93
+ );
94
+ } finally {
95
+ notifyClientWrite(req.url);
96
+ }
97
+ }
98
+
99
+ function problemStatus(text: string): number {
100
+ try {
101
+ const status = (JSON.parse(text) as { status?: unknown }).status;
102
+ return typeof status === 'number' ? status : 500;
103
+ } catch {
104
+ return 500;
105
+ }
106
+ }
@@ -72,14 +72,28 @@ export async function clientTransport<T = unknown>(req: TransportRequest): Promi
72
72
  // `rawBody` return, so the overload's `Promise<string>` holds for a raw upload read as text too.
73
73
  if (req.responseType === 'text') return answer.text as T;
74
74
  if (req.rawBody !== undefined) return undefined as T;
75
+ return settleAnswer(answer, req, read, issued) as T;
76
+ }
77
+
78
+ /**
79
+ * A 2xx JSON answer as its caller's value: the records envelope unwrapped and adopted, the
80
+ * instants revived. Exported for `clientStream`'s JSON answer, so `stream()` and `call()` of one
81
+ * action can never hand back two different values.
82
+ */
83
+ export function settleAnswer(
84
+ answer: Answer,
85
+ req: Pick<TransportRequest, 'url' | 'adopt' | 'onEnvelope'>,
86
+ read: boolean,
87
+ issued: number,
88
+ ): unknown {
75
89
  const envelope = unwrap(answer, req.url, read);
76
90
  // A write that crossed a rescope HAS landed, so its caller is told — but its rows are the
77
91
  // previous principal's, and the new scope's store never sees them.
78
- if (current === issued) {
92
+ if (pageClient().scope.epoch === issued) {
79
93
  if (req.adopt !== false) adopt(envelope);
80
94
  if (answer.enveloped) req.onEnvelope?.(envelope);
81
95
  }
82
- return envelope.data as T;
96
+ return envelope.data;
83
97
  }
84
98
 
85
99
  function unwrap(answer: Answer, url: string, read: boolean): RecordEnvelope {
@@ -0,0 +1,64 @@
1
+ // Single responsibility: the `assets` block of `app.config.ts` — whether a web pod keeps earlier
2
+ // builds' hashed files reachable across a deploy, and on which disk. Its own file for the reason
3
+ // `config-mail.ts` is: shape, merge and screen are one subject, and `config.ts` is at its ceiling.
4
+
5
+ import { countIssue } from './config-count';
6
+ import { describeValue } from './error-render';
7
+ import { isJsonObject } from './json-object';
8
+
9
+ /**
10
+ * A retention window over a disk the web role can read: an S3-compatible bucket, or a volume every
11
+ * replica mounts. `builds` has NO default — how many deploys an open tab may survive is the app's
12
+ * decision (axiom 8), and a guessed number is either a bucket that grows or a tab that breaks.
13
+ */
14
+ export interface AssetsRetainConfig {
15
+ /** A disk name from the app's `defineStorage({ disks })`, checked against it at boot. */
16
+ readonly disk: string;
17
+ /** How many builds' files stay readable, newest first: a whole number of at least 1. */
18
+ readonly builds: number;
19
+ }
20
+
21
+ /** `retain` absent is today's behaviour: each process serves only the assets it built. */
22
+ export interface AssetsConfig {
23
+ readonly retain: AssetsRetainConfig | undefined;
24
+ }
25
+
26
+ export interface AssetsSection {
27
+ readonly assets: AssetsConfig;
28
+ }
29
+
30
+ export interface AssetsSectionInput {
31
+ readonly assets?: { readonly retain?: AssetsRetainConfig | undefined } | undefined;
32
+ }
33
+
34
+ /** The last layer that said it wins, as every scalar does; a wrong shape is carried AS WRITTEN. */
35
+ export function mergeAssets(layers: readonly AssetsSectionInput[]): AssetsSection {
36
+ let retain: AssetsConfig['retain'];
37
+ for (const layer of layers) {
38
+ const said = layer.assets?.retain;
39
+ if (said !== undefined) retain = said;
40
+ }
41
+ return { assets: { retain } };
42
+ }
43
+
44
+ /** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
45
+ export function assetsIssues(
46
+ config: { readonly assets?: AssetsConfig | undefined },
47
+ issues: string[],
48
+ ): void {
49
+ // `unknown`: an untyped config file reaches this validator with whatever it wrote.
50
+ const retain: unknown = config.assets?.retain;
51
+ if (retain === undefined) return;
52
+ if (!isJsonObject(retain)) {
53
+ issues.push(`assets.retain must be { disk, builds }, not ${describeValue(retain)}`);
54
+ return;
55
+ }
56
+ const disk: unknown = retain['disk'];
57
+ if (typeof disk !== 'string' || disk.trim() === '') {
58
+ issues.push(
59
+ `assets.retain.disk must name a disk declared with defineStorage, not ${describeValue(disk)}`,
60
+ );
61
+ }
62
+ const builds = countIssue('assets.retain.builds', retain['builds'], 1);
63
+ if (builds !== undefined) issues.push(builds);
64
+ }
@@ -0,0 +1,132 @@
1
+ // Single responsibility: the `clientErrors` block of `app.config.ts` — whether `app/` documents
2
+ // carry the browser reporter (`@ultimat3/cli`'s `/x-client-errors.js`), who may file through it,
3
+ // and whether documents beacon real-user timings (`vitals`) through the same door. Shape, merge and
4
+ // screen are one subject, and `config.ts` is at its 500-line ceiling.
5
+
6
+ import { countIssue } from './config-count';
7
+ import { booleanIssue, oneOfIssue } from './config-shape';
8
+ import { describeValue } from './error-render';
9
+
10
+ /** The surfaces a document is rendered for. `site/` is listed by choice: a beacon costs it bytes. */
11
+ export const CLIENT_VITALS_SURFACES = ['app', 'site'] as const;
12
+
13
+ export type ClientVitalsSurface = (typeof CLIENT_VITALS_SURFACES)[number];
14
+
15
+ /**
16
+ * OFF BY DEFAULT, and anonymous: a timing carries no actor, so it needs no sign-in and lands as a
17
+ * histogram sample (`web_vital_seconds`, `web_vital_cls`), never a row. `sampleRate` is the share
18
+ * of page views that beacon — decided once per view, in the browser. `surfaces` defaults to
19
+ * `app/` alone: `site/` is the static path, and it pays for the script only when it is listed.
20
+ */
21
+ export interface ClientVitalsConfig {
22
+ readonly enabled: boolean;
23
+ readonly sampleRate: number;
24
+ readonly surfaces: readonly ClientVitalsSurface[];
25
+ }
26
+
27
+ /**
28
+ * OFF BY DEFAULT: a new public write route is the app's decision, as `pwa.push` is. `anonymous`
29
+ * is refused by default too — crash text is input to whoever reads the monitor, and an open door
30
+ * lets anyone write there. `perMinute` is the route's own bucket, per actor or, for an anonymous
31
+ * caller, per address, spent through the app's rate-limit store.
32
+ */
33
+ export interface ClientErrorsConfig {
34
+ readonly enabled: boolean;
35
+ readonly anonymous: boolean;
36
+ readonly perMinute: number;
37
+ readonly vitals: ClientVitalsConfig;
38
+ }
39
+
40
+ export const CLIENT_VITALS_DEFAULTS: ClientVitalsConfig = Object.freeze({
41
+ enabled: false,
42
+ sampleRate: 0.1,
43
+ surfaces: Object.freeze(['app'] as const),
44
+ });
45
+
46
+ export const CLIENT_ERRORS_DEFAULTS: ClientErrorsConfig = Object.freeze({
47
+ enabled: false,
48
+ anonymous: false,
49
+ perMinute: 30,
50
+ vitals: CLIENT_VITALS_DEFAULTS,
51
+ });
52
+
53
+ export interface ClientErrorsSection {
54
+ readonly clientErrors: ClientErrorsConfig;
55
+ }
56
+
57
+ export interface ClientErrorsSectionInput {
58
+ readonly clientErrors?:
59
+ | {
60
+ readonly enabled?: boolean | undefined;
61
+ readonly anonymous?: boolean | undefined;
62
+ readonly perMinute?: number | undefined;
63
+ readonly vitals?:
64
+ | {
65
+ readonly enabled?: boolean | undefined;
66
+ readonly sampleRate?: number | undefined;
67
+ readonly surfaces?: readonly ClientVitalsSurface[] | undefined;
68
+ }
69
+ | undefined;
70
+ }
71
+ | undefined;
72
+ }
73
+
74
+ /** Key by key, the last layer that said one wins it — every section's rule. */
75
+ export function mergeClientErrors(
76
+ layers: readonly ClientErrorsSectionInput[],
77
+ ): ClientErrorsSection {
78
+ let { enabled, anonymous, perMinute } = CLIENT_ERRORS_DEFAULTS;
79
+ let vitals: ClientVitalsConfig = CLIENT_VITALS_DEFAULTS;
80
+ for (const layer of layers) {
81
+ const said = layer.clientErrors;
82
+ if (said?.enabled !== undefined) enabled = said.enabled;
83
+ if (said?.anonymous !== undefined) anonymous = said.anonymous;
84
+ if (said?.perMinute !== undefined) perMinute = said.perMinute;
85
+ // One level further, by the same rule: an overlay naming `surfaces` keeps the base's rate.
86
+ const timings = said?.vitals;
87
+ if (timings?.enabled !== undefined) vitals = { ...vitals, enabled: timings.enabled };
88
+ if (timings?.sampleRate !== undefined) vitals = { ...vitals, sampleRate: timings.sampleRate };
89
+ if (timings?.surfaces !== undefined) vitals = { ...vitals, surfaces: timings.surfaces };
90
+ }
91
+ return { clientErrors: { enabled, anonymous, perMinute, vitals } };
92
+ }
93
+
94
+ /** A share of page views: a finite number in 0–1. `0` is legal — sampled out, still declared. */
95
+ function rateIssue(key: string, value: unknown): string | undefined {
96
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1) {
97
+ return undefined;
98
+ }
99
+ const shown = typeof value === 'number' ? `${value}` : describeValue(value);
100
+ return `${key} must be a number from 0 to 1 (the share of page views that report), not ${shown}`;
101
+ }
102
+
103
+ /** At least one listed surface: an empty list is `enabled: false` spelled a second way. */
104
+ function surfacesIssues(key: string, value: unknown, issues: string[]): void {
105
+ if (!Array.isArray(value) || value.length === 0) {
106
+ issues.push(`${key} must list at least one of ${CLIENT_VITALS_SURFACES.join(', ')}`);
107
+ return;
108
+ }
109
+ for (const surface of value) {
110
+ const issue = oneOfIssue(key, surface, CLIENT_VITALS_SURFACES);
111
+ if (issue !== undefined) issues.push(issue);
112
+ }
113
+ }
114
+
115
+ /** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
116
+ export function clientErrorsIssues(config: ClientErrorsSection, issues: string[]): void {
117
+ // `unknown`: an untyped config file reaches this validator with whatever it wrote.
118
+ const block: Readonly<Record<keyof ClientErrorsConfig, unknown>> = config.clientErrors;
119
+ const vitals: Readonly<Record<keyof ClientVitalsConfig, unknown>> = config.clientErrors.vitals;
120
+ for (const issue of [
121
+ booleanIssue('clientErrors.enabled', block.enabled),
122
+ booleanIssue('clientErrors.anonymous', block.anonymous),
123
+ // Zero is refused, not read as "off": a bucket of nothing refuses every report, and `enabled`
124
+ // is the switch that says off.
125
+ countIssue('clientErrors.perMinute', block.perMinute, 1),
126
+ booleanIssue('clientErrors.vitals.enabled', vitals.enabled),
127
+ rateIssue('clientErrors.vitals.sampleRate', vitals.sampleRate),
128
+ ]) {
129
+ if (issue !== undefined) issues.push(issue);
130
+ }
131
+ surfacesIssues('clientErrors.vitals.surfaces', vitals.surfaces, issues);
132
+ }
@@ -8,12 +8,25 @@ import {
8
8
  JOBS_DONE_RETENTION_MS_DEFAULT,
9
9
  JOBS_OUTBOX_RETENTION_MS_DEFAULT,
10
10
  } from './config-jobs';
11
- import { DRAIN_DEADLINE_DEFAULT_MS } from './drain-deadline';
11
+ import { DRAIN_DEADLINE_DEFAULT_MS, WORKER_RETIRE_DEFAULT_MS } from './drain-deadline';
12
12
  import { defaultReadinessGraceMs } from './lifecycle-grace';
13
13
  import { ROLES } from './roles';
14
14
 
15
- /** The keys their own files default: `config-site.ts`, `-navigation`, `-islands`, `-mail`. */
16
- type Sectioned = 'name' | 'site' | 'seo' | 'navigation' | 'islands' | 'mail';
15
+ /**
16
+ * The keys their own files default: `config-site.ts`, `-navigation`, `-islands`, `-mail`,
17
+ * `-client-errors`, `-assets`, `-failure-capture`, `-time`.
18
+ */
19
+ type Sectioned =
20
+ | 'name'
21
+ | 'site'
22
+ | 'seo'
23
+ | 'navigation'
24
+ | 'islands'
25
+ | 'mail'
26
+ | 'clientErrors'
27
+ | 'failureCapture'
28
+ | 'time'
29
+ | 'assets';
17
30
 
18
31
  export function configDefaults(name: string): Omit<AppConfig, Sectioned> {
19
32
  return {
@@ -28,7 +41,7 @@ export function configDefaults(name: string): Omit<AppConfig, Sectioned> {
28
41
  colors: undefined,
29
42
  },
30
43
  roles: [...ROLES],
31
- database: { driver: 'postgres', ssl: false },
44
+ database: { driver: 'postgres', ssl: false, rowSecurity: false },
32
45
  cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
33
46
  jobs: {
34
47
  queues: [`${name}-default`],
@@ -60,6 +73,7 @@ export function configDefaults(name: string): Omit<AppConfig, Sectioned> {
60
73
  deadlineMs: DRAIN_DEADLINE_DEFAULT_MS,
61
74
  // Unset: the worker drains on `deadlineMs`, like every role.
62
75
  workerDeadlineMs: undefined,
76
+ workerRetireMs: WORKER_RETIRE_DEFAULT_MS,
63
77
  },
64
78
  health: { readiness: 'dependencies' },
65
79
  };
@@ -0,0 +1,70 @@
1
+ // Single responsibility: the `failureCapture` block of `app.config.ts` — whether a 5xx at `invoke`
2
+ // keeps a replayable bundle (input, actor, flags, the rows the code read) for `x repro`, and how
3
+ // much of one. Shape, merge and screen are one subject, and `config.ts` is at its line ceiling.
4
+
5
+ import { countIssue } from './config-count';
6
+ import { booleanIssue } from './config-shape';
7
+
8
+ /**
9
+ * OFF BY DEFAULT: storing what a user sent is the app's decision, as `clientErrors` is. `x dev`
10
+ * turns it on for its own process. `perHour` bounds bundles per fingerprint per hour, per process;
11
+ * `maxRows` and `maxBytes` bound one bundle — past either it is stored truncated, and says so.
12
+ */
13
+ export interface FailureCaptureConfig {
14
+ readonly enabled: boolean;
15
+ readonly perHour: number;
16
+ readonly maxRows: number;
17
+ readonly maxBytes: number;
18
+ }
19
+
20
+ export const FAILURE_CAPTURE_DEFAULTS: FailureCaptureConfig = Object.freeze({
21
+ enabled: false,
22
+ perHour: 5,
23
+ maxRows: 50,
24
+ maxBytes: 64 * 1024,
25
+ });
26
+
27
+ export interface FailureCaptureSection {
28
+ readonly failureCapture: FailureCaptureConfig;
29
+ }
30
+
31
+ export interface FailureCaptureSectionInput {
32
+ readonly failureCapture?:
33
+ | {
34
+ readonly enabled?: boolean | undefined;
35
+ readonly perHour?: number | undefined;
36
+ readonly maxRows?: number | undefined;
37
+ readonly maxBytes?: number | undefined;
38
+ }
39
+ | undefined;
40
+ }
41
+
42
+ /** Key by key, the last layer that said one wins it — every section's rule. */
43
+ export function mergeFailureCapture(
44
+ layers: readonly FailureCaptureSectionInput[],
45
+ ): FailureCaptureSection {
46
+ let { enabled, perHour, maxRows, maxBytes } = FAILURE_CAPTURE_DEFAULTS;
47
+ for (const layer of layers) {
48
+ const said = layer.failureCapture;
49
+ if (said?.enabled !== undefined) enabled = said.enabled;
50
+ if (said?.perHour !== undefined) perHour = said.perHour;
51
+ if (said?.maxRows !== undefined) maxRows = said.maxRows;
52
+ if (said?.maxBytes !== undefined) maxBytes = said.maxBytes;
53
+ }
54
+ return { failureCapture: { enabled, perHour, maxRows, maxBytes } };
55
+ }
56
+
57
+ /** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
58
+ export function failureCaptureIssues(config: FailureCaptureSection, issues: string[]): void {
59
+ // `unknown`: an untyped config file reaches this validator with whatever it wrote.
60
+ const block: Readonly<Record<keyof FailureCaptureConfig, unknown>> = config.failureCapture;
61
+ // Zero is refused, not read as "off": `enabled` is the switch that says off.
62
+ for (const issue of [
63
+ booleanIssue('failureCapture.enabled', block.enabled),
64
+ countIssue('failureCapture.perHour', block.perHour, 1),
65
+ countIssue('failureCapture.maxRows', block.maxRows, 1),
66
+ countIssue('failureCapture.maxBytes', block.maxBytes, 1),
67
+ ]) {
68
+ if (issue !== undefined) issues.push(issue);
69
+ }
70
+ }
@@ -51,6 +51,14 @@ export interface DrainConfig {
51
51
  * `deadlineMs`. A whole number, 1–86400000. The chart's worker grace period is derived from it.
52
52
  */
53
53
  readonly workerDeadlineMs: number | undefined;
54
+ /**
55
+ * How long a retiring worker may finish what it holds on a Kubernetes rollout or scale-down:
56
+ * claim nothing, abort nothing, exit when the last job settles. Not `workerDeadlineMs`, which
57
+ * bounds a SIGTERM drain and then aborts. An idle worker exits at once, so it costs only while a
58
+ * job holds the pod. Default 3600000; 0 turns it off. A whole number, 0–86400000. `x deploy
59
+ * --method helm` hands it to the chart, whose worker `preStop` and grace period are sized from it.
60
+ */
61
+ readonly workerRetireMs: number;
54
62
  }
55
63
 
56
64
  /** Why a value is not a readiness mode, or `undefined` when it is one. */
@@ -11,7 +11,7 @@ import { describeValue } from './error-render';
11
11
  * reason is measured rather than cautious: tree shaking across one split build keeps whatever ANY
12
12
  * importer uses, so an island that imports one helper from a module other islands use heavily
13
13
  * pays for all of it. `examples/dummy`'s plain-DOM update banner went 712 → 16,288 B with it on;
14
- * notificado.co's `/afiliados` with two upload islands went 55,585 → 42,331 B. Turn it on where a
14
+ * one adopter's `/afiliados` with two upload islands went 55,585 → 42,331 B. Turn it on where a
15
15
  * surface's pages render many islands over one graph, and weigh the routes with `x verify`.
16
16
  */
17
17
  export interface IslandsConfig {
@@ -3,6 +3,7 @@
3
3
  // is merged and read by nothing is the switch with no wire, and a typo (`drian`) is the same defect.
4
4
 
5
5
  import type { JobsConfig } from './config';
6
+ import type { AssetsRetainConfig } from './config-assets';
6
7
  import { BASE_FIX } from './config-fixes';
7
8
  import type { MailRetainMimeConfig } from './config-mail';
8
9
  import type { NavigationConfig, SpeculationConfig } from './config-navigation';
@@ -62,6 +63,7 @@ export const SECTION_KEYS: Readonly<Record<string, readonly string[]>> = Object.
62
63
  'pwa.shortcuts[].icons[]': keysOf<PwaImage>()(['src', 'sizes', 'type', 'purpose']),
63
64
  'pwa.screenshots[]': keysOf<PwaScreenshot>()(['src', 'sizes', 'type', 'formFactor', 'label']),
64
65
  'mail.retainMime': keysOf<MailRetainMimeConfig>()(['maxBytes']),
66
+ 'assets.retain': keysOf<AssetsRetainConfig>()(['disk', 'builds']),
65
67
  jobs: keysOf<JobsConfig>()([
66
68
  'queues',
67
69
  'concurrency',