|
| 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. |
0 commit comments