@ultimat3/core 5.0.1 → 7.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 CHANGED
@@ -14,6 +14,7 @@ is a change to every package.
14
14
  | New code | add to `CORE_CODE_TITLES` in `error-codes.ts`, else the title is auto-humanised |
15
15
  | Time | take a `Clock`; `Date.now()` / `new Date()` only inside `clock.ts` |
16
16
  | Context | never thread `ctx` as a parameter — `useContext()` |
17
+ | A value ambient across an `await` | `asyncContext<T>(subject)` from `async-context.ts`, in **every** package — never `new AsyncLocalStorage` |
17
18
  | Exports | add to `src/index.ts` explicitly; no `export *`. Three subjects that each span a dozen modules arrive through `src/exports/` — every name is still written out in `index.ts`, so the public surface is one file to read |
18
19
  | Files | < 200 LOC, 500 hard ceiling, one responsibility, `kebab-case.ts`, test beside source |
19
20
  | Type claims | `type-pins.ts`, never a `.test.ts` — `tsconfig.json` excludes tests, so `tsc` never reads one |
@@ -22,6 +23,24 @@ Deliberate cycles (safe — nothing is referenced at module-evaluation time):
22
23
  `errors.ts ⇄ error-codes.ts`. Keep it that way: no top-level `UltimateError` use in
23
24
  `error-codes.ts`.
24
25
 
26
+ **`async-context.ts` is the framework's ONE `AsyncLocalStorage`, and that is a framework rule
27
+ rather than a core one, `As of 2026-08-20`.** `asyncContext` is exported from `src/index.ts` and
28
+ six modules outside this package opened their own before they adopted it — `@ultimat3/db`'s
29
+ transaction, statement attribution and expected-loop scopes, `@ultimat3/entity`'s `crossTenant`,
30
+ `@ultimat3/ai`'s budget ledger and LLM stream sink. Each was a module-scope `new` a browser bundler
31
+ turns into `TypeError: undefined is not a constructor` at module EVALUATION, so importing any of
32
+ those packages from a client bundle failed before a line of app code ran. Reads degrade to
33
+ `undefined`, writes throw `X_ASYNC_CONTEXT_UNAVAILABLE`; deferring the construction changes nothing
34
+ a server can observe — the storage is built on the first `get()` or `run()` rather than at module
35
+ evaluation, and `getStore()` outside a scope answers `undefined` either way.
36
+
37
+ The mechanical half is `scripts/async-context-guard.ts`, collected by `x verify`'s `unit` step
38
+ through `scripts/async-context-guard.test.ts` — it refuses a `new AsyncLocalStorage` **and** the
39
+ import that binds the class, aliased or namespaced, anywhere but this one file. The browser-barrel
40
+ test in `async-context.test.ts` covers the same defect for core alone and cannot see another
41
+ package; the guard cannot see a runtime `await import('node:async_hooks')`. Neither is the other's
42
+ duplicate.
43
+
25
44
  `error-render.ts` imports nothing, including from this package — an error factory that dies
26
45
  formatting its own message is the failure it exists to prevent, so it cannot depend on anything
27
46
  that could itself throw. The same defect shipped three times (`entity`, `flags`, `cli`) before
@@ -85,6 +104,7 @@ shape against a locally declared sample interface for exactly that reason.
85
104
  |---|---|---|
86
105
  | which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never declare a second env var for it |
87
106
  | what this process does | `roles.ts` (`ROLE`) | |
107
+ | how a route renders, caches offline and hydrates | `route-vocabulary.ts` (`RENDER_MODES`, `OFFLINE_STRATEGIES`, `HYDRATE_STRATEGIES`) | tier 0 because SIX packages name them and imports only go down — `render`, `http`, `seo`, `manifest` and `pwa` each kept a hand-copy until 2026-08, and `'spa'` was deleted from one while five went on admitting it under a green typecheck. Every union is `(typeof ARRAY)[number]`, pinned in `type-pins.ts`; `scripts/render-modes.test.ts` refuses a second declaration anywhere in `packages/*/src`. Re-export it, never restate it |
88
108
  | which build of the APP this is | `app-version.ts` (`APP_VERSION`) | one reader, `dev` by default: `db` writes it into `x_migrations` and `jobs` into `x_backfills`, and `jobs` cannot reach `db` for the answer |
89
109
  | the values | `env.ts` | `checkEnv().values` holds REAL secrets — anything that prints goes through `maskedEnvValues()` |
90
110
  | `.env.example` | `env-example.ts` | a projection of the schema, never hand-maintained |
package/README.md CHANGED
@@ -9,7 +9,8 @@ Zero dependencies, zero `@ultimat3/*` imports.
9
9
  | rendering an app's value into a `cause` / `fix` without throwing | `error-render.ts` |
