# Проекционный ресурс

`api.unstable_createProjectionResource` — обёртка над существующим [ресурсом][ресурс] для загрузки коллекций элементов по списку id с **гранулярностью кэша до отдельного элемента**. Обычный ресурс кэширует ответ целиком по аргументам: запросы `[1, 2, 3]` и `[1, 2, 4]` для него — два независимых полных запроса. Проекционный ресурс ведёт общий кэш элементов по id и догружает через обёрнутый ресурс только недостающие.

```typescript
const api = createApi({ plugins: [reactHooksPlugin()] });

// Обычный ресурс, умеющий отдавать пользователей списком
const usersResource = api.createResource({
    queryFn: async (args: { userIds: number[] }) => {
        const res = await fetch(`/api/users?ids=${args.userIds.join(',')}`);
        return res.json() as Promise<User[]>;
    },
});

const usersProjection = api.unstable_createProjectionResource({
    resource: usersResource,
    key: 'users-projection',
    parseData: (data) => data.map((item) => ({ id: item.id, item })),
    makeArgs: (ids) => ({ userIds: ids }),
});

// Первый запрос: queryFn получает { userIds: [1, 2, 3] }
usersProjection.useResource([1, 2, 3]);

// Элементы 1 и 2 уже в кэше — queryFn получает { userIds: [4] }
usersProjection.useResource([1, 2, 4]);

// Все элементы в кэше — запроса нет вовсе
usersProjection.useResource([2, 3]);
```

Проекционный ресурс — это полноценный `IResource<TArgs, TItem[]>`: у него работают агенты, `useResource` / `useSuspenseResource`, SWR, `ensure` / `fetch` / `prefetch`, devtools и расширения плагинов — ровно так же, как у обычного ресурса. Вся «магия» спрятана в его `queryFn`.


## Как это устроено

Снаружи — обычный ресурс с кэш-записью на каждый набор id. Внутри — общий **реактивный** кэш элементов, поверх которого каждый запуск запроса открывает [стрим][stream-query]: догрузив недостающие id, запись подписывается на «свои» элементы и переизлучает собранный `TItem[]` при каждом их обновлении — пока запись жива:

```mermaid
flowchart LR
    A["useResource([1, 2, 4])"] --> B{"id в кэше\nэлементов?"}
    B -- "1, 2 — да" --> C["берём из кэша"]
    B -- "4 — нет" --> D["makeArgs([4])"]
    D --> E["resource.fetch(...)"]
    E --> F["parseData(response)"]
    F --> G["кэш элементов"]
    G --> H["сборка TItem[]\nв порядке запроса"]
    C --> H
```

1. `parseArgs` извлекает id из аргументов (по умолчанию аргументы — сам массив id).
2. Id, которых нет в кэше элементов, собираются в один запрос `makeArgs(missingIds)` к обёрнутому ресурсу; id, уже загружаемые параллельным набором, не запрашиваются повторно — их результат ожидается.
3. `parseData` разбирает ответ на пары `{ id, item }`, элементы раскладываются в кэш.
4. Результат собирается по каждому запрошенному id (с сохранением порядка и дубликатов) и попадает в кэш-запись набора — **первой эмиссией стрима**. Дальше стрим остаётся открытым: обновление любого из наблюдаемых элементов (например, рефрешем пересекающегося набора) переизлучает собранный результат.


## Опции

