Data Providers

The single adapter every component fetches and mutates through.

A data provider is the one object that stands between Chelone and your API. Every list, field, form, and mutation hook speaks to it — never directly to the network — so swapping REST for GraphQL for Supabase is a one-line change at the root. The provider is just an object with nine async methods.

The contract

The types below are the ones in @chelone/core, trimmed to the shape. Every method is generic in the resource name, so a typed registry checks resource and types the records in the result.

type Pagination =
  | { page: number; perPage: number }
  | { cursor: string | null; limit: number };
 
type SortPayload = { field: string; order: "ASC" | "DESC" };
 
interface PageInfo {
  nextCursor?: string | null;
  prevCursor?: string | null;
  hasNextPage?: boolean;
  hasPreviousPage?: boolean;
}
 
interface GetListParams {
  pagination?: Pagination;
  sort?: SortPayload | SortPayload[]; // an array sorts by each key in order
  filter?: Record<string, unknown>;
  meta?: unknown;
}
 
interface GetListResult {
  data: Record[];
  total?: number; // optional
  pageInfo?: PageInfo;
}
 
interface DataProvider {
  getList(resource, params: GetListParams): Promise<GetListResult>;
  getOne(resource, params: { id; meta? }): Promise<{ data }>;
  getMany(resource, params: { ids; meta? }): Promise<{ data: Record[] }>;
  getManyReference(
    resource,
    params: {
      target: string; // the foreign-key field on `resource`
      id: Identifier; // the value `target` must equal
      pagination?: Pagination;
      sort?: SortPayload | SortPayload[];
      filter?: Record<string, unknown>;
      meta?: unknown;
    },
  ): Promise<GetListResult>;
  create(resource, params: { data; meta? }): Promise<{ data }>;
  update(resource, params: { id; data; previousData?; meta? }): Promise<{ data }>;
  updateMany(resource, params: { ids; data; meta? }): Promise<{ data: Identifier[] }>;
  delete(resource, params: { id; previousData?; meta? }): Promise<{ data }>;
  deleteMany(resource, params: { ids; meta? }): Promise<{ data: Identifier[] }>;
}
  • pagination is { page, perPage } for an offset backend or { cursor, limit } for a keyset one. The list views and useInfiniteGetList always page by number, so a provider behind them has to accept { page, perPage }. A cursor reaches the provider only from your own getList call.
  • sort is one { field, order } or an array of them, the first being the primary key.
  • meta is yours: Chelone does not read it, so it can carry anything the backend needs that has no param of its own.
  • updateMany and deleteMany resolve with the ids they touched, not records.
  • getOne, create, update and delete resolve one record in data; getList, getMany and getManyReference resolve an array.

Optional total

total is the count of every match, not of the page. A backend that cannot count, as a cursor API often cannot, leaves it out. Pagination then adapts:

  • ListPagination shows the range (1-25) without of N.
  • Whether a next page exists comes from pageInfo.hasNextPage and pageInfo.hasPreviousPage when the result carries pageInfo. With neither pageInfo nor total, the list controller cannot tell, and hasNextPage stays undefined.
  • useInfiniteGetList asks for the next page while pageInfo.hasNextPage is true. With no pageInfo it compares the rows loaded so far to total, and with no total either it stops at the first page that comes back shorter than perPage or empty.
  • ReferenceManyCount reads total and renders nothing when it is missing.

The in-memory provider

fakeDataProvider implements the full contract over an in-memory seed — no server. It's how the demo and every live preview on this site run, and it's the fastest way to prototype:

import { fakeDataProvider } from "@chelone/core";
 
const dataProvider = fakeDataProvider({
  posts: [{ id: 1, title: "Hello", author_id: 1 }],
  authors: [{ id: 1, name: "Ada Lovelace" }],
});

It copies the seed, so the arrays you pass in are never touched, and it mutates its own copy: a record you create, update, or delete stays changed for the life of the provider. Filtering is exact equality on each key (===) and sorting accepts one key or an array. A getList without pagination returns every row. A { page, perPage } call returns total; a { cursor, limit } call also returns pageInfo, with the cursor as a stringified offset. A full CRUD flow works with zero backend.

