`datavolve` brings an old persisted value forward to its latest shape through a chain of forward-only evolutions. Point it at whatever you persisted last year (localStorage, IndexedDB, a synced blob, a config file) and get back the shape your code expects today, with full type inference along the way.

## Features

- Full type inference through the evolution chain - no intermediate annotations
- Forward-only, pure `(prev) => next` transforms - no reverse evolutions, no side effects
- Total `run` - returns a discriminated result, never throws
- Storage-agnostic - the version comes in as an argument; `datavolve` never reads or writes it
- Zero dependencies, tiny (258 B), ESM-only

## Installation

Requires an ESM environment. No peer dependencies.

## Core Concepts

Real apps change the shape of the data they persist. A returning user's browser can hold data written by a version of your app from six months ago. Without an evolution step that old value flows into new code and either crashes or, worse, silently corrupts state. `datavolve` is the small primitive that closes that gap: declare how each version differs from the last, and let it walk any stored value up to the current shape.

It is useful wherever the shape of persisted client data evolves over time:

- localStorage / sessionStorage state
- IndexedDB records
- A synced JSON blob or config file
- Any versioned value read back by newer code

## Usage

### Step 1: Declare your evolutions

You add evolutions in order, starting at version `1`. Each one takes the previous version's value and returns the next. The input to the **first** evolution is `unknown` on purpose - it is genuinely untrusted data, and narrowing it is the evolution's job.

```typescript
import { datavolve } from "datavolve";

const settings = datavolve()
  .add(1, (raw) => ({ theme: "light" })) // legacy -> v1
  .add(2, (v1) => ({ ...v1, fontScale: 1 })) // v1 -> v2
  .add(3, (v2) => ({ ...v2, theme: v2.theme === "light" ? "day" : "night" })); // v2 -> v3
```

### Step 2: Run against stored data

You tell `run` what version the incoming data is; `datavolve` never looks for a `version` field inside your value. Data with no version at all is version `0` and runs the whole chain.

```typescript
const result = settings.run(storedValue, storedVersion);
```

### Step 3: Handle the result

`run` returns a discriminated result, so the compiler makes you handle failure. Each error `code` carries exactly the fields relevant to it. On success the result carries the evolved `value` together with the `version` it is now at (always `latestVersion`), so you can persist the upgraded data in one place.

```typescript
if (result.success) {
  useSettings(result.value); // typed as the v3 shape
  save({ version: result.version, value: result.value }); // store them together
} else {
  switch (result.error.code) {
    case "ahead":
      // stored data is newer than this build understands
      promptUserToUpdateApp(result.error.latestVersion);
      break;
    case "malformed":
      // fromVersion was negative or non-integer
      resetToDefaults();
      break;
    case "failed":
      // one of your evolutions threw; the original error is in `cause`
      report(result.error.cause);
      resetToDefaults();
      break;
  }
}
```

