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
7 changes: 7 additions & 0 deletions .changeset/tiny-cameras-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"fetchify": major
---

Add a pluggable cache-adapter system: a `CacheAdapter` interface, a `createCacheAdapter` factory for authoring custom adapters (Redis, Upstash, etc.), and a built-in dependency-free in-memory adapter at `fetchify/cache/memory`. `createFetchify`'s `FetchifyOptions` gains an optional `cache` field of type `CacheAdapter`.

**BREAKING**: Remove the `fetchify/native` entry point. It has been an exact duplicate of the main `fetchify` entry point since the initial scaffold, with no platform-specific behavior. Consumers importing from `fetchify/native` should import from `fetchify` instead — the API is identical.
72 changes: 65 additions & 7 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,13 @@ This file is kept in sync with the package's exports — any change that adds, m

## Entry points

fetchify exposes two entry points, each built independently:
fetchify exposes a main entry point, plus opt-in subpaths for pieces you don't always need:

- `fetchify` — web-targeted build (`dist/index.js`)
- `fetchify/native` — React Native-targeted build (`dist/native.js`)

Both entry points export the same API: `createFetchify`, `defineOptions`, and their associated types.
- `fetchify` — the package's API: `createFetchify`, `defineOptions`, `createCacheAdapter`, and their associated types (`dist/index.js`)
- `fetchify/cache/memory` — the built-in in-memory `CacheAdapter` implementation (`dist/cache/memory.js`), kept out of the main entry so consumers who don't use it don't bundle it

```ts
import { createFetchify, defineOptions } from "fetchify";
// or, in a React Native app:
import { createFetchify, defineOptions } from "fetchify/native";
```

