Skip to content

re-frame.resources

A resource is a named, cached read of remote state. Views read server state passively through subscriptions. Route entry, events, and machines cause it to fetch. The cache lives in the framework-owned runtime partition, not app-db. You register a resource once with a scope policy, a params schema, and a request. The runtime then owns the rest:

  • identity and cache scope
  • staleness, dedupe, and invalidation
  • GC and in-flight ownership
  • SSR hydration and the metadata Xray reads

Resources are an optional, post-v1 capability. They ship in day8/re-frame2-resources (re-frame.resources) and require day8/re-frame2-http for the transport. One (:require [re-frame.resources]) at app boot wires them in. An app that omits the artefact sees the reg-resource wrapper throw :rf.error/resources-artefact-missing. This page covers the registration shape, the events, the subs, and introspection. The tutorial is Guide ch.27 — Server-state and resources.

Scope in this slice is HTTP-only:

  • Read-resource MVP.
  • Mutations (reg-mutation / :rf.mutation/execute, the causal-write counterpart) — see Mutation events.
  • Focus/reconnect active-stale revalidation (:rf.resource/window-focused / :rf.resource/network-reconnected, declared by the frame's :revalidate-on config key) — see Revalidation is a frame property.
  • Active-owner polling (:poll-interval-ms) — see Polling.
  • GraphQL is deferred to a later slice — see Guide ch.27 §What's deferred.

reg-resource / reg-mutation are on the re-frame.core facade; the events and subscriptions are keyword-addressed:

(:require [re-frame.core :as rf])

Registration

reg-resource

  • Kind: function (post-v1 lib)
  • Signature:
    (reg-resource resource-id metadata request-fn)
    
  • Description: Register a resource as data. Returns resource-id.
    • metadata (middle slot): the registration-metadata map. It carries the fail-closed :scope policy, :params-schema, :doc, and so on. A non-map metadata raises :rf.error/resource-bad-spec.
    • request-fn (third slot): returns the managed-HTTP args map.
    • Validates the reconstructed spec (:scope first, then :params-schema), then writes a :resource-kind registrar entry.
    • The introspection spec stored under :rf/resource reconstructs :request onto the metadata map.

The resource spec

(rf/reg-resource :article/by-slug
  {:doc "Article detail by slug."

   :params-schema [:map [:slug :string]]      ;; REQUIRED — validates + canonicalizes params
   :data-schema   :app/article                ;; static shape decl for tooling — runtime validation rides :decode

   :scope          :rf.scope/global            ;; REQUIRED — an explicit, auditable claim
   :transport      :rf.http/managed            ;; the only initial-scope transport
   :stale-after-ms 60000
   :gc-after-ms    300000
   :poll-interval-ms 5000                      ;; (optional) revalidate every 5s while actively owned + visible
   :tags           (fn [{:keys [slug]} _data] #{[:article slug]})
   :sensitive?     false}

  ;; REQUIRED request fn (third positional arg) — returns a managed-HTTP args map
  (fn [{:keys [slug]} _ctx]
    {:request {:method :get :url (str "/api/articles/" slug)}
     :decode  :app/article}))

Required keys:

Key Notes
:params-schema Validates and canonicalizes params (the resource's identity).
:scope The scope policy: EXACTLY :rf.scope/global or {:from-db <resource-scope-id>}. Fail-closed: any other value — including none — raises a loud :rf.error/resource-missing-scope-policy. There is no implicit default; a user-scoped read must say so.
request fn (3rd positional arg) For :transport :rf.http/managed, returns a managed-HTTP args map. It MUST NOT supply :request-id / :on-success / :on-failure — the runtime supplies those from the scoped key + generation. Supplying one raises :rf.error/resource-reserved-request-key.

These three (:params-schema, :scope, request fn) are the registration gate. A reg-resource missing any of them throws. :data-schema is not part of the gate: it is an optional static declaration of the data's shape (surfaced to tooling), not a runtime validator, so the gate does not enforce it. Runtime shape-validation of a response rides the request's :decode.

Optional keys:

  • :doc
  • :data-schema — an optional static declaration of the decoded-data shape, surfaced to tooling (the resource's :schema fact) and the :rf/resource registration projection. It does not validate at runtime (runtime shape-validation rides the request's :decode) and is not enforced by the gate.
  • :transport — initial scope: :rf.http/managed.
  • :stale-after-ms, :gc-after-ms
  • :poll-interval-ms — the active-owner poll interval. See Polling.
  • :infinite, plus the infinite-only keys :next-page-param, :prev-page-param, :page->items, :initial-page-param, and :refetch. See Infinite resources. (There is no :page-data-schema — it is a retired key that reg-resource hard-rejects; per-page validation rides the request's :decode and per-page classification rides :sensitive / :large.)
  • :tags
  • :sensitive? / :large? / schema-based classification

Not in the v1 registration surface: :revalidate, :placeholder, :cache-key, :select, and transport extension protocols. The gate neither reads nor validates them. (Looking for interval polling? That is :poll-interval-ms. The load-more kind is :infinite — see Infinite resources.) The mutation-only keys (:invalidates, :patches, :populates, :removes, :optimistic, :optimistic-tags, :on-conflict) are not resource-registration keys. They live on reg-mutation.

Scope policy

:scope is required and is EXACTLY one of two shapes:

Policy Meaning
:rf.scope/global The resource is explicitly global: the same params produce the same data for every user/tenant/permission/locale/impersonation state. This is an auditable claim, not a convenience default.
{:from-db <resource-scope-id>} Derive the scope from app-db at use time via the named resolver registered with reg-resource-scope. The resolver's :inputs are declared, so tooling reads the derivation without running it.

Anything else — an app-namespaced keyword, a literal tuple / map / string, a fn, a :rf.scope/* typo, or no :scope at all — is a registration error. A scope known only at the call site is modelled as {:from-db …} over a slot the caller writes first, or by passing a concrete :scope override at each use site.

There is no [:rf.scope/global] fallthrough.

  • Event resolution precedence: payload :scope → route-entry :scope override → spec policy. A route entry's :scope is a concrete value or a {:from-db <id>} reference — never a function; any other present value is a planning error rather than a silent inherit. A {:from-db <id>} reference that resolves nil at an event/route site raises :rf.error/resource-scope-unresolved-reference.
  • Subscription resolution: payload :scope (an override) → spec policy → loud :rf.error/resource-sub-unresolved-scope when a {:from-db …} reference yields nil (never a silent global read or :idle).
  • Registration is the DEFAULT; a use-site :scope is an OVERRIDE. Declare the scope once on the resource; a route entry, subscription payload or event payload that omits :scope inherits it. Supply one at a use site only when that site genuinely reads under a different principal (an admin reading tenant X). Repeating the registration's own policy at every use site is noise, not safety.

See Guide ch.27 §Scope.

Clearing a resource

  • Signature:
    (rf/clear :resource resource-id)
    
  • Description: Remove a registered resource. Returns resource-id. There is no clear-resource name — not on re-frame.resources and not on the re-frame.core facade; :resource is one of the kinds the one kind-keyed registrar inverse dispatches (see clear). re-frame.resources.registry/clear-resource survives as the late-bind hook target that dispatch routes to — artefact-internal plumbing, not a public call (rf2-kuky.80).
    • A registration-lifecycle operation, NOT cache invalidation. For data lifecycle use :rf.resource/invalidate-tags / :rf.resource/remove / :rf.resource/clear-scope.
    • Also disposes the resource-runtime state for the id in each affected frame. That disposal:
      • releases owner indexes
      • cancels timers and host handles
      • aborts in-flight work where possible
      • suppresses late replies by generation
      • removes tag-index rows
      • emits a trace
;; deregister a resource (registration-lifecycle — e.g. on hot-reload / teardown)
(rf/clear :resource :feed/timeline)

A mutation is the causal-WRITE counterpart of a resource: a named write to remote state. On success it invalidates, patches, or populates cached resource reads. The write lowers through the same :rf.http/managed transport as resources. The runtime owns reply addressing and stale-suppression (work-id + generation) exactly as for reads. Runtime state is keyed by mutation instance id, so concurrent submissions of the same mutation never clobber each other. See Guide ch.27 §Mutations.

reg-mutation

  • Kind: function (post-v1 lib)
  • Signature:
    (reg-mutation mutation-id metadata request-fn)
    
  • Description: Register a mutation as data under mutation-id. Returns mutation-id. The causal-write counterpart of :resource.
    • metadata (middle slot): the registration-metadata map. It carries :params-schema, :invalidates, :patches, :doc, and so on. A non-map metadata raises :rf.error/mutation-bad-spec.
    • request-fn (third slot): the managed-HTTP write.
    • Validates the reconstructed spec and writes a :mutation-kind registrar entry.
    • An app that omits the resources artefact sees the wrapper throw :rf.error/resources-artefact-missing.
    • The introspection spec stored under :rf/mutation reconstructs :request onto the metadata map.
(rf/reg-mutation :article/save
  {:params-schema :app/article          ;; REQUIRED — validates + canonicalizes params
   :invalidates  (fn [{:keys [slug]} _result] #{[:article slug] [:article-list]})
   ;; :patches / :populates key a cached entry by its resource descriptor and
   ;; apply on success BEFORE invalidation — patch transforms the entry's data,
   ;; populate seeds it straight from the reply.
   :patches      (fn [{:keys [slug]} result]
                   {{:resource :article/by-slug :params {:slug slug} :scope :rf.scope/global}
                    (fn [old] (merge old result))})
   :populates    (fn [{:keys [slug]} result]
                   {{:resource :article/by-slug :params {:slug slug} :scope :rf.scope/global} result})
   :scope        :rf.scope/global       ;; the cache scope invalidation/patch defaults to
   :invalidate-timing :after-success}   ;; | :before-request | :after-failure | :after-settle

  ;; REQUIRED request fn (third positional arg) — a managed-HTTP write
  (fn [{:keys [slug] :as article} _ctx]
    {:request {:method :put :url (str "/api/articles/" slug) :body article}
     :decode  :app/article}))

Required keys:

Key Notes
:params-schema Validates and canonicalizes the write's params.
request fn (3rd positional arg) Returns a managed-HTTP args map (the write). It MUST NOT supply :request-id / :on-success / :on-failure — the runtime supplies those from the instance + generation. Supplying one raises :rf.error/resource-reserved-request-key. Write retries are opt-in and live in the returned args map's own :retry (see the note below), never in a reg-mutation spec key.

Optional keys:

  • :invalidates(fn [params result] → #{tag …}), the tags made stale on success. The runtime composes them with :rf.resource/invalidate-tags, scoped.
  • :patches / :populates — controlled resource-entry transforms / seeds applied on success, before invalidation. They are keyed by scoped key and use the same durable entry shape and structural sharing as the read path.
  • :removes(fn [params result] → [target …]), controlled resource-entry removals applied on success. Targets use the same map-form exact shape {:resource :params :scope}. A removed key's in-flight attempt is best-effort aborted.
  • :scope — the cache scope the invalidation / patch / populate targets.
  • :invalidate-timing:after-success (default) | :before-request | :after-failure | :after-settle. A value outside the closed enum is rejected with :rf.error/mutation-bad-spec.
  • :transport, :doc.

:retry is NOT a reg-mutation spec key. Write retries are opt-in. They ride the managed-HTTP args your :request fn returns — put :retry {…} in that map. The runtime passes the :request args through to the transport unchanged. It does not read or enforce a spec-level :retry, so there is no reg-mutation-level retry to arm. Reads inherit the same discipline — see Managed HTTP reference §Retry. Re-issuing a non-idempotent write because a reply was merely slow is the double-write bug, so retry stays explicit and per-request.

Mutation :scope is not fail-closed. A resource read's :scope is required and fails closed. A mutation's :scope is optional: it resolves payload :scope → spec :scope:rf.scope/global. The scope decides which cache scope the success-time invalidate / patch / populate targets, so it MUST match the scope of the resources the write changes. A write against user/tenant/locale-scoped entries that omits :scope invalidates the [:rf.scope/global] cache instead. It silently misses the scoped entries — stale reads, no error. Pass :scope on [:rf.mutation/execute …] when the principal is known only at the call site.

Optimistic keys (see EP-0019 and Invalidate after a mutation / model § mutations):

  • :optimistic — a registration-level forward plan applied before the server confirms. It is the twin of :patches, with signature (fn [params] → {target patch-fn}). There is no result arg; the reply does not exist yet.
  • :optimistic-tags — its tag-addressed twin for cross-view consistency.
  • :on-conflict:invalidate (default) | :force, the contested-rollback policy.

The inverse is runtime-recorded: the author supplies no :rollback registration key. The runtime snapshots each touched entry (with its :revision) on the instance row's :patch-summary :rollback slot and settles via the commit/rollback/reconcile protocol. An optimistic plan combined with :invalidate-timing :before-request is contradictory and rejected at registration with :rf.error/mutation-optimistic-before-request.

Clearing a mutation

  • Signature:
    (rf/clear :mutation mutation-id)
    
  • Description: Remove a registered mutation. Returns mutation-id. A registration-lifecycle operation, NOT a form-error reset. For the causal runtime-instance reset use the [:rf.mutation/clear …] event. There is no clear-mutation name — not on re-frame.resources and not on the re-frame.core facade; :mutation is one of the kinds the one kind-keyed registrar inverse dispatches (see clear). re-frame.resources.mutation-registry/clear-mutation survives as the late-bind hook target that dispatch routes to — artefact-internal plumbing, not a public call (rf2-kuky.80).
;; deregister a mutation (registration-lifecycle — NOT the runtime-instance reset)
(rf/clear :mutation :article/save)

Named scope resolvers

A resource-scope resolver is the third resources kind, alongside resources and mutations. It is a registrar entry under the :resource-scope kind that derives a cache scope from declared app-db inputs. It is also the one scope-resolution currency: the same named resolver works at every site that needs a scope —

  • resource registration
  • route resources
  • event-side ensure
  • subscriptions
  • invalidation descriptors
  • populate/patch/remove targets
  • clear-scope

A resource (or payload, or route) references a named resolver as {:from-db <scope-id>}. The reference is resolved at use time against the frame's app-db. A nil resolution is fail-closed at every scope-requiring site — never an implicit global read.

reg-resource-scope

  • Kind: function (post-v1 lib)
  • Signature:
    (reg-resource-scope scope-id metadata resolve-fn)   ;; ONE arity; :inputs is required
    
  • Description: Register a pure named scope resolver under scope-id in the canonical 3-slot grammar. Returns scope-id. Ships in day8/re-frame2-resources; require re-frame.resources at boot. An app that omits the artefact sees the wrapper throw :rf.error/resources-artefact-missing.
    • resolve-fn (value/third slot): the resolver. Its first arg is ALWAYS the resolved inputs map. It MUST be pure — it MUST NOT fetch, dispatch, mutate state, or read ambient host state. The ctx arg is reserved and invoked as literal nil in this slice. A nil resolve result is fail-closed.
    • metadata (middle slot): carries the declared :inputs {name [:db <rf-path>]} plus optional :doc. The only shipped input source is [:db <rf-path>] (a concrete :rf/path). [:runtime …] (route-derived scope) is reserved and rejected with :rf.error/resource-scope-source-reserved.
    • Whole-db form: declare the whole db as an ordinary input on the root path — {:inputs {:db [:db []]}}. There is no bare-fn sugar and no first-arg meaning-shift; the stored :whole-db? cost mark is DERIVED from that declaration.
    • A missing :inputs (an empty or :doc-only metadata), a non-map metadata, a malformed :inputs descriptor, a :resolve left inside the metadata map, or a non-fn value slot is rejected with :rf.error/invalid-resource-scope-spec.
    • Writes a :resource-scope-kind registrar entry carrying the canonical spec plus captured source coords.
(rf/reg-resource-scope :realworld/session
  {:inputs {:username [:db [:auth :user :username]]}}
  (fn [{:keys [username]} _ctx]
    (when username [:rf.scope/session {:username username}])))

;; referenced from a resource / payload / route as {:from-db :realworld/session}

Clearing a resource-scope resolver

  • Signature:
    (rf/clear :resource-scope scope-id)
    
  • Description: Remove a registered resource-scope resolver. This is a registration-lifecycle removal — the registrar inverse of reg-resource-scope. A resolver holds no per-frame runtime state (it is a pure derivation consulted at use time), so nothing is disposed beyond the registrar entry. When the id is not registered, the call is a no-op that returns scope-id. There is no clear-resource-scope name — not on re-frame.resources and not on the re-frame.core facade; :resource-scope is one of the kinds the one kind-keyed registrar inverse dispatches (see clear). re-frame.resources.scope-registry/clear-resource-scope survives as the late-bind hook target that dispatch routes to — artefact-internal plumbing, not a public call (rf2-kuky.80).
;; deregister a named scope resolver (registration-lifecycle — e.g. on hot-reload / teardown)
(rf/clear :resource-scope :realworld/session)

resolve-resource-scope

  • Kind: function (post-v1 lib)
  • Signature:
    (resolve-resource-scope db scope-id)  scope or nil
    
  • Description: Resolver helper: resolve the named resolver scope-id against the supplied db value, returning a canonical concrete scope or nil.
    • A pure function over the resolver registry, NOT an effect. It has no app-state or dispatch side effects, and no observability side effect: it does NOT emit a :rf.resource/scope-resolved trace. The causal {:from-db …} / route-entry / mutation-settle boundaries carry that evidence.
    • Throws :rf.error/resource-scope-not-registered when no resolver is registered under scope-id (a typo'd reference is never a silent nil).
    • A resolver that returns nil for the supplied db yields nil. That is the fail-closed unresolved condition; the use site interprets it — never as an implicit global.
    • Canonical use is the logout / account-switch idiom: resolve the concrete old scope from the handler's coeffect db — pre-transition by definition, the causal input — and pass it concretely to [:rf.resource/clear-scope …].
;; the logout idiom: resolve the concrete OLD scope off the coeffect db,
;; then clear that scope's whole cache so the next principal can never read it
(rf/reg-event :auth/logout
  (fn [{:keys [db]} _]
    (let [old (rf/resolve-resource-scope db :realworld/session)]
      {:db (dissoc db :auth)
       :fx [[:dispatch [:rf.resource/clear-scope {:scope old :cause :logout}]]]})))

Reading the resolver registry

There is no scope-resolver-meta / scope-resolver-ids accessor (rf2-kuky.31). The :resource-scope registrar kind is read the way every kind is — the generic registrar query plus the documented :rf/resource-scope inner-key projection:

(keys (rf/registrations {:source :store :kind :resource-scope}))
;; => (:realworld/session)

(:rf/resource-scope (rf/handler-meta {:source :store :kind :resource-scope
                                      :id     :realworld/session}))
;; => {:inputs {:username [:db [:auth :user :username]]} :resolve #fn :whole-db? false :doc nil}

The projected map is the resolver's canonical spec (:inputs, :resolve, :whole-db?, :doc), or nil if none is registered. :whole-db? is DERIVED, not authored: true iff some declared input targets the root path ({:inputs {:db [:db []]}}).

Revalidation is a frame property

Active-stale revalidation is expressed as resource events (see [:rf.resource/window-focused] / [:rf.resource/network-reconnected]) — never subscription-driven fetching. On window focus, tab-return, or network reconnect, the runtime rescans the frame's active-owner stale entries and refetches them by policy. A stale entry with no active owner is left alone: revalidation creates no liveness.

Which host signals reach a frame is declared on the frame, exactly as URL ownership is:

(rf/make-frame {:id :app :url-bound? true :revalidate-on #{:focus :reconnect}})

[rf/frame-root {:id app-frame :url-bound? true :revalidate-on #{:focus :reconnect}}
 [root-view]]
  • :revalidate-on is an optional frame-config key: a set drawn from the closed enum #{:focus :reconnect}.
    • :focus wires focus (on window) and visibilitychange-to-visible (on document, its only valid event target) → [:rf.resource/window-focused]. It is one setting, not two.
    • :reconnect wires online (on window) → [:rf.resource/network-reconnected].
  • An absent key, or an explicit #{}, installs nothing. The empty set is a legitimate "none".
  • The frame lifecycle owns them. Creation installs the declared subset once the frame is live; a deliberate re-registration reconciles (replace-don't-stack — it detaches whatever the frame had and attaches exactly the declared subset, so the listeners never stack however many times a frame is re-registered, and dropping the key relinquishes them); frame destroy cancels them via the single :resources/on-frame-destroyed! hook. There is no install-revalidation-listeners! / remove-revalidation-listeners! — the imperative pair, and the ordering rule it required, are gone.
  • CLJS-only host listeners; the JVM/SSR arm installs nothing and nothing throws. Declaring :revalidate-on without day8/re-frame2-resources on the classpath fails loud at registration with :rf.error/resources-artefact-missing.

Polling

A resource may declare an optional :poll-interval-ms policy. While an entry has at least one active owner and the document is visible, the runtime re-runs its load every N ms — no component-side fetch call needed. Polling is owner-driven: it tracks the active owner, not a component observer. A route, machine, or app-minted event owner keeps it alive, and polling stops the instant the last owner releases.

(rf/reg-resource :notifications/unread-count
  {:scope            {:from-db :app/session}
   :params-schema    [:map]
   :poll-interval-ms 15000           ;; refresh every 15s while someone owns it + the tab is visible
   :tags    (fn [_ _] #{[:notifications]})}
  (fn [_ _ctx] {:request {:method :get :url "/notifications/unread"} :decode :json}))

Semantics (per The model — owners and refetch):

  • Positive integer ms; non-positive / absent = no polling. The poll timer is the third member of the freshness-timer family (beside :stale-after-ms / :gc-after-ms).
  • Unconditional active-owner tick. A tick refetches by the interval, not gated on :stale?. :stale-after-ms stays the orthogonal focus/route-entry knob. (Structural sharing keeps views quiet when an unchanged response comes back.)
  • Cause :poll, never an owner. A poll refetch creates no liveness, extends no GC; generation + stale-reply suppression apply exactly as for any refetch.
  • Default-pause-when-hidden. Poll ticks are suppressed while the tab is hidden and resume on tab return (which also fires the focus scan). A :poll-when-hidden? opt-in for a true background monitor is reserved.
  • Coalescing. A tick that finds a live in-flight refetch skips (no overlap on a slow endpoint); focus + poll never double-fetch.
  • Background-failure resilient. A failed poll keeps prior :data + records :refresh-error, and the next poll still fires.

A "just polling" view with no natural route/machine owner needs an app-minted owner naming its event (e.g. [:dashboard/opened …]) to keep the poll alive, with a matching [:rf.resource/release-owner {…}]. An owner-free entry never polls.

Infinite resources

An infinite resource is the load-more / infinite-scroll feed kind (see EP-0021; the recipe is Paginate a feed). It is a resource registered with :infinite true plus a pure :next-page-param. The user accumulates pages — 1, then 1+2, then 1+2+3 — rendered as one growing list. The next page param is derived from the last page's data. Numbered / cursor pagination (:keep-previous? + per-page entries) is untouched and orthogonal; an app picks per feed.

(rf/reg-resource :feed/timeline
  {:doc "Infinite home timeline (load-more)."
   :infinite         true
   :params-schema    [:map [:filter :keyword]]   ;; FEED identity (filter/sort) — NOT the page cursor
   :scope            {:from-db :app/session}
   :sensitive        [[:data :author-email]]      ;; durable per-page classification (index-free walk — every page)
   :next-page-param  (fn [last-page _all-pages]    ;; REQUIRED; nil = the single terminal
                       (get-in last-page [:page-info :next-cursor]))
   :page->items      :items                        ;; REQUIRED when a page is non-vector/enveloped (loud over guessing)
   :tags             (fn [{:keys [filter]} _] #{[:feed filter]})}

  ;; request fn (third positional arg) — reserved ctx carries the page context (R8 — no new arity)
  (fn [{:keys [filter]} {:rf.resource/keys [page-param]}]
    {:request {:method :get :url "/api/timeline"
               :params (cond-> {:filter filter :limit 20} page-param (assoc :cursor page-param))}
     :decode  :app/timeline-page}))            ;; per-page VALIDATION — validates ONE page

Key semantics:

  • :infinite true makes :next-page-param required. Omitting it raises a loud :rf.error/infinite-missing-next-page-param. The fn is pure: (last-page all-pages) → next-param-or-nil. Returning nil is the single canonical terminal, exposed as the derived :has-next-page?.
  • One scoped entry per feed. Pages accumulate as an ordered vector inside the one :rf.runtime/resources entry — not N per-page entries, and not an app-db slice. The feed therefore has one owner set, one freshness clock, one GC clock, one SSR-restore unit, and one Xray row. The per-page param is internal sequencing state, never part of the cache key. Changing the identity params yields a different feed instance.
  • Per-page validation + classification. Per-page validation rides the request's :decode (a Malli schema validates one page before its reply exists, on page 0 / load-more / every refetch leg). Durable per-page classification rides the projection-relative :sensitive / :large declarations — the index-free walk matches [:data :field] against every indexed page path [:data <page-idx> :field], so the field redacts on every page. :data-schema is not used for the accumulated vector. (There is no :page-data-schema — it is a retired key reg-resource hard-rejects.)
  • Other infinite-only keys:
    • :prev-page-param — the bidirectional derivation mirror. Defined, but the load-prev prepend event is deferred; v1 ships next-direction load-more only.
    • :initial-page-param — the first-page param. Default nil.
    • :page->items — required for non-vector pages.
    • :refetch{:refetch-all-pages? false} by default, the conservative window-preserving refetch policy. :refetch-all-pages? / :refetch-window are the opt-ins.

A view reads the merged list and dispatches the causal [:rf.resource/load-more {…}]:

[:rf.resource/items          {:resource :feed/timeline :scope  :params }]   ;; merged flat list — the headline read
[:rf.resource/pages          {}]   ;; raw page boundaries
[:rf.resource/has-next-page? {}]   [:rf.resource/fetching-next? {}]
[:rf.resource/has-prev-page? {}]   ;; the bidirectional mirror (observable; the prepend event is deferred)
[:rf.resource/page-count     {}]   [:rf.resource/page-error     {}]
[:rf.resource/infinite-state {}]   ;; combined view-model (the feed analogue of :rf/resource)

:rf.resource/items, :rf.resource/pages, and :rf.resource/infinite-state are framework-owned memoised subscriptions. :rf.resource/ensure (and a route entry) loads page 0 only. A mutation touching an item inside a feed invalidates the whole feed — coarse, but correct. In-place item patching inside a feed's page vector is a distinct deferred axis (not the single-entry optimistic surface), homed at spec/016 §Refetch and invalidation of an infinite feed.

Introspection and projection (tool/test lane)

These direct functions are the tool/test projection lane, not an app-read API:

  • The generic registrar query plus an inner-key projection reads the registration (the registered spec), and rf/registrations enumerates the registry. There is no resource-meta / mutation-meta accessor (rf2-kuky.31).
  • resource-state / mutation-state project runtime state (one live entry, one live instance) as a one-shot, non-reactive snapshot at an explicit frame; the whole live table is the reserved runtime-db path read off rf/frame-state-value (§Enumerating the whole live table).

There is no bundled read returning both the registry and the live table: resources and mutations were deleted by rf2-kuky.85, and the three reads below are the replacement.

They serve Xray, unit tests, and SSR serialization — contexts with no reactive subscription. App views read runtime state through the passive :rf.resource/* / :rf.mutation/* subscriptions, never through these functions — they do not re-render on change. Registering a handler, dispatching a cause, projecting a snapshot, and subscribing are four distinct jobs; see The model — three lanes.

:frame is an explicit, app-registered frame id (EP-0002). There is no ambient :rf/default fallback; a frameless call with no resolvable context fails closed.

Reading a registered resource's spec

  • Kind: the generic registrar query plus the :rf/resource inner-key projection — there is no resource-meta accessor (rf2-kuky.31), and this read needs no artefact.
  • Returns: the registered resource's spec (:params-schema, :data-schema, :request, :scope, :transport, :stale-after-ms, :gc-after-ms, :poll-interval-ms, :tags, :doc, source coords), or nil.
;; tool/test lane: read a registered resource's spec back
(:rf/resource (rf/handler-meta {:source :store :kind :resource
                                :id     :article/by-slug}))
;; => {:scope :rf.scope/global :params-schema [...] :request #fn ... :doc "…"}

resource-state

  • Kind: function (post-v1 lib)
  • Signature:
    (resource-state {:resource  :scope  :params  :frame })  entry or nil
    
  • Description: A resource instance's durable runtime entry for an explicit-frame target. The scoped key resolves the same way a subscription's does: a {:from-db <id>} scope resolves against the frame's app-db.
    • nil when no entry exists.
    • An absent / nil :frame raises :rf.error/no-frame-context. This is fail-closed: a nil pass-through would be indistinguishable from a genuinely absent entry.
    • An explicit but unknown / destroyed :frame reads as nil.
;; the live durable entry for one scoped key, at an explicit frame
(rf/resource-state {:resource :article/by-slug
                    :scope    :rf.scope/global
                    :params   {:slug "welcome"}
                    :frame    :rf/default})
;; => entry map, or nil when no entry exists

Enumerating registered resource ids

There is no resource-ids accessor (rf2-kuky.31) — it is keys over the generic registry read:

(keys (rf/registrations {:source :store :kind :resource}))
;; => (:article/by-slug :feed/timeline)

Reading a registered mutation's spec

  • Kind: the generic registrar query plus the :rf/mutation inner-key projection — there is no mutation-meta accessor (rf2-kuky.31).
  • Returns: the registered mutation's spec map (:request, :params-schema, :invalidates, :patches, :populates, :removes, :optimistic, :optimistic-tags, :on-conflict, :scope, :invalidate-timing, :transport, :doc, source coords), or nil.
(:rf/mutation (rf/handler-meta {:source :store :kind :mutation
                                :id     :article/save}))
;; => {:request #fn ... :params-schema :app/article :invalidates #fn ... :scope :rf.scope/global …}

mutation-state

  • Kind: function (post-v1 lib)
  • Signature:
    (mutation-state {:instance  :frame })  row or nil
    
  • Description: A mutation instance's durable runtime row ({:status :result :error …}) for an explicit-frame target, or nil.
    • The frame is carried explicitly (see EP-0002).
    • An absent / nil :frame raises :rf.error/no-frame-context (fail-closed, symmetric with resource-state).
    • An explicit but unknown / destroyed :frame reads as nil.
;; one mutation INSTANCE's runtime row, at an explicit frame
(rf/mutation-state {:instance :form/save-1 :frame :rf/default})
;; => {:status … :result … :error …}, or nil

Enumerating registered mutation ids

There is no mutation-ids accessor (rf2-kuky.31) — it is keys over the generic registry read:

(keys (rf/registrations {:source :store :kind :mutation}))
;; => (:article/save)

Enumerating the whole live table

The registry, the whole live table, and one entry are three different reads. The registry answers what is registered and takes no frame; the live tables are runtime-db state and are read at an explicit frame through the reserved paths (§Cache home); resource-state / mutation-state narrow to one target.

;; 1. REGISTRY — every registered id, no frame.
(keys (rf/registrations {:source :store :kind :resource}))  ;; => (:article/by-slug :feed/timeline)
(keys (rf/registrations {:source :store :kind :mutation}))  ;; => (:article/save)

;; 2. WHOLE LIVE TABLE — the reserved runtime-db path off the frame-state projection.
(get-in (rf/frame-state-value :app/main) [:rf.db/runtime :rf.runtime/resources :entries])
;; => {<key-id> {:resource/id :article/by-slug
;;               :resource/key [:rf.scope/global :article/by-slug {:slug "welcome"}]
;;               :data … :error … :generation … :current-work …}
;;     …}
(get-in (rf/frame-state-value :app/main) [:rf.db/runtime :rf.runtime/mutations])
;; => {<instance-id> {:mutation/id :article/save :instance/id :form/save-1
;;                    :status … :result … :error … :generation …}
;;     …}

;; 3. ONE ENTRY / ONE INSTANCE — the narrowed reads documented above.
(rf/resource-state {:resource :article/by-slug :scope :rf.scope/global
                    :params {:slug "welcome"} :frame :app/main})
(rf/mutation-state {:instance :form/save-1 :frame :app/main})

Read each table as it is keyed. :entries is keyed by the entry's CEDN-1 byte key-id (§Resource identity) — the entry's human-readable [scope resource-id params] tuple rides the row as :resource/key, and re-keying the table onto it can collapse distinct entries. :rf.runtime/mutations is keyed by mutation instance id (:instance/id), never by mutation id, so concurrent submissions of the same mutation stay distinct; re-keying onto :mutation/id collapses them.

Both subtrees are allocated lazily: :rf.runtime/resources is absent until the first resource write, and :rf.runtime/mutations is absent in an app that registers no mutation, so either read can return nil at a live frame. That nil does not by itself say which case you are inrf/frame-state-value returns nil for an unknown or destroyed frame, and get-in of that nil is indistinguishable from get-in of a live frame whose subtree is unallocated. At a frame you already know is live, read it as the subtree has not been allocated; where you do not, (rf/frame-state-value :app/main) is itself the frame-liveness read.

Xray exposes the same shapes, plus:

  • the tool accessors — list-resources, list-resource-instances, get-resource-state, get-resource-history, list-resource-invalidations
  • the route/resource graph
  • the work-ledger table
  • the scope audit surface — the standing enumeration of every :rf.scope/global resource

Tool accessors prefer summaries over raw values. Params and scopes get the same privacy/size elision as data.

Keyword surfaces

The resource and mutation runtime is caused and read entirely through keyword-addressed events and subscriptions — never by calling a function. Resources are caused to fetch by events and read passively by subscriptions; the same split applies to mutations.

Resource events (map payloads)

Resource events take a map payload, not a positional argument vector. The resource-addressed events validate their target:

  • An unregistered :resource raises :rf.error/resource-not-registered.
  • Params that fail :params-schema raise :rf.error/resource-invalid-params.
  • Scope resolution is fail-closed (:rf.error/resource-scope-unresolved-reference / :rf.error/resource-sub-unresolved-scope — see Scope policy).

[:rf.resource/ensure {…}]

  • Kind: event
  • Payload: {:resource :scope :params :owner :cause :keep-previous?}
  • Description: Ensure the resource instance is loaded.
    • ensure while the same scoped key is already in flight joins the existing work (attaches the owner, records the cause, emits a dedupe trace).
    • ensure of an already-:loaded entry still fresh-by-policy is a fresh-skip: it serves the cached value, attaches the owner, and emits :rf.resource/cache-hit — no fetch.
    • :owner changes the active-owner set; :cause is recorded in trace/history.
    • :keep-previous? on a first-loading key records a projection pointer to the prior loaded sibling key. That lets :rf.resource/previous-data show old data while the new key loads. The pointer is never inserted into the new entry.
[:rf.resource/ensure
 {:resource :article/by-slug
  :scope    [:rf.scope/session {:user-id "u-42" :tenant-id "acme"}]
  :params   {:slug "welcome"}
  :owner    [:route :route/article nav-token]
  :cause    [:route-entry :route/article nav-token]}]

[:rf.resource/refetch {…}]

  • Kind: event
  • Payload: {:resource :scope :params :owner :cause}
  • Description: Force a refresh. It always starts a new generation, even when a request is already in flight. A still-in-flight prior request is marked superseded, then aborted when possible, otherwise suppressed by work-id + generation. A manual refresh is usually a :cause, not an :owner.
;; a "Refresh" button — a :cause, pointedly no :owner (the route keeps it alive)
[:rf.resource/refetch
 {:resource :articles/list
  :params   {}
  :cause    [:manual :resources.app/refresh-articles]}]

[:rf.resource/invalidate-tags {…}]

  • Kind: event
  • Payload: a closed union — scoped {:scope :tags :cause?} OR cross-scope {:cross-scope? true :tags :cause} (no :scope).
  • Description: Mark every entry whose tags intersect :tags as stale. Entries with active owners are refetched. Inactive entries stay stale or become GC-eligible.
    • Scoped by default — a scoped invalidation with no :scope raises :rf.error/resource-invalidate-scope-required (never a silent nil-scope match).
    • :scope is a ScopeInput — a concrete scope OR a {:from-db <id>} named-resolver reference, resolved at use time against the handler's app-db coeffect, symmetric with ensure. A reference that resolves nil is fail-closed with :rf.error/resource-scope-unresolved-reference (invalidate is scope-requiring like ensure). No in-handler resolve needed — pass the reference directly.
    • A cross-scope invalidation opts in explicitly with :cross-scope? true and carries no :scope (it is scope-agnostic). It ignores the scope filter, is visible in Xray, and MUST carry :cause — omitting one raises :rf.error/resource-cross-scope-cause-required; supplying a :scope alongside :cross-scope? true raises :rf.error/resource-cross-scope-scope-conflict.
    • On a successful load an entry's tags are replaced with the new data's tags.
;; after a write settles, stale the ACTING viewer's reads carrying these tags —
;; the {:from-db …} reference resolves against the handler's db, like ensure
[:rf.resource/invalidate-tags
 {:scope {:from-db :realworld/viewer}
  :tags  #{[:article "welcome"]}
  :cause [:follow-author-detail-sync "welcome"]}]

[:rf.resource/release-owner {…}]

  • Kind: event
  • Payload: {:owner …}
  • Description: Release an owner. Aborts in-flight work only when no remaining owner needs it. App-minted owners MUST have a matching release path — an orphaned owner pins an entry alive (Xray lints it).
;; the matching release for an app-minted event owner
[:rf.resource/release-owner
 {:owner [:article/preview-opened "welcome"]}]

[:rf.resource/clear-scope {…}]

  • Kind: event
  • Payload: {:scope :cause}:scope is a concrete scope, never a {:from-db <id>} reference
  • Description: Causal scope teardown. It:

    • removes (or marks unusable) every entry in the scope
    • releases owners
    • aborts in-flight requests with no owner outside the scope
    • suppresses late replies by scope + generation
    • emits explanatory trace rows

    Required on logout / account / tenant / permission / locale / impersonation change.

  • :scope is concrete, and this is the one event where that differs from ensure / invalidate-tags. Those resolve a {:from-db …} reference against their own handler's db, which is the right world for them. clear-scope is dispatched from a logout handler's :fx, so it runs in the next event — against the post-logout db, where the resolver's inputs are already gone. Resolve in the handler instead, with the pure resolve-resource-scope helper over its coeffect db, and pass the concrete result.

  • A {:from-db …} map on the payload is refused loud (:rf.error/resource-invalid-scope, :recovery :fix-scope) before anything is cleared — not ignored, because a map is a valid literal scope and would otherwise key nothing and clear nothing silently.
;; logout / tenant-switch — drop a whole scope's cache so the next principal
;; can never read the last one's data
[:rf.resource/clear-scope
 {:scope [:rf.scope/session {:user-id "u-42"}]
  :cause :logout}]

[:rf.resource/remove {…}]

  • Kind: event
  • Payload: {:resource :scope :params}
  • Description: Remove a single resource instance's cache entry.
;; drop one cached instance, keyed by its scoped resource key
[:rf.resource/remove
 {:resource :article/by-slug
  :scope    :rf.scope/global
  :params   {:slug "welcome"}}]

[:rf.resource/load-more {…}]

  • Kind: event (infinite resources only — see Infinite resources)
  • Payload: {:resource :scope :params :cause}ownerless (carries a :cause, never an :owner)
  • Description: Extend an :infinite feed by one page. The runtime computes the next page param from the entry's tail via :next-page-param, fetches that page through the same managed transport, and appends it to the feed's accumulated page vector. The feed stays :loaded and the pages stay visible. A load-more in flight shows as the derived :fetching-next? sub, distinct from a whole-feed :fetching? refresh.
    • A load-more with no next page (:next-page-paramnil) is a no-op trace.
    • A load-more while a page fetch is already in flight dedupes.
    • A supplied :owner is warn-and-ignored — the route (or whatever first-loaded the feed) already owns the one feed entry, and load-more never changes the active-owner set.
;; the "Load more" button — ownerless; carries a :cause, never an :owner
[:rf.resource/load-more
 {:resource :feed/timeline
  :scope    :rf.scope/global
  :params   {}
  :cause    [:user :feed/load-more]}]

[:rf.resource/window-focused] / [:rf.resource/network-reconnected]

  • Kind: event (no payload)
  • Description: Scan the frame's active-owner stale entries and refetch them by policy. The refetch carries cause :focus (window-focused) or :reconnect (network-reconnected). It never carries an owner — it creates no liveness. Generation + stale-suppression protect any late reply. The frame's :revalidate-on host listeners (Revalidation is a frame property) dispatch these; user code MUST NOT dispatch them directly.
;; no payload — dispatched by the frame's :revalidate-on host listeners;
;; user code MUST NOT dispatch these directly
[:rf.resource/window-focused]
[:rf.resource/network-reconnected]

Internal replies — do not dispatch. :rf.resource.internal/succeeded / …/failed / …/page-succeeded / …/page-failed / …/aborted / …/stale-fired / …/gc-fired / …/poll-fired / …/stale-suppressed / …/refetch-page are framework-internal. They carry the verification payload (:work/id, :resource/key, :scope, :generation, :rf.frame/id). User code MUST NOT dispatch them. Success/failure verify frame + work id + generation before writing — the mandatory stale-suppression boundary. Of these: :rf.resource.internal/stale-fired is the stale-timer re-check tick (it arms the stale transition, not a fetch); :rf.resource.internal/poll-fired is the poll-timer re-check tick (it refetches an active-owner entry by the interval); …/page-succeeded / …/page-failed are the infinite-feed page replies; …/refetch-page is one leg of a multi-page refetch sweep.

Resource subscriptions (passive)

A subscription is a pure passive read — it never fetches. It resolves scope per the sub-side precedence and raises :rf.error/resource-sub-unresolved-scope rather than reading global or returning a silent :idle.

[:rf/resource         {:resource  :scope  :params }]   ;; the full view-model
[:rf.resource/data          {}]   [:rf.resource/status        {}]
[:rf.resource/loading?      {}]   [:rf.resource/fetching?     {}]
[:rf.resource/stale?        {}]   [:rf.resource/error         {}]
[:rf.resource/refresh-error {}]   [:rf.resource/has-data?     {}]
[:rf.resource/previous-data {}]

The :rf/resource view-model — facts plus derived booleans:

{:status        :idle | :loading | :fetching | :loaded | :error
 :data          <last-known-good-or-nil>
 :error         <first-load-error-or-nil>          ;; :rf.http/* envelope
 :refresh-error <background-refresh-error-or-nil>  ;; :rf.http/* envelope
 :loading?      <bool>   ;; first load, no usable data
 :fetching?     <bool>   ;; refresh in flight, prior data visible
 :stale?        <bool>   ;; freshness — orthogonal to load status
 :has-data?     <bool>
 :previous?     <bool>}  ;; :keep-previous? projection — when true, also
                         ;; :previous-key + :previous-data (the prior key's data)

Status invariants:

  • :loading = first load, no usable data.
  • :fetching = refresh in flight while prior data stays visible.
  • :error = first load failed, no usable data.
  • A failed background refresh stays :loaded, keeps prior :data, records :refresh-error.

:stale? / :loading? / :fetching? / :has-data? are derived sub values, never stored. See Guide ch.27 §Status.

Lifecycle trace ops (observability, not events you dispatch). The runtime emits two trace ops here. :rf.resource/cache-hit fires when a fresh ensure is served from cache with no fetch — a fresh-skip. The already-:loaded entry, still fresh-by-policy, serves the cached value and attaches the owner; for a blocking route resource it also drains the blocking slot immediately, with no :fetching transition. :rf.resource/stale-fired fires when a stale timer ticks — it arms the stale transition, not a fetch. These are trace surfaces Xray reads. They are not :status values, and user code never dispatches them.

Mutation events (map payloads)

[:rf.mutation/execute {…}]

  • Kind: event
  • Payload: {:mutation :params :instance :scope :cause :reply-to :optimistic?}
  • Description: Run a mutation.
    • :instance is the caller-supplied (or generated) instance id all runtime state is keyed by — two concurrent submissions keep distinct rows.
    • On success the runtime patches/populates/removes resource entries then invalidates tags (per :invalidate-timing).
    • :reply-to is an optional data-only completion continuation target dispatched when the write settles.
    • :optimistic? false is the per-call opt-out that forces a registered optimistic plan to run pessimistically.
    • An unregistered :mutation raises :rf.error/mutation-not-registered; params that fail :params-schema raise :rf.error/mutation-invalid-params.
    • A superseded reply (a re-execute under the same instance, or an :rf.mutation/clear) never overwrites a newer instance (work-id + generation suppression).
[:rf.mutation/execute
 {:mutation :article/save
  :params   article
  :instance :form/save-1
  :scope    [:rf.scope/session {:user-id "u-42"}]
  :cause    [:form-submit :article/save]}]

[:rf.mutation/clear {…}]

  • Kind: event
  • Payload: {:instance …} (clear one instance) or {:mutation …} (clear every instance of a mutation id)
  • Description: The causal reset of runtime mutation-instance state. It clears the addressed row(s) and best-effort aborts in-flight work; the work row settles :cancelled. Distinct from (rf/clear :mutation mutation-id), which removes the registration.
;; reset one runtime instance's row (e.g. in a completion continuation)
[:rf.mutation/clear {:instance :form/save-1}]

Mutation subscriptions (passive)

A :rf.mutation/* subscription is a pure passive read keyed by instance id — it never executes a write.

[:rf/mutation    {:instance :form/save-1}]   ;; {:status :result :error :affected-keys
                                                   ;;  :pending? :success? :error? :settled? :optimistic?}
[:rf.mutation/status   {:instance :form/save-1}]
[:rf.mutation/pending? {:instance :form/save-1}]
[:rf.mutation/result   {:instance :form/save-1}]
[:rf.mutation/error    {:instance :form/save-1}]

:optimistic? (derived) is true while a live optimistic apply is showing — applied, not yet settled. :affected-keys carries the scoped resource keys the settle touched.

A failure settles :error — there is no :refresh-error analogue (a write has no last-known-good to keep).

Internal replies — do not dispatch. Two keyword families here are framework-internal, and user code MUST NOT dispatch them: the :rf.mutation.internal/* replies (succeeded / failed), and the :rf.mutation/* trace family — started / succeeded / failed / cleared / replied / stale-suppressed, plus the optimistic rows (optimistic-applied / optimistic-reconciled / optimistic-rolled-back). All carry the instance id.

Reading resource and mutation state

Read resource and mutation state with the ordinary subscribe, naming the framework sub vector. There is no named-read-sugar fn: a runtime-db framework read is a subscription vector, one grammar. subscribe's {:frame <target>} opts form reads from an explicit frame. A sub never fetches.

;; a view reads a resource passively — never fetches
@(rf/subscribe [:rf/resource {:resource :article :params {:slug "hello"}}])
;; => {:status :loading}  …then  {:status :loaded :data {…}}

query is the {:resource :params :scope} map — identical fail-closed scope resolution (:rf.error/resource-sub-unresolved-scope).

;; a form reads its own submission's state, keyed by instance
@(rf/subscribe [:rf/mutation {:instance :form/save-1}])
;; => {:status :idle …}  …then  {:pending? true …}  …then  {:success? true …}

Idle empty-state until the instance's first :rf.mutation/execute.

Cache home

Resource and mutation runtime state lives inside the runtime-db partition (:rf.db/runtime):

  • the resource cache, only at :rf.runtime/resources
  • the frame work ledger, at :rf.runtime/work-ledger
  • mutation instance rows, at :rf.runtime/mutations

All three are reserved runtime-db keys: framework-owned, per-frame isolated, and allocated lazily. App code reads through the subs and accessors and never hand-edits the slice.

Cache entries (durable facts) and work-ledger attempts (in-flight records) are separate. Host handles (AbortControllers, timers, promises) live in side tables and are never serialized. The correctness rule: cancellation is opportunistic; stale-reply suppression (by work-id + generation) is mandatory. See Guide ch.27 §Cache home and the work ledger.

Examples and cross-references