@ultimat3/query 1.2.0 → 3.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/README.md CHANGED
@@ -29,15 +29,51 @@ Every projection is a method on the query itself. A query has no `.def`.
29
29
  | `liveFeed.as(actor, { orgId })` | the same read as another actor — the surrounding context is untouched, `null` is signed out |
30
30
  | `liveFeed.page({ orgId }, { first: 20, after })` | one bounded page plus the signed cursor that continues it. There is no `offset` |
31
31
  | `liveFeed.live({ orgId })` | the `LiveQuery` `@ultimat3/realtime` subscribes to, carrying the same policy object |
32
- | `liveFeed.tool()` | the MCP read tool. `tool().policy === liveFeed.policy`, and it reads fresh |
32
+ | `liveFeed.tool()` | the MCP read tool, named `liveFeed`. `tool().policy === liveFeed.policy`, and it reads fresh |
33
33
  | `liveFeed.client({ baseUrl })` | `GET /_x/query/live-feed?orgId=…`, typed both ways |
34
34
  | `liveFeed.describe()` | the manifest row |
35
35
 
36
+ The route on the other end of that client is `toQueryRoute(liveFeed)`, and — `As of 2026-08` —
37
+ `x dev` and a container both mount it for every registered read, the framework's job, not the
38
+ app's. The search string is coerced at the wire and validated by the read's own schema, so a bad
39
+ `orgId` is the query's `X_INPUT_INVALID` and a 400; the answer is `no-store`, because the URL
40
+ names no actor while the rows are scoped to one.
41
+
42
+ `queryClient` is the same method for **every** registered read at once — the read half of
43
+ `@ultimat3/action`'s `rpc`, and the one spelling available to a surface that may not import a
44
+ feature:
45
+
46
+ ```ts
47
+ import { queryClient } from '@ultimat3/query';
48
+ import type { Api } from '../api'; // a TYPE, so no module-graph edge
49
+
50
+ export const queries = queryClient<Api['queries']>({ baseUrl });
51
+ const [post] = await queries.publicPost({ slug }); // typed input, typed rows
52
+ ```
53
+
54
+ Both spellings run `queryClientMethodFor`, so a read has one URL however it is addressed.
55
+
36
56
  The declaration is lifted too: `.input`, `.policy`, `.cache`, `.mcp`, `.isLive`. `sql` is not
37
57
  among them — it lives in a private store inside `read.ts`, so `sourceFor` is the only thing
38
58
  that can build a source and there is nowhere for a second authz path to hide. Something that
39
59
  merely looks like a query (`kind: 'query'`, no declaration) is `X_QUERY_FOREIGN`.
40
60
 
61
+ ### Skipping the policy costs a written reason
62
+
63
+ Two reads have no subscriber to decide about: developer tooling that returns the statement and no
64
+ rows (`explain`, `describeSql`), and the shared, subject-less window a sync node builds once per
65
+ `(query, input)`. Both say so, in words:
66
+
67
+ ```ts
68
+ const source = await sourceFor(target, input, {
69
+ unenforced: 'a scaffolded test asserts the SQL text; the policy is asserted separately',
70
+ });
71
+ ```
72
+
73
+ A blank reason is refused before the source is built. It is a string and not a boolean for the
74
+ reason `@ultimat3/entity`'s `crossTenant(reason, fn)` is: `enforce: false` reads exactly like
75
+ forgetting the policy, and the reason is what tells the next reader which of the two it is.
76
+
41
77
  ## What each file owns
42
78
 
43
79
  | File | Job |
@@ -47,7 +83,8 @@ merely looks like a query (`kind: 'query'`, no declaration) is `X_QUERY_FOREIGN`
47
83
  | `facade.ts` | binds each projection to the query; re-implements none of them |
48
84
  | `mcp-tool.ts` | the MCP read descriptor |
49
85
  | `client.ts` | the typed read client (browser-safe) |
50
- | `naming.ts` | export name wire path + tool name |
86
+ | `http.ts` | the route projection `GET /_x/query/<kebab>`, the URL the client derives |
87
+ | `naming.ts` | export name → wire path. The MCP tool name is the export name verbatim |
51
88
  | `live.ts` | the `LiveQuery` descriptor `@ultimat3/realtime` subscribes to |
52
89
  | `matcher.ts` | change event → minimal patch (`add` / `update` / `remove` / `refill`) |
