@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/CLAUDE.md +393 -0
- package/README.md +179 -10
- package/package.json +7 -5
- package/src/cache.ts +180 -71
- package/src/client.ts +83 -2
- package/src/cursor-value.ts +65 -0
- package/src/deprecation.ts +81 -0
- package/src/errors.ts +97 -1
- package/src/facade.ts +2 -0
- package/src/http.ts +109 -0
- package/src/index.ts +40 -9
- package/src/input-shape.ts +74 -0
- package/src/live.ts +27 -3
- package/src/matcher.ts +36 -9
- package/src/mcp-tool.ts +11 -5
- package/src/naming.ts +5 -9
- package/src/pagination.ts +25 -3
- package/src/policy-gate.ts +13 -2
- package/src/query.ts +108 -8
- package/src/read.ts +116 -16
- package/src/shape.ts +103 -6
- package/src/source.ts +137 -49
- package/src/sql.ts +2 -2
- package/src/stable.ts +23 -12
- package/src/tags.ts +0 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
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": "
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/
|
|
36
|
-
"@ultimat3/
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
81
|
+
const actorAuthority = (actor: Actor): string =>
|
|
82
|
+
JSON.stringify([actor.kind, actor.id, actor.orgId ?? null]);
|
|
49
83
|
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
/**
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|