diff --git a/skills/volverjs-data/README.md b/skills/volverjs-data/README.md new file mode 100644 index 0000000..a8c7719 --- /dev/null +++ b/skills/volverjs-data/README.md @@ -0,0 +1,78 @@ +# Volver Data Skill for Claude Code + +Agent skill that helps Claude Code consume REST APIs with [@volverjs/data](https://github.com/volverjs/data), the repository-pattern data layer built on a tiny `fetch`/`ky` HTTP client, a `qs`-powered URL builder, and a Vue 3 integration. + +## Installation + +```bash +npx skills add volverjs/data +``` + +This adds the skill to your Claude Code configuration. + +## What This Skill Covers + +The skill is specialized for real `@volverjs/data` implementation patterns: + +- **HttpClient**: a thin `ky`/`fetch` wrapper — verb methods (`get`/`post`/`put`/`patch`/`delete`/`head`), URL templates, JSON bodies, `setBearerToken`, request cancellation, `extend`/`clone`, hooks, retry, and timeouts. +- **RepositoryHttp**: repository-pattern CRUD (`read`/`create`/`update`/`remove`), typed `TRequest`/`TResponse` generics, `class` and `responseAdapter`/`requestAdapter`/`metadataAdapter` mapping, and concurrent-read de-duplication. +- **UrlBuilder**: template syntax (`:required`, `:optional?`), query-string encoding via `qs`, and array formats. +- **Hash**: `cyrb53` / `djb2` helpers for cache keys and request de-duplication. +- **Vue 3 Integration**: `createHttpClient`, `useHttpClient`, `useRepositoryHttp`, reactive `isLoading`/`isError`/`error`/`data` state, deferred vs immediate execution, reactive params, and multi-backend scopes. +- **Migration awareness**: v3 renamed `prefixUrl` → `prefix` and upgraded to `ky` 2.x. + +## Usage + +Once installed, Claude Code should automatically use this skill when you ask to: + +- Make HTTP requests against a REST API using `HttpClient`. +- Build a typed repository for a resource and run CRUD operations. +- Construct URLs with path placeholders and encoded query strings. +- Wire reactive API calls into Vue 3 components with the composables. +- Configure auth, cancellation, retries, adapters, or multiple API backends. + +### Example Prompts + +```text +Create an HttpClient pointing at https://my.api.com and fetch the user with id 1 as a typed User. +``` + +```text +Build a RepositoryHttp for the `users/:id?` endpoint and add read, create, update and delete helpers. +``` + +```text +Map the server's { firstname, lastname } response into a User model with a responseAdapter. +``` + +```text +In this Vue component, load a list of products reactively with useRepositoryHttp and show loading/error state. +``` + +```text +Build a URL for `users/:id` with id=42 and a `tags` query param encoded as repeated keys. +``` + +```text +Set up two API backends (global + a v2 scope) and use the v2 one in a component. +``` + +## Source of Truth + +When coding, verify implementation details directly from the library source: + +- `src/HttpClient.ts` — client, options, request/cancel, auth +- `src/RepositoryHttp.ts` — repository CRUD, adapters, read de-duplication +- `src/UrlBuilder.ts` — template parsing and query encoding +- `src/Hash.ts` — hash helpers +- `src/vue/index.ts` — `createHttpClient`, `useHttpClient`, `useRepositoryHttp` + +## Documentation + +- [Volver Data Repository](https://github.com/volverjs/data) +- [Skill Specification](./SKILL.md) +- [ky](https://github.com/sindresorhus/ky) and [qs](https://github.com/ljharb/qs) (underlying libraries) + +## License + +MIT diff --git a/skills/volverjs-data/SKILL.md b/skills/volverjs-data/SKILL.md new file mode 100644 index 0000000..ee5521d --- /dev/null +++ b/skills/volverjs-data/SKILL.md @@ -0,0 +1,183 @@ +--- +name: volverjs-data +description: >- + Write code that consumes REST APIs with @volverjs/data — the repository-pattern + data layer built on a tiny fetch/ky HttpClient. Use this skill whenever the user + is building HTTP/API access in a project that depends on @volverjs/data, or + mentions HttpClient, RepositoryHttp, UrlBuilder, Hash, or the Vue composables + useHttpClient / useRepositoryHttp / createHttpClient. Trigger it for tasks like + "fetch users from the API", "create a repository for this resource", "build a + URL with query params", "do CRUD against my REST endpoint", "set the bearer + token", or "make this request reactive in my Vue component" — even when the + library is not named explicitly but the project already uses it. +--- + +# @volverjs/data + +`@volverjs/data` is a small data-access layer for REST APIs. It exports four classes +and a Vue 3 integration: + +| Class | Purpose | +| --- | --- | +| `HttpClient` | Thin wrapper around [`ky`](https://github.com/sindresorhus/ky) (fetch). Makes requests and builds URLs. | +| `RepositoryHttp` | Repository-pattern CRUD layer over `HttpClient` (`read`/`create`/`update`/`remove`). | +| `UrlBuilder` | Builds URLs from a template + params, encoding the query string with [`qs`](https://github.com/ljharb/qs). | +| `Hash` | Static string-hash helpers (`cyrb53`, `djb2`). Used internally for request de-duplication. | + +```typescript +import { Hash, HttpClient, HTTPError, RepositoryHttp, TimeoutError, UrlBuilder } from '@volverjs/data' +``` + +Vue 3 helpers live in a separate subpath: + +```typescript +import { createHttpClient, removeHttpClient, useHttpClient, useRepositoryHttp } from '@volverjs/data/vue' +``` + +## Choosing the right tool + +- **One-off request, or a request not shaped like a CRUD resource** → use `HttpClient` directly. +- **A REST resource you read/create/update/delete repeatedly** → wrap it in a `RepositoryHttp`. It maps responses to typed items, manages metadata, and de-duplicates concurrent reads. +- **Just need a URL string** (e.g. for a link, a `
`, or a third-party fetch) → use `UrlBuilder`. +- **Inside a Vue 3 component** → use the composables (`useHttpClient`, `useRepositoryHttp`). They return reactive `isLoading`/`isError`/`error`/`data` refs so you don't hand-roll request state. + +When the task is non-trivial or you need exact option names, read the matching file in `references/` — the tables there are the source of truth. Don't guess option names; `@volverjs/data` v3 renamed several (see "Common pitfalls"). + +## HttpClient — quick start + +```typescript +import { HttpClient } from '@volverjs/data' + +const client = new HttpClient({ prefix: 'https://my.api.com' }) + +// Plain string URL (resolved against `prefix`) +const res = await client.get('users/1') +const user = await res.json() + +// Template + params: path placeholders are filled, leftover keys become the query string +const res2 = await client.get({ + template: ':endpoint/:id', + params: { endpoint: 'users', id: 1, _limit: 10, _page: 1 }, +}) +// → GET https://my.api.com/users/1?_limit=10&_page=1 +``` + +Key things to know: + +- Verb methods: `get`, `post`, `put`, `patch`, `delete`, `head`. Each takes `(url, options?)` and returns a ky `ResponsePromise` — call `.json()`, `.text()`, `.blob()` on the awaited response (or directly on the promise: `await client.get('users').json()`). +- Send a JSON body with the `json` option: `client.post('users', { json: { name: 'Ada' } })`. Don't `JSON.stringify` it yourself. +- `client.request(method, url, options)` returns `{ responsePromise, abort, signal }` — use it when you need to **cancel** a request. +- Auth: `client.setBearerToken(token)` sets `Authorization: Bearer ` on every subsequent request. Pass `null`/`undefined` to clear it. +- Errors throw by default (`throwHttpErrors: true`). A non-2xx response throws `HTTPError`; a timeout throws `TimeoutError`. Catch them, or set `throwHttpErrors: false` to inspect the response yourself. +- `extend(options)` mutates the client in place; `clone(options)` returns a new independent client. + +Full option reference (all ky + qs options, hooks, retry, timeout, progress): **read [references/http-client.md](references/http-client.md)**. + +## UrlBuilder — quick start + +```typescript +import { UrlBuilder } from '@volverjs/data' + +UrlBuilder.build(':resource/:id?', { resource: 'users', id: 1, q: 'ada' }) +// → 'users/1?q=ada' + +UrlBuilder.build(':resource/:id?', { resource: 'users', q: 'ada' }) +// → 'users?q=ada' (optional :id? is dropped when missing) +``` + +Template rules: + +- `:name` is a **required** path parameter — throws if the value is missing/empty. +- `:name?` is **optional** — silently omitted when the param is absent. +- Any param **not** consumed by a placeholder is appended to the **query string** (encoded with `qs`). +- Query defaults: `arrayFormat: 'comma'`, `skipNulls: true`, `encodeValuesOnly: true`, `format: 'RFC1738'`. Override per call or per instance. + +Use the static `UrlBuilder.build(...)` for one-offs, or `new UrlBuilder(options)` when you want shared query-encoding options. Details and qs options: **[references/url-builder.md](references/url-builder.md)**. + +## RepositoryHttp — quick start + +```typescript +import { HttpClient, RepositoryHttp } from '@volverjs/data' + +const client = new HttpClient({ prefix: 'https://my.api.com' }) + +interface User { id: number, name: string } + +const usersRepo = new RepositoryHttp(client, 'users/:id?') + +// READ a list (no id) — params beyond the template become query params +const { responsePromise } = usersRepo.read({ _page: 1 }) +const { ok, data, item, metadata } = await responsePromise +// data: User[] item: data[0] metadata: e.g. { total } from X-Total-Count + +// READ one +const { item: one } = await usersRepo.read({ id: 1 }).responsePromise + +// CREATE / UPDATE pass the payload first, params second +await usersRepo.create({ name: 'Ada' }).responsePromise +await usersRepo.update({ name: 'Ada L.' }, { id: 1 }).responsePromise + +// DELETE +await usersRepo.remove({ id: 1 }).responsePromise +``` + +Every method returns `{ responsePromise, abort, signal }`. The resolved value is +`{ ok, data?, item?, metadata?, aborted?, abortReason? }` (`remove` resolves to `{ ok }`). + +Essentials: + +- **`read` de-duplicates concurrent identical requests** by hashing the params. Two `read({ id: 1 })` calls in flight share one network request. Pass `{ key: false }` to opt out, or a custom `key` to control the cache bucket. +- **Map raw responses to a class** with `{ class: User }`, or to anything with `{ responseAdapter: raw => [...] }` (must return an array). Use `responseAdapter` when the server's shape differs from your model — type the second generic: `RepositoryHttp`. +- Transform outgoing bodies with `requestAdapter`; extract custom pagination/metadata with `metadataAdapter`. +- The template follows `UrlBuilder` rules — `users/:id?` handles both the collection and the single item. + +Full options table (adapters, `class`, `httpClientScope`, `httpClientOptions`, `hashFunction`, abort/cancel): **[references/repository-http.md](references/repository-http.md)**. + +## Vue 3 integration + +Register a client once at app start, then consume it reactively in components. + +```typescript +// main.ts +import { createHttpClient } from '@volverjs/data/vue' +import { createApp } from 'vue' +import App from './App.vue' + +const app = createApp(App) +app.use(createHttpClient({ prefix: 'https://my.api.com' })) +``` + +```vue + + + +``` + +The composables (`useHttpClient`, `useRepositoryHttp`) return reactive helpers +(`requestGet`/`requestPost`/… and `read`/`create`/`update`/`remove`) that each expose +`{ isLoading, isError, isSuccess, error, data, execute, ... }`. Requests run immediately +unless you pass `{ immediate: false }` and call `execute()` yourself. Multiple named API +backends are supported via `scope`. Full patterns (immediate vs deferred, reactive params, +`responseAdapter` typing, scopes): **[references/vue.md](references/vue.md)**. + +## Common pitfalls + +- **`prefix`, not `prefixUrl`.** v3 renamed the base-URL option to `prefix` (and upgraded to ky 2.x). Older snippets using `prefixUrl` are wrong for current versions. +- **With a `prefix`, use relative paths** (`'users/1'`, not `'/users/1'`). A leading slash is stripped, but write paths relative to the prefix to be safe. +- **`searchParams` is qs config, not the params themselves.** In `@volverjs/data` you pass query values inside the URL template's `params` (or as extra repository params); the `searchParams` option only configures *how* the query is encoded. +- **Responses throw on HTTP errors by default.** Wrap calls in `try/catch` for `HTTPError`, or check `ok` only after setting `throwHttpErrors: false`. +- **`create`/`update` take the payload first**, then params: `repo.update(payload, { id })`. Easy to flip. +- **Don't pre-stringify JSON bodies.** Pass an object to the `json` option and let the client serialize it. diff --git a/skills/volverjs-data/references/hash.md b/skills/volverjs-data/references/hash.md new file mode 100644 index 0000000..dfbb92e --- /dev/null +++ b/skills/volverjs-data/references/hash.md @@ -0,0 +1,26 @@ +# Hash reference + +`Hash` provides two static, fast, non-cryptographic string-hash functions. They return a +`number`. They are **not** for security — use them for cache keys, change detection, or +request de-duplication (which is what `RepositoryHttp` uses them for internally). + +```typescript +import { Hash } from '@volverjs/data' + +Hash.cyrb53('hello world') // → e.g. 5211024121371232 +Hash.cyrb53('hello world', 42) // seeded variant + +Hash.djb2('hello world') // → e.g. 894552257 +Hash.djb2('hello world', 5381) // custom seed (default 5381) +``` + +| Function | Signature | Notes | +| --- | --- | --- | +| `Hash.cyrb53` | `(str: string, seed = 0) => number` | 53-bit hash, very low collision rate. Default hash for `RepositoryHttp` read de-dup. | +| `Hash.djb2` | `(str: string, seed = 5381) => number` | Classic djb2, 32-bit. Faster, more collisions. | + +Use as a custom repository key function: + +```typescript +const repo = new RepositoryHttp(client, 'users/:id?', { hashFunction: Hash.djb2 }) +``` diff --git a/skills/volverjs-data/references/http-client.md b/skills/volverjs-data/references/http-client.md new file mode 100644 index 0000000..f444ee4 --- /dev/null +++ b/skills/volverjs-data/references/http-client.md @@ -0,0 +1,196 @@ +# HttpClient reference + +`HttpClient` wraps [`ky`](https://github.com/sindresorhus/ky) (a `fetch`-based client) and +uses `UrlBuilder` to turn URL templates into final URLs. + +## Contents + +- [Constructing](#constructing) +- [Options](#options) +- [Request methods](#request-methods) +- [Reading the response](#reading-the-response) +- [Cancelling a request](#cancelling-a-request) +- [Authentication](#authentication) +- [extend vs clone](#extend-vs-clone) +- [Errors](#errors) +- [Hooks, retry, timeout, progress](#hooks-retry-timeout-progress) + +## Constructing + +```typescript +import { HttpClient } from '@volverjs/data' + +const client = new HttpClient({ + prefix: 'https://my.api.com', // base URL for relative paths + headers: { 'Content-Type': 'application/json' }, + timeout: 10000, // ms; default 10000, `false` disables + searchParams: { skipNulls: false }, // qs encoding config (NOT the query values) +}) +``` + +`HttpClientInstanceOptions` also accepts `client` (a pre-built ky instance) and +`urlBuilder` (a pre-built `UrlBuilder`) for advanced composition — rarely needed. + +## Options + +`HttpClientOptions` = all `ky` options (except `searchParams`, repurposed) plus a +`searchParams` object of `qs` stringify options. + +### ky options + +| Option | Type | Notes | +| --- | --- | --- | +| `method` | `HttpClientMethod` | Usually set by the verb method. | +| `headers` | object / `Headers` | Per-request or per-client. A header set to `undefined` is removed. | +| `json` | `unknown` | Request body, serialized to JSON automatically. | +| `body` | `BodyInit` | Raw body (use instead of `json` for FormData, blobs, etc.). | +| `prefix` | `string \| URL` | Base URL. **Renamed from `prefixUrl` in v3.** | +| `parseJson` | `(text) => unknown` | Custom JSON parser; default `JSON.parse`. | +| `retry` | `number \| RetryOptions` | Retry policy (see ky docs). | +| `timeout` | `number \| false` | ms; default `10000`. | +| `hooks` | `Hooks` | `beforeRequest`, `afterResponse`, `beforeRetry`, `beforeError`. | +| `throwHttpErrors` | `boolean` | Default `true`. When `false`, non-2xx responses resolve instead of throwing. | +| `onDownloadProgress` | `(progress, chunk) => void` | Download progress callback. | +| `fetch` | `fetchFn` | Custom fetch implementation (SSR, mocking). | +| `signal` | `AbortSignal` | Cancellation signal. | + +### searchParams (qs) options + +Configure how the query string is encoded. Defaults applied by the library: +`skipNulls: true`, `encode: true`, `arrayFormat: 'comma'`, `format: 'RFC1738'`, +`encodeValuesOnly: true`. Other useful keys: `delimiter`, `strictNullHandling`, +`encoder`, `filter`, `indices`, `sort`, `serializeDate`, `addQueryPrefix`, +`allowDots`, `charset`, `charsetSentinel`. See [`qs`](https://github.com/ljharb/qs). + +## Request methods + +```typescript +client.get(url, options?) +client.post(url, options?) +client.put(url, options?) +client.patch(url, options?) +client.delete(url, options?) +client.head(url, options?) +``` + +`url` is a `HttpClientInputTemplate`: + +- a **string** (`'users/1'`) — resolved against `prefix`; +- a **template object** `{ template, params }` — path placeholders filled, extra params become the query string; +- a `Request` / `URL` — passed through. + +```typescript +// Template form +client.get({ + template: ':endpoint/:action?/:id', + params: { endpoint: 'users', id: 1, _limit: 10 }, +}) +// → GET /users/1?_limit=10 +``` + +`buildUrl(url, options?)` returns the resolved URL without making a request. + +## Reading the response + +Verb methods return a ky `ResponsePromise`. You can chain body parsers directly: + +```typescript +const users = await client.get('users').json() +const text = await client.get('readme.txt').text() +const blob = await client.get('avatar.png').blob() +``` + +Or await the response and call the parser: + +```typescript +const res = await client.get('users/1') +if (res.ok) { + const user = await res.json() +} +``` + +## Cancelling a request + +`request()` exposes an abort handle: + +```typescript +const { responsePromise, abort, signal } = client.request('get', 'long-task') + +// later… +abort('user navigated away') + +try { + const res = await responsePromise +} +catch (e) { + if (signal.aborted) { + // request was cancelled — signal.reason holds the reason + } +} +``` + +You can also pass your own controller: `client.request('get', url, { abortController })`, +or pass a `signal` to any verb method. + +## Authentication + +```typescript +client.setBearerToken('my-jwt') // sets Authorization: Bearer my-jwt on all requests +client.setBearerToken(null) // clears it + +// Custom header / prefix +client.setBearerToken(token, { headerName: 'X-Auth-Token', prefix: 'Token' }) +``` + +`setBearerToken` works by calling `extend`, so it mutates the client instance. + +## extend vs clone + +- `client.extend(options)` — mutate this client in place (merges headers, updates prefix, etc.). Returns `void`. +- `client.clone(options)` — return a **new** independent `HttpClient` with the options applied. The original is unchanged. + +Use `clone` for a variant client (e.g. a different `Accept` header) without affecting the shared instance. + +## Errors + +With the default `throwHttpErrors: true`: + +```typescript +import { HTTPError, TimeoutError } from '@volverjs/data' + +try { + const user = await client.get('users/1').json() +} +catch (e) { + if (e instanceof HTTPError) { + // e.response is the Response; e.response.status, etc. + const status = e.response.status + } + else if (e instanceof TimeoutError) { + // request exceeded the timeout + } +} +``` + +Set `throwHttpErrors: false` to handle status codes manually via `res.ok` / `res.status`. + +## Hooks, retry, timeout, progress + +These are ky features passed straight through: + +```typescript +const client = new HttpClient({ + prefix: 'https://my.api.com', + retry: { limit: 2, methods: ['get'] }, + timeout: 5000, + hooks: { + beforeRequest: [req => req.headers.set('X-Trace', 'abc')], + afterResponse: [(_req, _opts, res) => res], + }, + onDownloadProgress: (progress, _chunk) => { + console.log(`${Math.round(progress.percent * 100)}%`) + }, +}) +``` + +See the [ky documentation](https://github.com/sindresorhus/ky) for the full semantics of hooks and retry. diff --git a/skills/volverjs-data/references/repository-http.md b/skills/volverjs-data/references/repository-http.md new file mode 100644 index 0000000..dd7b7be --- /dev/null +++ b/skills/volverjs-data/references/repository-http.md @@ -0,0 +1,163 @@ +# RepositoryHttp reference + +`RepositoryHttp` implements the repository pattern over an +`HttpClient`. It centralizes CRUD for one REST resource, maps raw responses to typed items, +extracts metadata, and de-duplicates concurrent reads. + +## Contents + +- [Constructing](#constructing) +- [Generics](#generics) +- [Options](#options) +- [Methods](#methods) +- [The resolved result](#the-resolved-result) +- [Read de-duplication and keys](#read-de-duplication-and-keys) +- [Adapters](#adapters) +- [Cancelling](#cancelling) + +## Constructing + +```typescript +import { HttpClient, RepositoryHttp } from '@volverjs/data' + +const client = new HttpClient({ prefix: 'https://my.api.com' }) +const repo = new RepositoryHttp(client, 'users/:id?', options) +``` + +- `client` — an `HttpClientInstance`. +- `template` — a string or `{ template, params }`, following `UrlBuilder` rules. `users/:id?` serves both the collection (`read({})`) and a single item (`read({ id })`). +- `options` — `RepositoryHttpOptions` (all optional, below). + +## Generics + +```typescript +RepositoryHttp +``` + +- `TRequest` — your model / the shape you send and work with. +- `TResponse` — the raw server shape, when it differs. Set it so `responseAdapter`'s `raw` argument is typed: + +```typescript +interface IUser { id: number, name: string } +interface ApiUser { id: number, firstname: string, lastname: string } + +const repo = new RepositoryHttp(client, 'users/:id', { + responseAdapter: raw => [{ id: raw.id, name: `${raw.firstname} ${raw.lastname}` }], +}) +``` + +## Options + +| Option | Type | Default | Purpose | +| --- | --- | --- | --- | +| `class` | `new (...args) => TResponse` | — | Instantiate each raw item as a class. Shortcut for a `responseAdapter`. Ignored if `responseAdapter` is also set. | +| `responseAdapter` | `(raw: TResponse) => TResponse[]` | wrap-in-array | Map the raw response to an **array** of items. | +| `requestAdapter` | `(item: TRequest) => unknown` | identity | Transform each payload item before sending. | +| `metadataAdapter` | `(response: Response) => ParamMap` | reads `Content-Language`, `Accept-Language`, `X-Total-Count` → `total` | Extract metadata (e.g. pagination) from response headers. | +| `hashFunction` | `(str: string) => number` | `Hash.cyrb53` | Hash used to build read de-dup keys. | +| `httpClientScope` | `string` | — | (Vue) name of a registered client scope to use. | +| `httpClientOptions` | `HttpClientRequestOptions` | — | Options merged into every request this repo makes (e.g. a per-resource `prefix` or headers). | + +## Methods + +```typescript +repo.read(params?, options?) // GET +repo.create(payload?, params?, options?) // POST +repo.update(payload?, params?, options?) // PUT +repo.remove(params?, options?) // DELETE +``` + +- `params` fills the URL template; leftover keys become the query string. +- `payload` (create/update) is the request body — a single item or an array. **Payload comes first**, params second. +- `options` are `HttpClientRequestOptions`. Override the HTTP method with `{ method: 'patch' }` if needed (e.g. partial update via `update(payload, params, { method: 'patch' })`). +- `read` also accepts `{ key }` (see de-dup below). + +Each returns `{ responsePromise, abort, signal }`. + +```typescript +// List with query params +const { responsePromise } = repo.read({ _page: 1, _limit: 20 }) +const { data, metadata } = await responsePromise // data: User[], metadata.total from X-Total-Count + +// Single item +const { item } = await repo.read({ id: 1 }).responsePromise + +// Create one / many +await repo.create({ name: 'Ada' }).responsePromise +await repo.create([{ name: 'Ada' }, { name: 'Alan' }]).responsePromise + +// Update +await repo.update({ name: 'Ada L.' }, { id: 1 }).responsePromise + +// Delete +const { ok } = await repo.remove({ id: 1 }).responsePromise +``` + +## The resolved result + +`read`/`create`/`update` resolve to: + +```typescript +{ + ok: boolean + data?: TResponse[] // adapted items + item?: TResponse // data[0] — convenient for single-item reads + metadata?: ParamMap // from metadataAdapter + aborted?: boolean // true if the request was cancelled + abortReason?: string +} +``` + +`remove` resolves to `{ ok }` (plus `aborted`/`abortReason` if cancelled). + +Note on errors: an `HTTPError` (non-2xx) **rejects** `responsePromise` — catch it. An +**abort** does not reject; it resolves with `{ ok: false, aborted: true, abortReason }`. + +## Read de-duplication and keys + +`read` caches in-flight requests by a key derived from the params (via `hashFunction`). +Identical concurrent `read`s share a single network request and resolve together — useful +when several components request the same resource at once. + +```typescript +repo.read({ id: 1 }) // fires the request +repo.read({ id: 1 }) // reuses the in-flight one — no second request + +repo.read({ id: 1 }, { key: false }) // opt out: always a fresh request +repo.read({ id: 1 }, { key: 'user-1' }) // custom cache bucket +``` + +Each cloned caller gets its own `abort`; the underlying request is only aborted when **all** +sharers abort. Pass `{ key: false }` when you explicitly need an independent, always-fresh call. + +## Adapters + +```typescript +// class: instantiate a model per item +const repo = new RepositoryHttp(client, 'users/:id?', { class: User }) + +// responseAdapter: full control; MUST return an array +const repo2 = new RepositoryHttp(client, 'users/:id?', { + responseAdapter: raw => (Array.isArray(raw) ? raw : [raw]).map(u => new User(u)), +}) + +// requestAdapter: reshape outgoing payload +const repo3 = new RepositoryHttp(client, 'users/:id?', { + requestAdapter: user => ({ ...user, updatedAt: Date.now() }), +}) + +// metadataAdapter: custom pagination header +const repo4 = new RepositoryHttp(client, 'users', { + metadataAdapter: res => res.headers.has('X-Pagination') + ? JSON.parse(res.headers.get('X-Pagination')!) + : {}, +}) +``` + +## Cancelling + +```typescript +const { responsePromise, abort, signal } = repo.read({ _page: 1 }) +abort('navigated away') +const result = await responsePromise // { ok: false, aborted: true, abortReason: 'navigated away' } +``` diff --git a/skills/volverjs-data/references/url-builder.md b/skills/volverjs-data/references/url-builder.md new file mode 100644 index 0000000..2e1de48 --- /dev/null +++ b/skills/volverjs-data/references/url-builder.md @@ -0,0 +1,87 @@ +# UrlBuilder reference + +`UrlBuilder` turns a template + params into a URL, filling path placeholders and encoding the +remaining params as a query string with [`qs`](https://github.com/ljharb/qs). It is used +internally by `HttpClient`, but you can use it standalone for links, form actions, etc. + +## Contents + +- [Static vs instance](#static-vs-instance) +- [Template syntax](#template-syntax) +- [Query encoding defaults](#query-encoding-defaults) +- [query() helper](#query-helper) +- [Validation rules](#validation-rules) + +## Static vs instance + +```typescript +import { UrlBuilder } from '@volverjs/data' + +// One-off — static +const url = UrlBuilder.build(':resource/:id?', { resource: 'users', id: 1, q: 'ada' }) +// → 'users/1?q=ada' + +// Shared encoding options — instance +const builder = new UrlBuilder({ encodeValuesOnly: false, arrayFormat: 'brackets' }) +builder.build('https://api.com/:resource', { resource: 'users', tags: ['a', 'b'] }) +``` + +Instance methods: `build(template, params, options?)`, `query(params, options?)`, +`extend(options)` (merge options in place), `clone(options?)` (new builder with merged options). + +## Template syntax + +Placeholders are matched by `/?:[A-Za-z_]\w*\??/`: + +| Token | Meaning | +| --- | --- | +| `:name` | **Required** path param. Missing/empty value → throws. | +| `:name?` | **Optional** path param. Omitted (including its leading `/`) when absent. | +| (any leftover param) | Appended to the query string. | + +```typescript +UrlBuilder.build('users/:id', { id: 1 }) // 'users/1' +UrlBuilder.build('users/:id?', {}) // 'users' +UrlBuilder.build('users/:id?', { id: 1, _page: 2 }) // 'users/1?_page=2' +UrlBuilder.build(':a/:b?/:c', { a: 'x', c: 'z' }) // 'x/z' (optional b dropped) +``` + +Path values are passed through `encodeURIComponent`. `::` is left untouched (not treated as a param). + +## Query encoding defaults + +When encoding the query, these defaults are applied (override per call/instance): + +| Option | Default | +| --- | --- | +| `arrayFormat` | `'comma'` (e.g. `tags=a,b`) | +| `skipNulls` | `true` | +| `encodeValuesOnly` | `true` | +| `format` | `'RFC1738'` | + +Other `qs` options (`delimiter`, `allowDots`, `indices`, `sort`, `serializeDate`, +`charset`, `addQueryPrefix`, `strictNullHandling`, `filter`, `encoder`, …) are supported. + +```typescript +UrlBuilder.build('search', { tags: ['a', 'b'] }) // 'search?tags=a,b' +UrlBuilder.build('search', { tags: ['a', 'b'] }, { arrayFormat: 'repeat' }) // 'search?tags=a&tags=b' +``` + +`undefined` params are stripped before building; `null` params are dropped by `skipNulls` unless you disable it. + +## query() helper + +Build just the query string (no path): + +```typescript +UrlBuilder.query({ _page: 1, _limit: 10 }) // '_page=1&_limit=10' +new UrlBuilder({ addQueryPrefix: true }).query({ q: 'x' }) // '?q=x' +``` + +Returns `''` for an empty param object. + +## Validation rules + +Path params must be `string`, `number`, or `boolean`. A required `:name` with a missing key, +a non-primitive value, or an empty/whitespace string throws an `Error`. Optional `:name?` +params silently drop instead of throwing. diff --git a/skills/volverjs-data/references/vue.md b/skills/volverjs-data/references/vue.md new file mode 100644 index 0000000..c1fc6a7 --- /dev/null +++ b/skills/volverjs-data/references/vue.md @@ -0,0 +1,180 @@ +# Vue 3 integration reference + +Import from `@volverjs/data/vue`. The integration registers one or more `HttpClient` +instances on the app and exposes composables that wrap requests in reactive state. + +## Contents + +- [Setup: createHttpClient](#setup-createhttpclient) +- [useHttpClient](#usehttpclient) +- [request helpers](#request-helpers) +- [useRepositoryHttp](#userepositoryhttp) +- [Reactive params and immediate](#reactive-params-and-immediate) +- [Scopes (multiple backends)](#scopes-multiple-backends) +- [Reactive state shape](#reactive-state-shape) + +## Setup: createHttpClient + +`createHttpClient(options?)` returns a Vue plugin **and** registers the client in an internal +map (default scope `'global'`). Install it once: + +```typescript +import { createHttpClient } from '@volverjs/data/vue' +import { createApp } from 'vue' +import App from './App.vue' + +const app = createApp(App) +const httpClient = createHttpClient({ prefix: 'https://my.api.com' }) +app.use(httpClient, { globalName: 'vvHttp' }) // optional; exposes $vvHttp in Options API +``` + +`options` are `HttpClientInstanceOptions` plus an optional `scope`. Creating a second client +with an existing scope throws (`httpClient with scope already exist`). + +## useHttpClient + +```typescript +const { client, request, requestGet, requestPost, requestPut, requestPatch, requestHead, requestDelete } = useHttpClient() +``` + +- `client` — the raw `HttpClient` (for manual, non-reactive calls). +- `request(method, url, options)` and the `request*` shortcuts return **reactive** request objects. + +Throws `HttpClient instance not found` if no client was created for the scope. `useHttpClient('apiV2')` selects a named scope. + +## request helpers + +```vue + + + +``` + +POST/PUT with a body — pass `json`, and use a `computed` so the body stays reactive: + +```vue + +``` + +## useRepositoryHttp + +```typescript +const { repository, read, create, update, remove } = useRepositoryHttp('users/:id', options?) +``` + +- `repository` — the raw `RepositoryHttp` instance. +- `read`/`create`/`update`/`remove` — reactive wrappers returning the [state shape](#reactive-state-shape). + +```vue + + + +``` + +Read + update on the same item (deferred update, run on submit): + +```vue + + + +``` + +For server shapes that differ from your model, pass the second generic + `responseAdapter` +(now typed): `useRepositoryHttp('users/:id', { responseAdapter: raw => [...] })`. + +## Reactive params and immediate + +- `{ immediate: true }` (default) runs the request when the composable is created. The reactive + state (`isLoading`, `data`, …) is spread in immediately. +- `{ immediate: false }` defers it — call `execute()` to run. +- `execute(...overrides)` accepts positional overrides matching the method's args, so you can + re-run with new params/payload: `read` → `execute(newParams, newOptions)`, + `update` → `execute(newPayload, newParams, newOptions)`. +- `params`/`payload`/`options` may be refs or `computed` — they're unwrapped at execution time, + so the latest values are used. + +## Scopes (multiple backends) + +```typescript +// Register a second API +createHttpClient({ scope: 'apiV2', prefix: 'https://my.api.com/v2' }) + +// Use it +const { requestGet } = useHttpClient('apiV2') +const { read } = useRepositoryHttp('users/:id', { httpClientScope: 'apiV2' }) + +// Remove it (the 'global' scope cannot be removed) +removeHttpClient('apiV2') +``` + +The scope map is **not** reactive — a client used before `removeHttpClient` is not destroyed. + +## Reactive state shape + +`request*` helpers expose: + +| Field | Type | Notes | +| --- | --- | --- | +| `isLoading` / `isError` / `isSuccess` | `ComputedRef` | Mutually exclusive status flags. | +| `error` | readonly `Ref` | Populated on failure. | +| `response` | `Ref` | Raw response. | +| `data` | `Ref` | Parsed `.json()` body. | +| `execute(url?, options?)` | function | Returns `{ responsePromise, abort, signal }`. | + +`read`/`create`/`update`/`remove` expose `isLoading`/`isError`/`isSuccess`/`error`/`execute`, plus: + +- `data: Ref` and `metadata: Ref` for `read`/`create`/`update`; +- `item: Ref` (first item) for `read`; +- `remove` has no `data`/`item`/`metadata`. + +`execute()` returns `{ responsePromise, abort, signal }` for manual cancellation. diff --git a/src/HttpClient.ts b/src/HttpClient.ts index 2573a3b..bbe3eee 100644 --- a/src/HttpClient.ts +++ b/src/HttpClient.ts @@ -12,7 +12,7 @@ import { } from './UrlBuilder' -type HttpMethod = string +type HttpMethod = NonNullable type KyHeadersInit = NonNullable | Record export type HttpClientResponse = KyResponse diff --git a/src/RepositoryHttp.ts b/src/RepositoryHttp.ts index 2991ec1..da2a7e4 100644 --- a/src/RepositoryHttp.ts +++ b/src/RepositoryHttp.ts @@ -133,7 +133,7 @@ export class RepositoryHttp implements Repository { private _client: HttpClientInstance private _template: string | HttpClientUrlTemplate - private _responseAdapter = (raw: TResponse): TResponse[] => + private readonly _responseAdapter = (raw: TResponse): TResponse[] => (Array.isArray(raw) ? raw : [raw]) as TResponse[] private _requestAdapter = (item: TRequest): unknown => item