@ultimat3/core 8.0.0 → 9.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
@@ -110,6 +110,7 @@ shape against a locally declared sample interface for exactly that reason.
110
110
  | which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never declare a second env var for it |
111
111
  | what this process does | `roles.ts` (`ROLE`) | |
112
112
  | 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 |
113
+ | which rungs a cache ladder has | `cache-vocabulary.ts` (`CACHE_TIERS`) | tier 0 for the same reason, one tier lower down the stack: `app.config.ts` picks tiers by name and `@ultimat3/cache` builds them by name, and until 2026-08-22 those were two different vocabularies — config accepted `memo \| lru \| shared \| isr \| cdn`, the ladder ordered `request-memo \| lru \| redis \| cdn`, nothing mapped one onto the other, and `cache: { tiers: ['isr'] }` typechecked and selected nothing (issue #293). `TIER_ORDER` IS this array, so read order and the config vocabulary cannot disagree. `isr` is a `RenderMode`, never a tier. Pinned in `type-pins.ts` and by `scripts/render-modes.ts` |
113
114
  | 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 |
114
115
  | the values | `env.ts` | `checkEnv().values` holds REAL secrets — anything that prints goes through `maskedEnvValues()` |
115
116
  | `.env.example` | `env-example.ts` | a projection of the schema, never hand-maintained |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "8.0.0",
3
+ "version": "9.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",
@@ -0,0 +1,25 @@
1
+ // Single responsibility: the cache tiers' NAMES, in read order — the ladder itself is
2
+ // `@ultimat3/cache`'s. Tier 0 because that is the one place a tier-0 config declaration and a
3
+ // tier-1 implementation can both see; not `config.ts`, because `app.config.ts` consumes these
4
+ // names rather than owning them.
5
+
6
+ /**
7
+ * The rungs, near to far. **Order is load-bearing**: `sortTiers` in `@ultimat3/cache` orders a
8
+ * stack by index in this array, so a name's position here IS its distance from the request, and a
9
+ * name missing from it sorts to `-1` — ahead of the request memo.
10
+ *
11
+ * Spelled once because it was spelled twice and the two disagreed (issue #293): `app.config.ts`
12
+ * accepted `memo | lru | shared | isr | cdn` while the stack ordered `request-memo | lru | redis |
13
+ * cdn`, so `cache: { tiers: ['isr'] }` typechecked and selected nothing. `memo`/`request-memo` and
14
+ * `shared`/`redis` were one rung spelled twice; the ladder's spelling wins, because it is the one
15
+ * a `TierInvalidation`, a `TierFailure` and the `/_x` panel already report.
16
+ *
17
+ * **`isr` is not here and is not a cache tier.** It is a `RenderMode` (`route-vocabulary.ts`) — a
18
+ * per-route rendering decision, revalidated by a tag bust — and `@ultimat3/cache` has no ISR store
19
+ * to build: `'isr'` appears nowhere in `packages/cache/src` except one invalidation label. Serving
20
+ * ISR is `render: 'isr'` on the route, never a rung of this ladder.
21
+ */
22
+ export const CACHE_TIERS = ['request-memo', 'lru', 'redis', 'cdn'] as const;
23
+
24
+ /** Derived from the array rather than written twice, so the pair cannot disagree. */
25
+ export type CacheTierName = (typeof CACHE_TIERS)[number];
package/src/config.ts CHANGED
@@ -2,6 +2,10 @@
2
2
  // defaults, validated eagerly, and composable so a big app can split it across `config/*.ts`
3
3
  // without inventing a second config mechanism.
4
4
 
5
+ // Same rule for the same reason: `app.config.ts` CONSUMES the cache tier names, it does not own
6
+ // them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
7
+ // drift into two vocabularies with no map between them (issue #293).
8
+ import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
5
9
  import { ConfigInvalidError } from './errors';
6
10
  import { ROLES, type Role } from './roles';
7
11
  // `app.config.ts` CONSUMES the route vocabulary; it does not own it. Declaring `OfflineStrategy`
@@ -11,7 +15,6 @@ import type { OfflineStrategy } from './route-vocabulary';
11
15
  import { isIanaZoneName } from './time-zone-name';
12
16
 
13
17
  export type ThemeMode = 'light' | 'dark' | 'system';
14
- export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn';
15
18
  export type RealtimeTier = 'channels' | 'live-queries' | 'local-first';
16
19
  export type RealtimeTransport = 'memory' | 'nats' | 'redis';
17
20
 
@@ -70,11 +73,25 @@ export interface DatabaseConfig {
70
73
  readonly ssl: boolean;
71
74
  }
72
75
 
76
+ /**
77
+ * No `CacheTier`. It was a SECOND spelling of the ladder — `memo | lru | shared | isr | cdn`
78
+ * against `@ultimat3/cache`'s `request-memo | lru | redis | cdn` — with nothing mapping one onto
79
+ * the other, so `cache: { tiers: ['isr'] }` typechecked and selected nothing. Deleted 2026-08-22
80
+ * in favour of `CacheTierName`, which is the ladder's own names and the only ones `sortTiers` can
81
+ * place. It was also the second exported type called `CacheTier` in the tree; the other is
82
+ * `@ultimat3/cache`'s tier INTERFACE, which is the one every implementation names.
83
+ *
84
+ * No `driver` and no `urlEnv` either, deleted 2026-08-22 and for the same reason one rung further
85
+ * up: `tiers` is what BUILDS the ladder (`packages/cli/src/dev-cache.ts`), so `driver: 'redis'`
86
+ * beside `tiers: ['request-memo', 'lru']` was a second selector that selected nothing — the shape
87
+ * `examples/dummy/app.config.ts` shipped. `urlEnv` was `database.urlEnv` verbatim: the Redis tier
88
+ * reads the literal `REDIS_URL`, so `urlEnv: 'MY_REDIS'` made nothing read `MY_REDIS`. Which rungs
89
+ * exist is `cache.tiers` and only that; a rung the environment cannot supply refuses the boot.
90
+ */
73
91
  export interface CacheConfig {
74
- readonly driver: 'memory' | 'redis';
75
- readonly urlEnv: string | undefined;
76
92
  readonly defaultTtlMs: number;
77
- readonly tiers: readonly CacheTier[];
93
+ /** Order is fixed by `TIER_ORDER`; listing order here selects rungs, it does not rank them. */
94
+ readonly tiers: readonly CacheTierName[];
78
95
  }
79
96
 
80
97
  export interface JobsConfig {
@@ -219,7 +236,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
219
236
  pwa: { enabled: false, offline: 'network-only', backgroundSync: false, push: false },
220
237
  roles: [...ROLES],
221
238
  database: { driver: 'postgres', ssl: false },
222
- cache: { driver: 'memory', urlEnv: undefined, defaultTtlMs: 60_000, tiers: ['memo', 'lru'] },
239
+ cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
223
240
  jobs: {
224
241
  queues: [`${name}-default`],
225
242
  concurrency: 8,
@@ -244,10 +261,21 @@ const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run:
244
261
  const TIMEZONE_FIX =
245
262
  "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)";
246
263
 
264
+ /**
265
+ * Appended only when a tier name is what failed, and it names the rename rather than the rule: the
266
+ * three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
267
+ * replacement while `isr` has none — it is a `RenderMode`, and no cache tier ever served it.
268
+ */
269
+ const CACHE_TIER_FIX =
270
+ "in app.config.ts, rewrite cache.tiers with the rung names the ladder serves — request-memo, lru, redis, cdn — where memo becomes request-memo and shared becomes redis, and isr is dropped: it is a render mode, so move it to render: 'isr' on the routes that want it";
271
+
247
272
  function validate(config: AppConfig): void {
248
273
  const issues: string[] = [];
249
274
  // Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
250
275
  const zoneFix: string[] = [];
276
+ // Same shape, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']` in an
277
+ // untyped config file reaches here rather than the compiler, and needs the new spelling.
278
+ const tierFix: string[] = [];
251
279
 
252
280
  if (!NAME_RE.test(config.name)) {
253
281
  issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
@@ -277,8 +305,13 @@ function validate(config: AppConfig): void {
277
305
  if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
278
306
  issues.push(`realtime.transport "${config.realtime.transport}" requires realtime.urlEnv`);
279
307
  }
280
- if (config.cache.driver === 'redis' && config.cache.urlEnv === undefined) {
281
- issues.push('cache.driver "redis" requires cache.urlEnv');
308
+ // A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
309
+ // index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
310
+ // So an unknown tier is refused at boot rather than silently ignored or silently placed first.
311
+ for (const tier of config.cache.tiers) {
312
+ if (CACHE_TIERS.includes(tier)) continue;
313
+ issues.push(`cache.tiers contains "${tier}", which is not one of ${CACHE_TIERS.join(', ')}`);
314
+ if (tierFix.length === 0) tierFix.push(CACHE_TIER_FIX);
282
315
  }
283
316
 
284
317
  if (issues.length > 0) {
@@ -286,7 +319,7 @@ function validate(config: AppConfig): void {
286
319
  cause: issues.join('; '),
287
320
  // The generic instruction goes LAST so the fix line still ends in a command that can be
288
321
  // pasted — a trailing `.` after `x verify` is a command nobody can run.
289
- fix: [...zoneFix, BASE_FIX].join('. '),
322
+ fix: [...zoneFix, ...tierFix, BASE_FIX].join('. '),
290
323
  meta: { issues },
291
324
  });
292
325
  }
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
33
33
  export { assert, assertNever, type InvariantOptions, invariant } from './assert';
34
34
  export { type AsyncContext, asyncContext } from './async-context';
35
+ export { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
35
36
  export { canonicalJson, fingerprint } from './canonical-json';
36
37
  export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
37
38
  export type {
@@ -42,7 +43,6 @@ export type {
42
43
  AppConfigOverlay,
43
44
  AuthConfig,
44
45
  CacheConfig,
45
- CacheTier,
46
46
  DatabaseConfig,
47
47
  JobsConfig,
48
48
  McpConfig,
package/src/registrar.ts CHANGED
@@ -59,6 +59,7 @@ export const PRIMITIVE_FACTORIES = Object.freeze<readonly PrimitiveFactory[]>(
59
59
  { factory: 'hive', pkg: '@ultimat3/ai', kind: 'action' },
60
60
  { factory: 'llm', pkg: '@ultimat3/ai', kind: 'action' },
61
61
  { factory: 'backfill', pkg: '@ultimat3/jobs', kind: 'job' },
62
+ { factory: 'purge', pkg: '@ultimat3/jobs', kind: 'job' },
62
63
  { factory: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
63
64
  ] satisfies readonly PrimitiveFactory[]
64
65
  ).map((entry) => Object.freeze(entry)),
package/src/type-pins.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  // Compile-time pins for the actor-facts seam, the config surface, the request-context patch and
2
- // the route vocabulary.
2
+ // the route and cache-tier vocabularies.
3
3
  // Source, not a `.test.ts`, on purpose: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b`
4
4
  // never reads a test file and a type-level assertion written there can never fail. This module
5
5
  // emits nothing and exports nothing anybody imports — a regression is a build error.
6
6
 
7
7
  import type { Actor, ActorFactMap, FactKeysOf, FactMapOf } from './actor';
8
- import type { AppConfigInput, DatabaseConfig } from './config';
8
+ import type { CacheTierName } from './cache-vocabulary';
9
+ import type { AppConfigInput, CacheConfig, DatabaseConfig } from './config';
9
10
  import type { CtxPatch } from './context';
10
11
  import type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
11
12
 
@@ -101,6 +102,23 @@ type _DatabaseInputCarriesNoDeadField = Assert<
101
102
  : false
102
103
  >;
103
104
 
105
+ /**
106
+ * The two `config.cache` fields deleted 2026-08-22, held down for the reason the `database` three
107
+ * are: `cache.tiers` is what BUILDS the ladder, so `driver: 'redis'` was a second selector that
108
+ * selected nothing and `urlEnv` named an env key the Redis tier never reads — it reads the literal
109
+ * `REDIS_URL`. Re-adding either restores a knob an SRE sets, redeploys, and sees no effect from.
110
+ */
111
+ type DeadCacheField = 'driver' | 'urlEnv';
112
+
113
+ type _CacheConfigCarriesNoDeadField = Assert<
114
+ Extract<keyof CacheConfig, DeadCacheField> extends never ? true : false
115
+ >;
116
+
117
+ /** And the input side with it — `Input<CacheConfig>` is what an `app.config.ts` writes. */
118
+ type _CacheInputCarriesNoDeadField = Assert<
119
+ Extract<keyof NonNullable<AppConfigInput['cache']>, DeadCacheField> extends never ? true : false
120
+ >;
121
+
104
122
  /**
105
123
  * Neither id a child context may patch. `withChildContext` forwards the parent's `buildId`
106
124
  * verbatim, so `{ buildId }` on the patch was an option that read as honoured and was dropped
@@ -142,3 +160,33 @@ type _OfflineStrategyIsItsArray = Assert<
142
160
  type _HydrateStrategyIsItsArray = Assert<
143
161
  Exact<HydrateStrategy, 'idle' | 'visible' | 'interaction' | 'never'>
144
162
  >;
163
+
164
+ /**
165
+ * The cache ladder's rungs, same rule as the three above and for a defect that shipped: 8.0.0's
166
+ * `CacheTier` was a hand-written union in `config.ts` — `memo | lru | shared | isr | cdn` — while
167
+ * `@ultimat3/cache` ordered `request-memo | lru | redis | cdn`, so `cache: { tiers: ['isr'] }`
168
+ * typechecked and selected nothing (issue #293). Derived from `CACHE_TIERS` now, and pinned here
169
+ * because a `@ts-expect-error` in an excluded test file asserts nothing.
170
+ */
171
+ type _CacheTierNameIsItsArray = Assert<
172
+ Exact<CacheTierName, 'request-memo' | 'lru' | 'redis' | 'cdn'>
173
+ >;
174
+
175
+ /**
176
+ * The three spellings deleted in 9.0.0 must stay deleted. `memo` and `shared` were the ladder's
177
+ * near and shared rungs under a second name; `isr` was never a tier at all — it is a `RenderMode`,
178
+ * and re-admitting it would put a rung `sortTiers` places at `-1` (AHEAD of the request memo)
179
+ * back within reach of `app.config.ts`.
180
+ */
181
+ type DeadCacheTier = 'memo' | 'shared' | 'isr';
182
+
183
+ type _CacheTiersRefuseTheOldSpellings = Assert<
184
+ Extract<CacheTierName, DeadCacheTier> extends never ? true : false
185
+ >;
186
+
187
+ /** And `cache.tiers` is that vocabulary, not a second one — the whole of the fix. */
188
+ type _CacheConfigNamesTheLadder = Assert<Exact<CacheConfig['tiers'], readonly CacheTierName[]>>;
189
+
190
+ type _CacheInputNamesTheLadder = Assert<
191
+ Exact<NonNullable<AppConfigInput['cache']>['tiers'], readonly CacheTierName[] | undefined>
192
+ >;