@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/CLAUDE.md +404 -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 +56 -13
- 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/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
|
|
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
|
-
| `
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
127
|
-
`
|
|
128
|
-
|
|
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": "
|
|
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": "
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/
|
|
36
|
-
"@ultimat3/
|
|
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
|
-
*
|
|
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
|
}
|