10
10
  | code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` |
11
11
  | `Result<T, E>` for boundaries where throwing is wrong | `result.ts` |
12
- | request context on `AsyncLocalStorage` | `context.ts` |
12
+ | the one lazy `AsyncLocalStorage`, every ambient scope in the framework | `async-context.ts` |
13
+ | request context on that seam | `context.ts` |
13
14
  | `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
14
15
  | acting as another actor, with an origin and a reason | `impersonate.ts` |
15
16
  | is an error worth retrying? one classification per code | `error-retry.ts` |
@@ -20,6 +21,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
20
21
  | the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
21
22
  | the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
22
23
  | `defineConfig()` for `app.config.ts` | `config.ts` |
24
+ | the closed route vocabulary every renderer names | `route-vocabulary.ts` |
23
25
  | runtime roles + `ROLE` resolution | `roles.ts` |
24
26
  | `Clock` — the only source of "now" | `clock.ts` |
25
27
  | UUIDv7, nanoid, branded ids | `ids.ts` |
@@ -38,7 +40,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
38
40
  | the sockets this process opened, so a self-request is not egress | `listeners.ts` |
39
41
  | `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
40
42
  | the registrar table one same-tier package reaches another through | `registrar.ts` |
41
- | decode → resize → encode, the one image pipeline | `image/` |
43
+ | decode → resize → encode, the one image pipeline (over `Bun.Image`) | `image/` |
42
44
  | `assertNever`, `invariant` | `assert.ts` |
43
45
 
44
46
  ## Errors are instructions
@@ -466,31 +468,55 @@ than in `@ultimat3/time` because `@ultimat3/money` needs it too and tier 1 may n
466
468
 
467
469
  ```ts
468
470
  probeImage(bytes); // { format, width, height, mimeType }
