> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vendo.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Persistence: Postgres schema and PGlite for local dev

> How Vendo's Postgres schema, default-on secret encryption, append-only audit, and eraseStore API work across PGlite dev and hosted production.

```ts theme={null}
export function createStore(config?: {
  url?: string;
  dataDir?: string;
  encryption?: { key: string };
}): VendoStore;
```

Omit `url` for PGlite. `dataDir` defaults to `.vendo/data`. Set `url` for a
hosted Postgres database. Both use the same schema and migration path.

The stable public tables are `vendo_meta`, `vendo_apps`, `vendo_records`,
`vendo_blobs`, `vendo_state`, `vendo_threads`, `vendo_grants`,
`vendo_approvals`, `vendo_audit`, `vendo_runs`, and `vendo_secrets`.

All structured payloads use `jsonb`. App record refs are GIN-indexed and can be
joined to host tables with containment queries. Collection names use
`app:<appId>:<name>`.

App machines reach `vendo_records` and `vendo_blobs` through the guarded proxy,
not directly. Each collection must appear in the app's `storage` declaration
with the `refs` keys it will write; the reserved name `state` is rejected.
Records are capped at 256 KB, blobs at 5 MB, and every declared `refs` key must
carry a non-empty string value at write time. See the [app machine
environment](/reference/environment-variables#app-machine-environment) for the
`/data` and `/files` proxy routes.

`encryption.key` encrypts secret values in `vendo_secrets` only. App records
stay queryable. Ephemeral principals use an in-memory overlay and never write
to disk.

## Encryption is on by default

`vendo init` provisions a 32-byte base64 key at `VENDO_STORE_ENCRYPTION_KEY`
in your host `.env`. The default composition reads it when no store is passed
to `createVendo`, so secret values in `vendo_secrets` are encrypted at rest
without extra wiring. The key is append-safe: `vendo init` never regenerates
it, even with `--force`. Do not rotate the key by hand — losing it makes
existing secrets unreadable.

An explicitly configured store always wins. If you pass
`store: createStore({ encryption: { key } })` to `createVendo`, the environment
variable is ignored.

Ciphertext is bound to the secret name with AES-GCM authenticated data, so a
tampered or swapped row fails to decrypt. Rows written before this hardening
keep decrypting and upgrade to the bound envelope on their next write.

## Audit is append-only

`vendo_audit` is write-once. Calling `put` with an existing id fails with
`conflict`, and `delete` is refused with `blocked`. The only sanctioned way
to remove audit rows is the erase API below.

## Ownership flips are refused

`vendo_apps`, `vendo_grants`, and `vendo_threads` all reject same-id writes
that move a row to a different subject. If you need to transfer ownership,
delete the row and re-seed it under the new subject.

## Erasing data

`eraseStore(store)` is the sanctioned deletion path for `vendo_audit` and the
one call that cascades across every store table plus the ephemeral overlay.
Use it for right-to-erasure requests, tearing down a test app, or purging
old runs.

```ts theme={null}
import { eraseStore } from "@vendoai/vendo/server";

// Full erasure for a subject across every store table and the overlay.
const bySubject = await eraseStore(vendo.store, { bySubject: subjectId });

// Erase everything owned by one app.
const byApp = await eraseStore(vendo.store, { byApp: appId });

// Erase rows whose entire lifecycle predates a cutoff.
const byAge = await eraseStore(vendo.store, {
  byAge: { before: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) },
});
```

Each call returns per-table deleted counts so you can log or verify the
cascade. `byAge` treats a row as recent when any lifecycle timestamp
(including `updated_at`) is at or after the cutoff — so a standing grant
with a future `expires_at` and a rotated secret both survive an age sweep.
Overlay blobs carry no timestamps and are exempt from the age axis; they
end with the process.

## Atomic claims

The store contract exposes a compare-and-set (CAS) claim primitive so
concurrent workers can safely coordinate on a shared key without external
locks. Use it whenever two ticks, retries, or agent runs might race on the
same record — for example, deduplicating a webhook delivery, claiming a
queued token exchange, or ensuring only one worker processes an invoice.

```ts theme={null}
const claim = await vendo.store.claim({
  key: `invoice:${invoiceId}`,
  owner: runId,
  ttlMs: 60_000,
});

if (!claim.acquired) {
  // Another run already holds this key. Skip.
  return;
}

try {
  await processInvoice(invoiceId);
} finally {
  await vendo.store.release(claim);
}
```

Claims are keyed per principal and expire after `ttlMs`, so a crashed worker
never holds a key forever. `acquired` is false when another owner holds the
key and its TTL has not elapsed. The same CAS machinery backs run-lifecycle
transitions (start, stop, complete), so cancellation and scheduler ticks
cannot resurrect a terminal run.
