# Overview What ECHO is and how these docs are organized. ECHO, the **E**ventually **C**onsistent **H**ierarchical **O**bject store, is a peer-to-peer graph database for TypeScript. ## Model - Peers replicate data with [CRDTs](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type). There is no central authority: the data lives with the clients. - Peers connect to each other over [WebRTC](https://en.wikipedia.org/wiki/WebRTC). - Several writers can change the same data at once, including structured objects and collaborative text. - A peer can write while offline. Its changes merge when it rejoins the network. Data lives in [spaces](/docs/echo/concepts/spaces/), and each unit of data is an [object](/docs/echo/concepts/objects-relations/). [Why ECHO?](/docs/echo/why-echo/) makes the case for this design. ## Where ECHO fits in DXOS DXOS is an open source framework for real-time, collaborative apps that keep their data on the client and sync it peer to peer. ECHO is one of its parts: - **ECHO** stores and replicates the data. - **[HALO](/docs/halo/overview/)** gives each user an identity, links their devices and controls who can join a space. - **MESH** is the networking that ECHO and HALO use to reach peers. - **EDGE** runs web services on Cloudflare Workers for sync, backup and compute. - **[PLEX](/docs/plex/overview/)** builds apps from plugins and capabilities. [Composer](/composer), our local-first workspace, is built on it. The SDK is under active development, and right now its features follow what Composer needs. Support for other uses is limited, so suggestions and contributions that line up with Composer get priority. Feedback goes to [GitHub](https://github.com/dxos/dxos/issues) or [Discord](https://dxos.org/discord). ## How these docs are organized - **Learn** builds one app step by step, from [Installation](/docs/echo/learn/installation/) to [Sharing a space](/docs/echo/learn/sharing/): 1. Install `@dxos/client` and create a client. 2. Set up an identity with [HALO](/docs/halo/overview/). 3. Create or join a space. 4. Add, change and query objects. - **Core concepts** describe one idea each, for a reader who has finished Learn. - **Guides** cover one `@dxos/echo` namespace per page, such as [Filter](/docs/echo/api/filter/), then the frameworks, such as [React](/docs/echo/guides/react/). ## Behavior Each concept page states what ECHO guarantees, and each claim cites an item in the ECHO spec. --- # Why ECHO? The data model that apps and agents share, and that ties external data together. ECHO is the data model that applications and agents share. Plugins code against its objects instead of keeping private stores. An agent keeps its memory in the same graph, and can inspect the schema of everything in it. Data from external systems lands there as linked objects, so one query can reach all of it. ## Agents need memory they can query A conversation ends and its context goes with it. An agent that works across sessions needs somewhere durable to put what it learned, and that memory is only useful if it connects to the work it is about. In ECHO a memory is an ordinary object. It has a type, it can reference the issue it is about, and it comes back through the same query an application uses. The `org.dxos.skill.memory` skill stores an agent's memories this way. ```ts import * as Schema from 'effect/Schema'; import { DXN, Filter, Obj, Query, Ref, Type } from '@dxos/echo'; class Memory extends Type.makeObject( DXN.make('com.example.type.memory', '0.1.0'), )( Schema.Struct({ title: Schema.String, content: Schema.String, about: Schema.optional(Ref.Ref(Issue)), }), ) {} const issue = db.add(Obj.make(Issue, { title: 'Sync drops the last page' })); db.add( Obj.make(Memory, { title: 'Root cause', content: 'The cursor skips short pages.', about: Ref.make(issue), }), ); const recalled = await db.query(Query.select(Filter.type(Memory))).run(); ``` Because memory is not a separate store, it replicates with the rest of the space, and any code that can query the space can read it. ## An agent can only use what it can inspect An agent has no compiled types. It works from what it can read at run time, so the schema has to be data, not only a TypeScript declaration. Every ECHO type reports its name, version and metadata at run time, and converts to JSON Schema, the format tool parameters already use. [org.dxos.echo#F-2.3] The schema goes the other way too: a type built from JSON Schema and added to a database replicates like any object, so an agent can define a new type while it runs. ```ts import * as Schema from 'effect/Schema'; import { JsonSchema, Type } from '@dxos/echo'; const typename = Type.getTypename(Memory); const version = Type.getVersion(Memory); const parameters = JsonSchema.toJsonSchema(Memory); const Contact = Type.makeObjectFromJsonSchema({ typename: 'com.example.type.contact', version: '0.1.0', jsonSchema: JsonSchema.toJsonSchema(Schema.Struct({ name: Schema.String })), }); await db.addType(Contact); ``` The database skill in `plugin-space` gives agents this through its `QueryTypes` and `AddType` operations. An agent can look up what a space holds before it writes, instead of guessing at field names. ## External data needs somewhere to meet Mail, issues and calendars live in systems that do not know about each other. Syncing them into ECHO makes each record an object, with a foreign key that records where it came from. A `Ref` can then link a GitHub issue to a Linear task, or to the note that discusses both. The foreign key is what makes a repeat sync safe. Look the record up by its key, then update the object you found or add a new one, and the record does not end up in the graph twice. ```ts import { Filter, Obj, Query } from '@dxos/echo'; const key = { source: 'github.com', id: '1234' }; const [existing] = await db .query(Query.select(Filter.foreignKeys(Issue, [key]))) .run(); if (existing) { Obj.update(existing, (existing) => { existing.title = 'Sync drops the last page'; }); } else { db.add( Obj.make(Issue, { [Obj.Meta]: { keys: [key] }, title: 'Sync drops the last page', }), ); } ``` `plugin-github` and `plugin-linear` sync this way. Once the data is in the graph, the code that reads it does not need to know which system it came from. ## Plugins code against the data model A plugin declares its types with `Type.makeObject` and contributes them to the schema registry. Its UI, its operations and the agents that use it all read and write the same objects. There is no plugin-specific API in between, so a second plugin can select the first plugin's objects by type. The rest of this page is the argument for the model itself. Keeping state on a server means client code spends its time on copies: caches, optimistic updates, retry queues and conflict handling. ECHO moves the state onto the device. Objects are CRDT documents in a local database, so a write is finished when the call returns, and peers merge their changes when they connect. ## The ECHO pattern An ECHO type is a class that declares one schema. The class is the runtime schema and the TypeScript type of its instances. ```ts import * as Schema from 'effect/Schema'; import { DXN, Ref, Type } from '@dxos/echo'; class Person extends Type.makeObject( DXN.make('com.example.type.person', '0.1.0'), )(Schema.Struct({ name: Schema.String })) {} class Task extends Type.makeObject( DXN.make('com.example.type.task', '0.1.0'), )( Schema.Struct({ title: Schema.String, done: Schema.Boolean, assignee: Schema.optional(Ref.Ref(Person)), }), ) {} ``` Reading the signature left to right: - `Type.makeObject`: an object type - ``: instance type - `(dxn)`: global type name and version - `(schema)`: Effect Schema of the fields Instances are live objects. You add them to a database, change them with `Obj.update`, and select them with a typed query. ```ts import { Filter, Obj, Query, Ref } from '@dxos/echo'; const ada = db.add(Obj.make(Person, { name: 'Ada' })); const task = db.add( Obj.make(Task, { title: 'Review', done: false, assignee: Ref.make(ada) }), ); Obj.update(task, (task) => { task.done = true; }); const finished = await db .query(Query.select(Filter.type(Task, { done: true }))) .run(); ``` Nothing here waits on a network. `db.add` and `Obj.update` apply locally and return, and `finished` already contains the task. [org.dxos.echo.query#F-2] ## Don't rebuild the data layer Here is one task, marking an item as done, written against a REST API and against ECHO. The REST version is not careless. It is the minimum that handles a slow network, an offline user and a concurrent edit, and it still has two open questions in its comments. ```ts const finish = async (id: string) => { const previous = cache.get(id); if (!previous) { return; } const next = { ...previous, done: true }; cache.set(id, next); render(); const response = await fetch(`/api/tasks/${id}`, { method: 'PATCH', headers: { 'If-Match': String(previous.version) }, body: JSON.stringify({ done: true }), }).catch(() => undefined); if (!response) { queue.push(next); // Offline: replay later, somehow. } else if (response.status === 412) { cache.set(id, previous); // Conflict: drop the user's edit? render(); } }; ``` ```ts import { Obj } from '@dxos/echo'; const finish = (id: string) => { const task = db.getObjectById(id); if (task) { Obj.update(task, (task) => { task.done = true; }); } }; ``` The ECHO version has no error branches for these cases because the failures do not reach it. There is no request to fail, the local write cannot conflict with itself, and a concurrent edit from a peer merges as a CRDT change. ## DXOS apps compared with SaaS apps | | SaaS web apps | DXOS apps | | :-- | :-- | :-- | | How code is served | Rendered on the client and the server | Served as static files and rendered on the client | | Where data is stored | On the **server** | On the **client**, optionally backed up to a server | | How data is exchanged | Client to server over HTTP or WebSockets | Peer to peer over WebRTC, optionally through a sync server | | How identity is established | The server issues a session token after checking credentials, for example with OAuth | Each client generates its own key pair and signs its changes with it | ## Solving practical problems ### Your UI never shows stale local state Each query reads one local engine, which answers from the objects this tab holds. A subscriber sees your own write in the same call that made it, so a list does not need an optimistic copy of itself. [org.dxos.echo.query#F-2] ### A query can see more than this device holds Queries also read the host index and EDGE, which hold objects this tab has not loaded. ECHO treats those answers as evidence. It re-evaluates each remote entry against the local document and drops the entry when local state contradicts it, so a slow server cannot bring back an object you deleted. [org.dxos.echo.query#F-5] ### Your types can change after you ship Every type carries a version in its DXN. An object migration declares how objects of an old version become the new one, so data written by last year's build still opens. [org.dxos.echo#Migration] ### Your graph is not limited to one document A `Ref` field stores a DXN and resolves lazily, across objects and across spaces. Relations are objects too, with typed source and target endpoints, so you can query from either end. [org.dxos.echo#F-4] ## Enjoy building Once persistence and merging are the database's job, the code that remains is your application. Start with the [learning path](/docs/echo/learn/defining-types/), or look up a namespace in the [guides](/docs/echo/api/filter/). --- # Build with a coding agent Have Claude Code, Codex or Cursor build a small ECHO app in a folder of yours, from a prompt you can edit first. A coding agent can build a small app on ECHO in a folder of yours. Open an empty folder in your agent, then describe the app you want in the prompt below, or pick an example, and open it in your agent. export const examples = [ { label: 'TodoMVC', prompt: 'Build TodoMVC on ECHO in this folder: a Vite and React app where each todo is an ECHO object in a space the browser keeps. I can add a todo, tick it done, edit its text with a double click, delete it, filter All, Active and Completed, and clear the completed ones. The todos are still there after a reload.', }, { label: 'Kanban board', prompt: 'Build a kanban board on ECHO in this folder: a Vite and React app with the columns To do, Doing and Done. Each card is an ECHO object that references its column. I can add a card, edit its title, and drag it to another column or to a new place in its own. A Share button gives me an invite code, and someone who pastes it into the app in another browser sees the same board and each move as it happens.', }, { label: 'Collaborative notes', prompt: 'Build a collaborative notes app on ECHO in this folder: a Vite and React app with a list of notes beside an editor for the selected one. Each note has a title and a body, and the body is ECHO text, so two people can type in the same note at once and keep both their edits. A Share button gives me an invite code that someone pastes into the app in another browser to join.', }, ]; export const context = [ 'Read https://dxos.org/echo/llms-full.txt first. It has every ECHO page in one file.', 'Set the project up from these files: https://dxos.org/echo/project/package.json, https://dxos.org/echo/project/vite.config.ts, https://dxos.org/echo/project/tsconfig.json and https://dxos.org/echo/project/index.html. Until the next npm release, every @dxos package comes from a pkg.pr.new build of main that package.json pins. The npm releases are older than these docs, so do not install @dxos packages from npm or change those versions.', ].join(' '); ```text ``` ## What the agent reads The prompt has the agent read `/echo/llms-full.txt` first. It holds every ECHO page as Markdown, code samples included, so the agent has the whole section in one fetch. The agent then sets the project up from the learning path's package.json and Vite config, which pin each `@dxos` package to a build of main until the next npm release. [llms.txt](/docs/echo/resources/llms-txt/) lists the other files for agents. ## While it works The agent sets up the project, writes the app and tells you how to run it. Run it when it says so and try what you asked for. To try a shared app, open it in a second browser or a private window, so the second copy has an identity of its own, and join with the code the first one gives you. To build the learning path's app yourself instead, start with [The ECHO object](/docs/echo/learn/the-echo-object/). --- # The ECHO object What a live object is, and the app the learning path builds, running. The learning path builds one app: a contact book of organizations, the people who work at them, and the events they attend. This page explains what the app is made of, and shows it finished. ## What an object is Everything the app stores is an ECHO object. An object has three parts: - **An id.** Every peer that holds the object knows it by the same id. - **A type.** The type names a schema, which says what fields the object has. The compiler checks the fields, and so does ECHO when it stores them. - **Fields.** You read them like the fields of a plain object. A field can hold a reference to another object, which is how a person points at the organization they work for. Objects live in a space's database, on the device. Reading one never waits on the network. A change is written locally first and then reaches the other peers in the space. When two peers change the same object, ECHO merges their changes rather than keeping only one. An object is also live: code can subscribe to it, or to a query, and run whenever it changes, wherever the change came from. The app redraws itself this way. [Objects and relations](/docs/echo/concepts/objects-relations/) describes the model in full. ## The finished app Here it runs on two devices side by side. Pick a type, open a record, and edit it in the form. Invite Bob from Alice's toolbar and join from his, and both devices show the same records. To change the code and run it yourself, fork it on StackBlitz. Each page of the learning path builds one piece of it: | Page | File | What it adds | | --- | --- | --- | | [Installation](/docs/echo/learn/installation/) | `App.tsx` | A client on the network, an identity and the app's space | | [Defining types](/docs/echo/learn/defining-types/) | `schema.ts`, `seed.ts`, `Contacts.tsx` | Organization, Person and Event, and the first records | | [Querying](/docs/echo/learn/querying/) | `Contacts.tsx`, `RecordForm.tsx` | Sorted lists, search, a live count and the people at an organization | | [Mutating objects](/docs/echo/learn/mutating-objects/) | `RecordForm.tsx`, `Contacts.tsx`, `RecordGraph.tsx` | Adding, editing and removing records | | [Sharing a space](/docs/echo/learn/sharing/) | `Share.tsx` | Invite and Join in the toolbar, so a second device can share the space | The pages show the app's ECHO code in plain TypeScript. The [React guide](/docs/echo/guides/react/) walks through the components that draw it. --- # Installation Add the packages and open a database in Node or the browser. By the end of this page the tutorial app will have a running client, an identity, and the space it keeps its records in. To write the code as you read, open the starter on StackBlitz. It is the finished app with the code from these pages taken out. Each piece the pages write is a `todo` in the file that uses it, and the app names the next step to write and its file. ## Background The `Client` is the app's way into ECHO. It holds the databases of the spaces you belong to, on the device, and connects them to the network so peers can replicate them. A client acts for an identity. The identity owns the spaces it creates, and other peers recognize it when it joins theirs. A space is a database that a set of identities replicate between them, and every object lives in exactly one space. The app keeps all its records in one space, called Contacts. ## Steps 1. **Install the client.** `@dxos/client` holds the `Client`, and it brings `@dxos/echo` with it. The schemas these pages write also import `effect`. Until the next npm release, install the build of main these pages describe, from pkg.pr.new. The releases on npm are older and lack some of the APIs below. ```bash pnpm add https://pkg.pr.new/dxos/dxos/@dxos/client@20a14b9 effect@4.0.4 ``` ```bash npm install --save https://pkg.pr.new/dxos/dxos/@dxos/client@20a14b9 effect@4.0.4 ``` ```bash yarn add https://pkg.pr.new/dxos/dxos/@dxos/client@20a14b9 effect@4.0.4 ``` Install any other `@dxos` package from the same build, with its name in place of `client`. The tutorial app's [package.json](/echo/project/package.json) lists every package it uses at that build. Most of them are for its interface: the `@dxos/react-ui` packages, `@dxos/ui-theme`, `@dxos/vite-plugin-icons` and `tailwindcss`. An app with its own interface needs only `@dxos/client` (or `@dxos/react-client`) and `effect`. In TypeScript, also add `@types/zen-observable` as a dev dependency. The client's observables, such as `client.spaces` and an invitation, take their `subscribe` method from it. In a React app, install `@dxos/react-client` instead and follow the [React guide](/docs/echo/guides/react/). 2. **Configure the bundler.** In the browser, ECHO runs its database in a worker and loads WebAssembly in the page and in that worker. Vite needs four settings for it: - `vite-plugin-wasm` in `plugins` and in `worker.plugins`, so both can import WebAssembly. - `build.target: 'esnext'`, since the WebAssembly modules are loaded with top-level `await`. - `optimizeDeps.exclude` for `@dxos/wa-sqlite`, `@automerge/automerge` and `@automerge/automerge-subduction`. Vite's pre-bundling would move their WebAssembly files away from the code that loads them. - `optimizeDeps.include` for the CommonJS modules that only the database worker imports. Vite's dependency scan starts from the page, so it does not find them, and the worker cannot import a CommonJS module that Vite has not pre-bundled. ```ts import { defineConfig } from 'vite'; import wasm from 'vite-plugin-wasm'; export default defineConfig({ build: { target: 'esnext' }, optimizeDeps: { exclude: [ '@dxos/wa-sqlite', '@automerge/automerge', '@automerge/automerge-subduction', ], include: ['chalk', 'lodash.defaultsdeep', '@dxos/node-std > deep-equal'], }, plugins: [wasm()], worker: { format: 'es', plugins: () => [wasm()], }, }); ``` The tutorial app's [vite.config.ts](/echo/project/vite.config.ts) has these settings, plus React, the theme's `ThemePlugin` and an `IconsPlugin` for the icons of the `@dxos/react-ui` components. Node needs no bundler setup. 3. **Point the client at the network.** A client keeps its data on the device and reaches other devices through DXOS's EDGE network, which relays invitations and replicates spaces. The app's config, at the top of `App.tsx`, says where EDGE is and keeps the data across reloads. The learning path writes its config in code; the [Configuration guide](/docs/echo/guides/configuration/) covers loading it from files instead. ```ts import { Config } from '@dxos/config'; /** * Keeps the app's data on the device and reaches other devices through DXOS's * EDGE network, which relays invitations and replicates spaces. */ const config = new Config({ runtime: { client: { storage: { persistent: true }, edgeFeatures: { signaling: true, subductionReplicator: true, feedReplicator: true, }, }, services: { edge: { url: 'https://dxos.network/' }, iceProviders: [{ urls: 'https://dxos.network/ice' }], }, }, }); ``` EDGE serves only identities linked to a DXOS account, which for now you can create only through [Composer](https://composer.space). Without one, the console shows `403` responses from `dxos.network` for `/db/spaces/…` and `/inbox`. They are expected: peers still find each other through EDGE and replicate directly. 4. **Open the app's space.** The app does this in one function, below the config. The `App` component calls it when it mounts. `new Client` takes the app's types, which the next page declares, and the config. `createOpfsWorker` keeps the database in the browser's private file system. `initialize` opens local storage and connects to the network. The first time, the client needs an identity before it can create a space; [HALO](/docs/halo/overview/) covers identities and devices. After that, the app opens the space it made before. ```ts import { Client, type ClientServicesProvider } from '@dxos/client'; import { types } from '../schema.ts'; /** * Starts a client that knows the app's types, gives it an identity the first * time, and opens the space the app keeps its records in, creating it the * first time. */ export const openSpace = async (services?: ClientServicesProvider) => { const client = new Client( services ? { types, services } : { types, config, // Keeps the database in the browser's private file system. createOpfsWorker: () => new Worker(new URL('@dxos/client/opfs-worker', import.meta.url), { type: 'module', }), }, ); await client.initialize(); if (!client.halo.identity.get()) { await client.halo.createIdentity(); } const space = client.spaces.get()[0] ?? (await client.spaces.create({ name: 'Contacts' })); await space.waitUntilReady(); return { client, space }; }; ``` `space.db` is the database the rest of the learning path reads and writes. `services` replaces the network: the embeds on these pages pass in-memory services, so each runs its devices in the page and keeps nothing. ## What you can do now You can install ECHO, configure a client for the browser or Node, and open the app's space with an identity. --- # Defining types Declare the tutorial app's types, with references between them, and see the form ECHO derives from each. By the end of this page the app will have its three types: organizations, the people who work at them, and the events they attend. ## Background A type is a schema with a name. ECHO uses the schema three ways: - The compiler uses it to check the fields of every object you create. - ECHO uses it to check what it stores, and to send the type to peers that have not seen it. - Code can read it at run time. The app's forms, table and graph are built from the schemas rather than written per type. The schema is an [Effect Schema](https://effect.website/docs/schema/introduction/). The name is a DXN: a reverse-domain name and a version, such as `com.example.type.person` at `0.1.0`. Use a domain you control, since the name is global. A field can hold a reference to another object, with `Ref.Ref(Type)`. A reference stores the target's identity, not a copy, so the graph stays consistent when the target changes. ## Steps 1. **Declare an organization.** `Type.makeObject` takes the name and the schema, and returns a class that is both the schema and the TypeScript type of its instances. ```ts import * as Schema from 'effect/Schema'; import { Annotation, DXN, Format, Type } from '@dxos/echo'; export class Organization extends Type.makeObject( DXN.make('com.example.type.organization', '0.1.0'), )( Schema.Struct({ name: Schema.String, website: Schema.optional(Format.URL), }).pipe( Annotation.IconAnnotation.set({ icon: 'ph--buildings--regular', hue: 'amber', }), ), ) {} ``` `Format.URL` marks the website as a URL, so a form offers a URL field for it. `IconAnnotation` gives the type an icon and a hue, which the app's list and graph use. 2. **Point a person at an organization.** `employer` is a reference to an `Organization`. ```ts import * as Schema from 'effect/Schema'; import { Annotation, DXN, Format, Ref, Type } from '@dxos/echo'; export class Person extends Type.makeObject( DXN.make('com.example.type.person', '0.1.0'), )( Schema.Struct({ name: Schema.String, email: Schema.optional(Format.Email), employer: Schema.optional(Ref.Ref(Organization)), }).pipe( Annotation.IconAnnotation.set({ icon: 'ph--user--regular', hue: 'cyan' }), ), ) {} ``` 3. **Point an event at many people.** A list of references gives an event its attendees. `LabelAnnotation` says which field names an event, since it has a `title` rather than a `name`. ```ts import * as Schema from 'effect/Schema'; import { Annotation, DXN, Ref, Type } from '@dxos/echo'; export class Event extends Type.makeObject( DXN.make('com.example.type.event', '0.1.0'), )( Schema.Struct({ title: Schema.String, description: Schema.optional(Schema.String), attendees: Schema.optional(Schema.Array(Ref.Ref(Person))), }).pipe( Annotation.IconAnnotation.set({ icon: 'ph--calendar-dots--regular', hue: 'green', }), Annotation.LabelAnnotation.set(['title']), ), ) {} ``` 4. **List the types.** The client is given this list when the app starts, in [Installation](/docs/echo/learn/installation/). Steps 1 to 4 all go in `schema.ts`. ```ts export const types = [Organization, Person, Event]; ``` 5. **Create instances.** `Obj.make` takes the props the schema allows, and `Ref.make` makes a reference to an object. The app starts with these records, in `seed.ts`: ```ts import { Obj, Ref } from '@dxos/echo'; import { Event, Organization, Person } from './schema.ts'; /** An organization, a person who works there, and an event she attends. */ export const makeTeam = () => { const northwind = Obj.make(Organization, { name: 'Northwind Labs', website: 'https://northwind.example', }); const ada = Obj.make(Person, { name: 'Ada Lovelace', employer: Ref.make(northwind), }); const review = Obj.make(Event, { title: 'Quarterly review', attendees: [Ref.make(ada)], }); return { northwind, ada, review }; }; ``` Leave out a person's `name` and the compiler rejects the call. 6. **Follow a reference.** `peek` reads the target, if it is loaded, without waiting. The list shows each person's employer beside their name with this, in `Contacts.tsx`: ```ts import { Person } from '../schema.ts'; /** The name of the organization `person` works for, if it is loaded. */ const employerName = (person: Person): string | undefined => person.employer?.peek()?.name; ``` ## Try it The form beside the list is built from the selected type's schema: a URL field for a website, a picker of organizations for an employer, and a list of people for an event's attendees. Choose a type to see its form, or a record to see its values. Editing comes in [Mutating objects](/docs/echo/learn/mutating-objects/). ## What you can do now You can declare object types with references between them, give a type an icon and a label, and create objects that the compiler checks against their schema. --- # Querying Select the tutorial app's records with Filter and Query, by type, sorted, searched, and live. By the end of this page the app will list its records sorted, search them, and find the people who work at an organization. ## Background A query has two parts. A **filter** says which objects match: by type, by field values, by text. A **query** wraps the filter and adds what to do with the matches: their order, a limit, or a step to the objects they reference or that reference them. A query is a value. Building one runs nothing; `db.query(query)` runs it against the database on the device, so a query never waits on the network. `run` returns the matches once. `subscribe` returns them, then runs again whenever an object that could match changes, whichever peer changed it. The client returns typed objects only for the types it knows. The app passes its types to the client when it starts, in [Installation](/docs/echo/learn/installation/). [The Filter guide](/docs/echo/api/filter/) lists every comparison and combinator. Each query goes in the component that runs it: the lists, search and count in `Contacts.tsx`, and an organization's people in `RecordForm.tsx`. ## Steps 1. **List each type, sorted.** `Filter.type` selects the objects of a type, and `orderBy` sorts them by a field. The app's lists use these. ```ts import { Filter, Order, Query } from '@dxos/echo'; import { Event, Organization, Person } from '../schema.ts'; const organizations = Query.select(Filter.type(Organization)).orderBy( Order.property('name', 'asc'), ); const people = Query.select(Filter.type(Person)).orderBy( Order.property('name', 'asc'), ); const events = Query.select(Filter.type(Event)).orderBy( Order.property('title', 'asc'), ); ``` 2. **Search text.** `Filter.text` matches objects whose text contains the words, whatever their type. The space indexes text as objects are written. The search box uses this. ```ts // Searches only while there is text: an empty search has nothing to match. const found = useQuery( searching ? space.db : undefined, Query.select( searching ? Filter.text(text.trim(), { type: 'full-text' }) : Filter.nothing(), ), ); ``` 3. **Follow references backwards.** `referencedBy` steps from an organization to the people whose `employer` points at it. ```ts const employees = Query.select(Filter.id(organization.id)).referencedBy( Person, 'employer', ); ``` 4. **Run a query.** `db.query(query).run()` returns the matches once, typed, so each result is a `Person`. An organization's form lists its people with this. ```ts void db.query(employees).run().then(setStaff); ``` 5. **Keep a query live.** `subscribe` calls back with the current results each time they change, and returns a function that stops it. `fire: true` calls back once at the start too. The toolbar's count of people uses this. ```ts const [count, setCount] = useState(); useEffect( () => space.db .query(people) .subscribe(({ results }) => setCount(results.length), { fire: true }), [space], ); ``` The callback runs again whenever a person is added, changed or removed, on this peer or another. [Mutating objects](/docs/echo/learn/mutating-objects/) makes those changes. ## Try it Type in the search box to search every record. Choose Organizations and open one to see its people. The table button shows the records of the chosen type with one column per field. Each list is a live query, which the next page puts to work. ## What you can do now You can select records by type, sort them, search their text, follow references backwards, and keep a query running as the results change. --- # Mutating objects Create, change and remove the tutorial app's records, and observe each change. By the end of this page the app will add, edit and remove records, and you will watch a record change. ## Background An object you make with `Obj.make` exists only in memory until you add it to a database. `db.add` starts tracking it: from then on the database stores every change and sends it to the space's other peers. A tracked object is read like any other, but it is changed only inside `Obj.update`. The callback gets a mutable view of the object. Each update is one change, which peers apply whole, and which notifies subscribers once. Writes are local. `db.add` and `Obj.update` return once the change is in the local database, and replication runs after. Two peers that change the same object concurrently both keep their edits: ECHO merges them field by field. Each change goes in the component that makes it: adding and editing in `RecordForm.tsx`, removing in `Contacts.tsx`, and watching in `RecordGraph.tsx`. ## Steps 1. **Add records.** `db.add` takes an object made with `Obj.make` and returns it, now tracked. The **New** form saves with this. ```ts const handleSave = useCallback( (values: Record) => onCreate(db.add(Obj.make(type, values))), [db, type, onCreate], ); ``` 2. **Watch a record.** `Obj.subscribe` calls back after each change to the object, and returns a function that stops the subscription. The graph redraws an edge with this when a reference moves. ```ts const [revision, setRevision] = useState(0); useEffect( () => combine( ...records.map((record) => Obj.subscribe(record, () => setRevision((revision) => revision + 1)), ), ), [records], ); ``` 3. **Change a record.** Every change goes inside `Obj.update`, which hands the callback a mutable view. The form calls this as you type. ```ts const handleChange = useCallback( ( values: Record, { isValid, changed, }: { isValid: boolean; changed: Record }, ) => { // A changed path such as `attendees[0]` rewrites the whole field. const fields = new Set( Object.keys(changed) .filter((path) => changed[path]) .map((path) => path.split(/[.[]/)[0]), ); if (isValid && fields.size > 0) { const changes = Object.fromEntries( [...fields].map((field) => [field, values[field]]), ); Obj.update(record, (record) => { Object.assign(record, changes); }); } }, [record], ); ``` 4. **Remove a record.** The **×** on a row calls this. ```ts const handleRemove = useCallback( (id: string) => { const record = records.find((record) => record.id === id); if (record) { space.db.remove(record); setSelected((current) => (current === id ? undefined : current)); } }, [space, records], ); ``` ECHO does not delete a removed object at once. It marks the object as removed and keeps it in the change history until the next [epoch](/docs/additional-resources/glossary#epoch), so a peer that wrote while offline can still reconcile its changes when it reconnects. ## Try it Select a record and change a field: the form writes each change inside `Obj.update` as you type, and the list follows. Press **New** to add a record of the chosen type, and the **×** on a row removes it. The [React guide](/docs/echo/guides/react/) shows the form. ## What you can do now You can add records to a database, change them inside `Obj.update`, watch them change, and remove them. --- # Sharing a space Invite a second peer to the tutorial app's space and watch both replicas follow each other's edits. By the end of this page a second peer will have joined the app's space, and each peer will see the other's edits. ## Background A space is shared by invitation. The host creates an invitation, and the guest uses its code to find the host and open a secure connection. An invitation can also ask the guest for an authentication code, which the host passes on by another channel, to prove the guest received the invitation from the host. The app leaves that out, so pasting the code is enough. Once the guest is in, both peers hold a full replica of the space's database. Each peer reads and writes its own replica, and the replicas exchange changes whenever the peers can reach each other. A peer that goes offline keeps working, and catches up when it reconnects. [Invitations](/docs/halo/concepts/invitations/) explains the handshake in detail. Both go in `Share.tsx`, which draws the toolbar's Invite and Join buttons. ## Steps 1. **Invite a peer.** `space.share` starts an invitation and reports each change to its state. Once it is connecting to the network, its encoded form is the code to send. Opening the **Invite** popover runs this, and the app copies the code. ```ts const handleOpen = (open: boolean) => { if (!open) { return; } setCode(undefined); const invitation = space.share({ authMethod: Invitation_AuthMethod.NONE }); invitation.subscribe((invitation) => { if (invitation.state === Invitation_State.CONNECTING) { setCode(InvitationEncoder.encode(invitation)); } }); }; ``` Anyone with the code can join while the invitation is open. For a code that is not enough on its own, leave out `authMethod` and send the authentication code the invitation then asks for over a different channel. 2. **Join from the other peer.** The guest needs a client that knows the app's types and an identity of its own. It decodes the code and joins; once the invitation succeeds, the space is among the guest's spaces, and both peers replicate it. The **Join** button calls this. ```ts import { type Client } from '@dxos/client'; import { type Space } from '@dxos/client/echo'; import { InvitationEncoder } from '@dxos/client/invitations'; import { SpaceId } from '@dxos/keys'; /** Joins the space `code` invites to, and resolves with it once it is open. */ const joinSpace = async (client: Client, code: string) => { const invitation = client.spaces.join(InvitationEncoder.decode(code)); const { spaceId } = await invitation.wait(); if (!SpaceId.isValid(spaceId)) { throw new Error('The invitation names no space.'); } // The space joins the client's list a moment after the invitation succeeds. const space = client.spaces.get(spaceId) ?? (await new Promise((resolve) => { const subscription = client.spaces.subscribe(() => { const space = client.spaces.get(spaceId); if (space) { subscription.unsubscribe(); resolve(space); } }); })); await space.waitUntilReady(); return space; }; ``` `openSpace` from [Installation](/docs/echo/learn/installation/) reopens the first space in `client.spaces.get()`, which after a join may be the guest's own empty space. To reopen the space a peer joined, remember its id, as the app does in `localStorage`, and on load open `client.spaces.get(id)` when it is there. ## Try it Two devices run the app side by side, each with a space of its own; Bob's is empty. On Alice's device, press **Invite** in the toolbar, which copies a code. On Bob's, press **Join**, paste the code and press **Join** again: Bob's app switches to Alice's space. Then edit a record on one device and watch the other follow. Switch a device offline, edit on both, then switch it back on: the replicas exchange what they missed. ## What you can do now You can invite a peer to a space, accept the invitation from a second client, and edit one database from both. --- # Types and schema What a type is, how ECHO names it, and how it changes over time. A type describes the fields an object or relation has, and ECHO uses it to check every write and to tell other peers what the data means. ## Model A type is an [Effect Schema](https://effect.website/docs/schema/introduction/) with a **typename** and a **version**. Together they make the type's DXN, `dxn::`, and every object stores the DXN of its type. [Defining types](/docs/echo/learn/defining-types/) builds the Person, Organization and Event types that the snippets on this page use. ```ts import { Obj, Type } from '@dxos/echo'; import { Person } from './schema.ts'; Type.getTypename(Person); // 'com.example.type.person' Type.getVersion(Person); // '0.1.0' Type.getURI(Person); // 'dxn:com.example.type.person:0.1.0' const ada = Obj.make(Person, { name: 'Ada' }); Obj.getTypename(ada); // 'com.example.type.person' ``` There are two kinds of type: - An **object type**, made with `Type.makeObject`, describes a record such as a person. - A **relation type**, made with `Type.makeRelation`, describes an entity that joins a source and a target, and can carry fields of its own. [Objects and relations](/docs/echo/concepts/objects-relations/) shows when to use one. ```ts import * as Schema from 'effect/Schema'; import { DXN, Type } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; export class WorksAt extends Type.makeRelation( DXN.make('com.example.type.worksAt', '0.1.0'), )({ source: Person, target: Organization, })({ role: Schema.String }) {} ``` **Annotations** add what the schema alone does not say: which field labels an object, which icon it has, how a form orders its fields. ECHO reads some of them itself; `Obj.getLabel` returns the field the label annotation names. Tools such as Composer read the rest. [Annotation](/docs/echo/api/annotation/) lists them. ```ts import { Annotation, Obj, Type } from '@dxos/echo'; import { Event } from './schema.ts'; // The tutorial's Event names `title` as its label. const labelFields = Annotation.LabelAnnotation.get(Type.getSchema(Event)); // Some(['title']) const launch = Obj.make(Event, { title: 'Launch' }); Obj.getLabel(launch); // 'Launch' ``` ## Behavior - A database accepts only objects of types it knows. Register types with the client, when you make it or later; types registered there apply to every space. ```ts import { Client } from '@dxos/client'; import { types } from './schema.ts'; import { WorksAt } from './works-at.ts'; const client = new Client({ types }); await client.initialize(); await client.addTypes([WorksAt]); ``` - A type converts to and from [JSON Schema](https://json-schema.org/), so a type can come from a form, an agent or another system as well as from code. ```ts import { JsonSchema, Type } from '@dxos/echo'; import { Organization } from './schema.ts'; const jsonSchema = JsonSchema.toJsonSchema(Type.getSchema(Organization)); const Note = Type.makeObjectFromJsonSchema({ typename: 'com.example.type.note', version: '0.1.0', jsonSchema: { type: 'object', properties: { title: { type: 'string' } } }, }); ``` - `db.addType` stores a type in the space, as an object of its own, so it replicates to the other members like data. Fields of a stored type can be added, changed and removed at runtime. ```ts import * as Schema from 'effect/Schema'; import { Type } from '@dxos/echo'; // Stored in the space, so it replicates to the other members. const note = await db.addType(Note); Type.addFields(note, { pinned: Schema.optional(Schema.Boolean) }); ``` ### Versions and migrations A change to a type that old data does not fit, such as a field that becomes an array, is a new version of the type. A **migration** moves objects from one version to the next: - `Migration.define` reads an object of the old version and returns the fields of the new one. - `Migration.defineRename` gives a type a new typename and points every reference at it. - `Migration.defineContent` upgrades a content format that an object carries in a field, such as a drawing's scene, without changing the type's version. ```ts import * as Schema from 'effect/Schema'; import { DXN, Format, Migration, Ref, Type } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; // Version 0.2.0 of Person holds any number of email addresses. class PersonV2 extends Type.makeObject( DXN.make('com.example.type.person', '0.2.0'), )( Schema.Struct({ name: Schema.String, emails: Schema.Array(Format.Email), employer: Schema.optional(Ref.Ref(Organization)), }), ) {} const personEmails = Migration.define({ from: Person, to: PersonV2, transform: async ({ name, email, employer }) => ({ name, emails: email ? [email] : [], employer, }), onMigration: async () => {}, }); // Points every reference to the old typename at the new one. const renameEvent = Migration.defineRename({ from: 'com.example.type.event', to: 'com.example.type.meeting', }); ``` A plugin contributes its migrations to Composer, which runs them on every space once the plugin is active. The [API reference](/docs/additional-resources/api-reference/) covers each kind. ## Limits - Objects of two versions of one type do not merge. Peers must run the same migration before they edit the same objects. - The docs do not yet say how to run migrations outside a Composer plugin. - Adding a field to a stored type is safe; changing or removing one leaves the old values in objects already written. --- # Objects and relations Typed records in one space, the entities that join them, and the fields ECHO manages. An object is a typed record, like a document or a row in other databases, and it always belongs to one space. A relation is an entity that joins two others. ## Model An object has the fields its [type](/docs/echo/concepts/types/) declares. Field values are strings, numbers, booleans and `null`, records and arrays of those, and [references](/docs/echo/concepts/references/) to other entities. ```ts import { Obj, Ref } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; const acme = db.add(Obj.make(Organization, { name: 'Acme' })); const ada = db.add( Obj.make(Person, { [Obj.Meta]: { keys: [{ source: 'crm.example.com', id: 'contact-42' }] }, name: 'Ada', employer: Ref.make(acme), }), ); ``` Every object also carries fields that ECHO manages: | Field | How to read it | | --- | --- | | Id, unique and stable | `obj.id` | | Type | `Obj.getTypename(obj)` | | Keys from other systems, and tags | `Obj.getKeys(obj, source)`, `Obj.getMeta(obj)` | | Label | `Obj.getLabel(obj)` | | Whether it was removed | `Obj.isDeleted(obj)` | ```ts import { Obj } from '@dxos/echo'; ada.id; // '01J…', unique and stable Obj.getTypename(ada); // 'com.example.type.person' Obj.getKeys(ada, 'crm.example.com'); // [{ source: 'crm.example.com', id: 'contact-42' }] Obj.getLabel(ada); // 'Ada' Obj.isDeleted(ada); // false ``` A **relation** joins a source and a target, and can have fields of its own. Person's `employer` field and a WorksAt relation record the same fact in two ways: - A reference field belongs to its object. It is the simplest choice when the link is part of the record, one value or a list. - A relation is an entity of its own. It can carry data about the link, such as a role, and it can be added or removed without writing to either end. ```ts import { Relation } from '@dxos/echo'; import { WorksAt } from './works-at.ts'; // The same fact as `employer`, as an entity of its own with a field of its own. const job = db.add( Relation.make(WorksAt, { [Relation.Source]: ada, [Relation.Target]: acme, role: 'Engineer', }), ); Relation.getSource(job); // ada Relation.getTarget(job); // acme ``` An object can have a **parent**. A child belongs to its parent: removing the parent removes the child. The parent holds a reference to each child in one of its fields, and `Obj.setParent` and `Obj.getParent` set and read the link. ## Behavior - `Obj.make` creates an object with an id and the DXN of its type. [org.dxos.echo#F-3.1] - A change to an object's field notifies `Obj.subscribe` listeners and re-runs live queries. [org.dxos.echo#F-3.2] - An object read from a database is live: its fields show the latest value. `Obj.getSnapshot` returns an immutable copy that does not change. ```ts import { Obj } from '@dxos/echo'; const before = Obj.getSnapshot(ada); // an immutable copy Obj.update(ada, (ada) => { ada.name = 'Ada Lovelace'; }); before.name; // still 'Ada' ada.name; // 'Ada Lovelace' ``` - Serializing an object and reading it back keeps its id, type, metadata and references. [org.dxos.echo#F-3.4] - Removing an object marks it as deleted. It stays in the change history until the next [epoch](/docs/additional-resources/glossary#epoch), so a peer that wrote while offline can still merge its changes. ```ts import { Obj } from '@dxos/echo'; db.remove(ada); Obj.isDeleted(ada); // true ``` ## Limits - The top level of an object is always a record, never a single number or string. - A relation needs both ends. Its source and target are set when it is made and do not change. - Only an object can be a child. A relation can be a parent, with `Relation.setParent`, so that an object is removed along with the relation it belongs to. --- # References and DXNs How objects point at each other, and how a reference resolves. A reference is a field whose value names another entity, so that objects can form trees and graphs without copying each other. ## Model A field declared with `Ref.Ref(Type)` holds a reference to an entity of that type. The stored value is the target's URI, not the target's data. Two objects that reference the same person both see the person's latest name, because neither holds a copy. [Ref](/docs/echo/api/ref/) shows how to declare and make one. ECHO uses two kinds of name: - An **EID** addresses an object or a space, with the `echo:` scheme. The local form `echo:///` means "in this space". The qualified form `echo:///` names the space too, and is what a reference into another space stores. - A **DXN** names a resource that is not an object, most often a type: `dxn:com.example.type.person:0.1.0`. A DXN can also carry an object mnemonic after `#` to tell apart objects that share a key. ```ts import { EID, Obj, Type } from '@dxos/echo'; import { Person } from './schema.ts'; const typeName = Type.getURI(Person); // dxn:com.example.type.person:0.1.0 const ada = db.add(Obj.make(Person, { name: 'Ada' })); const local = EID.make({ entityId: ada.id }); // echo:/// // echo:/// const qualified = EID.make({ spaceId: db.spaceId, entityId: ada.id }); ``` ## Behavior - A reference stores the target's URI and resolves to a target of the declared type only when read. [org.dxos.echo#F-4.1] - `ref.peek()` returns the target if it is in memory and never starts a load. `ref.load()` reads it from disk or the network. - Serializing an object keeps its references as URIs, and reading it back restores them. [org.dxos.echo#F-3.4] - A query can follow references forwards with `reference` and backwards with `referencedBy`, so you do not need to load each target yourself. [Query](/docs/echo/api/query/) covers traversal. A reference can point into another space, which [Spaces and databases](/docs/echo/concepts/spaces/#the-hypergraph) covers. ## Limits - A reference can dangle. Nothing stops a target from being removed while references to it remain, and the field keeps the URI. `load()` throws for a missing target; `tryLoad()` returns `undefined`. - By default a removed target counts as missing. `Ref.loadAll` leaves out missing and removed targets unless you pass `{ deleted: 'include' }`. - An array of references keeps every entry, dangling ones included. To list only the targets that exist, query them with `reference` instead of filtering the array. - Each read of a reference field returns a new wrapper, so compare references with `Ref.equals`, not `===`. --- # Feeds An append log of ECHO objects, as a tree of branches. A feed is a log that objects are appended to, such as the messages of a chat, and it can branch, so that a reader sees one line of the conversation at a time. ## Model A feed is one append log of ECHO objects, usually messages. [org.dxos.echo.feeds#F-1.1] The feed itself is an object in a space; the items appended to it are objects too, with types, ids and references, but they are not stored as objects of the space. ```ts import { Feed, Obj } from '@dxos/echo'; const feed = db.add(Feed.make({ name: 'Chat' })); await db.appendToFeed(feed, [ Obj.make(Message, { text: 'Hello' }), Obj.make(Message, { text: 'Hi back' }), ]); ``` A feed is a **tree of branches**. Each branch has a parent and a fixed fork point, and a read of a branch gives the items of its ancestors up to the fork point, then the items of the branch. A branch costs about one record and needs no other object in the space. [org.dxos.echo.feeds#F-1.2] AI chat, comments and messaging all use this model. [org.dxos.echo.feeds#F-3.1] These are uses of one model, not different kinds of feed: an AI chat where a person retries a reply forks a branch at the message before it, and the reader follows the newest branch. ## Behavior - A **walk** reads a feed along one path of the tree: forward from the root to a branch, or backward from an item to the root. A walk is a query with a feed scope, so any filter works with it. [org.dxos.echo.feeds#F-5.1] [org.dxos.echo.feeds#F-5.2] - A **cursor** marks where a reader stopped, and the branch it was on. A reader resumes after the cursor and reads only what is new, without the items of other branches. [org.dxos.echo.feeds#F-5.3] ```ts import { Feed, Filter, Order, Query, Scope } from '@dxos/echo'; const uri = Feed.getFeedUri(feed)!.toString(); // The first page, oldest first. const page = await db .query( Query.select(Filter.type(Message)) .from(Scope.feed(uri, { walk: {} })) .orderBy(Order.natural('asc')) .limit(50), ) .run(); // The next page resumes after the last item read. const cursor = Feed.getLocalCursor(page[page.length - 1]); const next = await db .query( Query.select( Filter.and( Filter.type(Message), Filter.feedCursor({ begin: cursor, positions: 'local' }), ), ) .from(Scope.feed(uri, { walk: {} })) .orderBy(Order.natural('asc')) .limit(50), ) .run(); ``` - `db.forkFeed` starts a branch at any item. A walk can also return the fork points it passes, so a reader can show that other branches start there. [org.dxos.echo.feeds#F-5.4] ```ts import { Filter, Obj, Order, Query, Scope } from '@dxos/echo'; // A new branch that replies to the first message instead of the second. const branch = await db.forkFeed( feed, Obj.make(Message, { text: 'An alternate reply' }), { at: page[0] }, ); // The root branch up to the fork point, then the branch. const alternate = await db .query( Query.select(Filter.type(Message)) .from(Scope.feed(uri, { walk: { branch } })) .orderBy(Order.natural('asc')), ) .run(); ``` - A feed can have no index, which saves the cost of indexing a log that is only walked. Retention trims old items by count or age. [org.dxos.echo.feeds#F-2.1] ```ts import { Obj } from '@dxos/echo'; Obj.update(feed, (feed) => { feed.indexing = { enabled: false }; feed.retention = { maxLength: 1_000 }; }); ``` ### When to use a feed Use a feed for data that grows by appending and is read in order: messages, comments, events, an agent's steps. Use objects for data that people edit in place and query by field. To try out a change to objects, such as a draft of a document, use a [branch of objects](/docs/echo/concepts/changes/#branches), not a feed. ## Limits The design of feeds goes further than the code does today. These parts of [the feeds spec](https://github.com/dxos/dxos/blob/main/packages/core/echo/echo/docs/FEEDS.mdl) are not built yet: - A list of a feed's branches and their heads, and a cheap call for the heads that changed. [org.dxos.echo.feeds#F-2.2] - Sync of some branches only. A client syncs the whole feed. [org.dxos.echo.feeds#F-2.3] - Merging a branch back, and removing a branch from reads. [org.dxos.echo.feeds#F-1.4] - Retention per branch. Retention applies to the whole feed. [org.dxos.echo.feeds#F-4.4] --- # Queries Filters, the shape of a query, traversal, and results that stay live. A query selects entities from one or more sources and keeps its results current as the data changes, so an app reads data the same way whether it was written here or by another peer. ## Model A **filter** is a predicate: it says which entities match, by type, field values, text or time. A **query** is the shape around filters: which entities to start from, which references or relations to follow, and how to order, limit and group the results. [Learn to query](/docs/echo/learn/querying/) builds queries for the contacts app. ```ts import { Filter, Order, Query } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; // A filter matches entities. const adas = Filter.type(Person, { name: 'Ada' }); // A query is the shape around filters: what to select, follow and order. const employers = Query.select(adas).reference('employer'); const staff = Query.select(Filter.type(Organization, { name: 'Acme' })) .referencedBy(Person, 'employer') .orderBy(Order.property('name', 'asc')) .limit(20); ``` A query can follow references forwards with `reference` and backwards with `referencedBy`, and relations with `sourceOf` and `targetOf`. One query answers "the people who work at Acme" without loading each person to check. A query can also **aggregate**: count, sum, find the minimum or maximum, or group the results by a field. ```ts import { Aggregate, Query } from '@dxos/echo'; import { Person } from './schema.ts'; const [{ people }] = await db .query(Query.type(Person).aggregate({ people: Aggregate.count() })) .run(); ``` A query reads from **sources**, chosen with `Scope`: this space, the code-shipped registry of types, or a [feed](/docs/echo/concepts/feeds/). Without `from`, a query reads the database it runs on. ```ts import { Filter, Query, Scope, Type } from '@dxos/echo'; // Every type this space stores, and every type registered in code. const allTypes = await db .query( Query.select(Filter.type(Type.Type)).from(Scope.space(), Scope.registry()), ) .run(); ``` ## Behavior - A query is live. Its results change when matching data changes, here or on another peer, and subscribers are notified only when the results change. [org.dxos.echo.query#F-2.3] ```ts import { Query } from '@dxos/echo'; import { Event } from './schema.ts'; const result = db.query(Query.type(Event)); const unsubscribe = result.subscribe( (result) => console.log(result.results.length), { fire: true }, ); // Each source of the answer, with its state: pending, answered or failed. const sources = result.sources; unsubscribe(); ``` - A local write shows in the results, and reaches subscribers, before the writing call returns. [org.dxos.echo.query#F-2.1] - Results combine what this tab holds with answers from the index and from EDGE. Each source has its own state, pending until it first answers, and the results grow as sources answer. A live query never ends: there is no point after which its results stop changing. [org.dxos.echo.query#F-3.3] - A query without an order returns entities by id. [org.dxos.echo.query#F-2.4] - A traversal checks each step against the objects this tab holds, so a hit whose reference has since changed here is dropped. [org.dxos.echo.query#F-7.2] ## Limits - An entry from the index or EDGE is checked against the object only if this tab holds the object. Otherwise it is shown on the source's word until the object loads. [org.dxos.echo.query#F-5.2] - A page read with `skip` is cut by the source that answers it. Local writes can drop entries from the page, but they do not re-page it. A window with a property order and only a `limit` also admits local matches that sort inside it. [org.dxos.echo.query#F-9.2] [org.dxos.echo.query#F-9.3] - An aggregate from a remote source is adjusted for local changes, but a minimum or maximum that a local removal would change is marked unverified until the source answers again. [org.dxos.echo.query#F-8.3] --- # Spaces and databases The unit of sharing and replication, how a space is stored, and the Hypergraph over every space. A space is a database that a set of peers share and replicate, and it is the unit of sharing: a person shares a whole space, not single objects. ## Model Every object belongs to exactly one space, and `space.db` is the database that holds them. A device usually holds many spaces at once, and each space has its own members. ```ts import { Obj } from '@dxos/echo'; import { Person } from './schema.ts'; const space = await client.spaces.create(); const db = space.db; db.add(Obj.make(Person, { name: 'Ada' })); ``` ### Storage A device keeps each space in local storage: the browser's storage in a web app, files on disk in Node. In a browser the client runs the database in a worker, apart from the page. It keeps an index of every space, and queries read from the index as well as from the objects the page holds. A write is finished, and visible to queries in the same tab, when the call returns. `db.flush` waits until pending writes are on disk, and optionally until the index has them too. ### Replication There is no server that owns a space. Each member holds a full copy of the space's data and exchanges changes with the other members directly, over [WebRTC](https://en.wikipedia.org/wiki/WebRTC), or through EDGE, the DXOS sync service. Reads and writes work offline, and the changes merge when the device reconnects, by the rules in [Changes and branches](/docs/echo/concepts/changes/). ### The Hypergraph The **Hypergraph** is every space this device holds, as one graph. `client.graph` queries across spaces and finds a space's database by id. It also holds the **registry**: the types and objects registered in code, which a query without `from` searches along with the database. ```ts import { Filter, Query } from '@dxos/echo'; import { Person } from './schema.ts'; // Every space this device holds, in one query. const everyone = await client.graph .query(Query.select(Filter.type(Person)).from('all-accessible-spaces')) .run(); // One space's database, by id. const same = client.graph.getDatabase(space.id); ``` A reference can point into another space. It stores the qualified EID, `echo:///`, and resolves if this device holds or can reach that space. ```ts import { EID, Obj } from '@dxos/echo'; import { Person } from './schema.ts'; // A reference into another space carries that space's id. const other = await client.spaces.create(); const grace = other.db.add(Obj.make(Person, { name: 'Grace' })); const toGrace = db.makeRef( EID.make({ spaceId: other.id, entityId: grace.id }), ); // undefined if this device cannot reach the space const loaded = await toGrace.tryLoad(); ``` Use `space.db` for work inside one space, which is most work. Use the Hypergraph when the code cannot know the space in advance, such as a search across everything a person has. ### Access HALO decides who is a member of a space and what each member can do, with access control built on [Keyhive](https://www.inkandswitch.com/keyhive/notebook/). [Spaces and membership](/docs/halo/concepts/spaces-membership/) covers members and roles, and [Invitations](/docs/halo/concepts/invitations/) covers how a peer joins. ## Behavior - Each member holds a full replica, so reads and writes work offline and merge when the peer reconnects. - Several members can write to the same space at the same time. Their changes merge without a central authority. - A reference into a space this device does not hold resolves to nothing: `tryLoad` returns `undefined`. ## Limits - An object cannot move to another space. Copy it into the other space and remove the original. - Sharing is per space. To share some objects and not others, put them in different spaces and link them with references. --- # Changes and branches Every write as a change, how changes merge, the history they leave, and branches of objects. Every write to an object is a change. Changes from different peers merge without asking either of them, each object keeps the history of its changes, and a branch lets a set of changes wait apart from the main line until they are merged. ## Model Each object lives in an [Automerge](https://automerge.org/) document, a CRDT. A peer writes to its own copy and the write is finished when the call returns. [org.dxos.echo.query#F-2.1] Peers then exchange changes, and every peer that has seen the same changes ends up with the same state, whatever order the changes arrived in. What "merged" means depends on the kind of value: | Concurrent change | Result | | ------------------------------------------------ | ------------------------------------------------------------ | | Different fields of one object | Both writes kept | | The same scalar field, such as a status | One value wins, the same on every peer | | A whole string assigned twice | One string wins, as with any scalar | | Characters inserted or deleted with `Text` | Both edits kept, interleaved | | Items inserted into one array | Both kept; the order of the two new items is decided by ECHO | | One peer edits an object another removes | The removal stands | The table explains the choice between assigning a string and editing it with `Text`. Assignment replaces the value; `Text.splice` and `Text.update` change characters, so two people typing in the same field both keep their work. ```ts import { Obj, Text } from '@dxos/echo'; Obj.update(launch, (launch) => { // replaces the whole string: a concurrent edit to the title loses launch.title = 'Product launch'; // inserts characters: concurrent edits interleave Text.splice(launch, 'description', 8, 0, 'demo, questions. '); }); ``` ## Behavior - A local write shows in query results and reaches subscribers before the writing call returns. [org.dxos.echo.query#F-2.1] - A subscriber that writes from inside a notification makes a separate commit, with its own notification after the first. [org.dxos.echo.query#F-2.2] - A peer that was offline keeps writing to its copy. When it reconnects, peers exchange the changes each is missing and merge them by the rules above. Nothing is rejected for being late. - Each change records the device that made it, so an app can see that two devices wrote the same field and ask a person to confirm the result. ```ts const writers = new Set(changes.map((change) => change.actor)); if (writers.size > 1) { // Several devices set the title; show the history so a person can confirm // the result. } ``` ### History `Obj.getChanges` lists an object's changes, each with the device that made it, the time, and the value before and after. `Obj.getVersion` reads the object as it was after any change, without changing the live object. `Filter.changes` queries changes across a space, to count or list them. ```ts import { Aggregate, Filter, Obj, Order, Query } from '@dxos/echo'; const changes = Obj.getChanges(launch, { property: 'title' }); for (const { actor, time, before, after } of changes) { console.log(actor, time, before, '→', after); } // The event as it was after the first change. const first = Obj.getVersion(launch, changes[0].heads); // Changes per day across the space, newest first. const perDay = await db .query( Query.select(Filter.changes()) .aggregate({ day: Aggregate.time('time', 'day'), changes: Aggregate.count(), }) .orderBy(Order.property('day', 'desc')), ) .run(); ``` ### Branches A **branch** is a separate line of changes to an object and the objects it references, such as a draft of a document. `createBranch` starts one from the latest state or from a past version. Changes on a branch do not show on main until `mergeBranch` merges them, as one CRDT merge, so main's own changes since the branch started are kept. - `switchBranch` shows a branch on this device, for the object and its children. Other devices keep the branch they are on. - `syncBranch` brings main's newer changes into a branch, and `deleteBranch` drops a branch. - `db.branch` gives a second live copy of an object, bound to a branch, without switching. Use it to show a branch next to main; call `dispose` when the view closes. ```ts import { Obj } from '@dxos/echo'; // A draft of the event and everything it references. await db.createBranch(launch.id, 'draft'); await db.switchBranch(launch.id, 'draft'); Obj.update(launch, (launch) => { launch.title = 'Launch, moved to Friday'; }); // Back to main, then merge the draft into it. await db.switchBranch(launch.id, 'main'); await db.mergeBranch(launch.id, 'draft', { deleteAfter: true }); ``` ```ts import { Obj } from '@dxos/echo'; // A second live copy bound to the branch, without switching this device. await db.createBranch(launch.id, 'proposal'); const proposal = await db.branch(launch, 'proposal'); Obj.update(proposal.object, (event) => { event.title = 'Launch, online only'; }); // launch.title is unchanged; show the two side by side. proposal.dispose(); ``` A [feed](/docs/echo/concepts/feeds/) has branches of its own, for items appended to a log. Branches of objects are for objects that people edit in place. ## Limits - ECHO does not know what your fields mean. When one peer sets `status: 'done'` and another sets `status: 'doing'`, one value wins and the other is not flagged. If the loser matters, model the intent instead: record each person's choice in a field of its own, or append events to a [feed](/docs/echo/api/feed/) and derive the state from them. - Invariants across fields or objects are not kept. Two peers can each make a valid change whose merge breaks a rule such as "at most one task is in progress". Check such rules when you read, not only when you write. - A schema change is not merged with data written under the old version. Use a new version and a [migration](/docs/echo/concepts/types/#versions-and-migrations). - A switch to a branch is local to the device. Another device of the same person still shows main until it switches too. --- # Effect Use ECHO from Effect code, with Database.Service, layers and typed errors. ```ts import { Database, Hypergraph } from '@dxos/echo'; ``` The rest of the ECHO docs use the promise API. This page is for code written with [Effect](https://effect.website/): there, the database is a service that a program asks for, and failures are typed errors. Composer's operations and plugins are written this way. ## Ask for the database `Database.Service` is the database of one space. The functions of the `Database` module, such as `add`, `query`, `flush` and `load`, are Effects that need it, so a program that uses them says in its type that it acts on a space. ```ts import * as Effect from 'effect/Effect'; import { Database, Filter, Obj } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; // Asks for Database.Service; the caller decides which database. const hire = (name: string) => Effect.gen(function* () { const acme = yield* Database.add(Obj.make(Organization, { name: 'Acme' })); yield* Database.add(Obj.make(Person, { name })); const people = yield* Database.query(Filter.type(Person)).run; yield* Database.flush(); return { acme, people }; }); ``` ## Provide it `Database.layer` provides the service from a database you hold. Where a program has no database, `Database.notAvailable` stands in and fails on use, so a missing database shows up at the call rather than as a silent no-op. ```ts import * as Effect from 'effect/Effect'; import { Database } from '@dxos/echo'; import * as EffectEx from '@dxos/effect/EffectEx'; const result = await EffectEx.runPromise( hire('Ada').pipe(Effect.provide(Database.layer(db))), ); ``` ## Handle errors Failures carry a tag, so a program handles each by name. `Database.load` fails with `EntityNotFoundError` when a reference's target is missing; `Blob.read` fails with `BlobNotAvailableError` when the bytes cannot be reached. ```ts import * as Effect from 'effect/Effect'; import { Database } from '@dxos/echo'; const employerName = Effect.gen(function* () { const organization = yield* Database.load(employer); return organization.name; }).pipe( // A missing target fails with a typed error, caught by its tag. Effect.catchTag('EntityNotFoundError', () => Effect.succeed('unknown')), ); ``` ## Find a space Some work cannot be told its space in advance, such as a trigger that fires with a fixed payload. It asks for `Hypergraph.Service`, every space this device holds, and `Hypergraph.withDatabase` narrows that to one space's `Database.Service`. A space this device does not hold fails with `SpaceNotFoundError` when the layer is built. ```ts import * as Effect from 'effect/Effect'; import { Hypergraph } from '@dxos/echo'; // Work that finds its own space asks for the Hypergraph, then narrows to one database. const inSpace = hire('Grace').pipe( Effect.provide(Hypergraph.withDatabase(spaceId)), Effect.provide(Hypergraph.layer(client.graph)), Effect.catchTag('SpaceNotFoundError', () => Effect.succeed(undefined)), ); ``` ## Effect versions of other modules `Feed` has Effect versions of the database's feed methods: `append`, `walk`, `query`, `fork`, `setIndexing` and `setRetention`. `Blob` has `fromBytes`, `read`, `exists` and `url`. Each needs `Database.Service`. ## Related - [Database](/docs/echo/api/database/) for the promise API these mirror. - [Effect in plugins](/docs/plex/guides/effect/) for Effect in a Composer plugin. --- # Testing Test code that uses ECHO, with in-memory databases and peers that replicate. ```ts import { EchoTestBuilder } from '@dxos/echo-client/testing'; ``` A test of code that uses ECHO needs a database, and a test of sync needs more than one peer. `EchoTestBuilder` makes both in memory, without an identity or a server, so tests run fast in Node. ## Make a database `createDatabase` makes a peer with its own storage and opens a database on it. Pass the types the test uses. `await using` closes the builder, and every peer it made, when the test ends. ```ts import { Filter, Obj } from '@dxos/echo'; import { EchoTestBuilder } from '@dxos/echo-client/testing'; import { Person, types } from './schema.ts'; // In memory, with nothing to clean up on disk. await using builder = await new EchoTestBuilder().open(); const { db } = await builder.createDatabase({ types }); db.add(Obj.make(Person, { name: 'Ada' })); await db.flush({ indexes: true }); const people = await db.query(Filter.type(Person)).run(); ``` `flush({ indexes: true })` waits until queries see what the test wrote, so a read right after a write does not race the index. ## Replicate between peers Each peer from `createPeer` has its own storage, like a separate device. A `TestReplicationNetwork` from `@dxos/echo-host/testing` connects them in memory. One peer creates the space, and the other opens it from the space's root URL. `waitUntilHeadsReplicated` waits until one peer has all the changes another has made. ```ts import { Context } from '@dxos/context'; import { Obj } from '@dxos/echo'; import { TestReplicationNetwork } from '@dxos/echo-host/testing'; import { PublicKey } from '@dxos/keys'; import { Person, types } from './schema.ts'; // Two peers, each with its own storage, joined by an in-memory network. await using network = await new TestReplicationNetwork().open(); await using peer1 = await builder.createPeer({ types }); await using peer2 = await builder.createPeer({ types }); await peer1.host.addReplicator( Context.default(), await network.createReplicator(), ); await peer2.host.addReplicator( Context.default(), await network.createReplicator(), ); const spaceKey = PublicKey.random(); await using db1 = await peer1.createDatabase(spaceKey); await using db2 = await peer2.openDatabase(spaceKey, db1.rootUrl!); db1.add(Obj.make(Person, { name: 'Grace' })); await db1.flush(); // Wait until peer 2 has every change peer 1 made. await db2.waitUntilHeadsReplicated(await db1.getDocumentHeads()); ``` Use two peers to test what happens when both write: make the writes on each peer, wait for each to have the other's changes, then check that both show the same result. ## Related - [Changes and branches](/docs/echo/concepts/changes/) for how concurrent writes merge. - [Testing a plugin](/docs/plex/guides/testing/) for tests that run a whole Composer plugin. --- # React The tutorial app's React layer: ClientProvider, useQuery, useObject and a schema form, and the reactivity rules behind them. By the end of this page you will know how the tutorial app's components get their data, and why they re-render when any peer changes it. ## Background The [learning path](/docs/echo/learn/the-echo-object/) builds the app's ECHO code in plain TypeScript: its types, its add and update functions, and its queries. This guide covers the React layer on top, which `@dxos/react-client` connects to ECHO. Two rules decide when a component re-renders: - A hook subscribes the component to what it returns. `useQuery` re-renders when the query's results change, and `useObject` when the object changes. - Reading a field of an ECHO object during render subscribes to nothing. A component that reads `person.name` straight off the object does not re-render when the name changes. ## Steps 1. **Install the React client.** `@dxos/react-client` wraps `@dxos/client` in a provider and hooks. Install it from the same build as `@dxos/client`, and configure the bundler, as in [Installation](/docs/echo/learn/installation/). It needs React 19.3. ```bash pnpm add https://pkg.pr.new/dxos/dxos/@dxos/react-client@20a14b9 effect@4.0.4 react@~19.3.1 react-dom@~19.3.1 ``` ```bash npm install --save https://pkg.pr.new/dxos/dxos/@dxos/react-client@20a14b9 effect@4.0.4 react@~19.3.1 react-dom@~19.3.1 ``` ```bash yarn add https://pkg.pr.new/dxos/dxos/@dxos/react-client@20a14b9 effect@4.0.4 react@~19.3.1 react-dom@~19.3.1 ``` 2. **Provide the client.** `ClientProvider` gives the hooks below it a client. The app opens its client and space with `openSpace` from [Installation](/docs/echo/learn/installation/), and the app renders one provider for it. The app also keeps the space it shows in state, so Join can switch it to another. ```ts import { useState } from 'react'; import { type Space } from '@dxos/client/echo'; import { ClientProvider } from '@dxos/react-client'; import { Contacts, reached } from './Contacts.tsx'; import { Share } from './Share.tsx'; /** * The app for one client; the hooks below the provider read this client. */ export const PeerApp = ({ client, space: initial, stage, compact, onSpaceChange, }: PeerAppProps) => { const [space, setSpace] = useState(initial); const handleJoin = (space: Space) => { setSpace(space); onSpaceChange?.(space); }; return ( ) } /> ); }; ``` Open the client once per page load. In development, `StrictMode` runs each effect twice, and two clients on the same storage race to create the identity, so a reload can come back with the other one and none of its records. Start `openSpace` at module level, `const opened = openSpace();`, and await that promise in the effect. 3. **Query the space.** `useQuery` takes a database and a query, here the app's queries from [Querying](/docs/echo/learn/querying/). It returns the current results and re-renders the component when they change. ```ts const allOrganizations = useQuery(space.db, lists.organizations); const allPeople = useQuery(space.db, lists.people); const allEvents = useQuery(space.db, lists.events); ``` A query returns the same live object for an id every time, so a renamed record leaves the results array as it was. The app's list reads its labels with `useLabels`, which re-renders the list when a label changes. To show only the records whose field has a value, put the field in the query, as in `Filter.type(Todo, { completed: false })`. The results then change when a record's field does. A `.filter()` over the results in render reads the fields straight off the objects, so the list goes stale. 4. **Show a record.** `useObject` returns a snapshot of the object and re-renders the component when the object changes. The form's title reads the record's name through it. ```ts import { Obj } from '@dxos/echo'; import { useObject } from '@dxos/react-client/echo'; /** Re-renders whenever any peer renames the record. */ const RecordTitle = ({ record }: { record: Obj.Unknown }) => { const [snapshot] = useObject(record); return

