逻辑函数在短生命周期的 Node.js 进程中以沙盒方式运行——一旦一次运行结束,内存中不会保留任何内容。 当你需要在多次运行之间记住一些东西时(缓存一次昂贵的 API 响应、存储增量同步的游标、对工作进行防抖处理,或在函数之间传递状态),请将其持久化到工作区数据库中。
你不需要专门的存储原语来实现这一点:一个带有 key 字段和 value 字段的小型技术对象就可以为你提供一个持久的键值存储,它以工作区为作用域,并且可以通过你已经用于记录的同一个类型化 API 客户端进行查询。
┌─────────────────┐ set(key, value) ┌──────────────────────────┐
│ Logic function │ ───────────────────▶ │ "KV Store" object │
│ (your handler) │ ◀─────────────────── │ key (unique) │ value │
└─────────────────┘ get(key) └──────────────────────────┘
定义存储对象
声明一个具有两个字段的自定义对象——key(唯一的 TEXT)和 value(RAW_JSON,因此你可以存储任何可序列化为 JSON 的有效负载)。 完整的 defineObject 参考请参见对象。
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',
},
],
});
强制键唯一性
在 key 上添加一个唯一索引,这样同一个键就永远不会有两行。 这是实现唯一性的推荐原语——参见数据 → 唯一索引。
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,
},
],
});
从逻辑函数中读写
用几个小型的辅助函数来封装该对象,这样其余代码用起来就像键值 API 一样——get、set 和 del。 它们使用 CoreApiClient,该客户端由你的工作区模式生成,并针对 kvStore 对象提供完整的类型。
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 },
});
}
};
唯一索引可以防止重复,但在查找和创建之间,两个运行在同一时刻写入相同的新键时仍然可能发生竞争。 将因唯一性约束失败的创建视为“被其他人抢先了”——捕获该错误并重新读取,或改为重试执行更新。
使用示例:缓存一次昂贵的调用
一个典型用例是缓存一次缓慢或受限于速率的第三方响应,这样重复的运行就可以重复使用该响应,而不必每次都付出相同的开销。
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,
});
模式与技巧
- 命名空间划分。 给键添加前缀,以区分不同的用途,并便于批量查找——
sync-cursor:linear、cache:exchange-rate:USD:EUR、lock:nightly-report。 使用 key: { like: 'cache:%' } 进行过滤,以列出或清理整个命名空间。
- 过期时间(TTL)。 该存储本身不带有过期机制。 在
value 中存储时间戳(如缓存示例中所示)并在读取时检查,或者添加一个 DATE_TIME 字段,并通过定时任务触发的函数定期清理陈旧的行。
- 存什么。
RAW_JSON 可以存储任何可序列化为 JSON 的值——数字、字符串、数组、对象。 保持条目足够小;此存储用于协调和缓存,而不是用于存放大型二进制对象或文件。 对于文件,请使用 FILES 字段和 uploadFile。
- 可见性与权限。 这些行像任何其他记录一样存放在工作区数据库中,因此可以通过 API 查询,并遵循你的应用角色设置。 要将存储从主 UI 中隐藏,只需不要把它加入到你的导航菜单中。
- 作用域到记录。 需要针对每条记录的状态,而不是全局键吗? 从存储对象到目标对象添加一个关系,而不是把 id 编码进键中。
这是一种约定,而不是一个单独的功能——“KV Store” 只是你定义并通过标准 API 查询的常规自定义对象。 这意味着它可以像你的应用中其他数据一样,受益于相同的同步机制、权限控制和工具链。