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:
valuefor one value per authenticated caller and contractmapfor 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/themeordrafts/report-123 - treat
Stateas app memory, not as a substitute for large files or service databases - use
resources.kvinstead when a service owns durable structured data for itself - do not pass a public
scopeor choose a raw contract-wide keyspace in normal caller code - store only JSON values; encode unusual payloads yourself when needed
- use
expectedRevisionwhen multiple app instances may write the same key - keep
stateVersionstable across compatible additive schema changes - do not rely on unversioned entries; v1 State requires stored metadata and uses
acceptedVersionsonly for values that carry an olderstateVersion - write migration code in the app or device runtime when you declare
acceptedVersions
9. Next steps
- If you need browser auth setup, read Write a SvelteKit app
- If you need service-private storage instead, read Store resources: TypeScript