@ultimat3/query 1.1.0 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,9 +31,10 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/cache": "1.1.0",
34
- "@ultimat3/core": "1.1.0",
35
- "@ultimat3/policy": "1.1.0",
36
- "@ultimat3/schema": "1.1.0"
34
+ "@ultimat3/cache": "2.0.0",
35
+ "@ultimat3/core": "2.0.0",
36
+ "@ultimat3/http": "2.0.0",
37
+ "@ultimat3/policy": "2.0.0",
38
+ "@ultimat3/schema": "2.0.0"
37
39
  }
38
40
  }
package/src/cache.ts CHANGED
@@ -1,101 +1,210 @@
1
1
  /**
2
- * Read caching, two layers: a per-request memo (same query twice in one render
3
- * costs one round trip) and a tag-keyed tier behind the `ReadCache` interface.
4
- * Invalidation is never local — it goes through @ultimat3/cache so an action's
5
- * `invalidates` and a query's `tags` meet in one graph.
2
+ * The read path: a per-request memo (`readOnce` — same query twice in one render costs one
3
+ * execution, whether the second read follows the first or races it) and, for a query that
4
+ * declares `cache:`, the fill through `@ultimat3/cache`'s registered tiers (`readThrough`). Every
5
+ * read gets the memo; the ladder is the half a query opts into.
6
6
  */
7
7
 
8
- import type { CacheTag } from '@ultimat3/cache';
9
- import { invalidateTags } from '@ultimat3/cache';
10
- import type { Ctx } from '@ultimat3/core';
8
+ import type { CacheStack, CacheTag, CacheTier } from '@ultimat3/cache';
9
+ import { createCacheStack, registeredTiers, tagKeys } from '@ultimat3/cache';
10
+ import type { Actor, Clock, Ctx } from '@ultimat3/core';
11
+ import { assertNever } from '@ultimat3/core';
11
12
  import { fingerprint } from './stable';
12
- import { tagKeys } from './tags';
13
13
 
14
- export interface ReadCacheEntry {
15
- readonly value: unknown;
16
- readonly expiresAt: number | null;
17
- }
14
+ /**
15
+ * A `cache:` block with no `ttlMs`. Tag invalidation is the primary eviction, so this is the
16
+ * backstop for the read whose tags never fire — one number, the same 60s `@ultimat3/cache`'s
17
+ * LRU tier defaults to.
18
+ */
19
+ export const DEFAULT_READ_CACHE_TTL_MS = 60_000;
18
20
 
19
- export interface ReadCache {
20
- get(key: string): Promise<ReadCacheEntry | undefined>;
21
- set(key: string, entry: ReadCacheEntry): Promise<void>;
22
- delete(key: string): Promise<void>;
23
- }
21
+ /**
22
+ * Request-scoped memo. Keyed by ctx identity so it dies with the request.
23
+ *
24
+ * An entry is the read *in flight*, not its value: unsettled it is the answer a caller is
25
+ * already waiting for, settled it is the answer. That is what makes two concurrent identical
26
+ * reads one round trip — and it is why no sentinel is needed for a legitimately `undefined`
27
+ * value, which a value-keyed memo cannot tell apart from a miss. A promise is never `undefined`.
28
+ */
29
+ const memos = new WeakMap<object, Map<string, Promise<unknown>>>();
24
30
 
25
- /** In-memory default. Production installs the tiered cache from @ultimat3/cache. */
26
- export class MemoryReadCache implements ReadCache {
27
- readonly #entries = new Map<string, ReadCacheEntry>();
28
-
29
- async get(key: string): Promise<ReadCacheEntry | undefined> {
30
- const entry = this.#entries.get(key);
31
- if (entry === undefined) return undefined;
32
- if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
33
- this.#entries.delete(key);
34
- return undefined;
35
- }
36
- return entry;
37
- }
31
+ export function requestMemo(ctx: Ctx): Map<string, Promise<unknown>> {
32
+ const key: object = ctx;
33
+ const existing = memos.get(key);
34
+ if (existing !== undefined) return existing;
35
+ const created = new Map<string, Promise<unknown>>();
36
+ memos.set(key, created);
37
+ return created;
38
+ }
38
39
 
