@ultimat3/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +33 -0
- package/src/actor.ts +81 -0
- package/src/assert.ts +44 -0
- package/src/clock.ts +51 -0
- package/src/config.ts +265 -0
- package/src/context.ts +210 -0
- package/src/cursor.ts +116 -0
- package/src/env.ts +259 -0
- package/src/error-codes.ts +131 -0
- package/src/errors.ts +159 -0
- package/src/ids.ts +132 -0
- package/src/image/color.ts +34 -0
- package/src/image/errors.ts +58 -0
- package/src/image/fixtures.ts +263 -0
- package/src/image/jpeg-decode.ts +283 -0
- package/src/image/jpeg-encode.ts +463 -0
- package/src/image/jpeg-headers.ts +267 -0
- package/src/image/jpeg-huffman.ts +202 -0
- package/src/image/jpeg-tables.ts +117 -0
- package/src/image/pipeline.ts +117 -0
- package/src/image/png-bytes.ts +91 -0
- package/src/image/png.ts +433 -0
- package/src/image/probe-svg.ts +85 -0
- package/src/image/probe.ts +302 -0
- package/src/image/raster.ts +71 -0
- package/src/image/resize.ts +320 -0
- package/src/index.ts +256 -0
- package/src/lifecycle.ts +242 -0
- package/src/listeners.ts +80 -0
- package/src/logger.ts +146 -0
- package/src/registrar.ts +94 -0
- package/src/result.ts +78 -0
- package/src/roles.ts +66 -0
- package/src/service.ts +61 -0
- package/src/telemetry.ts +290 -0
- package/src/version.ts +37 -0
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Single responsibility: process lifecycle and graceful drain. Every role runs the same three
|
|
2
|
+
// phases on SIGTERM — stop accepting, finish in-flight, close resources — under one deadline,
|
|
3
|
+
// and reports the same /healthz + /readyz state.
|
|
4
|
+
|
|
5
|
+
import { type Clock, systemClock } from './clock';
|
|
6
|
+
import { type Logger, logger as rootLogger } from './logger';
|
|
7
|
+
|
|
8
|
+
export type HealthState = 'starting' | 'ready' | 'draining' | 'stopped';
|
|
9
|
+
|
|
10
|
+
/** Ordered. `accept` runs first, `close` last. */
|
|
11
|
+
export type ShutdownPhase = 'accept' | 'inflight' | 'close';
|
|
12
|
+
|
|
13
|
+
export const SHUTDOWN_PHASES: readonly ShutdownPhase[] = ['accept', 'inflight', 'close'];
|
|
14
|
+
|
|
15
|
+
/** Signals Ultimate reacts to. Narrower than `NodeJS.Signals` on purpose. */
|
|
16
|
+
export type ProcessSignal = 'SIGTERM' | 'SIGINT' | 'SIGHUP' | 'SIGQUIT';
|
|
17
|
+
|
|
18
|
+
export interface ShutdownReason {
|
|
19
|
+
readonly signal: string;
|
|
20
|
+
/** Monotonic ms after which hooks are abandoned. */
|
|
21
|
+
readonly deadlineAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ShutdownHook = (reason: ShutdownReason) => void | Promise<void>;
|
|
25
|
+
|
|
26
|
+
export interface OnShutdownOptions {
|
|
27
|
+
readonly phase?: ShutdownPhase | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LifecycleOptions {
|
|
31
|
+
readonly deadlineMs?: number | undefined;
|
|
32
|
+
readonly clock?: Clock | undefined;
|
|
33
|
+
readonly logger?: Logger | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface HealthReport {
|
|
37
|
+
readonly state: HealthState;
|
|
38
|
+
readonly ready: boolean;
|
|
39
|
+
readonly uptimeMs: number;
|
|
40
|
+
readonly inflight: number;
|
|
41
|
+
readonly buildId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HealthPayload {
|
|
45
|
+
readonly ok: boolean;
|
|
46
|
+
/** The status code the HTTP layer should return. Core stays HTTP-free; this is just data. */
|
|
47
|
+
readonly status: number;
|
|
48
|
+
readonly body: HealthReport;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Registration {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly phase: ShutdownPhase;
|
|
54
|
+
readonly hook: ShutdownHook;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_DEADLINE_MS = 25_000;
|
|
58
|
+
|
|
59
|
+
let deadlineMs = DEFAULT_DEADLINE_MS;
|
|
60
|
+
let clock: Clock = systemClock;
|
|
61
|
+
let log: Logger = rootLogger;
|
|
62
|
+
let state: HealthState = 'starting';
|
|
63
|
+
let startedAtMono = clock.monotonic();
|
|
64
|
+
let inflight = 0;
|
|
65
|
+
let registrations: Registration[] = [];
|
|
66
|
+
let drainPromise: Promise<void> | undefined;
|
|
67
|
+
let idleWaiters: (() => void)[] = [];
|
|
68
|
+
|
|
69
|
+
export function configureLifecycle(options: LifecycleOptions): void {
|
|
70
|
+
if (options.deadlineMs !== undefined) deadlineMs = options.deadlineMs;
|
|
71
|
+
if (options.clock !== undefined) {
|
|
72
|
+
clock = options.clock;
|
|
73
|
+
startedAtMono = clock.monotonic();
|
|
74
|
+
}
|
|
75
|
+
if (options.logger !== undefined) log = options.logger;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function lifecycleState(): HealthState {
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function markReady(): void {
|
|
83
|
+
if (state === 'starting') state = 'ready';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function inflightCount(): number {
|
|
87
|
+
return inflight;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Register a drain hook. Returns an unregister function. */
|
|
91
|
+
export function onShutdown(
|
|
92
|
+
name: string,
|
|
93
|
+
hook: ShutdownHook,
|
|
94
|
+
options?: OnShutdownOptions,
|
|
95
|
+
): () => void {
|
|
96
|
+
const registration: Registration = { name, phase: options?.phase ?? 'close', hook };
|
|
97
|
+
registrations.push(registration);
|
|
98
|
+
return () => {
|
|
99
|
+
registrations = registrations.filter((candidate) => candidate !== registration);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Mark a unit of work in flight. Call the returned function when it completes — drain waits
|
|
105
|
+
* for the count to reach zero before closing resources.
|
|
106
|
+
*/
|
|
107
|
+
export function beginWork(): () => void {
|
|
108
|
+
inflight += 1;
|
|
109
|
+
let done = false;
|
|
110
|
+
return () => {
|
|
111
|
+
if (done) return;
|
|
112
|
+
done = true;
|
|
113
|
+
inflight -= 1;
|
|
114
|
+
if (inflight === 0) {
|
|
115
|
+
const waiters = idleWaiters;
|
|
116
|
+
idleWaiters = [];
|
|
117
|
+
for (const waiter of waiters) waiter();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** True when new work must be refused — the HTTP layer answers 503 while this holds. */
|
|
123
|
+
export function isDraining(): boolean {
|
|
124
|
+
return state === 'draining' || state === 'stopped';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function waitForIdle(timeoutMs: number): Promise<boolean> {
|
|
128
|
+
if (inflight === 0) return Promise.resolve(true);
|
|
129
|
+
return new Promise<boolean>((resolve) => {
|
|
130
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
131
|
+
idleWaiters.push(() => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
resolve(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function runPhase(phase: ShutdownPhase, reason: ShutdownReason): Promise<void> {
|
|
139
|
+
for (const registration of registrations.filter((entry) => entry.phase === phase)) {
|
|
140
|
+
try {
|
|
141
|
+
await registration.hook(reason);
|
|
142
|
+
} catch (thrown) {
|
|
143
|
+
log.error('shutdown hook failed', {
|
|
144
|
+
hook: registration.name,
|
|
145
|
+
phase,
|
|
146
|
+
error: thrown,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Idempotent: concurrent signals join the same drain. */
|
|
153
|
+
export function drain(signal = 'manual'): Promise<void> {
|
|
154
|
+
if (drainPromise !== undefined) return drainPromise;
|
|
155
|
+
state = 'draining';
|
|
156
|
+
const reason: ShutdownReason = { signal, deadlineAt: clock.monotonic() + deadlineMs };
|
|
157
|
+
|
|
158
|
+
drainPromise = (async () => {
|
|
159
|
+
log.info('draining', { signal, deadlineMs, inflight });
|
|
160
|
+
await runPhase('accept', reason);
|
|
161
|
+
|
|
162
|
+
const remaining = Math.max(0, reason.deadlineAt - clock.monotonic());
|
|
163
|
+
const idle = await waitForIdle(remaining);
|
|
164
|
+
if (!idle) {
|
|
165
|
+
log.warn('X_SHUTDOWN_TIMEOUT', {
|
|
166
|
+
code: 'X_SHUTDOWN_TIMEOUT',
|
|
167
|
+
cause: `${inflight} in-flight operations still running after ${deadlineMs}ms`,
|
|
168
|
+
fix: 'raise configureLifecycle({ deadlineMs }) or shorten the slow handler',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await runPhase('inflight', reason);
|
|
173
|
+
await runPhase('close', reason);
|
|
174
|
+
state = 'stopped';
|
|
175
|
+
log.info('stopped', { signal });
|
|
176
|
+
})();
|
|
177
|
+
|
|
178
|
+
return drainPromise;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface SignalHandlerOptions {
|
|
182
|
+
readonly signals?: readonly ProcessSignal[] | undefined;
|
|
183
|
+
/** Call `process.exit()` once drained. Off in tests. */
|
|
184
|
+
readonly exit?: boolean | undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Install SIGTERM/SIGINT handling. Returns an uninstall function. */
|
|
188
|
+
export function installSignalHandlers(options?: SignalHandlerOptions): () => void {
|
|
189
|
+
const signals: readonly ProcessSignal[] = options?.signals ?? ['SIGTERM', 'SIGINT'];
|
|
190
|
+
const handlers = new Map<ProcessSignal, () => void>();
|
|
191
|
+
|
|
192
|
+
for (const signal of signals) {
|
|
193
|
+
const handler = (): void => {
|
|
194
|
+
void drain(signal).then(() => {
|
|
195
|
+
if (options?.exit === true) process.exit(0);
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
handlers.set(signal, handler);
|
|
199
|
+
process.on(signal, handler);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function healthReport(): HealthReport {
|
|
208
|
+
return {
|
|
209
|
+
state,
|
|
210
|
+
ready: state === 'ready',
|
|
211
|
+
uptimeMs: Math.round(clock.monotonic() - startedAtMono),
|
|
212
|
+
inflight,
|
|
213
|
+
buildId: process.env['BUILD_ID'] ?? 'dev',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Liveness: the process exists and is not wedged. Stays 200 while draining. */
|
|
218
|
+
export function healthzPayload(): HealthPayload {
|
|
219
|
+
const body = healthReport();
|
|
220
|
+
const ok = state !== 'stopped';
|
|
221
|
+
return { ok, status: ok ? 200 : 503, body };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Readiness: may this instance receive traffic? 503 while starting or draining. */
|
|
225
|
+
export function readyzPayload(): HealthPayload {
|
|
226
|
+
const body = healthReport();
|
|
227
|
+
const ok = state === 'ready';
|
|
228
|
+
return { ok, status: ok ? 200 : 503, body };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Test-only: forget all hooks and return to `starting`. */
|
|
232
|
+
export function resetLifecycle(): void {
|
|
233
|
+
deadlineMs = DEFAULT_DEADLINE_MS;
|
|
234
|
+
clock = systemClock;
|
|
235
|
+
log = rootLogger;
|
|
236
|
+
state = 'starting';
|
|
237
|
+
startedAtMono = clock.monotonic();
|
|
238
|
+
inflight = 0;
|
|
239
|
+
registrations = [];
|
|
240
|
+
drainPromise = undefined;
|
|
241
|
+
idleWaiters = [];
|
|
242
|
+
}
|
package/src/listeners.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Single responsibility: the sockets this process is currently listening on. A request to one of
|
|
2
|
+
// them is the process calling itself, not egress — which is how a sealed test network can let a
|
|
3
|
+
// booted server reach its own port without an allowlist entry per kernel-assigned port.
|
|
4
|
+
|
|
5
|
+
import { assert } from './assert';
|
|
6
|
+
|
|
7
|
+
interface Listener {
|
|
8
|
+
readonly origin: string;
|
|
9
|
+
count: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every spelling of "this machine". A wildcard bind is reachable over loopback, so it collapses
|
|
14
|
+
* to the same key: a server on `0.0.0.0:3000` answers `http://localhost:3000`.
|
|
15
|
+
*/
|
|
16
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]', '[::]']);
|
|
17
|
+
|
|
18
|
+
const listeners = new Map<string, Listener>();
|
|
19
|
+
|
|
20
|
+
const portOf = (url: URL): string => {
|
|
21
|
+
if (url.port !== '') return url.port;
|
|
22
|
+
return url.protocol === 'https:' || url.protocol === 'wss:' ? '443' : '80';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Identity is host+port, not the origin string: the same socket has several valid spellings. */
|
|
26
|
+
const keyOf = (url: URL): string => {
|
|
27
|
+
const host = url.hostname.toLowerCase();
|
|
28
|
+
return `${LOOPBACK_HOSTS.has(host) ? 'loopback' : host}:${portOf(url)}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const parseUrl = (url: string): URL | undefined => {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(url);
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Announce a socket this process just opened; call the returned release when it closes. Releasing
|
|
41
|
+
* twice is a no-op, so a manual `stop()` and a SIGTERM drain may both call it. Refcounted, so two
|
|
42
|
+
* servers on one origin do not un-announce each other.
|
|
43
|
+
*/
|
|
44
|
+
export function markListening(origin: string): () => void {
|
|
45
|
+
const url = parseUrl(origin);
|
|
46
|
+
assert(
|
|
47
|
+
url !== undefined,
|
|
48
|
+
`not a URL: ${origin}`,
|
|
49
|
+
'pass the server origin, e.g. markListening(server.url.origin)',
|
|
50
|
+
);
|
|
51
|
+
const key = keyOf(url);
|
|
52
|
+
const existing = listeners.get(key);
|
|
53
|
+
if (existing === undefined) listeners.set(key, { origin: url.origin, count: 1 });
|
|
54
|
+
else existing.count += 1;
|
|
55
|
+
|
|
56
|
+
let released = false;
|
|
57
|
+
return () => {
|
|
58
|
+
if (released) return;
|
|
59
|
+
released = true;
|
|
60
|
+
const entry = listeners.get(key);
|
|
61
|
+
if (entry === undefined) return;
|
|
62
|
+
entry.count -= 1;
|
|
63
|
+
if (entry.count <= 0) listeners.delete(key);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The origins this process is serving right now, in announce order. */
|
|
68
|
+
export const listeningOrigins = (): readonly string[] =>
|
|
69
|
+
[...listeners.values()].map((listener) => listener.origin);
|
|
70
|
+
|
|
71
|
+
/** True when the URL points at a socket this process opened — same port, any loopback spelling. */
|
|
72
|
+
export function isSelfOrigin(url: string): boolean {
|
|
73
|
+
const parsed = parseUrl(url);
|
|
74
|
+
return parsed !== undefined && listeners.has(keyOf(parsed));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Test-only: forget every announced socket. */
|
|
78
|
+
export function resetListeners(): void {
|
|
79
|
+
listeners.clear();
|
|
80
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Single responsibility: structured JSON logging. One line per event, machine-readable by
|
|
2
|
+
// default because the primary reader is an agent tailing `x logs --json`.
|
|
3
|
+
|
|
4
|
+
import { type Clock, systemClock } from './clock';
|
|
5
|
+
import { isUltimateError } from './errors';
|
|
6
|
+
|
|
7
|
+
export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'] as const;
|
|
8
|
+
|
|
9
|
+
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
10
|
+
|
|
11
|
+
const LEVEL_WEIGHT: Readonly<Record<LogLevel, number>> = Object.freeze({
|
|
12
|
+
trace: 10,
|
|
13
|
+
debug: 20,
|
|
14
|
+
info: 30,
|
|
15
|
+
warn: 40,
|
|
16
|
+
error: 50,
|
|
17
|
+
fatal: 60,
|
|
18
|
+
silent: 100,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export interface LogFields {
|
|
22
|
+
readonly [key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Logger {
|
|
26
|
+
readonly level: LogLevel;
|
|
27
|
+
trace(message: string, fields?: LogFields): void;
|
|
28
|
+
debug(message: string, fields?: LogFields): void;
|
|
29
|
+
info(message: string, fields?: LogFields): void;
|
|
30
|
+
warn(message: string, fields?: LogFields): void;
|
|
31
|
+
error(message: string, fields?: LogFields): void;
|
|
32
|
+
fatal(message: string, fields?: LogFields): void;
|
|
33
|
+
/** Bind fields onto every subsequent line. Child fields win over parent fields. */
|
|
34
|
+
child(fields: LogFields): Logger;
|
|
35
|
+
withLevel(level: LogLevel): Logger;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LoggerOptions {
|
|
39
|
+
readonly level?: LogLevel | undefined;
|
|
40
|
+
readonly fields?: LogFields | undefined;
|
|
41
|
+
readonly clock?: Clock | undefined;
|
|
42
|
+
/** Receives one complete JSON line (no trailing newline). Defaults to stdout/stderr. */
|
|
43
|
+
readonly writer?: ((line: string, level: LogLevel) => void) | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const REDACTED = '[redacted]';
|
|
47
|
+
|
|
48
|
+
const redactedKeys = new Set<string>([
|
|
49
|
+
'password',
|
|
50
|
+
'token',
|
|
51
|
+
'secret',
|
|
52
|
+
'authorization',
|
|
53
|
+
'cookie',
|
|
54
|
+
'apiKey',
|
|
55
|
+
'accessToken',
|
|
56
|
+
'refreshToken',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Mark keys as secret everywhere. `defineEnv()` calls this for every `secret: true` var. */
|
|
60
|
+
export function redactKeys(keys: Iterable<string>): void {
|
|
61
|
+
for (const key of keys) redactedKeys.add(key.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isRedactedKey(key: string): boolean {
|
|
65
|
+
return redactedKeys.has(key.toLowerCase());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fields injected into every line — set once by `context.ts` so `requestId`/`traceId` appear
|
|
70
|
+
* without threading the context into every call site.
|
|
71
|
+
*/
|
|
72
|
+
let contextFields: () => LogFields | undefined = () => undefined;
|
|
73
|
+
|
|
74
|
+
export function setLoggerContextFields(provider: () => LogFields | undefined): void {
|
|
75
|
+
contextFields = provider;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function defaultWriter(line: string, level: LogLevel): void {
|
|
79
|
+
const stream = LEVEL_WEIGHT[level] >= LEVEL_WEIGHT.error ? process.stderr : process.stdout;
|
|
80
|
+
stream.write(`${line}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function serialiseValue(value: unknown, depth: number): unknown {
|
|
84
|
+
if (value === null || typeof value !== 'object') return value;
|
|
85
|
+
if (value instanceof Date) return value.toISOString();
|
|
86
|
+
if (isUltimateError(value)) return value.toJSON();
|
|
87
|
+
if (value instanceof Error) return { name: value.name, message: value.message };
|
|
88
|
+
if (depth >= 6) return '[depth-limit]';
|
|
89
|
+
if (Array.isArray(value)) return value.map((item) => serialiseValue(item, depth + 1));
|
|
90
|
+
const out: Record<string, unknown> = {};
|
|
91
|
+
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
|
92
|
+
out[key] = isRedactedKey(key) ? REDACTED : serialiseValue(nested, depth + 1);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function redactFields(fields: LogFields): Record<string, unknown> {
|
|
98
|
+
const out: Record<string, unknown> = {};
|
|
99
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
100
|
+
out[key] = isRedactedKey(key) ? REDACTED : serialiseValue(value, 0);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function envLevel(): LogLevel {
|
|
106
|
+
const raw = process.env['LOG_LEVEL'];
|
|
107
|
+
return raw !== undefined && (LOG_LEVELS as readonly string[]).includes(raw)
|
|
108
|
+
? (raw as LogLevel)
|
|
109
|
+
: 'info';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createLogger(options?: LoggerOptions): Logger {
|
|
113
|
+
const level = options?.level ?? envLevel();
|
|
114
|
+
const bound = options?.fields ?? {};
|
|
115
|
+
const clock = options?.clock ?? systemClock;
|
|
116
|
+
const writer = options?.writer ?? defaultWriter;
|
|
117
|
+
const threshold = LEVEL_WEIGHT[level];
|
|
118
|
+
|
|
119
|
+
function emit(lineLevel: LogLevel, message: string, fields?: LogFields): void {
|
|
120
|
+
if (LEVEL_WEIGHT[lineLevel] < threshold) return;
|
|
121
|
+
const line = {
|
|
122
|
+
ts: clock.now().toISOString(),
|
|
123
|
+
level: lineLevel,
|
|
124
|
+
msg: message,
|
|
125
|
+
...redactFields(bound),
|
|
126
|
+
...redactFields(contextFields() ?? {}),
|
|
127
|
+
...redactFields(fields ?? {}),
|
|
128
|
+
};
|
|
129
|
+
writer(JSON.stringify(line), lineLevel);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
level,
|
|
134
|
+
trace: (message, fields) => emit('trace', message, fields),
|
|
135
|
+
debug: (message, fields) => emit('debug', message, fields),
|
|
136
|
+
info: (message, fields) => emit('info', message, fields),
|
|
137
|
+
warn: (message, fields) => emit('warn', message, fields),
|
|
138
|
+
error: (message, fields) => emit('error', message, fields),
|
|
139
|
+
fatal: (message, fields) => emit('fatal', message, fields),
|
|
140
|
+
child: (fields) => createLogger({ level, clock, writer, fields: { ...bound, ...fields } }),
|
|
141
|
+
withLevel: (next) => createLogger({ level: next, clock, writer, fields: bound }),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The process-wide logger. Prefer `ctx.logger` inside a request — it carries the ids. */
|
|
146
|
+
export const logger: Logger = createLogger();
|
package/src/registrar.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Hands a module of primitives to the package that owns them, without a sideways import:
|
|
2
|
+
// `defineApi` in `@ultimat3/action` cannot import `@ultimat3/query`'s `registerQueries` on
|
|
3
|
+
// the same tier, so each owner announces its registrar here at import time and callers ask
|
|
4
|
+
// by kind. Same shape as `defineService` and `registerErrorCodes`.
|
|
5
|
+
|
|
6
|
+
import { UltimateError } from './errors';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The eight primitives — the framework's whole vocabulary. A registrar exists only for the
|
|
10
|
+
* kinds registered by module.
|
|
11
|
+
*
|
|
12
|
+
* The runtime list is the source and the type derives from it, so the two cannot drift apart.
|
|
13
|
+
* A ninth entry is a design error, not a feature: a new capability arrives as a FACTORY over an
|
|
14
|
+
* existing primitive — `llm()` returns an `action` — never as a new kind of thing. That rule is
|
|
15
|
+
* only real if something fails when it is broken, so `registrar.test.ts` pins this set.
|
|
16
|
+
*/
|
|
17
|
+
export const PRIMITIVE_KINDS = [
|
|
18
|
+
'action',
|
|
19
|
+
'entity',
|
|
20
|
+
'job',
|
|
21
|
+
'mutator',
|
|
22
|
+
'policy',
|
|
23
|
+
'query',
|
|
24
|
+
'route',
|
|
25
|
+
'task',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export type PrimitiveKind = (typeof PRIMITIVE_KINDS)[number];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a registrar hands back: the primitives it actually took, each carrying the name
|
|
32
|
+
* registration stamped on it. Returning the registered set — rather than nothing — is what lets
|
|
33
|
+
* a caller build its API map from what registered instead of from what a module exported.
|
|
34
|
+
*/
|
|
35
|
+
export interface RegisteredPrimitive {
|
|
36
|
+
readonly kind: PrimitiveKind;
|
|
37
|
+
readonly name: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `registerActions` / `registerQueries`: export names become primitive names. */
|
|
41
|
+
export type ModuleRegistrar = (
|
|
42
|
+
module: Readonly<Record<string, unknown>>,
|
|
43
|
+
) => readonly RegisteredPrimitive[];
|
|
44
|
+
|
|
45
|
+
const registrars = new Map<PrimitiveKind, ModuleRegistrar>();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Announce the registrar for `kind`, once per process. Re-announcing the same function is a
|
|
49
|
+
* no-op (a module re-evaluated under a different specifier); announcing a *different* one
|
|
50
|
+
* means two copies of the owning package are loaded, each with its own registry — half the
|
|
51
|
+
* primitives would register into a table nothing else reads.
|
|
52
|
+
*/
|
|
53
|
+
export function registerPrimitiveRegistrar(kind: PrimitiveKind, registrar: ModuleRegistrar): void {
|
|
54
|
+
const existing = registrars.get(kind);
|
|
55
|
+
if (existing !== undefined && existing !== registrar) {
|
|
56
|
+
throw new UltimateError({
|
|
57
|
+
code: 'X_REGISTRAR_CONFLICT',
|
|
58
|
+
cause: `two different ${kind} registrars are loaded, so ${kind} primitives would split across two registries`,
|
|
59
|
+
// One command, because a `fix:` is pasted verbatim: collapsing every range on the package
|
|
60
|
+
// to one resolved version is the repair. `bun pm why @ultimat3/<kind>` names the dependents
|
|
61
|
+
// when a range genuinely disagrees and the update cannot converge on its own.
|
|
62
|
+
fix: `bun update @ultimat3/${kind}`,
|
|
63
|
+
meta: { kind },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
registrars.set(kind, registrar);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function hasPrimitiveRegistrar(kind: PrimitiveKind): boolean {
|
|
70
|
+
return registrars.has(kind);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The registrar for `kind`. Throws rather than returning `undefined`: a caller that skipped a
|
|
75
|
+
* missing registrar would drop every primitive of that kind silently, which is exactly the
|
|
76
|
+
* failure this seam exists to make impossible.
|
|
77
|
+
*/
|
|
78
|
+
export function primitiveRegistrar(kind: PrimitiveKind): ModuleRegistrar {
|
|
79
|
+
const registrar = registrars.get(kind);
|
|
80
|
+
if (registrar === undefined) {
|
|
81
|
+
throw new UltimateError({
|
|
82
|
+
code: 'X_REGISTRAR_MISSING',
|
|
83
|
+
cause: `no ${kind} registrar is loaded, so ${kind} primitives cannot be registered`,
|
|
84
|
+
fix: `bun add @ultimat3/${kind}`,
|
|
85
|
+
meta: { kind },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return registrar;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Test-only. Production announces once at import and never withdraws. */
|
|
92
|
+
export function resetPrimitiveRegistrars(): void {
|
|
93
|
+
registrars.clear();
|
|
94
|
+
}
|
package/src/result.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Single responsibility: `Result<T, E>` for boundaries where throwing is wrong —
|
|
2
|
+
// validation seams, driver probes, CLI commands that must render `--json` either way.
|
|
3
|
+
|
|
4
|
+
import { toUltimateError, type UltimateError } from './errors';
|
|
5
|
+
|
|
6
|
+
export interface Ok<T> {
|
|
7
|
+
readonly ok: true;
|
|
8
|
+
readonly value: T;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Err<E> {
|
|
12
|
+
readonly ok: false;
|
|
13
|
+
readonly error: E;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Result<T, E = UltimateError> = Ok<T> | Err<E>;
|
|
17
|
+
|
|
18
|
+
export function ok<T>(value: T): Ok<T> {
|
|
19
|
+
return { ok: true, value };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function err<E>(error: E): Err<E> {
|
|
23
|
+
return { ok: false, error };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
|
|
27
|
+
return result.ok;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
|
|
31
|
+
return !result.ok;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function map<T, E, U>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
|
|
35
|
+
return result.ok ? ok(fn(result.value)) : result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
|
|
39
|
+
return result.ok ? result : err(fn(result.error));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
|
|
43
|
+
return result.ok ? result.value : fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Throws the contained error — use only where a throw is genuinely correct. */
|
|
47
|
+
export function unwrap<T, E>(result: Result<T, E>): T {
|
|
48
|
+
if (result.ok) return result.value;
|
|
49
|
+
throw toUltimateError(result.error);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function tryCatch<T>(fn: () => Promise<T>): Promise<Result<T, UltimateError>>;
|
|
53
|
+
export function tryCatch<T>(fn: () => T): Result<T, UltimateError>;
|
|
54
|
+
export function tryCatch<T>(
|
|
55
|
+
fn: () => T | Promise<T>,
|
|
56
|
+
): Result<T, UltimateError> | Promise<Result<T, UltimateError>> {
|
|
57
|
+
try {
|
|
58
|
+
const value = fn();
|
|
59
|
+
if (isPromiseLike(value)) {
|
|
60
|
+
return value.then(
|
|
61
|
+
(resolved) => ok(resolved),
|
|
62
|
+
(reason: unknown) => err(toUltimateError(reason)),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return ok(value);
|
|
66
|
+
} catch (thrown) {
|
|
67
|
+
return err(toUltimateError(thrown));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
|
|
72
|
+
return (
|
|
73
|
+
typeof value === 'object' &&
|
|
74
|
+
value !== null &&
|
|
75
|
+
'then' in value &&
|
|
76
|
+
typeof (value as { then: unknown }).then === 'function'
|
|
77
|
+
);
|
|
78
|
+
}
|