| Опция           | Тип                                                    | Описание |
|-----------------|--------------------------------------------------------|----------|
| `resource`      | `IResource<TResArgs, TResData>`                        | Обёрнутый ресурс, выполняющий фактические запросы. **Обязательна.** |
| `parseData`     | `(data: TResData) => { id: TId; item: TItem }[]`       | Разбирает ответ обёрнутого ресурса на пары `{ id, item }`. **Обязательна.** |
| `makeArgs`      | `(ids: TId[]) => TResArgs`                             | Собирает аргументы обёрнутого ресурса из списка id, которые нужно догрузить. **Обязательна.** |
| `parseArgs`     | `(args: TArgs) => TId[]`                               | Извлекает id из аргументов проекционного ресурса. Опциональна: без неё аргументы — сам массив id (`TArgs = TId[]`). Должна быть чистой и детерминированной. |
| `key`           | `string`                                               | Ключ для devtools; получает префикс `keyPrefix`, как у любого ресурса. |
| `serializeId`   | `(id: TId) => string`                                  | Сериализация id в ключ кэша элементов. По умолчанию — `stableStringify` (объектные id сравниваются структурно). |
| `onCacheEntryAdded` | `(args, ctx) => void`                              | [Lifecycle-хук](./lifecycle.md) над записями наборов (`args` — аргументы проекционного ресурса, `data` — собранный `TItem[]`). Компонуется с внутренним хуком рантайма. |
| `onQueryStarted` | `(args, ctx) => void \| Promise<void>`                | [Lifecycle-хук](./lifecycle.md) на каждый запуск запроса набора — включая запуски, целиком обслуженные кэшем элементов без сети. Реальные сетевые запросы наблюдайте хуками на обёрнутом ресурсе. |
| `retentionTime` | `number` \| `false`                                    | Время удержания кэш-записей наборов; по умолчанию — значение API. |
| `serializeArgs` | `(args: TArgs) => string`                              | Сериализация аргументов проекционного ресурса в ключ кэш-записи набора. |


## Семантика

- **Дедупликация.** Повторяющиеся id внутри одного запроса запрашиваются один раз, но в результате занимают все свои позиции. Пустой список id разрешается в `[]` без запроса.
- **Параллельные запросы.** Набор, пересекающийся с уже летящим запросом, не дублирует общие id — он дозапрашивает только свои недостающие и ждёт чужой ответ для остальных.
- **`refresh` / `prefetch({ force: true })` / `fetch`** на существующей записи обновляют **все** id набора, минуя кэш элементов: на каждый id выпускается свежий запрос. К запросам, начатым до рефреша, он не присоединяется (иначе мог бы получить «данные до рефреша»); запуски, начатые после, присоединяются уже к его запросам.
- **Консистентность между наборами.** Каждая живая запись — открытая [стрим][stream-query]-проекция кэша элементов: после рефреша `[1, 2, 3]` запись `[1, 2, 4]` сама переизлучит результат с новыми элементами 1 и 2 (в devtools — действие `stream-next`). Все наборы разделяют один и тот же экземпляр элемента. Записи с незавершёнными оптимистичными патчами тоже получают обновление — активные патчи переигрываются поверх новых элементов (штатный ребейз).
- **`retry` / `ensure`** после ошибки повторяют только всё ещё недостающие id — успевшие попасть в кэш элементы не перезапрашиваются.
- **Вытеснение.** Элемент живёт, пока жива хотя бы одна кэш-запись набора, упоминающая его id; с удалением последней (retention GC, `resetAll`) элемент вытесняется из кэша элементов.
- **Ошибки.** Отказ обёрнутого ресурса становится ошибкой записи набора; `mapError` API применяется ровно один раз — запись набора отдаёт тот же нормализованный экземпляр ошибки, что и запись обёрнутого ресурса. Если запрос успешен, но ответ не покрыл какие-то запрошенные id, запись падает с `ProjectionItemMissingError` (экспортируется публично; поле `ids` — непокрытые id); на рефреше это `refresh-error` с сохранением устаревших данных — как у обычного проваленного рефреша.


## Бесконечная лента: useInfiniteResource

С подключённым `reactHooksPlugin()` проекционный ресурс получает — **в дополнение** к `useResource` / `useSuspenseResource` — хук `useInfiniteResource` для бесконечной подгрузки. Лента — упорядоченный список **страниц**, каждая страница — обычная кэш-запись проекции со своим фиксированным набором id:

```tsx
function Feed() {
  const feed = postsProjection.useInfiniteResource(firstPageIds);

  return (
    <>
      {feed.data?.map((post) => <PostCard key={post.id} post={post} />)}
      {feed.isFetchingNext && <Spinner />}
      <button onClick={async () => {
        const nextIds = await pager.fetch({ after: lastLoadedId });
        feed.fetchNext(nextIds);
      }}>
        Загрузить ещё
      </button>
    </>
  );
}
```

