Relate:用 TypeScript 定义带类型与权限感知的业务数据模型
relatehq/relate
Viable Systems 开源 Relate,一个用 TypeScript 定义业务数据模型的库,让应用和 AI 智能体通过单一类型化 API 读取 CRM、账单数据库等系统,每次读取都按调用者过滤权限并标注数据来源与新鲜度。
A typed, permission-aware model of your business data, defined in TypeScript.
Relate lets you describe your business objects (customers, invoices, reviews), where each field comes from, how the objects relate and who may see what. Your application, and eventually your agents, then read across your CRM, billing database and other systems through one typed API. Every read is filtered for the caller and says where each value came from and how fresh it is.
Request for comment. Relate is early (
0.0.0-dev.0), not yet on npm and not ready for production. We're publishing it now to get the model right in the open. What works today · What we'd like feedback on
Try it in two minutes
You need Node 22 (22.16+), 24 or 26 and pnpm 12 (npm i -g pnpm@12.9.1). No
database, Docker or credentials.
git clone https://github.com/relatehq/relate && cd relate pnpm install pnpm example:customer-workspace
Preparation runs quietly; add --verbose to see build logs. Build failures and
runtime errors are always shown.
A small app opens in your browser. Its customer comes from an HTTP CRM, its invoices from a SQLite database and its account reviews from Relate itself.
- As Ana, an account manager, you see Northwind's invoices but the amounts are withheld. The model says only finance sees them.
- Switch to Fin in finance. The amounts appear.
- As Ana, add a review, then click Retry last submission. You get the same receipt back and no duplicate review.
- Rename the customer in the CRM and click Refresh from sources.
- Open Inspect model to see the graph. It redraws when you edit
src/graph.ts.
Expand See what Relate did to see the exact SDK calls and the evidence returned with each read.
Why
Most real work touches more than one system. Before anyone can review an account, the CRM's customer has to be joined to the billing database's invoices. Then the code has to check that the caller may see them, judge whether the data is fresh enough to act on and make a retried submission safe.
Usually each app and each agent integration solves this again in its own glue code. Relate moves it into one model that every consumer shares. That matters more once agents are involved: an agent should see what the user asking it may see, and know whether a number is current.
We built the first version inside ARC, where Viable Systems deploys agents to do end-to-end work for media businesses. Every client had a different shape of "customer" spread across different systems. Relate is that layer, extracted as an open-source library.
What it looks like
Describe the systems you already have, the objects you want and the rules:
const access = defineAccess({ roles: ['employee', 'finance'], fieldGroups: ['ordinary', 'financial'], claims: { portfolio: z.string() }, }); // Your CRM owns customers. const crm = defineSource({ id: 'crm.customers', idField: 'id', schema: z.object({ id: z.string(), name: z.string(), portfolio: z.string() }), }); // Your billing database owns invoices. It knows customers by CRM ID. const billing = defineSource({ id: 'billing.invoices', idField: 'id', schema: z.object({ id: z.string(), crm_customer_id: z.string(), status: z.string(), total: z.number(), }), }); const Customer = defineObject({ id: 'customer', membership: source(crm), properties: { id: objectId({ id: 'customer.id' }), name: from(crm.fields.name, { id: 'customer.name' }), portfolio: from(crm.fields.portfolio, { id: 'customer.portfolio' }), }, }); const Invoice = defineObject({ id: 'invoice', membership: source(billing), properties: { id: objectId({ id: 'invoice.id' }), customer: reference(Customer, { id: 'invoice.customer', from: billing.fields.crm_customer_id, // CRM ID → Customer }), status: from(billing.fields.status, { id: 'invoice.status' }), total: from(billing.fields.total, { id: 'invoice.total', access: access.groups.financial, }), }, }); const CustomerInvoices = defineRelationship({ id: 'customer.invoices', forward: 'invoices', reverse: 'customer', via: Invoice.properties.customer, }); const graph = defineGraph({ id: 'business', objects: { Customer, Invoice }, relationships: { CustomerInvoices }, access, policies: { // Employees see customers in their own portfolio... Customer: { read: { gate: access.role('employee'), where: { portfolio: { eq: access.claims.portfolio } }, evidenceMaxAgeMs: 30_000, }, }, // ...and those customers' invoices. Only finance sees amounts. Invoice: { read: { gate: access.role('employee'), where: { customer: { portfolio: { eq: access.claims.portfolio } } }, evidenceMaxAgeMs: 30_000, }, groups: { financial: access.role('finance') }, }, }, });
Then connect the sources and read as an authenticated caller:
const relate = createRuntime({ graph, connections: [ connect(crm, { connectionId: 'crm', providerAccountId: 'acme-crm', connector: crmApi, }), connect(billing, { connectionId: 'billing', connector: sqlite({ path: './billing.sqlite' }).table('invoices', { idColumn: 'id', columns: ['crm_customer_id', 'status', 'total'], }), }), ], }); // Records enter the graph by explicit adoption for now (see Status). const northwind = await relate.host.adopt(Customer, 'crm_456'); await relate.host.adopt(Invoice, 'INV-1042'); // Your app authenticates the caller. Relate enforces the model's rules. const { objects } = relate.as({ id: 'ana', roles: ['employee'], claims: { portfolio: 'north' }, }); const invoices = await objects.Customer.traverse.invoices(northwind, { select: ['status', 'total'], });
Ana gets the status. Compact evidence keeps the explanation for the withheld amount. Each record in the page looks like this:
{
"id": "invoice-uuid",
"data": { "status": "Overdue" },
"meta": {
"evidence": "compact",
"completeness": "partial",
"degraded": false,
"definitionRevision": "sha256:…",
"fields": { "total": { "status": "forbidden" } }
}
}Pass evidence: 'full' to include every selected field's provenance and
freshness details.
A customer outside Ana's portfolio returns not-found, the same as one that
doesn't exist.
What's different
- Access rules live in the model. Role gates, claim-based row filters (including through references, such as "invoices of customers in my portfolio") and field groups are declared once. They are enforced on every read, traversal and action, so no consumer can forget to check.
- Every value has evidence. Compact responses preserve exceptional field states; full responses report provenance and freshness for every selected field. Policy checks can demand evidence no older than a set age.
- Writes are actions with receipts. Typed, authorized actions run in a transaction. They return a receipt for success or for a declared business failure, and retrying with the same idempotency key replays the receipt rather than repeating the write.
- Source systems stay in charge. Relate keeps the identities and observations it needs. It doesn't copy your CRM into a new silo.
Querying the graph
Query existing graph members with equality filters, or omit where to
enumerate:
const page = await objects.Invoice.query({ where: { status: 'Overdue' }, select: ['status', 'total'], limit: 25, });
Await one page or use for await to follow all pages. Queries cover adopted
source records and Relate-owned records; they do not discover provider records.
See
Reading Data
for filters, access rules, pagination, and current limits.
Many-to-many relationships
Model collections in both directions using an explicit junction object and
defineRelationship({ through: { from, to }, ... }). Traversal returns distinct
destinations while keeping membership metadata on the junction. See
Graph Modeling
for a complete example.
Status
| Works today | Not yet |
|---|---|
| TypeScript authoring, compiler and diagnostics | Direct source queries and sync from sources |
| Objects backed by one source; references across sources | One object enriched from several sources |
| Graph queries with equality filters | Comparison filters, sorting and aggregates |
| Two-way relationship traversal | MCP and HTTP interfaces (designed, not built) |
| Role gates, claim filters, field-level access | Writing back to source systems |
| Per-field evidence and freshness bounds | Migrating between model revisions |
| Relate-owned objects, idempotent actions and receipts | Published npm packages |
| In-memory and Postgres stores | Work lineage graph of agent reads and actions |
| SQLite, Stripe and Salesforce (read-only) connectors | |
relate dev model inspector |
Relate runs embedded in a Node application today. Agent access through MCP is
the next major piece. The intended shape is get, query and traverse tools
per object plus one tool per action, all scoped to the authenticated caller. It
isn't built yet.
After that comes work lineage: a graph of how an agent's work unfolded, linking each object it read, each traversal it followed and each action it took, so you can see what it looked at and why it did what it did.
Compact evidence
Reads default to compact evidence, preserving exceptional field states and
warnings. Pass evidence: 'full' for every selected field's provenance and
freshness. See
Read Responses & Evidence for
the canonical response and options reference.
Feedback
We'd most like to hear about:
- The model. Do sources → objects → relationships → policies match how you think about your own domain? What can't you express?
- Evidence. Is per-field provenance and freshness useful in practice, or noise? What would you do with it?
- Getting records in. Adoption is explicit today. Would you want sync, scanning, change feeds or on-demand fetches?
- Agents. What should an agent see? Is a tool per object the right granularity for MCP?
- Connectors. Which systems would you need first?
Please open a discussion or an issue.
Learn more
- Docs: Why Relate, Getting started, Key concepts
- Examples, in learning order, from the smallest terminal hello world to persistent Postgres storage
- Inspector:
pnpm relate dev - Contributing · Releasing
License
Apache 2.0. Created by Viable Systems.
来源:GitHub · github.com