This guide shows how a TypeScript app or device uses Trellis-managed State.* RPCs for semi-durable cloud-backed app memory.

Read Trellis Concepts first for background on contracts, uses, and the difference between public state and service-owned resources.

What State is for

Use State when the caller itself needs semi-durable memory that should survive restarts, browser refreshes, or moving between machines. State belongs to the authenticated app or device caller; it is not service-private data and normal callers do not pass a public scope.

Good uses:

  • saved filters and UI preferences
  • in-progress drafts
  • recent selections or last-opened items
  • device-local preferences for an authenticated device contract

Do not use State for:

  • service-private projections or internal service state
  • large binary payloads
  • cross-app shared namespaces

For service-owned structured data, keep using schema-backed resources.kv from a TrellisService.connect(...) runtime.

1. Declare named state stores in the contract

Apps and devices declare Trellis-managed state with top-level state entries. The State runtime API is derived from those declarations automatically.

import { defineAppContract } from "@qlever-llc/trellis";
import { Type } from "@sinclair/typebox";

const schemas = {
  PreferencesV1: Type.Object({ theme: Type.String() }),
  Preferences: Type.Object({ theme: Type.String(), compact: Type.Boolean() }),
  Draft: Type.Object({ body: Type.String() }),
} as const;

export const notesApp = defineAppContract({ schemas }, (ref) => ({
  id: "notes-app@v1",
  displayName: "Notes App",
  description: "A browser app with cloud-backed drafts and preferences.",
  state: {
    preferences: {
      kind: "value",
      schema: ref.schema("Preferences"),
      stateVersion: "preferences.v2",
      acceptedVersions: {
        "preferences.v1": ref.schema("PreferencesV1"),
      },
    },
    drafts: {
      kind: "map",
      schema: ref.schema("Draft"),
    },
  },
}));

The same pattern works for device contracts with defineDeviceContract(...).

Use stateVersion for the logical persisted shape of the store. Adding optional fields can keep the same stateVersion; incompatible stored-value changes should increment it and list the old shape under acceptedVersions so the runtime can surface migration-required entries.

Stored entries are stamped with both stateVersion and Trellis internal writer provenance. v1 Trellis rejects unstamped pre-v1 entries instead of treating them as current or inferring old accepted versions. Declared acceptedVersions apply only to entries that were stamped with that older author-known version.

2. Read and write state from the client

After your app has an authenticated Trellis client, use the generated trellis.state.<store> facade.

const trellis = getTrellis();

const prefs = await trellis.state.preferences.get().orThrow();

if ("migrationRequired" in prefs) {
  // See the migration section below.
} else if (!prefs.found) {
  await trellis.state.preferences.put({ theme: "dark", compact: false }).orThrow();
}

In a Svelte component, call the app-local getTrellis() helper during component initialization and reuse the returned client from handlers and async helpers.

The runtime derives the app, user, or device namespace from the authenticated session and contract id lineage. The presented contract evidence still validates the store declaration and schema, but state follows the same app lineage across compatible upgrades. Stored entries also carry internal writer-digest provenance and an author-known state version. The same user signing into a different app contract does not see these entries.

3. Handle state migrations

When a stored entry was written under an accepted older state version, get, list, or a failed conditional put can return migrationRequired instead of a current typed entry. Trellis validates the old value against the declared older schema but does not run app migration code server-side.

const prefs = await trellis.state.preferences.get().orThrow();

type PreferencesV1 = { theme: string };

if ("migrationRequired" in prefs && prefs.stateVersion === "preferences.v1") {
  const legacy = prefs.entry.value as PreferencesV1;
  const migrated = {
    theme: legacy.theme,
    compact: false,
  };

  await trellis.state.preferences.put(migrated, {
    expectedRevision: prefs.entry.revision,
  }).orThrow();
}

4. Choose the right store kind

v1 supports two store kinds:

  • value for one value per authenticated caller and contract
  • map for many values under caller-provided keys

These are contract-owned state stores, not OAuth scopes and not Trellis capabilities.

Use value for settings that should follow the same user across browsers or CLIs of the same app lineage.

Use map for drafts or cached records keyed by id. Device contracts use the same facade shape, with the runtime deriving the device-owned namespace.

5. Use expectedRevision for revision-sensitive writes

Every state entry includes a revision. Use that revision when you want optimistic concurrency.

const current = await trellis.state.drafts.get("current").orThrow();

if (current.found) {
  const updated = await trellis.state.drafts.put(
    "current",
    { body: "updated text" },
    { expectedRevision: current.entry.revision },
  ).orThrow();

  if (!updated.applied) {
    // Another instance wrote a newer revision first.
  }
}

To create only if absent, pass expectedRevision: null.

6. List and delete map keys

Map store list(...) returns a live offset page in lexicographic key order. Pass { offset?: number; limit: number } and read items from .entries; use .nextOffset when you want to request the next page. Value stores do not expose list(...).

const page = await trellis.state.drafts
  .prefix("reports/")
  .list({ offset: 0, limit: 20 })
  .orThrow();

for (const draft of page.entries) {
  console.info(draft.key, draft.value.title);
}

if (page.nextOffset !== undefined) {
  const nextPage = await trellis.state.drafts
    .prefix("reports/")
    .list({ offset: page.nextOffset, limit: page.limit })
    .orThrow();
  console.info(`loaded ${nextPage.entries.length} more drafts`);
}

await trellis.state.drafts.delete("old").orThrow();

delete(...) may also include expectedRevision when you want a conditional delete.

7. Optional TTL

If a key is cache-like rather than durable, attach ttlMs to put(...).

await trellis.state.drafts.put(
  "search/last-results",
  { body: "cached ids: a,b,c" },
  { ttlMs: 5 * 60_000 },
).orThrow();

Expired entries behave like missing keys.

8. Practical guidelines

  • keep values small
  • keep keys stable and path-like, such as prefs/theme or drafts/report-123
  • treat State as app memory, not as a substitute for large files or service databases
  • use resources.kv instead when a service owns durable structured data for itself
  • do not pass a public scope or choose a raw contract-wide keyspace in normal caller code
  • store only JSON values; encode unusual payloads yourself when needed
  • use expectedRevision when multiple app instances may write the same key
  • keep stateVersion stable across compatible additive schema changes
  • do not rely on unversioned entries; v1 State requires stored metadata and uses acceptedVersions only for values that carry an older stateVersion
  • write migration code in the app or device runtime when you declare acceptedVersions

9. Next steps