Откуда берутся id следующей страницы, хук не знает — их передаёт вызывающий код через `fetchNext(nextArgs)` (обычно из отдельного ресурса-пагинатора). Есть ли ещё страницы — тоже знание вызывающего (`hasNext` у хука нет).

Свойства модели «страница = запись»:

- **Загруженные страницы не мигают** при догрузке хвоста: их записи не пересоздаются, меняется только хвост.
- **Дедупликация элементов между страницами бесплатна** — общий кэш элементов проекции: id, уже загруженный любой страницей (или любым другим набором), в сеть не уходит, а `data` разных страниц разделяют один экземпляр.
- **Живые обновления**: рефреш любого пересекающегося набора переизлучает затронутые страницы через стрим-проекции — лента остаётся консистентной без перезапросов.

Состояние `TInfiniteResourceState`:

| Поле | Описание |
|---|---|
| `data` | `TItem[] \| null` — элементы всех страниц с данными, склеенные в порядке страниц. Идентичность стабильна: новый массив создаётся, только когда изменились данные какой-либо страницы; чистые смены статусов (success ↔ refreshing, тики фонового рефреша) сохраняют прежний экземпляр — `React.memo` / `useMemo` / виртуализация по `data` не перезапускаются впустую. Трактуйте массив как иммутабельный и не используйте идентичность `data` как сигнал «что-то произошло» — для этого есть `pages` и флаги. |
| `pages` | Состояния страниц (`TResourceAgentState[]`) — для гранулярного рендера. |
| `isInitialLoading` | Первая страница грузится, показать нечего. |
| `isFetchingNext` | Грузится страница за первой. |
| `isLoading` / `isError` / `error` | Агрегаты по страницам (`error` — первый по порядку страниц). |
| `isIdle` | Лента пуста — `initialArgs` = `SKIP`. |
| `fetchNext(args)` | Добавить страницу. Повторные args существующей страницы — no-op; упавшая страница — ретрай. |
| `refresh()` | Перевалидировать всю ленту: страницы с данными — refresh, упавшие — retry. |
| `reset()` | Отбросить все страницы после первой. |

Смена `initialArgs` (по ключу кэша) сбрасывает ленту к новой первой странице.


## Ограничения

- **Кросс-табовая синхронизация отключена** — она заполняла бы записи наборов в обход кэша элементов.
- **Снапшоты (SSR): проекционный ресурс в них не участвует** (`snapshotable: false` выставлен автоматически). Записи наборов — производные проекции; владелец данных для снимка — обёрнутый ресурс, его записи сериализуются и гидрируются как обычно.
- **Отмена не пробрасывается** в обёрнутый ресурс: один batch-запрос могут ждать несколько наборов, поэтому сворачивание одного из них не отменяет общий запрос (результат отменённой записи просто игнорируется — стандартное поведение ресурса).
- Ошибка записи набора — на весь набор: частичный результат не отдаётся.
- **Оптимистичные патчи — set-local.** `createPatch` (напрямую или через `links` команд) применяется к проекции конкретного набора: оптимистика и ребейс внутри него работают штатно, но кэш элементов и пересекающиеся наборы сам патч не видят. При этом пропатченная запись продолжает получать обновления элементов — они ребейзятся под патч до его завершения. При первом патче на проекционном ресурсе выводится однократное предупреждение в консоль (общий варнинг [стрим-патчей][stream-query] подавлен через `allowStreamPatches` — у проекции своё, более точное сообщение).
- **`$queryStream.allReceived`** в `onQueryStarted` для записи проекции никогда не разрешается штатно: стрим набора не завершается, а сворачивается вместе с запуском (реджект причиной отмены). Используйте `$queryFulfilled` / `firstReceived`.


## См. также

- [Ресурс][ресурс] — все методы и состояния проекционного ресурса идентичны обычному.
- [Стриминговые запросы][stream-query] — механика «живых» записей, на которой построена проекция.
- [API-справочник ресурса](../api/resource.md)
- [Кэш и время жизни записей](../concepts/cache.md)


[ресурс]: ./resource.md
[stream-query]: ./stream-query.md