See [Version Behavior](/api#version-behavior) for every `fromVersion` case `run` handles, including the edge cases.

## Narrowing Untrusted Input

The first evolution's input is `unknown`. Narrow it with a runtime check, not a cast. Either a small type guard (zero dependencies):

```typescript
const isRecord = (x: unknown): x is Record<string, unknown> =>
  typeof x === "object" && x !== null;

datavolve().add(1, (legacy) => {
  const old = isRecord(legacy) ? legacy : {};
  return { theme: old["theme"] === "light" ? "light" : "dark" };
});
```

or a validator you already use (Valibot, Zod, etc.):

```typescript
import * as v from "valibot";

const legacySchema = v.object({ theme: v.optional(v.string()) });

datavolve().add(1, (legacy) => {
  const old = v.parse(legacySchema, legacy); // throws on garbage -> reported as `failed`
  return { theme: old.theme === "light" ? "light" : "dark" };
});
```

A raw `as` would skip the check and let malformed legacy data through untyped - exactly what you don't want at this boundary.

## Validation

`datavolve` does **no** validation of its own - the inferred output type of `run` is a *claim* each evolution makes, not a runtime guarantee that untrusted stored data matches. Make it a guarantee by driving each evolution with a schema. Define one schema per version (each reusing the previous where it can), and let every evolution validate the shape it receives and transform it to the next with [Valibot](https://valibot.dev)'s `transform` pipeline:

```typescript
import * as v from "valibot";

// One schema per version - each reuses the previous one's entries where it can.
const settingsV1 = v.object({ theme: v.picklist(["light", "dark"]) });
const settingsV2 = v.object({ ...settingsV1.entries, fontScale: v.number() });
const settingsV3 = v.object({ ...settingsV2.entries, sidebar: v.boolean() });
// v4 deprecates `sidebar`: omit the key, keep the rest.
const settingsV4 = v.omit(settingsV3, ["sidebar"]);
const settingsV5 = v.object({
  ...settingsV4.entries,
  theme: v.picklist(["day", "night"]),
});
// update this when a new version is added to the chain
const settingsLatest = settingsV5;

type Settings = v.InferOutput<typeof settingsLatest>;

// Each evolution validates the previous shape and transforms it to the next.
const toV2 = v.pipe(
  settingsV1,
  v.transform((s) => ({ ...s, fontScale: 1 })),
);
const toV3 = v.pipe(
  settingsV2,
  v.transform((s) => ({ ...s, sidebar: true })),
);
const toV4 = v.pipe(
  settingsV3,
  v.transform(({ sidebar, ...rest }) => rest),
);
const toV5 = v.pipe(
  settingsV4,
  v.transform((s) => ({
    ...s,
    theme: s.theme === "light" ? ("day" as const) : ("night" as const),
  })),
);

const settings = datavolve()
  .add(1, (raw) => v.parse(settingsV1, raw)) // legacy -> v1
  .add(2, (prev) => v.parse(toV2, prev))
  .add(3, (prev) => v.parse(toV3, prev))
  .add(4, (prev) => v.parse(toV4, prev))
  .add(5, (prev) => v.parse(toV5, prev));

const result = settings.run(storedValue, storedVersion);
if (result.success) {
  const value: Settings = result.value; // inferred *and* runtime-checked
  useSettings(value);
}
```

Every evolution parses its input, so a value is validated at whatever version it enters the chain - corrupt or unexpected data surfaces as a `failed` result (`v.parse` throws, and `run` turns any thrown error into `failed`). You handle it through the same branch you already handle, with no extra `try`/`catch`.

The only value not re-checked is one already at `latestVersion`: it skips the chain and is returned unchanged (the no-op fast path). That is the shape your app just wrote, so it is trusted.

## Persisting to localStorage

`datavolve` never touches storage - you own the `{ version, value }` envelope and decide where it lives. A typical `localStorage` read evolves the stored value up to the latest shape and re-stores it, so the next read is a no-op; a fresh write stamps the current `latestVersion`:

```typescript
const KEY = "settings";

// Read: evolve stored data up to the latest shape, then re-store it.
function load(): Settings {
  const raw = localStorage.getItem(KEY);
  const stored = raw ? JSON.parse(raw) : { version: 0, value: null }; // no data -> version 0

  const result = settings.run(stored.value, stored.version);
  if (!result.success) return defaults; // reset on ahead / malformed / failed

  // persist the upgraded shape so the next read skips the chain
  localStorage.setItem(
    KEY,
    JSON.stringify({ version: result.version, value: result.value }),
  );
  return result.value;
}

// Write: a fresh in-app change is already the latest shape, so stamp latestVersion.
function save(value: Settings): void {
  localStorage.setItem(
    KEY,
    JSON.stringify({ version: settings.latestVersion, value }),
  );
}
```

## Design Notes

- **Forward-only.** This evolves persisted client state; you never roll a user's storage back, so there are no down evolutions.
- **Synchronous.** Persisted-state evolutions are pure shape transforms. Keeping `run` sync means it fits synchronous read paths. Need async evolutions? [Open an issue](https://github.com/psd-coder/datavolve/issues) - an async variant is ready to build if there is demand.
- **Result over throw.** A newer-than-expected stored value is expected control flow, not an exception. Returning a discriminated result makes handling every reason compiler-enforced.

## License

[MIT](https://github.com/psd-coder/datavolve/blob/main/LICENSE.md)