469
- transformImageBytes(bytes, { width: 640, format: 'jpeg', quality: 80 });
470
- blurDataUrl(bytes); // 16px PNG data: URI, the LQIP
471
+ await transformImageBytes(bytes, { width: 640, format: 'webp', quality: 80 });
472
+ await blurDataUrl(bytes); // ThumbHash PNG data: URI, the LQIP
471
473
  ```
472
474
 
473
475
  `storage` variants, `seo` `<picture>` sources and `pwa` icons are the same three steps —
474
476
  decode, resize, encode — with different numbers, so there is one implementation and no second
475
- scaler for an icon to grow a halo in. Zero dependencies: no `sharp`, no native module.
477
+ scaler for an icon to grow a halo in. The codecs are **`Bun.Image`** statically-linked
478
+ libjpeg-turbo / libspng / libwebp with SIMD resize kernels, in the runtime. Still zero
479
+ dependencies: no `sharp`, no native module.
480
+
481
+ Every terminal is `async`, because the pipeline runs on a worker thread. `probeImage` stays
482
+ synchronous: it reads a header and never decodes, which is also why it measures SVG and AVIF that
483
+ no codec here reads.
476
484
 
477
485
  | | |
478
486
  |---|---|
479
- | Decode / encode | PNG and JPEG. `canDecode()` / `canEncode()` publish the real list |
480
- | Probe only | WebP, AVIF, GIF, SVG — measured from the header so `width`/`height` still inline and CLS stays 0 |
487
+ | Decode | PNG, JPEG, WebP, GIF. `canDecode()` publishes the real list |
488
+ | Encode | PNG, JPEG, WebP. `canEncode()` publishes the real list |
489
+ | Probe only | AVIF and SVG — measured from the header so `width`/`height` still inline and CLS stays 0 |
481
490
  | Anything else | `X_IMAGE_UNSUPPORTED`, naming the format and pointing at an `ImageTransformDriver` |
482
- | Ceiling | `MAX_IMAGE_PIXELS` (64MP), checked from the header **before** a byte is allocated |
483
- | Determinism | same bytes + same spec → same output bytes. No clock, no randomness |
484
-
485
- Adding a format is a decoder plus an entry in `DECODABLE_FORMATS` / `ENCODABLE_FORMATS` never a
486
- second dispatch. An unencodable `format` is refused from the spec alone, before the source is
487
- decoded, so a request nothing can write never expands 64 megapixels first.
488
-
489
- `image/` is the one place in core allowed past the 200-line target, and only there: a JPEG or PNG
490
- codec is a single algorithm that does not split into smaller responsibilities without inventing
491
- seams. Nothing else in it qualifies, which is why the segment headers (`jpeg-headers.ts`), the SVG
492
- text parse (`probe-svg.ts`) and the colour grammar (`color.ts`) are their own files. The 500-line
493
- hard ceiling applies to all of them.
491
+ | Ceiling | `MAX_IMAGE_PIXELS` (64MP), passed to the decoder as `maxPixels` and refused from the header **before** a byte is allocated |
492
+ | Determinism | same bytes + same spec → same output bytes, **on every platform** |
493
+
494
+ **AVIF and HEIC are refused everywhere, deliberately.** `Bun.Image` can reach them through an OS
495
+ codec (ImageIO on macOS, WIC on Windows), and this pipeline sets `Bun.Image.backend = 'bun'` on
496
+ every call to forbid exactly that: the static codecs and the Highway geometry kernels are what make
497
+ a laptop and a Linux node produce the same bytes, and `variantKey` is content-addressed. A variant
498
+ that re-encoded differently per platform is a cache that never hits. Producing AVIF means a CDN or
499
+ a custom `ImageTransformDriver`.
500
+
501
+ `transformImageBytes` has two paths and picks by geometry. When the resampled artwork IS the output
502
+ box it is one `Bun.Image` call, source bytes to encoded bytes. When it is not — a letterbox, a
503
+ `padding`, a `cover` crop — the artwork comes back as PNG and `canvas.ts` composites it, because
504
+ `Bun.Image` resamples but has no compositor and the PWA maskable safe zone is a composite.
505
+ `png-pixels.ts` is the raw-pixel seam that hop needs, 8-bit RGBA only; anything else is
506
+ `X_IMAGE_UNSUPPORTED` naming `transformImageBytes`.
507
+
508
+ Adding a format is an entry in `DECODABLE_FORMATS` / `ENCODABLE_FORMATS` and a branch in
509
+ `withFormat` — never a second dispatch. An unencodable `format` is refused from the spec alone,
510
+ before the source is decoded, so a request nothing can write never expands 64 megapixels first.
511
+
512
+ `Bun.Image` rejects with `ERR_IMAGE_*` on `error.code`. `imageFromBunError` is the ONE place that
513
+ is read, mapping it onto `X_IMAGE_UNSUPPORTED` / `X_IMAGE_TOO_LARGE` / `X_IMAGE_DECODE_FAILED`; no
514
+ caller branches on a Bun code.
515
+
516
+ Two files in `image/` are past the 200-line target and neither splits without inventing a seam:
517
+ `probe.ts` is one algorithm per format over header bytes, and `fixtures.ts` is data. The 500-line
518
+ hard ceiling applies to both. Everything else in `image/` is under the target — deleting the
519
+ hand-rolled JPEG and PNG codecs is what put it there.
494
520
 
495
521
  `image/fixtures.ts` is byte-exact output from Pillow and ffmpeg on purpose: a codec that only round
496
522
  trips against itself proves nothing. Never regenerate a fixture with our own encoder.
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "5.0.1",
3
+ "version": "7.0.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
+ "sideEffects": [
8
+ "./src/context.ts",
9
+ "./src/lifecycle-errors.ts",
10
+ "./src/schema-error-codes.ts",
11
+ "./src/secrets-errors.ts"
12
+ ],
7
13
  "repository": {
8
14
  "type": "git",
9
15
  "url": "git+https://github.com/developerz-ai/ultimate.git",
@@ -0,0 +1,79 @@
1
+ // Single responsibility: the one lazily-constructed `AsyncLocalStorage` in the framework. Every
2
+ // ambient value core carries — the request context, the active span, the impersonation reason —
3
+ // opens its scope through this seam, so "what happens where there is no async context" has one
4
+ // answer instead of one per module.
5
+
6
+ // `node:async_hooks` is unavoidable and deliberate: nothing else in Bun makes a value ambient
7
+ // across an `await` without threading it through every signature in the framework.
8
+ import { AsyncLocalStorage } from 'node:async_hooks';
9
+ import { UltimateError } from './errors';
10
+
11
+ export interface AsyncContext<T> {
12
+ /** The value in flight, or `undefined` — outside a scope, and in a runtime that has none. */
13
+ get(): T | undefined;
14
+ /** Run `fn` with `value` in flight. `X_ASYNC_CONTEXT_UNAVAILABLE` where that is impossible. */
15
+ run<R>(value: T, fn: () => R): R;
16
+ }
17
+
18
+ /**
19
+ * The storage is constructed on the first `get()` or `run()`, never at module scope. That is the
20
+ * whole point of this file: a browser bundler stubs `node:async_hooks` to `{}` — Bun's
21
+ * `target: 'browser'` emits `var { AsyncLocalStorage } = (() => ({}))` — so a module-scope `new` threw
22
+ * `TypeError: undefined is not a constructor` at module EVALUATION, and every package that
23
+ * transitively imports core was dead on arrival in a client bundle. `@ultimat3/ui` calls itself a
24
+ * SolidJS design system and could not be put on a client by the only client bundler the framework
25
+ * has, for this reason and no other.
26
+ *
27
+ * The laziness buys the browser bundle, not a server allocation. `open()` runs on a READ as well as
28
+ * a write, so a server whose first call is `get()` constructs the storage there — it is deferred,
29
+ * never skipped. What deferring changes is nothing observable: `getStore()` outside a scope answers
30
+ * `undefined` whether the storage was ever constructed or not, which is what makes it safe.
31
+ *
32
+ * **Reads degrade, writes throw**, and that split is the doctrine rather than a convenience.
33
+ * `get()` answers `undefined` in a browser because that is TRUE — nothing is in flight there, so
34
+ * "am I inside a scope" has a definite no for an answer and does not deserve an exception. It is
35
+ * the same call `@ultimat3/ui`'s `solid()` makes: inert where the capability is genuinely absent,
36
+ * throwing only where a caller asked for something the runtime cannot deliver. `run()` is that
37
+ * second case, so it names itself with a code and a fix rather than leaving a bare `TypeError`
38
+ * from a stack that mentions no file the caller wrote.
39
+ *
40
+ * **A synchronous save/restore fallback is not the answer here**, and this note exists so that it
41
+ * is not re-proposed: a module-level `current` swapped in a `try`/`finally` serves sync code and
42
+ * is silently WRONG across an `await` — two overlapping scopes interleave and the second one's
43
+ * `finally` restores a value the first is still inside. That is the `jobs: { driver: 'redis' }`
44
+ * failure mode this repo already paid for once: accepted, unwarned, and wrong in the dangerous
45
+ * direction. An error a caller can read beats an ambient value that is occasionally somebody
46
+ * else's.
47
+ *
48
+ * `subject` names what could not be opened, and is a `string` by construction — never an
49
+ * `unknown` reaching a `cause:`, which `bun run error-render` refuses.
50
+ */
51
+ export function asyncContext<T>(subject: string): AsyncContext<T> {
52
+ let storage: AsyncLocalStorage<T> | undefined;
53
+
54
+ function open(): AsyncLocalStorage<T> | undefined {
55
+ if (storage !== undefined) return storage;
56
+ // The stub is an object with no `AsyncLocalStorage` key, so the binding reads `undefined`.
57
+ if (typeof AsyncLocalStorage !== 'function') return undefined;
58
+ storage = new AsyncLocalStorage<T>();
59
+ return storage;
60
+ }
61
+
62
+ return {
63
+ get(): T | undefined {
64
+ return open()?.getStore();
65
+ },
66
+ run<R>(value: T, fn: () => R): R {
67
+ const store = open();
68
+ if (store === undefined) {
69
+ throw new UltimateError({
70
+ code: 'X_ASYNC_CONTEXT_UNAVAILABLE',
71
+ cause: `${subject} needs AsyncLocalStorage, and node:async_hooks is stubbed to {} in this runtime`,
72
+ fix: `${subject} is server-only — open it in apps/web/server.ts or a route handler, and keep every module that opens one out of the import graph of a client island entry`,
73
+ meta: { subject },
74
+ });
75
+ }
76
+ return store.run(value, fn);
77
+ },
78
+ };
79
+ }
package/src/config.ts CHANGED
@@ -4,9 +4,13 @@
4
4
 
5
5
  import { ConfigInvalidError } from './errors';
6
6
  import { ROLES, type Role } from './roles';
7
+ // `app.config.ts` CONSUMES the route vocabulary; it does not own it. Declaring `OfflineStrategy`
8
+ // here is what made it copyable — `render`, `manifest` and `pwa` each wrote their own rather than
9
+ // import a name that reads like a config key.
10
+ import type { OfflineStrategy } from './route-vocabulary';
11
+ import { isIanaZoneName } from './time-zone-name';
7
12
 
8
13
  export type ThemeMode = 'light' | 'dark' | 'system';
9
- export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
10
14
  export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn';
11
15
  export type RealtimeTier = 'channels' | 'live-queries' | 'local-first';
12
16
  export type RealtimeTransport = 'memory' | 'nats' | 'redis';
@@ -205,15 +209,6 @@ const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
205
209
  */
206
210
  const CURRENCY_RE = /^[A-Z]{3}$/;
207
211
 
208
- function isTimeZone(value: string): boolean {
209
- try {
210
- new Intl.DateTimeFormat('en', { timeZone: value });
211
- return true;
212
- } catch {
213
- return false;
214
- }
215
- }
216
-
217
212
  function isLocale(value: string): boolean {
218
213
  try {
219
214
  return Intl.getCanonicalLocales(value).length === 1;
@@ -252,8 +247,22 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
252
247
  };
253
248
  }
254
249
 
250
+ const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
251
+
252
+ /**
253
+ * Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
254
+ * spelling to write, and the two refused classes have different remedies — a single-label legacy
255
+ * name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
256
+ * no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
257
+ * two refuse the same strings and an operator may meet either first.
258
+ */
259
+ const TIMEZONE_FIX =
260
+ "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)";
261
+
255
262
  function validate(config: AppConfig): void {
256
263
  const issues: string[] = [];
264
+ // Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
265
+ const zoneFix: string[] = [];
257
266
 
258
267
  if (!NAME_RE.test(config.name)) {
259
268
  issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
@@ -265,8 +274,14 @@ function validate(config: AppConfig): void {
265
274
  if (!config.locales.includes(config.defaultLocale)) {
266
275
  issues.push(`defaultLocale "${config.defaultLocale}" is not in locales`);
267
276
  }
268
- if (!isTimeZone(config.defaultTimeZone)) {
269
- issues.push(`defaultTimeZone "${config.defaultTimeZone}" is not an IANA time zone`);
277
+ // `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
278
+ // `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
279
+ // `format` call, `task()` and `toZoned` below it can then do arithmetic in.
280
+ if (!isIanaZoneName(config.defaultTimeZone)) {
281
+ issues.push(
282
+ `defaultTimeZone "${config.defaultTimeZone}" is not an IANA Area/Location zone name`,
283
+ );
284
+ zoneFix.push(TIMEZONE_FIX);
270
285
  }
