Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rename-controller-to-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fetchium": minor
---

Rename *Controller to *Adapter across the entire API surface. `QueryController`, `RESTQueryController`, and `TopicQueryController` are now `QueryAdapter`, `RESTQueryAdapter`, and `TopicQueryAdapter`. The `static controller` property on Query/Mutation classes is now `static adapter`, and the `controllers` option on `QueryClient` is now `adapters`.
80 changes: 40 additions & 40 deletions docs/src/app/api/fetchium/page.md

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions docs/src/app/core/queries/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,19 +451,19 @@ For a more in depth guide to query configuration, see the [REST Queries referenc

## Custom Queries

`RESTQuery` is an adapter for JSON REST APIs. But queries as a concept are protocol-agnostic. When your use case doesn't fit REST --- GraphQL, gRPC, WebSockets, local databases, or any other data source --- you build a **`QueryController`** that handles the transport, and a **`Query`** subclass that stays purely declarative.
`RESTQuery` is an adapter for JSON REST APIs. But queries as a concept are protocol-agnostic. When your use case doesn't fit REST --- GraphQL, gRPC, WebSockets, local databases, or any other data source --- you build a **`QueryAdapter`** that handles the transport, and a **`Query`** subclass that stays purely declarative.

The split follows the same logic as the rest of Fetchium: the _definition_ (params, result, identity) lives on the `Query` class; the _transport_ (how to actually fetch data) lives on the controller.
The split follows the same logic as the rest of Fetchium: the _definition_ (params, result, identity) lives on the `Query` class; the _transport_ (how to actually fetch data) lives on the adapter.

### Defining a controller
### Defining an adapter

A `QueryController` handles sending requests on behalf of queries that declare it. Extend `QueryController` and implement `send(ctx, signal)`:
A `QueryAdapter` handles sending requests on behalf of queries that declare it. Extend `QueryAdapter` and implement `send(ctx, signal)`:

```ts
import { QueryController } from 'fetchium';
import { QueryAdapter } from 'fetchium';
import type { Query } from 'fetchium';

class DBQueryController extends QueryController {
class DBQueryAdapter extends QueryAdapter {
async send(ctx: Query, signal: AbortSignal): Promise<unknown> {
const q = ctx as DBQuery;
const db = await openDatabase();
Expand All @@ -478,24 +478,24 @@ Inside `send()`:
- **`signal`** --- an `AbortSignal` for cancellation, passed automatically by the query lifecycle
- **`this.queryClient`** --- the registered `QueryClient`; call `this.queryClient.getContext()` to access `log` and any other context properties you passed at setup

Register the controller when creating the `QueryClient`:
Register the adapter when creating the `QueryClient`:

```ts
new QueryClient({
store,
controllers: [new DBQueryController()],
adapters: [new DBQueryAdapter()],
});
```

### Defining the query class

The query class is purely declarative. It declares `static controller` to point at the controller, defines `params`, `result`, and `getIdentityKey()`, and can include any additional fields your controller reads:
The query class is purely declarative. It declares `static adapter` to point at the adapter, defines `params`, `result`, and `getIdentityKey()`, and can include any additional fields your adapter reads:

```ts
import { Query, t } from 'fetchium';

abstract class DBQuery extends Query {
static override controller = DBQueryController;
static override adapter = DBQueryAdapter;

abstract collection: string;
abstract id: unknown;
Expand All @@ -520,10 +520,10 @@ class GetUser extends DBQuery {
Here is a more complete example --- a GraphQL adapter:

```ts
import { QueryController, Query, t } from 'fetchium';
import { QueryAdapter, Query, t } from 'fetchium';

// Controller: owns the transport
class GraphQLController extends QueryController {
// Adapter: owns the transport
class GraphQLAdapter extends QueryAdapter {
async send(ctx: Query, signal: AbortSignal): Promise<unknown> {
const q = ctx as GraphQLQuery;
const { log } = this.queryClient!.getContext();
Expand Down Expand Up @@ -551,7 +551,7 @@ class GraphQLController extends QueryController {

// Base query class: purely declarative
abstract class GraphQLQuery extends Query {
static override controller = GraphQLController;
static override adapter = GraphQLAdapter;

abstract query: string;
abstract variables?: Record<string, unknown>;
Expand All @@ -575,7 +575,7 @@ class GetUser extends GraphQLQuery {
}
```

Custom queries participate in all the same systems as `RESTQuery` --- caching, entity normalization, live data, refetching, and pagination (via `sendNext()` and `hasNext()` on the controller). The `Query` base class provides the full reactive lifecycle; your controller only needs to implement the transport.
Custom queries participate in all the same systems as `RESTQuery` --- caching, entity normalization, live data, refetching, and pagination (via `sendNext()` and `hasNext()` on the adapter). The `Query` base class provides the full reactive lifecycle; your adapter only needs to implement the transport.

{% callout title="The identity key" type="note" %}
`getIdentityKey()` returns a value that uniquely identifies this query's _definition_. Two query instances with the same identity key and the same params share the same cache entry and are deduplicated. For `RESTQuery`, the default is `${method}:${path}`. For custom adapters, choose a key that captures all the inputs that make a query unique.
Expand Down
36 changes: 18 additions & 18 deletions docs/src/app/core/streaming/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class GetPrices extends RESTQuery {
The `subscribe` function receives an `onEvent` callback that accepts `MutationEvent` objects and returns a cleanup function. Fetchium calls `subscribe` when the query activates (a component reads it) and calls the cleanup function when the query deactivates (all observers disconnect).

{% callout %}
The `subscribe` config is a low-level building block. For polling, use the built-in `poll()` helper. For topic-based streaming (WebSocket message buses, SSE, pub/sub), use [TopicQuery](#topic-queries) --- which provides a declarative, controller-based approach.
The `subscribe` config is a low-level building block. For polling, use the built-in `poll()` helper. For topic-based streaming (WebSocket message buses, SSE, pub/sub), use [TopicQuery](#topic-queries) --- which provides a declarative, adapter-based approach.
{% /callout %}

### Polling
Expand Down Expand Up @@ -129,7 +129,7 @@ Both mechanisms feed into the same entity event system, so you can mix and match

## Topic Queries

For applications with a centralized message bus --- a single WebSocket connection, an SSE endpoint, a pub/sub system --- `TopicQuery` provides a declarative adapter. Instead of manually wiring `subscribe` callbacks per query, you define _topics_ and let a controller manage the connection lifecycle.
For applications with a centralized message bus --- a single WebSocket connection, an SSE endpoint, a pub/sub system --- `TopicQuery` provides a declarative adapter. Instead of manually wiring `subscribe` callbacks per query, you define _topics_ and let an adapter manage the connection lifecycle.

### Defining a topic query

Expand Down Expand Up @@ -164,14 +164,14 @@ class GetBalances extends MyTopicQuery {

The identity key for a topic query is `topic:${topic}` --- two queries with the same topic and params share the same cache entry and are deduplicated.

### Implementing a controller
### Implementing an adapter

The `TopicQueryController` is the bridge between your message bus and Fetchium. Extend it and implement two abstract methods:
The `TopicQueryAdapter` is the bridge between your message bus and Fetchium. Extend it and implement two abstract methods:

```tsx
import { TopicQueryController } from 'fetchium/topic';
import { TopicQueryAdapter } from 'fetchium/topic';

class MyStreamController extends TopicQueryController {
class MyStreamAdapter extends TopicQueryAdapter {
private ws: WebSocket;

constructor(url: string) {
Expand Down Expand Up @@ -205,7 +205,7 @@ class MyStreamController extends TopicQueryController {
}
```

The controller has several protected helper methods:
The adapter has several protected helper methods:

| Method | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------ |
Expand All @@ -215,33 +215,33 @@ The controller has several protected helper methods:
| `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. |
| `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. |

### Registering the controller
### Registering the adapter

Pass the controller to `QueryClient` in the `controllers` array, the same way you register a `RESTQueryController`:
Pass the adapter to `QueryClient` in the `adapters` array, the same way you register a `RESTQueryAdapter`:

```tsx
import { QueryClient } from 'fetchium';
import { RESTQueryController } from 'fetchium/rest';
import { RESTQueryAdapter } from 'fetchium/rest';

const queryClient = new QueryClient({
controllers: [
new RESTQueryController({ baseUrl: '/api' }),
new MyStreamController('ws://api.example.com/stream'),
adapters: [
new RESTQueryAdapter({ baseUrl: '/api' }),
new MyStreamAdapter('ws://api.example.com/stream'),
],
});
```

Then make your topic query classes reference the controller:
Then make your topic query classes reference the adapter:

```tsx
abstract class MyTopicQuery extends TopicQuery {
static override controller = MyStreamController;
static override adapter = MyStreamAdapter;
}
```

### Pre-fulfillment

A powerful feature of the controller is that `fulfillTopic` can be called _before_ the query activates. If your message bus proactively sends data for topics it knows the page will need, the controller can buffer that data:
A powerful feature of the adapter is that `fulfillTopic` can be called _before_ the query activates. If your message bus proactively sends data for topics it knows the page will need, the adapter can buffer that data:

```tsx
// Data arrives from the stream before any component subscribes
Expand All @@ -257,8 +257,8 @@ This enables smart pre-fetching strategies where the server pushes data ahead of

The full lifecycle of a topic query:

1. **Component reads the query** --- Fetchium calls `send()` on the controller, which creates a deferred promise and calls your `subscribe(topic)` implementation.
2. **Controller subscribes** --- Your implementation connects to the message bus for this topic (e.g., sends a subscribe message over WebSocket).
1. **Component reads the query** --- Fetchium calls `send()` on the adapter, which creates a deferred promise and calls your `subscribe(topic)` implementation.
2. **Adapter subscribes** --- Your implementation connects to the message bus for this topic (e.g., sends a subscribe message over WebSocket).
3. **Initial data arrives** --- Your `onmessage` handler calls `fulfillTopic(topic, data)`, resolving the deferred promise. The component renders with the data.
4. **Ongoing updates** --- Your handler calls `sendMutationEvent(event)` for each update. Live arrays and live values react automatically.
5. **Component unmounts** --- Fetchium calls your `unsubscribe(topic)` implementation. Your code disconnects from the message bus for this topic.
Expand Down
10 changes: 5 additions & 5 deletions docs/src/app/data/mutations/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,15 +324,15 @@ If a mutation with optimistic updates fails, the rollback restores the entity to

## Custom Mutations

`RESTMutation` is an adapter for JSON REST APIs. But mutations as a concept are protocol-agnostic. When your use case doesn't fit REST --- GraphQL, file uploads, WebSocket messages, RPC calls --- you build a **`QueryController`** that handles the transport and a **`Mutation`** subclass that stays purely declarative.
`RESTMutation` is an adapter for JSON REST APIs. But mutations as a concept are protocol-agnostic. When your use case doesn't fit REST --- GraphQL, file uploads, WebSocket messages, RPC calls --- you build a **`QueryAdapter`** that handles the transport and a **`Mutation`** subclass that stays purely declarative.

The same controller that handles queries can also handle mutations by implementing `sendMutation(ctx, signal)`. This means custom query and mutation transports for the same protocol live in one place:
The same adapter that handles queries can also handle mutations by implementing `sendMutation(ctx, signal)`. This means custom query and mutation transports for the same protocol live in one place:

```ts
import { QueryController, Mutation, t } from 'fetchium';
import { QueryAdapter, Mutation, t } from 'fetchium';
import type { Query } from 'fetchium';

class MyController extends QueryController {
class MyAdapter extends QueryAdapter {
async send(ctx: Query, signal: AbortSignal): Promise<unknown> {
// ... query transport
}
Expand Down Expand Up @@ -362,7 +362,7 @@ The mutation class is purely declarative:
import { Mutation, t } from 'fetchium';

class UploadAvatar extends Mutation {
static override controller = MyController;
static override adapter = MyAdapter;

params = { userId: t.id, file: t.any };
result = { url: t.string };
Expand Down
2 changes: 1 addition & 1 deletion docs/src/app/quickstart/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function App() {
}
```

This is the minimal setup. The store defaults to an in-memory cache and `RESTQueryController` is auto-instantiated on first use with `globalThis.fetch`. When you need a `baseUrl`, auth headers, or persistent storage, pass explicit options --- see [Project Setup](/setup/project-setup).
This is the minimal setup. The store defaults to an in-memory cache and `RESTQueryAdapter` is auto-instantiated on first use with `globalThis.fetch`. When you need a `baseUrl`, auth headers, or persistent storage, pass explicit options --- see [Project Setup](/setup/project-setup).

{% callout title="Want to go deeper?" type="note" %}
For a complete guide to configuring `baseUrl`, auth headers, persistent stores, and project structure, see [Project Setup](/setup/project-setup).
Expand Down
38 changes: 19 additions & 19 deletions docs/src/app/setup/project-setup/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ The `QueryClient` constructor takes a single config object. The only required fi
```tsx
import { QueryClient } from 'fetchium';
import { SyncQueryStore, MemoryPersistentStore } from 'fetchium/stores/sync';
import { RESTQueryController } from 'fetchium/rest';
import { RESTQueryAdapter } from 'fetchium/rest';

const client = new QueryClient({
store: new SyncQueryStore(new MemoryPersistentStore()),
controllers: [
new RESTQueryController({
adapters: [
new RESTQueryAdapter({
fetch: globalThis.fetch,
baseUrl: 'https://api.example.com',
}),
Expand All @@ -43,29 +43,29 @@ The store is responsible for _persistent_ caching --- saving query results and e

### QueryClientConfig options

| Option | Type | Default | Description |
| ------------- | ------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `store` | `QueryStore` | `SyncQueryStore` (in-memory) | Persistent storage backend for query results and entity data. Defaults to an in-memory store — data is lost on page refresh. |
| `controllers` | `QueryController[]` | `[]` | Transport controllers. Register a `RESTQueryController` to configure `fetch`, `baseUrl`, and headers for REST queries. |
| `log` | `object` | `console` | A logger with `warn` and `error` methods. Fetchium uses `log.warn` for non-fatal parse failures. |
| Option | Type | Default | Description |
| ---------- | ---------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `store` | `QueryStore` | `SyncQueryStore` (in-memory) | Persistent storage backend for query results and entity data. Defaults to an in-memory store — data is lost on page refresh. |
| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters. Register a `RESTQueryAdapter` to configure `fetch`, `baseUrl`, and headers for REST queries. |
| `log` | `object` | `console` | A logger with `warn` and `error` methods. Fetchium uses `log.warn` for non-fatal parse failures. |

### Auto-instantiation

Both the store and controllers have sensible defaults, so the minimal `QueryClient` requires no configuration at all:
Both the store and adapters have sensible defaults, so the minimal `QueryClient` requires no configuration at all:

```tsx
// Fully minimal — in-memory store, RESTQueryController auto-instantiated on first use
// Fully minimal — in-memory store, RESTQueryAdapter auto-instantiated on first use
const client = new QueryClient();
```

- `store` defaults to `SyncQueryStore(MemoryPersistentStore)` — data lives in memory and is lost on page refresh
- Controllers are auto-instantiated from their base class the first time a query of that type runs. `RESTQueryController` has a no-arg constructor that defaults to `globalThis.fetch`
- Adapters are auto-instantiated from their base class the first time a query of that type runs. `RESTQueryAdapter` has a no-arg constructor that defaults to `globalThis.fetch`

Once you need a `baseUrl`, auth headers, persistent storage, or a custom fetch wrapper, pass explicit options.

### The RESTQueryController
### The RESTQueryAdapter

`RESTQueryController` is the transport layer for all REST queries and mutations. It accepts:
`RESTQueryAdapter` is the transport layer for all REST queries and mutations. It accepts:

| Option | Type | Default | Description |
| --------- | ---------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand All @@ -85,13 +85,13 @@ The `QueryClient` is made available to your component tree through Signalium's `
```tsx
import { QueryClient, QueryClientContext } from 'fetchium';
import { SyncQueryStore, MemoryPersistentStore } from 'fetchium/stores/sync';
import { RESTQueryController } from 'fetchium/rest';
import { RESTQueryAdapter } from 'fetchium/rest';
import { ContextProvider } from 'signalium/react';

const client = new QueryClient({
store: new SyncQueryStore(new MemoryPersistentStore()),
controllers: [
new RESTQueryController({
adapters: [
new RESTQueryAdapter({
fetch: globalThis.fetch,
baseUrl: 'https://api.example.com',
}),
Expand Down Expand Up @@ -248,12 +248,12 @@ A single file creates and exports the `QueryClient`. This is the place to config
// src/api/client.ts
import { QueryClient } from 'fetchium';
import { SyncQueryStore, MemoryPersistentStore } from 'fetchium/stores/sync';
import { RESTQueryController } from 'fetchium/rest';
import { RESTQueryAdapter } from 'fetchium/rest';

export const queryClient = new QueryClient({
store: new SyncQueryStore(new MemoryPersistentStore()),
controllers: [
new RESTQueryController({
adapters: [
new RESTQueryAdapter({
fetch: globalThis.fetch,
baseUrl: import.meta.env.VITE_API_URL ?? 'https://api.example.com',
}),
Expand Down
Loading
Loading