跳到正文
GitHub·· 9 天前AI 评分49

Relate:用 TypeScript 定义带类型与权限感知的业务数据模型

relatehq/relate

AI 导读

Viable Systems 开源 Relate,一个用 TypeScript 定义业务数据模型的库,让应用和 AI 智能体通过单一类型化 API 读取 CRM、账单数据库等系统,每次读取都按调用者过滤权限并标注数据来源与新鲜度。

正文

Relate

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

The Relate inspector showing Customer, Invoice and AccountReview objects and their relationships

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.

  1. As Ana, an account manager, you see Northwind's invoices but the amounts are withheld. The model says only finance sees them.
  2. Switch to Fin in finance. The amounts appear.
  3. As Ana, add a review, then click Retry last submission. You get the same receipt back and no duplicate review.
  4. Rename the customer in the CRM and click Refresh from sources.
  5. 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:

  1. The model. Do sources → objects → relationships → policies match how you think about your own domain? What can't you express?
  2. Evidence. Is per-field provenance and freshness useful in practice, or noise? What would you do with it?
  3. Getting records in. Adoption is explicit today. Would you want sync, scanning, change feeds or on-demand fetches?
  4. Agents. What should an agent see? Is a tool per object the right granularity for MCP?
  5. Connectors. Which systems would you need first?

Please open a discussion or an issue.

Learn more

License

Apache 2.0. Created by Viable Systems.

来源:GitHub · github.com