4c4a154d31
## What
Key-value storage for applications, as proposed in
twentyhq/core-team-issues#2391 — built on the existing `keyValuePair`
entity:
- Nullable `applicationId` relation on `keyValuePair` + a new
`APPLICATION_VARIABLE` type (fast instance command included)
- GraphQL CRUD on the metadata schema (`appKeyValue`, `setAppKeyValue`,
`deleteAppKeyValue`), requiring an `APPLICATION_ACCESS` token —
`applicationId` always comes from the token, never from arguments, so
apps can't touch each other's entries
- `kv.get` / `kv.set` / `kv.delete` helpers in
`twenty-sdk/logic-function`
## Scopes
- **`INSTALL`** (default): entries are private to one workspace install;
arbitrary JSON values
- **`GLOBAL`**: entries are shared across every install of the app, with
claim semantics — the value is always the claiming `workspaceId` and
only that workspace can overwrite or delete the key (guarded writes,
race-safe via insert-if-absent)
Since `applicationId` identifies an install (one row per workspace),
GLOBAL entries are stored under the registration owner workspace's
install so all installs of the same app share one namespace.
The GLOBAL scope is what enables cross-workspace webhook routing: e.g.
the Slack app's `serverRoute` resolver (running in the owner workspace)
can resolve `kv.get('slack:team:' + team_id, { scope: 'GLOBAL' })` to
find the workspace that connected that Slack team — without a workspace
being able to hijack another's mapping.
## Follow-ups
- Wire the Slack assistant PR (#22984) to write the claim at connect
time and read it in the events resolver
- `kv.*` access from front components
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23089?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. -->
111 lines
6.8 KiB
Plaintext
111 lines
6.8 KiB
Plaintext
---
|
|
title: Key-Value Store
|
|
description: Persist intermediate results, cache data, and share state across logic function runs with the built-in application key-value store.
|
|
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 built-in key-value store.
|
|
|
|
Every application gets its own isolated namespace: entries are keyed by the authenticated app, so your keys can never collide with — or be read by — another application.
|
|
|
|
```text
|
|
┌─────────────────┐ kv.set(key, value) ┌──────────────────────────┐
|
|
│ Logic function │ ─────────────────────▶ │ Application KV store │
|
|
│ (your handler) │ ◀───────────────────── │ key (unique) │ value │
|
|
└─────────────────┘ kv.get(key) └──────────────────────────┘
|
|
```
|
|
|
|
## Get, set, delete
|
|
|
|
Import `kv` from `twenty-sdk/logic-function`. Values can be any JSON-serializable payload.
|
|
|
|
```ts src/logic-functions/sync-linear-issues.ts
|
|
import { kv } from 'twenty-sdk/logic-function';
|
|
|
|
// Read a value. Returns null when the key is missing.
|
|
const cursor = await kv.get<string>('sync-cursor:linear');
|
|
|
|
// Write a value. Creates the entry on first write, updates it afterwards.
|
|
await kv.set('sync-cursor:linear', newCursor);
|
|
|
|
// Delete an entry. Returns true when an entry was removed.
|
|
await kv.delete('sync-cursor:linear');
|
|
```
|
|
|
|
## Scopes
|
|
|
|
Each entry has a scope, passed as an option on every call. The default is `WORKSPACE`.
|
|
|
|
- **`WORKSPACE`** (default) — the entry is private to the current workspace install of your app. Each workspace that installs the app gets its own independent set of keys. This is what you want for caches, cursors, and per-workspace state.
|
|
- **`SERVER`** — the entry is shared across **every install** of your app on the server. Server entries behave like **claims**: the stored value is always the workspaceId that claimed the key (omit `value` on `set` to claim the key for the current workspace), and only that workspace can overwrite or delete it. Any install can read the entry.
|
|
|
|
Server claims exist for cross-workspace routing. A [server-route resolver](/developers/extend/apps/logic/logic-functions#server-route-trigger) runs in the application registration owner workspace, but an inbound webhook usually only carries an external account id — not a Twenty workspaceId. Have each workspace claim its external id at connect time, then resolve it in the route:
|
|
|
|
```ts
|
|
// In the connected workspace, when the external account is linked:
|
|
await kv.set(`slack:team:${teamId}`, undefined, { scope: 'SERVER' });
|
|
|
|
// In the server-route resolver (owner workspace), on each webhook:
|
|
const workspaceId = await kv.get<string>(`slack:team:${teamId}`, {
|
|
scope: 'SERVER',
|
|
});
|
|
```
|
|
|
|
Because a server key can only be claimed for the caller's own workspace and never overwritten by another one, a workspace can't hijack a mapping that belongs to someone else. `kv.set` throws when the key is already claimed by another workspace.
|
|
|
|
## 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 { kv } from 'twenty-sdk/logic-function';
|
|
|
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
|
|
type CachedRate = { rate: number; fetchedAt: number };
|
|
|
|
const handler = async (params: { from: string; to: string }) => {
|
|
const cacheKey = `cache:exchange-rate:${params.from}:${params.to}`;
|
|
const cached = await kv.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 kv.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 — `sync-cursor:linear`, `cache:exchange-rate:USD:EUR`, `lock:nightly-report`.
|
|
- **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 clear stale keys from a [cron-triggered function](/developers/extend/apps/logic/logic-functions).
|
|
- **What to store.** 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.** Entries live in the instance database, not as workspace records — they never show up in the workspace UI, aren't part of your app's data model, and need no role or object permissions.
|
|
|
|
## Alternative: a queryable store object
|
|
|
|
The built-in store is deliberately opaque: entries aren't records, so you can't browse them in the UI, relate them to other objects, or filter them with record queries. When you need any of that — say a visible sync log, or per-record state — define a small **technical object** with a unique `key` field and a `RAW_JSON` `value` field instead, and query it through the [typed API client](/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk). See [Objects](/developers/extend/apps/data/objects) for the `defineObject` reference and [Data → Unique indexes](/developers/extend/apps/data/overview#unique-indexes) for enforcing key uniqueness.
|
|
|
|
- **Scoping to a record.** Add a [relation](/developers/extend/apps/data/relations) from the store object to the target object rather than encoding the id into the key.
|
|
- **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).
|
|
|
|
<Note>
|
|
Unlike the built-in store, a custom object is always scoped to one workspace — it can't share entries across installs the way `SERVER` keys do.
|
|
</Note>
|