{Obj.getLabel(snapshot)}

; }; ``` Read fields through the snapshot from `useObject`. A field read straight off the ECHO object shows its value at render time, and the component does not re-render when the field changes. 5. **Edit a record.** The app's form is `Form.Root` from `@dxos/react-ui-form`, given the record's type and the values from `useObject`. `onValuesChanged` writes each change back inside `Obj.update`, as in [Mutating objects](/docs/echo/learn/mutating-objects/). A reference field lists the records of the type it points at. ```ts import { useCallback, useMemo } from 'react'; import { Obj } from '@dxos/echo'; import { useObject } from '@dxos/react-client/echo'; import { omitId } from '@dxos/react-ui-form'; /** The record's fields; each change is written back as it is made. */ const EditRecord = ({ db, type, record }: EditRecordProps) => { const [snapshot] = useObject(record); const schema = useMemo(() => omitId(type), [type]); const values = useMemo( () => Object.fromEntries(Object.entries(snapshot ?? {})), [snapshot], ); // #region update const handleChange = useCallback( ( values: Record, { isValid, changed, }: { isValid: boolean; changed: Record }, ) => { // A changed path such as `attendees[0]` rewrites the whole field. const fields = new Set( Object.keys(changed) .filter((path) => changed[path]) .map((path) => path.split(/[.[]/)[0]), ); if (isValid && fields.size > 0) { const changes = Object.fromEntries( [...fields].map((field) => [field, values[field]]), ); Obj.update(record, (record) => { Object.assign(record, changes); }); } }, [record], ); ``` `ObjectForm` from the same package does all of this in one component: ```tsx ``` 6. **Create a record.** For a new record, `Form.Root` takes the type's schema and hands its values to `onSave`, which adds the record as in [Mutating objects](/docs/echo/learn/mutating-objects/). ```ts import { useCallback, useMemo } from 'react'; import { Obj } from '@dxos/echo'; import { omitId } from '@dxos/react-ui-form'; /** A blank form for `type`; saving adds the new record to the database. */ const NewRecord = ({ db, type, onCreate }: NewRecordProps) => { const schema = useMemo(() => omitId(type), [type]); // #region add const handleSave = useCallback( (values: Record) => onCreate(db.add(Obj.make(type, values))), [db, type, onCreate], ); ``` ## Try it The app with every piece in place: the lists, the table and the graph are all `useQuery`, and the form writes inside `Obj.update`. ## Configure the provider ### Show a fallback `fallback` renders while the client initializes. ```ts import { ClientProvider } from '@dxos/react-client'; const Loading = () => Loading…; const WithFallback = () => ( ); ``` ### Pass your own client Create the client yourself to use it outside React too. ```ts import { Client } from '@dxos/client'; import { ClientProvider } from '@dxos/react-client'; const client = new Client({ types: [Task] }); const WithClient = () => ( ); ``` ### Pass a config `config` takes a function that returns a `Config`, built from the same sources as in the [Configuration guide](/docs/echo/guides/configuration/). ```ts import { Config } from '@dxos/client'; import { Defaults, Local } from '@dxos/config'; import { ClientProvider } from '@dxos/react-client'; const WithConfig = () => ( new Config(Local(), Defaults())} > ); ``` ## What you can do now You can provide a client to a React app, query a space from a component, show a record that re-renders on every change, and edit records with a form built from their schema. The [Tasks sample](/docs/additional-resources/samples#tasks) is another complete app built this way. --- # Configuration Load a client's config from files and environment variables instead of writing it in code. `new Client()` uses built-in defaults. Pass a `Config` to choose where settings come from. Each source is a function from `@dxos/config`, and earlier sources override later ones. ## Defaults from a file `Defaults` returns the config in `dx.yml`. In Node it reads `config/default.yml` in your project instead. ```ts import { Client, Config } from '@dxos/client'; import { Defaults } from '@dxos/config'; const fromDefaults = new Client({ config: new Config(Defaults()) }); ``` ## Local development `Local` returns the config in `dx-local.yml`, so a development preset can sit beside the defaults. In Node, `Local` returns nothing. ```ts import { Client, Config } from '@dxos/client'; import { Defaults, Local } from '@dxos/config'; const forLocalDevelopment = new Client({ config: new Config(Local(), Defaults()), }); ``` ## Environment variables `Envs` maps environment variables to config paths, as listed in `dx-env.yml`. ```ts import { Client, Config } from '@dxos/client'; import { Defaults, Envs, Local } from '@dxos/config'; const fromEnvironment = new Client({ config: new Config(Envs(), Local(), Defaults()), }); ``` Each entry in `dx-env.yml` names a variable, the config path it sets, and its type: ```yml LOG_FILTER: path: runtime.client.log.filter type: string DX_PERSIST: path: runtime.client.storage.persistence type: boolean ``` ## Loading the files in the browser In the browser, `Defaults`, `Local` and `Envs` read what the bundler puts in the page. Add `ConfigPlugin` from `@dxos/config/vite-plugin` to the `plugins` in `vite.config.ts`, and it loads the three files from the package root: ```ts ConfigPlugin({ root: import.meta.dirname, env: ['MY_ENV_VAR'] }), ``` Vite exposes only variables prefixed with `VITE_`. To read any other variable at build time, list it in `env`. Its value appears at the config path `runtime.app.env.MY_ENV_VAR`. `@dxos/config/rollup-plugin` and `@dxos/config/esbuild-plugin` export the same `ConfigPlugin` for Rollup and esbuild. The tutorial app passes its config in code, as in [Installation](/docs/echo/learn/installation/), so it has no `ConfigPlugin`. --- # Type Declare and inspect object and relation types, and convert them to and from JSON Schema. ```ts import { Type } from '@dxos/echo'; ``` A type names a kind of entity and fixes its fields. You declare one at module scope as a class, and that class is both the runtime schema and the TypeScript type of its instances. Every type carries a DXN with a typename and a version, so two peers agree on what an object is without sharing code. ## Declare an object type `makeObject` takes the DXN first, then the fields. The optional second argument pipes annotations onto the schema, here the field that labels an object in lists. ```ts import * as Schema from 'effect/Schema'; import { Annotation, DXN, Type } from '@dxos/echo'; class Project extends Type.makeObject( DXN.make('com.example.type.project', '0.1.0'), )( { name: Schema.String, summary: Schema.optional(Schema.String) }, Annotation.LabelAnnotation.set(['name']), ) {} ``` The last segment of the typename must be camelCase. A typename such as `com.example.type.works-at` fails to compile. ## Declare a relation type A relation type also names the types of its two endpoints. [Relation](/docs/echo/api/relation/) covers making instances. ```ts import * as Schema from 'effect/Schema'; import { DXN, Type } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; export class WorksAt extends Type.makeRelation( DXN.make('com.example.type.worksAt', '0.1.0'), )({ source: Person, target: Organization, })({ role: Schema.String }) {} ``` ## Convert to and from JSON Schema The `JsonSchema` module converts a type's schema to JSON Schema and back. `Type.makeObjectFromJsonSchema` builds a type from JSON Schema, for a type that comes from a form, an agent or another system. ```ts import * as Schema from 'effect/Schema'; import { JsonSchema, Type } from '@dxos/echo'; import { Organization } from './schema.ts'; // Out: a JSON Schema document for a form, an agent or another system. const jsonSchema = JsonSchema.toJsonSchema(Type.getSchema(Organization)); // In: a type built from JSON Schema at runtime. const Note = Type.makeObjectFromJsonSchema({ typename: 'com.example.type.note', version: '0.1.0', jsonSchema: { type: 'object', properties: { title: { type: 'string' } } }, }); ``` ## Change fields at runtime A type stored in a space with `db.addType` can gain and lose fields after it exists; the change replicates like any write. The functions throw for a type that is not stored. Objects already made from the type keep their data. Use a new version and a [migration](/docs/echo/concepts/types/#versions-and-migrations) instead when old objects need converting. ```ts import * as Schema from 'effect/Schema'; import { Type } from '@dxos/echo'; const note = await db.addType(Note); Type.addFields(note, { pinned: Schema.optional(Schema.Boolean) }); Type.removeFields(note, ['pinned']); ``` ## Inspect a type These read the identity and schema of a type, which is what an agent or a generic UI works from. ```ts import { Type } from '@dxos/echo'; import { Person } from './schema.ts'; const typename = Type.getTypename(Person); // 'com.example.type.person' const version = Type.getVersion(Person); // '0.1.0' const uri = Type.getURI(Person); // 'dxn:com.example.type.person:0.1.0' const schema = Type.getSchema(Person); ``` ## Check the kind of a value `Type.isObject` and `Type.isRelation` test a type. To test an instance, use the predicates in [Obj](/docs/echo/api/obj/) and [Relation](/docs/echo/api/relation/). ```ts import { Obj, Type } from '@dxos/echo'; import { Person } from './schema.ts'; import { WorksAt } from './works-at.ts'; const ada = Obj.make(Person, { name: 'Ada' }); Type.isObject(Person); // true Type.isRelation(WorksAt); // true Obj.isObject(ada); // true Obj.instanceOf(Person, ada); // true ``` ## Members at a glance | Member | Summary | | ----------------------------------------------- | ----------------------------------------------- | | `makeObject` | Declare an object type | | `makeRelation` | Declare a relation type with typed endpoints | | `makeObjectFromJsonSchema` | Build an object type from JSON Schema | | `addFields` `updateFields` `removeFields` | Change the fields of a type at runtime | | `getTypename` `getVersion` `getURI` `getMeta` | Read the identity and metadata of a type | | `getSchema` | The Effect Schema of a type | | `JsonSchema.toJsonSchema` `JsonSchema.toEffectSchema` | Convert to and from JSON Schema | | `isObject` `isRelation` `isType` | Test what kind of type a value is | The full list is in the [API reference](/docs/additional-resources/api-reference/). ## Related - [Defining types](/docs/echo/learn/defining-types/) declares the tutorial app's types. - [Types and schema](/docs/echo/concepts/types/) explains types, versions and migrations. - [Annotation](/docs/echo/api/annotation/) lists what a type can carry beyond its fields. --- # Annotation What a type carries beyond its fields, and the tools that read it. ```ts import { Annotation } from '@dxos/echo'; ``` An annotation is a typed value attached to a schema, a field, or an object. Annotations on a type are saved with the type's schema, so they replicate with it and a tool that has never seen the code can still read them. ## Annotate a type Each annotation has a `set` that pipes onto a schema, and a `get` that reads it back as an `Option`. ```ts import * as Schema from 'effect/Schema'; import { Annotation, DXN, Type } from '@dxos/echo'; class Project extends Type.makeObject( DXN.make('com.example.type.project', '0.1.0'), )( Schema.Struct({ name: Schema.String, summary: Schema.optional(Schema.String), }).pipe( Annotation.LabelAnnotation.set(['name']), Annotation.IconAnnotation.set({ icon: 'ph--kanban--regular', hue: 'indigo', }), Annotation.DescriptionAnnotation.set('summary'), ), ) {} ``` | Annotation | Value | What reads it | | --- | --- | --- | | `LabelAnnotation` | The fields that label an object, in order | `Obj.getLabel`, lists and pickers | | `DescriptionAnnotation` | The field that describes an object | `Obj.getDescription` | | `IconAnnotation` | An icon and a hue | Composer's navigation tree and cards | | `FormInputAnnotation` `FormLayoutAnnotation` | Whether a field shows in a form, and the form's layout | Forms built from the schema | | `ContentVersionAnnotation` | The fields a version field describes | [Content migrations](/docs/echo/concepts/types/#versions-and-migrations) | | `UserType` | That the type is for people, not internal | Composer's pickers, navigation tree and collections | ## Read annotations ```ts import * as Option from 'effect/Option'; import { Annotation, Obj, Type } from '@dxos/echo'; import { Event } from './schema.ts'; const icon = Annotation.IconAnnotation.get(Type.getSchema(Event)); Option.getOrUndefined(icon); // { icon: 'ph--calendar-dots--regular', hue: 'green' } const roadmap = Obj.make(Project, { name: 'Roadmap', summary: 'What ships next', }); Obj.getLabel(roadmap); // 'Roadmap' Obj.getDescription(roadmap); // 'What ships next' ``` ## Define an annotation `Annotation.make` defines an annotation with an id and a schema for its value. Set it on a schema like the built-in ones, or on one object with `Annotation.set`, which stores the value in the object's metadata. ```ts import * as Schema from 'effect/Schema'; import { Annotation, Obj } from '@dxos/echo'; const ColorAnnotation = Annotation.make({ id: 'com.example.annotation.color', schema: Schema.String, }); // On an instance: stored in the object's metadata, and replicated with it. Obj.update(roadmap, (roadmap) => { Annotation.set(roadmap, ColorAnnotation, 'teal'); }); const color = Annotation.get(roadmap, ColorAnnotation); // Some('teal') ``` ## Members at a glance | Member | Summary | | --- | --- | | `make` | Define an annotation | | `get` `set` `update` | Read and write an annotation on an object | | `.get` `.set` | Read and write an annotation on a schema | The full list is in the [API reference](/docs/additional-resources/api-reference/). ## Related - [Types and schema](/docs/echo/concepts/types/) - [Type](/docs/echo/api/type/) --- # Obj Make, read and write objects, and read their history. ```ts import { Obj } from '@dxos/echo'; ``` `Obj` makes objects and reads and writes them. Its functions take the object as the first argument and work whether or not the object is in a database yet, so a getter that needs the database returns `undefined` outside one instead of throwing. [org.dxos.echo#F-3.3] ## Make an object `Obj.make` checks the props against the type and gives the object an id. Reserved fields such as `Obj.Meta` go in the same props. Here the meta records the object's key in an external system. [org.dxos.echo#F-3.1] ```ts import { Obj, Ref } from '@dxos/echo'; import { Person } from './schema.ts'; const ada = Obj.make(Person, { [Obj.Meta]: { keys: [{ source: 'crm.example.com', id: 'contact-42' }] }, name: 'Ada', employer: Ref.make(acme), }); db.add(ada); ``` ## Read and update an object Read a field like any property. Writes go inside `Obj.update`; a write outside it throws. All the writes in one callback reach subscribers as one change. A live object always shows the latest state. `Obj.getSnapshot` returns a frozen copy that later updates do not touch, which is what you want to keep a before-and-after or hand data to code that must not write it. ```ts import { Obj } from '@dxos/echo'; const name = ada.name; // read a field directly Obj.update(ada, (ada) => { ada.email = 'ada@example.com'; }); const before = Obj.getSnapshot(ada); // an immutable copy Obj.update(ada, (ada) => { ada.name = 'Ada Lovelace'; }); // before.name is still 'Ada' ``` ## Subscribe to one object The callback runs synchronously after each committed change. Query subscriptions over the object re-evaluate at the same time. [org.dxos.echo#F-3.2] In React, use the hooks in the [React guide](/docs/echo/guides/react/) instead. ```ts import { Obj } from '@dxos/echo'; const unsubscribe = Obj.subscribe(ada, () => { console.log('changed', ada.name); }); unsubscribe(); ``` ## Meta, tags, labels and parents Meta holds data about the object rather than in it, such as foreign keys and tags. Labels and descriptions are what lists and pickers show. ```ts import { Obj, Tag } from '@dxos/echo'; const [crm] = Obj.getKeys(ada, 'crm.example.com'); const speaker = db.add(Tag.make({ label: 'speaker' })); Obj.update(ada, (ada) => { Obj.addTag(ada, speaker); }); ``` A child object can name a parent with `Obj.setParent`. The parent must hold a reference to the child, and deleting the parent deletes the child. ## Read the history Each change to an object is a `Change` with the device that made it, the time, and the heads of the document after it. `Obj.getChanges` lists them, for the whole object or one field, where each carries the value before and after. `Obj.getVersion` reads the object at a change's heads. [Changes and branches](/docs/echo/concepts/changes/) explains the model. ```ts import { Obj } from '@dxos/echo'; const changes = Obj.getChanges(ada, { property: 'name' }); const [last] = changes.slice(-1); last.actor; // the device that made the change last.before; // 'Ada' last.after; // 'Ada Lovelace' // The object as it was after a change, without touching the live object. const then = Obj.getVersion(ada, changes[0].heads); ``` ## Clone and serialize `Obj.clone` makes a structural copy. `Obj.toJSON` and `Obj.fromJSON` round-trip an object's id, type, meta and references. [org.dxos.echo#F-3.4] ```ts import { Obj } from '@dxos/echo'; const copy = Obj.clone(ada); const json = Obj.toJSON(ada); ``` ## Members at a glance | Member | Summary | | ------------------------------------------- | ---------------------------------------------------- | | `make` | Create an object of a type | | `update` | Write to an object; one notification per call | | `getSnapshot` | A frozen copy of the current state | | `subscribe` | Run a callback after each change | | `getMeta` `getKeys` `deleteKeys` | Read and change metadata and foreign keys | | `addTag` `removeTag` | Tag an object | | `getLabel` `setLabel` | The label lists and pickers show | | `getParent` `setParent` | Parent and child ownership | | `getChanges` `getVersion` `getBranch` | History, past versions and the current branch | | `instanceOf` `isObject` `isDeleted` | Predicates | | `clone` `toJSON` `fromJSON` | Copy and serialize | Functions shared by objects and relations, such as `getURI` and `getTypename`, are also in the `Entity` module. The full list is in the [API reference](/docs/additional-resources/api-reference/). ## Related - [The ECHO object](/docs/echo/learn/the-echo-object/) and [Mutating objects](/docs/echo/learn/mutating-objects/) in the learning path. - [Objects and relations](/docs/echo/concepts/objects-relations/) for the reserved fields and ownership. --- # Relation The runtime API for relations. ```ts import { Relation } from '@dxos/echo'; ``` A relation is an object with two typed endpoints, a source and a target. Use one when the link has data of its own, such as a role or a date, or when you need to query it from either end. A plain `Ref` field is enough when one object just points at another. `Relation` mirrors [Obj](/docs/echo/api/obj/) for everything that is not about endpoints. ## Make a relation Declare the type with `Type.makeRelation`, which fixes the endpoint types [org.dxos.echo#F-2.2]. Then pass the endpoints under the reserved `Relation.Source` and `Relation.Target` keys. ```ts import { Obj, Relation } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; import { WorksAt } from './works-at.ts'; const ada = db.add(Obj.make(Person, { name: 'Ada' })); const acme = db.add(Obj.make(Organization, { name: 'Acme' })); const job = db.add( Relation.make(WorksAt, { [Relation.Source]: ada, [Relation.Target]: acme, role: 'Engineer', }), ); ``` ## Read the endpoints `getSource` and `getTarget` return the endpoint objects with their types. `getSourceURI` and `getTargetURI` return their URIs without resolving them. ```ts import { Relation } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; const employee = Relation.getSource(job); // Person const employer = Relation.getTarget(job); // Organization const employerUri = Relation.getTargetURI(job); ``` ## Query from either end `targetOf` selects the relations that point at the current selection, and `sourceOf` the ones that start from it. Then `source()` or `target()` steps to the objects at the other end. [Query](/docs/echo/api/query/) covers traversal. ```ts import { Filter, Query } from '@dxos/echo'; import { WorksAt } from './works-at.ts'; const jobsAtAcme = await db .query(Query.select(Filter.id(acme.id)).targetOf(WorksAt)) .run(); const staff = await db .query(Query.select(Filter.id(acme.id)).targetOf(WorksAt).source()) .run(); ``` ## Update, subscribe and sort ```ts import { Relation } from '@dxos/echo'; Relation.update(job, (job) => { job.role = 'Staff engineer'; }); const unsubscribe = Relation.subscribe(job, () => console.log(job.role)); unsubscribe(); const sorted = jobsAtAcme.sort( Relation.sort(Relation.sortByLabel, Relation.sortByTypename), ); ``` ## Members at a glance | Member | Summary | | ------------------------------------------ | --------------------------------------------- | | `make` | Create a relation between two objects | | `Source` `Target` | Reserved keys for the endpoints | | `getSource` `getTarget` | The endpoint objects | | `getSourceURI` `getTargetURI` | The endpoint URIs, unresolved | | `update` `subscribe` `getSnapshot` | Write, watch and copy, as in `Obj` | | `sort` `sortByLabel` `sortByTypename` | Comparators for `Array.sort` | | `instanceOf` `isRelation` | Predicates | The full list is in the [API reference](/docs/additional-resources/api-reference/). ## Related - [Objects and relations](/docs/echo/concepts/objects-relations/) - [Type](/docs/echo/api/type/) for declaring relation types. --- # Ref Typed references between entities. ```ts import { Ref } from '@dxos/echo'; ``` A reference is a field that points at another entity. It stores the target's DXN, not a copy, and resolves the target only when you ask. [org.dxos.echo#F-4.1] [References and DXNs](/docs/echo/concepts/references/) explains the model; this page is the API. ## Declare a reference field `Ref.Ref(Type)` is the schema of a field that points at that type. Use it inside `Schema.optional` or `Schema.Array` like any other field schema. The tutorial's Person and Event declare both: ```ts import * as Schema from 'effect/Schema'; import { Annotation, DXN, Format, Ref, Type } from '@dxos/echo'; export class Person extends Type.makeObject( DXN.make('com.example.type.person', '0.1.0'), )( Schema.Struct({ name: Schema.String, email: Schema.optional(Format.Email), employer: Schema.optional(Ref.Ref(Organization)), }).pipe( Annotation.IconAnnotation.set({ icon: 'ph--user--regular', hue: 'cyan' }), ), ) {} ``` ## Make a reference `Ref.make` takes an object you already hold. `db.makeRef` takes a URI and returns a reference bound to the database, so it can load its target. `Ref.fromURI` also takes a URI, but its reference has nothing to load from until a database binds it. ```ts import { Obj, Ref } from '@dxos/echo'; import { Event, Organization, Person } from './schema.ts'; const acme = db.add(Obj.make(Organization, { name: 'Acme' })); const ada = db.add(Obj.make(Person, { name: 'Ada', employer: Ref.make(acme) })); const launch = db.add( Obj.make(Event, { title: 'Launch', attendees: [Ref.make(ada)] }), ); // From a URI: bound to the database, so it can load. const again = db.makeRef(Obj.getURI(ada)); ``` ## Resolve a reference `peek` returns the target if it is already in memory and never starts a load. `load` waits for the target, reading it from disk or the network, and throws if it cannot be found; `tryLoad` returns `undefined` instead. `Ref.loadAll` loads an array of references at once. Avoid `ref.target`. It is deprecated because reading it starts a load as a side effect and can throw. ```ts import { Ref } from '@dxos/echo'; import { Organization } from './schema.ts'; const loaded = ada.employer?.peek(); // Organization | undefined, never loads // loads from disk or the network if needed const employer = await ada.employer?.load(); // undefined instead of throwing when the target is missing const maybe = await again.tryLoad(); // missing and deleted targets are left out const attendees = await Ref.loadAll(launch.attendees ?? []); ``` ## Compare references Each read of a reference field returns a new wrapper, so `===` never matches. Compare with `Ref.equals`, or test the target id with `Ref.hasEntityId`. ```ts import { Ref } from '@dxos/echo'; const same = ada.employer !== undefined && Ref.equals(ada.employer, Ref.make(acme)); const attending = (launch.attendees ?? []).some(Ref.hasEntityId(ada.id)); Ref.isRef(ada.employer); // true ``` ## Members at a glance | Member | Summary | | ------------------------- | --------------------------------------------------------- | | `Ref(Type)` | Schema of a reference field | | `Array(Type)` | Schema of an array of references | | `make` | A reference to an object you hold | | `fromURI` | An unbound reference from a URI | | `loadAll` | Load many targets, skipping missing and deleted ones | | `equals` `hasEntityId` | Compare references | | `isRef` | Predicate | | `ref.peek()` `ref.load()` `ref.tryLoad()` | Read the target in memory, or load it | The full list is in the [API reference](/docs/additional-resources/api-reference/). ## Related - [References and DXNs](/docs/echo/concepts/references/) - [Query](/docs/echo/api/query/) traverses references inside a query. --- # Database The database of one space, its branches, and the Hypergraph over every space. ```ts import { Database } from '@dxos/echo'; ``` Each space has one database. It holds the space's objects, runs queries over them and saves writes to disk. Effect code reaches the same database as `Database.Service`, which the [Effect guide](/docs/echo/guides/effect/) covers. ## Get a database In plain code the database is a property of the space. ```ts const db = space.db; ``` ## Add and remove objects `add` is synchronous. It returns the object, now stored in the database, and saves it in the background. `remove` marks the object deleted; queries leave it out from then on. ```ts import { Obj } from '@dxos/echo'; import { Person } from './schema.ts'; const ada = db.add(Obj.make(Person, { name: 'Ada' })); db.remove(ada); ``` ## Run a query `db.query` takes a filter or a query and returns a `QueryResult`. Call `run()` for the results once, or `subscribe()` to follow them. [org.dxos.echo#F-5.1] [Query](/docs/echo/api/query/) covers building queries. ```ts import { Filter, Query } from '@dxos/echo'; import { Person } from './schema.ts'; const people = await db.query(Query.select(Filter.type(Person))).run(); ``` ## Wait for writes `flush` resolves once pending writes are on disk. Options also wait for the indexes and for query updates, which is what a test needs before it reads back what it wrote. ```ts await db.flush(); // pending writes are on disk await db.flush({ indexes: true, updates: true }); // and queries see them ``` ## Add types `db.add` rejects type entities. Persist a type with `addType`, which replicates it to the space's other members. If the space already holds a type with the same typename and version, `addType` returns that one and writes nothing. ```ts import { Type } from '@dxos/echo'; const Note = Type.makeObjectFromJsonSchema({ typename: 'com.example.type.note', version: '0.1.0', jsonSchema: { type: 'object', properties: { title: { type: 'string' } } }, }); const persisted = await db.addType(Note); // replicates to the other members ``` ## Branches A branch is a separate line of changes to an object and the objects it references. `createBranch` starts one, `switchBranch` shows it on this device, and `mergeBranch` merges it into main. `db.branch` returns a second live copy bound to a branch, without switching; call `dispose` on it when done. [Changes and branches](/docs/echo/concepts/changes/#branches) explains the model. ```ts import { Obj } from '@dxos/echo'; import { Event } from './schema.ts'; const launch = db.add(Obj.make(Event, { title: 'Launch' })); await db.createBranch(launch.id, 'draft'); db.listBranches(launch.id); // ['main', 'draft'] await db.switchBranch(launch.id, 'draft'); // this device only await db.switchBranch(launch.id, 'main'); await db.mergeBranch(launch.id, 'draft', { deleteAfter: true }); ``` ## The Hypergraph `client.graph` is the Hypergraph: every space this device holds. It queries across spaces, finds a space's database by id, and holds the registry of types and objects registered in code. ```ts import { Filter, Query } from '@dxos/echo'; import { Person } from './schema.ts'; const everyone = await client.graph .query(Query.select(Filter.type(Person)).from('all-accessible-spaces')) .run(); const sameDb = client.graph.getDatabase(space.id); ``` ## Sync state `getSyncState` reports how many documents this device has not yet synced with the remote peer, and `subscribeToSyncState` calls back as that changes. ```ts const state = await db.getSyncState(); const unsubscribe = db.subscribeToSyncState((state) => console.log(state)); unsubscribe(); ``` ## Members at a glance | Member | Summary | | ----------------------------------------- | ------------------------------------------------------- | | `db.add` `db.remove` | Store and delete objects and relations | | `db.query` | Query the space; returns a `QueryResult` | | `db.addType` | Persist a type so it replicates | | `db.makeRef` | A reference bound to this database | | `db.flush` | Wait for writes, indexes and query updates | | `createBranch` `switchBranch` `mergeBranch` | Branches of objects | | `syncBranch` `deleteBranch` `branch` | Update, drop or bind a branch | | `createBlob` `readBlob` `getBlobUrl` | Store and read [blobs](/docs/echo/api/blob/) | | `appendToFeed` `forkFeed` | Write to [feeds](/docs/echo/api/feed/) | | `client.graph.query` `getDatabase` | Query every space; find a space's database | | `getSyncState` `subscribeToSyncState` | Replication progress | The full list is in the [API reference](/docs/additional-resources/api-reference/). ## Related - [Spaces and databases](/docs/echo/concepts/spaces/) - [Installation](/docs/echo/learn/installation/) opens the tutorial app's space. --- # Query Graph-shaped selections, traversal, ordering and results. ```ts import { Order, Query } from '@dxos/echo'; ``` A query selects a set of entities, then can step through the graph from that set: along reference fields, backwards to whatever references them, or across relations. Building a query does no work. It compiles to a query AST, and runs only when you pass it to `db.query` and read the result. [org.dxos.echo#F-5.2] ## Select objects Most queries start from a type and a few field values. `Query.select` takes any filter; these three cover most uses, and [Filter](/docs/echo/api/filter/) has the rest. ```ts import { Filter, Query } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; const people = Query.type(Person); const acme = Query.select(Filter.type(Organization, { name: 'Acme' })); const launches = Query.select(Filter.text('launch')); ``` ## Combine queries `Query.all` is the union of several queries. `Query.without` removes the results of one query from another. ```ts import { Query } from '@dxos/echo'; import { Organization, Person } from './schema.ts'; const peopleOrOrgs = Query.all(people, Query.type(Organization)); const withoutAda = Query.without(people, Query.type(Person, { name: 'Ada' })); ``` ## Traverse references and relations Each step returns a new query, so steps chain. - `reference(field)` follows a `Ref` field to its targets. - `referencedBy(Type, field)` goes the other way, to the objects whose field points at the selection. - `sourceOf(Relation)` and `targetOf(Relation)` select the relations that start or end at the selection. - `source()` and `target()` step from relations to their endpoints. ```ts import { Person } from './schema.ts'; import { WorksAt } from './works-at.ts'; const employers = people.reference('employer'); // follow a Ref field const staff = acme.referencedBy(Person, 'employer'); // follow it backwards // relations that end at Acme, and the people at their other end const jobs = acme.targetOf(WorksAt); const workers = jobs.source(); ``` ## Order results `orderBy` takes one or more orders, applied in turn. [org.dxos.echo#F-5.3] `Order.property` sorts by a field; `Order.created` and `Order.updated` sort by timestamp; `Order.natural` keeps insertion order. ```ts import { Order } from '@dxos/echo'; import { Person } from './schema.ts'; const ordered = people .orderBy(Order.property('name', 'asc'), Order.updated()) .limit(20); ``` ## Aggregate results `aggregate` turns the results into records of named values: `Aggregate.count`, `sum`, `min`, `max`, `group` by a field, `items` for the members of each group, and `created`, `updated` or `time` bucketed by a unit. It must be the last step that selects data; only `orderBy`, `limit`, `skip` and `from` may follow it. ```ts import { Aggregate, Query } from '@dxos/echo'; import { Event } from './schema.ts'; const [{ count }] = await db .query(Query.type(Event).aggregate({ count: Aggregate.count() })) .run(); ``` ## Choose sources Without `from`, a query reads the database it runs on. `from` names the sources instead, with `Scope`: `Scope.space()` for a space, `Scope.registry()` for the types and objects registered in code, and `Scope.feed()` for a [feed](/docs/echo/api/feed/). `from` also takes a database, a feed object, or `'all-accessible-spaces'`. ```ts import { Filter, Query, Scope, Type } from '@dxos/echo'; // Types stored in the space, and types registered in code. const types = await db .query( Query.select(Filter.type(Type.Type)).from(Scope.space(), Scope.registry()), ) .run(); ``` ## Run once or subscribe `db.query` returns a `QueryResult`. `run()` resolves with the results once. `subscribe` calls back each time the results change, and `{ fire: true }` also calls it straight away with the current results. [org.dxos.echo#F-5.1] A query reads the objects on this device first and adds what the indexes and EDGE return. A subscriber can see results arrive in more than one step. `QueryResult.sources` lists each source with its state: pending, answered or failed. [org.dxos.echo.query#F-5] ```ts const all = await db.query(ordered).run(); const first = await db.query(ordered).firstOrUndefined(); const result = db.query(ordered); const unsubscribe = result.subscribe( (result) => console.log(result.results.length), { fire: true }, ); // Each source of the answer, with its state: pending, answered or failed. const sources = result.sources; unsubscribe(); ``` ## Members at a glance | Member | Summary | | --------------------------------------- | ------------------------------------------------- | | `select` `type` | Start a query from a filter or a type | | `all` `without` | Union and difference of queries | | `.reference` `.referencedBy` | Follow reference fields, forwards or backwards | | `.sourceOf` `.targetOf` | Relations that start or end at the selection | | `.source` `.target` | Endpoints of selected relations | | `.orderBy` `.limit` `.skip` | Order, cap and page the results | | `.aggregate` `Aggregate.*` | Count, sum, group and bucket the results | | `.from` `Scope.*` | Choose the sources | | `Order.property` `created` `updated` | Orders | | `QueryResult.run` `first` `subscribe` | Read the results | | `QueryResult.sources` | Each source and its state | The full list is in the [API reference](/docs/additional-resources/api-reference/). ## Related - [Querying](/docs/echo/learn/querying/) in the learning path. - [Filter](/docs/echo/api/filter/) for every predicate. --- # Filter Predicate builders that compile to a query AST. ```ts import { Filter } from '@dxos/echo'; ``` Build a predicate, then pass it to `db.query` directly or through `Query.select`. Every builder is O(1): it creates an AST node and matches nothing until the query runs. | Member | Signature | Summary | | ------------------------------------- | --------------------------------------- | ------------------------------------- | | [`type`](#match-by-type) | `(type, props?) => Filter` | Objects of a type, optionally by props | | [`props`](#match-by-type) | `(props) => Filter` | Objects whose fields match | | [`eq` `neq`](#compare-a-field) | `(value) => Filter` | Equal, not equal | | [`gt` `gte` `lt` `lte`](#compare-a-field) | `(value) => Filter` | Ordered comparison | | [`between`](#compare-a-field) | `(from, to) => Filter` | From inclusive, to exclusive | | [`contains`](#compare-a-field) | `(value) => Filter` | Array field holds the value | | [`in`](#compare-a-field) | `(...values) => Filter` | Field equals one of the values | | [`and` `or` `not`](#combine-filters) | `(...filters) => Filter` | Boolean combinators | | [`text`](#search-text) | `(text, options?) => Filter` | Full-text or vector search | | [`created` `updated`](#filter-by-time) | `({ after?, before? }) => Filter` | Timestamp range | | [`childOf`](#filter-by-parent) | `(parents, options?) => Filter` | Children of an object | | [`changes`](#filter-changes) | `(targets?) => Filter` | Changes instead of objects | ## Match by type `type` takes an ECHO type and an optional props shape, so field names and value types are checked. `props` matches on shape alone. ```ts import { Filter } from '@dxos/echo'; import { Person } from './schema.ts'; const adas = Filter.type(Person, { name: 'Ada' }); const untyped = Filter.props<{ name: string }>({ name: 'Ada' }); ``` ## Compare a field Comparators go in the props shape, on the field they test. ```ts import { Filter } from '@dxos/echo'; import { Person } from './schema.ts'; const atExample = Filter.type(Person, { email: Filter.in('ada@example.com', 'grace@example.com'), }); ``` ## Combine filters ```ts import { Filter, Query } from '@dxos/echo'; import { Person } from './schema.ts'; const peopleNotAda = Filter.and(Filter.type(Person), Filter.not(adas)); const results = await db.query(Query.select(peopleNotAda)).run(); ``` ## Search text `options.type` selects `full-text` or `vector`. ```ts import { Filter } from '@dxos/echo'; const matches = Filter.text('product launch', { type: 'full-text' }); ``` ## Filter by time Both bounds accept a number of milliseconds or a `Date`. With both set, the filter is the AND of the two. ```ts import { Filter } from '@dxos/echo'; const recent = Filter.updated({ after: new Date('2026-11-01') }); ``` ## Filter by parent `childOf` matches the children of one or more objects, and their children in turn unless `transitive` is false. It takes objects or references, and reads a reference's URI without loading the target. ```ts import { Filter } from '@dxos/echo'; const owned = Filter.childOf(launch); // children, grandchildren and on const direct = Filter.childOf(launch, { transitive: false }); ``` ## Filter changes `changes` selects the changes to some objects, or to every object in the space, instead of the objects. Order them by `time`, or count them with an [aggregate](/docs/echo/api/query/#aggregate-results). ```ts import { Filter, Query } from '@dxos/echo'; const history = Query.select(Filter.changes([launch])).limit(50); ``` --- # Feed Append to a feed, walk it, branch it, and set its options. ```ts import { Feed } from '@dxos/echo'; ``` A feed is an append-only log of objects, such as the messages of a chat or the events of a sync. The feed itself is an ECHO object of type `org.dxos.type.feed`, stored in the space like any other. Its items are stored in blocks, apart from the space's documents. [Feeds](/docs/echo/concepts/feeds/) explains the model of branches and walks. ## Make a feed and append to it Add the feed object to the database first. `db.appendToFeed` then writes items in one batch. ```ts import { Feed, Obj } from '@dxos/echo'; const feed = db.add(Feed.make({ name: 'Chat' })); await db.appendToFeed(feed, [ Obj.make(Message, { text: 'Hello' }), Obj.make(Message, { text: 'Hi back' }), ]); ``` ## Read a feed A walk reads items in the order they were appended. It is a query with a `Scope.feed` source and an `Order.natural` order. To read a page at a time, pass `limit`, then select with `Filter.feedCursor` from the cursor of the last item to get the page after it. Any filter works over a feed's items, as over a space, and `from(feed)` reads every item without walking a path. ```ts import { Feed, Filter, Order, Query, Scope } from '@dxos/echo'; const uri = Feed.getFeedUri(feed)!.toString(); // Every message on the main path, oldest first. const messages = await db .query( Query.select(Filter.type(Message)) .from(Scope.feed(uri, { walk: {} })) .orderBy(Order.natural('asc')), ) .run(); // The next page: resume after the last item read. const cursor = Feed.getLocalCursor(messages[messages.length - 1]); const next = await db .query( Query.select( Filter.and( Filter.type(Message), Filter.feedCursor({ begin: cursor, positions: 'local' }), ), ) .from(Scope.feed(uri, { walk: {} })) .orderBy(Order.natural('asc')) .limit(50), ) .run(); // Any filter works, as in a database query. const greetings = await db .query(Query.select(Filter.type(Message, { text: 'Hello' })).from(feed)) .run(); ``` ## Indexing and retention Both options are fields of the feed object, so they replicate and merge like any other field. [org.dxos.echo#F-7.1] - `indexing: { enabled: false }` turns indexing of the feed's items off completely. There is no partial mode. [org.dxos.echo#F-7.2] - `retention` drops items past `maxLength` items or older than `maxAge` milliseconds. With both set, an item goes when it breaks either limit. [org.dxos.echo#F-7.3] A write returns once the change is applied locally. It does not wait for the feed store to trim anything. ```ts import { Obj } from '@dxos/echo'; Obj.update(feed, (feed) => { feed.indexing = { enabled: false }; feed.retention = { maxLength: 1_000, maxAge: 30 * 24 * 60 * 60 * 1000 }; }); ``` ## Fork a feed A fork starts a branch at an existing item, for example an alternate reply in a conversation. `db.forkFeed` returns the branch id. Walk with `{ branch }` to read the path from the start of the feed to the end of that branch. ```ts import { Filter, Obj, Order, Query, Scope } from '@dxos/echo'; const branch = await db.forkFeed( feed, Obj.make(Message, { text: 'An alternate reply' }), { at: messages[0] }, ); const alternate = await db .query( Query.select(Filter.type(Message)) .from(Scope.feed(uri, { walk: { branch } })) .orderBy(Order.natural('asc')), ) .run(); ``` ## Remove items `db.deleteFromFeed` removes items you hold. `db.removeFeedItemsByIds` removes items by id. ```ts await db.deleteFromFeed(feed, [messages[1]]); ``` ## Members at a glance | Member | Summary | | ---------------------------------------- | ---------------------------------------------------- | | `Feed` | The feed object type | | `make` | Create a feed object | | `getFeedUri` | The URI a `Scope.feed` source takes | | `getLocalCursor` | The cursor to resume a walk after an item | | `getBranch` `isFork` | The branch an item is on, and whether it starts one | | `db.appendToFeed` `db.deleteFromFeed` | Write and delete items | | `db.forkFeed` | Start a branch | | `db.syncFeed` `db.getFeedSyncState` | Replicate a feed and read its progress | The full list is in the [API reference](/docs/additional-resources/api-reference/). ## Related - [Feeds](/docs/echo/concepts/feeds/) for branches, walks and cursors. - [Filter](/docs/echo/api/filter/) for the filters a walk takes. --- # Text Edit string fields so concurrent edits merge character by character. ```ts import { Text } from '@dxos/echo'; ``` `Text` edits a string field as a sequence of characters. Assigning a string replaces it, so of two concurrent assignments one wins. An edit with `Text` inserts and deletes characters, so two people typing in the same field both keep their work. [Changes and branches](/docs/echo/concepts/changes/) shows what merges and how. ## Edit text Any string field can be edited this way; it needs no special declaration. Call the functions inside `Obj.update`. - `splice` removes and inserts characters at an index, like `Array.prototype.splice`. - `update` replaces the whole value, but writes only the characters that differ. - `apply` runs find-and-replace edits, and throws `TextEditNotFoundError` when a text to replace is missing. ```ts import { Obj, Text } from '@dxos/echo'; Obj.update(launch, (launch) => { // Insert at a character index, as Array.prototype.splice does. Text.splice(launch, 'description', 8, 0, 'welcome, '); // Replace the whole value with a minimal diff of characters. Text.update(launch, 'description', 'Agenda: welcome, demo, questions.'); // Find and replace. Text.apply(launch, 'description', [ { oldString: 'questions', newString: 'Q&A' }, ]); }); ``` Read the field as usual: it is a string. ## Text in an input A text field bound to an ECHO string, such as a `