@ultimat3/core 21.0.0 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +189 -527
- package/README.md +39 -1
- package/package.json +2 -2
- package/src/address-class.ts +143 -0
- package/src/canonical-json.ts +24 -1
- package/src/client-flight.ts +5 -2
- package/src/config-count.ts +19 -0
- package/src/config-fixes.ts +23 -0
- package/src/config-merge.ts +36 -0
- package/src/config.ts +122 -65
- package/src/core-error-codes.ts +1 -0
- package/src/dev-secrets.ts +45 -0
- package/src/exports/secrets.ts +1 -0
- package/src/host-rules.ts +71 -0
- package/src/image/exif-orientation.ts +40 -0
- package/src/image/probe.ts +13 -1
- package/src/in-process-fetch.ts +39 -0
- package/src/index.ts +26 -29
- package/src/iso-date.ts +5 -0
- package/src/lifecycle-grace.ts +44 -0
- package/src/lifecycle-signals.ts +35 -0
- package/src/lifecycle.ts +44 -36
- package/src/logger.ts +22 -3
- package/src/measurement-actor.ts +52 -0
- package/src/metrics-text.ts +10 -2
- package/src/otlp-metric-exporter.ts +39 -12
- package/src/otlp-span-exporter.ts +38 -15
- package/src/secrets-store.ts +51 -5
- package/src/source-mask.ts +30 -0
- package/src/type-pins.ts +9 -0
- package/src/result.ts +0 -78
package/src/logger.ts
CHANGED
|
@@ -304,6 +304,22 @@ function resolveLevel(declared: LogLevel): LogLevel {
|
|
|
304
304
|
return declared;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/** The keys a line owns. A caller field spelled like one is renamed, never allowed to replace it. */
|
|
308
|
+
const RESERVED_KEYS: ReadonlySet<string> = new Set(['ts', 'level', 'msg']);
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The caller's fields with any reserved key moved to `field.<key>`. Spread after `level`, a field
|
|
312
|
+
* `{ level: 'debug' }` turned an `error` line into a `debug` one, so a level-filtered alert never
|
|
313
|
+
* saw it. Renamed rather than dropped: the value is still evidence.
|
|
314
|
+
*/
|
|
315
|
+
function unreserved(fields: Record<string, unknown>): Record<string, unknown> {
|
|
316
|
+
const out: Record<string, unknown> = {};
|
|
317
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
318
|
+
out[RESERVED_KEYS.has(key) ? `field.${key}` : key] = value;
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
307
323
|
export function createLogger(options?: LoggerOptions): Logger {
|
|
308
324
|
const level = options?.level === undefined ? envLevel() : resolveLevel(options.level);
|
|
309
325
|
const bound = options?.fields ?? {};
|
|
@@ -313,13 +329,16 @@ export function createLogger(options?: LoggerOptions): Logger {
|
|
|
313
329
|
|
|
314
330
|
function emit(lineLevel: LogLevel, message: string, fields?: LogFields): void {
|
|
315
331
|
if (LEVEL_WEIGHT[lineLevel] < threshold) return;
|
|
332
|
+
const caller = {
|
|
333
|
+
...redactFields(bound),
|
|
334
|
+
...redactFields(contextFields() ?? {}),
|
|
335
|
+
...redactFields(fields ?? {}),
|
|
336
|
+
};
|
|
316
337
|
const line = {
|
|
317
338
|
ts: timestamp(clock),
|
|
318
339
|
level: lineLevel,
|
|
319
340
|
msg: message,
|
|
320
|
-
...
|
|
321
|
-
...redactFields(contextFields() ?? {}),
|
|
322
|
-
...redactFields(fields ?? {}),
|
|
341
|
+
...unreserved(caller),
|
|
323
342
|
};
|
|
324
343
|
writer(renderLine(line, lineLevel, message, line.ts), lineLevel);
|
|
325
344
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The actor a route is rendered AS when the render exists only to be weighed — `x build`'s budget
|
|
2
|
+
// measurement, whose document is discarded. The default holds every permission and no app facts;
|
|
3
|
+
// a page that reads the app's own facts (`useActor()` over `actorFact`) failed `X_ACTOR_UNRESOLVED`
|
|
4
|
+
// under it, so the app declares its own here, from `app.config.ts`, with no import of the CLI.
|
|
5
|
+
|
|
6
|
+
import type { Actor } from './actor';
|
|
7
|
+
import { serviceActor } from './actor';
|
|
8
|
+
import { assert } from './assert';
|
|
9
|
+
|
|
10
|
+
/** The id every trace and log line under a default measurement render carries. */
|
|
11
|
+
export const MEASUREMENT_ACTOR_ID = 'x-build-measure';
|
|
12
|
+
|
|
13
|
+
/** What an app declares: the actor its authed pages render as while they are weighed. */
|
|
14
|
+
export type MeasurementActorFactory = () => Actor | Promise<Actor>;
|
|
15
|
+
|
|
16
|
+
let declared: MeasurementActorFactory | undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Declare the measurement actor. Called at module scope in `app.config.ts`, the one file every
|
|
20
|
+
* build imports. The LAST declaration wins, because `x dev` re-imports that file on a save and a
|
|
21
|
+
* refusal there would be a reload that fails over a line nobody changed.
|
|
22
|
+
*
|
|
23
|
+
* Only a render that is WEIGHED AND DISCARDED uses it — never a published `site/` artifact, whose
|
|
24
|
+
* `load` must keep failing the build rather than render this actor's rows into a file for everyone.
|
|
25
|
+
*/
|
|
26
|
+
export function defineMeasurementActor(factory: MeasurementActorFactory): void {
|
|
27
|
+
declared = factory;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `kind: 'service'` and `'*'`: weighing bytes needs no data authority, and an app's own
|
|
32
|
+
* `requireMember()` has nothing to resolve for a service actor, which is the honest answer.
|
|
33
|
+
*/
|
|
34
|
+
const defaultMeasurementActor = (): Actor =>
|
|
35
|
+
serviceActor({ id: MEASUREMENT_ACTOR_ID, permissions: ['*'] });
|
|
36
|
+
|
|
37
|
+
/** The declared actor, or the framework's default. The build's ONE reader. */
|
|
38
|
+
export async function measurementActor(): Promise<Actor> {
|
|
39
|
+
if (declared === undefined) return defaultMeasurementActor();
|
|
40
|
+
const actor: Actor | undefined = await declared();
|
|
41
|
+
assert(
|
|
42
|
+
typeof actor === 'object' && actor !== null && typeof actor.id === 'string',
|
|
43
|
+
'the factory handed to defineMeasurementActor() answered no actor',
|
|
44
|
+
'return one from it in app.config.ts: defineMeasurementActor(() => userActor({ id: "measure", roles: ["member"] }))',
|
|
45
|
+
);
|
|
46
|
+
return actor;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Back to the default. For a test, and for nothing else. */
|
|
50
|
+
export function resetMeasurementActor(): void {
|
|
51
|
+
declared = undefined;
|
|
52
|
+
}
|
package/src/metrics-text.ts
CHANGED
|
@@ -9,7 +9,15 @@ export const METRICS_PATH = '/metrics';
|
|
|
9
9
|
|
|
10
10
|
export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* HELP's two escapes, and only those: the exposition format defines `\\` and `\n` for a docstring.
|
|
14
|
+
* `\"` is a label-value escape — in HELP a parser keeps it as a literal backslash before the quote.
|
|
15
|
+
*/
|
|
16
|
+
function escapeHelp(value: string): string {
|
|
17
|
+
return value.replaceAll('\\', '\\\\').replaceAll('\n', '\\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A label VALUE escapes exactly these three, and nothing else. */
|
|
13
21
|
function escapeLabel(value: string): string {
|
|
14
22
|
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n');
|
|
15
23
|
}
|
|
@@ -60,7 +68,7 @@ function histogramLines(name: string, point: HistogramPoint): readonly string[]
|
|
|
60
68
|
function metricLines(metric: ReadableMetric): readonly string[] {
|
|
61
69
|
const { name, kind, description, unit } = metric.descriptor;
|
|
62
70
|
const help = unit === '1' || unit === '' ? description : `${description} (${unit})`;
|
|
63
|
-
const lines = [`# HELP ${name} ${
|
|
71
|
+
const lines = [`# HELP ${name} ${escapeHelp(help)}`, `# TYPE ${name} ${kind}`];
|
|
64
72
|
for (const point of metric.points) {
|
|
65
73
|
if (kind === 'histogram' && isHistogramPoint(point)) {
|
|
66
74
|
lines.push(...histogramLines(name, point));
|
|
@@ -122,7 +122,33 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
|
|
|
122
122
|
const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
|
|
123
123
|
const send = options.fetch ?? globalThis.fetch;
|
|
124
124
|
let startedAtMs = options.startedAtMs;
|
|
125
|
-
|
|
125
|
+
/** The ONE POST in flight, and the one body waiting behind it — never a chain. */
|
|
126
|
+
let sending: Promise<void> | undefined;
|
|
127
|
+
let waiting: string | undefined;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One snapshot in flight and at most one waiting. Each export used to chain a POST behind the
|
|
131
|
+
* last, so a stalled collector held every snapshot taken while it stalled. Snapshots are
|
|
132
|
+
* CUMULATIVE, so the newest supersedes every older one still waiting — dropping those loses no
|
|
133
|
+
* count — and sending strictly one at a time keeps a counter from arriving out of order and
|
|
134
|
+
* reading as a reset. `postOtlp` never rejects; the `then` pair keeps that true for this loop.
|
|
135
|
+
*/
|
|
136
|
+
const pump = (): Promise<void> => {
|
|
137
|
+
if (sending !== undefined) return sending;
|
|
138
|
+
const body = waiting;
|
|
139
|
+
waiting = undefined;
|
|
140
|
+
if (body === undefined) return Promise.resolve();
|
|
141
|
+
sending = postOtlp({ url, headers, body, timeoutMs, fetch: send })
|
|
142
|
+
.then(
|
|
143
|
+
() => undefined,
|
|
144
|
+
() => undefined,
|
|
145
|
+
)
|
|
146
|
+
.then(() => {
|
|
147
|
+
sending = undefined;
|
|
148
|
+
if (waiting !== undefined) void pump();
|
|
149
|
+
});
|
|
150
|
+
return sending;
|
|
151
|
+
};
|
|
126
152
|
|
|
127
153
|
return {
|
|
128
154
|
export(collection: MetricCollection): void {
|
|
@@ -143,18 +169,19 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
|
|
|
143
169
|
});
|
|
144
170
|
return;
|
|
145
171
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// `otlp-span-exporter.ts` spells out: a chain that carries a rejection forward stops calling
|
|
149
|
-
// `postOtlp` for the life of the process, in silence.
|
|
150
|
-
const settled = inflight.then(
|
|
151
|
-
() => undefined,
|
|
152
|
-
() => undefined,
|
|
153
|
-
);
|
|
154
|
-
inflight = settled.then(() => postOtlp({ url, headers, body, timeoutMs, fetch: send }));
|
|
172
|
+
waiting = body;
|
|
173
|
+
void pump();
|
|
155
174
|
},
|
|
156
|
-
flush(): Promise<void> {
|
|
157
|
-
|
|
175
|
+
async flush(): Promise<void> {
|
|
176
|
+
// The one in flight, then the one that was waiting behind it — which that settle started.
|
|
177
|
+
// Bounded, so a timer exporting while this waits cannot keep a shutdown here forever.
|
|
178
|
+
for (
|
|
179
|
+
let round = 0;
|
|
180
|
+
round < 3 && (sending !== undefined || waiting !== undefined);
|
|
181
|
+
round += 1
|
|
182
|
+
) {
|
|
183
|
+
await pump();
|
|
184
|
+
}
|
|
158
185
|
},
|
|
159
186
|
};
|
|
160
187
|
}
|
|
@@ -136,14 +136,15 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
136
136
|
);
|
|
137
137
|
const send = options.fetch ?? globalThis.fetch;
|
|
138
138
|
const queue: ReadableSpan[] = [];
|
|
139
|
-
|
|
139
|
+
/** The ONE batch in flight, or `undefined`. Never a chain: see `pump`. */
|
|
140
|
+
let sending: Promise<void> | undefined;
|
|
140
141
|
|
|
141
142
|
const post = (batch: readonly ReadableSpan[]): Promise<void> => {
|
|
142
143
|
const first = batch[0];
|
|
143
144
|
if (first === undefined) return Promise.resolve();
|
|
144
145
|
let body: string;
|
|
145
146
|
try {
|
|
146
|
-
// The one synchronous throw on this path, and the only way
|
|
147
|
+
// The one synchronous throw on this path, and the only way a send could reject at all:
|
|
147
148
|
// `AttributeValue` is a compile-time claim, so an attribute the app spelled as an object, a
|
|
148
149
|
// bigint or a cycle reaches `anyValue`'s `value.map(...)` as a TypeError. Dropped with a
|
|
149
150
|
// line, the same degradation `postOtlp` already applies to a collector that is down —
|
|
@@ -160,25 +161,47 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
160
161
|
return postOtlp({ url, headers, body, timeoutMs, fetch: send });
|
|
161
162
|
};
|
|
162
163
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
164
|
+
/**
|
|
165
|
+
* At most ONE batch in flight, and at most `maxBatchSize` spans in it. The queue used to be
|
|
166
|
+
* moved wholesale into a promise chain behind the previous POST, so a stalled collector held
|
|
167
|
+
* every span ever exported — 100k with `maxQueueSize: 2048` — and the bound was a bound on the
|
|
168
|
+
* queue, not on memory. Now spans WAIT IN the queue while a POST is unsettled, where drop-oldest
|
|
169
|
+
* applies, and what is held is `maxQueueSize` plus one batch. One at a time also keeps batches
|
|
170
|
+
* in order, so a parent span never arrives after its child. A POST cannot reject (`postOtlp`
|
|
171
|
+
* degrades to a log line) and `post` catches its own serialisation throw, so nothing here can
|
|
172
|
+
* poison the next send.
|
|
173
|
+
*/
|
|
174
|
+
const pump = (): Promise<void> => {
|
|
175
|
+
if (sending !== undefined) return sending;
|
|
176
|
+
const batch = queue.splice(0, maxBatchSize);
|
|
177
|
+
if (batch.length === 0) return Promise.resolve();
|
|
178
|
+
const current = post(batch).then(
|
|
172
179
|
() => undefined,
|
|
173
180
|
() => undefined,
|
|
174
181
|
);
|
|
175
|
-
|
|
176
|
-
|
|
182
|
+
sending = current.then(() => {
|
|
183
|
+
sending = undefined;
|
|
184
|
+
// A full batch waiting behind a slow POST goes next without waiting for the timer.
|
|
185
|
+
if (queue.length >= maxBatchSize) void pump();
|
|
186
|
+
});
|
|
187
|
+
return sending;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Everything queued NOW, batch by batch. Bounded, so a busy exporter cannot keep it looping. */
|
|
191
|
+
const drainQueue = async (): Promise<void> => {
|
|
192
|
+
let rounds = Math.ceil(queue.length / maxBatchSize) + 1;
|
|
193
|
+
while (rounds > 0) {
|
|
194
|
+
rounds -= 1;
|
|
195
|
+
await pump();
|
|
196
|
+
if (queue.length === 0 && sending === undefined) return;
|
|
197
|
+
}
|
|
177
198
|
};
|
|
178
199
|
|
|
179
200
|
// Unref'd, so a pending flush never holds a draining process open — `shutdown()` is what
|
|
180
201
|
// decides the last batch leaves, exactly as `startMetricExport` defers to the drain hook.
|
|
181
|
-
|
|
202
|
+
// `pump`, never `drainQueue`: while a POST is stalled `pump` answers the send already in flight
|
|
203
|
+
// and allocates nothing, where a drain per tick would park one more waiter per interval.
|
|
204
|
+
const timer = setInterval(() => void pump(), flushIntervalMs);
|
|
182
205
|
timer.unref();
|
|
183
206
|
|
|
184
207
|
return {
|
|
@@ -187,7 +210,7 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
187
210
|
// the spans an operator wants during an incident are the ones happening now.
|
|
188
211
|
if (queue.length >= maxQueueSize) queue.shift();
|
|
189
212
|
queue.push(span);
|
|
190
|
-
if (queue.length >= maxBatchSize) void
|
|
213
|
+
if (queue.length >= maxBatchSize) void pump();
|
|
191
214
|
},
|
|
192
215
|
flush(): Promise<void> {
|
|
193
216
|
return drainQueue();
|
package/src/secrets-store.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
// `node:fs` sync, by necessity twice over: Bun.write takes no mode, and a world-readable master key
|
|
7
7
|
// is the whole failure this file exists to prevent — and `installSecrets()` runs once, at boot,
|
|
8
8
|
// before the process is serving anything, so there is nothing for an async read to overlap with.
|
|
9
|
-
|
|
9
|
+
// `renameSync` because Bun has no atomic-replace primitive, and `rmSync` to clear the temp file.
|
|
10
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
11
|
// Bun exposes no path-join primitive.
|
|
11
12
|
import { join } from 'node:path';
|
|
13
|
+
import { isUltimateError } from './errors';
|
|
12
14
|
import type { SecretValues } from './secrets';
|
|
13
15
|
import { masterKeyId, openSecrets, parseMasterKey, sealSecrets } from './secrets';
|
|
14
16
|
import { SecretsFileMissingError, SecretsKeyMissingError } from './secrets-errors';
|
|
@@ -36,6 +38,38 @@ type EnvRecord = Record<string, string | undefined>;
|
|
|
36
38
|
export const secretsPath = (root: string): string => join(root, SECRETS_FILE);
|
|
37
39
|
export const masterKeyPath = (root: string): string => join(root, SECRETS_KEY_FILE);
|
|
38
40
|
export const secretsFileExists = (root: string): boolean => existsSync(secretsPath(root));
|
|
41
|
+
/**
|
|
42
|
+
* Where `x secrets rotate` STAGES a new key before sealing the committed file with it; the rename
|
|
43
|
+
* that makes it live comes last. One spelling, here, for the CLI and the runtime alike.
|
|
44
|
+
*/
|
|
45
|
+
export const stagedMasterKeyPath = (root: string): string => `${masterKeyPath(root)}.next`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The committed file, opened with `key` — or, when that key no longer opens it and a rotation left
|
|
49
|
+
* a staged key, with the staged one. A crash between the seal and the rename leaves exactly that
|
|
50
|
+
* state, and a process booting then could not read its own secrets. READ-ONLY: finishing the
|
|
51
|
+
* rotation (the rename) is `x secrets`'s recovery, never a booting process's — two replicas racing
|
|
52
|
+
* a rename is how a key gets lost. An env key is never second-guessed: the platform owns it.
|
|
53
|
+
*/
|
|
54
|
+
async function openWithStaged(
|
|
55
|
+
root: string,
|
|
56
|
+
key: MasterKeyRef,
|
|
57
|
+
): Promise<{ readonly values: SecretValues; readonly key: MasterKeyRef }> {
|
|
58
|
+
try {
|
|
59
|
+
return { values: await readSecretsFile(root, key), key };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const staged = stagedMasterKeyPath(root);
|
|
62
|
+
const mismatch = isUltimateError(error) && error.code === 'X_SECRETS_KEY_MISMATCH';
|
|
63
|
+
if (!mismatch || key.source !== 'file' || !existsSync(staged)) throw error;
|
|
64
|
+
const candidate: MasterKeyRef = {
|
|
65
|
+
hex: readFileSync(staged, 'utf-8').trim(),
|
|
66
|
+
source: 'file',
|
|
67
|
+
at: staged,
|
|
68
|
+
};
|
|
69
|
+
// The staged key's own mismatch when neither opens it — the file's problem, not the key's.
|
|
70
|
+
return { values: await readSecretsFile(root, candidate), key: candidate };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
39
73
|
|
|
40
74
|
/**
|
|
41
75
|
* Env var first, key file second. That order is what makes one image run everywhere: a container
|
|
@@ -83,10 +117,23 @@ export async function writeSecretsFile(
|
|
|
83
117
|
return path;
|
|
84
118
|
}
|
|
85
119
|
|
|
86
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Write the master key at 0600. Callers must have made the ignore rule true first.
|
|
122
|
+
*
|
|
123
|
+
* Through a fresh temp file renamed over the target, never a write in place: `mode` applies only
|
|
124
|
+
* when a write CREATES the file, so rotating over a key that was 0644 left the new key 0644. The
|
|
125
|
+
* rename is also atomic — a reader sees the old key or the new one, never half of either.
|
|
126
|
+
*/
|
|
87
127
|
export function writeMasterKeyFile(root: string, keyHex: string): string {
|
|
88
128
|
const path = masterKeyPath(root);
|
|
89
|
-
|
|
129
|
+
const temp = `${path}.${crypto.randomUUID()}.tmp`;
|
|
130
|
+
try {
|
|
131
|
+
writeFileSync(temp, `${keyHex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE, flag: 'wx' });
|
|
132
|
+
renameSync(temp, path);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
rmSync(temp, { force: true });
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
90
137
|
return path;
|
|
91
138
|
}
|
|
92
139
|
|
|
@@ -149,8 +196,7 @@ export async function installSecrets(
|
|
|
149
196
|
skipped: [],
|
|
150
197
|
};
|
|
151
198
|
}
|
|
152
|
-
const key = requireMasterKey(root, env);
|
|
153
|
-
const values = await readSecretsFile(root, key);
|
|
199
|
+
const { values, key } = await openWithStaged(root, requireMasterKey(root, env));
|
|
154
200
|
const installed: string[] = [];
|
|
155
201
|
const skipped: string[] = [];
|
|
156
202
|
for (const [name, value] of Object.entries(values)) {
|
package/src/source-mask.ts
CHANGED
|
@@ -27,11 +27,41 @@ export function endOfLiteral(text: string, from: number): number {
|
|
|
27
27
|
for (let i = from + 1; i < text.length; i += 1) {
|
|
28
28
|
if (text[i] === '\\') i += 1;
|
|
29
29
|
else if (text[i] === quote) return i + 1;
|
|
30
|
+
else if (spansLines && text[i] === '$' && text[i + 1] === '{')
|
|
31
|
+
i = endOfInterpolation(text, i + 2) - 1;
|
|
30
32
|
else if (!spansLines && text[i] === '\n') return from + 1;
|
|
31
33
|
}
|
|
32
34
|
return spansLines ? text.length : from + 1;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Index just past the `}` closing a template's `${` whose body starts at `from`. The body is CODE:
|
|
39
|
+
* braces nest, and a string or a template inside it is skipped whole — a nested template's backtick
|
|
40
|
+
* read as the outer one's close desynced every literal after it, and `scripts/guards-doc.ts` lost
|
|
41
|
+
* every `code:` below the nesting to the gate. Comments in the body are skipped the same way.
|
|
42
|
+
*/
|
|
43
|
+
function endOfInterpolation(text: string, from: number): number {
|
|
44
|
+
let depth = 1;
|
|
45
|
+
let i = from;
|
|
46
|
+
while (i < text.length) {
|
|
47
|
+
const ch = text[i] as string;
|
|
48
|
+
if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
|
|
49
|
+
const line = text[i + 1] === '/';
|
|
50
|
+
const end = line ? text.indexOf('\n', i) : text.indexOf('*/', i + 2);
|
|
51
|
+
i = end === -1 ? text.length : line ? end : end + 2;
|
|
52
|
+
} else if (QUOTES.has(ch)) i = endOfLiteral(text, i);
|
|
53
|
+
else if (ch === '{') {
|
|
54
|
+
depth += 1;
|
|
55
|
+
i += 1;
|
|
56
|
+
} else if (ch === '}') {
|
|
57
|
+
depth -= 1;
|
|
58
|
+
i += 1;
|
|
59
|
+
if (depth === 0) return i;
|
|
60
|
+
} else i += 1;
|
|
61
|
+
}
|
|
62
|
+
return text.length;
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
/**
|
|
36
66
|
* Whether the `/` at `at` opens a regex rather than divides — the call no scanner without a parser
|
|
37
67
|
* avoids. A regex cannot follow what ends an expression: an identifier that is not one of the words
|
package/src/type-pins.ts
CHANGED
|
@@ -132,6 +132,15 @@ type _RealtimeConfigCarriesNoDeadField = Assert<
|
|
|
132
132
|
Extract<keyof RealtimeConfig, DeadRealtimeField> extends never ? true : false
|
|
133
133
|
>;
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* `'redis'` accepted, built by nothing, and booted whichever bus `NATS_URL` chose — removed in
|
|
137
|
+
* 22.0.0 when `selectTransport` began building what `transport` says. Re-adding it to the union is
|
|
138
|
+
* a type that promises a bus the framework does not have.
|
|
139
|
+
*/
|
|
140
|
+
type _RealtimeTransportHasNoRedis = Assert<
|
|
141
|
+
'redis' extends RealtimeConfig['transport'] ? false : true
|
|
142
|
+
>;
|
|
143
|
+
|
|
135
144
|
/** And the input side with it — `Input<RealtimeConfig>` is what an `app.config.ts` writes. */
|
|
136
145
|
type _RealtimeInputCarriesNoDeadField = Assert<
|
|
137
146
|
Extract<keyof NonNullable<AppConfigInput['realtime']>, DeadRealtimeField> extends never
|
package/src/result.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
}
|