39
- async set(key: string, entry: ReadCacheEntry): Promise<void> {
40
- this.#entries.set(key, entry);
41
- }
40
+ /**
41
+ * Who a cached answer may be handed back to. Declared as `cache: { scope }`.
42
+ *
43
+ * `actor` is the default, and the default is the mechanism (axiom 3): a read that says nothing
44
+ * gets the NARROWEST key, which is always correct. Widening is a written statement about what the
45
+ * rows are — `tenant` says "every member of this org gets the same rows", `global` says "everyone
46
+ * does" — and a wrong one is visible in the declaration rather than in a support ticket.
47
+ */
48
+ export type QueryCacheScope = 'actor' | 'tenant' | 'global';
42
49
 
43
- async delete(key: string): Promise<void> {
44
- this.#entries.delete(key);
50
+ /**
51
+ * The authority a read was answered under, as a key component.
52
+ *
53
+ * `sql(input, ctx)` is handed the context, and `@ultimat3/entity` derives every tenant predicate
54
+ * from `ctx.actor.orgId` rather than from the input — so the name, the input and the tags do not
55
+ * identify a read's answer, and a tier keyed on those three served one org's rows to the next org
56
+ * that asked. Folding the authority in is what `@ultimat3/entity`'s `scopeKey` does for a batched
57
+ * point read, for exactly this reason.
58
+ *
59
+ * JSON, never a joined string: an actor id is app data and may carry the separator, and a value
60
+ * that can spell a boundary can spell someone else's.
61
+ */
62
+ export function readAuthority(actor: Actor, scope: QueryCacheScope): string {
63
+ switch (scope) {
64
+ case 'global':
65
+ return '*';
66
+ case 'tenant':
67
+ // An actor inside no org is not a shared tenant. Nothing here can prove two org-less callers
68
+ // see the same rows, so the key narrows to the actor rather than widening to everyone —
69
+ // declining instead of guessing, which is the only safe direction for a sharing key.
70
+ return actor.orgId === undefined || actor.orgId === ''
71
+ ? actorAuthority(actor)
72
+ : JSON.stringify(['org', actor.orgId]);
73
+ case 'actor':
74
+ return actorAuthority(actor);
75
+ default:
76
+ // A fourth scope is a compile error here, not a value that silently keys as `undefined`.
77
+ return assertNever(scope);
45
78
  }
46
79
  }
47
80
 
48
- let tier: ReadCache = new MemoryReadCache();
81
+ const actorAuthority = (actor: Actor): string =>
82
+ JSON.stringify([actor.kind, actor.id, actor.orgId ?? null]);
49
83
 
50
- export function setReadCache(cache: ReadCache): void {
51
- tier = cache;
84
+ /**
85
+ * Deterministic: same query + same input + same tags + same authority => same key.
86
+ *
87
+ * `authority` is REQUIRED and positional rather than optional, because an optional one is one a
88
+ * call site can forget — and a forgotten one is the cross-tenant read this argument exists to
89
+ * make impossible. `readAuthority` is the only thing that produces it.
90
+ */
91
+ export function cacheKeyFor(
92
+ name: string,
93
+ input: unknown,
94
+ tags: readonly CacheTag[],
95
+ authority: string,
96
+ ): string {
97
+ return `query:${name}:${authority}:${fingerprint(input)}:${tagKeys(tags).join(',')}`;
52
98
  }
53
99
 
54
- export function getReadCache(): ReadCache {
55
- return tier;
100
+ /**
101
+ * One execution per key per request: the first caller runs it, every caller after joins it.
102
+ *
103
+ * This is the layer a query gets whether or not it declares `cache:` — an uncached read asked
104
+ * once per row of a list is the N+1 the memo exists to collapse.
105
+ */
106
+ export async function readOnce<T>(ctx: Ctx, key: string, run: () => Promise<T>): Promise<T> {
107
+ const memo = requestMemo(ctx);
108
+ const joined = memo.get(key);
109
+ // Already answered or already being answered: the second reader waits on the first read
110
+ // rather than starting a competing one. Awaiting a settled promise costs a microtask.
111
+ if (joined !== undefined) return (await joined) as T;
112
+ return publish(memo, key, run);
56
113
  }
57
114
 
58
- /** Request-scoped memo. Keyed by ctx identity so it dies with the request. */
59
- const memos = new WeakMap<object, Map<string, unknown>>();
115
+ /**
116
+ * Runs no matter what the memo holds, and then *becomes* what it holds — what `fresh: true` asks
117
+ * for.
118
+ *
119
+ * Joining is the half `fresh` refuses; publishing is not. A fresh read that left the earlier entry
120
+ * in place would read past a write for its own caller and hand the next plain read of that key in
121
+ * the same request the answer this one just proved stale — so the guarantee would end at the one
122
+ * call that asked for it.
123
+ */
124
+ export function readFresh<T>(ctx: Ctx, key: string, run: () => Promise<T>): Promise<T> {
125
+ return publish(requestMemo(ctx), key, run);
126
+ }
60
127
 
61
- export function requestMemo(ctx: Ctx): Map<string, unknown> {
62
- const key: object = ctx;
63
- const existing = memos.get(key);
64
- if (existing !== undefined) return existing;
65
- const created = new Map<string, unknown>();
66
- memos.set(key, created);
67
- return created;
128
+ /** The read in flight: published before its first await, evicted if it rejects. */
129
+ async function publish<T>(
130
+ memo: Map<string, Promise<unknown>>,
131
+ key: string,
132
+ run: () => Promise<T>,
133
+ ): Promise<T> {
134
+ // Published before the first await, so a reader arriving in the same tick finds this read.
135
+ const flight = run();
136
+ memo.set(key, flight);
137
+ try {
138
+ return await flight;
139
+ } catch (error) {
140
+ // A rejection is not an answer. Drop it so a later read in the same request retries
141
+ // instead of replaying one failure until the request ends. Only ours: a fresh read may have
142
+ // replaced this entry already, and evicting that one would discard a live answer.
143
+ if (memo.get(key) === flight) memo.delete(key);
144
+ throw error;
145
+ }
68
146
  }
69
147
 
70
- /** Deterministic: same query + same input + same tags => same key. */
71
- export function cacheKeyFor(name: string, input: unknown, tags: readonly CacheTag[]): string {
72
- return `query:${name}:${fingerprint(input)}:${tagKeys(tags).join(',')}`;
148
+ /**
149
+ * One stack per (registry, clock) never one per read.
150
+ *
151
+ * `createCacheStack` owns a single-flight map, so a stack built per call joins nothing and the
152
+ * cross-request stampede guard would be a no-op. Keyed on the clock because the stack's expiry
153
+ * decision and the tiers' own have to agree: a tier registered with a frozen clock under a stack
154
+ * reading the wall clock calls every entry expired, which is the shape that made the old read
155
+ * tier undrivable by a test.
156
+ */
157
+ const stacks = new WeakMap<Clock, { tiers: readonly CacheTier[]; stack: CacheStack }>();
158
+
159
+ const sameTiers = (a: readonly CacheTier[], b: readonly CacheTier[]): boolean =>
160
+ a.length === b.length && a.every((tier, index) => tier === b[index]);
161
+
162
+ function stackFor(clock: Clock): CacheStack {
163
+ const tiers = registeredTiers();
164
+ const held = stacks.get(clock);
165
+ // Rebuilt whenever the registry changes — a boot that registers the shared tier after the first
166
+ // read, and `resetTiers()` between suites. Compared element-wise by identity: a tier object is
167
+ // registered once and never mutated, so two equal lists are the same ladder.
168
+ if (held !== undefined && sameTiers(held.tiers, tiers)) return held.stack;
169
+ const stack = createCacheStack(tiers, { clock });
170
+ stacks.set(clock, { tiers, stack });
171
+ return stack;
73
172
  }
74
173
 
75
- /** Memo first, then the tier, then the source. */
76
- export async function readThrough<T>(
174
+ /**
175
+ * Memo first, then the tier ladder, then the source — what a query with `cache:` reads through.
176
+ *
177
+ * `tags` is what the written entry is dropped by; an entry stored without them is reachable
178
+ * only by its key and can therefore only expire.
179
+ */
180
+ export function readThrough<T>(
77
181
  ctx: Ctx,
78
182
  key: string,
79
183
  ttlMs: number | null,
80
184
  run: () => Promise<T>,
185
+ tags: readonly CacheTag[] = [],
81
186
  ): Promise<T> {
82
- const memo = requestMemo(ctx);
83
- const memoized = memo.get(key);
84
- if (memoized !== undefined) return memoized as T;
85
-
86
- const cached = await tier.get(key);
87
- if (cached !== undefined) {
88
- memo.set(key, cached.value);
89
- return cached.value as T;
90
- }
91
-
92
- const value = await run();
93
- memo.set(key, value);
94
- await tier.set(key, { value, expiresAt: ttlMs === null ? null : Date.now() + ttlMs });
95
- return value;
187
+ return readOnce(ctx, key, () => fill(ctx.clock, key, ttlMs, tags, run));
96
188
  }
97
189
 
98
- /** The one invalidation path. Actions call the same function via their `cache`. */
99
- export async function invalidateQueryTags(tags: readonly CacheTag[]): Promise<void> {
100
- await invalidateTags(tags);
190
+ /**
191
+ * The read itself, through the tiers `@ultimat3/cache` has registered and no store of this
192
+ * package's own. Runs once per key per request; the rest join it at the memo above.
193
+ *
194
+ * Everything this used to do by hand — the fence sampled before the load, `bestEffort` around
195
+ * every tier call, the expiry — is `createCacheStack`'s, which is the point: there was one read
196
+ * cache too many, and the one that lived here was in no registry, so `invalidateTags` could not
197
+ * reach it. A relative `ttlMs` and never an absolute expiry: the tier's own clock decides when
198
+ * the entry dies, so a tier registered with a frozen clock is drivable end to end.
199
+ */
200
+ function fill<T>(
201
+ clock: Clock,
202
+ key: string,
203
+ ttlMs: number | null,
204
+ tags: readonly CacheTag[],
205
+ run: () => Promise<T>,
206
+ ): Promise<T> {
207
+ // `null` is "the caller named none", never "never": every tier refuses a non-positive `ttlMs`
208
+ // and none has an immortal entry to offer, so omitting it falls to the tier's own default.
209
+ return stackFor(clock).read(key, run, { ...(ttlMs === null ? {} : { ttlMs }), tags });
101
210
  }
package/src/client.ts CHANGED
@@ -3,11 +3,18 @@
3
3
  * the same pure derivation the server uses, so a renamed query is a compile error
4
4
  * in a Solid component rather than a 404 at runtime. Browser-safe on purpose: no
5
5
  * server imports, nothing here touches a context, a policy or a database.
6
+ *
7
+ * Rows arrive as JSON and are handed back as parsed, exactly as `rpc` does: a query declares no
8
+ * output schema — row types come from the `SqlSource` its `sql:` returns — so there is nothing
9
+ * here to rehydrate a `Date` with, and an instant reaches a caller as the ISO string
10
+ * `JSON.stringify` wrote. A surface that formats one converts at its own edge.
6
11
  */
7
12
 
13
+ import { currentSpanContext, traceparent } from '@ultimat3/core';
8
14
  import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
9
15
  import { QueryRequestFailedError } from './errors';
10
16
  import { derivePath } from './naming';
17
+ import type { Query } from './query';
11
18
  import { isJsonObject } from './stable';
12
19
 
13
20
  export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
@@ -28,7 +35,57 @@ export type QueryClientMethod<TInput extends StandardSchemaV1, TRow extends obje
28
35
  options?: QueryCallOptions,
29
36
  ) => Promise<readonly TRow[]>;
30
37
 
31
- /** One query's method — what `query.client()` returns. */
38
+ /**
39
+ * Loose constraint on purpose: a map of concrete `Query<TInput, TRow>` values must be
40
+ * assignable to it, while `QueryClient<T>` still recovers each read's own input schema and
41
+ * row type. The mirror of `@ultimat3/action`'s `ActionLike`.
42
+ */
43
+ export interface QueryLike {
44
+ readonly kind: 'query';
45
+ readonly name: string;
46
+ }
47
+
48
+ export type QueryMap = Record<string, QueryLike>;
49
+
50
+ /** `queries.publicPost({ slug })`, with the input schema and the row type both inferred. */
51
+ export type QueryClient<TQueries extends QueryMap> = {
52
+ readonly [K in keyof TQueries]: TQueries[K] extends Query<infer TInput, infer TRow>
53
+ ? QueryClientMethod<TInput, TRow>
54
+ : never;
55
+ };
56
+
57
+ /**
58
+ * The typed client for a whole query map: `queryClient<Api['queries']>({ baseUrl })`, the read
59
+ * half of `rpc<Api['actions']>`. A surface that must not import a feature — `site/`, whose one
60
+ * edge into `app/` would be a boundary violation — reaches every registered read through this
61
+ * and the `Api` TYPE, with no module-graph edge and no codegen step.
62
+ *
63
+ * One blessed name, and one implementation underneath it: every method is
64
+ * `queryClientMethodFor`, so the map-wide spelling and `read.client()` can never derive
65
+ * different URLs for the same read.
66
+ */
67
+ export function queryClient<TQueries extends QueryMap>(
68
+ options: QueryClientOptions,
69
+ ): QueryClient<TQueries> {
70
+ const proxy = new Proxy(
71
+ {},
72
+ {
73
+ get(_target, property: string | symbol) {
74
+ // `then` is answered with `undefined` for the same reason a symbol is: `await client`,
75
+ // `Promise.resolve(client)` and returning the client from an async function all read it,
76
+ // and a method there makes the client a thenable that fetches a read named "then" and
77
+ // resolves the await to its rows. No query may be called `then` — it is the one name the
78
+ // language reserves at this seam.
79
+ if (typeof property !== 'string' || property === 'then') return undefined;
80
+ return queryClientMethodFor(property, options);
81
+ },
82
+ },
83
+ );
84
+ // The proxy realizes the mapped type structurally; TS cannot check a Proxy.
85
+ return proxy as QueryClient<TQueries>;
86
+ }
87
+
88
+ /** One query's method — what `query.client()` returns, and what `queryClient` proxies to. */
32
89
  export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow extends object>(
33
90
  name: string,
34
91
  options: QueryClientOptions,
@@ -52,7 +109,10 @@ async function read(
52
109
  const url = `${base}${derivePath(name)}${search === '' ? '' : `?${search}`}`;
53
110
  const init: RequestInit = {
54
111
  method: 'GET',
55
- headers: { accept: 'application/json', ...options.headers },
112
+ // `traceHeaders()` before the caller's, so an explicit `traceparent` still wins. Without it a
113
+ // service-to-service read started a fresh root trace on the other side, and "which of my
114
+ // downstreams is slow" was unanswerable across every Ultimate-to-Ultimate hop.
115
+ headers: { accept: 'application/json', ...traceHeaders(), ...options.headers },
56
116
  ...(callOptions.signal === undefined ? {} : { signal: callOptions.signal }),
57
117
  };
58
118
 
@@ -63,6 +123,27 @@ async function read(
63
123
  return body;
64
124
  }
65
125
 
126
+ /** A `traceparent` is `00-<32 hex>-<16 hex>-<2 hex>`, and nothing else may be sent as one. */
127
+ const TRACE_ID = /^[0-9a-f]{32}$/;
128
+ const SPAN_ID = /^[0-9a-f]{16}$/;
129
+
130
+ /**
131
+ * The current trace, as the W3C header — or nothing at all. `currentSpanContext()` answers with
132
+ * an empty `spanId` when a request context exists but no span is active, and `00-<trace>--01` is
133
+ * a header every collector drops, so an incomplete context sends none. In a browser there is no
134
+ * ambient context and this is always empty, which is also what keeps a cross-origin read from
135
+ * acquiring a CORS preflight it did not have.
136
+ *
137
+ * `@ultimat3/action`'s client carries the twin of this function: both are tier 3, so neither may
138
+ * import the other — the same reason `naming.ts` is ported rather than shared.
139
+ */
140
+ function traceHeaders(): Record<string, string> {
141
+ const context = currentSpanContext();
142
+ if (context === undefined) return {};
143
+ if (!TRACE_ID.test(context.traceId) || !SPAN_ID.test(context.spanId)) return {};
144
+ return { traceparent: traceparent(context) };
145
+ }
146
+
66
147
  /**
67
148
  * Input as a query string. Keys are sorted so the same input always produces the
68
149
  * same URL — a GET is a cache key, and an unstable one caches nothing.
@@ -0,0 +1,65 @@
1
+ // Single responsibility: what a sort value becomes inside a cursor, and what it becomes again on
2
+ // the way out. The codec is `@ultimat3/core`'s and it is JSON, so a `Date` went in and an ISO
3
+ // STRING came back: `isAfterKey` then compared `"1769904000000"` against `"2026-02-01T…"` through
4
+ // `compareValues`' string branch and page two came back empty. A `bigint` was worse — a bare
5
+ // `TypeError` out of `JSON.stringify`, with no code and no fix.
6
+ //
7
+ // `@ultimat3/entity`'s `cursor.ts` solves the same problem by reading the column's declared kind.
8
+ // A `query` has no column kinds — `QueryShape.orderBy` is a name and a direction — so the value
9
+ // carries its own tag instead. Self-describing, which is also what makes the revive total: nothing
10
+ // here has to know which read minted the cursor.
11
+
12
+ import { CursorValueUnsupportedError } from './errors';
13
+
14
+ /** The two tagged forms. `$x` is a key no column value can collide with: JSON has no bigints. */
15
+ const DATE = 'date';
16
+ const BIGINT = 'bigint';
17
+
18
+ interface TaggedValue {
19
+ readonly $x: typeof DATE | typeof BIGINT;
20
+ readonly v: string;
21
+ }
22
+
23
+ function isTagged(value: unknown): value is TaggedValue {
24
+ if (typeof value !== 'object' || value === null) return false;
25
+ const tag = (value as Record<string, unknown>)['$x'];
26
+ return (
27
+ (tag === DATE || tag === BIGINT) && typeof (value as Record<string, unknown>)['v'] === 'string'
28
+ );
29
+ }
30
+
31
+ /**
32
+ * One sort value, in a form `JSON.stringify` carries losslessly.
33
+ *
34
+ * `undefined` becomes `null` because SQL has one absence and `isNull` reads both as it — a key
35
+ * that encoded `undefined` would be dropped by `JSON.stringify` and shift every later key one
36
+ * position left, which is a cursor that seeks by the wrong column.
37
+ *
38
+ * Everything JSON cannot carry AND this cannot tag is refused HERE, where the cursor is minted:
39
+ * the mistake is the read's `orderBy`, and reporting it on the next request would blame a client
40
+ * for a declaration it never saw.
41
+ */
42
+ export function serializeSortValue(value: unknown): unknown {
43
+ if (value === null || value === undefined) return null;
44
+ if (value instanceof Date) {
45
+ if (Number.isNaN(value.getTime())) throw new CursorValueUnsupportedError('an Invalid Date');
46
+ return { $x: DATE, v: value.toISOString() } satisfies TaggedValue;
47
+ }
48
+ if (typeof value === 'bigint') return { $x: BIGINT, v: value.toString() } satisfies TaggedValue;
49
+ if (typeof value === 'string' || typeof value === 'boolean') return value;
50
+ // `NaN` and `±Infinity` are `null` in JSON, which is the largest value in this framework's sort
51
+ // order — so an unsortable number would decode as "past every row" and end the listing.
52
+ if (typeof value === 'number') {
53
+ if (Number.isFinite(value)) return value;
54
+ throw new CursorValueUnsupportedError(`the number ${String(value)}`);
55
+ }
56
+ throw new CursorValueUnsupportedError(`a ${typeof value}`);
57
+ }
58
+
59
+ /** The inverse, over a whole decoded key. A value that is not one of ours is handed back as is. */
60
+ export function reviveSortKey(key: readonly unknown[]): readonly unknown[] {
61
+ return key.map((value) => {
62
+ if (!isTagged(value)) return value;
63
+ return value.$x === DATE ? new Date(value.v) : BigInt(value.v);
64
+ });
65
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A declared retirement, rendered as the two headers the standards already define — RFC 9745
3
+ * `Deprecation` and RFC 8594 `Sunset` — plus the successor link. Pure string and date maths, and
4
+ * deliberately throw-free: each package raises its own `X_*` for a date it cannot render.
5
+ *
6
+ * `@ultimat3/action` carries the twin of this file. Both are tier 3, so neither may import the
7
+ * other, and the shared home is `@ultimat3/http` (tier 2) once that package grows one — the same
8
+ * compromise `naming.ts` is ported under.
9
+ */
10
+ import { counter } from '@ultimat3/core';
11
+
12
+ export interface Deprecation {
13
+ /** When it was deprecated. ISO-8601, e.g. `'2026-08-01T00:00:00Z'`. */
14
+ readonly since: string;
15
+ /** When it stops answering. ISO-8601 — the date `Sunset` publishes and clients plan against. */
16
+ readonly sunset: string;
17
+ /** The export name of the replacement, projected to a `rel="successor-version"` link. */
18
+ readonly replacedBy?: string;
19
+ }
20
+
21
+ export type DeprecationField = 'since' | 'sunset';
22
+
23
+ export type DeprecationRender =
24
+ | {
25
+ readonly ok: true;
26
+ readonly headers: Readonly<Record<string, string>>;
27
+ /** The same facts as data, for the descriptor and the manifest. */
28
+ readonly meta: Readonly<Record<string, string>>;
29
+ }
30
+ | { readonly ok: false; readonly field: DeprecationField; readonly value: string };
31
+
32
+ /**
33
+ * How many calls a deprecated declaration is still taking — the number "can we remove it yet?"
34
+ * needs. Attributes are the primitive and the declared NAME, both bounded by the size of the
35
+ * codebase; a caller id here would be an unbounded series.
36
+ */
37
+ const deprecatedCalls = counter('deprecated_calls_total', {
38
+ unit: '{call}',
39
+ description: 'Calls served by a declaration that has been deprecated, by primitive and name',
40
+ });
41
+
42
+ export function recordDeprecatedCall(primitive: 'action' | 'query', name: string): void {
43
+ deprecatedCalls.add(1, { primitive, name });
44
+ }
45
+
46
+ /**
47
+ * `Deprecation` is a structured-field Date (`@` + unix seconds, RFC 9745); `Sunset` is an
48
+ * HTTP-date (IMF-fixdate, RFC 8594). Two spellings of one instant because two RFCs chose
49
+ * differently — never render one in the other's format, and never emit `Invalid Date`.
50
+ */
51
+ export function renderDeprecation(
52
+ deprecation: Deprecation,
53
+ successorPath: string | undefined,
54
+ ): DeprecationRender {
55
+ const since = Date.parse(deprecation.since);
56
+ if (Number.isNaN(since)) return { ok: false, field: 'since', value: deprecation.since };
57
+ const sunset = Date.parse(deprecation.sunset);
58
+ if (Number.isNaN(sunset)) return { ok: false, field: 'sunset', value: deprecation.sunset };
59
+
60
+ const headers: Record<string, string> = {
61
+ deprecation: `@${Math.floor(since / 1000)}`,
62
+ sunset: new Date(sunset).toUTCString(),
63
+ };
64
+ // The successor's URL, derived by the caller from the same `naming.ts` the client uses — a
65
+ // link this file built from the export name would be the second URL derivation in the package.
66
+ if (successorPath !== undefined) {
67
+ headers['link'] = `<${successorPath}>; rel="successor-version"`;
68
+ }
69
+
70
+ const meta: Record<string, string> = {
71
+ since: new Date(since).toISOString(),
72
+ sunset: new Date(sunset).toISOString(),
73
+ ...(deprecation.replacedBy === undefined ? {} : { replacedBy: deprecation.replacedBy }),
74
+ };
75
+ return { ok: true, headers, meta };
76
+ }
77
+
78
+ /** Set on a response that already exists, so a problem document carries them too. */
79
+ export function applyHeaders(response: Response, headers: Readonly<Record<string, string>>): void {
80
+ for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
81
+ }