--- title: Key-Value Store description: Persist intermediate results, cache data, and share state across logic function runs with a simple key-value object. icon: "database" --- Logic functions run sandboxed in short-lived Node.js processes — once a run finishes, nothing it kept in memory survives. When you need to **remember something between runs** (cache an expensive API response, store a cursor for incremental syncs, debounce work, or hand state from one function to another), persist it in the workspace database. You don't need a dedicated storage primitive for this: a small **technical object** with a `key` field and a `value` field gives you a durable key-value store, scoped to the workspace, queryable through the same [typed API client](/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk) you already use for records. ```text ┌─────────────────┐ set(key, value) ┌──────────────────────────┐ │ Logic function │ ───────────────────▶ │ "KV Store" object │ │ (your handler) │ ◀─────────────────── │ key (unique) │ value │ └─────────────────┘ get(key) └──────────────────────────┘ ``` ## Define the store object Declare a custom object with two fields — `key` (a unique `TEXT`) and `value` (a `RAW_JSON` so you can store any JSON-serializable payload). See [Objects](/developers/extend/apps/data/objects) for the full `defineObject` reference. ```ts src/objects/kv-store.object.ts import { defineObject, FieldType } from 'twenty-sdk/define'; export const KV_STORE_UNIVERSAL_IDENTIFIER = '2f1c8a90-3b6d-4e2a-9c47-7d0e5a1b9f33'; export const KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER = '4a7e2d11-9c83-4f60-b5a2-1e6c8d0f4b21'; export const KV_STORE_VALUE_FIELD_UNIVERSAL_IDENTIFIER = '8b3f6c02-5d19-47ae-9f31-2c4a7e0b6d58'; export default defineObject({ universalIdentifier: KV_STORE_UNIVERSAL_IDENTIFIER, nameSingular: 'kvStore', namePlural: 'kvStores', labelSingular: 'KV Store', labelPlural: 'KV Store', description: 'Key-value storage for logic functions', icon: 'IconDatabase', fields: [ { universalIdentifier: KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER, name: 'key', type: FieldType.TEXT, label: 'Key', description: 'Unique lookup key', icon: 'IconKey', }, { universalIdentifier: KV_STORE_VALUE_FIELD_UNIVERSAL_IDENTIFIER, name: 'value', type: FieldType.RAW_JSON, label: 'Value', description: 'Stored JSON payload', icon: 'IconJson', }, ], }); ``` ### Enforce key uniqueness Add a **unique index** on `key` so the same key can never have two rows. This is the recommended primitive for uniqueness — see [Data → Unique indexes](/developers/extend/apps/data/overview#unique-indexes). ```ts src/indexes/kv-store-key.index.ts import { defineIndex } from 'twenty-sdk/define'; import { KV_STORE_UNIVERSAL_IDENTIFIER, KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER, } from '../objects/kv-store.object'; export default defineIndex({ universalIdentifier: 'c0d4e8f2-6a1b-4c93-8e57-3f9a2d0b7e14', objectUniversalIdentifier: KV_STORE_UNIVERSAL_IDENTIFIER, isUnique: true, fields: [ { universalIdentifier: 'c0d4e8f2-6a1b-4c93-8e57-3f9a2d0b7e15', fieldUniversalIdentifier: KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER, }, ], }); ``` ## Read and write from a logic function Wrap the object behind a few small helpers so the rest of your code reads like a key-value API — `get`, `set`, and `del`. They use [`CoreApiClient`](/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk), which is generated from your workspace schema and fully typed against the `kvStore` object. ```ts src/logic-functions/handlers/kv-store.ts import { CoreApiClient } from 'twenty-client-sdk/core'; import { isDefined } from 'twenty-sdk/utils'; const client = new CoreApiClient(); // Look up a single row by its key. const findByKey = async (key: string) => { const { kvStores } = await client.query({ kvStores: { __args: { filter: { key: { eq: key } }, first: 1 }, edges: { node: { id: true, value: true } }, }, }); return kvStores.edges[0]?.node; }; // Read a value. Returns undefined when the key is missing. export const get = async (key: string): Promise => { const row = await findByKey(key); return isDefined(row) ? (row.value as TValue) : undefined; }; // Write a value. Creates the row on first write, updates it afterwards (upsert). export const set = async (key: string, value: unknown): Promise => { const existing = await findByKey(key); if (isDefined(existing)) { await client.mutation({ updateKvStore: { __args: { id: existing.id, data: { value } }, id: true, }, }); return; } await client.mutation({ createKvStore: { __args: { data: { key, value } }, id: true, }, }); }; // Delete a value. No-op when the key is missing. export const del = async (key: string): Promise => { const existing = await findByKey(key); if (isDefined(existing)) { await client.mutation({ deleteKvStore: { __args: { id: existing.id }, id: true }, }); } }; ``` The unique index protects against duplicates, but two runs writing the **same new key** at the same instant can still race between the lookup and the create. Treat a create that fails on the uniqueness constraint as "someone else won" — catch it and re-read, or retry as an update. ## Use it: cache an expensive call A typical use is caching a slow or rate-limited third-party response so repeated runs reuse it instead of paying the cost every time. ```ts src/logic-functions/getExchangeRate.logic-function.ts import { defineLogicFunction } from 'twenty-sdk/define'; import { get, set } from './handlers/kv-store'; const ONE_HOUR_MS = 60 * 60 * 1000; type CachedRate = { rate: number; fetchedAt: number }; const handler = async (params: { from: string; to: string }) => { const cacheKey = `exchange-rate:${params.from}:${params.to}`; const cached = await get(cacheKey); if (cached && Date.now() - cached.fetchedAt < ONE_HOUR_MS) { return { rate: cached.rate, cached: true }; } const response = await fetch( `https://api.example.com/rate?from=${params.from}&to=${params.to}`, ); const { rate } = (await response.json()) as { rate: number }; await set(cacheKey, { rate, fetchedAt: Date.now() }); return { rate, cached: false }; }; export default defineLogicFunction({ universalIdentifier: 'd9b2f4e6-1c83-4a07-9e52-6b1d3c8a0f47', name: 'get-exchange-rate', timeoutSeconds: 10, handler, }); ``` ## Patterns & tips - **Namespacing.** Prefix keys to keep different concerns apart and to make bulk lookups easy — `sync-cursor:linear`, `cache:exchange-rate:USD:EUR`, `lock:nightly-report`. Filter with `key: { like: 'cache:%' }` to list or clear a whole namespace. - **Expiry (TTL).** The store has no built-in expiration. Store a timestamp inside the `value` (as in the cache example) and check it on read, or add a `DATE_TIME` field and periodically clear stale rows from a [cron-triggered function](/developers/extend/apps/logic/logic-functions). - **What to store.** `RAW_JSON` holds any JSON-serializable value — numbers, strings, arrays, objects. Keep entries small; this is for coordination and caching, not large blobs or files. For files, use a `FILES` field and [`uploadFile`](/developers/extend/apps/logic/logic-functions#uploading-files). - **Visibility & permissions.** Rows live in the workspace database like any other record, so they're queryable through the API and respect your app's [role](/developers/extend/apps/config/roles). To keep the store out of the main UI, leave it off your [navigation menu](/developers/extend/apps/layout/navigation-menu-items). - **Scoping to a record.** Need per-record state instead of global keys? Add a [relation](/developers/extend/apps/data/relations) from the store object to the target object rather than encoding the id into the key. This is a convention, not a separate feature — the "KV Store" is just a regular custom object you define and query with the standard API. That means it benefits from the same sync, permissions, and tooling as the rest of your app's data.