## `createFetchify`
Expand Down Expand Up @@ -57,6 +53,8 @@ const created = await client.post("/users", {

Calling `createFetchify()` or `createFetchify({})` returns a client with the same methods and no base URL resolution applied.

`options.cache` accepts a [`CacheAdapter`](#cacheadapter) (see [`createCacheAdapter`](#createcacheadapter)). It's only stored on the options object today — `createFetchify` doesn't yet read from or write to it as part of making a request.

## `defineOptions`

```ts
Expand All @@ -70,18 +68,78 @@ const options = defineOptions({ baseUrl: "https://api.example.com" });
const client = createFetchify(options);
```

## `createCacheAdapter`

```ts
function createCacheAdapter(adapter: CacheAdapter): CacheAdapter;
```

Identity helper, analogous to `defineOptions`, that types a consumer-supplied object against the [`CacheAdapter`](#cacheadapter) interface and returns it unchanged. This is the supported pattern for building your own cache adapter — for Redis, Upstash, `AsyncStorage`, or anything else — since fetchify does not ship any adapter other than the built-in in-memory one:

```ts
import { createCacheAdapter } from "fetchify";

const redisAdapter = createCacheAdapter({
async get(key) {
/* ... */
},
async set(key, value, options) {
/* ... */
},
async delete(key) {
/* ... */
},
async has(key) {
/* ... */
},
});
```

### Built-in: `fetchify/cache/memory`

```ts
function createMemoryCacheAdapter(): CacheAdapter; // default export
```

An in-memory `CacheAdapter`, built with `createCacheAdapter`, with no external dependencies — works on both web and React Native. Each call returns a fresh, independent adapter backed by a `Map`, with lazy TTL expiry (an expired entry is dropped the next time it's read).

```ts
import createMemoryCacheAdapter from "fetchify/cache/memory";

const cache = createMemoryCacheAdapter();
await cache.set("key", "value", { ttlMs: 60_000 });
```

## Types

### `FetchifyOptions`

```ts
interface FetchifyOptions {
baseUrl?: string | URL;
cache?: CacheAdapter;
}
```

Configuration accepted by `createFetchify` and `defineOptions`.

### `CacheAdapter`

```ts
interface CacheAdapter {
get(key: string): Promise<string | undefined>;
set(
key: string,
value: string,
options?: { ttlMs?: number },
): Promise<void>;
delete(key: string): Promise<void>;
has(key: string): Promise<boolean>;
}
```

A storage- and platform-agnostic async key/value store. Build one with [`createCacheAdapter`](#createcacheadapter), or use the built-in [`fetchify/cache/memory`](#built-in-fetchifycachememory) adapter.

### `FetchifyMethod`

```ts
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
97 changes: 97 additions & 0 deletions openspec/changes/archive/2026-08-02-add-cache-adapters/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
## Context

`createFetchify` currently has no caching concept at all. `FetchifyOptions` only knows about `baseUrl`, and the package ships two entry points — `fetchify` (`src/index.ts`) and `fetchify/native` (`src/native.ts`) — that have been identical re-exports since the scaffold. The existing `defineOptions` (`src/lib/define-options.ts`) is the only precedent for a "typed factory" in this codebase: it's an identity function that exists purely to give consumers a typed authoring entry point, with no runtime validation.

This change adds the plumbing for pluggable caching (a type, a factory, one built-in adapter) and removes `fetchify/native` in the same release, since both changes touch `package.json`'s `exports` map and the same set of specs (`package-exports`, `fetchify-options`, `http-client`).

## Goals / Non-Goals

**Goals:**
- Define a minimal, storage-agnostic `CacheAdapter` interface that any key/value store (in-memory, Redis, Upstash, AsyncStorage, ...) can implement.
- Provide `createCacheAdapter` as the one supported way to author a conforming adapter, mirroring `defineOptions`'s role for `FetchifyOptions`.
- Ship exactly one built-in adapter (in-memory) with zero external dependencies, tree-shakeable via its own subpath so consumers who don't use it don't bundle it.
- Let `createFetchify` accept a `cache` option typed as `CacheAdapter` without ever importing a concrete adapter implementation itself.
- Remove `fetchify/native` cleanly, with a major-bump changeset and migration note.

**Non-Goals:**
- Implementing read-through/write-through request caching inside `createFetchify`'s request logic (cache-key derivation from method/URL/headers, staleness rules, invalidation on mutating verbs, etc.). This change only wires the `cache` option through and types/stores it; consuming the adapter to actually cache responses is deliberately left to a follow-up change once cache-key and invalidation semantics are decided. See Open Questions.
- Shipping Redis, Upstash, or any other non-memory adapter. `createCacheAdapter` is the documented extension point for that.
- Any TTL/eviction *policy* decisions beyond what the `CacheAdapter` interface's `set(key, value, { ttlMs })` shape allows an implementation to honor — the memory adapter implements lazy TTL expiry, but policy (e.g. LRU size caps) is an implementation detail of whichever adapter a consumer chooses.
- Reintroducing any platform-specific branch in `createFetchify` itself — the reason `./native` existed is now fully superseded by adapter subpaths.

## Decisions

### `CacheAdapter` shape: async string k/v store with optional TTL

```ts
// src/types/cache-adapter.ts
export interface CacheAdapter {
get(key: string): Promise<string | undefined>;
set(key: string, value: string, options?: { ttlMs?: number }): Promise<void>;
delete(key: string): Promise<void>;
has(key: string): Promise<boolean>;
}
```

- **Async everywhere**, even for the in-memory adapter: Redis/Upstash clients are inherently async, and a sync interface would force either a second "sync adapter" type or an awkward sync-only in-memory special case. A uniform async interface keeps `CacheAdapter` a single type regardless of backend.
- **`string` values**, not a generic `<T>`: fetchify's own response handling already normalizes to text before attempting `JSON.parse` (see `create-fetchify.ts`), and every realistic backend (Redis, Upstash, `AsyncStorage`) is a string/bytes store natively. A generic value type would push (de)serialization concerns onto every adapter author for no benefit at this stage; callers that need structured data serialize before `set` and parse after `get`.
- **`ttlMs` as a `set` option, not a separate method**: this is the one piece of behavior every backend needs to support to be useful for HTTP response caching, so it's part of the interface rather than an adapter-specific extra.
- **`has` included**: cheap to implement everywhere (Redis `EXISTS`, Upstash equivalent, `Map.has`) and lets consumers check presence without deserializing/touching a value, per the proposal's "evt. has".

Alternative considered: mirror the `Map`/`Storage` sync interface instead. Rejected because it can't represent real network-backed adapters (Redis, Upstash), which are the actual motivating use case for pluggability.

### `createCacheAdapter`: identity factory, same pattern as `defineOptions`

```ts
// src/lib/create-cache-adapter.ts
import type { CacheAdapter } from "../types/cache-adapter";

export function createCacheAdapter(adapter: CacheAdapter): CacheAdapter {
return adapter;
}
```

No runtime validation, matching `defineOptions`'s precedent exactly: the value it adds is a typed authoring entry point (parameter and return type both pinned to `CacheAdapter`), so a consumer's adapter object is checked against the interface at the call site with a useful error location, and the returned value is guaranteed to satisfy `CacheAdapter` for anything downstream (like `createFetchify`'s `cache` option). Adding real validation (e.g. checking each method is callable at runtime) would be dead weight — TypeScript's structural typing already does the job at the boundary that matters (compile time), and there's no untyped/external input here to validate against (unlike, say, parsing JSON from a network response).

### Memory adapter: lazy-expiry `Map`, factory default export, own subpath

```ts
// src/cache/memory.ts
import { createCacheAdapter } from "../lib/create-cache-adapter";
import type { CacheAdapter } from "../types/cache-adapter";

export function createMemoryCacheAdapter(): CacheAdapter { /* ... */ }
export default createMemoryCacheAdapter;
```

- **Factory, not a singleton instance**: a default-exported adapter *instance* would share one cache across every importer of `fetchify/cache/memory` in a process, which is surprising and makes tests/multiple clients interfere with each other. A factory function that returns a fresh adapter per call matches how consumers will actually use it: `createFetchify({ cache: createMemoryCacheAdapter() })`.
- **Lazy expiry over `setTimeout`-per-entry**: storing `{ value, expiresAt }` and checking `expiresAt` on `get`/`has` (deleting if past) avoids holding a timer handle per cache entry, which would leak in long-lived processes and has no equivalent concept in React Native environments with aggressive timer throttling. The trade-off is that an expired-but-unread key keeps its memory until the next `get`/`has`/`set` touches it — acceptable for a lightweight built-in adapter.
- **Own file, own subpath**: `src/cache/memory.ts` is the single build entry point mapping 1:1 to the `fetchify/cache/memory` subpath, the same convention `src/native.ts` used for `fetchify/native` — added to `tsconfig.build.json`'s `include` and the `bun build` entry list so `dist/cache/memory.js`/`.d.ts` are produced.

### `FetchifyOptions` gains `cache?: CacheAdapter`; `create-fetchify.ts` imports only the type

```ts
// src/lib/define-options.ts
import type { CacheAdapter } from "../types/cache-adapter";

export interface FetchifyOptions {
baseUrl?: string | URL;
cache?: CacheAdapter;
}
```

`create-fetchify.ts` adds `import type { CacheAdapter } from "../types/cache-adapter"` only — never `../cache/memory` or `create-cache-adapter`. This is what guarantees `createFetchify`'s module graph, and therefore `fetchify`'s main entry bundle, never pulls in the memory adapter's code. The option is accepted and typed on `FetchifyOptions` today; per the Non-Goals above, `createFetchify` does not yet read from or write to it during a request — that's follow-up work once cache-key/invalidation semantics are designed.

### `./native` removal: delete, don't deprecate-then-remove

Delete `src/native.ts` and `src/native.test.ts` outright and drop `"./native"` from `package.json` `exports` and the build script, rather than leaving a deprecated re-export for a release cycle. Rationale: `fetchify` is pre-1.0 (`version: "0.0.0"`) with no evidence of external consumers yet, and the proposal already routes this through a major-bump changeset with a migration note, which is the project's established mechanism (per `CLAUDE.md`) for breaking changes — a deprecation shim would add complexity the versioning workflow already makes unnecessary.

## Risks / Trade-offs

- **`cache` option with no request-path behavior yet** → could look like a no-op/broken feature if a consumer passes `cache` expecting caching to happen. Mitigated by `DOCS.md` explicitly stating current scope (the option exists for `createCacheAdapter`-built adapters to be constructed and typed against; wiring into request caching is future work) and by keeping the option's own scenario tests limited to "accepted without error," not "requests are cached."
- **Breaking `./native` removal** → any existing consumer importing `fetchify/native` breaks at their next install of the major version. Mitigated by the changeset's major bump (semver signals the break) and explicit migration instruction ("import `fetchify` instead of `fetchify/native`").
- **Lazy TTL expiry leaves stale entries in memory until touched** → in a long-running process with many short-TTL, never-re-read keys, the `Map` could grow unbounded. Acceptable for a lightweight default adapter aimed at typical request-cache lifetimes; consumers with high-volume/long-lived caching needs are expected to reach for `createCacheAdapter` with a real backend (Redis/Upstash), which is exactly the pluggability this change enables.

## Open Questions

- Should a follow-up change wire `cache` into `createFetchify`'s request flow (read-through on `get`/`head`, write-through after a successful response, invalidation on mutating verbs)? Left open pending a decision on cache-key derivation (method + URL only, or headers too?) and default TTL/staleness behavior — out of scope here.
33 changes: 33 additions & 0 deletions openspec/changes/archive/2026-08-02-add-cache-adapters/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## Why

fetchify currently has no caching primitive at all — every consumer that wants to cache responses has to roll their own wrapper around a `Fetchify` client. At the same time, the package still ships a `fetchify/native` entry point that has been a byte-for-byte duplicate of the web entry point since the initial scaffold (`c3580cf`), with no platform-specific logic and none planned. Introducing pluggable caching is a good moment to also retire that dead entry point, since the environment distinction it was meant to capture (web-safe vs. Node/Bun-only code) is now handled per-capability via cache adapter subpaths instead of a whole-client web/native split.

## What Changes

- Add a `CacheAdapter` interface (`get`/`set`/`delete`/`has`) in `src/types`, with no assumptions about platform or storage implementation.
- Add `createCacheAdapter`, a typed factory analogous to `defineOptions`, that types/validates a consumer-supplied object as a `CacheAdapter`. This is the supported way for consumers to build their own adapter (Redis, Upstash, etc.) without fetchify bundling those implementations.
- Add an in-memory `CacheAdapter` implementation, built with `createCacheAdapter`, with no external dependencies, published at its own subpath `fetchify/cache/memory` (not the main entry) so consumers who don't use it don't pay for it in their bundle.
- `createFetchify` accepts an optional `cache` option of type `CacheAdapter` on `FetchifyOptions`. `create-fetchify.ts` only imports the `CacheAdapter` *type*, never a concrete adapter implementation, so using `createFetchify` never pulls in a cache implementation unasked.
- `createCacheAdapter` and the `CacheAdapter` type are exported from the main entry (`.`), alongside `defineOptions`.
- Document the "bring your own adapter via `createCacheAdapter`" pattern in `DOCS.md`, including that Redis/Upstash/etc. adapters are intentionally not shipped by fetchify itself.
- **BREAKING**: Remove `src/native.ts` and the `"./native"` export from `package.json`. Existing consumers importing `fetchify/native` must switch to importing `fetchify` instead. Recorded via a major-bump changeset with migration instructions. If native-specific `createFetchify` logic is ever needed, `./native` can be reintroduced without another breaking change.

## Capabilities

### New Capabilities
- `cache-adapters`: The `CacheAdapter` interface, the `createCacheAdapter` factory, and `createFetchify`'s optional `cache` option — all available from the package's main entry point.
- `cache-memory-adapter`: The in-memory `CacheAdapter` implementation, built via `createCacheAdapter`, published at the `fetchify/cache/memory` subpath.

### Modified Capabilities
- `package-exports`: Remove the `"./native"` export and its dedicated build/entry-point requirements; add the `fetchify/cache/memory` subpath export requirement.
- `fetchify-options`: Drop the "available from both the web and native entry points" requirement (only one entry point remains); add the optional `cache` field of type `CacheAdapter` to `FetchifyOptions`.
- `http-client`: Drop the "available from both the web and native entry points" requirement for `createFetchify`.
- `project-documentation`: `DOCS.md`'s required coverage drops the native entry point and gains the cache adapter API surface (`CacheAdapter`, `createCacheAdapter`, the `cache` option, and the `fetchify/cache/memory` adapter) plus the "build your own adapter" pattern.

## Impact

- Code: `src/types` (new `CacheAdapter` type), `src/lib` (new `create-cache-adapter.ts`, updated `define-options.ts`/`create-fetchify.ts`), new `src/cache/memory.ts` (or similar) for the memory adapter, removal of `src/native.ts` and `src/native.test.ts`.
- Package surface: `package.json` `exports` map loses `"./native"`, gains `"./cache/memory"`; build script drops the `native.ts` build target and gains the memory adapter's.
- Docs: `DOCS.md` updated for the new API surface and the removed native entry point.
- Versioning: one changeset with a **major** bump (breaking `./native` removal) — the cache adapter additions are non-breaking and can ride in the same release since they don't warrant a separate minor-only changeset for a major release.
- Consumers: anyone importing `fetchify/native` must migrate to `fetchify`; no other behavioral change for existing `createFetchify`/`defineOptions` usage.
Loading
Loading