@cratestack/refine
v0.15.4
Published
A refine.dev DataProvider over CrateStack's generated TypeScript client (REST or RPC) — the safe end-user admin-UI surface that goes through the generated API (policy, validation, @version concurrency, audit), unlike cratestack-studio's direct-to-database
Maintainers
Readme
@cratestack/refine
A refine.dev DataProvider over CrateStack's generated TypeScript client —
REST (createCratestackDataProvider) or RPC (createCratestackRpcDataProvider, for
transport rpc schemas). Wires refine's fixed getList/getOne/getMany/create/update/
deleteOne surface to a generated model class (client.widgets, client.ledgers, …), mapping
refine's filter operators, pagination, primary keys, and @version optimistic-locking conflicts
onto the server's real contract — the same one cratestack-studio
deliberately bypasses. cratestack-studio talks to [target.db] directly and skips @@allow; a
refine app built on this package goes through the generated API and inherits policy, validation,
@version concurrency, and audit. That's the reason this package exists (cratestack#571): refine
is the safe end-user admin surface, Studio is the sysadmin one.
Both transports share this same README except where a section says otherwise — the two providers'
getList/getOne/getMany/create/update/deleteOne/createMany/updateMany/deleteMany/
custom behavior, filter-operator table, pagination semantics, primary-key handling, and @version
optimistic-locking guarantee are identical; only the wire calls underneath differ. See
RPC transport below for what's RPC-specific, and Scope for what neither
provider does.
A runtime package with a generated manifest
A DataProvider needs four things per resource — the generated API object itself, the model's
primary-key field name, whether it declares @@paged, and whether/where it declares @version.
None of them has a runtime discovery path: the generated client carries no $meta object and no
introspection endpoint, nothing beyond what its TypeScript types already encode at compile time
(confirmed against the real generated client.ts/models.ts this package's own tests drive — see
tests/). So the provider itself has to be a hand-written runtime function; there is
nothing schema-shaped in its logic to generate.
The manifest is a different question, and cratestack#571 settled it the other way: those four
facts all live in the .cstack schema, so the generator emits them. Pass --refine to
cratestack generate-typescript and it writes an extra src/refine.ts next to the client:
cratestack generate-typescript \
--schema schema.cstack \
--out ./generated \
--refine// generated/src/refine.ts — generated, do not edit
export function cratestackRefineResources(client: ExampleApiClientClient): ResourceMap { … }Writing that manifest by hand is still perfectly supported (it is a plain object literal, and every
example below shows one), but the generated one cannot drift: a model that gains @version, or one
whose @id is not called id, updates itself on the next generate-typescript run instead of
failing quietly at runtime.
--refine works for both REST and RPC schemas. The emitted function is the same shape either
way — cratestackRefineResources(client) — typed ResourceMap for REST and RpcResourceMap for
RPC, so consumer code is identical across transports. It also composes freely with
--swr (the file-per-model + hooks layout, <package-name>/swr): the manifest binds to the
always-emitted default layout's client class regardless of that flag. See Scope.
Usage (REST)
With the generated manifest:
import { createCratestackDataProvider } from "@cratestack/refine";
import { ExampleApiClientClient } from "./generated/src/client.js";
import { cratestackRefineResources } from "./generated/src/refine.js";
const client = new ExampleApiClientClient("https://api.example.com", { basePath: "/api" });
const dataProvider = createCratestackDataProvider(cratestackRefineResources(client));Or writing the manifest yourself — identical shape, and what the generated file contains:
const dataProvider = createCratestackDataProvider({
widgets: { api: client.widgets, primaryKey: "id", paged: false },
ledgers: { api: client.ledgers, primaryKey: "id", paged: true, versionField: "version" },
products: { api: client.products, primaryKey: "sku", paged: false },
});Pass this straight to refine's <Refine dataProvider={dataProvider} .../>.
RPC transport
For a transport rpc schema, use createCratestackRpcDataProvider and an RpcResourceMap instead
— same four facts per resource (api, primaryKey, paged, optional versionField), same
getList/getOne/.../custom behavior, same filter-operator table and @version guarantee as
REST. There is no generated manifest for RPC yet (--refine rejects RPC schemas — see above), so
write it by hand:
import { createCratestackRpcDataProvider } from "@cratestack/refine";
import { ExampleApiClientClient } from "./generated/src/client.js";
const client = new ExampleApiClientClient("https://api.example.com", { basePath: "/api" });
const dataProvider = createCratestackRpcDataProvider({
widgets: { api: client.widgets, primaryKey: "id", paged: false },
ledgers: { api: client.ledgers, primaryKey: "id", paged: true, versionField: "version" },
products: { api: client.products, primaryKey: "sku", paged: false },
});What's different from REST, mechanically:
- The generated RPC model class's
listtakes its query positionally (list(query, options)) instead of nested in an options object the way REST'slist({ query, headers, signal })does —createCratestackRpcDataProviderbuilds anRpcListQuery(RPC's typed list-input shape) instead of REST'sCratestackFetchQuery.get/create/update/deleteare positionally identical between the two transports; only their options type differs (CratestackRpcCallOptionsvsCratestackRequestConfig). - Filters compile to
RpcListPredicate[]({ key, value }pairs) instead of REST'sRecord<string,string>— samefield[__operator]key convention either way (see Filters below), just a different container shape on the wire. - Sort compiles to a single comma-joined string (
"-createdAt,id") instead of REST'sstring[]the runtime joins client-side — samefield/-fieldDSL. - Every unary call carries
If-Matchexactly like REST does. The RPC dispatch arms (crates/cratestack-macros/src/transport/rpc.rs) pass the real HTTPHeaderMapstraight through to the identicalhandle_update_*_dispatch/handle_delete_*_dispatchfns REST uses (crates/cratestack-macros/src/axum/model/handlers_update.rs), which readIf-Matchviaparse_if_match_version— so a stale or missingIf-Matchagainst a@versionmodel returns the same412 Precondition Failedon both transports, verified against a real generated RPC client in this package's own test suite (tests/rpc-optimistic-locking.test.ts). createCratestackRpcDataProvidernever callsPOST /rpc/batch. A batch request is one HTTP request carrying N frames, so a per-frameIf-Matchheader isn't expressible there —createMany/updateMany/deleteManyare N real unary round trips instead, same non-atomic strategy REST uses (see createMany / updateMany / deleteMany below). A batched RPC data provider is possible in principle but out of scope here — see Scope.
Filters
Applies to both providers. refine's CrudFilters ({ field, operator, value }) map onto the same
field[__operator] key convention on both transports — REST's field__operator=value query params
(toQueryFilters) and RPC's RpcListPredicate[] (toRpcQueryFilters) carry an identical key,
just in different container shapes on the wire. Same operator set the generated client's shared
filter types (EqualityFilter/ComparableFilter/StringFilter) expose:
| refine operator | cratestack query key |
| --- | --- |
| eq | field (no suffix) |
| ne | field__ne |
| in | field__in (comma-separated) |
| lt / lte / gt / gte | field__lt / __lte / __gt / __gte |
| contains | field__contains |
| startswith | field__startsWith |
| null | field__isNull=true |
| nnull | field__isNull=false |
Every other refine operator throws — endswith, between, nin, containss, refine's
or/and filter groups, and anything else not in the table above. Dropping an unsupported filter
silently would show the wrong data as if it had been filtered; an error is strictly better. Note
also that eq/ne/in/comparison operators only work against required (non-nullable) fields
— that's a server-side codegen gate
(crates/cratestack-macros/src/axum/filter_arms.rs::generate_query_filter_arm), not something this
package can work around.
Pagination
Applies to both providers. refine's { current, pageSize } maps to limit/offset directly.
totalCount is only ever
emitted for a @@paged model's list route. Set paged: false in a resource's config honestly —
a non-@@paged resource's getList still returns real data, but total degrades to
"how many rows this one response returned" rather than a true count. Either add @@paged to the
model, or configure refine's pagination: { mode: "off" } for that resource and treat the result as
one (capped) page.
Primary keys
Applies to both providers. refine assumes every record has id: BaseKey. cratestack's @id can be on any field
(primaryKey in the resource config above). Every returned record gets a synthetic id field
alongside its real primary key so refine's row-selection machinery has something to key off;
writes (get/update/deleteOne) receive that same value back and pass it straight through as
the real primary key — no translation needed, since the value space is identical. The one place
this bites: a <Create> form's fields must use the schema's real primary-key field name (e.g.
sku), not id — a create payload has no record yet to synthesize an id from.
Optimistic locking (@version / If-Match)
The single most important correctness point in this package, and it holds on both transports.
A @version model requires If-Match on both update and delete (cratestack#493/#519/#538) —
missing or stale If-Match returns 412 Precondition Failed and leaves the row untouched, whether
the request went out as an RPC unary call or a REST PATCH/DELETE (see
RPC transport for why the RPC dispatch path enforces this identically to REST).
refine's update/deleteOne hooks fetch the record before editing it, so the version is known by
the time a mutation fires; each provider remembers it (keyed per createCratestackDataProvider/
createCratestackRpcDataProvider call, not module-global) from every read/write that returns a
fresh record, and sends it automatically. A 412 is surfaced as a
distinguishable conflict (statusCode: 412, a human-readable message) rather than a generic
failure — check error.statusCode === 412 in an onError handler to show a "someone else changed
this" message instead of a generic one.
Pass meta: { ifMatch: <version> } to update/deleteOne to override the cache explicitly (e.g.
optimistic-lock-aware bulk workflows). Calling update/deleteOne on a @version resource with no
known version and no override throws rather than silently omitting If-Match — omitting it doesn't
fail on the wire the way a stale value does, so this package refuses to make that mistake for you.
Errors
Every method converts the same way, and conversion never destroys what was thrown. All of
getList, getMany, getOne, create, update, deleteOne and custom route a thrown value
through toRefineError (exported, so you can reuse it) — there is no method that rethrows raw and
no method whose errors reach refine without statusCode (cratestack#786; before that fix the two
list methods and the four single-record methods disagreed, and which one you hit depended on which
screen the user was on).
Conversion annotates the thrown value in place rather than replacing it. If your transport
throws a typed error, the value your onError handler receives is that error:
class DeviceNotEnrolledError extends Error {}
// works identically on list screens and on detail/create/edit screens
if (error instanceof DeviceNotEnrolledError) { … }message and statusCode are set on it, which is what refine renders — so a CratestackHttpError
gets its envelope's payload.message promoted, and a 412 gets the optimistic-locking message
above, while status/payload/response stay readable on the same object. When the thrown value
cannot be annotated (a thrown primitive, a frozen object), you get { message, statusCode, cause }
with the original under cause.
Do not classify errors by string-matching message — it is the one field conversion is allowed to
rewrite.
createMany / updateMany / deleteMany
Implemented, not declined, on both providers — but as N sequential single-record round trips
(Promise.all over create/update/deleteOne), not a real batch. The generated REST client
exposes no updateMany/deleteMany wrapper around the server's actual update_many/
delete_many. The RPC transport does have a real batch endpoint (POST /rpc/batch), but
createCratestackRpcDataProvider deliberately doesn't use it here either — see
RPC transport for why (per-frame If-Match isn't expressible in a batch request).
So these three methods work on both transports, but without atomicity and at the cost of N requests
instead of one.
Procedures (custom)
A cratestack procedure has no other home in a DataProvider. Pass a procedures map to
createCratestackDataProvider's (or createCratestackRpcDataProvider's) second argument and call
it through refine's custom — identical shape on both providers:
const dataProvider = createCratestackDataProvider(resources, {
procedures: { publishPost: (args) => client.procedures.publishPost(args as never) },
});
await dataProvider.custom!({
url: "",
method: "post",
meta: { procedure: "publishPost" },
payload: { args: { postId: 1 } },
});Scope
Out of scope, tracked as follow-ups by cratestack#571 itself:
liveProvider— the generated TypeScript client has no SSE consumer today (noEventSourceanywhere incrates/cratestack-client-typescript/templates/), even though the server has a real subscribe endpoint. Needs a TS SSE client first. Applies to both transports.authProvider— a separate, orthogonal concern from data access. Applies to both transports.- Batched RPC writes —
createCratestackRpcDataProvidernever callsPOST /rpc/batch, even though the RPC transport has one. A per-frameIf-Matchheader isn't expressible in a single batch request, and this package's@versionguarantee is not something it will silently weaken to get atomicity — see RPC transport. - RPC's
where/orfilter-expression DSL, andfields/include/includeFieldsprojection —CratestackRpcListQuerysupports all of these on the wire, but refine'sCrudFilters/Sortshapes have nothing that maps onto them (refine has no "raw server filter expression" or "relation projection" concept), so this provider never writes to them. Available to a caller who wants to reach past refine's abstraction: callconfig.api.list({ where: "..." })directly.
A known, pre-existing gap this package inherits rather than causes: route suppression
(cratestack#514) isn't implemented, so a policy-denied create/update/delete still generates a
working-looking client method — config.api.create being present only proves the model declares
some @@allow("create", ...), not that the current caller satisfies it. A refine <Create>
button wired to such a resource renders and then 403s on submit for a caller who can't actually
create one. Until #514 ships, gate that in refine's own resources config
(create: false/edit: false/delete: false) per caller role, not on this package's method
presence.
See guides/refine-integration.md in
cratestack-docs for the hand-wired version of
everything this package automates, including the full reasoning behind each design choice above.
