21c3574f05
## What
Adds a new docs page under **Developers → Extend → Apps → Logic**
explaining how to give logic functions key-value storage (to persist
intermediate results, cache data, and share state across runs).
Rather than introducing a dedicated storage primitive, the guide shows
how to achieve this with a small **technical object** ("KV Store") that
has a unique `key` field and a `RAW_JSON` `value` field — queried
through the existing typed `CoreApiClient`.
Closes the documentation part of
[core-team-issues#2427](https://github.com/twentyhq/core-team-issues/issues/2427).
## Contents of the new page
- `defineObject` for the `kvStore` object (`key` + `value`)
- A unique index on `key` (the recommended uniqueness primitive)
- `get` / `set` (upsert) / `del` helpers built on `CoreApiClient`
- A worked example: caching an expensive third-party call with a TTL
- Patterns & tips: namespacing, expiry, what to store,
visibility/permissions, per-record scoping
## Files
- `developers/extend/apps/logic/key-value-store.mdx` — the new page
- `navigation/base-structure.json` — navigation source-of-truth
- `docs.json` + `twenty-shared/.../DocumentationPaths.ts` — regenerated
from base-structure
## Notes
- Documentation only — no code/behavior changes.
- This is a convention (a regular custom object), not a new feature, so
it inherits the same sync, permissions, and tooling as the rest of an
app's data.
https://claude.ai/code/session_01XAgXy1mUUMff5BFkFPjtfZ
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XAgXy1mUUMff5BFkFPjtfZ)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22061?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
201 lines
8.5 KiB
Plaintext
201 lines
8.5 KiB
Plaintext
---
|
|
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 <TValue>(key: string): Promise<TValue | undefined> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
const existing = await findByKey(key);
|
|
|
|
if (isDefined(existing)) {
|
|
await client.mutation({
|
|
deleteKvStore: { __args: { id: existing.id }, id: true },
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
<Note>
|
|
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.
|
|
</Note>
|
|
|
|
## 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<CachedRate>(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.
|
|
|
|
<Note>
|
|
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.
|
|
</Note>
|