@ultimat3/query 31.6.1 → 31.7.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 +2 -1
- package/README.md +7 -1
- package/package.json +8 -8
- package/src/cache.ts +19 -30
package/CLAUDE.md
CHANGED
|
@@ -261,7 +261,8 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
261
261
|
## Invariants — caching
|
|
262
262
|
|
|
263
263
|
- **The request memo holds the read, not the rows**: `readOnce` publishes the in-flight promise
|
|
264
|
-
before its first await; a rejection is evicted. `requestMemo(ctx)` is `
|
|
264
|
+
before its first await; a rejection is evicted. `requestMemo(ctx)` is `memoFlights(ctx,
|
|
265
|
+
'query')` (`@ultimat3/cache`): one store, 8 MiB per ctx. Never a second `WeakMap` here.
|
|
265
266
|
- **Every read is memoized; only a `cache:` read goes through the tier** (`readThrough`). One key
|
|
266
267
|
function, `cacheKeyFor`, over the PARSED input `admitAndBuild` returns — never `raw`, or a
|
|
267
268
|
stripped `?junk=N` is an entry and a read per N (`read-cache-key.test.ts`). Outside, a key
|
package/README.md
CHANGED
|
@@ -609,9 +609,15 @@ round trip. A rejection is evicted — a failed read is not the request's answer
|
|
|
609
609
|
|
|
610
610
|
| Layer | Applies to | Lifetime |
|
|
611
611
|
|---|---|---|
|
|
612
|
-
| request memo (`readOnce`) | **every** query, `cache:` or not | the request — a `Ctx`'s identity is the key |
|
|
612
|
+
| request memo (`readOnce`) | **every** query, `cache:` or not | the request — a `Ctx`'s identity is the key; at most `MEMO_MAX_BYTES` (8 MiB) per ctx, oldest evicted (a job attempt reading page after page) |
|
|
613
613
|
| tag-keyed tier (`readThrough`) | a query that declares `cache:` | `ttlMs`, or until an `invalidates` fan-out drops the tag |
|
|
614
614
|
|
|
615
|
+
**`requestMemo(ctx)` returns `MemoFlights`** (`@ultimat3/cache`), not a `Map` — `get`, `has`,
|
|
616
|
+
`publish`, `bytes` over `memoFlights(ctx, 'query')`, the one per-ctx memo store. A caller that wrote
|
|
617
|
+
`requestMemo(ctx).set(…)`/`.delete(…)` no longer compiles: publish with `readFresh(ctx, key, run)`,
|
|
618
|
+
and let a rejection evict itself. A held read is charged by `approxBytes` — a bounded sample of
|
|
619
|
+
the rows (≈2 µs for 50 or 500 rows), never a `JSON.stringify` (≈155 µs / 1.5 ms).
|
|
620
|
+
|
|
615
621
|
`cache:` buys the tier, never the memo: an uncached lookup called once per row of a list would
|
|
616
622
|
otherwise cost one round trip per row. Parsing, the policy and `sql()` still run on every call —
|
|
617
623
|
the memo holds the execution, not the decision — and `.as()` reads in a child context, so an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "31.
|
|
3
|
+
"version": "31.7.0",
|
|
4
4
|
"description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -49,12 +49,12 @@
|
|
|
49
49
|
"test": "bun test"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@ultimat3/cache": "31.
|
|
53
|
-
"@ultimat3/core": "31.
|
|
54
|
-
"@ultimat3/db": "31.
|
|
55
|
-
"@ultimat3/entity": "31.
|
|
56
|
-
"@ultimat3/http": "31.
|
|
57
|
-
"@ultimat3/policy": "31.
|
|
58
|
-
"@ultimat3/schema": "31.
|
|
52
|
+
"@ultimat3/cache": "31.7.0",
|
|
53
|
+
"@ultimat3/core": "31.7.0",
|
|
54
|
+
"@ultimat3/db": "31.7.0",
|
|
55
|
+
"@ultimat3/entity": "31.7.0",
|
|
56
|
+
"@ultimat3/http": "31.7.0",
|
|
57
|
+
"@ultimat3/policy": "31.7.0",
|
|
58
|
+
"@ultimat3/schema": "31.7.0"
|
|
59
59
|
}
|
|
60
60
|
}
|
package/src/cache.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* read gets the memo; the ladder is the half a query opts into.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { CacheStack, CacheTag, CacheTier } from '@ultimat3/cache';
|
|
9
|
-
import { cacheStack, registeredTiers, tagKeys } from '@ultimat3/cache';
|
|
8
|
+
import type { CacheStack, CacheTag, CacheTier, MemoFlights } from '@ultimat3/cache';
|
|
9
|
+
import { cacheStack, memoFlights, registeredTiers, tagKeys } from '@ultimat3/cache';
|
|
10
10
|
import type { Actor, Clock, Ctx } from '@ultimat3/core';
|
|
11
11
|
import { assertNever, fingerprint } from '@ultimat3/core';
|
|
12
12
|
|
|
@@ -18,22 +18,18 @@ import { assertNever, fingerprint } from '@ultimat3/core';
|
|
|
18
18
|
export const DEFAULT_READ_CACHE_TTL_MS = 60_000;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Request-scoped memo
|
|
21
|
+
* Request-scoped memo: `@ultimat3/cache`'s per-ctx memo store, under its `MEMO_MAX_BYTES` bound —
|
|
22
|
+
* never a second store. Keyed by ctx identity so it dies with the request; bounded because a job's
|
|
23
|
+
* ctx is the whole attempt, and an unbounded memo held every page a backfill read until it ended.
|
|
22
24
|
*
|
|
23
25
|
* An entry is the read *in flight*, not its value: unsettled it is the answer a caller is
|
|
24
26
|
* already waiting for, settled it is the answer. That is what makes two concurrent identical
|
|
25
27
|
* reads one round trip — and it is why no sentinel is needed for a legitimately `undefined`
|
|
26
28
|
* value, which a value-keyed memo cannot tell apart from a miss. A promise is never `undefined`.
|
|
29
|
+
* An evicted entry is one more read of the source, never a wrong answer.
|
|
27
30
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
export function requestMemo(ctx: Ctx): Map<string, Promise<unknown>> {
|
|
31
|
-
const key: object = ctx;
|
|
32
|
-
const existing = memos.get(key);
|
|
33
|
-
if (existing !== undefined) return existing;
|
|
34
|
-
const created = new Map<string, Promise<unknown>>();
|
|
35
|
-
memos.set(key, created);
|
|
36
|
-
return created;
|
|
31
|
+
export function requestMemo(ctx: Ctx): MemoFlights {
|
|
32
|
+
return memoFlights(ctx, 'query');
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
/**
|
|
@@ -153,28 +149,21 @@ export async function readOnce<T>(ctx: Ctx, key: string, run: () => Promise<T>):
|
|
|
153
149
|
* the same request the answer this one just proved stale — so the guarantee would end at the one
|
|
154
150
|
* call that asked for it.
|
|
155
151
|
*/
|
|
156
|
-
export function readFresh<T>(ctx: Ctx, key: string, run: () => Promise<T>): Promise<T> {
|
|
157
|
-
|
|
152
|
+
export async function readFresh<T>(ctx: Ctx, key: string, run: () => Promise<T>): Promise<T> {
|
|
153
|
+
// `async`, so a `run` that throws synchronously rejects like every other failed read.
|
|
154
|
+
return await publish(requestMemo(ctx), key, run);
|
|
158
155
|
}
|
|
159
156
|
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
): Promise<T> {
|
|
157
|
+
/**
|
|
158
|
+
* The read in flight: published before its first await. A rejection is not an answer, so the store
|
|
159
|
+
* drops it and a later read in the same request retries — only ours: a fresh read may have
|
|
160
|
+
* replaced this entry already, and evicting that one would discard a live answer (`holdValue`).
|
|
161
|
+
*/
|
|
162
|
+
function publish<T>(memo: MemoFlights, key: string, run: () => Promise<T>): Promise<T> {
|
|
166
163
|
// Published before the first await, so a reader arriving in the same tick finds this read.
|
|
167
164
|
const flight = run();
|
|
168
|
-
memo.
|
|
169
|
-
|
|
170
|
-
return await flight;
|
|
171
|
-
} catch (error) {
|
|
172
|
-
// A rejection is not an answer. Drop it so a later read in the same request retries
|
|
173
|
-
// instead of replaying one failure until the request ends. Only ours: a fresh read may have
|
|
174
|
-
// replaced this entry already, and evicting that one would discard a live answer.
|
|
175
|
-
if (memo.get(key) === flight) memo.delete(key);
|
|
176
|
-
throw error;
|
|
177
|
-
}
|
|
165
|
+
memo.publish(key, flight);
|
|
166
|
+
return flight;
|
|
178
167
|
}
|
|
179
168
|
|
|
180
169
|
/**
|