# PersistedCache

`PersistedCache` is a two-layer cache for [`Persistable`](https://effect.plants.sh/persistence/persistence/)
request keys. Each `get(key)` walks three layers in order:

1. **Process-local memory** — a [`Cache`](https://effect.plants.sh/caching/cache/) keyed by the request
   value, bounded by `inMemoryCapacity` and `inMemoryTTL`.
2. **Durable persistence** — a named [`Persistence`](https://effect.plants.sh/persistence/persistence/)
   store, selected by `storeId`, that holds the encoded result `Exit`.
3. **The lookup** — your `(key) => Effect<Success, Error, R>`, run only when
   neither layer has the value.

Both successes **and** failures are stored as `Exit` values, so a failed lookup
is remembered too (and replayed) for as long as the persistent TTL allows. With
an in-memory backing store the cache de-duplicates work within a single runtime;
with SQL, Redis, or `KeyValueStore` backing it survives process restarts and is
shared across workers that point at the same store.

```ts
import { Effect, Schema } from "effect"
import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"

// 1. Describe the request as a Persistable.Class:
//    - the tag and payload become the request shape
//    - primaryKey is the durable entry id (must be stable + collision-free)
//    - success/error are the schemas used to encode/decode the stored Exit
class GetUser extends Persistable.Class<{ payload: { id: number } }>()("GetUser", {
  primaryKey: ({ id }) => `GetUser:${id}`,
  success: Schema.Struct({ id: Schema.Number, name: Schema.String }),
  error: Schema.Never
}) {}

const program = Effect.gen(function*() {
  const cache = yield* PersistedCache.make(
    // The lookup only runs on a miss in BOTH layers
    Effect.fn("getUser")(function*(req: GetUser) {
      yield* Effect.log(`fetching user ${req.id}`)
      return { id: req.id, name: `User ${req.id}` }
    }),
    {
      storeId: "users",
      // TTL for the DURABLE layer, computed per result + request
      timeToLive: (exit) => (exit._tag === "Success" ? "1 hour" : "1 minute")
    }
  )

  const first = yield* cache.get(new GetUser({ id: 1 }))  // logs "fetching user 1"
  const second = yield* cache.get(new GetUser({ id: 1 })) // no log: served from memory
  // => first and second are the same { id: 1, name: "User 1" }
  return [first, second]
})

// make needs Persistence + Scope; provide a backing layer at the edge
Effect.runPromise(
  Effect.scoped(program.pipe(Effect.provide(Persistence.layerMemory)))
)
```
**make is scoped:** `PersistedCache.make` allocates a scoped persistence store and a scoped
  in-memory cache, so it requires `Scope.Scope` (use `Effect.scoped` or run it
  inside a `Layer`) **and** a `Persistence` service. Swap `Persistence.layerMemory`
  for `Persistence.layerSql`, `layerRedis`, or `layerKvs` to make the durable
  layer outlive the process. See [Persistence](https://effect.plants.sh/persistence/persistence/) for the
  backing layers.

## Surviving a restart

Because the durable layer stores the encoded `Exit`, the same request resolved in
a previous process (or by another worker) is replayed without re-running the
lookup — as long as the entry is still within its `timeToLive` and the schemas /
`primaryKey` / `storeId` are unchanged.

```ts
import { Effect, Schema } from "effect"
import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"
import { SqlClient } from "effect/unstable/sql"

class GetReport extends Persistable.Class<{ payload: { day: string } }>()("GetReport", {
  primaryKey: ({ day }) => `GetReport:${day}`,
  success: Schema.Struct({ day: Schema.String, total: Schema.Number })
}) {}

const makeCache = PersistedCache.make(
  Effect.fn("buildReport")(function*(req: GetReport) {
    // expensive aggregation runs once per day, then is reused across restarts
    return { day: req.day, total: 42 }
  }),
  {
    storeId: "daily-reports",
    timeToLive: () => "24 hours"
  }
)

// Persistence.layerSql persists results in a shared `effect_persistence` table.
// It requires a SqlClient layer (chosen per dialect at the edge).
declare const SqlLive: import("effect").Layer.Layer<SqlClient.SqlClient>

const program = Effect.scoped(
  Effect.gen(function*() {
    const cache = yield* makeCache
    return yield* cache.get(new GetReport({ day: "2026-05-30" }))
    // => { day: "2026-05-30", total: 42 } — recomputed only if the row expired
  })
).pipe(Effect.provide(Persistence.layerSql), Effect.provide(SqlLive))
```

## Invalidating entries

When the underlying data changes, call `invalidate(key)` so both layers forget
it. It removes the **persisted** value first, then drops the in-memory entry, so
the next `get` runs a fresh lookup.

```ts
import { Effect, Schema } from "effect"
import { PersistedCache, Persistable } from "effect/unstable/persistence"

class GetUser extends Persistable.Class<{ payload: { id: number } }>()("GetUser", {
  primaryKey: ({ id }) => `GetUser:${id}`,
  success: Schema.Struct({ id: Schema.Number, name: Schema.String })
}) {}

declare const cache: PersistedCache.PersistedCache<GetUser>
declare const req: GetUser

const refresh = Effect.gen(function*() {
  yield* cache.invalidate(req) // removes persisted Exit, then the memory entry
  return yield* cache.get(req) // re-runs the lookup and re-stores both layers
})
```

## Tuning the two layers

The memory layer and the durable layer are tuned independently:

| Layer    | Option              | Default        | Controls                                                          |
| -------- | ------------------- | -------------- | ----------------------------------------------------------------- |
| Memory   | `inMemoryTTL`       | `() => "10 seconds"` | How long a result stays in the process-local `Cache`.       |
| Memory   | `inMemoryCapacity`  | `1024`         | Max number of entries kept in memory before eviction.            |
| Durable  | `timeToLive`        | (required)     | How long the encoded `Exit` is reused from the persistence store. |
| Durable  | `storeId`           | (required)     | The persistence store namespace (prefix / table / partition).    |

Both `inMemoryTTL` and `timeToLive` are `Persistable.TimeToLiveFn` — they receive
the result `Exit` and the request, so you can give successes and failures
different lifetimes (a zero or negative durable TTL skips the persistent write
entirely).

```ts
import { Effect, Schema } from "effect"
import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"

class Lookup extends Persistable.Class<{ payload: { key: string } }>()("Lookup", {
  primaryKey: ({ key }) => `Lookup:${key}`,
  success: Schema.String,
  error: Schema.String
}) {}

const make = PersistedCache.make(
  Effect.fn("lookup")(function*(req: Lookup) {
    return req.key.toUpperCase()
  }),
  {
    storeId: "lookups",
    inMemoryCapacity: 256,
    inMemoryTTL: () => "30 seconds",
    // cache successes for an hour, but don't persist failures at all
    timeToLive: (exit) => (exit._tag === "Success" ? "1 hour" : "0 millis")
  }
)
```

### `requireServicesAt`

A `Persistable` schema (and your lookup) may require services to decode/encode or
run. `requireServicesAt` decides **where** those services are supplied, which
shifts them between the cache's `R` parameter and the `R` of each `get`:

- `"construction"` (the default behavior when omitted) — services are provided
  once when `make` runs. The resulting `PersistedCache<K, never>` and its `get`
  carry no extra requirement, but those services must be in scope at `make`.
- `"lookup"` — services are provided per read. `make` produces a
  `PersistedCache<K, R>` and each `get` keeps `R` in its requirement channel, so
  the services are pulled from the calling fiber's context.

```ts
import { Effect, Schema } from "effect"
import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"

class Tax extends Persistable.Class<{ payload: { amount: number }, requires: never }>()("Tax", {
  primaryKey: ({ amount }) => `Tax:${amount}`,
  success: Schema.Number
}) {}

// With "lookup", R from the lookup stays on `get` instead of being baked into make.
const make = PersistedCache.make(
  Effect.fn("tax")(function*(req: Tax) {
    return req.amount * 0.2
  }),
  {
    storeId: "tax",
    timeToLive: () => "1 hour",
    requireServicesAt: "lookup"
  }
)
```
**Persisted entries are a migration surface:** Persisted values are decoded with the key's **success and error schemas** via
  the JSON codec. Changing a schema, the `primaryKey` format, or the `storeId`
  is a persistence migration: old entries can silently stop being found or fail
  to decode. Remember that **failures are cached too** — a lookup error is
  persisted as a failed `Exit` and replayed until its durable TTL expires, so
  give error exits a short `timeToLive` (or `0` to skip persisting them).

## Reference

Import everything from `effect/unstable/persistence`:

```ts
import { PersistedCache } from "effect/unstable/persistence"
```

### `PersistedCache<K, R>`

The cache interface, parameterized by the `Persistable` key type `K` and the
residual requirements `R` (non-`never` only when `requireServicesAt: "lookup"`).

Fields:

- **`inMemory`** — the underlying [`Cache`](https://effect.plants.sh/caching/cache/):
  `Cache<K, Persistable.Success<K>, Persistable.Error<K> | PersistenceError | SchemaError, Persistable.Services<K> | R>`.
  Use it for direct cache operations like `Cache.size` or `Cache.invalidateAll`.
- **`get(key)`** — `Effect<Persistable.Success<K>, Persistable.Error<K> | PersistenceError | SchemaError, Persistable.Services<K> | R>`.
  Reads memory, then persistence, then runs the lookup; the error channel unions
  your lookup's errors with persistence and schema-decoding errors.
- **`invalidate(key)`** — `Effect<void, PersistenceError>`. Removes the persisted
  entry first, then the in-memory entry.

```ts
import { Cache, Effect, Schema } from "effect"
import { PersistedCache, Persistable } from "effect/unstable/persistence"

class MyReq extends Persistable.Class<{ payload: { id: number } }>()("MyReq", {
  primaryKey: ({ id }) => `MyReq:${id}`,
  success: Schema.String
}) {}

declare const cache: PersistedCache.PersistedCache<MyReq>

const program = Effect.gen(function*() {
  // reach through to the underlying Cache for memory-level operations
  const inMemorySize = yield* Cache.size(cache.inMemory)
  // => number of entries currently held in the process-local memory layer
  return inMemorySize
})
```

### `make(lookup, options)`

Creates a scoped `PersistedCache` for a `Persistable` request type. Reads
persisted exits before running `lookup`, stores lookup exits under the durable
TTL, and keeps a scoped in-memory cache.

Signature shape:

```ts
PersistedCache.make(
  lookup: (key: K) => Effect.Effect<Persistable.Success<K>, Persistable.Error<K>, R>,
  options: {
    readonly storeId: string                                 // durable store namespace
    readonly timeToLive: Persistable.TimeToLiveFn<K>         // durable TTL (exit, request) => Duration.Input
    readonly inMemoryCapacity?: number | undefined          // default 1024
    readonly inMemoryTTL?: Persistable.TimeToLiveFn<K> | undefined  // default 10 seconds
    readonly requireServicesAt?: "lookup" | "construction" | undefined
  }
): Effect.Effect<
  PersistedCache<K, "lookup" extends ServiceMode ? R : never>,
  never,
  ("lookup" extends ServiceMode ? never : R) | Persistence.Persistence | Scope.Scope
>
```

The return type always requires `Persistence.Persistence` and `Scope.Scope`.
A complete runnable program providing the in-memory backing layer:

```ts
import { Effect, Schema } from "effect"
import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"

class Square extends Persistable.Class<{ payload: { n: number } }>()("Square", {
  primaryKey: ({ n }) => `Square:${n}`,
  success: Schema.Number
}) {}

const program = Effect.scoped(
  Effect.gen(function*() {
    const cache = yield* PersistedCache.make(
      Effect.fn("square")(function*(req: Square) {
        return req.n * req.n
      }),
      { storeId: "squares", timeToLive: () => "10 minutes" }
    )

    const a = yield* cache.get(new Square({ n: 4 })) // computes
    const b = yield* cache.get(new Square({ n: 4 })) // memory hit
    return [a, b]
  })
).pipe(Effect.provide(Persistence.layerMemory))

Effect.runPromise(program).then(console.log)
// => [ 16, 16 ]
```
**Related modules:** `PersistedCache` builds on two lower-level pieces:
  [Persistence](https://effect.plants.sh/persistence/persistence/) supplies `Persistable.Class`, the
  durable store, the backing layers (`layerMemory`, `layerSql`, `layerRedis`,
  `layerKvs`), and the TTL semantics; the
  [in-memory Cache](https://effect.plants.sh/caching/cache/) is the process-local layer reachable through
  `cache.inMemory`.