From 457035bb18caf90d1f4f4d190269c866ba648963 Mon Sep 17 00:00:00 2001 From: Dennis Paler Date: Thu, 6 Aug 2026 22:49:06 +0800 Subject: [PATCH 1/2] fix: packaging issue --- .github/workflows/ci.yml | 40 ++++++ .github/workflows/release.yml | 37 ++++-- CHANGELOG.md | 84 +++++++++++++ Cargo.lock | 8 +- Cargo.toml | 2 +- Formula/recached.rb | 6 +- README.md | 17 ++- docs/browser/getting-started.md | 110 ++++++++++++++--- docs/browser/offline.md | 1 + docs/guide/introduction.md | 2 +- docs/guide/use-cases.md | 57 +++++++++ docs/index.md | 14 +++ sdks/recached-react/package.json | 6 +- sdks/recached-vue/package.json | 6 +- wasm-edge/package.json | 16 ++- wasm-edge/scripts/verify-package.mjs | 177 +++++++++++++++++++++++++++ wasm-edge/tsconfig.json | 4 +- 17 files changed, 539 insertions(+), 48 deletions(-) create mode 100644 wasm-edge/scripts/verify-package.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb3b0c1..6492ad5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,46 @@ jobs: - name: Run browser tests run: wasm-pack test --headless --chrome wasm-edge + # Builds the npm package the way the release does, packs a tarball, and + # imports it from outside the working tree. Typecheck and unit tests both + # pass against a tree where `pkg/snippets/` sits on disk, so neither can see + # that it never ships — which is how 0.1.3 through 0.3.0 all published a + # package that threw ERR_MODULE_NOT_FOUND on import. + npm-package: + name: Package Installability (recached-edge) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: "recached-wasm-cache" + + - name: Install wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + # Pinned for the same reason as browser-tests above. + version: 'v0.15.0' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.x" + + - name: Build the package + run: cd wasm-edge && npm install && npm run build + + - name: Verify the tarball is installable + run: cd wasm-edge && npm run verify + integration-load-chaos: name: Load & Chaos Tests runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abe32cd..66684b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -207,21 +207,36 @@ jobs: node-version: "20.x" registry-url: "https://registry.npmjs.org" + # `--out-name recached_edge` is not cosmetic: sdk.ts imports + # `./pkg/recached_edge.js`, and the crate is named wasm-edge, so the + # default output (wasm_edge.js) would not match the import. - name: Build Wasm package - run: wasm-pack build wasm-edge --target web --release --out-name recached-edge + run: wasm-pack build wasm-edge --target web --release --out-name recached_edge + + # wasm-pack writes pkg/.gitignore containing "*". npm honours a nested + # .gitignore even inside a directory listed in "files", so leaving it + # here publishes an SDK with no WebAssembly in it. + - name: Drop wasm-pack's pkg/.gitignore + run: rm -f wasm-edge/pkg/.gitignore + + - name: Build the TypeScript SDK + # The published entrypoint is sdk.js — the `createCache`/`Cache` API every + # doc example and both framework SDKs import. Releases up to 0.3.0 + # published wasm-edge/pkg instead, which exports only the low-level + # `RecachedCache` binding, so the documented API was never on npm at all. + run: cd wasm-edge && npm install && npx tsc - name: Copy LICENSE + NOTICE into package - run: cp LICENSE.md NOTICE wasm-edge/pkg/ + run: cp LICENSE.md NOTICE wasm-edge/ - - name: Set package name and version - # wasm-pack derives the npm name and version from the crate — patch both so the - # published package always matches the git tag (e.g. v0.1.5 → 0.1.5). + - name: Set package version + # The npm name lives in wasm-edge/package.json already; only the version + # is patched, so it always matches the git tag (e.g. v0.3.1 → 0.3.1). run: | node -e " const fs = require('fs'); - const path = 'wasm-edge/pkg/package.json'; + const path = 'wasm-edge/package.json'; const pkg = JSON.parse(fs.readFileSync(path, 'utf8')); - pkg.name = 'recached-edge'; pkg.version = process.env.TAG_VERSION.replace(/^v/, ''); fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); console.log('Package:', pkg.name, pkg.version); @@ -229,10 +244,16 @@ jobs: env: TAG_VERSION: ${{ github.ref_name }} + # Packs a real tarball and imports it from outside the tree: proves the + # snippets/ glue resolves and that `createCache` is actually exported. + # Every release from 0.1.3 to 0.3.0 failed both and shipped anyway. + - name: Verify the package is installable + run: cd wasm-edge && npm run verify + - name: Publish to NPM env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: cd wasm-edge/pkg && npm publish --access public + run: cd wasm-edge && npm publish --access public npm-sdks: name: Publish @recached/${{ matrix.package }} to NPM diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5e039..9bd2aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,90 @@ All notable changes to Recached are documented here. --- +## [0.3.1] — 2026-08-06 + +A packaging-only release. No engine, server or SDK behaviour changed — but every +`recached-edge` version before this one was impossible to install, so in practice this is the +first usable browser release. + +### Fixed — the npm package could not be imported (0.1.3 – 0.3.0) + +- **`snippets/` was never published.** The wasm-bindgen glue opens with + `import { openRecachedDb, … } from './snippets//inline0.js'` — the IndexedDB + helpers behind `enable_persistence`. That directory was in neither the tarball nor the `files` + array wasm-pack generates, so `npm install recached-edge` followed by any import died with + `ERR_MODULE_NOT_FOUND` before a line of application code ran. It bundles statically, so + webpack, Turbopack and Vite all failed at build time, not at runtime. Fourteen consecutive + releases shipped this way: the break dates from 0.1.3, when the IndexedDB helpers were added. + +- **The documented API was not the published one.** The release workflow published + `wasm-edge/pkg` — raw wasm-pack output, whose only exports are `RecachedCache`, `initSync` and + a default init. `createCache`, `Cache`, `onMutation`, `getJSON` and the ref-counted `liveQuery` + live in `sdk.js`, which was never published. Every example in the README and docs imported a + symbol that did not exist on npm, and both framework SDKs peer-depend on it. The release now + publishes the `wasm-edge` package itself, with `pkg/` nested inside it. + +- **`sdk.ts` imported a filename no build produced.** It loads `./pkg/recached_edge.js`, but the + crate is named `wasm-edge` (default output `wasm_edge.js`) and the release built with + `--out-name recached-edge` (output `recached-edge.js`). Both build paths now pass + `--out-name recached_edge`, matching the import and the stub CI already generated. + +- **`pkg/.gitignore` would have silently emptied the fixed package.** wasm-pack writes one + containing `*`, and npm applies a nested `.gitignore` even to a directory listed in `files` — + so simply switching the publish directory would have shipped an SDK with no WebAssembly in it. + `npm run build:wasm` deletes it, and the release deletes it again before packing. + +### Added — a release gate that would have caught all of the above + +- **`wasm-edge/scripts/verify-package.mjs`**, run by a new `npm-package` CI job on every push and + by the release workflow immediately before `npm publish`. It packs a real tarball, extracts it + outside the working tree, walks the import graph from `sdk.js` through the glue to `snippets/`, + and imports the result in Node to assert `createCache`, `Cache` and `init` are exported. It + also runs from `prepack`, so a manual `npm publish` cannot bypass it. + + The gap this closes: typecheck, unit tests and `wasm-pack test` all pass against a working tree + where `snippets/` is present on disk. None of them can observe what `files` excludes. Only + packing and importing from outside the tree can, and nothing did that. + +- `LICENSE.md` and `NOTICE` now ship inside the npm package (`files`), and the meaningless + `licenseFile: "../LICENSE.md"` key — which pointed outside the package — was dropped. + +### Documentation — running the client with no server + +Local-only mode was supported in code and mentioned in passing, but never documented as a mode with +edges. It now is, because "can I use this as a client cache without running the server?" has a +sharper answer than the docs were giving: + +- **New [use case: no server at all](docs/guide/use-cases.md)** — what works standalone, and a table + of what is *inert* rather than broken. `publish`/`subscribe`/`onMessage`, `liveQuery`, + `syncToken`/`syncScopes` and `pendingWrites`/`onOutboxFull` do not throw without a connection; they + silently do nothing, and pub/sub in particular does **not** fall back to BroadcastChannel. Also + states where local-only Recached is the wrong choice: against React Query or SWR for a plain + request cache, a ~550 KB `.wasm` buys Redis semantics and nothing else. +- **The "one-sentence test"** framed Recached as pointless unless a client reads backend-written + data. It now names the third answer — no server in the picture at all. +- **[Getting Started (Browser)](docs/browser/getting-started.md)** gained the same works/inert split + next to its local-only example. +- **Known wart, now written down:** with `persistence: true` and no `connect`, every write still + records an IndexedDB outbox row for a replay that cannot happen, and warns `offline write queue + full` past 10,000 writes. Nothing is lost; it is wasted I/O and a misleading message. + (`wasm-edge/src/lib.rs` — `queue_write` skips the outbox only when there is neither a URL *nor* + persistence.) +- **The Next.js (App Router) example was a no-op** — a provider that returned its children untouched + and imported a symbol it never used. Replaced with the real client-only pattern: `RecachedProvider` + in a `'use client'` boundary, plus the plain `useEffect` version. Documents that the provider + renders `null` until `createCache()` resolves in an effect, so wrapping the whole app opts the page + out of SSR. + +### Changed + +- `@recached/react` and `@recached/vue` raise their `recached-edge` peer floor from `>=0.1.4` to + `>=0.3.1`. The old range was satisfiable only by versions that cannot be imported. +- `wasm-edge/tsconfig.json` adds `ESNext.Disposable` to `lib`: wasm-bindgen 0.2.120 emits + `[Symbol.dispose]()` on the generated class, which ES2020's lib does not declare. + +--- + ## [0.3.0] — 2026-08-03 ### Added diff --git a/Cargo.lock b/Cargo.lock index 9972e07..d623146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,7 +124,7 @@ dependencies = [ [[package]] name = "core-engine" -version = "0.3.0" +version = "0.3.1" dependencies = [ "dashmap", "indexmap", @@ -855,7 +855,7 @@ dependencies = [ [[package]] name = "recached" -version = "0.3.0" +version = "0.3.1" dependencies = [ "base64", "core-engine", @@ -1195,7 +1195,7 @@ dependencies = [ [[package]] name = "sync-client" -version = "0.3.0" +version = "0.3.1" dependencies = [ "core-engine", ] @@ -1581,7 +1581,7 @@ checksum = "60238e5b4b1b295701d6f9a66d2a126fe19990348f5fb9dae3b623a370119d94" [[package]] name = "wasm-edge" -version = "0.3.0" +version = "0.3.1" dependencies = [ "core-engine", "getrandom 0.3.4", diff --git a/Cargo.toml b/Cargo.toml index a9431ca..cab83e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ resolver = "2" # ── Single source of truth for all crate versions ──────────────────────────── # Members inherit with: version.workspace = true / edition.workspace = true [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "Apache-2.0" authors = ["ThinkGrid Labs"] diff --git a/Formula/recached.rb b/Formula/recached.rb index cc4e450..575dcb7 100644 --- a/Formula/recached.rb +++ b/Formula/recached.rb @@ -1,7 +1,7 @@ class Recached < Formula desc "Blazing fast, multi-core drop-in replacement for Redis" homepage "https://github.com/recached-dev/recached" - version "0.3.0" + version "0.3.1" license "Apache-2.0" # The checksums below are placeholders until the v0.2.4 release artifacts @@ -16,11 +16,11 @@ class Recached < Formula # placeholder makes brew fail loudly, which is the far better failure. on_macos do on_intel do - url "https://github.com/recached-dev/recached/releases/download/v0.3.0/recached-macos-amd64" + url "https://github.com/recached-dev/recached/releases/download/v0.3.1/recached-macos-amd64" sha256 "REPLACE_WITH_AMD64_SHA256" end on_arm do - url "https://github.com/recached-dev/recached/releases/download/v0.3.0/recached-macos-arm64" + url "https://github.com/recached-dev/recached/releases/download/v0.3.1/recached-macos-arm64" sha256 "REPLACE_WITH_ARM64_SHA256" end end diff --git a/README.md b/README.md index eb29b85..5ca9404 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ npm install recached-edge ``` > [!IMPORTANT] -> **0.3.0 is the first stable release.** Install `recached-edge@^0.3.0`; earlier versions are not -> recommended. +> **Install `recached-edge@^0.3.1`.** Every published version from 0.1.3 to 0.3.0 shipped without +> wasm-pack's `snippets/` directory and failed to import at all; 0.3.1 is the first release that +> installs from npm. See the [changelog](CHANGELOG.md) for details. --- @@ -96,6 +97,18 @@ password, no TLS, and no restriction on which web pages may open the sync socket `6379` and `6380` are defaults, not fixtures — set `RECACHED_PORT` and `RECACHED_WS_PORT` (plus `RECACHED_METRICS_PORT`) to move them, which is also what running two instances on one host takes. +**The browser half also runs alone.** Drop `connect` and `recached-edge` never opens a socket — the +same engine runs in WASM as a standalone client cache with TTLs, counters, JSON documents, glob +queries, IndexedDB persistence and cross-tab sync, with no Recached server and no backend changes: + +```typescript +const cache = await createCache({ persistence: true, broadcastChannel: 'my-app' }); +cache.setJSON('user:42', user, 60); // expires on its own, survives a refresh +``` + +What you give up is what needs a peer: pub/sub, live queries and cross-device sync. See +[use cases: no server at all](https://recached.dev/guide/use-cases#no-server-at-all-the-client-cache-on-its-own). + --- ## Benchmarks diff --git a/docs/browser/getting-started.md b/docs/browser/getting-started.md index 4223b32..346b61a 100644 --- a/docs/browser/getting-started.md +++ b/docs/browser/getting-started.md @@ -1,14 +1,21 @@ # Getting Started (Browser) -::: danger recached-edge 0.1.1 – 0.2.0 are broken in the browser -`core-engine` read the clock via `std::time::SystemTime::now()`, which **panics** on -`wasm32-unknown-unknown`. The clock is read on nearly every operation, so **no store write -completes** on those versions — and a client whose write-ahead log passed the compaction threshold -erased its own persisted cache before the replacement snapshot was written. - -Fixed in **0.2.1**. Install `recached-edge@^0.2.1` or later. - -The Recached **server is unaffected**: it runs on a native target where the clock works normally. +::: danger Install `recached-edge@^0.3.1` — every earlier version is unusable +Two separate defects, both fixed as of **0.3.1**: + +**Packaging (0.1.3 – 0.3.0).** The published tarball omitted wasm-pack's `snippets/` directory, +which the generated glue imports on its first line, and published wasm-pack's `pkg/` output instead +of the SDK — so `npm install recached-edge` failed at module resolution before any application code +ran, and `createCache` was never on npm at all. Fixed by publishing the SDK package (with +`snippets/`) and gating every release on a tarball that is packed and imported in CI. + +**Clock panic (0.1.1 – 0.2.0).** `core-engine` read the clock via `std::time::SystemTime::now()`, +which **panics** on `wasm32-unknown-unknown`. The clock is read on nearly every operation, so **no +store write completes** on those versions — and a client whose write-ahead log passed the compaction +threshold erased its own persisted cache before the replacement snapshot was written. Fixed in 0.2.1. + +The Recached **server is unaffected** by both: it runs on a native target where the clock works +normally, and it ships as a binary rather than through npm. ::: The `recached-edge` package is the TypeScript SDK for the browser WASM client. It gives you a `Cache` class backed by the same `core-engine` as the server, with optional WebSocket sync to a Recached server instance. @@ -198,6 +205,30 @@ See the [Vue composables docs](/vue/getting-started) for the full guide. Do not pass `connect` to `createCache()`. The WASM module runs as a pure in-memory cache with TTL — no server, no WebSocket, no backend changes required. +This is a supported mode, not a degraded one: the same `core-engine` that runs on the server runs in +the tab, so the local command surface is identical either way. + +```typescript +const cache = await createCache({ + persistence: true, // IndexedDB WAL — survives refresh, no server needed + broadcastChannel: 'my-app', // cross-tab fan-out — no server needed +}) // no `connect` — nothing is networked +``` + +**Works with no server:** `get`/`set`/`del`, `getJSON`/`setJSON`, `getBytes`/`setBytes`, `setEx` and +TTL expiry, `exists`/`ttl`, `incr`/`decr`, `jset`/`jget`/`jmerge`, `getMatching`, `onMutation`, +`persistence`, `broadcastChannel`. + +**Silently does nothing with no server** — these do not throw, they have nowhere to send to: +`publish`, `subscribe`/`unsubscribe`/`onMessage` (pub/sub is server-brokered and does *not* fall back +to BroadcastChannel), `liveQuery`, `syncToken`/`syncScopes`, and `pendingWrites`/`onOutboxFull`. + +::: warning `persistence: true` with no `connect` +Every write still records an outbox row in IndexedDB for a replay that can never happen, and past +10,000 writes the console shows `offline write queue full`. Wasted I/O and a misleading warning — +your data and the WAL are fine. Use `persistence: false` if the noise matters to you. +::: + ```typescript import { createCache } from 'recached-edge' @@ -286,20 +317,67 @@ export default defineConfig({ ### Next.js (App Router) -```typescript +The cache is browser-only: `createCache()` is async, fetches a `.wasm` file, and touches +`indexedDB` and `BroadcastChannel`. So it must be created in a client component after hydration, +never at module scope in anything the server renders. + +`@recached/react` does this for you — its provider builds the cache in an effect: + +```tsx // app/providers.tsx 'use client' -import { createCache } from 'recached-edge' -import { cache as cacheRef } from '../lib/cache' +import { RecachedProvider } from '@recached/react' + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + // Local-only: drop `connect` and no socket is ever opened. + + {children} + + ) +} +``` -export function CacheProvider({ children }: { children: React.ReactNode }) { - // WASM must be initialized in a client component (after hydration) - return <>{children} +```tsx +// app/layout.tsx — a server component; only Providers is client-side +import { Providers } from './providers' + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) } ``` -Use `@recached/react` for the recommended Next.js App Router integration — it wraps WASM init and the cache lifecycle automatically. +::: warning The provider renders `null` until the cache is ready +`createCache()` resolves in an effect, which does not run during SSR — so anything inside +`` is absent from the server-rendered HTML and appears on hydration. Wrapping your +entire app therefore opts the whole page out of SSR. Mount it around the subtree that actually reads +the cache, and keep content you need server-rendered (or indexed) outside it. +::: + +Without the React SDK, do the same thing by hand — build the cache in `useEffect`, or reach for +`next/dynamic` with `ssr: false` on the component that uses it: + +```tsx +'use client' + +import { useEffect, useState } from 'react' +import { createCache, type Cache } from 'recached-edge' + +export function useLocalCache(): Cache | null { + const [cache, setCache] = useState(null) + useEffect(() => { + let cancelled = false + createCache({ persistence: true }).then((c) => !cancelled && setCache(c)) + return () => { cancelled = true } + }, []) + return cache +} +``` ### webpack diff --git a/docs/browser/offline.md b/docs/browser/offline.md index 0ae30a6..22d6eb6 100644 --- a/docs/browser/offline.md +++ b/docs/browser/offline.md @@ -53,3 +53,4 @@ cache.incr('cart:count') - **LWW means arrival order, not wall-clock order.** A `set` replayed from a client that was offline for an hour overwrites the server's newer value for that key. Prefer operation forms for anything multiple parties write. - `clearPersistence()` (sign-out) discards unsent offline writes along with the local state. - Reconnection uses `window.setTimeout` — in non-browser environments without a `window`, auto-reconnect is inactive. +- **The outbox also fills in local-only mode.** A cache created with `persistence: true` but no `connect` still records a row per write for a replay that can never happen, and warns `offline write queue full` past 10,000 of them. Nothing is lost — the store and its WAL are unaffected — but the queue is pure overhead there. See [no server at all](/guide/use-cases#no-server-at-all-the-client-cache-on-its-own). diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index f8a5538..285d1f1 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -43,7 +43,7 @@ Recached is a good fit when: - **Your frontend reads the same data your backend writes.** User sessions, feature flags, live counters, cart state, active user lists — anything your backend mutates that the UI needs to display instantly. - **You want live UI without polling.** The WebSocket sync replaces a polling loop without requiring you to build a separate SSE or WebSocket server. -- **You want a frontend-only cache with TTL.** The WASM module works entirely without a server. Call `createCache()` without `connect()` and you get a local in-memory cache with built-in TTL — no Recached server, no Redis, no backend changes required. +- **You want a frontend-only cache with TTL.** The WASM module works entirely without a server. Call `createCache()` without `connect` and you get a local cache with TTLs, counters, JSON documents, glob queries, optional IndexedDB persistence and cross-tab sync — no Recached server, no Redis, no backend changes required. Pub/sub, live queries and cross-device sync are what you give up; see [no server at all](/guide/use-cases#no-server-at-all-the-client-cache-on-its-own) for the full boundary. - **You need cross-tab sync.** BroadcastChannel support means all open tabs in the same browser share mutations automatically. - **You want a drop-in Redis replacement** for the subset of commands most applications actually use (strings, expiry, counters, collections, transactions, pub/sub). diff --git a/docs/guide/use-cases.md b/docs/guide/use-cases.md index dae0856..da3e44d 100644 --- a/docs/guide/use-cases.md +++ b/docs/guide/use-cases.md @@ -17,6 +17,12 @@ If **yes**, you are currently paying for that in one of three ways: polling, a b layer, or a second client-side cache you invalidate by hand. Removing that cost is the entire reason Recached exists. +There is a third answer the question does not cover: **no, and there is no server involved at all.** +The browser client is a complete cache on its own — see +[no server at all](#no-server-at-all-the-client-cache-on-its-own) below. That is a narrower pitch +than the one this page is mostly about, and it is judged against `Map`, IndexedDB wrappers and +React Query rather than against Redis. + ## Where the difference actually shows up ### Live dashboards and counters @@ -64,6 +70,57 @@ are unreachable when the network is down, by definition. Multiple tabs of the same app share mutations through BroadcastChannel without any server round-trip. With Redis this is either polling per tab or a `storage` event protocol you write yourself. +### No server at all — the client cache on its own + +Omit `connect` and `recached-edge` never opens a socket. Nothing else changes: the same +`core-engine` state machine that runs on the server runs in WASM in the tab, so you get the full +local command surface with no backend of any kind. + +```typescript +const cache = await createCache({ + persistence: true, // IndexedDB WAL — survives refresh + broadcastChannel: 'my-app', // cross-tab — needs no server +}) // no `connect` — nothing is networked +``` + +**What you get without a server:** reads and writes (`get`/`set`/`del`, `getJSON`/`setJSON`), +TTL that expires on its own, `incr`/`decr`, JSON documents with path writes and merge patches +(`jset`/`jget`/`jmerge`), glob snapshots over the keyspace (`getMatching`), change notification via +`onMutation`, refresh-survival through the IndexedDB WAL, and cross-tab fan-out through +BroadcastChannel. + +**What is inert without a server**, because it has nothing to talk to — these do not throw, they +simply do nothing: + +| API | Why it needs a server | +|---|---| +| `publish` / `subscribe` / `unsubscribe` / `onMessage` | Pub/sub is brokered by the server; there is no local loopback, and messages do **not** travel over BroadcastChannel | +| `liveQuery` | The initial state snapshot and the change stream are both server-sent | +| `syncToken` / `syncScopes` | Scope grants are a server-side authorization decision | +| `pendingWrites` / `onOutboxFull` | They describe a replay queue for a server that will never connect | + +Cross-*device* sync is the other obvious absence: two browsers with no server between them share +nothing. + +**Where this earns its place.** A TTL cache in front of `fetch` (the pattern in +[Getting Started](/browser/getting-started#without-a-server-local-only-cache)) is the common one — +declaring expiry once at write time instead of hand-rolling `fetchedAt` comparisons in a Zustand or +Redux store. Beyond that: state that must survive a refresh without you writing an IndexedDB schema, +tabs that must agree without a `storage`-event protocol, and read-heavy derived state where a +local map lookup per render is the point. + +**Where it does not.** If you only need a request cache with revalidation, React Query and SWR do +that with far less machinery and no WebAssembly to load. `recached-edge` ships a ~550 KB `.wasm` +binary; that cost buys Redis semantics, and if you are not using them it buys nothing. The honest +framing: pick local-only Recached when you want the *data model* — TTLs, counters, JSON paths, glob +queries — not merely somewhere to park fetched JSON. + +**One wart to know about.** With `persistence: true` and no `connect`, every write still records an +outbox row in IndexedDB for a replay that cannot happen, and after 10,000 of them you will see +`offline write queue full` warnings in the console. It is wasted I/O and a misleading message, not +data loss — the local store and its WAL are unaffected. Pass `persistence: false`, or ignore the +warning, until this is fixed. + ## Where you should reach for something else Being specific here is more useful than a feature grid. diff --git a/docs/index.md b/docs/index.md index 6518f8b..b3d0870 100644 --- a/docs/index.md +++ b/docs/index.md @@ -68,3 +68,17 @@ cache.onMutation(() => { ``` No polling. No extra state management library. No round-trips for reads. The server is your backend's cache; the WASM module is your frontend's cache; the WebSocket is the invisible sync layer between them. + +### Or just the browser half + +The sync layer is optional. Omit `connect` and no socket is opened: `recached-edge` becomes a +standalone client cache — TTLs, counters, JSON documents, glob queries, IndexedDB persistence and +cross-tab sync — with no Recached server anywhere and no changes to your backend. + +```typescript +const cache = await createCache({ persistence: true, broadcastChannel: 'my-app' }) +cache.setJSON('user:42', user, 60) // expires on its own, survives a refresh +``` + +Pub/sub, live queries and cross-device sync are the parts that need a server. See +[no server at all](/guide/use-cases#no-server-at-all-the-client-cache-on-its-own). diff --git a/sdks/recached-react/package.json b/sdks/recached-react/package.json index 9692e5e..fbe45ee 100644 --- a/sdks/recached-react/package.json +++ b/sdks/recached-react/package.json @@ -1,7 +1,7 @@ { "name": "@recached/react", - "version": "0.3.0", - "description": "Official React hooks for Recached \u2014 zero-latency reactive cache", + "version": "0.3.1", + "description": "Official React hooks for Recached — zero-latency reactive cache", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", @@ -25,7 +25,7 @@ }, "peerDependencies": { "react": ">=18", - "recached-edge": ">=0.1.4" + "recached-edge": ">=0.3.1" }, "devDependencies": { "@types/react": "^18", diff --git a/sdks/recached-vue/package.json b/sdks/recached-vue/package.json index 14ce222..6ddd4d3 100644 --- a/sdks/recached-vue/package.json +++ b/sdks/recached-vue/package.json @@ -1,7 +1,7 @@ { "name": "@recached/vue", - "version": "0.3.0", - "description": "Official Vue 3 composables for Recached \u2014 zero-latency reactive cache", + "version": "0.3.1", + "description": "Official Vue 3 composables for Recached — zero-latency reactive cache", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", @@ -25,7 +25,7 @@ }, "peerDependencies": { "vue": ">=3", - "recached-edge": ">=0.1.4" + "recached-edge": ">=0.3.1" }, "devDependencies": { "@vue/runtime-core": "^3", diff --git a/wasm-edge/package.json b/wasm-edge/package.json index e2d02b6..9d2e164 100644 --- a/wasm-edge/package.json +++ b/wasm-edge/package.json @@ -1,7 +1,7 @@ { "name": "recached-edge", - "description": "Browser and edge WebAssembly client for Recached \u2014 zero-latency local cache with automatic server sync", - "version": "0.3.0", + "description": "Browser and edge WebAssembly client for Recached — zero-latency local cache with automatic server sync", + "version": "0.3.1", "type": "module", "main": "sdk.js", "module": "sdk.js", @@ -17,10 +17,15 @@ "sdk.js", "sdk.d.ts", "sdk.d.ts.map", - "pkg/" + "pkg/", + "LICENSE.md", + "NOTICE" ], "scripts": { - "build": "wasm-pack build --target web --out-dir pkg && tsc", + "build": "npm run build:wasm && tsc", + "build:wasm": "rm -rf pkg && wasm-pack build --target web --out-dir pkg --out-name recached_edge && rm -f pkg/.gitignore", + "prepack": "node scripts/verify-package.mjs --pre", + "verify": "node scripts/verify-package.mjs", "typecheck": "tsc --noEmit", "version:patch": "npm version patch --no-git-tag-version", "version:minor": "npm version minor --no-git-tag-version", @@ -52,6 +57,5 @@ "browser-cache", "zero-latency" ], - "license": "Apache-2.0", - "licenseFile": "../LICENSE.md" + "license": "Apache-2.0" } diff --git a/wasm-edge/scripts/verify-package.mjs b/wasm-edge/scripts/verify-package.mjs new file mode 100644 index 0000000..23e07e1 --- /dev/null +++ b/wasm-edge/scripts/verify-package.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +// Packaging guard for the `recached-edge` npm package. +// +// Every published version from 0.1.3 to 0.3.0 was impossible to import: the +// wasm-bindgen glue starts with +// +// import { openRecachedDb, ... } from './snippets//inline0.js'; +// +// and `snippets/` was in neither the published tarball nor the `files` array +// wasm-pack generates. `npm install recached-edge` then failed at module +// resolution before any application code ran. Nothing caught it because the +// package was never installed from a tarball — only ever used from the working +// tree, where `snippets/` is right there on disk. +// +// The second trap is subtler: wasm-pack writes `pkg/.gitignore` containing `*`, +// and npm honours a nested .gitignore even for a path listed in `files`. So +// publishing the SDK directory with `"files": ["pkg/"]` silently ships an SDK +// with no WebAssembly in it at all. +// +// Both are invisible to typecheck, unit tests and `npm run build`. Only packing +// a tarball and importing it from outside the tree finds them, which is what +// this script does. +// +// node scripts/verify-package.mjs --pre # working tree, runs from prepack +// node scripts/verify-package.mjs # pack a real tarball and import it +// +// Exits non-zero with a specific reason on the first failure. + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const PKG_ROOT = path.resolve(import.meta.dirname, '..'); +const PRE = process.argv.includes('--pre'); + +const problems = []; +const fail = (msg) => problems.push(msg); + +/** The `./pkg/.js` specifier sdk.js loads the wasm-bindgen glue from. */ +function glueSpecifier(sdkSource) { + const m = sdkSource.match(/import\(\s*['"](\.\/pkg\/[^'"]+)['"]\s*\)/); + return m ? m[1] : null; +} + +/** Every relative specifier a module imports at the top level. */ +function staticImports(source) { + return [...source.matchAll(/from\s*['"](\.[^'"]+)['"]/g)].map((m) => m[1]); +} + +/** + * Resolve the import graph starting at sdk.js and report anything missing. + * `root` is a directory laid out like the published package. + */ +function checkTree(root, label) { + const sdkPath = path.join(root, 'sdk.js'); + if (!existsSync(sdkPath)) { + fail(`${label}: sdk.js is missing — the SDK entrypoint is what package.json "main" points at. Run \`npx tsc\`.`); + return null; + } + + const spec = glueSpecifier(readFileSync(sdkPath, 'utf8')); + if (!spec) { + fail(`${label}: sdk.js has no dynamic import of ./pkg/* — cannot locate the wasm glue to verify.`); + return null; + } + + const gluePath = path.join(root, spec); + if (!existsSync(gluePath)) { + const built = existsSync(path.join(root, 'pkg')) + ? readdirSync(path.join(root, 'pkg')).filter((f) => f.endsWith('.js')).join(', ') || 'none' + : 'no pkg/ directory at all'; + fail( + `${label}: sdk.js imports "${spec}" but that file does not exist (pkg/ holds: ${built}). ` + + `Build with \`--out-name recached_edge\` so the emitted name matches the import.`, + ); + return null; + } + + const glueSource = readFileSync(gluePath, 'utf8'); + for (const dep of staticImports(glueSource)) { + const depPath = path.resolve(path.dirname(gluePath), dep); + if (!existsSync(depPath)) { + fail( + `${label}: the wasm glue imports "${dep}" but it is not present. ` + + `This is the 0.1.3–0.3.0 bug: wasm-pack's snippets/ directory must ship with the package.`, + ); + } + } + + const wasm = readdirSync(path.join(root, 'pkg')).filter((f) => f.endsWith('.wasm')); + if (wasm.length === 0) fail(`${label}: pkg/ contains no .wasm file.`); + + return { sdkPath, gluePath }; +} + +// ── working-tree checks (also run from prepack) ─────────────────────────────── + +if (existsSync(path.join(PKG_ROOT, 'pkg', '.gitignore'))) { + fail( + 'pkg/.gitignore exists. wasm-pack writes it containing "*", and npm applies a nested ' + + '.gitignore even to a directory listed in "files" — packing now would drop the whole ' + + 'pkg/ tree. `npm run build:wasm` removes it; delete it before publishing.', + ); +} + +checkTree(PKG_ROOT, 'working tree'); + +// ── tarball checks ──────────────────────────────────────────────────────────── +// The working tree can be complete while the tarball is not: `files`, nested +// .gitignore and .npmignore all decide what actually ships. So pack for real +// and import the result the way a consumer would. + +let tmp; +if (!PRE && problems.length === 0) { + tmp = mkdtempSync(path.join(tmpdir(), 'recached-pack-')); + try { + const out = execFileSync('npm', ['pack', '--json', '--pack-destination', tmp], { + cwd: PKG_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); + // `npm pack` runs prepack, whose output lands on this stdout too, so the + // JSON is not necessarily the whole of it. + const json = out.slice(out.indexOf('[')); + const tarball = path.join(tmp, JSON.parse(json)[0].filename); + execFileSync('tar', ['-xzf', tarball, '-C', tmp]); + const extracted = path.join(tmp, 'package'); + + const found = checkTree(extracted, 'tarball'); + + if (found) { + // Resolution is the thing that broke, so import both modules for real. + // The glue only defines bindings at import time — instantiating the wasm + // needs a fetchable URL, which is a browser concern, so we stop here. + try { + await import(pathToFileURL(found.gluePath).href); + } catch (e) { + fail(`tarball: importing the wasm glue failed — ${e.code ?? ''} ${e.message.split('\n')[0]}`); + } + + try { + const sdk = await import(pathToFileURL(found.sdkPath).href); + for (const name of ['createCache', 'init', 'Cache']) { + if (!(name in sdk)) { + fail( + `tarball: the package does not export \`${name}\`. Every doc example imports it; ` + + `0.1.0–0.3.0 published wasm-pack's pkg/ directory, which exports only RecachedCache.`, + ); + } + } + } catch (e) { + fail(`tarball: importing sdk.js failed — ${e.code ?? ''} ${e.message.split('\n')[0]}`); + } + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +// ── report ──────────────────────────────────────────────────────────────────── + +if (problems.length > 0) { + console.error(`\nrecached-edge package verification failed (${problems.length}):\n`); + for (const p of problems) console.error(` ✗ ${p}\n`); + process.exit(1); +} + +// stderr in --pre: it runs inside `npm pack`, and anything on stdout there +// corrupts `npm pack --json` for whatever is parsing it. +const report = PRE ? console.error : console.log; +report( + PRE + ? 'recached-edge: working tree is publishable (glue + snippets resolve, no pkg/.gitignore).' + : 'recached-edge: tarball verified — snippets ship, glue resolves, createCache is exported.', +); diff --git a/wasm-edge/tsconfig.json b/wasm-edge/tsconfig.json index 514167f..ff2ac2b 100644 --- a/wasm-edge/tsconfig.json +++ b/wasm-edge/tsconfig.json @@ -3,7 +3,9 @@ "target": "ES2020", "module": "ES2020", "moduleResolution": "bundler", - "lib": ["ES2020", "DOM"], + // ESNext.Disposable: wasm-bindgen 0.2.120 emits `[Symbol.dispose](): void` + // on the generated class, which ES2020's lib does not know about. + "lib": ["ES2020", "DOM", "ESNext.Disposable"], "strict": true, "declaration": true, "declarationMap": true, From d190c958e8ed3a8e4ea3273cf321421919aecccb Mon Sep 17 00:00:00 2001 From: Dennis Paler Date: Thu, 6 Aug 2026 22:52:53 +0800 Subject: [PATCH 2/2] fix: packaging issue --- README.md | 4 ++-- docs/browser/offline.md | 2 +- docs/guide/introduction.md | 2 +- docs/guide/use-cases.md | 6 +++--- docs/index.md | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5ca9404..f18a571 100644 --- a/README.md +++ b/README.md @@ -107,13 +107,13 @@ cache.setJSON('user:42', user, 60); // expires on its own, survives a refresh ``` What you give up is what needs a peer: pub/sub, live queries and cross-device sync. See -[use cases: no server at all](https://recached.dev/guide/use-cases#no-server-at-all-the-client-cache-on-its-own). +[use cases: no server at all](https://recached.dev/guide/use-cases#no-server-at-all). --- ## Benchmarks -Measured with `redis-benchmark` (100k requests, 50 connections, 64-byte values, randomized keys, persistence disabled on all servers) on a 4-core Intel i5-8259U laptop, July 2026 — Recached v0.1.8 vs Redis 7.2.5 vs Valkey 9.1.0, one server at a time. Current release is v0.3.0; these command paths were A/B tested across the v0.2.4 changes and spot-checked again on v0.3.0 (SET 455k, GET 518k, INCR 526k pipelined on the same laptop), moving within run-to-run noise each time — but the three-way suite has not been re-run since v0.1.8. +Measured with `redis-benchmark` (100k requests, 50 connections, 64-byte values, randomized keys, persistence disabled on all servers) on a 4-core Intel i5-8259U laptop, July 2026 — Recached v0.1.8 vs Redis 7.2.5 vs Valkey 9.1.0, one server at a time. Current release is v0.3.1 (packaging only — no engine change since v0.3.0); these command paths were A/B tested across the v0.2.4 changes and spot-checked again on v0.3.0 (SET 455k, GET 518k, INCR 526k pipelined on the same laptop), moving within run-to-run noise each time — but the three-way suite has not been re-run since v0.1.8. Pipelined (`-P 16`) — raw command throughput, requests/sec, **bold** = best per row: diff --git a/docs/browser/offline.md b/docs/browser/offline.md index 22d6eb6..dc962eb 100644 --- a/docs/browser/offline.md +++ b/docs/browser/offline.md @@ -53,4 +53,4 @@ cache.incr('cart:count') - **LWW means arrival order, not wall-clock order.** A `set` replayed from a client that was offline for an hour overwrites the server's newer value for that key. Prefer operation forms for anything multiple parties write. - `clearPersistence()` (sign-out) discards unsent offline writes along with the local state. - Reconnection uses `window.setTimeout` — in non-browser environments without a `window`, auto-reconnect is inactive. -- **The outbox also fills in local-only mode.** A cache created with `persistence: true` but no `connect` still records a row per write for a replay that can never happen, and warns `offline write queue full` past 10,000 of them. Nothing is lost — the store and its WAL are unaffected — but the queue is pure overhead there. See [no server at all](/guide/use-cases#no-server-at-all-the-client-cache-on-its-own). +- **The outbox also fills in local-only mode.** A cache created with `persistence: true` but no `connect` still records a row per write for a replay that can never happen, and warns `offline write queue full` past 10,000 of them. Nothing is lost — the store and its WAL are unaffected — but the queue is pure overhead there. See [no server at all](/guide/use-cases#no-server-at-all). diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 285d1f1..8bac134 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -43,7 +43,7 @@ Recached is a good fit when: - **Your frontend reads the same data your backend writes.** User sessions, feature flags, live counters, cart state, active user lists — anything your backend mutates that the UI needs to display instantly. - **You want live UI without polling.** The WebSocket sync replaces a polling loop without requiring you to build a separate SSE or WebSocket server. -- **You want a frontend-only cache with TTL.** The WASM module works entirely without a server. Call `createCache()` without `connect` and you get a local cache with TTLs, counters, JSON documents, glob queries, optional IndexedDB persistence and cross-tab sync — no Recached server, no Redis, no backend changes required. Pub/sub, live queries and cross-device sync are what you give up; see [no server at all](/guide/use-cases#no-server-at-all-the-client-cache-on-its-own) for the full boundary. +- **You want a frontend-only cache with TTL.** The WASM module works entirely without a server. Call `createCache()` without `connect` and you get a local cache with TTLs, counters, JSON documents, glob queries, optional IndexedDB persistence and cross-tab sync — no Recached server, no Redis, no backend changes required. Pub/sub, live queries and cross-device sync are what you give up; see [no server at all](/guide/use-cases#no-server-at-all) for the full boundary. - **You need cross-tab sync.** BroadcastChannel support means all open tabs in the same browser share mutations automatically. - **You want a drop-in Redis replacement** for the subset of commands most applications actually use (strings, expiry, counters, collections, transactions, pub/sub). diff --git a/docs/guide/use-cases.md b/docs/guide/use-cases.md index da3e44d..918db67 100644 --- a/docs/guide/use-cases.md +++ b/docs/guide/use-cases.md @@ -19,7 +19,7 @@ reason Recached exists. There is a third answer the question does not cover: **no, and there is no server involved at all.** The browser client is a complete cache on its own — see -[no server at all](#no-server-at-all-the-client-cache-on-its-own) below. That is a narrower pitch +[no server at all](#no-server-at-all) below. That is a narrower pitch than the one this page is mostly about, and it is judged against `Map`, IndexedDB wrappers and React Query rather than against Redis. @@ -70,9 +70,9 @@ are unreachable when the network is down, by definition. Multiple tabs of the same app share mutations through BroadcastChannel without any server round-trip. With Redis this is either polling per tab or a `storage` event protocol you write yourself. -### No server at all — the client cache on its own +### No server at all -Omit `connect` and `recached-edge` never opens a socket. Nothing else changes: the same +The client cache on its own. Omit `connect` and `recached-edge` never opens a socket. Nothing else changes: the same `core-engine` state machine that runs on the server runs in WASM in the tab, so you get the full local command surface with no backend of any kind. diff --git a/docs/index.md b/docs/index.md index b3d0870..1e1d11a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -81,4 +81,4 @@ cache.setJSON('user:42', user, 60) // expires on its own, survives a refresh ``` Pub/sub, live queries and cross-device sync are the parts that need a server. See -[no server at all](/guide/use-cases#no-server-at-all-the-client-cache-on-its-own). +[no server at all](/guide/use-cases#no-server-at-all).