refine.dev integration
refine is a React meta-framework for admin panels and internal tools, built around aDataProvider interface: one small object
with a fixed set of methods (getList, getOne, create, update,
deleteOne, …) that every refine hook and component calls through instead
of talking to your API directly.
Most readers want
@cratestack/refine instead of this guide.
It ships a tested DataProvider for both transports —
createCratestackDataProvider (REST) and createCratestackRpcDataProvider
(RPC) — covering everything below: pagination, the filter-operator
mapping, non-id primary keys, @version optimistic locking, and bulk
operations. Pair it with generate-typescript --refine, which emits the
resource manifest for your schema, and you write no dataProvider code at
all.This guide is the hand-wired version of the same thing. Read it when
you need to understand what the package does under the hood, or when you
want to adapt the approach rather than take the dependency.Using the package
Four variations, in the order you’d reach for them. All four assumecratestack generate-typescript --schema schema.cstack --out ./generated --refine,
which emits generated/src/refine.ts alongside the client.
REST
RPC
Same shape — a different factory, and the generated manifest is typedRpcResourceMap instead of ResourceMap. Consumer code is otherwise
identical, which is deliberate:
@cratestack/cbor codec unless the schema was generated with --no-native-cbor — see The CBOR codec for what that means for a server whose CodecSet doesn’t include CborCodec.
RPC with logging and batching
RPC clients accept alinks chain. createBatchLink collapses calls
fired in the same tick into one POST /rpc/batch — which is what refine’s
getMany does, so a table with relation columns goes from N requests to
one:
Batching never weakens
@version optimistic locking. createBatchLink
partitions queued calls by their full transport envelope — headers
included — so two updates carrying different If-Match values can’t be
merged into one request with one header set. They go out as separate
requests automatically. The upside is on reads: getList/getMany
collapse; version-carrying writes correctly do not.RPC over axios
createAxiosRuntime adapts an axios instance to the fetch signature, so
axios becomes the transport while the links and the generated client keep
speaking Request/Response. Reach for it when you already have an axios
instance carrying interceptors — auth refresh, retries, a corporate proxy
agent:
@cratestack/runtime-fetch’s createFetchRuntime is the drop-in
equivalent when you want the default transport with explicit options —
the two are interchangeable in the fetch slot.
Every method name, type, and request shape below is checked against the
real generated client and server.
This guide covers REST-transport schemas only (generate-typescript’s
default transport). RPC-transport clients expose an equivalent per-model
API (see TypeScript client generation),
but the query-string filter convention this guide’s getList relies on
is REST-specific — an RPC dataProvider needs its own filter-mapping layer
and isn’t covered here.
Prerequisites
Before wiring a resource, check three things against its.cstack model:
Generate the client the usual way:
Widget model (plain CRUD) and a Ledger model
(@@paged, @version) to show both the simple and the crux-of-this-guide
cases:
The shape of the problem
A generated model API class (client.widgets, client.ledgers, …) has
list/get/create/update/delete — see
Using the REST client
for the full generated surface. refine’s DataProvider interface is
shaped similarly but not identically: different method names, a
pagination/filter/sort object instead of a query string, and an id
field it assumes every record has. The dataProvider below is the
adapter layer between the two.
Structural note: each generated model class (WidgetApi, LedgerApi,
…) is its own concrete TypeScript class, not an implementation of a
shared interface the package exports — there’s no generated common type
to write one generic dataProvider function against across every
resource. The ModelApi interface below is hand-written to match the
real generated shape closely enough to type-check against it; loosen or
drop it if your tsconfig is stricter than this guide’s example. This
exact gap — one dataProvider per app instead of one per framework — is
what @cratestack/refine closes generically; this section is what it does
for you.
@@paged, which have @version, what’s the primary key field” — the
generated client carries no such metadata object. Those facts live in the
.cstack schema and in the generated client’s TypeScript types, and
nowhere else at runtime, so ResourceConfig has to be written down
somewhere.
Let the generator write it. Pass --refine to
cratestack generate-typescript and it emits an extra src/refine.ts
alongside the client, holding exactly this map:
--refine is additive: every other generated file is byte-identical with
and without it. It requires a REST schema and the default preset — the
RPC clients don’t share the REST client’s list(options) /
CratestackFetchQuery shape, and the /swr layout emits free functions
rather than a client class for a resource to bind to.
Writing the map by hand stays fully supported — it is a plain object
literal, and it’s what the generated file contains:
@version, or one
whose @id isn’t called id, updates itself on the next
generate-typescript run; the hand-written copy doesn’t, and gets it
wrong silently — a stale versionField means writes stop sending
If-Match and lose optimistic-locking, with no error anywhere.
Pagination
refine’sPagination is { currentPage?: number; pageSize?: number; mode?: "client" | "server" | "off" } as of @refinedev/core v5 (what npm install @refinedev/core gives you today — verified against the
package’s own shipped .d.ts). If your project is still on the v4
major, the same field is named current instead — v5 renamed
current → currentPage; nothing else about this section changes
between the two. cratestack’s list route takes limit/offset and,
only for a @@paged model, returns totalCount alongside the items
(Page<T> { items, totalCount, pageInfo }, mirroring
cratestack_core::page::{Page, PageInfo} — crates/cratestack-client-typescript/templates/src/models.ts.j2). The mapping:
Pagination requirement
A resource’s model must declare@@paged, or refine’s pagination
controls silently lie. totalCount is only ever computed and emitted
for a @@paged model’s list route — the token-generation gate is
literally if !paged { return quote!{}; } for the total-count query
(crates/cratestack-macros/src/axum/model/prep/list_logging.rs).
A non-@@paged model’s list() returns a bare Widget[], no
totalCount at all, and (this is the trap) limit/offset still work
on it — every list route enforces the same MAX_LIST_LIMIT regardless
of @@paged (see Pagination). So a non-paged resource
wired into getList naively will fetch a real page 2, get back 10 rows,
report total: 10 because that’s all you have to count, and refine’s
pagination UI will conclude there’s no page 3 — even though there is
one. Either add @@paged to the model, or configure that refine
resource with pagination: { mode: "off" } and treat the (capped, up
to MAX_LIST_LIMIT) full array as one page. Don’t wire page controls to
a non-@@paged resource and assume total is trustworthy — it isn’t.
Filters
refine’s filter operators map onto the generated list route’sfield__operator=value query convention almost one to one — this is
the same operator set the generated TypeScript client’s shared filter
interfaces expose (EqualityFilter<V> { eq, ne, in, isNull },
ComparableFilter<V> extends EqualityFilter<V> { lt, lte, gt, gte },
StringFilter extends ComparableFilter<string> { contains, startsWith }
— crates/cratestack-client-typescript/templates/src/models.ts.j2),
because both are generated from the same per-field arm table
(crates/cratestack-macros/src/axum/filter_arms.rs::generate_query_filter_arm).
A bare field=value query param means eq; every other operator is
field__<operator>=value:
Caveat that’s easy to miss:
eq/ne/in/lt/lte/gt/gte are
only wired for required (non-nullable) fields. A nullable field
(weight Int?) only ever gets contains/startsWith (if it’s a string)
and isNull — the codegen arm for the comparison/equality operators is
gated on field.ty.arity == TypeArity::Required
(crates/cratestack-macros/src/axum/filter_arms.rs). Filtering a
nullable field by exact value needs a workaround on your side (a
generated column, a NOT NULL companion field) — there’s no server-side
operator for it today.
refine operators with no cratestack equivalent must fail loudly, not
silently drop the filter. endswith, between, nin, containss
(and every other operator/combinator not in the table above, including
refine’s or/and conditional-filter groups — the query convention
here is a flat AND of per-field predicates only) have no server-side
translation. Silently dropping one of these would make the UI show an
unfiltered result set as if it were filtered — worse than an error,
because nothing signals the data is wrong:
contains/startsWith/comparison filtering across a field
combination this convention can’t express — an OR group, a nullable
field’s exact-match filter — cratestack’s typed FindMany<Model>
argument (<Model>Where/<Model>FindMany, the same
EqualityFilter/ComparableFilter/StringFilter shapes as a structured
JSON body instead of query-string suffixes) is a procedure-only
mechanism, not something the plain list() route accepts — see
Search with Filters. Wiring refine to a FindMany<Model>-backed
procedure instead of the plain list route is a legitimate escape hatch,
but it’s a different getList implementation (calling
client.procedures.searchX(...) instead of client.x.list(...)), not
covered further here.
Primary keys
refine assumes every record has anid: BaseKey (string | number).
cratestack’s @id can be on any field, any scalar type. Mapping is
one direction on read, the identity function on write, because the
value space is the same — only the property name differs:
-
Read (cratestack → refine): attach a synthetic
idalongside the real field, so refine’s row-selection/detail-view machinery has something to key off: -
Write (refine → cratestack):
getOne/update/deleteOneall receive refine’s synthesizedid, which is exactly the primary key’s value — pass it straight through as theidargument to.get()/.update()/.delete(), no translation needed:The one place this bites: a<Create>form’s fields must be named after the schema’s real primary-key field (sku, notid) — the syntheticidonly exists on records that already came back from the server; a create payload has no record yet to synthesize it from.
Optimistic locking (the crux)
This is the single most important correctness point in this guide. A@version model requires If-Match on both PATCH (update) and
DELETE — cratestack#519, closed by cratestack#538, which landed the
delete-side enforcement delete_if_match_decl/delete_if_match_apply
deliberately mirroring the update path token-for-token
(crates/cratestack-macros/src/axum/model/prep/etag.rs). Missing or
stale If-Match on either verb returns 412 Precondition Failed and
leaves the row untouched. See Optimistic Locking
for the full contract (ETag/If-Match format is a quoted integer,
e.g. If-Match: "3" — crates/cratestack-axum/src/headers/etag.rs).
refine’s update/deleteOne hooks fetch the record before editing it
(useOne/useShow populate the edit form; a list/detail view is
usually on screen before a delete button is clicked), so the version is
available by the time a mutation fires — it just isn’t part of refine’s
UpdateParams/DeleteOneParams by default. Thread it through a small
version cache the dataProvider maintains itself, populated by every read
and write that returns a fresh record:
update/deleteOne, with a 412 surfaced as a real,
distinguishable conflict rather than a generic failure:
CratestackHttpError (status, response, payload) is generated
into runtime.ts and re-exported from the package root
(crates/cratestack-client-typescript/templates/src/rest-runtime.ts.j2).
412’s response body is the standard CratestackErrorResponse { code: "PRECONDITION_FAILED", message, details } envelope
(crates/cratestack-core/src/error.rs) — checking error.status === 412
rather than pattern-matching payload.code is the more robust test,
since the status is set unconditionally by the runtime’s !response.ok
branch regardless of codec.
create and getOne/getList/getMany should also call
rememberVersion on their own responses, so a versioned resource’s
cache stays populated after every round trip, not just after an update:
getMany
getMany is optional on DataProvider — refine falls back to it only
if you implement it. Because the in operator from the filters
table applies to any required field, including the primary
key, getMany is a single list() call rather than N getOne calls:
create
config.api.create is only present on the generated class in the first
place when the model declares a create policy — see
Policy-denied operations below for the case
where the method exists but a particular caller still can’t use it.
Procedures as custom
A cratestack procedure call has no other home in a DataProvider —
map refine’s custom onto client.procedures.<name>(...), using
meta.procedure to name which one:
payload —
publishPost(args: PublishPostInput) generates PublishPostArgs { args: PublishPostInput }
(TypeScript client generation § Procedures),
so:
Running example
examples/react-vite-refine
is a real refine.dev admin app wired through @cratestack/refine against a generated,
stateful WireMock backend — no database, no hand-written server.
The chain is schema → generate-typescript --refine → generate-wiremock →
createCratestackDataProvider → a live admin UI, with a Post model exercising @@paged +
@version end to end: create, list, update, delete, and a stale If-Match correctly rejected with
412, all against a running container.
It’s a useful reference for the packaged path this guide points to above, not the hand-wired one —
but the wiring pattern (generated manifest in, createCratestackDataProvider out) is identical either
way. Two honest limits worth knowing before treating it as a template:
- No sorting, filtering, or pagination controls.
cratestack-mock-wiremock’s generated stubs ignorefield__operator=value,sort,limit, andoffsetentirely — everylistresponse is the complete, unfiltered collection. The example is built directly on@refinedev/core’s headless hooks withpagination: { mode: "off" }on every list call specifically to avoid rendering controls that would appear to work and silently do nothing. This is a limitation of the mock, not of@cratestack/refineitself — see Generating WireMock stubs for what it does and doesn’t implement, and@cratestack/refine’s own test suite for pagination/filter/sort logic proven against a fake server that actually implements it. createnever honors a client-submitted primary key against the mock — it always fabricates its own id, so a create form’s submitted value is silently discarded and every follow-up call must use the id the mock actually returned. This is specific to testing againstgenerate-wiremock’s stubs; a realcratestack-pgserver honors a client-supplied@id.
Gaps: honest limitations
Three things a refine app might reach for that aren’t wired today — stated plainly rather than hand-waved: NoliveProvider — the generated TypeScript client has no SSE
consumer. The server has a real SSE subscription surface (GET /rpc/subscribe/{op_id},
crates/cratestack-macros/src/include/server/rpc_module/subscribe.rs,
exercised end-to-end by
crates/cratestack-pg/tests/rpc_subscribe_sse.rs), but the TypeScript
client templates contain no EventSource/text/event-stream consumer
anywhere in crates/cratestack-client-typescript/templates/ — a
grep for EventSource/text/event-stream across that whole directory
comes back empty. refine’s liveProvider (real-time list/detail
updates via a subscription) is not wireable today against a
generated TypeScript client. Poll instead (refetchInterval on the
relevant query), or hand-roll an EventSource against the RPC
subscribe endpoint yourself if a schema uses transport rpc.
Bulk operations aren’t exposed on the generated client.
update_many/delete_many exist server-side
(crates/cratestack-sqlx/src/delegate/model.rs), and POST /rpc/batch
exists for RPC-transport schemas, but the REST client’s per-model class
only ever generates list/get/create/update/delete — no
updateMany/deleteMany wrapper, and the RPC dispatch table’s op verbs
are hardcoded to ["list", "get", "create", "update", "delete"]
(crates/cratestack-macros/src/transport/rpc.rs). refine’s
createMany/updateMany/deleteMany are optional on DataProvider —
leave them unimplemented (refine falls back to sequential single-record
calls in the hooks that need them, at the cost of N round trips instead
of one, and no cross-record atomicity), or implement them yourself as
Promise.all(...) over the single-record methods with the same caveat.
Policy-denied operations still generate a working-looking client
method. Route suppression — hiding a client method entirely when a
caller’s policy can never satisfy it — is a designed-but-unimplemented
feature: docs/design/route-suppression.md is explicit that
cratestack#514 is a spike only (“Not implemented. No implementation
may merge under #514”). What actually gates a generated create()
method’s presence is only whether the model declares any
@@allow("create", ...) at all
(crates/cratestack-client-typescript/src/types.rs::model_allows_create)
— not whether the predicate can ever evaluate true for the caller in
question. A model with @@allow("create", auth().role == "admin") still
gets a .create() method on the generated client for every caller, and
a refine <Create> button wired to that resource will render for a
non-admin user and fail with 403 the moment they submit. Until route
suppression ships, declare such resources with create: false/edit: false/delete: false (whichever verbs your policy actually denies) in
refine’s resources config for the caller roles that can’t use them —
don’t rely on the generated client’s method presence as a proxy for
“the current caller is allowed to do this.”
Full example
Everything above assembled into onedataProvider:
getList, getOne, getMany, create, update, deleteOne,
custom, withRefineId, toQueryFilters, toSortQuery,
rememberVersion, ifMatchHeaders, toRefineError are the functions
defined section by section above — assemble them into one module in
that order.)
See also
@cratestack/refine— the packaged version of this entire guide, for both REST and RPC schemas- TypeScript client generation — the generated client surface this guide adapts, including
--refine - Optimistic Locking — the full
@version/If-Match/ETagcontract - Pagination —
@@paged,Page<T>,MAX_LIST_LIMIT - Search with Filters —
FindMany<Model>— the typed, procedure-only filter argument for cases the query-string convention can’t express - RPC transport — if your schema uses
transport rpcinstead of REST - Generating WireMock stubs — the mock backend
examples/react-vite-refineruns against, including what it does and doesn’t cover