@abide/abide 0.47.0 → 0.48.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/AGENTS.md +11 -6
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
- package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
- package/src/lib/shared/cache.ts +66 -28
- package/src/lib/shared/cacheStores.ts +3 -3
- package/src/lib/shared/refresh.ts +3 -3
- package/src/lib/shared/sharedCacheStore.ts +14 -0
- package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
- package/src/lib/shared/types/CacheOptions.ts +15 -11
- package/src/lib/shared/types/CacheStats.ts +1 -1
- package/src/lib/shared/types/SmartReadOptions.ts +15 -11
- package/src/lib/test/createTestApp.ts +6 -6
- package/src/lib/ui/startClient.ts +3 -3
- package/src/serverEntry.ts +11 -9
- package/src/lib/shared/globalCacheStore.ts +0 -15
- package/src/lib/shared/globalCacheStoreSlot.ts +0 -12
package/AGENTS.md
CHANGED
|
@@ -92,12 +92,17 @@ validate as args, `File` parts validate against `filesSchema`.
|
|
|
92
92
|
|
|
93
93
|
**Consuming an rpc** — the bare call `fn(args, opts?)` IS the smart read:
|
|
94
94
|
cached, coalesced, reactive, stale-while-revalidate for replayable (GET/HEAD)
|
|
95
|
-
reads; the second arg takes `{ ttl, tags, throttle, debounce,
|
|
96
|
-
(retention and the refetch clock — not transport). `
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
reads; the second arg takes `{ ttl, tags, throttle, debounce, shared, n }`
|
|
96
|
+
(retention and the refetch clock — not transport). `ttl` defaults to `0` on
|
|
97
|
+
the server (coalesce-only — the request is the atomic unit, nothing is
|
|
98
|
+
retained past it) and `Infinity` on the client (retain until invalidate/
|
|
99
|
+
refresh). `shared: true` selects the process-level store instead of the
|
|
100
|
+
request-scoped default (server) — it does not by itself retain, pair it with
|
|
101
|
+
an explicit `ttl` (e.g. `{ shared: true, ttl: Infinity }`) to memoise across
|
|
102
|
+
requests, for an external endpoint, never per-user data; on the client it is
|
|
103
|
+
a no-op (one tab store). A read with no request in flight (e.g. a background
|
|
104
|
+
job) also resolves against the process-level store. During SSR the same call
|
|
105
|
+
resolves
|
|
101
106
|
in-process and its value is baked into the HTML so hydration starts warm —
|
|
102
107
|
there is no `cache()` wrapper; the bare call carries the caching. Around it:
|
|
103
108
|
`fn.raw(args, init?)` returns the raw `Response` (per-call transport options —
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.48.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 182cdf5: server reads coalesce-only by default, SWR retain client-only ([`98a3e84`](https://github.com/briancray/abide/commit/98a3e84b1e6bdb50b8950a4197ec4a255c560d2a))
|
|
8
|
+
- 182cdf5: warn on a request-scoped ttl>0 read (add shared or drop ttl) ([`b55425c`](https://github.com/briancray/abide/commit/b55425c986fb4c7cebcb1e8c29d1c616466d7d63))
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- 182cdf5: rename global option/exports/comments to shared ([`1256934`](https://github.com/briancray/abide/commit/125693481ba23152a44375bbe200d575b66fdf9a))
|
|
13
|
+
- 182cdf5: evict outside-request coalesce-only entries; correct shared help copy ([`1b73e21`](https://github.com/briancray/abide/commit/1b73e21c33c8ee985f7109fdedcbe302322ce7d1))
|
|
14
|
+
- 182cdf5: route outside-a-request server reads to the shared store ([`456cfa6`](https://github.com/briancray/abide/commit/456cfa662405714df99545e02ed6e3b851da84df))
|
|
15
|
+
- 182cdf5: Breaking: the `global` cache option is renamed `shared`, and it no longer implies retention. `{ global: true }` (memoise across requests forever) becomes `{ shared: true, ttl: Infinity }` — `shared` now only selects the process store, while `ttl` alone controls retention (default `0` on the server = coalesce-only, `Infinity` on the client). Reads made with no request in flight resolve to the shared store instead of an orphan fallback. ([`d94f0f9`](https://github.com/briancray/abide/commit/d94f0f9cb15a5822b2d5824db7e32da27368fe04))
|
|
16
|
+
|
|
3
17
|
## 0.47.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sharedCacheStore } from '../../shared/sharedCacheStore.ts'
|
|
2
2
|
import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
|
|
3
3
|
import type { InspectorCacheEntry } from './types/InspectorCacheEntry.ts'
|
|
4
4
|
import type { InspectorCacheSnapshot } from './types/InspectorCacheSnapshot.ts'
|
|
@@ -48,14 +48,14 @@ function projectEntry(entry: CacheEntry, now: number): InspectorCacheEntry {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/*
|
|
51
|
-
Snapshots the process-level cache store (the persistent one `cache(fn, {
|
|
52
|
-
true })` writes to) for the inspector. Read at call time, so it
|
|
53
|
-
store as it stands; request-scoped stores are deliberately
|
|
54
|
-
ephemeral and already visible as per-request cache tallies.
|
|
51
|
+
Snapshots the process-level cache store (the persistent one `cache(fn, { shared:
|
|
52
|
+
true, ttl: Infinity })` writes to) for the inspector. Read at call time, so it
|
|
53
|
+
reflects the store as it stands; request-scoped stores are deliberately
|
|
54
|
+
excluded — they're ephemeral and already visible as per-request cache tallies.
|
|
55
55
|
*/
|
|
56
56
|
export function buildCacheSnapshot(): InspectorCacheSnapshot {
|
|
57
57
|
const now = Date.now()
|
|
58
|
-
const entries = Array.from(
|
|
58
|
+
const entries = Array.from(sharedCacheStore().entries.values(), (entry) =>
|
|
59
59
|
projectEntry(entry, now),
|
|
60
60
|
)
|
|
61
61
|
return { entries }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
One
|
|
2
|
+
One shared cache entry projected for the inspector — the serializable facts the
|
|
3
3
|
Cache tab renders. The stored promise/Request/timer aren't included; what an
|
|
4
4
|
operator wants is the entry's identity, lifecycle state, retention, and a peek
|
|
5
5
|
at the held value.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { InspectorCacheEntry } from './InspectorCacheEntry.ts'
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
|
-
The inspector's view of the cache: the process-level (`
|
|
5
|
-
current entries. That's the persistent store —
|
|
6
|
-
die with their request, so they surface as
|
|
7
|
-
traces rather than here.
|
|
4
|
+
The inspector's view of the cache: the process-level (`shared: true, ttl:
|
|
5
|
+
Infinity`) store's current entries. That's the persistent store —
|
|
6
|
+
request-scoped caches live and die with their request, so they surface as
|
|
7
|
+
per-request tallies in the feed and traces rather than here.
|
|
8
8
|
*/
|
|
9
9
|
export type InspectorCacheSnapshot = {
|
|
10
10
|
entries: InspectorCacheEntry[]
|
|
@@ -17,7 +17,7 @@ export type InspectorContext = {
|
|
|
17
17
|
app: { name: string; version: string }
|
|
18
18
|
/* Eager-loads the registries, then projects the current RPC + socket catalog. */
|
|
19
19
|
loadSurface: () => Promise<InspectorSurface>
|
|
20
|
-
/* Snapshots the persistent (
|
|
20
|
+
/* Snapshots the persistent (shared) cache store — current entries with their
|
|
21
21
|
lifecycle state, retention, tags, and a value preview. */
|
|
22
22
|
cacheSnapshot: () => InspectorCacheSnapshot
|
|
23
23
|
/* Snapshots the requests whose handler is executing right now — the live
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { activeCacheStore } from './activeCacheStore.ts'
|
|
|
3
3
|
import { cacheStores } from './cacheStores.ts'
|
|
4
4
|
import { decodeResponse } from './decodeResponse.ts'
|
|
5
5
|
import { getRemoteMeta } from './getRemoteMeta.ts'
|
|
6
|
-
import { globalCacheStore } from './globalCacheStore.ts'
|
|
7
6
|
import { HttpError } from './HttpError.ts'
|
|
8
7
|
import { hydratingSlot } from './hydratingSlot.ts'
|
|
9
8
|
import { invalidateEvent } from './invalidateEvent.ts'
|
|
@@ -16,6 +15,7 @@ import { rpcErrorRegistry } from './rpcErrorRegistry.ts'
|
|
|
16
15
|
import { SocketDisconnectedError } from './SocketDisconnectedError.ts'
|
|
17
16
|
import { selectorMatcher } from './selectorMatcher.ts'
|
|
18
17
|
import { selectorPrefix } from './selectorPrefix.ts'
|
|
18
|
+
import { sharedCacheStore } from './sharedCacheStore.ts'
|
|
19
19
|
import { openStreamProbe } from './subscribableProbes.ts'
|
|
20
20
|
import { toTagSet } from './toTagSet.ts'
|
|
21
21
|
import type { CacheEntry } from './types/CacheEntry.ts'
|
|
@@ -36,7 +36,7 @@ const cacheLog = abideLog.channel('abide:cache')
|
|
|
36
36
|
|
|
37
37
|
/*
|
|
38
38
|
Tallies one read and narrates it on the diagnostics channel. The sink is the
|
|
39
|
-
request/tab store even when the data store is the process-level
|
|
39
|
+
request/tab store even when the data store is the process-level shared one —
|
|
40
40
|
attribution follows the asker, so a request's closing record reflects every
|
|
41
41
|
read it made. A settled retained entry (including the warm SSR sync path) is
|
|
42
42
|
a hit; an unsettled entry is a coalesced join of an in-flight call; no entry
|
|
@@ -62,8 +62,10 @@ Reads a call through a cache store. `cache(fn, args?, options?)` checks the stor
|
|
|
62
62
|
for a prior entry and returns a shared promise on hit, or invokes `fn` once and
|
|
63
63
|
stores its promise on miss — a direct read-through call, not a curried invoker.
|
|
64
64
|
Args lead (the common refinement); options trail in a fixed final position so
|
|
65
|
-
they can't collide with arg shapes.
|
|
66
|
-
|
|
65
|
+
they can't collide with arg shapes. ttl = 0 → dedupe only; ttl > 0 → entry
|
|
66
|
+
expires `ttl` ms after the promise resolves. Omitted ttl → forever for a
|
|
67
|
+
producer and for a remote call on the client; a remote call on the server with
|
|
68
|
+
neither ttl nor swr stated defaults to 0 (coalesce-only, see CacheOptions).
|
|
67
69
|
|
|
68
70
|
Coalescing is always on: identical in-flight calls share one flight, so
|
|
69
71
|
`cache(createPost, args, { ttl: 0 })` is the mutation idiom — double-submit
|
|
@@ -87,7 +89,7 @@ new reference every call and never does; a warning fires once per such call
|
|
|
87
89
|
site), and the promise is stored and handed back as-is (no Response, no decode,
|
|
88
90
|
no SSR snapshot).
|
|
89
91
|
|
|
90
|
-
`options.
|
|
92
|
+
`options.shared` puts the entry in the process-level store instead of the
|
|
91
93
|
request-scoped one, so a value computed in one request is reused by later
|
|
92
94
|
requests — the memoise-an-external-endpoint case. Default (omitted) is
|
|
93
95
|
request-scoped on the server, which keeps per-user data from leaking across
|
|
@@ -172,9 +174,9 @@ cache.read = smartRead
|
|
|
172
174
|
|
|
173
175
|
/*
|
|
174
176
|
The shared read-through core. `smart` marks the smart bare call, which enables
|
|
175
|
-
unconditional SWR retention for replayable
|
|
176
|
-
|
|
177
|
-
drop-on-invalidate old surface.
|
|
177
|
+
unconditional SWR retention for a replayable read — but only on the client (see
|
|
178
|
+
smartRead / entry.retain and the `retain` computation below); the public cache()
|
|
179
|
+
passes false, keeping its explicit drop-on-ttl / drop-on-invalidate old surface.
|
|
178
180
|
*/
|
|
179
181
|
function readThrough<Args, Return>(
|
|
180
182
|
fn: AnyRemote<Args, Return> | Producer<Args, Return>,
|
|
@@ -200,30 +202,45 @@ function readThrough<Args, Return>(
|
|
|
200
202
|
const method = isRemote ? (rawFn as RawRemoteFunction<Args>).method : undefined
|
|
201
203
|
const replayable = method !== undefined && REPLAYABLE_METHODS.has(method.toUpperCase())
|
|
202
204
|
validatePolicy(options, method, smart)
|
|
203
|
-
/*
|
|
204
|
-
|
|
205
|
+
/* SWR retention is a client concern (the tab store lives; revalidation is
|
|
206
|
+
visible). On the server there is no live UI to hold stale, so a smart read
|
|
207
|
+
never retains — it coalesces only. */
|
|
208
|
+
const retain = smart && replayable && options?.swr !== false && typeof window !== 'undefined'
|
|
205
209
|
/*
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
+
ttl defaults to 0 (coalesce-only) on the server for any remote read/write, and
|
|
211
|
+
on the client for a smart write — in both cases only when the caller stated
|
|
212
|
+
neither ttl nor swr. The server default makes retention opt-in (via an explicit
|
|
213
|
+
ttl, paired with `shared` to survive the request). An explicit swr opts out so
|
|
214
|
+
it does not trip validatePolicy's "swr + ttl:0" throw; server-side swr is a
|
|
215
|
+
client-only concept with no added server guarantee.
|
|
210
216
|
*/
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
const serverTtlZero =
|
|
218
|
+
isRemote &&
|
|
219
|
+
typeof window === 'undefined' &&
|
|
220
|
+
options?.ttl === undefined &&
|
|
221
|
+
options?.swr === undefined
|
|
222
|
+
const smartWriteTtlZero = smart && isRemote && !replayable && options?.ttl === undefined
|
|
223
|
+
const effectiveOptions = serverTtlZero || smartWriteTtlZero ? { ...options, ttl: 0 } : options
|
|
215
224
|
if (!isRemote) {
|
|
216
225
|
warnAnonymousProducer(fn as Producer<Args, Return>)
|
|
217
226
|
}
|
|
218
|
-
const store = effectiveOptions?.
|
|
227
|
+
const store = effectiveOptions?.shared ? sharedCacheStore() : activeCacheStore()
|
|
219
228
|
if (!isRemote) {
|
|
220
229
|
return invokeProducer(store, fn as Producer<Args, Return>, args, effectiveOptions)
|
|
221
230
|
}
|
|
222
231
|
const remote = rawFn as RawRemoteFunction<Args>
|
|
223
232
|
const key = keyForRemoteCall(remote.method, remote.url, args)
|
|
233
|
+
if (
|
|
234
|
+
!effectiveOptions?.shared &&
|
|
235
|
+
effectiveOptions?.ttl !== undefined &&
|
|
236
|
+
effectiveOptions.ttl > 0 &&
|
|
237
|
+
store !== sharedCacheStore()
|
|
238
|
+
) {
|
|
239
|
+
warnEphemeralTtl(key)
|
|
240
|
+
}
|
|
224
241
|
store.subscribe(key)
|
|
225
242
|
const existing = store.entries.get(key)
|
|
226
|
-
recordRead(effectiveOptions?.
|
|
243
|
+
recordRead(effectiveOptions?.shared ? activeCacheStore() : store, key, existing)
|
|
227
244
|
if (existing) {
|
|
228
245
|
tagEntry(existing, effectiveOptions?.tags)
|
|
229
246
|
attachPolicy(existing, effectiveOptions, () => remote(args as Args), retain)
|
|
@@ -390,6 +407,20 @@ function warnAnonymousProducer(producer: (args?: never) => unknown): void {
|
|
|
390
407
|
)
|
|
391
408
|
}
|
|
392
409
|
|
|
410
|
+
/* Warn once per key when a ttl>0 read lands in an ephemeral (request-scoped)
|
|
411
|
+
store — the ttl expires with the request. `shared` (the process store) or a
|
|
412
|
+
client tab store are the retention homes; a request-scoped ttl is dead config. */
|
|
413
|
+
const warnedEphemeralTtl = new Set<string>()
|
|
414
|
+
function warnEphemeralTtl(key: string): void {
|
|
415
|
+
if (warnedEphemeralTtl.has(key)) {
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
warnedEphemeralTtl.add(key)
|
|
419
|
+
abideLog.warn(
|
|
420
|
+
`cache(): a request-scoped ttl expires with the request — add \`shared\` to cache "${key}" across requests, or drop the ttl.`,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
393
424
|
/*
|
|
394
425
|
Producer path: key on the producer's reference + args, share the
|
|
395
426
|
in-flight/retained promise on hit, and store the value promise as-is on miss — no
|
|
@@ -404,7 +435,7 @@ function invokeProducer<Args, Return>(
|
|
|
404
435
|
const key = producerKey(producer, args)
|
|
405
436
|
store.subscribe(key)
|
|
406
437
|
const existing = store.entries.get(key)
|
|
407
|
-
recordRead(options?.
|
|
438
|
+
recordRead(options?.shared ? activeCacheStore() : store, key, existing)
|
|
408
439
|
if (existing) {
|
|
409
440
|
tagEntry(existing, options?.tags)
|
|
410
441
|
attachPolicy(existing, options, () => producer(args), false)
|
|
@@ -502,11 +533,18 @@ function registerEntry(
|
|
|
502
533
|
own method filter; writes never ship). The keep never applies on the
|
|
503
534
|
client (the tab store outlives any unit — a kept write would block every
|
|
504
535
|
future re-submit, so entries evict the moment they settle), to producer
|
|
505
|
-
entries (no request), or
|
|
506
|
-
|
|
536
|
+
entries (no request), or when the resolved store is the process-level
|
|
537
|
+
`shared` store — including a non-shared read made outside an inbound
|
|
538
|
+
request, where the resolver falls back to `sharedCacheStore()` (Decision
|
|
539
|
+
1): that store is never request-scoped, so keeping it would leak forever.
|
|
540
|
+
The `store !== sharedCacheStore()` check is what tells a genuinely
|
|
541
|
+
request-scoped store apart from that fallback.
|
|
507
542
|
*/
|
|
508
543
|
const keepZeroTtlForRequest =
|
|
509
|
-
request !== undefined &&
|
|
544
|
+
request !== undefined &&
|
|
545
|
+
!options?.shared &&
|
|
546
|
+
typeof window === 'undefined' &&
|
|
547
|
+
store !== sharedCacheStore()
|
|
510
548
|
function deleteIfCurrent() {
|
|
511
549
|
evictIfCurrent(store, entry)
|
|
512
550
|
}
|
|
@@ -890,10 +928,10 @@ function peek<Args, Return>(
|
|
|
890
928
|
active.trackLifecycle(key)
|
|
891
929
|
let entry = active.entries.get(key)
|
|
892
930
|
if (entry === undefined) {
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
entry =
|
|
931
|
+
const shared = sharedCacheStore()
|
|
932
|
+
shared.subscribe(key)
|
|
933
|
+
shared.trackLifecycle(key)
|
|
934
|
+
entry = shared.entries.get(key)
|
|
897
935
|
}
|
|
898
936
|
if (entry === undefined || entry.settled !== true || entry.value === undefined) {
|
|
899
937
|
return undefined
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { activeCacheStore } from './activeCacheStore.ts'
|
|
2
|
-
import {
|
|
2
|
+
import { sharedCacheStore } from './sharedCacheStore.ts'
|
|
3
3
|
import type { CacheStore } from './types/CacheStore.ts'
|
|
4
4
|
|
|
5
5
|
/* Active + process-level stores, deduped (one tab store on the client). */
|
|
6
6
|
export function cacheStores(): CacheStore[] {
|
|
7
7
|
const active = activeCacheStore()
|
|
8
|
-
const
|
|
9
|
-
return active ===
|
|
8
|
+
const shared = sharedCacheStore()
|
|
9
|
+
return active === shared ? [active] : [active, shared]
|
|
10
10
|
}
|
|
@@ -4,9 +4,9 @@ import type { CacheSelector } from './types/CacheSelector.ts'
|
|
|
4
4
|
/*
|
|
5
5
|
Refetch every cached read matching the selector, keeping the stale value visible
|
|
6
6
|
until the fresh one swaps in (refreshing() true meanwhile) — the smart-call
|
|
7
|
-
refetch. Because the smart read retains its value
|
|
8
|
-
always refetches-and-swaps; it never drops to a
|
|
9
|
-
selector grammar:
|
|
7
|
+
refetch. Because the smart read retains its value on the client (SWR
|
|
8
|
+
unconditional there), a refresh always refetches-and-swaps; it never drops to a
|
|
9
|
+
pending blank. Follows the shared selector grammar:
|
|
10
10
|
|
|
11
11
|
refresh(getFoo, args) → that exact call
|
|
12
12
|
refresh(getFoo) → every args-variant of that rpc
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { activeCacheStore } from './activeCacheStore.ts'
|
|
2
|
+
import { sharedCacheStoreSlot } from './sharedCacheStoreSlot.ts'
|
|
3
|
+
import type { CacheStore } from './types/CacheStore.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Resolves the process-level ("shared") CacheStore that `cache(fn, { shared: true })`
|
|
7
|
+
entries live in. The server entry registers a module-singleton resolver so the
|
|
8
|
+
store survives across requests; the client points it at the active tab store.
|
|
9
|
+
When no resolver is registered it falls back to the active store, so `shared`
|
|
10
|
+
degrades to request/tab-scoped rather than throwing.
|
|
11
|
+
*/
|
|
12
|
+
export function sharedCacheStore(): CacheStore {
|
|
13
|
+
return sharedCacheStoreSlot.resolver?.() ?? activeCacheStore()
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createResolverSlot } from './createResolverSlot.ts'
|
|
2
|
+
import type { CacheStore } from './types/CacheStore.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The process-level ("shared") CacheStore slot that `cache(fn, { shared: true })`
|
|
6
|
+
entries live in. The server entry registers a module-singleton store outliving
|
|
7
|
+
every request; the client points it at the active tab store. When no resolver
|
|
8
|
+
is registered, sharedCacheStore() falls back to the active store.
|
|
9
|
+
*/
|
|
10
|
+
export const sharedCacheStoreSlot = createResolverSlot<CacheStore>()
|
|
@@ -2,21 +2,25 @@
|
|
|
2
2
|
Options for cache(). The key is always auto-derived (method+url+args for a remote
|
|
3
3
|
function, producer-reference+args for a plain producer): hoist a producer to a
|
|
4
4
|
stable reference to share its entry across calls. `ttl` is the
|
|
5
|
-
milliseconds-past-resolve that the entry stays live:
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
milliseconds-past-resolve that the entry stays live: 0 = dedupe only (entry
|
|
6
|
+
dropped once the promise settles — the mutation idiom: in-flight coalescing and
|
|
7
|
+
pending() visibility, nothing retained), any other number = TTL. Omitted = forever
|
|
8
|
+
for a producer and for a remote call on the client; a remote call on the SERVER
|
|
9
|
+
with neither `ttl` nor `swr` stated defaults to 0 (coalesce-only — the request is
|
|
10
|
+
the atomic unit, nothing is retained past it; pair with `shared` + an explicit
|
|
11
|
+
`ttl` to memoise across requests).
|
|
9
12
|
`tags` is an array of free-form labels grouping unrelated calls so one
|
|
10
13
|
`cache.invalidate({ tags })` drops every entry sharing any of them — list
|
|
11
14
|
multiple when a call belongs to multiple invalidation groups. A unique tag (e.g.
|
|
12
15
|
a uuid) shared by a set of calls gives them their own private invalidation group.
|
|
13
16
|
|
|
14
|
-
`
|
|
15
|
-
request-scoped one (server) —
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
`shared` opts the entry into the process-level store instead of the default
|
|
18
|
+
request-scoped one (server) — a store that outlives every request. It selects
|
|
19
|
+
the store only; it does NOT retain (pair it with `ttl` to memoise across
|
|
20
|
+
requests). The shared store is keyed by method+url+args, never by user, so do
|
|
21
|
+
not put per-user data in it — it would be served to other users. Omit `shared`
|
|
22
|
+
for per-request data. Write only `shared: true`; there is no `false` form. On
|
|
23
|
+
the client there is a single tab store, so the flag is a no-op there.
|
|
20
24
|
|
|
21
25
|
`swr` is stale-while-revalidate: it changes what a `cache.invalidate` hit does
|
|
22
26
|
to this key. Without it, an invalidate drops the entry and the next read shows
|
|
@@ -44,7 +48,7 @@ with the same wrap-time guard as the `swr` window: set one, not both.
|
|
|
44
48
|
export type CacheOptions = {
|
|
45
49
|
ttl?: number
|
|
46
50
|
tags?: string[]
|
|
47
|
-
|
|
51
|
+
shared?: boolean
|
|
48
52
|
swr?: boolean | { throttle?: number; debounce?: number }
|
|
49
53
|
throttle?: number
|
|
50
54
|
debounce?: number
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Per-store cache read tallies, frozen into the request's closing log record at
|
|
3
3
|
settle. hit = a read served from a settled retained entry (including the warm
|
|
4
4
|
SSR sync path); coalesced = a read that joined an in-flight call; miss = a
|
|
5
|
-
read that invoked its producer/remote. Reads against the process-level
|
|
5
|
+
read that invoked its producer/remote. Reads against the process-level shared
|
|
6
6
|
store count into the requesting scope's store, so a request's record reflects
|
|
7
7
|
everything that request asked the cache for.
|
|
8
8
|
*/
|
|
@@ -5,19 +5,23 @@ these govern retention and the refetch clock — NOT transport. Per-call transpo
|
|
|
5
5
|
options (signal/keepalive/priority/cache/headers) live on `.raw(args, init)`.
|
|
6
6
|
|
|
7
7
|
Fetch reads:
|
|
8
|
-
- `ttl` — retention/staleness in ms:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
- `ttl` — retention/staleness in ms. Default: 0 on the server (coalesce-only —
|
|
9
|
+
the request is the atomic unit, nothing is retained past it), Infinity on the
|
|
10
|
+
client (retain until invalidate/refresh — the tab is the atomic unit). On the
|
|
11
|
+
client, N ms marks a staleness deadline: the retained value goes stale after N
|
|
12
|
+
ms and the next access triggers a background revalidation (stale stays visible,
|
|
13
|
+
`refreshing()` true); the display value is never dropped. On the server, N ms
|
|
14
|
+
is a plain expiry and only takes effect in the shared store (pair with `shared`).
|
|
12
15
|
- `tags` — free-form invalidation-group labels (see the selector grammar).
|
|
13
16
|
- `throttle` / `debounce` — rate-limit the background refetch clock (set one, not
|
|
14
17
|
both). Leading-edge-then-coalesce, or fire-after-quiet respectively.
|
|
15
|
-
- `
|
|
16
|
-
request-scoped one (server)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
- `shared` — opts the entry into the process-level store instead of the default
|
|
19
|
+
request-scoped one (server) — a store that outlives every request. It selects
|
|
20
|
+
the store only; it does NOT retain (pair it with `ttl` to memoise across
|
|
21
|
+
requests). The shared store is keyed by method+url+args, never by user, so do
|
|
22
|
+
not put per-user data in it — it would be served to other users. Omit `shared`
|
|
23
|
+
for per-request data. Write only `shared: true`; there is no `false` form. On
|
|
24
|
+
the client there is a single tab store, so the flag is a no-op there.
|
|
21
25
|
|
|
22
26
|
Streaming reads (jsonl/sse):
|
|
23
27
|
- `n` — how many retained frames to replay before going live.
|
|
@@ -27,6 +31,6 @@ export type SmartReadOptions = {
|
|
|
27
31
|
tags?: string[]
|
|
28
32
|
throttle?: number
|
|
29
33
|
debounce?: number
|
|
30
|
-
|
|
34
|
+
shared?: boolean
|
|
31
35
|
n?: number
|
|
32
36
|
}
|
|
@@ -37,10 +37,10 @@ import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
|
|
|
37
37
|
import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
|
|
38
38
|
import { commandNameForUrl } from '../shared/commandNameForUrl.ts'
|
|
39
39
|
import { createCacheStore } from '../shared/createCacheStore.ts'
|
|
40
|
-
import { globalCacheStoreSlot } from '../shared/globalCacheStoreSlot.ts'
|
|
41
40
|
import { HEALTH_PATH } from '../shared/HEALTH_PATH.ts'
|
|
42
41
|
import { pageSlot } from '../shared/pageSlot.ts'
|
|
43
42
|
import { SOCKETS_PATH } from '../shared/SOCKETS_PATH.ts'
|
|
43
|
+
import { sharedCacheStoreSlot } from '../shared/sharedCacheStoreSlot.ts'
|
|
44
44
|
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
45
45
|
import type { Socket } from '../shared/types/Socket.ts'
|
|
46
46
|
import { createTestSocketChannel } from './createTestSocketChannel.ts'
|
|
@@ -98,15 +98,15 @@ leaking the request-scope/cache/page resolvers into the next test file.
|
|
|
98
98
|
export async function createTestApp(): Promise<TestApp> {
|
|
99
99
|
const previous = {
|
|
100
100
|
cacheResolver: cacheStoreSlot.resolver,
|
|
101
|
-
|
|
101
|
+
sharedResolver: sharedCacheStoreSlot.resolver,
|
|
102
102
|
pageResolver: pageSlot.resolver,
|
|
103
103
|
baseResolver: baseSlot.resolver,
|
|
104
104
|
activeServer: serverSlot.active,
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
const sharedStore = createCacheStore()
|
|
108
|
+
sharedCacheStoreSlot.resolver = () => sharedStore
|
|
109
|
+
cacheStoreSlot.resolver = () => requestContext.getStore()?.cache ?? sharedStore
|
|
110
110
|
pageSlot.resolver = resolvePageSnapshot
|
|
111
111
|
|
|
112
112
|
/* Eager env validation, exactly as serverEntry: a top-level env(schema) in
|
|
@@ -185,7 +185,7 @@ export async function createTestApp(): Promise<TestApp> {
|
|
|
185
185
|
channel?.close()
|
|
186
186
|
server.stop(true)
|
|
187
187
|
cacheStoreSlot.resolver = previous.cacheResolver
|
|
188
|
-
|
|
188
|
+
sharedCacheStoreSlot.resolver = previous.sharedResolver
|
|
189
189
|
pageSlot.resolver = previous.pageResolver
|
|
190
190
|
baseSlot.resolver = previous.baseResolver
|
|
191
191
|
serverSlot.active = previous.activeServer
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
|
|
2
2
|
import { createCacheStore } from '../shared/createCacheStore.ts'
|
|
3
|
-
import { globalCacheStoreSlot } from '../shared/globalCacheStoreSlot.ts'
|
|
4
3
|
import { pageSlot } from '../shared/pageSlot.ts'
|
|
4
|
+
import { sharedCacheStoreSlot } from '../shared/sharedCacheStoreSlot.ts'
|
|
5
5
|
import type { SsrPayload } from '../shared/types/SsrPayload.ts'
|
|
6
6
|
import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
|
|
7
7
|
import { probeNavigation } from './probeNavigation.ts'
|
|
@@ -68,8 +68,8 @@ export function startClient(
|
|
|
68
68
|
|
|
69
69
|
const store = createCacheStore()
|
|
70
70
|
cacheStoreSlot.resolver = () => store
|
|
71
|
-
/* One tab store: cache(fn, {
|
|
72
|
-
|
|
71
|
+
/* One tab store: cache(fn, { shared: true }) shares it, so shared is a no-op here. */
|
|
72
|
+
sharedCacheStoreSlot.resolver = () => store
|
|
73
73
|
/* Seed both SSR cache partitions through the one streamed-resolution sink: `ssr.cache`
|
|
74
74
|
(inline — reads settled at render-return, in __SSR__) and `__abideResumeCache` (pending
|
|
75
75
|
{#await} reads whose `__abideResolve(...)` chunks the stream pushed during parse, before
|
package/src/serverEntry.ts
CHANGED
|
@@ -31,10 +31,10 @@ import { requestContext } from './lib/server/runtime/requestContext.ts'
|
|
|
31
31
|
import { resolvePageSnapshot } from './lib/server/runtime/resolvePageSnapshot.ts'
|
|
32
32
|
import { cacheStoreSlot } from './lib/shared/cacheStoreSlot.ts'
|
|
33
33
|
import { createCacheStore } from './lib/shared/createCacheStore.ts'
|
|
34
|
-
import { globalCacheStoreSlot } from './lib/shared/globalCacheStoreSlot.ts'
|
|
35
34
|
import { loadEnvFromDataDir } from './lib/shared/loadEnvFromDataDir.ts'
|
|
36
35
|
import { pageSlot } from './lib/shared/pageSlot.ts'
|
|
37
36
|
import { runningAsStandaloneBinary } from './lib/shared/runningAsStandaloneBinary.ts'
|
|
37
|
+
import { sharedCacheStoreSlot } from './lib/shared/sharedCacheStoreSlot.ts'
|
|
38
38
|
|
|
39
39
|
/*
|
|
40
40
|
Resolve config into process.env before anything reads it (createServer reads
|
|
@@ -65,16 +65,18 @@ await import('./_virtual/config.ts')
|
|
|
65
65
|
// In a bundle, tie this server's life to the launcher's (no-op standalone).
|
|
66
66
|
exitWithParent()
|
|
67
67
|
|
|
68
|
-
cacheStoreSlot.resolver = () => requestContext.getStore()?.cache
|
|
69
|
-
|
|
70
|
-
pageSlot.resolver = resolvePageSnapshot
|
|
71
|
-
|
|
72
68
|
/*
|
|
73
|
-
Process-level store for cache(fn, {
|
|
74
|
-
outlives every request so memoised external calls are shared across them.
|
|
69
|
+
Process-level ("shared") store for cache(fn, { shared: true }) — one per server
|
|
70
|
+
process, outlives every request so memoised external calls are shared across them.
|
|
71
|
+
It is also the store a read with no request in flight resolves to, so boot/cron/
|
|
72
|
+
socket-handler reads coalesce into a real store instead of an orphan fallback.
|
|
75
73
|
*/
|
|
76
|
-
const
|
|
77
|
-
|
|
74
|
+
const sharedCacheStore = createCacheStore()
|
|
75
|
+
sharedCacheStoreSlot.resolver = () => sharedCacheStore
|
|
76
|
+
|
|
77
|
+
cacheStoreSlot.resolver = () => requestContext.getStore()?.cache ?? sharedCacheStore
|
|
78
|
+
|
|
79
|
+
pageSlot.resolver = resolvePageSnapshot
|
|
78
80
|
|
|
79
81
|
await createServer({
|
|
80
82
|
pages,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { activeCacheStore } from './activeCacheStore.ts'
|
|
2
|
-
import { globalCacheStoreSlot } from './globalCacheStoreSlot.ts'
|
|
3
|
-
import type { CacheStore } from './types/CacheStore.ts'
|
|
4
|
-
|
|
5
|
-
/*
|
|
6
|
-
Resolves the process-level CacheStore that `cache(fn, { global: true })` entries
|
|
7
|
-
live in. The server entry registers a module-singleton resolver so the store
|
|
8
|
-
survives across requests; the client points it at the active tab store. When no
|
|
9
|
-
resolver is registered (isolated tests, or a client that never set one) it falls
|
|
10
|
-
back to the active store, so `global` degrades to request/tab-scoped rather than
|
|
11
|
-
throwing.
|
|
12
|
-
*/
|
|
13
|
-
export function globalCacheStore(): CacheStore {
|
|
14
|
-
return globalCacheStoreSlot.resolver?.() ?? activeCacheStore()
|
|
15
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { createResolverSlot } from './createResolverSlot.ts'
|
|
2
|
-
import type { CacheStore } from './types/CacheStore.ts'
|
|
3
|
-
|
|
4
|
-
/*
|
|
5
|
-
Slot for the process-level cache store used by cache() entries opting into
|
|
6
|
-
`global: true`. The server entry registers a module-singleton store outliving
|
|
7
|
-
any one request; the client entry points it at its single tab store so
|
|
8
|
-
`global` is a no-op there. No fallback creator — unset means no global store
|
|
9
|
-
is registered, in which case globalCacheStore() falls back to the active
|
|
10
|
-
(request/tab) store. Test helpers snapshot/poke `.resolver` directly.
|
|
11
|
-
*/
|
|
12
|
-
export const globalCacheStoreSlot = createResolverSlot<CacheStore>()
|