---
title: "@unikvs/ttl"
description: "Middleware that attaches a TTL in relative seconds or absolute time, treating expired reads as missing."
---

## Overview [#overview]

`Ttl` attaches a relative-seconds or absolute-time expiry to written values, and treats expired reads as missing.Middleware

Use it for short-lived caches or session-like data. Plain values and streams expire differently.

- `name` is `Ttl`.
- `isOpen` is always `true`.
- Set the expiry per operation with the `@unikvs/ttl:ttl` / `@unikvs/ttl:expiresAt` vars (bare `ttl` / `expiresAt` are also accepted for backward compatibility), or with `defaultTtl`.
- Expired `get` / `has` / `stream` / `copy` / `move` behave as `KeyNotFoundError`.
- `copy` and `move` carry the source expiry to the destination only when every TTL-applicable target in the middleware's branch is `committed`.

Install with:

```package-install
npm install @unikvs/ttl
```

## Usage [#usage]

Ttl implements IMiddleware. Pass it to UniKvs with use().

```ts
import { Ttl } from "@unikvs/ttl";
```

Constructor arguments:

| Prop | Type | Default | Description |
| - | - | - | - |
| `defaultTtl?` | `[#ttl-options-default-ttl] TtlInput` | - | Default TTL used when an operation specifies none. Accepts relative seconds, absolute time, or the object form. |
| `now?` | `[#ttl-options-now] () => number` | `Date.now` | Returns the current time (epoch ms). Use it to freeze time in tests. |

The following sets a 60-second default TTL and overrides it per write:

```ts
import { Memory } from "@unikvs/memory";
import { Ttl } from "@unikvs/ttl";
import * as unikvs from "unikvs";

const kvs = new unikvs.Builder<{ report: unikvs.PlainValue<string> }>()
  .use(new Ttl({ defaultTtl: 60 }))
  .persist(new Memory())
  .build();

await kvs.open();
await kvs.set("report", "hello");
await kvs.set("short", "bye", { vars: { "@unikvs/ttl:ttl": 10 } });
console.log(await kvs.get("report")); // "hello"
await kvs.close();
```

### `Ttl` [#ttl]

Middleware that manages expiry with an expiry envelope plus per-operation records. It checks expiry before reads and treats existence checks as missing.

### `TtlOptions` [#ttl-options]

Options accepted by the `Ttl` constructor. Specifies the default TTL and the clock.

### `TtlInput` [#ttl-input]

Per-write TTL value. A `number` is relative seconds (fractions allowed; zero or below expires immediately), a `Date` is an absolute expiry time, and the object form takes `ttl` in relative seconds or `expiresAt` in epoch ms or a `Date`.

## Data types [#data-types]

| Method         | Input                 | Output                         |
| -------------- | --------------------- | ------------------------------ |
| `encode`       | `unknown`             | envelope or passthrough        |
| `decode`       | envelope or any value | `unknown`                      |
| `encodeStream` | `unknown` (chunk)     | `unknown` (chunk, passthrough) |
| `decodeStream` | `unknown` (chunk)     | `unknown` (chunk, passthrough) |

- `encode` wraps the value as `{ "@unikvs/ttl": 1, expiresAt: number, data: unknown }` when a TTL applies. Overwrites without a TTL pass data through.
- `decode` unwraps the envelope while it is valid, and throws `KeyNotFoundError` once expired. When the record is missing (e.g. after a restart), it restores the record from the envelope.
- Streams never embed the expiry in-band (to fit chunk type constraints) and are managed by records only. `encodeStream` and `decodeStream` pass data through.
- TTL resolution prefers the `ttl` var, then the `expiresAt` var, then `defaultTtl`. Namespaced vars take precedence over bare names.

### When records are committed [#transaction]

This middleware receives storage outcomes through [`afterUse`](/unikvs/en/packages/core/middleware#hooks-after-use) and commits records then. `use`, `encode`, and `encodeStream` do not mutate records; they stage pending values in the operation's variable store. A failed write therefore cannot leave an expiry on a value that was never stored. The targets passed to `afterUse` are only those in the branch where this middleware is applied.

- `set` applies the staged expiry after every target commits. An overwrite without a TTL removes the record. On failure, the previous record is kept.
- `copy` carries the source expiry to the destination when every TTL-applicable target in the middleware's branch is `committed`. If the source has no record, the destination record is removed. If any target is `notFound` or `failed`, the destination record is left unchanged.
- `move` updates the destination record and removes the source record when every TTL-applicable target in the middleware's branch is `committed`. If any target is `notFound` or `failed`, it keeps the source record and does not change the destination record.
- If an operation is aborted after storage settles, its result is still recorded when every TTL-applicable target in the middleware's branch is `committed`. If any target is uncommitted, records are left unchanged as on failure.
- The source expiry is not frozen at `use` time. It is read again from the record in `afterUse` (while the key locks are held), so a concurrent `set` that changes the source TTL after `use` is reflected in the destination.
- `delete` and `clear` remove records only when they succeed. Partial success keeps records so surviving values do not lose their expiry.
- An expired copy or move source fails with `KeyNotFoundError` but keeps its record. Streams carry no in-band expiry, so this preserves expiry detection after a failed copy or move. A successful `set` replaces the record with the new expiry (or removes it when the write has no TTL); a successful `delete` or `clear` removes it.
- `decodeStream` keeps the record when it detects expiry. Later reads of the same key still fail with `KeyNotFoundError`.
- Repair writes performed by `get` / `stream` are treated as part of the read and do not trigger `afterUse`. Therefore the reading instance does not commit a stream record for a repaired destination that has no in-band expiry envelope.

:::note[Partial-success limitation]
When an operation spanning multiple storages partially succeeds, TTL records are left unchanged for safety. For example, if `set` or `move` succeeds on only some storages, stream data written to a destination may not have an expiry. Likewise, when a `copy` or `move` source exists in only some storages, the destination record is left unchanged so an old expiry in an untouched storage cannot be resurrected. A single per-key record cannot represent that partial state exactly.
:::

## Errors [#errors]

Errors thrown by this package:

### TtlInvalidValueError [#error-ttl-invalid-value]

| Prop | Type | Default | Description |
| - | - | - | - |
| `actual` | `[#ttl-invalid-value-actual] unknown` | - | The specified TTL value. |

#### Meaning [#error-ttl-invalid-value-meaning]

Thrown when a TTL value matches none of relative seconds, absolute time, or the object form. Both the constructor's `defaultTtl` and per-write vars are validated.

#### How to fix [#error-ttl-invalid-value-howto]

Pass a finite number (relative seconds), a `Date`, or an object such as `{ ttl }` or `{ expiresAt }`. Strings and non-finite values are rejected.

#### Example [#error-ttl-invalid-value-example]

Catching `TtlInvalidValueError`:

```ts
import { TtlInvalidValueError } from "@unikvs/ttl";

try {
  await kvs.set("report", "hello", { vars: { ttl: "60" } });
} catch (ex) {
  if (ex instanceof TtlInvalidValueError) {
    console.log(ex.meta); // { actual: "60" }
  } else {
    throw ex;
  }
}
```

Expired reads fail as `@unikvs/core` `KeyNotFoundError`. See [Errors](/unikvs/en/packages/core/errors#error-key-not-found).
