From 18cf01e0e9e0845b460f01aff9499cdd267617b1 Mon Sep 17 00:00:00 2001 From: Joey Kamsteeg Date: Sun, 2 Aug 2026 12:02:17 +0200 Subject: [PATCH] feat!: add pluggable cache adapters and remove fetchify/native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CacheAdapter interface, a createCacheAdapter factory for authoring custom adapters (Redis, Upstash, etc.), and a dependency-free in-memory adapter at fetchify/cache/memory. createFetchify's FetchifyOptions gains an optional cache field. BREAKING CHANGE: fetchify/native is removed. It has been an exact duplicate of the main fetchify entry point since the initial scaffold, with no platform-specific behavior. Import from fetchify instead — the API is identical. Co-Authored-By: Claude Sonnet 5 --- .changeset/tiny-cameras-jog.md | 7 ++ DOCS.md | 72 ++++++++++++-- .../.openspec.yaml | 2 + .../2026-08-02-add-cache-adapters/design.md | 97 +++++++++++++++++++ .../2026-08-02-add-cache-adapters/proposal.md | 33 +++++++ .../specs/cache-adapters/spec.md | 52 ++++++++++ .../specs/cache-memory-adapter/spec.md | 53 ++++++++++ .../specs/fetchify-options/spec.md | 26 +++++ .../specs/http-client/spec.md | 5 + .../specs/package-exports/spec.md | 41 ++++++++ .../specs/project-documentation/spec.md | 20 ++++ .../2026-08-02-add-cache-adapters/tasks.md | 43 ++++++++ openspec/specs/cache-adapters/spec.md | 58 +++++++++++ openspec/specs/cache-memory-adapter/spec.md | 59 +++++++++++ openspec/specs/fetchify-options/spec.md | 19 ++-- openspec/specs/http-client/spec.md | 13 +-- openspec/specs/package-exports/spec.md | 30 +++--- openspec/specs/project-documentation/spec.md | 14 ++- package.json | 8 +- src/cache/memory.test.ts | 76 +++++++++++++++ src/cache/memory.ts | 49 ++++++++++ src/index.ts | 2 + src/lib/create-cache-adapter.test.ts | 23 +++++ src/lib/create-cache-adapter.ts | 5 + src/lib/create-fetchify.test.ts | 14 +++ src/lib/define-options.test.ts | 14 +++ src/lib/define-options.ts | 3 + src/native.test.ts | 21 ---- src/native.ts | 8 -- src/types/cache-adapter.ts | 10 ++ tsconfig.build.json | 2 +- 31 files changed, 795 insertions(+), 84 deletions(-) create mode 100644 .changeset/tiny-cameras-jog.md create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/design.md create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/proposal.md create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/specs/cache-adapters/spec.md create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/specs/cache-memory-adapter/spec.md create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/specs/fetchify-options/spec.md create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/specs/http-client/spec.md create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/specs/package-exports/spec.md create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/specs/project-documentation/spec.md create mode 100644 openspec/changes/archive/2026-08-02-add-cache-adapters/tasks.md create mode 100644 openspec/specs/cache-adapters/spec.md create mode 100644 openspec/specs/cache-memory-adapter/spec.md create mode 100644 src/cache/memory.test.ts create mode 100644 src/cache/memory.ts create mode 100644 src/lib/create-cache-adapter.test.ts create mode 100644 src/lib/create-cache-adapter.ts delete mode 100644 src/native.test.ts delete mode 100644 src/native.ts create mode 100644 src/types/cache-adapter.ts diff --git a/.changeset/tiny-cameras-jog.md b/.changeset/tiny-cameras-jog.md new file mode 100644 index 0000000..96c7628 --- /dev/null +++ b/.changeset/tiny-cameras-jog.md @@ -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. diff --git a/DOCS.md b/DOCS.md index 9e84582..63466a6 100644 --- a/DOCS.md +++ b/DOCS.md @@ -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` @@ -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 @@ -70,6 +68,48 @@ 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` @@ -77,11 +117,29 @@ const client = createFetchify(options); ```ts interface FetchifyOptions { baseUrl?: string | URL; + cache?: CacheAdapter; } ``` Configuration accepted by `createFetchify` and `defineOptions`. +### `CacheAdapter` + +```ts +interface CacheAdapter { + get(key: string): Promise; + set( + key: string, + value: string, + options?: { ttlMs?: number }, + ): Promise; + delete(key: string): Promise; + has(key: string): Promise; +} +``` + +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 diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/.openspec.yaml b/openspec/changes/archive/2026-08-02-add-cache-adapters/.openspec.yaml new file mode 100644 index 0000000..d658936 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/design.md b/openspec/changes/archive/2026-08-02-add-cache-adapters/design.md new file mode 100644 index 0000000..1db5013 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/design.md @@ -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; + set(key: string, value: string, options?: { ttlMs?: number }): Promise; + delete(key: string): Promise; + has(key: string): Promise; +} +``` + +- **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 ``: 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. diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/proposal.md b/openspec/changes/archive/2026-08-02-add-cache-adapters/proposal.md new file mode 100644 index 0000000..3662727 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/proposal.md @@ -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. diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/cache-adapters/spec.md b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/cache-adapters/spec.md new file mode 100644 index 0000000..2cf6ecd --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/cache-adapters/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: CacheAdapter interface +The package SHALL define a `CacheAdapter` interface describing an async, string-valued key/value store with `get`, `set`, `delete`, and `has` methods, with no dependency on any particular storage backend or platform: `get(key: string): Promise`, `set(key: string, value: string, options?: { ttlMs?: number }): Promise`, `delete(key: string): Promise`, `has(key: string): Promise`. + +#### Scenario: Implementing a conforming adapter +- **WHEN** a consumer writes an object implementing `get`, `set`, `delete`, and `has` with the signatures above +- **THEN** that object type-checks as a `CacheAdapter` with no additional properties required + +#### Scenario: set accepts an optional TTL +- **WHEN** a consumer calls `adapter.set("key", "value", { ttlMs: 1000 })` +- **THEN** the call type-checks against `CacheAdapter`'s `set` signature + +### Requirement: createCacheAdapter factory +The package SHALL export a `createCacheAdapter` function that accepts an object conforming to `CacheAdapter` and returns it typed as `CacheAdapter`, giving consumers a typed entry point for authoring their own adapters (e.g. for Redis or Upstash) independently of `createFetchify`. + +#### Scenario: Building a custom adapter +- **WHEN** a consumer calls `createCacheAdapter({ get, set, delete: del, has })` with functions matching the `CacheAdapter` signatures +- **THEN** the call returns a `CacheAdapter` object equal to the argument passed in + +#### Scenario: Non-conforming object is rejected at compile time +- **WHEN** a consumer calls `createCacheAdapter(...)` with an object missing one of `get`, `set`, `delete`, or `has`, or with a mismatched signature +- **THEN** the call fails to type-check + +### Requirement: createFetchify accepts an optional cache option +`FetchifyOptions` SHALL include an optional `cache` field typed as `CacheAdapter`, so `createFetchify` accepts a `cache` option without requiring one. + +#### Scenario: Creating a client with a cache adapter +- **WHEN** a consumer calls `createFetchify({ cache: someCacheAdapter })` where `someCacheAdapter` conforms to `CacheAdapter` +- **THEN** the call succeeds and returns a `Fetchify` client, the same as if `cache` had been omitted + +#### Scenario: Creating a client without a cache adapter +- **WHEN** a consumer calls `createFetchify({})` or `createFetchify()` +- **THEN** the call succeeds and returns a `Fetchify` client with no cache adapter configured + +### Requirement: createFetchify's module graph does not import a concrete cache adapter +The module implementing `createFetchify` SHALL import only the `CacheAdapter` type, never a concrete `CacheAdapter` implementation, so that using `createFetchify` never transitively loads or bundles any specific cache adapter's code. + +#### Scenario: Importing createFetchify does not load the memory adapter +- **WHEN** a consumer imports only `createFetchify` from `"fetchify"` and never imports `"fetchify/cache/memory"` +- **THEN** no code from the in-memory cache adapter's module is loaded or executed + +### Requirement: Available from the main entry point +`createCacheAdapter` and the `CacheAdapter` type SHALL be exported from the package's main entry point (`fetchify`), alongside `defineOptions` and `FetchifyOptions`. + +#### Scenario: Importing createCacheAdapter +- **WHEN** a consumer imports `createCacheAdapter` from `"fetchify"` +- **THEN** the imported function behaves as described by this capability's other requirements + +#### Scenario: Importing the CacheAdapter type +- **WHEN** a consumer imports the `CacheAdapter` type from `"fetchify"` +- **THEN** it can be used to annotate a custom adapter object passed to `createCacheAdapter` or `createFetchify` diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/cache-memory-adapter/spec.md b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/cache-memory-adapter/spec.md new file mode 100644 index 0000000..4626681 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/cache-memory-adapter/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: In-memory CacheAdapter factory +The package SHALL provide a factory (default-exported from the `fetchify/cache/memory` subpath) that returns a fresh object conforming to `CacheAdapter`, built using `createCacheAdapter`, backed by an in-process store with no external dependencies. + +#### Scenario: Creating a memory adapter +- **WHEN** a consumer calls the default export from `"fetchify/cache/memory"` +- **THEN** the call returns an object conforming to `CacheAdapter` + +#### Scenario: Each call returns an independent adapter +- **WHEN** a consumer calls the factory twice +- **THEN** the two returned adapters do not share cached entries with each other + +### Requirement: Basic get/set/delete/has behavior +The in-memory adapter SHALL store a value under a key on `set`, return it from `get`, report its presence via `has`, and remove it via `delete`. + +#### Scenario: Round-tripping a value +- **WHEN** a consumer calls `adapter.set("key", "value")` and then `adapter.get("key")` +- **THEN** the resolved value is `"value"` + +#### Scenario: Checking presence +- **WHEN** a consumer calls `adapter.set("key", "value")` and then `adapter.has("key")` +- **THEN** the resolved value is `true` + +#### Scenario: Getting a missing key +- **WHEN** a consumer calls `adapter.get("missing")` on a key that was never set +- **THEN** the resolved value is `undefined` + +#### Scenario: Deleting a key +- **WHEN** a consumer calls `adapter.set("key", "value")`, then `adapter.delete("key")`, then `adapter.get("key")` +- **THEN** the resolved value is `undefined` + +### Requirement: TTL-based expiry +When `set` is called with a `ttlMs` option, the in-memory adapter SHALL treat the entry as expired once `ttlMs` milliseconds have elapsed, causing subsequent `get` and `has` calls to behave as if the key were never set. + +#### Scenario: Reading before expiry +- **WHEN** a consumer calls `adapter.set("key", "value", { ttlMs: 1000 })` and calls `adapter.get("key")` before 1000ms have elapsed +- **THEN** the resolved value is `"value"` + +#### Scenario: Reading after expiry +- **WHEN** a consumer calls `adapter.set("key", "value", { ttlMs: 1000 })` and calls `adapter.get("key")` after 1000ms have elapsed +- **THEN** the resolved value is `undefined` + +#### Scenario: No ttlMs means no expiry +- **WHEN** a consumer calls `adapter.set("key", "value")` without a `ttlMs` option +- **THEN** subsequent `adapter.get("key")` calls continue to resolve `"value"` regardless of elapsed time + +### Requirement: Platform-agnostic implementation +The in-memory adapter SHALL be implemented using only ECMAScript built-ins, with no Node.js-only, browser-only, or React Native-only APIs and no external package dependencies, so it runs unmodified on both web and React Native. + +#### Scenario: No external dependencies +- **WHEN** the `fetchify/cache/memory` subpath's module graph is inspected +- **THEN** it contains no imports from packages other than fetchify's own source diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/fetchify-options/spec.md b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/fetchify-options/spec.md new file mode 100644 index 0000000..aabb88c --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/fetchify-options/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: FetchifyOptions shape +`FetchifyOptions` SHALL describe the same option fields accepted by `createFetchify`: an optional `baseUrl` accepting either a `string` or a `URL` instance, and an optional `cache` accepting a `CacheAdapter`. + +#### Scenario: baseUrl as a string +- **WHEN** a consumer calls `defineOptions({ baseUrl: "https://api.example.com" })` +- **THEN** the call type-checks and returns the object unchanged + +#### Scenario: baseUrl as a URL instance +- **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })` +- **THEN** the call type-checks and returns the object unchanged + +#### Scenario: cache as a CacheAdapter +- **WHEN** a consumer calls `defineOptions({ cache: someCacheAdapter })` where `someCacheAdapter` conforms to `CacheAdapter` +- **THEN** the call type-checks and returns the object unchanged + +#### Scenario: No options +- **WHEN** a consumer calls `defineOptions({})` or `defineOptions()` +- **THEN** the call returns an empty (or undefined-defaulted) `FetchifyOptions` object without error + +## REMOVED Requirements + +### Requirement: Available from both the web and native entry points +**Reason**: The `fetchify/native` entry point has been removed (see `package-exports`); the package now has a single main entry point, so an "available from both entry points" requirement no longer applies. +**Migration**: Consumers importing `defineOptions` or `FetchifyOptions` from `"fetchify/native"` MUST import from `"fetchify"` instead. diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/http-client/spec.md b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/http-client/spec.md new file mode 100644 index 0000000..5d9c598 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/http-client/spec.md @@ -0,0 +1,5 @@ +## REMOVED Requirements + +### Requirement: Available from both the web and native entry points +**Reason**: The `fetchify/native` entry point has been removed (see `package-exports`); the package now has a single main entry point, so an "available from both entry points" requirement no longer applies. +**Migration**: Consumers importing `createFetchify` from `"fetchify/native"` MUST import from `"fetchify"` instead. diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/package-exports/spec.md b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/package-exports/spec.md new file mode 100644 index 0000000..fff2170 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/package-exports/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: In-memory cache adapter entry point +The package SHALL expose a `fetchify/cache/memory` subpath entry point that resolves to the in-memory `CacheAdapter` implementation, independent of the main entry point. + +#### Scenario: Importing the memory adapter entry +- **WHEN** a consumer imports `"fetchify/cache/memory"` +- **THEN** the module resolves to `dist/cache/memory.js` and its corresponding `dist/cache/memory.d.ts` types, and the import succeeds + +## MODIFIED Requirements + +### Requirement: Independent, side-effect-free entry modules +The package's entry points SHALL be independently importable such that importing one does not require or execute the module graph of another. + +#### Scenario: Importing the main entry does not load the memory adapter +- **WHEN** a consumer imports only `"fetchify"` +- **THEN** no code from the `fetchify/cache/memory` module graph is loaded or executed + +#### Scenario: Importing the memory adapter does not require the main entry's runtime behavior +- **WHEN** a consumer imports only `"fetchify/cache/memory"` +- **THEN** the module resolves and its factory is usable without importing or invoking `createFetchify` + +### Requirement: Package exports map +The package's `package.json` SHALL declare an `exports` map with entries for `"."` and `"./cache/memory"`, each specifying `types` and `default` conditions pointing at built output, so that Node.js, bundlers, and Metro can resolve both subpaths without relying on the legacy `main` field. + +#### Scenario: Resolving via exports map +- **WHEN** a module resolver reads `package.json` +- **THEN** it finds an `exports` entry for `"."` pointing at the main build and an `exports` entry for `"./cache/memory"` pointing at the memory adapter build, each with a `types` condition for its corresponding `.d.ts` file + +### Requirement: Buildable distributable output +The package SHALL provide a build script that compiles the main entry point and the in-memory cache adapter entry point into distributable JavaScript and type declaration files before the package can be consumed. + +#### Scenario: Running the build +- **WHEN** `bun run build` is executed +- **THEN** `dist/index.js`, `dist/index.d.ts`, `dist/cache/memory.js`, and `dist/cache/memory.d.ts` are all produced + +## REMOVED Requirements + +### Requirement: React Native entry point +**Reason**: `fetchify/native` (`src/native.ts`) has been a byte-for-byte duplicate of the main entry point since the initial scaffold, with no platform-specific logic and none planned. The environment distinction it existed for (web-safe vs. Node/Bun-only code) is now covered by dedicated adapter subpaths like `fetchify/cache/memory`, not by a whole-client web/native split. +**Migration**: Consumers importing from `"fetchify/native"` MUST import from `"fetchify"` instead. Both entry points always exported an identical API, so no other code changes are required. diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/project-documentation/spec.md b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/project-documentation/spec.md new file mode 100644 index 0000000..0e34ec8 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/specs/project-documentation/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: DOCS.md covers the public API surface +`DOCS.md` SHALL document every value and type exported from the package's main entry point (`fetchify`) and its `fetchify/cache/memory` subpath, including `createFetchify`, `defineOptions`, `FetchifyOptions`, `FetchifyMethod`, `CacheAdapter`, `createCacheAdapter`, and the in-memory cache adapter. + +#### Scenario: Reading about createFetchify +- **WHEN** a consumer reads `DOCS.md` to learn how to construct a client +- **THEN** it describes the `createFetchify` factory, its `FetchifyOptions` argument (including the optional `cache` option), and the HTTP methods on the returned client + +#### Scenario: Reading about defineOptions +- **WHEN** a consumer reads `DOCS.md` to learn how to author options separately from `createFetchify` +- **THEN** it describes the `defineOptions` factory and its relationship to `createFetchify` + +#### Scenario: Reading about building a custom cache adapter +- **WHEN** a consumer reads `DOCS.md` to learn how to integrate a cache backend other than the built-in memory adapter (e.g. Redis or Upstash) +- **THEN** it describes the `CacheAdapter` interface and the `createCacheAdapter` factory as the supported pattern for authoring one, and states that fetchify does not ship such adapters itself + +#### Scenario: Reading about the in-memory cache adapter +- **WHEN** a consumer reads `DOCS.md` to learn how to use the built-in cache adapter +- **THEN** it describes importing it from `fetchify/cache/memory` and passing the result to `createFetchify`'s `cache` option diff --git a/openspec/changes/archive/2026-08-02-add-cache-adapters/tasks.md b/openspec/changes/archive/2026-08-02-add-cache-adapters/tasks.md new file mode 100644 index 0000000..3fd1c65 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-add-cache-adapters/tasks.md @@ -0,0 +1,43 @@ +## 1. CacheAdapter type and factory + +- [x] 1.1 Add `src/types/cache-adapter.ts` exporting the `CacheAdapter` interface (`get`, `set` with optional `{ ttlMs?: number }`, `delete`, `has`) +- [x] 1.2 Add `src/lib/create-cache-adapter.ts` exporting `createCacheAdapter`, an identity factory typed `(adapter: CacheAdapter) => CacheAdapter`, mirroring `src/lib/define-options.ts` +- [x] 1.3 Add `src/lib/create-cache-adapter.test.ts` covering: returns the input unchanged, and a non-conforming object fails to type-check (compile-time test via a `// @ts-expect-error` case or equivalent) + +## 2. Wire `cache` into FetchifyOptions and createFetchify + +- [x] 2.1 Add optional `cache?: CacheAdapter` to `FetchifyOptions` in `src/lib/define-options.ts`, importing `CacheAdapter` as a type-only import +- [x] 2.2 `createFetchify(options: FetchifyOptions)` already accepts `cache` structurally via `FetchifyOptions`; no code change needed in `create-fetchify.ts` itself since there's nothing to do with the value yet (see design's Non-Goals) — added an explicit test instead of an unused local variable +- [x] 2.3 Update `src/lib/define-options.test.ts` and `src/lib/create-fetchify.test.ts` to cover a `cache` option being accepted (client construction succeeds with and without it) + +## 3. In-memory cache adapter + +- [x] 3.1 Add `src/cache/memory.ts` implementing a `createMemoryCacheAdapter` factory (default export) via `createCacheAdapter`, backed by a `Map` with lazy TTL expiry, no external dependencies +- [x] 3.2 Add `src/cache/memory.test.ts` covering: get/set/delete/has round-trips, missing-key `get`/`has`, TTL expiry before/after elapsed time, no-TTL entries never expiring, and two factory calls producing independent adapters + +## 4. Package exports and build + +- [x] 4.1 Add `"./cache/memory"` to `package.json`'s `exports` map (`types`/`default` pointing at `dist/cache/memory.d.ts`/`dist/cache/memory.js`) +- [x] 4.2 Remove `"./native"` from `package.json`'s `exports` map +- [x] 4.3 Update the `build` script in `package.json` to build `src/cache/memory.ts` instead of `src/native.ts` +- [x] 4.4 Update `tsconfig.build.json`'s `include` to list `src/cache/memory.ts` instead of `src/native.ts` +- [x] 4.5 Run `bun run build` and confirm `dist/index.js`, `dist/index.d.ts`, `dist/cache/memory.js`, and `dist/cache/memory.d.ts` are produced and `dist/native.*` is no longer produced + +## 5. Remove fetchify/native + +- [x] 5.1 Delete `src/native.ts` and `src/native.test.ts` +- [x] 5.2 Grep the repo for remaining `fetchify/native` or `native.ts` references outside `openspec/` and fix or confirm none remain in source/docs (found only `DOCS.md`, addressed in 7.1) + +## 6. Main entry exports + +- [x] 6.1 Export `createCacheAdapter` and the `CacheAdapter` type from `src/index.ts` + +## 7. Documentation + +- [x] 7.1 Update `DOCS.md`: remove the `fetchify/native` entry point section/references, document `CacheAdapter`, `createCacheAdapter`, the `cache` option on `FetchifyOptions`/`createFetchify`, the `fetchify/cache/memory` adapter, and the "build your own adapter via `createCacheAdapter`" pattern (including that fetchify does not ship Redis/Upstash/etc. adapters) +- [x] 7.2 Add a changeset (`.changeset/tiny-cameras-jog.md`) recording a **major** bump for the `./native` removal, with a migration note ("import `fetchify` instead of `fetchify/native`"), covering the cache-adapter additions in the same entry since they ship in the same release (written directly since `bun run changeset` is interactive) + +## 8. Verification + +- [x] 8.1 Run `bun test` and confirm all tests pass, including the new cache-adapter and memory-adapter tests +- [x] 8.2 Run `bun run build` and confirm no type errors and the expected `dist/` output described in 4.5 diff --git a/openspec/specs/cache-adapters/spec.md b/openspec/specs/cache-adapters/spec.md new file mode 100644 index 0000000..6c9c4b8 --- /dev/null +++ b/openspec/specs/cache-adapters/spec.md @@ -0,0 +1,58 @@ +# cache-adapters Specification + +## Purpose + +TBD - defines the `CacheAdapter` interface and `createCacheAdapter` factory that let `createFetchify` and third-party consumers plug in a caching backend without the package depending on any concrete implementation. + +## Requirements + +### Requirement: CacheAdapter interface +The package SHALL define a `CacheAdapter` interface describing an async, string-valued key/value store with `get`, `set`, `delete`, and `has` methods, with no dependency on any particular storage backend or platform: `get(key: string): Promise`, `set(key: string, value: string, options?: { ttlMs?: number }): Promise`, `delete(key: string): Promise`, `has(key: string): Promise`. + +#### Scenario: Implementing a conforming adapter +- **WHEN** a consumer writes an object implementing `get`, `set`, `delete`, and `has` with the signatures above +- **THEN** that object type-checks as a `CacheAdapter` with no additional properties required + +#### Scenario: set accepts an optional TTL +- **WHEN** a consumer calls `adapter.set("key", "value", { ttlMs: 1000 })` +- **THEN** the call type-checks against `CacheAdapter`'s `set` signature + +### Requirement: createCacheAdapter factory +The package SHALL export a `createCacheAdapter` function that accepts an object conforming to `CacheAdapter` and returns it typed as `CacheAdapter`, giving consumers a typed entry point for authoring their own adapters (e.g. for Redis or Upstash) independently of `createFetchify`. + +#### Scenario: Building a custom adapter +- **WHEN** a consumer calls `createCacheAdapter({ get, set, delete: del, has })` with functions matching the `CacheAdapter` signatures +- **THEN** the call returns a `CacheAdapter` object equal to the argument passed in + +#### Scenario: Non-conforming object is rejected at compile time +- **WHEN** a consumer calls `createCacheAdapter(...)` with an object missing one of `get`, `set`, `delete`, or `has`, or with a mismatched signature +- **THEN** the call fails to type-check + +### Requirement: createFetchify accepts an optional cache option +`FetchifyOptions` SHALL include an optional `cache` field typed as `CacheAdapter`, so `createFetchify` accepts a `cache` option without requiring one. + +#### Scenario: Creating a client with a cache adapter +- **WHEN** a consumer calls `createFetchify({ cache: someCacheAdapter })` where `someCacheAdapter` conforms to `CacheAdapter` +- **THEN** the call succeeds and returns a `Fetchify` client, the same as if `cache` had been omitted + +#### Scenario: Creating a client without a cache adapter +- **WHEN** a consumer calls `createFetchify({})` or `createFetchify()` +- **THEN** the call succeeds and returns a `Fetchify` client with no cache adapter configured + +### Requirement: createFetchify's module graph does not import a concrete cache adapter +The module implementing `createFetchify` SHALL import only the `CacheAdapter` type, never a concrete `CacheAdapter` implementation, so that using `createFetchify` never transitively loads or bundles any specific cache adapter's code. + +#### Scenario: Importing createFetchify does not load the memory adapter +- **WHEN** a consumer imports only `createFetchify` from `"fetchify"` and never imports `"fetchify/cache/memory"` +- **THEN** no code from the in-memory cache adapter's module is loaded or executed + +### Requirement: Available from the main entry point +`createCacheAdapter` and the `CacheAdapter` type SHALL be exported from the package's main entry point (`fetchify`), alongside `defineOptions` and `FetchifyOptions`. + +#### Scenario: Importing createCacheAdapter +- **WHEN** a consumer imports `createCacheAdapter` from `"fetchify"` +- **THEN** the imported function behaves as described by this capability's other requirements + +#### Scenario: Importing the CacheAdapter type +- **WHEN** a consumer imports the `CacheAdapter` type from `"fetchify"` +- **THEN** it can be used to annotate a custom adapter object passed to `createCacheAdapter` or `createFetchify` diff --git a/openspec/specs/cache-memory-adapter/spec.md b/openspec/specs/cache-memory-adapter/spec.md new file mode 100644 index 0000000..6a2f7e8 --- /dev/null +++ b/openspec/specs/cache-memory-adapter/spec.md @@ -0,0 +1,59 @@ +# cache-memory-adapter Specification + +## Purpose + +TBD - defines the built-in in-memory `CacheAdapter` implementation shipped at the `fetchify/cache/memory` subpath, usable as-is or as a reference for authoring other adapters. + +## Requirements + +### Requirement: In-memory CacheAdapter factory +The package SHALL provide a factory (default-exported from the `fetchify/cache/memory` subpath) that returns a fresh object conforming to `CacheAdapter`, built using `createCacheAdapter`, backed by an in-process store with no external dependencies. + +#### Scenario: Creating a memory adapter +- **WHEN** a consumer calls the default export from `"fetchify/cache/memory"` +- **THEN** the call returns an object conforming to `CacheAdapter` + +#### Scenario: Each call returns an independent adapter +- **WHEN** a consumer calls the factory twice +- **THEN** the two returned adapters do not share cached entries with each other + +### Requirement: Basic get/set/delete/has behavior +The in-memory adapter SHALL store a value under a key on `set`, return it from `get`, report its presence via `has`, and remove it via `delete`. + +#### Scenario: Round-tripping a value +- **WHEN** a consumer calls `adapter.set("key", "value")` and then `adapter.get("key")` +- **THEN** the resolved value is `"value"` + +#### Scenario: Checking presence +- **WHEN** a consumer calls `adapter.set("key", "value")` and then `adapter.has("key")` +- **THEN** the resolved value is `true` + +#### Scenario: Getting a missing key +- **WHEN** a consumer calls `adapter.get("missing")` on a key that was never set +- **THEN** the resolved value is `undefined` + +#### Scenario: Deleting a key +- **WHEN** a consumer calls `adapter.set("key", "value")`, then `adapter.delete("key")`, then `adapter.get("key")` +- **THEN** the resolved value is `undefined` + +### Requirement: TTL-based expiry +When `set` is called with a `ttlMs` option, the in-memory adapter SHALL treat the entry as expired once `ttlMs` milliseconds have elapsed, causing subsequent `get` and `has` calls to behave as if the key were never set. + +#### Scenario: Reading before expiry +- **WHEN** a consumer calls `adapter.set("key", "value", { ttlMs: 1000 })` and calls `adapter.get("key")` before 1000ms have elapsed +- **THEN** the resolved value is `"value"` + +#### Scenario: Reading after expiry +- **WHEN** a consumer calls `adapter.set("key", "value", { ttlMs: 1000 })` and calls `adapter.get("key")` after 1000ms have elapsed +- **THEN** the resolved value is `undefined` + +#### Scenario: No ttlMs means no expiry +- **WHEN** a consumer calls `adapter.set("key", "value")` without a `ttlMs` option +- **THEN** subsequent `adapter.get("key")` calls continue to resolve `"value"` regardless of elapsed time + +### Requirement: Platform-agnostic implementation +The in-memory adapter SHALL be implemented using only ECMAScript built-ins, with no Node.js-only, browser-only, or React Native-only APIs and no external package dependencies, so it runs unmodified on both web and React Native. + +#### Scenario: No external dependencies +- **WHEN** the `fetchify/cache/memory` subpath's module graph is inspected +- **THEN** it contains no imports from packages other than fetchify's own source diff --git a/openspec/specs/fetchify-options/spec.md b/openspec/specs/fetchify-options/spec.md index 19b34c5..c9853a6 100644 --- a/openspec/specs/fetchify-options/spec.md +++ b/openspec/specs/fetchify-options/spec.md @@ -2,7 +2,7 @@ ## Purpose -Defines the `defineOptions` factory and the `FetchifyOptions` type that consumers use to author fetchify configuration independently of `createFetchify`, available from both the web and React Native entry points. +Defines the `defineOptions` factory and the `FetchifyOptions` type that consumers use to author fetchify configuration independently of `createFetchify`. ## Requirements @@ -18,7 +18,7 @@ The package SHALL export a `defineOptions` function that accepts a `FetchifyOpti - **THEN** `createFetchify` behaves exactly as if the same object literal had been passed to it directly ### Requirement: FetchifyOptions shape -`FetchifyOptions` SHALL describe the same option fields accepted by `createFetchify`, currently an optional `baseUrl` accepting either a `string` or a `URL` instance. +`FetchifyOptions` SHALL describe the same option fields accepted by `createFetchify`: an optional `baseUrl` accepting either a `string` or a `URL` instance, and an optional `cache` accepting a `CacheAdapter`. #### Scenario: baseUrl as a string - **WHEN** a consumer calls `defineOptions({ baseUrl: "https://api.example.com" })` @@ -28,17 +28,10 @@ The package SHALL export a `defineOptions` function that accepts a `FetchifyOpti - **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })` - **THEN** the call type-checks and returns the object unchanged +#### Scenario: cache as a CacheAdapter +- **WHEN** a consumer calls `defineOptions({ cache: someCacheAdapter })` where `someCacheAdapter` conforms to `CacheAdapter` +- **THEN** the call type-checks and returns the object unchanged + #### Scenario: No options - **WHEN** a consumer calls `defineOptions({})` or `defineOptions()` - **THEN** the call returns an empty (or undefined-defaulted) `FetchifyOptions` object without error - -### Requirement: Available from both the web and native entry points -`defineOptions` and the `FetchifyOptions` type SHALL be available, with identical behavior, from both the package's web entry point (`fetchify`) and its React Native entry point (`fetchify/native`). - -#### Scenario: Importing from the web entry -- **WHEN** a consumer imports `defineOptions` from `"fetchify"` -- **THEN** the imported function behaves as described by this capability's other requirements - -#### Scenario: Importing from the native entry -- **WHEN** a consumer imports `defineOptions` from `"fetchify/native"` -- **THEN** the imported function behaves identically to the one imported from `"fetchify"` diff --git a/openspec/specs/http-client/spec.md b/openspec/specs/http-client/spec.md index efd633a..9b4fd63 100644 --- a/openspec/specs/http-client/spec.md +++ b/openspec/specs/http-client/spec.md @@ -2,7 +2,7 @@ ## Purpose -Defines the `createFetchify` HTTP client factory that consumers use to make requests against a configured base URL, available from both the web and React Native entry points. +Defines the `createFetchify` HTTP client factory that consumers use to make requests against a configured base URL. ## Requirements @@ -21,17 +21,6 @@ The package SHALL export a `createFetchify` function that accepts a `FetchifyOpt - **WHEN** a consumer calls `createFetchify(defineOptions({ baseUrl: "https://api.example.com" }))` - **THEN** the call returns a client behaving identically to one created by passing the equivalent object literal directly to `createFetchify` -### Requirement: Available from both the web and native entry points -`createFetchify` SHALL be available, with identical behavior, from both the package's web entry point (`fetchify`) and its React Native entry point (`fetchify/native`). - -#### Scenario: Importing from the web entry -- **WHEN** a consumer imports `createFetchify` from `"fetchify"` -- **THEN** the imported function behaves as described by this capability's other requirements - -#### Scenario: Importing from the native entry -- **WHEN** a consumer imports `createFetchify` from `"fetchify/native"` -- **THEN** the imported function behaves identically to the one imported from `"fetchify"` - ### Requirement: baseUrl option accepts string or URL `createFetchify`'s `baseUrl` option SHALL accept either a `string` or a `URL` instance, normalized to a `URL` for resolving request paths. diff --git a/openspec/specs/package-exports/spec.md b/openspec/specs/package-exports/spec.md index 82816ba..5b5f9f7 100644 --- a/openspec/specs/package-exports/spec.md +++ b/openspec/specs/package-exports/spec.md @@ -13,35 +13,35 @@ The package SHALL expose a default entry point importable as `fetchify` that res - **WHEN** a consumer imports `"fetchify"` - **THEN** the module resolves to `dist/index.js` and its corresponding `dist/index.d.ts` types, and the import succeeds without pulling in any React Native-only module -### Requirement: React Native entry point -The package SHALL expose a `fetchify/native` subpath entry point that resolves to a React Native-targeted build, independent of the web entry. +### Requirement: In-memory cache adapter entry point +The package SHALL expose a `fetchify/cache/memory` subpath entry point that resolves to the in-memory `CacheAdapter` implementation, independent of the main entry point. -#### Scenario: Importing the native entry -- **WHEN** a consumer imports `"fetchify/native"` -- **THEN** the module resolves to `dist/native.js` and its corresponding `dist/native.d.ts` types, and the import succeeds +#### Scenario: Importing the memory adapter entry +- **WHEN** a consumer imports `"fetchify/cache/memory"` +- **THEN** the module resolves to `dist/cache/memory.js` and its corresponding `dist/cache/memory.d.ts` types, and the import succeeds ### Requirement: Independent, side-effect-free entry modules -The web and native entry points SHALL be independently importable such that importing one does not require or execute the other. +The package's entry points SHALL be independently importable such that importing one does not require or execute the module graph of another. -#### Scenario: Importing web entry does not load native module +#### Scenario: Importing the main entry does not load the memory adapter - **WHEN** a consumer imports only `"fetchify"` -- **THEN** no code from the native entry point's module graph is loaded or executed +- **THEN** no code from the `fetchify/cache/memory` module graph is loaded or executed -#### Scenario: Importing native entry does not load web module -- **WHEN** a consumer imports only `"fetchify/native"` -- **THEN** no code from the web entry point's module graph is loaded or executed +#### Scenario: Importing the memory adapter does not require the main entry's runtime behavior +- **WHEN** a consumer imports only `"fetchify/cache/memory"` +- **THEN** the module resolves and its factory is usable without importing or invoking `createFetchify` ### Requirement: Package exports map -The package's `package.json` SHALL declare an `exports` map with entries for `"."` and `"./native"`, each specifying `types` and `default` conditions pointing at built output, so that Node.js, bundlers, and Metro can resolve both subpaths without relying on the legacy `main` field. +The package's `package.json` SHALL declare an `exports` map with entries for `"."` and `"./cache/memory"`, each specifying `types` and `default` conditions pointing at built output, so that Node.js, bundlers, and Metro can resolve both subpaths without relying on the legacy `main` field. #### Scenario: Resolving via exports map - **WHEN** a module resolver reads `package.json` -- **THEN** it finds an `exports` entry for `"."` pointing at the web build and an `exports` entry for `"./native"` pointing at the native build, each with a `types` condition for its corresponding `.d.ts` file +- **THEN** it finds an `exports` entry for `"."` pointing at the main build and an `exports` entry for `"./cache/memory"` pointing at the memory adapter build, each with a `types` condition for its corresponding `.d.ts` file ### Requirement: Buildable distributable output -The package SHALL provide a build script that compiles both entry points into distributable JavaScript and type declaration files before the package can be consumed. +The package SHALL provide a build script that compiles the main entry point and the in-memory cache adapter entry point into distributable JavaScript and type declaration files before the package can be consumed. #### Scenario: Running the build - **WHEN** `bun run build` is executed -- **THEN** `dist/index.js`, `dist/index.d.ts`, `dist/native.js`, and `dist/native.d.ts` are all produced +- **THEN** `dist/index.js`, `dist/index.d.ts`, `dist/cache/memory.js`, and `dist/cache/memory.d.ts` are all produced diff --git a/openspec/specs/project-documentation/spec.md b/openspec/specs/project-documentation/spec.md index 6c088bc..d5fbc24 100644 --- a/openspec/specs/project-documentation/spec.md +++ b/openspec/specs/project-documentation/spec.md @@ -14,19 +14,27 @@ The repository SHALL contain a `DOCS.md` file at its root documenting the packag - **THEN** `DOCS.md` at the repo root contains that documentation, separate from the install/release instructions in `README.md` ### Requirement: DOCS.md covers the public API surface -`DOCS.md` SHALL document every value and type exported from the package's entry points (`fetchify` and `fetchify/native`), including `createFetchify`, `defineOptions`, `FetchifyOptions`, and `FetchifyMethod`. +`DOCS.md` SHALL document every value and type exported from the package's main entry point (`fetchify`) and its `fetchify/cache/memory` subpath, including `createFetchify`, `defineOptions`, `FetchifyOptions`, `FetchifyMethod`, `CacheAdapter`, `createCacheAdapter`, and the in-memory cache adapter. #### Scenario: Reading about createFetchify - **WHEN** a consumer reads `DOCS.md` to learn how to construct a client -- **THEN** it describes the `createFetchify` factory, its `FetchifyOptions` argument, and the HTTP methods on the returned client +- **THEN** it describes the `createFetchify` factory, its `FetchifyOptions` argument (including the optional `cache` option), and the HTTP methods on the returned client #### Scenario: Reading about defineOptions - **WHEN** a consumer reads `DOCS.md` to learn how to author options separately from `createFetchify` - **THEN** it describes the `defineOptions` factory and its relationship to `createFetchify` +#### Scenario: Reading about building a custom cache adapter +- **WHEN** a consumer reads `DOCS.md` to learn how to integrate a cache backend other than the built-in memory adapter (e.g. Redis or Upstash) +- **THEN** it describes the `CacheAdapter` interface and the `createCacheAdapter` factory as the supported pattern for authoring one, and states that fetchify does not ship such adapters itself + +#### Scenario: Reading about the in-memory cache adapter +- **WHEN** a consumer reads `DOCS.md` to learn how to use the built-in cache adapter +- **THEN** it describes importing it from `fetchify/cache/memory` and passing the result to `createFetchify`'s `cache` option + ### Requirement: DOCS.md is kept current with the public API Whenever a change modifies the package's public API surface (adding, changing, or removing an export), that change SHALL update `DOCS.md` to reflect the new surface. #### Scenario: A change adds a new exported method -- **WHEN** a future change adds a new export to `src/index.ts` or `src/native.ts` +- **WHEN** a future change adds a new export to `src/index.ts` or a new subpath's entry module - **THEN** that change's scope includes updating `DOCS.md` to document the new export diff --git a/package.json b/package.json index aff2aa7..bc4bd6b 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,13 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./native": { - "types": "./dist/native.d.ts", - "default": "./dist/native.js" + "./cache/memory": { + "types": "./dist/cache/memory.d.ts", + "default": "./dist/cache/memory.js" } }, "scripts": { - "build": "bun build ./src/index.ts ./src/native.ts --outdir dist --target browser --format esm && tsc -p tsconfig.build.json", + "build": "bun build ./src/index.ts ./src/cache/memory.ts --outdir dist --target browser --format esm && tsc -p tsconfig.build.json", "changeset": "changeset", "version": "changeset version" }, diff --git a/src/cache/memory.test.ts b/src/cache/memory.test.ts new file mode 100644 index 0000000..55d1922 --- /dev/null +++ b/src/cache/memory.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { createMemoryCacheAdapter } from "./memory"; + +test("round-trips a value", async () => { + const adapter = createMemoryCacheAdapter(); + + await adapter.set("key", "value"); + + expect(await adapter.get("key")).toBe("value"); +}); + +test("has reports presence", async () => { + const adapter = createMemoryCacheAdapter(); + + await adapter.set("key", "value"); + + expect(await adapter.has("key")).toBe(true); +}); + +test("get returns undefined for a missing key", async () => { + const adapter = createMemoryCacheAdapter(); + + expect(await adapter.get("missing")).toBeUndefined(); +}); + +test("has returns false for a missing key", async () => { + const adapter = createMemoryCacheAdapter(); + + expect(await adapter.has("missing")).toBe(false); +}); + +test("delete removes a key", async () => { + const adapter = createMemoryCacheAdapter(); + + await adapter.set("key", "value"); + await adapter.delete("key"); + + expect(await adapter.get("key")).toBeUndefined(); +}); + +test("a value is still readable before its ttl elapses", async () => { + const adapter = createMemoryCacheAdapter(); + + await adapter.set("key", "value", { ttlMs: 1000 }); + + expect(await adapter.get("key")).toBe("value"); + expect(await adapter.has("key")).toBe(true); +}); + +test("a value expires after its ttl elapses", async () => { + const adapter = createMemoryCacheAdapter(); + + await adapter.set("key", "value", { ttlMs: 10 }); + await Bun.sleep(20); + + expect(await adapter.get("key")).toBeUndefined(); + expect(await adapter.has("key")).toBe(false); +}); + +test("a value with no ttl never expires", async () => { + const adapter = createMemoryCacheAdapter(); + + await adapter.set("key", "value"); + await Bun.sleep(20); + + expect(await adapter.get("key")).toBe("value"); +}); + +test("each call returns an independent adapter", async () => { + const a = createMemoryCacheAdapter(); + const b = createMemoryCacheAdapter(); + + await a.set("key", "value"); + + expect(await b.get("key")).toBeUndefined(); +}); diff --git a/src/cache/memory.ts b/src/cache/memory.ts new file mode 100644 index 0000000..6a8a2f1 --- /dev/null +++ b/src/cache/memory.ts @@ -0,0 +1,49 @@ +import { createCacheAdapter } from "../lib/create-cache-adapter"; +import type { CacheAdapter } from "../types/cache-adapter"; + +interface Entry { + value: string; + expiresAt: number | undefined; +} + +function isExpired(entry: Entry): boolean { + return entry.expiresAt !== undefined && entry.expiresAt <= Date.now(); +} + +export function createMemoryCacheAdapter(): CacheAdapter { + const store = new Map(); + + return createCacheAdapter({ + async get(key) { + const entry = store.get(key); + if (entry === undefined) return undefined; + if (isExpired(entry)) { + store.delete(key); + return undefined; + } + return entry.value; + }, + + async set(key, value, options) { + const expiresAt = + options?.ttlMs === undefined ? undefined : Date.now() + options.ttlMs; + store.set(key, { value, expiresAt }); + }, + + async delete(key) { + store.delete(key); + }, + + async has(key) { + const entry = store.get(key); + if (entry === undefined) return false; + if (isExpired(entry)) { + store.delete(key); + return false; + } + return true; + }, + }); +} + +export default createMemoryCacheAdapter; diff --git a/src/index.ts b/src/index.ts index 623db3a..6f01371 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,7 @@ export { createFetchify } from "./lib/create-fetchify"; export type { Fetchify } from "./lib/create-fetchify"; export { defineOptions } from "./lib/define-options"; export type { FetchifyOptions } from "./lib/define-options"; +export { createCacheAdapter } from "./lib/create-cache-adapter"; +export type { CacheAdapter } from "./types/cache-adapter"; export type { FetchifyMethod } from "./types/fetchify-method"; export type { FetchifyResponse } from "./types/fetchify-response"; diff --git a/src/lib/create-cache-adapter.test.ts b/src/lib/create-cache-adapter.test.ts new file mode 100644 index 0000000..c77bb12 --- /dev/null +++ b/src/lib/create-cache-adapter.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import type { CacheAdapter } from "../types/cache-adapter"; +import { createCacheAdapter } from "./create-cache-adapter"; + +const noop: CacheAdapter = { + get: async () => undefined, + set: async () => {}, + delete: async () => {}, + has: async () => false, +}; + +test("returns the input unchanged", () => { + expect(createCacheAdapter(noop)).toBe(noop); +}); + +test("rejects an object missing a required method", () => { + // @ts-expect-error missing `has` + createCacheAdapter({ + get: async () => undefined, + set: async () => {}, + delete: async () => {}, + }); +}); diff --git a/src/lib/create-cache-adapter.ts b/src/lib/create-cache-adapter.ts new file mode 100644 index 0000000..3c19f02 --- /dev/null +++ b/src/lib/create-cache-adapter.ts @@ -0,0 +1,5 @@ +import type { CacheAdapter } from "../types/cache-adapter"; + +export function createCacheAdapter(adapter: CacheAdapter): CacheAdapter { + return adapter; +} diff --git a/src/lib/create-fetchify.test.ts b/src/lib/create-fetchify.test.ts index ad8ae01..07d5c46 100644 --- a/src/lib/create-fetchify.test.ts +++ b/src/lib/create-fetchify.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import type { CacheAdapter } from "../types/cache-adapter"; import { createFetchify } from "./create-fetchify"; import { defineOptions } from "./define-options"; @@ -137,6 +138,19 @@ test("non-ok response yields null data", async () => { expect(response.bodyUsed).toBe(false); }); +test("accepts a cache option", () => { + const cache: CacheAdapter = { + get: async () => undefined, + set: async () => {}, + delete: async () => {}, + has: async () => false, + }; + + const client = createFetchify({ baseUrl: "https://api.example.com", cache }); + + expect(typeof client.get).toBe("function"); +}); + test("accepts options built with defineOptions", async () => { const client = createFetchify( defineOptions({ baseUrl: "https://api.example.com" }), diff --git a/src/lib/define-options.test.ts b/src/lib/define-options.test.ts index 16295cc..8bb8a18 100644 --- a/src/lib/define-options.test.ts +++ b/src/lib/define-options.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import type { CacheAdapter } from "../types/cache-adapter"; import { defineOptions } from "./define-options"; test("returns the input unchanged", () => { @@ -25,3 +26,16 @@ test("works with no arguments", () => { expect(options).toEqual({}); }); + +test("works with a cache adapter", () => { + const cache: CacheAdapter = { + get: async () => undefined, + set: async () => {}, + delete: async () => {}, + has: async () => false, + }; + + const options = defineOptions({ cache }); + + expect(options.cache).toBe(cache); +}); diff --git a/src/lib/define-options.ts b/src/lib/define-options.ts index 40ecf0b..afba95e 100644 --- a/src/lib/define-options.ts +++ b/src/lib/define-options.ts @@ -1,5 +1,8 @@ +import type { CacheAdapter } from "../types/cache-adapter"; + export interface FetchifyOptions { baseUrl?: string | URL; + cache?: CacheAdapter; } export function defineOptions( diff --git a/src/native.test.ts b/src/native.test.ts deleted file mode 100644 index 280106c..0000000 --- a/src/native.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { expect, test } from "bun:test"; -import { createFetchify, defineOptions, VERSION } from "./native"; - -test("native entry exports VERSION", () => { - expect(VERSION).toBeDefined(); -}); - -test("native entry exports createFetchify", () => { - expect(typeof createFetchify).toBe("function"); -}); - -test("native entry exports defineOptions", () => { - expect(typeof defineOptions).toBe("function"); -}); - -test("native entry: defineOptions works end-to-end with createFetchify", () => { - const options = defineOptions({ baseUrl: "https://api.example.com" }); - const client = createFetchify(options); - - expect(typeof client.get).toBe("function"); -}); diff --git a/src/native.ts b/src/native.ts deleted file mode 100644 index 623db3a..0000000 --- a/src/native.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const VERSION = "0.0.0"; - -export { createFetchify } from "./lib/create-fetchify"; -export type { Fetchify } from "./lib/create-fetchify"; -export { defineOptions } from "./lib/define-options"; -export type { FetchifyOptions } from "./lib/define-options"; -export type { FetchifyMethod } from "./types/fetchify-method"; -export type { FetchifyResponse } from "./types/fetchify-response"; diff --git a/src/types/cache-adapter.ts b/src/types/cache-adapter.ts new file mode 100644 index 0000000..03729c7 --- /dev/null +++ b/src/types/cache-adapter.ts @@ -0,0 +1,10 @@ +export interface CacheAdapter { + get(key: string): Promise; + set( + key: string, + value: string, + options?: { ttlMs?: number }, + ): Promise; + delete(key: string): Promise; + has(key: string): Promise; +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 5200c0f..c5c966e 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,6 +7,6 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src/index.ts", "src/native.ts"], + "include": ["src/index.ts", "src/cache/memory.ts"], "exclude": ["src/**/*.test.ts"] }