@nokto-labs/relate
v0.9.0
Published
Define your domain in TypeScript. Get typed records, relationships, activity tracking, dynamic lists, and a full REST API.
Maintainers
Readme
@nokto-labs/relate
Core SDK for defining your domain in TypeScript and working with typed records, refs, relationships, activities, lists, hooks, and aggregate queries.
What You Can Build
- A CRM with contacts, accounts, deals, notes, ownership, and timelines
- An event app with guests, sessions, sponsors, check-ins, and outreach history
- An operations backend for vendors, inventory, orders, approvals, and queues
- A community product with members, orgs, referrals, activities, and saved segments
Links
- Repository: github.com/nokto-labs/relate
- Package source: packages/relate
- Issues: github.com/nokto-labs/relate/issues
- Example: Cloudflare Worker example
Install
npm install @nokto-labs/relateQuick Start
import { defineSchema, relate } from '@nokto-labs/relate'
const schema = defineSchema({
objects: {
person: {
plural: 'people',
attributes: {
email: { type: 'email', required: true },
name: 'text',
source: 'text',
tier: { type: 'select', options: ['vip', 'regular', 'trial'] as const },
},
uniqueBy: 'email',
},
company: {
plural: 'companies',
attributes: {
domain: { type: 'text', required: true },
name: 'text',
},
uniqueBy: 'domain',
},
deal: {
plural: 'deals',
attributes: {
title: { type: 'text', required: true },
value: 'number',
stage: {
type: 'select',
options: ['lead', 'qualified', 'proposal', 'closed_won', 'closed_lost'] as const,
},
owner: { type: 'ref', object: 'person', onDelete: 'set_null' },
},
},
},
relationships: {
works_at: { from: 'person', to: 'company' },
},
})
const db = relate({ adapter: myAdapter, schema })
const alice = await db.person.create({ email: '[email protected]', name: 'Alice' })
const acme = await db.company.upsert({ domain: 'acme.com', name: 'Acme' })
await db.relationships.create({
from: { object: 'person', id: alice.id },
to: { object: 'company', id: acme.id },
type: 'works_at',
})Schema
Attribute types
textnumberbooleandateemailurlselectref
Object options
| Option | Description |
|--------|-------------|
| attributes | Record fields |
| plural | REST name, defaults to slug + "s" |
| uniqueBy | Field used by upsert() and duplicate detection; storage adapters can enforce it as a hard uniqueness guarantee |
| id | Custom ID generator function. Must return a string. Defaults to crypto.randomUUID() |
| idPrefix | Prefix prepended to generated IDs (e.g. 'evt' produces evt_<id>) |
Client API
db instance
relate() returns one typed object client per schema object plus shared clients and helpers.
| Property / method | Description |
|-------------------|-------------|
| db.<object> | Typed object client generated from your schema |
| db.relationships | Relationship client |
| db.activities | Activity client |
| db.lists | Lists client |
| db.migrate() | Sync schema to storage |
| db.applyMigrations(migrations) | Run tracked custom migrations |
| db.batch(builder) | Atomically commit queued record creates and updates when the adapter supports batched mutations |
| db.webhook(externalId, handler, options?) | Deduplicate webhook handlers with built-in retry state when the adapter supports it |
| db.cleanupWebhooks(before?) | Delete old processed webhook entries when the adapter supports webhook cleanup |
| db.on(event, handler) | Register a lifecycle hook |
| db.off(event, handler) | Remove a lifecycle hook |
Object client
| Method | Description |
|--------|-------------|
| create(attributes) | Create a record |
| upsert(attributes) | Create or update by uniqueBy |
| get(id) | Get one record by ID |
| find(options?) | List records |
| findPage(options?) | Cursor-paginated list |
| count(filter?) | Count matching records |
| aggregate(options) | Count, group, and sum matching records |
| update(id, attributes) | Partially update a record |
| delete(id) | Delete a record |
batch() atomic writes
Use db.batch() when you need several writes to succeed or fail together without dropping to raw SQL.
const result = await db.batch((b) => {
const order = b.deal.create({ title: 'Launch', stage: 'lead' })
b.deal.update(order.id, { stage: 'won' })
return { orderId: order.id }
})batch() is intentionally a write builder, not a general transaction callback:
- the callback must stay synchronous
- reads such as
get(),find(), andcount()are not available inside the builder - v1 supports
create()andupdate() - hooks fire only after the full batch commits successfully
- refs can target records created earlier in the same batch by using the returned handle
id
batch() gives you atomicity for the queued write set. It does not solve read-then-write races such as stock checks or counters.
webhook() idempotency helper
Use db.webhook() when you want built-in webhook claim state without defining your own webhookEvent object.
const result = await db.webhook('stripe:evt_123', async () => {
await db.contact.upsert({ email: '[email protected]' })
await db.deal.create({ title: 'Webhook order' })
return 'processed'
})Behavior:
- the handler runs only when Relate claims a fresh or retryable webhook key
- already processed keys return
{ executed: false, reason: 'processed' } - in-flight keys with an active lease return
{ executed: false, reason: 'processing' } - handler errors release the claim and record retry state so a later call can run again
- if a handler runs longer than its lease, another caller can reclaim the key and the original completion update will be ignored
db.cleanupWebhooks(before?)deletes processed webhook rows older than the chosen cutoff
This helper is honest about D1’s limits: it tracks claims, processed timestamps, and retry state, but it is not a true exact-once transaction across crashes. Keep the writes inside your handler idempotent if a crash between side effects and processedAt would matter.
You can pass options.leaseMs when a handler may legitimately run longer than the default claim lease.
find() options
| Option | Type | Description |
|--------|------|-------------|
| filter | object | Filter expression |
| limit | number | Maximum rows |
| offset | number | Offset pagination |
| orderBy | string | Attribute to sort by |
| order | 'asc' \| 'desc' | Sort direction |
findPage() options
| Option | Type | Description |
|--------|------|-------------|
| filter | object | Filter expression |
| limit | number | Maximum rows |
| orderBy | string | Attribute to sort by |
| order | 'asc' \| 'desc' | Sort direction |
| cursor | string | Cursor from the previous page |
Relationships client
| Method | Description |
|--------|-------------|
| create(input) | Create a relationship row; declared relationship types are validated against the schema |
| list(ref?, options?) | List relationships, optionally scoped to a record |
| update(id, attributes) | Update relationship attributes |
| delete(id) | Delete a relationship |
Activities client
| Method | Description |
|--------|-------------|
| track(input) | Append an activity for an existing record |
| list(ref?, options?) | List activities, optionally scoped to a record |
Lists client
| Method | Description |
|--------|-------------|
| create(input) | Create a list |
| get(id) | Get a list by ID |
| list(options?) | List all lists |
| update(id, attrs) | Update a list's name or filter |
| delete(id) | Delete a list |
| addTo(listId, recordIds) | Add existing records to a static list |
| removeFrom(listId, recordIds) | Remove records from a static list |
| items(listId, options?) | Get list items; saved dynamic filters cannot be overridden at read time |
| count(listId, filter?) | Count list items |
Refs
Refs are foreign-key-like attributes stored directly on a record.
guest: { type: 'ref', object: 'guest', required: true, onDelete: 'cascade' }
event: { type: 'ref', object: 'event', required: true, onDelete: 'restrict' }Ref options
| Option | Default | Description |
|--------|---------|-------------|
| object | required | Target object slug |
| required | false | Rejects missing or null values |
| validate | true | Verifies the target record exists on create and update |
| onDelete | 'restrict' | Controls delete behavior |
onDelete
| Value | Behavior |
|-------|----------|
| restrict | Block deletion if referencing records exist |
| cascade | Recursively delete referencing records |
| set_null | Set the ref field to null |
| none | Leave the ref unchanged |
Guarantees
set_nullwithrequired: trueis rejected at schema validation time- Cascade deletes and
set_nullupdates emit the same*.deletedand*.updatedhooks as direct operations - When an adapter supports batched record mutations, the entire cascade plan can be committed atomically
@nokto-labs/relate-d1supports atomic cascade application
Relationships
When you declare relationship types in schema.relationships, db.relationships.create() validates that:
- the relationship type exists
- the
fromandtoobjects match the declared shape - both endpoint records exist
If schema.relationships is omitted, relationship type remains open-ended and is not schema-validated.
Filtering
Filters can be written as equality shorthand:
await db.person.find({ filter: { tier: 'vip' } })Or with explicit operators:
await db.deal.find({
filter: {
value: { gte: 10_000 },
stage: { in: ['lead', 'qualified'] },
},
})
await db.order.count({
account: accountId,
paymentId: { eq: null },
})Operators
| Operator | SDK shape | Example |
|----------|-----------|---------|
| equality shorthand | { field: value } | { tier: 'vip' } |
| eq | { field: { eq: value } } | { tier: { eq: 'vip' } } |
| ne | { field: { ne: value } } | { tier: { ne: 'trial' } } |
| gt | { field: { gt: value } } | { value: { gt: 1000 } } |
| gte | { field: { gte: value } } | { value: { gte: 1000 } } |
| lt | { field: { lt: value } } | { value: { lt: 5000 } } |
| lte | { field: { lte: value } } | { value: { lte: 5000 } } |
| in | { field: { in: [...] } } | { stage: { in: ['lead', 'won'] } } |
| like | { field: { like: pattern } } | { name: { like: 'Ali%' } } |
Null filtering
Optional attributes can be filtered with null through the typed SDK:
await db.order.find({ filter: { paymentId: null } })
await db.order.count({ paymentId: { eq: null } })
await db.order.find({ filter: { paymentId: { in: [null, 'pay_123'] } } })Notes:
- equality shorthand accepts
nullon optional fields eq,ne, andinalso acceptnullwhere the attribute type is optional- range operators such as
gtandltstay non-nullable
Filter value types
| Attribute type | Filter value |
|----------------|--------------|
| text, email, url, select, ref | string |
| number | number |
| boolean | boolean |
| date | Date |
Optional attributes also accept null for equality-style filters.
Aggregates
Use aggregate() for grouped counts and numeric sums.
const byStage = await db.deal.aggregate({
filter: { owner: alice.id },
count: true,
groupBy: 'stage',
})
// { groups: { lead: 3, qualified: 5, closed_won: 2 } }
const pipelineValue = await db.deal.aggregate({
filter: { owner: alice.id, stage: { in: ['lead', 'qualified', 'proposal'] } },
sum: { field: 'value' },
})
// { sum: 125000 }
const revenueByPrice = await db.ticket.aggregate({
filter: { paymentStatus: 'confirmed' },
count: true,
groupBy: 'price',
sum: { field: 'price.amountCents' },
})
// {
// groups: { price_basic: 12, price_vip: 3 },
// groupSums: { price_basic: 240000, price_vip: 180000 },
// }Aggregate notes
- v1 supports
count,sum, andgroupBy groupByrequirescount: truegroupByandsumcan be combined in one call; grouped sums are returned asgroupSumssum.fieldsupports direct numeric attributes everywheresum.fieldcan also traverse exactly one ref hop, for exampleprice.amountCents, when the adapter implements native aggregate joins- ref sums still typecheck when that ref is optional, for example
pricewithonDelete: 'set_null' groupBystays limited to direct attributes in v2- If an adapter does not implement native aggregates, Relate falls back to a JavaScript implementation for direct-field aggregates, logs a warning, and loads matching records into memory
- Ref-aware aggregate sums require native adapter support and do not silently fall back to JavaScript joins
Pagination
const page1 = await db.person.findPage({ limit: 20 })
const page2 = await db.person.findPage({ limit: 20, cursor: page1.nextCursor })Use findPage() unless you specifically need offset pagination.
Relationships
const rel = await db.relationships.create({
from: { object: 'person', id: alice.id },
to: { object: 'company', id: acme.id },
type: 'works_at',
attributes: { role: 'CEO' },
})
await db.relationships.list({ object: 'person', id: alice.id })
await db.relationships.update(rel.id, { role: 'CTO' })
await db.relationships.delete(rel.id)Activities
await db.activities.track({
record: { object: 'deal', id: deal.id },
type: 'stage_changed',
metadata: { from: 'lead', to: 'qualified' },
})
await db.activities.list({ object: 'deal', id: deal.id }, { limit: 50 })Lists
const speakers = await db.lists.create({
name: 'Speakers',
object: 'person',
type: 'static',
})
await db.lists.addTo(speakers.id, [alice.id])
await db.lists.items(speakers.id, { limit: 20 })
const bigDeals = await db.lists.create({
name: 'Big open deals',
object: 'deal',
type: 'dynamic',
filter: { value: { gte: 50_000 }, stage: { in: ['lead', 'qualified', 'proposal'] } },
})
await db.lists.count(bigDeals.id)Events
import { EventBus } from '@nokto-labs/relate'
const events = new EventBus()
events.on('person.created', async ({ record, db }) => {
await db.person.update(record.id, { source: 'api' })
})
events.on('deal.updated', ({ record, changes }) => {
if (changes.stage === 'closed_won') {
console.log(`Deal won: ${record.title}`)
}
})
const db = relate({ adapter: myAdapter, schema, events })Event behavior
createdhandlers receive{ record, db }updatedhandlers receive{ record, changes, db }deletedhandlers receive{ id, db }- Hook errors are isolated and logged
- Recursive hook chains are capped at depth 5 per event name
Errors
Relate throws structured errors such as:
ValidationErrorDuplicateErrorNotFoundErrorRefNotFoundErrorRefConstraintErrorCascadeDepthErrorInvalidSchemaError
Migrations
await db.migrate()
await db.applyMigrations(migrations)migrate() syncs schema structure. applyMigrations() runs tracked custom migrations when the adapter supports them.
Companion packages
- D1 adapter: @nokto-labs/relate-d1
- Hono routes: @nokto-labs/relate-hono