Writing your own

Any object that satisfies the interface is a valid provider. fetchUtils is a thin layer over fetch for the REST case: fetchJson sends the request and parses the body, and queryParameters builds the query string.

import { fetchUtils } from "@chelone/core";
import type { DataProvider } from "@chelone/core";
 
const { fetchJson, queryParameters } = fetchUtils;
 
// Each verb is generic in the resource name, so TypeScript cannot match a
// parsed body to the record type: `as never` hands that check to your backend.
export const dataProvider: DataProvider = {
  async getList(resource, { pagination, sort, filter }) {
    const query = queryParameters({ ...pagination, sort, filter });
    const { json, headers } = await fetchJson(`/api/${resource}?${query}`);
    if (!Array.isArray(json)) throw new Error(`Expected a list from ${resource}`);
    // `total` is optional: leave it out when the backend sends no count.
    const count = headers.get("X-Total");
    return {
      data: json as never,
      total: count === null ? undefined : Number(count),
    };
  },
  async getOne(resource, { id }) {
    const { json } = await fetchJson(`/api/${resource}/${id}`);
    return { data: json as never };
  },
  // …create / update / delete / getMany / …
};

fetchJson(url, options) takes the options of fetch plus a token. It sets these headers unless you already did:

  • Accept: application/json, always.
  • Content-Type: application/json, when body is a string.

A non-empty token is sent as Authorization: Bearer <token> and wins over an Authorization header in options.headers; an empty token is ignored. It resolves { status, statusText, headers, body, json } on a 2xx, where body is the response text and json is the parsed body, or undefined when the text is not JSON. queryParameters skips undefined and null, repeats the key for an array, writes a Date as an ISO string, and JSON-encodes any other object.

HttpError

fetchJson rejects a non-2xx response with an HttpError. It carries the status and the parsed response body, so the auth provider can tell an expired session from any other failure. Rejecting from checkError logs the user out, so reject on a 401 or 403:

import { HttpError } from "@chelone/core";
import type { AuthProvider } from "@chelone/core";
 
export const authProvider: Pick<AuthProvider, "checkError"> = {
  async checkError(error) {
    if (
      error instanceof HttpError &&
      (error.status === 401 || error.status === 403)
    ) {
      throw { message: "chelone.notification.logged_out" };
    }
  },
};

The rejection value is read for hints, so it does not have to be the original error. Any object works, and so does a fresh Error, but an Error always carries a message, so its text becomes the notification. To get the default notice, reject with an object that has no message key. These keys are read:

  • redirectTo: where to send the user. Without it, a logout goes to the login page. A value starting with http suppresses the notification.
  • logoutUser: false keeps the session and shows a "not authorized" notice, and still redirects when redirectTo is set. It defaults to true.
  • message: the notification text or a translation key. false shows no notification. With no message key, the notice is chelone.notification.logged_out on a logout and chelone.notification.not_authorized otherwise.
throw { redirectTo: "/sign-in", message: "Your session timed out" };
throw { logoutUser: false, message: false };
throw new Error("Unauthorized"); // notifies "Unauthorized", not the default

Resolving from checkError leaves an ordinary data error alone.

A network failure is not an HttpError: fetchJson rejects it with the error fetch threw.

Server-side validation

Reject a create or update with a ValidationError and the form shows the messages next to the fields instead of a generic error notification. The constructor takes a message and a fieldErrors object that maps a field's source path to one message or an array of them.

import { fetchUtils, HttpError, ValidationError } from "@chelone/core";
import type { DataProvider } from "@chelone/core";
 
const { fetchJson } = fetchUtils;
 