53
90
  | `pagination.ts` | `paginate()` — keyset pages over core's cursor codec |
@@ -106,6 +143,14 @@ the package, and rotating the secret is what invalidates every open cursor. This
106
143
  the only thing that is its business — the scope, `queryHash(name, input)` — and re-exports
107
144
  `CursorInvalidError` so the failure keeps its name on this surface.
108
145
 
146
+ The scope's hash is **SHA-256, first 16 hex** (`fingerprint` in `stable.ts`), the primitive and
147
+ width `@ultimat3/realtime`'s `stableDigest` and `@ultimat3/entity`'s `planScope` already use. It
148
+ was FNV-1a/32 until 2026-08 — 4×10⁹ values over input a client chooses, brute-forceable offline in
149
+ seconds, and a fingerprint here is a *sharing* key: which read-cache entry two callers are served
150
+ from, and which scope a cursor is bound to. The canonical serialization did not change, only the
151
+ hash, so a cursor issued before it fails its scope check as `X_CURSOR_INVALID` with "request the
152
+ first page again" as its fix, and a warm read cache is cold once.
153
+
109
154
  A cursor names a **position in the ordering**, never a row and never a count. Both seek paths
110
155
  answer "is this row after that position?" through the one predicate, `isAfterKey`: `Builder.seek()`
111
156
  compiles it to SQL — spelled out per key, so a mixed `createdAt desc, id asc` listing is a real
