Skip to content

Commit 7aef8ce

Browse files
authored
Merge pull request #6 from kamsteegsoftware/feature/cache-adapters
feat!: add pluggable cache adapters and remove fetchify/native
2 parents 67d87c1 + 18cf01e commit 7aef8ce

31 files changed

Lines changed: 795 additions & 84 deletions

File tree

.changeset/tiny-cameras-jog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"fetchify": major
3+
---
4+
5+
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`.
6+
7+
**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.

DOCS.md

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,13 @@ This file is kept in sync with the package's exports — any change that adds, m
66

77
## Entry points
88

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

11-
- `fetchify` — web-targeted build (`dist/index.js`)
12-
- `fetchify/native` — React Native-targeted build (`dist/native.js`)
13-
14-
Both entry points export the same API: `createFetchify`, `defineOptions`, and their associated types.
11+
- `fetchify` — the package's API: `createFetchify`, `defineOptions`, `createCacheAdapter`, and their associated types (`dist/index.js`)
12+
- `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
1513

1614
```ts
1715
import { createFetchify, defineOptions } from "fetchify";
18-
// or, in a React Native app:
19-
import { createFetchify, defineOptions } from "fetchify/native";
2016
```
2117

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

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

56+
`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.
57+
6058
## `defineOptions`
6159

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

71+
## `createCacheAdapter`
72+
73+
```ts
74+
function createCacheAdapter(adapter: CacheAdapter): CacheAdapter;
75+
```
76+
77+
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:
78+
79+
```ts
80+
import { createCacheAdapter } from "fetchify";
81+
82+
const redisAdapter = createCacheAdapter({
83+
async get(key) {
84+
/* ... */
85+
},
86+
async set(key, value, options) {
87+
/* ... */
88+
},
89+
async delete(key) {
90+
/* ... */
91+
},
92+
async has(key) {
93+
/* ... */
94+
},
95+
});
96+
```
97+
98+
### Built-in: `fetchify/cache/memory`
99+
100+
```ts
101+
function createMemoryCacheAdapter(): CacheAdapter; // default export
102+
```
103+
104+
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).
105+
106+
```ts
107+
import createMemoryCacheAdapter from "fetchify/cache/memory";
108+
109+
const cache = createMemoryCacheAdapter();
110+
await cache.set("key", "value", { ttlMs: 60_000 });
111+
```
112+
73113
## Types
74114

75115
### `FetchifyOptions`
76116

77117
```ts
78118
interface FetchifyOptions {
79119
baseUrl?: string | URL;
120+
cache?: CacheAdapter;
80121
}
81122
```
82123

83124
Configuration accepted by `createFetchify` and `defineOptions`.
84125

126+
### `CacheAdapter`
127+
128+
```ts
129+
interface CacheAdapter {
130+
get(key: string): Promise<string | undefined>;
131+
set(
132+
key: string,
133+
value: string,
134+
options?: { ttlMs?: number },
135+
): Promise<void>;
136+
delete(key: string): Promise<void>;
137+
has(key: string): Promise<boolean>;
138+
}
139+
```
140+
141+
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.
142+
85143
### `FetchifyMethod`
86144

87145
```ts
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-08-02
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
## Context
2+
3+
`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.
4+
5+
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`).
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
- Define a minimal, storage-agnostic `CacheAdapter` interface that any key/value store (in-memory, Redis, Upstash, AsyncStorage, ...) can implement.
11+
- Provide `createCacheAdapter` as the one supported way to author a conforming adapter, mirroring `defineOptions`'s role for `FetchifyOptions`.
12+
- 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.
13+
- Let `createFetchify` accept a `cache` option typed as `CacheAdapter` without ever importing a concrete adapter implementation itself.
14+
- Remove `fetchify/native` cleanly, with a major-bump changeset and migration note.
15+
16+
**Non-Goals:**
17+
- 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.
18+
- Shipping Redis, Upstash, or any other non-memory adapter. `createCacheAdapter` is the documented extension point for that.
19+
- 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.
20+
- Reintroducing any platform-specific branch in `createFetchify` itself — the reason `./native` existed is now fully superseded by adapter subpaths.
21+
22+
## Decisions
23+
24+
### `CacheAdapter` shape: async string k/v store with optional TTL
25+
26+
```ts
27+
// src/types/cache-adapter.ts
28+
export interface CacheAdapter {
29+
get(key: string): Promise<string | undefined>;
30+
set(key: string, value: string, options?: { ttlMs?: number }): Promise<void>;
31+
delete(key: string): Promise<void>;
32+
has(key: string): Promise<boolean>;
33+
}
34+
```
35+
36+
- **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.
37+
- **`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`.
38+
- **`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.
39+
- **`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".
40+
41+
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.
42+
43+
### `createCacheAdapter`: identity factory, same pattern as `defineOptions`
44+
45+
```ts
46+
// src/lib/create-cache-adapter.ts
47+
import type { CacheAdapter } from "../types/cache-adapter";
48+
49+
export function createCacheAdapter(adapter: CacheAdapter): CacheAdapter {
50+
return adapter;
51+
}
52+
```
53+
54+
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).
55+
56+
### Memory adapter: lazy-expiry `Map`, factory default export, own subpath
57+
58+
```ts
59+
// src/cache/memory.ts
60+
import { createCacheAdapter } from "../lib/create-cache-adapter";
61+
import type { CacheAdapter } from "../types/cache-adapter";
62+
63+
export function createMemoryCacheAdapter(): CacheAdapter { /* ... */ }
64+
export default createMemoryCacheAdapter;
65+
```
66+
67+
- **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() })`.
68+
- **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.
69+
- **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.
70+
71+
### `FetchifyOptions` gains `cache?: CacheAdapter`; `create-fetchify.ts` imports only the type
72+
73+
```ts
74+
// src/lib/define-options.ts
75+
import type { CacheAdapter } from "../types/cache-adapter";
76+
77+
export interface FetchifyOptions {
78+
baseUrl?: string | URL;
79+
cache?: CacheAdapter;
80+
}
81+
```
82+
83+
`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.
84+
85+
### `./native` removal: delete, don't deprecate-then-remove
86+
87+
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.
88+
89+
## Risks / Trade-offs
90+
91+
- **`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."
92+
- **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`").
93+
- **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.
94+
95+
## Open Questions
96+
97+
- 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.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
## Why
2+
3+
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.
4+
5+
## What Changes
6+
7+
- Add a `CacheAdapter` interface (`get`/`set`/`delete`/`has`) in `src/types`, with no assumptions about platform or storage implementation.
8+
- 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.
9+
- 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.
10+
- `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.
11+
- `createCacheAdapter` and the `CacheAdapter` type are exported from the main entry (`.`), alongside `defineOptions`.
12+
- 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.
13+
- **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.
14+
15+
## Capabilities
16+
17+
### New Capabilities
18+
- `cache-adapters`: The `CacheAdapter` interface, the `createCacheAdapter` factory, and `createFetchify`'s optional `cache` option — all available from the package's main entry point.
19+
- `cache-memory-adapter`: The in-memory `CacheAdapter` implementation, built via `createCacheAdapter`, published at the `fetchify/cache/memory` subpath.
20+
21+
### Modified Capabilities
22+
- `package-exports`: Remove the `"./native"` export and its dedicated build/entry-point requirements; add the `fetchify/cache/memory` subpath export requirement.
23+
- `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`.
24+
- `http-client`: Drop the "available from both the web and native entry points" requirement for `createFetchify`.
25+
- `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.
26+
27+
## Impact
28+
29+
- 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`.
30+
- 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.
31+
- Docs: `DOCS.md` updated for the new API surface and the removed native entry point.
32+
- 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.
33+
- Consumers: anyone importing `fetchify/native` must migrate to `fetchify`; no other behavioral change for existing `createFetchify`/`defineOptions` usage.

0 commit comments

Comments
 (0)