export const dataProvider: Pick<DataProvider, "create"> = {
  async create(resource, { data }) {
    try {
      const { json } = await fetchJson(`/api/${resource}`, {
        method: "POST",
        body: JSON.stringify(data),
      });
      return { data: json as never };
    } catch (error) {
      if (error instanceof HttpError && error.status === 422) {
        const { errors } = error.body as {
          errors: Record<string, string | string[]>;
        };
        throw new ValidationError("Validation failed", errors);
      }
      throw error;
    }
  },
};

The record controllers resolve save with the fieldErrors on a ValidationError and raise no error notification. Any other rejection notifies.

Reverse relations

getManyReference fetches the records of resource whose target field equals id: the "many" side of a one-to-many. target is the foreign key on the records you fetch, so getManyReference("posts", { target: "author_id", id: 1 }) returns the posts of author 1. It takes the same pagination, sort, filter and meta as getList and returns the same { data, total?, pageInfo? }.

async getManyReference(resource, { target, id, pagination, sort, filter }) {
  const query = queryParameters({
    ...pagination,
    sort,
    filter: { ...filter, [target]: id },
  });
  const { json, headers } = await fetchJson(`/api/${resource}?${query}`);
  const count = headers.get("X-Total");
  return {
    data: json as never,
    total: count === null ? undefined : Number(count),
  };
},

Three fields read through it:

  • ReferenceManyField shows the matching rows, one server page at a time, with sort and filter forwarded.
  • ReferenceManyCount shows the total of a one-row page, so a cell can show a count while the provider sends a single row.
  • ReferenceOneField takes the first match, for a one-to-one relation.

All three take reference (the resource) and target (the foreign key), plus an optional source, the field on the current record that target matches. It defaults to id. Outside a field, useGetManyReference makes the same call.

Combining providers

When resources live in different backends, combineDataProviders takes a matcher that picks the provider for each resource. The matcher runs on every call, and a resource it returns nothing for rejects with an error.

import { combineDataProviders } from "@chelone/core";
import { catalogProvider } from "./catalogProvider";
import { billingProvider } from "./billingProvider";
 
export const dataProvider = combineDataProviders((resource) => {
  switch (resource) {
    case "invoices":
    case "payments":
      return billingProvider;
    default:
      return catalogProvider;
  }
});

Lifecycle callbacks

withLifecycleCallbacks wraps a provider with before and after hooks for any of the nine verbs, such as beforeCreate or afterGetOne. Each handler names a resource, or "*" for every resource. Handlers run in array order, the before chain feeds the provider, and the after chain feeds the caller. Every callback must return the value, since undefined does not mean unchanged. Each callback also receives the wrapped provider as its last argument; calling it does not re-enter the callbacks.

import { withLifecycleCallbacks } from "@chelone/core";
import { baseProvider } from "./baseProvider";
 
export const dataProvider = withLifecycleCallbacks(baseProvider, [
  {
    resource: "posts",
    beforeCreate: (params) => ({
      ...params,
      data: { ...params.data, createdAt: new Date().toISOString() },
    }),
    afterGetOne: (result) => ({
      ...result,
      data: {
        ...result.data,
        title:
          typeof result.data.title === "string"
            ? result.data.title.trim()
            : result.data.title,
      },
    }),
  },
]);

Using the provider

You rarely call the provider by hand — the views and the mutation hooks do it — but useDataProvider returns it when you need a one-off call:

import { useDataProvider } from "@chelone/react";
 
const dataProvider = useDataProvider();
await dataProvider.getOne("posts", { id: 1 });

Tips

  • Return { data, total } from getList when the backend can count: total drives the page numbers. Without it, send pageInfo so the next and previous buttons still work.
  • Errors are thrown/rejected promises; the query cache surfaces them to the view, and checkError on the auth provider can react to 401/403.
  • Reference fields share one getMany per resource: they read through useGetManyAggregate, which batches reads started in the same tick into a single call, so a list of fifty posts asks for its authors once.
  • useGetManyReference(resource, params) reads getManyReference and useInfiniteGetList(resource, params) reads getList page by page, with fetchNextPage appending the next one. Its params.pagination must be { page, perPage }.