271
286
  if (!CURRENCY_RE.test(config.defaultCurrency)) {
272
287
  issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
@@ -284,7 +299,9 @@ function validate(config: AppConfig): void {
284
299
  if (issues.length > 0) {
285
300
  throw new ConfigInvalidError({
286
301
  cause: issues.join('; '),
287
- fix: 'edit app.config.ts to fix the fields named in cause, then run: x verify',
302
+ // The generic instruction goes LAST so the fix line still ends in a command that can be
303
+ // pasted — a trailing `.` after `x verify` is a command nobody can run.
304
+ fix: [...zoneFix, BASE_FIX].join('. '),
288
305
  meta: { issues },
289
306
  });
290
307
  }
package/src/context.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  // service bag reach every layer through AsyncLocalStorage instead of being threaded as
3
3
  // parameters — otherwise every signature in the framework grows a `ctx` argument twice.
4
4
 
5
- import { AsyncLocalStorage } from 'node:async_hooks';
6
5
  import { type Actor, anonymousActor } from './actor';
6
+ import { asyncContext } from './async-context';
7
7
  import { type Clock, systemClock } from './clock';
8
8
  import { UltimateError } from './errors';
9
9
  import { traceId as newTraceId, uuid } from './ids';
@@ -72,7 +72,12 @@ export interface CtxInit {
72
72
 
73
73
  export type CtxPatch = Omit<CtxInit, 'requestId'>;
74
74
 
75
- const storage = new AsyncLocalStorage<Ctx>();
75
+ /**
76
+ * `async-context.ts` owns why this is a lazily-opened seam rather than a module-scope
77
+ * `new AsyncLocalStorage()`, and why a browser gets `undefined` from a read and an error from a
78
+ * write. It is the same seam `telemetry.ts` and `impersonate.ts` open, on purpose: one answer.
79
+ */
80
+ const requestContext = asyncContext<Ctx>('the request context');
76
81
 
77
82
  const neverAborted = new AbortController().signal;
78
83
 
@@ -133,16 +138,16 @@ export function createContext(init: CtxInit = {}): Ctx {
133
138
  }
134
139
 
135
140
  export function runWithContext<T>(ctx: Ctx, fn: () => T): T {
136
- return storage.run(ctx, fn);
141
+ return requestContext.run(ctx, fn);
137
142
  }
138
143
 
139
144
  /** The context, or `undefined` outside a request. Prefer `useContext()` in app code. */
140
145
  export function tryUseContext(): Ctx | undefined {
141
- return storage.getStore();
146
+ return requestContext.get();
142
147
  }
143
148
 
144
149
  export function useContext(): Ctx {
145
- const ctx = storage.getStore();
150
+ const ctx = tryUseContext();
146
151
  if (ctx === undefined) {
147
152
  throw new UltimateError({
148
153
  code: 'X_NO_CONTEXT',
@@ -154,7 +159,7 @@ export function useContext(): Ctx {
154
159
  }
155
160
 
156
161
  export function hasContext(): boolean {
157
- return storage.getStore() !== undefined;
162
+ return tryUseContext() !== undefined;
158
163
  }
159
164
 
160
165
  /**
@@ -183,7 +188,7 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
183
188
  signal: patch.signal ?? parent.signal,
184
189
  services: { ...carried, ...(patch.services ?? {}) },
185
190
  });
186
- return storage.run(child, fn);
191
+ return requestContext.run(child, fn);
187
192
  }
188
193
 
189
194
  /** Resolve a late-bound service. Throws `X_SERVICE_MISSING` rather than returning undefined. */
@@ -222,7 +227,7 @@ export function throwIfAborted(ctx: Ctx = useContext()): void {
222
227
  * never mistaken for the customer's own.
223
228
  */
224
229
  setLoggerContextFields(() => {
225
- const ctx = storage.getStore();
230
+ const ctx = tryUseContext();
226
231
  if (ctx === undefined) return undefined;
227
232
  const { actor } = ctx;
228
233
  return {
@@ -28,6 +28,7 @@ export function errorDocsUrl(code: string): string {
28
28
  /** Codes owned by `@ultimat3/core`. Every other package calls `registerErrorCodes()`. */
29
29
  const CORE_CODE_TITLES = {
30
30
  X_ABORTED: 'operation aborted',
31
+ X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable',
31
32
  X_CONFIG_INVALID: 'app.config.ts is invalid',
32
33
  X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
33
34
  X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
@@ -64,7 +64,7 @@ export function parseSentryDsn(dsn: string): SentryDsn {
64
64
  }
65
65
 
66
66
  /** The protocol's own level names. `warning`/`error`/`fatal` happen to be the same three words. */
67
- const LEVELS: Readonly<Record<ErrorSeverity, string>> = Object.freeze({
67
+ const LEVELS = Object.freeze<Record<ErrorSeverity, string>>({
68
68
  warning: 'warning',
69
69
  error: 'error',
70
70
  fatal: 'fatal',
@@ -0,0 +1,170 @@
1
+ // Single responsibility: the GEOMETRY of a resize — output box, padded inner area, drawn size —
2
+ // and the source-over composite that places the drawn artwork on it. `Bun.Image` resamples but
3
+ // cannot letterbox, pad or crop, so this is the half of a transform it does not do; keeping the
4
+ // arithmetic here is also what lets a caller ask for the box before any pixel exists.
5
+
6
+ import { parseColor } from './color';
7
+ import { imageUnsupported } from './errors';
8
+ import { assertPixelBudget, createRaster, type ImageSize, type Raster } from './raster';
9
+
10
+ export type ImageFit = 'cover' | 'contain';
11
+
12
+ export interface ResizeSpec {
13
+ readonly width?: number | undefined;
14
+ readonly height?: number | undefined;
15
+ /** Default 'contain'. */
16
+ readonly fit?: ImageFit | undefined;
17
+ /** Fraction of the shorter OUTPUT edge left empty on every side. `0 <= padding < 0.5`. */
18
+ readonly padding?: number | undefined;
19
+ /** '#rgb' | '#rgba' | '#rrggbb' | '#rrggbbaa' | 'transparent'. Default transparent. */
20
+ readonly background?: string | undefined;
21
+ }
22
+
23
+ function assertDimension(value: number, field: string): void {
24
+ if (!Number.isInteger(value) || value < 1) {
25
+ throw imageUnsupported(
26
+ `resize ${field} is ${value}, which is not a whole number of pixels above zero`,
27
+ `pass an integer ${field} of 1 or more, or omit it to derive it from the source`,
28
+ { field, value },
29
+ );
30
+ }
31
+ }
32
+
33
+ /**
34
+ * The output CANVAS size. A single-axis request clamps to the source: asking for `width: 2000`
35
+ * of a 400px original must not invent 1600 pixels of blur, it must hand back the 400.
36
+ */
37
+ export function fitBox(source: ImageSize, spec: ResizeSpec): ImageSize {
38
+ const { width, height } = spec;
39
+ if (width !== undefined) assertDimension(width, 'width');
40
+ if (height !== undefined) assertDimension(height, 'height');
41
+ if (width !== undefined && height !== undefined) return { width, height };
42
+ if (width !== undefined) {
43
+ const w = Math.min(width, source.width);
44
+ return { width: w, height: Math.max(1, Math.round((w * source.height) / source.width)) };
45
+ }
46
+ if (height !== undefined) {
47
+ const h = Math.min(height, source.height);
48
+ return { width: Math.max(1, Math.round((h * source.width) / source.height)), height: h };
49
+ }
50
+ return { width: source.width, height: source.height };
51
+ }
52
+
53
+ /** The size the source is DRAWN at inside `box` — no letterbox, no crop maths. May upscale. */
54
+ export function scaledToFit(source: ImageSize, box: ImageSize, fit: ImageFit): ImageSize {
55
+ const x = box.width / source.width;
56
+ const y = box.height / source.height;
57
+ const scale = fit === 'cover' ? Math.max(x, y) : Math.min(x, y);
58
+ return {
59
+ width: Math.max(1, Math.round(source.width * scale)),
60
+ height: Math.max(1, Math.round(source.height * scale)),
61
+ };
62
+ }
63
+
64
+ /** The whole plan for one transform, decided before a pixel is touched. */
65
+ export interface Layout {
66
+ /** The output canvas. */
67
+ readonly box: ImageSize;
68
+ /** Pixels of padding on each edge. */
69
+ readonly pad: number;
70
+ /** The area inside the padding the artwork may occupy. */
71
+ readonly inner: ImageSize;
72
+ /** What the source is resampled to before it is placed. */
73
+ readonly drawn: ImageSize;
74
+ /** Parsed once, here, so an unspellable colour is refused before any pixel is produced. */
75
+ readonly background: readonly [number, number, number, number];
76
+ /** False when `drawn` IS the box and nothing shows through — the resampler's output is the answer. */
77
+ readonly needsCanvas: boolean;
78
+ }
79
+
80
+ export function layOut(source: ImageSize, spec: ResizeSpec): Layout {
81
+ const box = fitBox(source, spec);
82
+ assertPixelBudget(box.width, box.height, 'resize');
83
+ const padding = spec.padding ?? 0;
84
+ if (!Number.isFinite(padding) || padding < 0 || padding >= 0.5) {
85
+ throw imageUnsupported(
86
+ `resize padding is ${padding}, outside the 0 <= padding < 0.5 range`,
87
+ 'pass a fraction of the shorter output edge, e.g. 0.1 for a 10% border on every side',
88
+ { padding },
89
+ );
90
+ }
91
+ const pad = Math.round(Math.min(box.width, box.height) * padding);
92
+ const inner = { width: box.width - 2 * pad, height: box.height - 2 * pad };
93
+ if (inner.width < 1 || inner.height < 1) {
94
+ throw imageUnsupported(
95
+ `padding ${padding} leaves no room inside a ${box.width}x${box.height} output`,
96
+ 'lower the padding or raise the requested width and height',
97
+ { padding, pad, width: box.width, height: box.height },
98
+ );
99
+ }
100
+ const drawn = scaledToFit(source, inner, spec.fit ?? 'contain');
101
+ // Parsed even when the fast path will not use it: 'chartreuse' must be refused whether or not
102
+ // the geometry happens to hide the colour, or the rejection depends on the source's dimensions.
103
+ const background = parseColor(spec.background ?? 'transparent');
104
+ // An opaque background still shows THROUGH a source with alpha, so it needs the canvas even at
105
+ // full bleed. A transparent one does not, and skipping it keeps a plain `srcset` variant out of
106
+ // the RGBA round trip entirely.
107
+ const needsCanvas =
108
+ drawn.width !== box.width || drawn.height !== box.height || background[3] !== 0;
109
+ return { box, pad, inner, drawn, background, needsCanvas };
110
+ }
111
+
112
+ function fill(canvas: Raster, color: readonly [number, number, number, number]): void {
113
+ const [r, g, b, a] = color;
114
+ // A zero-alpha background is canonicalised to all-zero, matching the composite's own
115
+ // `outA === 0 -> outC = 0`: '#ff000000' and 'transparent' must not produce different bytes.
116
+ if (a === 0) return;
117
+ const { pixels } = canvas;
118
+ for (let i = 0; i < pixels.length; i += 4) {
119
+ pixels[i] = r;
120
+ pixels[i + 1] = g;
121
+ pixels[i + 2] = b;
122
+ pixels[i + 3] = a;
123
+ }
124
+ }
125
+
126
+ /** Source-over. `outA === 0` means every contributor was transparent — the colour is nothing. */
127
+ function blend(dst: Uint8ClampedArray, d: number, s: Uint8ClampedArray, p: number): void {
128
+ const sa = s[p + 3] ?? 0;
129
+ if (sa === 0) return;
130
+ const da = dst[d + 3] ?? 0;
131
+ if (sa === 255 || da === 0) {
132
+ dst[d] = s[p] ?? 0;
133
+ dst[d + 1] = s[p + 1] ?? 0;
134
+ dst[d + 2] = s[p + 2] ?? 0;
135
+ dst[d + 3] = sa;
136
+ return;
137
+ }
138
+ const sf = sa / 255;
139
+ const df = (da / 255) * (1 - sf);
140
+ const outA = sf + df;
141
+ dst[d] = ((s[p] ?? 0) * sf + (dst[d] ?? 0) * df) / outA;
142
+ dst[d + 1] = ((s[p + 1] ?? 0) * sf + (dst[d + 1] ?? 0) * df) / outA;
143
+ dst[d + 2] = ((s[p + 2] ?? 0) * sf + (dst[d + 2] ?? 0) * df) / outA;
144
+ dst[d + 3] = outA * 255;
145
+ }
146
+
147
+ /**
148
+ * The artwork, centred in the inner area and clipped to it — that clip is exactly the `cover`
149
+ * crop, so one blit serves both fits.
150
+ */
151
+ export function composeOnto(art: Raster, layout: Layout): Raster {
152
+ const { box, pad, inner } = layout;
153
+ const canvas = createRaster(box.width, box.height, 'resize');
154
+ fill(canvas, layout.background);
155
+ const ox = pad + Math.round((inner.width - art.width) / 2);
156
+ const oy = pad + Math.round((inner.height - art.height) / 2);
157
+ const x1 = Math.min(pad + inner.width, ox + art.width);
158
+ const y1 = Math.min(pad + inner.height, oy + art.height);
159
+ for (let y = Math.max(pad, oy); y < y1; y += 1) {
160
+ for (let x = Math.max(pad, ox); x < x1; x += 1) {
161
+ blend(
162
+ canvas.pixels,
163
+ (y * canvas.width + x) * 4,
164
+ art.pixels,
165
+ ((y - oy) * art.width + (x - ox)) * 4,
166
+ );
167
+ }
168
+ }
169
+ return canvas;
170
+ }
@@ -1,7 +1,9 @@
1
- // Single responsibility: the three failure modes of the image pipeline, as coded errors.
2
- // Every one names the format AND a runnable way forward, because an agent that hits
3
- // "unsupported" needs to know which format to ask for instead, not that it lost.
1
+ // Single responsibility: the three failure modes of the image pipeline, as coded errors, and the
2
+ // one translation of `Bun.Image`'s `ERR_IMAGE_*` rejections into them. Every one names the format
3
+ // AND a runnable way forward, because an agent that hits "unsupported" needs to know which format
4
+ // to ask for instead, not that it lost.
4
5
 
6
+ import { renderThrowable, stringField } from '../error-render';
5
7
  import { UltimateError } from '../errors';
6
8
 
7
9
  export class ImageUnsupportedError extends UltimateError {
@@ -56,3 +58,33 @@ export const imageTooLarge = (
56
58
  'downscale the source before it reaches the pipeline, or raise MAX_IMAGE_PIXELS deliberately',
57
59
  meta,
58
60
  );
61
+
62
+ /**
63
+ * `Bun.Image` rejects with a plain `Error` carrying a stable `error.code`. This is the ONE place
64
+ * that code is read: a caller branching on `ERR_IMAGE_*` would be a second vocabulary for the same
65
+ * three failures, and `X_IMAGE_*` is the one the rest of the framework, the wiki and `x errors
66
+ * explain` already know. Unknown codes land on decode-failed rather than on a bare `Error`.
67
+ */
68
+ const UNSUPPORTED_FIX =
69
+ "request 'png', 'jpeg' or 'webp' — AVIF and HEIC need an OS codec the portable backend never " +
70
+ 'uses, so route those through an ImageTransformDriver (a CDN or an external encoder)';
71
+
72
+ const UNKNOWN_FORMAT_FIX =
73
+ 're-export the source as PNG, JPEG or WebP: `file <path>` reports what these bytes actually are';
74
+
75
+ export function imageFromBunError(value: unknown, doing: string): UltimateError {
76
+ // `renderThrowable`, never `${value}` — the rejection is Bun's value, not ours, and a cause that
77
+ // throws while formatting itself replaces the refusal with a TypeError nothing catches by code.
78
+ const cause = `${doing}: ${renderThrowable(value)}`;
79
+ const code = stringField(value, 'code');
80
+ if (code === 'ERR_IMAGE_FORMAT_UNSUPPORTED') {
81
+ return new ImageUnsupportedError(cause, UNSUPPORTED_FIX, { bunCode: code });
82
+ }
83
+ if (code === 'ERR_IMAGE_UNKNOWN_FORMAT') {
84
+ return new ImageUnsupportedError(cause, UNKNOWN_FORMAT_FIX, { bunCode: code });
85
+ }
86
+ if (code === 'ERR_IMAGE_TOO_MANY_PIXELS') {
87
+ return imageTooLarge(cause, { bunCode: code });
88
+ }
89
+ return imageDecodeFailed(cause, code === undefined ? {} : { bunCode: code });
90
+ }