@@ -116,23 +161,147 @@ gone. A row with no `id` cannot name a position at all: that is `X_QUERY_NOT_PAG
116
161
  cursor signed over `"undefined"`.
117
162
 
118
163
  Because the predicate always carries that id, the ordering carries it too: a paged read is served
119
- `order by <declared keys>, "id" asc`, and the in-memory path sorts by the same list. Ordering by
120
- the declared keys alone leaves rows with equal sort values in whatever order the database chose,
121
- while the cursor reads them as if id had decided so one of a tied pair comes back on both pages
122
- and the other on neither.
164
+ `order by <declared keys>, "id" asc` that list is `totalOrder(orderBy)`, exported for the reason
165
+ `isAfterKey` is and the in-memory sort and the live matcher's insertion position read the same
166
+ one. Ordering by the declared keys alone leaves rows with equal sort values in whatever order the
167
+ database chose, while the cursor reads them as if id had decided — so one of a tied pair comes back
168
+ on both pages and the other on neither, and a row the matcher appends after a tie group is a
169
+ position no re-read returns.
170
+
171
+ A **live** read is served that way too, and `SqlSource.total()` is how it says so: the same read
172
+ with no cursor and no window, ordered `<declared keys>, "id" asc`. `sourceFor` calls it for
173
+ `surface: 'live'` and for nothing else, so the initial window, the patch positions the matcher
174
+ computes and the keyset re-read a reconnect resumes with are one ordering. A source that does not
175
+ implement `total()` is left alone — it already serves one order it can be resumed in.
176
+
177
+ ## NULL
178
+
179
+ One rule, three readers: the SQL a source generates, the in-memory execution behind `from()`, and
180
+ the live matcher. `null` and a column the row omits are the same absence — `isNull` is the one
181
+ definition, exported for the same reason `isAfterKey` is.
182
+
183
+ | Operator | NULL is | Emitted as |
184
+ |---|---|---|
185
+ | `=` `!=` `in` | a value — it matches itself and nothing else | `is null` · `is not null` · `is distinct from` · `in (…) or is null` |
186
+ | `>` `>=` `<` `<=` | unknown — a NULL on either side matches nothing | `"col" > $n`, which already matches no NULL |
187
+ | `order by`, the cursor | the largest value: last ascending, first descending | `asc nulls last` · `desc nulls first` |
188
+
189
+ `where({ deletedAt: null })` compiles to `"deletedAt" is null` and binds no parameter: `= $1` with
190
+ a NULL argument is unknown in Postgres and unknown is never true, so that filter used to match
191
+ every row in memory and none in the database. The seek predicate had the same defect one page
192
+ later — `"publishedAt" > $1` is unknown for every draft, so page two stopped at the first NULL and
193
+ the rows after it were unreachable through a cursor. An ascending key now reaches them
194
+ (`("col" > $1 or "col" is null)`); a NULL cursor value drops its own term, nothing sorting after a
195
+ NULL under `nulls last`, and the page continues on the id tiebreak, which is never NULL.
196
+
197
+ `nulls last` / `nulls first` are Postgres' own defaults, written down rather than inherited: it is
198
+ the rule `compareValues` implements, so the in-memory sort and the seek predicate can only agree
199
+ with it, and a driver whose default differs cannot re-open the divergence.
123
200
 
124
201
  ## Caching
125
202
 
126
- Request memo (same read twice in one render ⇒ one round trip), then the tier behind
127
- `ReadCache`. Keys are `query:<name>:<input fingerprint>:<tags>`. An action's
128
- `cache.invalidates` and a query's `cache.tags` meet in the one graph owned by
129
- `@ultimat3/cache`.
203
+ Request memo (same read twice in one render ⇒ one round trip), then the tier ladder
204
+ `@ultimat3/cache` has registered `createCacheStack(registeredTiers())`, read down and promoted
205
+ up. Keys are `query:<name>:<authority>:<input fingerprint>:<tags>`. An action's `cache.invalidates`
206
+ and a query's `cache.tags` meet in the one graph owned by `@ultimat3/cache`, because there is one
207
+ registry and this package holds no store of its own.
208
+
209
+ **The authority is who the read was answered for, and it is not optional.** `sql(input, ctx)` is
210
+ handed the context and `@ultimat3/entity` derives every tenant predicate from `ctx.actor.orgId`,
211
+ never from the input — so a key made of the name, the input and the tags did not identify a read's
212
+ answer, and the process-wide tier served one org's rows to the next org that asked. `cache.scope`
213
+ declares who may be served one entry:
214
+
215
+ | `scope` | Key holds | Use it when |
216
+ |---|---|---|
217
+ | `actor` (default) | the actor's kind, id and org | anything. Declaring nothing gets this, and this is always correct |
218
+ | `tenant` | the actor's org — the actor itself when there is none | every member of one org sees the same rows |
219
+ | `global` | nothing | the rows are the same for everyone, signed-in or not |
220
+
221
+ The default is the mechanism: forgetting to declare a scope gives the narrowest key. Widening it
222
+ is a written statement about the rows, one `grep` away — the same shape `unenforced:` uses for a
223
+ skipped policy. `readAuthority(actor, scope)` is the only producer of the component, and it is a
224
+ required positional argument of `cacheKeyFor`, because an optional one is one a call site forgets.
225
+
226
+ **The fill is fenced, best-effort and single-flighted, and none of that is written here.**
227
+ `createCacheStack` samples `@ultimat3/cache`'s fence immediately before it runs the source and
228
+ re-asks it per rung before each write, so a bust that lands mid-read cannot be republished for the
229
+ whole TTL — the caller still gets the rows it read, because those are its answer; only publishing is
230
+ refused. Every tier call goes through `bestEffort`, so a Redis refusal is a miss rather than a
231
+ failed business read and shows up in `recentTierFailures()` under the name of the tier that
232
+ refused. Concurrent misses of one key share one `load()`. `@ultimat3/query` used to carry its own
233
+ copy of the first two and none of the third.
234
+
235
+ `cache.ttlMs` is refused at `query()` unless it is positive and finite
236
+ (`X_QUERY_CACHE_TTL_INVALID`): every tier refuses such a lease, so `ttlMs: Infinity` used to make
237
+ one read fail permanently at run time with a cause about a cache key.
238
+
239
+ An entry is written with the read's `cache.tags`, so a row bust (`post:1`) drops the lists that
240
+ held the row, exactly as `tagMatches` defines it — and `invalidateTags(tags)`, the call an action's
241
+ `cache.invalidates` makes, is the whole of what drops it. There is nothing to install and nothing
242
+ to wire: a `cache:` read fills the registered tiers, so a process that registered none reads
243
+ uncached rather than filling a store no fan-out can see.
244
+
245
+ A `cache:` block that omits `ttlMs` gets `DEFAULT_READ_CACHE_TTL_MS` (60s) rather than immortality.
246
+ Tags are the primary eviction; the TTL is the backstop for the read whose tags never fire. The
247
+ lease handed to the ladder is **relative** — the tier's own clock turns it into an expiry, so a
248
+ tier registered with a frozen clock is drivable end to end — and omitting it is how a tier is asked
249
+ for its own default. `@ultimat3/cache`'s tiers refuse a non-positive `ttlMs` and have no immortal
250
+ entry to offer, so there is no "never" to spell.
251
+
252
+ The memo entry is the read **in flight**, not its value, so "twice" covers reads that race as
253
+ well as reads that follow: five holes rendering concurrently share one execution and one tier
254
+ round trip. A rejection is evicted — a failed read is not the request's answer.
255
+
256
+ | Layer | Applies to | Lifetime |
257
+ |---|---|---|
258
+ | request memo (`readOnce`) | **every** query, `cache:` or not | the request — a `Ctx`'s identity is the key |
259
+ | tag-keyed tier (`readThrough`) | a query that declares `cache:` | `ttlMs`, or until an `invalidates` fan-out drops the tag |
260
+
261
+ `cache:` buys the tier, never the memo: an uncached lookup called once per row of a list would
262
+ otherwise cost one round trip per row. Parsing, the policy and `sql()` still run on every call —
263
+ the memo holds the execution, not the decision — and `.as()` reads in a child context, so an
264
+ impersonated read never joins one made as someone else.
265
+
266
+ `fresh: true` skips both on the way in, and **publishes into the memo on the way out**: it is how
267
+ a caller reads past a write made earlier in the same request, and the read it just made is the
268
+ request's answer from then on, so a later plain read of the same key joins it rather than the entry
269
+ the write moved past. Invalidation drops tier entries, not memo entries.
270
+
271
+ ## `rateLimit:` — the read half, and it is enforced
272
+
273
+ ```ts
274
+ rateLimit: { limit: 3, windowMs: 600_000 }, // 3 held, one back every three and a bit minutes
275
+ ```
276
+
277
+ Symmetric with an action's, and for a reason: without it **every** `GET /_x/query/*` fell to the
278
+ `default` bucket — 120 burst, 2/s per actor — so one authenticated caller could hold 120
279
+ cross-tenant aggregates in flight and then 2/s indefinitely, from a single account, with no
280
+ declaration able to say otherwise. `toQueryRoute` sets the bucket NAME and the NUMBERS, and
281
+ `@ultimat3/http`'s `withRouteBuckets` registers them: a name alone falls through to `default`.
282
+ The conversion is `toBucket` from `@ultimat3/http` — the same one the action route uses, because
283
+ http owns `Bucket` and `action` is the same tier as this package. A pair the limiter cannot run
284
+ on is `X_RATE_LIMIT_INVALID`, at projection.
285
+
286
+ ## `deprecated:` — a compat window, not a version
287
+
288
+ ```ts
289
+ deprecated: { since: '2026-08-01T00:00:00Z', sunset: '2026-12-31T23:59:59Z', replacedBy: 'searchOrders' },
290
+ ```
291
+
292
+ `Deprecation` (RFC 9745) and `Sunset` (RFC 8594) on every answer, a
293
+ `link: </_x/query/search-orders>; rel="successor-version"`, the dates on the descriptor, and a
294
+ `deprecated_calls_total{primitive,name}` counter so "is anyone still reading it?" has an answer
295
+ before the read is deleted. A date that cannot be rendered is `X_QUERY_DEPRECATION_INVALID` at
296
+ projection, not on the first read. Versioning is two deployments behind one ingress, never a
297
+ router feature here.
130
298
 
131
299
  ## Errors
132
300
 
133
301
  | Code | When | Fix |
134
302
  |---|---|---|
135
303
  | `X_QUERY_DUPLICATE` | two queries under one name | rename one export |
304
+ | `X_QUERY_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
136
305
  | `X_QUERY_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
137
306
  | `X_MATCHER_UNSUPPORTED` | live query the matcher cannot patch | reshape it, or `live: false` |
138
307
  | `X_CURSOR_INVALID` | tampered / foreign / malformed cursor | request the first page again |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "1.2.0",
3
+ "version": "3.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.2.0",
34
- "@ultimat3/core": "1.2.0",
35
- "@ultimat3/policy": "1.2.0",
36
- "@ultimat3/schema": "1.2.0"
34
+ "@ultimat3/cache": "3.0.0",
35
+ "@ultimat3/core": "3.0.0",
36
+ "@ultimat3/http": "3.0.0",
37
+ "@ultimat3/policy": "3.0.0",
38
+ "@ultimat3/schema": "3.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
  }