Skip to content

Entities and Schema

Your connector’s foundation is its data model - the entities, fields, and schema that describe what data you’re syncing.

This tutorial builds an Acme connector step by step. By the end of all six parts, you’ll have a complete, installable connector. Each part adds one layer.

Define your entities

An entity represents a data object from your source system - users, projects, tasks. Each entity has typed fields.

connectors/connector-acme/src/entities.ts
import { EntityDef, Field, type ScalarField, type RefField, type CollectionField } from "@max/core";

Start with a simple entity:

export interface AcmeUser extends EntityDef<{
displayName: ScalarField<"string">;
email: ScalarField<"string">;
role: ScalarField<"string">;
active: ScalarField<"boolean">;
}> {}
export const AcmeUser: AcmeUser = EntityDef.create("AcmeUser", {
displayName: Field.string(),
email: Field.string(),
role: Field.string(),
active: Field.boolean(),
});

The interface + const pattern gives you one name that works as both a type and a value:

// As a type
const ref: Ref<AcmeUser> = ...
// As a value
AcmeUser.ref("u123")

Relational fields

Entities can reference each other. Field.ref() creates a one-to-one reference; Field.collection() creates a one-to-many relationship:

export interface AcmeProject extends EntityDef<{
name: ScalarField<"string">;
description: ScalarField<"string">;
status: ScalarField<"string">;
owner: RefField<AcmeUser>;
tasks: CollectionField<AcmeTask>;
}> {}
export const AcmeProject: AcmeProject = EntityDef.create("AcmeProject", {
name: Field.string(),
description: Field.string(),
status: Field.string(),
owner: Field.ref(AcmeUser),
tasks: Field.collection(AcmeTask),
});

Field types

FactoryTypeUse
Field.string()ScalarField<"string">Text values
Field.number()ScalarField<"number">Numeric values
Field.boolean()ScalarField<"boolean">True/false
Field.date()ScalarField<"date">Timestamps
Field.ref(Target)RefField<Target>Reference to another entity
Field.refThunk(() => Target)RefField<Target>Lazy ref (breaks circular deps)
Field.collection(Target)CollectionField<Target>One-to-many relationship

Declaration order

Declare entities leaf-first. Field.ref() needs its target to already exist as a const:

AcmeUser (leaf - no refs)
AcmeTask (refs AcmeUser)
AcmeProject (refs AcmeUser, collection of AcmeTask)
AcmeWorkspace (collections of AcmeUser, AcmeProject)
AcmeRoot (collection of AcmeWorkspace)

For circular references, use Field.refThunk() to defer resolution:

export const AcmeTask: AcmeTask = EntityDef.create("AcmeTask", {
title: Field.string(),
project: Field.refThunk(() => AcmeProject),
});

Define your schema

The schema declares your connector’s complete data model and its entry points:

connectors/connector-acme/src/schema.ts
import { Schema } from "@max/core";
import { AcmeUser, AcmeWorkspace, AcmeRoot, AcmeProject, AcmeTask } from "./entities.js";
export const AcmeSchema = Schema.create({
namespace: "acme",
entities: [AcmeUser, AcmeWorkspace, AcmeRoot, AcmeProject, AcmeTask],
roots: [AcmeRoot],
});

roots are the starting points for sync. The seeder creates root entities and the sync plan fans out from there.

Define your context

Context holds the runtime dependencies your loaders will need - API clients, configuration values, workspace IDs.

connectors/connector-acme/src/context.ts
import { Context } from "@max/core";
import type { AcmeClientProvider } from "./acme-client.js";
export class AcmeAppContext extends Context {
api = Context.instance<AcmeClientProvider>();
workspaceId = Context.string;
}

Extend Context and use typed descriptors as field initializers:

DescriptorUse
Context.instance<T>()Object instance (API client, service)
Context.stringString value
Context.numberNumber value
Context.booleanBoolean value

The context is hydrated later when the connector is installed - you’ll see this in Wiring and Packaging.

What you have so far

At this point your connector has:

  • Entity definitions with typed fields and relationships
  • A schema that registers all entities and declares entry points
  • A context class describing what runtime dependencies loaders will need

Next, you’ll learn how to wrap your API calls in operations - giving the framework visibility into every external call your connector makes.

Next: Operations