@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 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, global, n }`
96
- (retention and the refetch clock — not transport). `global: true` stores the
97
- entry in the process-level store instead of the request-scoped default
98
- (server), so later requests reuse the value for memoising an external
99
- endpoint, never per-user data; on the client it is a no-op (one tab store).
100
- During SSR the same call resolves
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -1,4 +1,4 @@
1
- import { globalCacheStore } from '../../shared/globalCacheStore.ts'
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, { global:
52
- true })` writes to) for the inspector. Read at call time, so it reflects the
53
- store as it stands; request-scoped stores are deliberately excluded — they're
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(globalCacheStore().entries.values(), (entry) =>
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 global cache entry projected for the inspector — the serializable facts the
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 (`global: true`) store's
5
- current entries. That's the persistent store — request-scoped caches live and
6
- die with their request, so they surface as per-request tallies in the feed and
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 (global) cache store — current entries with their
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
@@ -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 global one —
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. TTL = undefinedforever; ttl = 0 → dedupe
66
- only; ttl > 0 → entry expires `ttl` ms after the promise resolves.
65
+ they can't collide with arg shapes. ttl = 0dedupe 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.global` puts the entry in the process-level store instead of the
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 reads (see smartRead / entry.retain);
176
- the public cache() passes false, keeping its explicit drop-on-ttl /
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
- /* Unconditional SWR retention for a smart replayable read (unless opted out). */
204
- const retain = smart && replayable && options?.swr !== false
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
- A smart write is coalesce-only, not retained (design: same as cache(fn, body,
207
- { ttl: 0 })) a second identical submit must re-fire, never replay the first
208
- result. Default a smart write's ttl to 0 when the caller left it open. Reads and
209
- explicit cache() keep the caller's ttl (undefined = forever).
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 effectiveOptions =
212
- smart && isRemote && !replayable && options?.ttl === undefined
213
- ? { ...options, ttl: 0 }
214
- : options
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?.global ? globalCacheStore() : activeCacheStore()
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?.global ? activeCacheStore() : store, key, existing)
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?.global ? activeCacheStore() : store, key, existing)
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 to the process-level `global` store (not
506
- request-scopedkeeping it would leak forever).
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 && !options?.global && typeof window === '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 global = globalCacheStore()
894
- global.subscribe(key)
895
- global.trackLifecycle(key)
896
- entry = global.entries.get(key)
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 { globalCacheStore } from './globalCacheStore.ts'
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 global = globalCacheStore()
9
- return active === global ? [active] : [active, global]
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 (SWR unconditional), a refresh
8
- always refetches-and-swaps; it never drops to a pending blank. Follows the shared
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: omitted = forever, 0 =
6
- dedupe only (entry dropped once the promise settles — the mutation idiom:
7
- in-flight coalescing and pending() visibility, nothing retained), any other
8
- number = TTL.
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
- `global` opts the entry into the process-level store instead of the default
15
- request-scoped one (server) — so a value computed in one request is reused by
16
- later requests, e.g. memoising an external endpoint the server calls. Omit it
17
- for per-request data: the default keeps a per-user response from leaking across
18
- requests. Write only `global: true`; there is no `false` form. On the client
19
- there is a single tab store, so the flag is a no-op there.
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
- global?: boolean
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 global
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: undefined = retain forever (no auto-refetch),
9
- N ms = the retained value goes stale after N ms and the next access triggers a
10
- background revalidation (stale stays visible, `refreshing()` true). The display
11
- value is never dropped; ttl drives staleness, not eviction.
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
- - `global` — store the entry in the process-level store instead of the default
16
- request-scoped one (server), so a value computed in one request is reused by
17
- later requests the memoise-an-external-endpoint case. Omit it for per-user
18
- data: the default keeps a per-user response from leaking across requests.
19
- Write only `global: true`; there is no `false` form. On the client there is a
20
- single tab store, so the flag is a no-op there.
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
- global?: boolean
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
- globalResolver: globalCacheStoreSlot.resolver,
101
+ sharedResolver: sharedCacheStoreSlot.resolver,
102
102
  pageResolver: pageSlot.resolver,
103
103
  baseResolver: baseSlot.resolver,
104
104
  activeServer: serverSlot.active,
105
105
  }
106
106
 
107
- cacheStoreSlot.resolver = () => requestContext.getStore()?.cache
108
- const globalStore = createCacheStore()
109
- globalCacheStoreSlot.resolver = () => globalStore
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
- globalCacheStoreSlot.resolver = previous.globalResolver
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, { global: true }) shares it, so global is a no-op here. */
72
- globalCacheStoreSlot.resolver = () => store
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
@@ -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, { global: true }) — one per server process,
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 globalCacheStore = createCacheStore()
77
- globalCacheStoreSlot.resolver = () => globalCacheStore
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>()