# Welcome 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. ## 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/introduction). 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 a framework, such as [React](/docs/echo/guides/react/). - **Reference** covers one module per page, such as [Filter](/docs/echo/reference/filter/). ## Behavior Each concept page states what ECHO guarantees, and each claim cites an item in the ECHO spec. ## Limits These docs are being written. A page marked planned in the sidebar shows its scope but has no content yet. --- # 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 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 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 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 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 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 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. ## 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 module in the [reference](/docs/echo/reference/filter/). --- # 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 Pick a type, open a record, and edit it in the form. The table shows the records of one type, and the graph draws every reference between them. Each page of the learning path builds one piece of it: | Page | What it adds | | --- | --- | | [Installation](/docs/echo/learn/installation/) | A client, an identity and the app's space | | [Defining types](/docs/echo/learn/defining-types/) | Organization, Person and Event, and a form for each | | [Mutating objects](/docs/echo/learn/mutating-objects/) | Adding, editing and removing records | | [Querying](/docs/echo/learn/querying/) | Sorted lists, search, and the people at an organization | | [Sharing a space](/docs/echo/learn/sharing/) | A second peer that follows every edit | 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. ## Next [Installation](/docs/echo/learn/installation/): install the client and open the app's space. --- # 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. ## Before you start 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. 1. **Install the client.** `@dxos/client` holds the `Client`, and it brings `@dxos/echo` with it. ```bash pnpm add @dxos/client ``` ```bash npm install --save @dxos/client ``` ```bash yarn add @dxos/client ``` 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 loads WebAssembly in the page and in workers, so Vite needs `vite-plugin-wasm` for both. `ConfigPlugin` loads the client's config from YAML files at the package root. ```ts export default defineConfig({ plugins: [ConfigPlugin({ root: import.meta.dirname, env: ['MY_ENV_VAR'] }), wasm()], worker: { format: 'es', plugins: () => [wasm()], }, }); ``` `@dxos/config/rollup-plugin` and `@dxos/config/esbuild-plugin` export the same `ConfigPlugin` for Rollup and esbuild. Node needs no bundler setup. 3. **Open the app's space.** The app does this in one function. `new Client` takes the app's types, which the next page declares. `initialize` opens local storage and connects to the network. The client then needs an identity before it can create a space; [HALO](/docs/halo/typescript) covers identities and devices. Last, it creates the space and waits until its database is ready. ```ts /** Starts a client that knows the app's types, gives it an identity, and creates the space the app keeps its records in. */ export const openContacts = async (services?: ClientServicesProvider) => { const client = new Client({ types, services }); await client.initialize(); if (!client.halo.identity.get()) { await client.halo.createIdentity(); } const space = 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` is for running the client somewhere other than the default, as the embeds on these pages do: each runs its clients in memory in the page. ## Configure the client `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 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 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 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 ``` Vite exposes only variables prefixed with `VITE_`. To read any other variable at build time, list it in the `env` option of `ConfigPlugin`, as in step 2. Its value appears at the config path `runtime.app.env.MY_ENV_VAR`. ## 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. ## Next [Defining types](/docs/echo/learn/defining-types/): declare the app's organizations, people and events. --- # 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. ## Before you start 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. 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 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 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 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/). ```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. ```ts 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)] }); ``` 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. ```ts // The reference holds the organization's identity, not a copy of it. const employer = ada.employer?.peek(); console.log(employer?.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 on the next page. ## 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. ## Next [Mutating objects](/docs/echo/learn/mutating-objects/): add, change and remove the app's records. --- # 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. ## Before you start 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. 1. **Write the app's add functions.** Each takes the database and the props of one type, and returns the tracked object. ```ts export const addOrganization = (db: Database.Database, props: { name: string; website?: string }) => db.add(Obj.make(Organization, props)); export const addPerson = ( db: Database.Database, { employer, ...props }: { name: string; email?: string; employer?: Organization }, ) => db.add(Obj.make(Person, { ...props, ...(employer && { employer: Ref.make(employer) }) })); export const addEvent = ( db: Database.Database, { attendees = [], ...props }: { title: string; description?: string; attendees?: Person[] }, ) => db.add(Obj.make(Event, { ...props, attendees: attendees.map((person) => Ref.make(person)) })); ``` A person's `employer` is passed as an object and stored as a reference to it. 2. **Add records.** ```ts const northwind = addOrganization(db, { name: 'Northwind Labs' }); const ada = addPerson(db, { name: 'Ada Lovelace', employer: northwind }); ``` 3. **Watch a record.** `Obj.subscribe` calls back after each change to the object, and returns a function that stops the subscription. ```ts const employers: (string | undefined)[] = []; const unsubscribe = Obj.subscribe(ada, () => { employers.push(ada.employer?.peek()?.name); }); ``` 4. **Change a record.** The app moves a person to another organization in one update, and its form writes whatever fields the reader changed. ```ts /** Moves a person to another organization. */ export const changeEmployer = (person: Person, employer: Organization) => Obj.update(person, (person) => { person.employer = Ref.make(employer); }); /** Writes the values a form returns onto a record, one field at a time. */ export const updateRecord = (record: Obj.Unknown, values: Record) => Obj.update(record, (record) => { for (const [key, value] of Object.entries(values)) { Obj.setValue(record, [key], value); } }); ``` ```ts const halcyon = addOrganization(db, { name: 'Halcyon Robotics' }); changeEmployer(ada, halcyon); ``` The subscriber from step 3 has run, so `employers` holds `'Halcyon Robotics'`. 5. **Remove a record.** ```ts export const removeRecord = (db: Database.Database, record: Obj.Unknown) => db.remove(record); ``` ```ts removeRecord(db, ada); ``` 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 it in the form, or press **New** to add one of the chosen type. The **Ă—** on a row removes it. A person's employer is a picker of the organizations in the space. ## What you can do now You can add records to a database, change them inside `Obj.update`, watch them change, and remove them. ## Next [Querying](/docs/echo/learn/querying/): list the records, sorted and searched. --- # 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. ## Before you start 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 reference](/docs/echo/reference/filter/) lists every comparison and combinator. 1. **List each type, sorted.** `Filter.type` selects the objects of a type, and `orderBy` sorts them by a field. ```ts export const organizations = Query.select(Filter.type(Organization)).orderBy( Order.property('name', 'asc'), ); export const people = Query.select(Filter.type(Person)).orderBy(Order.property('name', 'asc')); export const events = Query.select(Filter.type(Event)).orderBy(Order.property('title', 'asc')); ``` 2. **Run a query.** The results are typed, so `person.name` is a `string`. ```ts const everyone = await db.query(people).run(); console.log(everyone.map((person) => person.name)); ``` 3. **Search text.** `Filter.text` matches objects whose text contains the words, whatever their type. The space indexes text as objects are written. ```ts /** Every record whose text contains `text`, whatever its type. */ export const search = (text: string) => Query.select(Filter.text(text, { type: 'full-text' })); ``` ```ts const matches = await db.query(search('Northwind')).run(); ``` 4. **Follow references backwards.** `referencedBy` steps from the selected organization to the people whose `employer` points at it. ```ts /** The people whose `employer` points at `organization`. */ export const employeesOf = (organization: Organization) => Query.select(Filter.id(organization.id)).referencedBy(Person, 'employer'); ``` ```ts const staff = await db.query(employeesOf(northwind)).run(); ``` 5. **Keep a query live.** `subscribe` calls back with the current results each time they change, and returns a function that stops it. ```ts const unsubscribe = db.query(people).subscribe(({ results }) => { console.log(`${results.length} people`); }); addPerson(db, { name: 'Grace Hopper' }); unsubscribe(); ``` The callback runs again for the person added after the subscription, before `addPerson` returns. ## Try it Type in the search box to search every record. The table button shows the records of the chosen type with one column per field. Each list is a live query, so an edit in the form shows up in the list as you save it. ## 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. ## Next [Sharing a space](/docs/echo/learn/sharing/): replicate the app's space to a second peer. --- # 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. ## Before you start A space is shared by invitation. The host creates an invitation, and the guest uses it to find the host and open a secure connection. The host then gives the guest an authentication code, which the guest sends back over that connection to prove it received the invitation from the host. 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. [Spaces and databases](/docs/echo/concepts/spaces/#joining-a-space) explains the handshake in detail. 1. **Invite a peer.** `space.share` starts an invitation. Its code locates you on the network, and its authentication code proves the guest received the invitation from you. ```ts const { space } = await openContacts(); const invitation = space.share(); // Send this code to the guest; it locates the host and opens a secure connection. const code = InvitationEncoder.encode(invitation.get()); // Pass this second factor over another channel, such as a QR code or a call. const authCode = invitation.get().authCode; ``` Send the two codes over different channels. Someone who intercepts one of them cannot join. 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 invitation, connects, and sends the authentication code back. ```ts const guest = new Client({ types }); await guest.initialize(); await guest.halo.createIdentity(); const accepted = guest.spaces.join(InvitationEncoder.decode(code)); await accepted.authenticate(authCode!); ``` 3. **Find the space.** Once authentication completes, the space is among the guest's spaces, and both peers replicate it. ```ts // The space appears among the guest's spaces once the invitation succeeds. const spaces = guest.spaces.get(); ``` ## Try it Alice created the space and Bob joined it by invitation. Edit a record on one side and watch the other follow. Switch a peer offline, edit on both sides, 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. ## Next [Spaces and databases](/docs/echo/concepts/spaces/): what a space is, and how peers join one. --- # Spaces and databases The unit of sharing, access and replication. A space is an ECHO database that a set of peers replicates, and it is the unit of sharing and access control. ## Model A space works like a collection in a document store. Every object belongs to exactly one space, and `space.db` is the database that holds them. A peer usually belongs to many spaces at once, and each space has its own members. There is no server that owns a space. Each member keeps a full copy of the space's database in local storage, and members exchange changes directly over [WebRTC](https://en.wikipedia.org/wiki/WebRTC) or through EDGE. ### Joining a space A peer joins a space through an invitation from a member. The handshake has five steps: 1. generates an [invitation code](/docs/additional-resources/glossary#invitation-code) and listens on the network for peers that present it. 2. obtains the invitation code and finds through the [signaling service](/docs/additional-resources/glossary#signaling-service). 3. The two peers open a secure connection with a [Diffie-Hellman](https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange) key exchange. 4. generates an [authorization code](/docs/additional-resources/glossary#authorization-code) and gives it to over a second channel, such as a QR code. 5. sends the authorization code back to over the new connection. The last step proves that the connection is secure and that each peer is who the other expects. [Sharing a space](/docs/echo/learn/sharing/) drives these steps from code. ### Spaces and HALO ECHO keeps both the user's data and the user's identity in persistent storage on the device: browser storage in a web app, files on disk in Node, where the database runs in the same process. [HALO](/docs/halo/introduction) holds the identity, its keys and its credentials. An identity can span several devices. To give a second app or device read and write access to all of a user's spaces, the user starts a device invitation from one that already has access. The user can revoke a device at any time. ## 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 peer joins only after it completes the handshake above. Holding the invitation code alone is not enough. ## Limits This page is a first pass ported from the earlier ECHO guides. The behavior above does not cite spec items yet, because the ECHO spec does not cover spaces or invitations. --- # Objects and relations Entities, the reserved fields, and parent and child ownership. An object is the unit of data in ECHO, like a document or a row in other databases, and it always belongs to one space. ## Model An object has fields with values, and references to other objects. References let objects form trees and graphs. Field values are JavaScript strings, numbers, booleans and `null`, and objects and arrays of those. The top level of an object is always a JavaScript object, never a single number or string. Every object also carries fields that ECHO manages: | Field | How to read it | | --- | --- | | Id | `obj.id` | | Type | `Obj.getTypename(obj)` | | Metadata, such as keys from other systems | `Obj.getMeta(obj)` | | Whether it was removed | `Obj.isDeleted(obj)` | An object either has a type, declared as in [Defining types](/docs/echo/learn/defining-types/), or is an `Expando` that accepts any fields. ## 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 query subscriptions. [org.dxos.echo#F-3.2] - Serializing an object and reading it back keeps its id, type, metadata and references. [org.dxos.echo#F-3.4] - A reference stores the DXN of its target and loads the target only when read. [org.dxos.echo#F-4.1] - Removing an object marks it as removed. It stays 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. ## Limits This page is a first pass ported from the earlier ECHO guides. It does not yet describe relations or parent and child ownership, and the removal behavior does not cite a spec item. --- # 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. ## Before you start 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. 1. **Install the React client.** `@dxos/react-client` wraps `@dxos/client` in a provider and hooks. Configure the bundler as in [Installation](/docs/echo/learn/installation/). ```bash pnpm add @dxos/react-client ``` ```bash npm install --save @dxos/react-client ``` ```bash yarn add @dxos/react-client ``` 2. **Provide the client.** `ClientProvider` gives the hooks below it a client. The app opens its client and space with `openContacts` from [Installation](/docs/echo/learn/installation/), and renders one provider per peer. ```ts /** One peer's copy of the app: the provider gives the hooks below it the peer's client. */ export const PeerApp = ({ client, space, stage }: { client: Client; space: Space; stage: Stage }) => ( ); ``` 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 // Each list is a live query: it re-renders when a record is added, changed by any peer, or removed. const allOrganizations = useQuery(space.db, organizations); const allPeople = useQuery(space.db, people); const allEvents = useQuery(space.db, 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. 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 /** The record's name, re-rendered whenever any peer renames it. */ const RecordTitle = ({ record }: { record: Obj.Unknown }) => { const [snapshot] = useObject(record); const label = 'title' in snapshot ? snapshot.title : 'name' in snapshot ? snapshot.name : undefined; return

{String(label ?? '')}

; }; ``` 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 with a form built from the schema.** `Form.Root` from `@dxos/react-ui-form` takes the type's schema, so a new field in the schema is a new field in the form. A reference field lists the records of the type it points at. ```ts /** The options a reference field offers: the records of its target type, by label. */ const getOptions = useCallback>( (results) => [...results] .sort((a, b) => (Entity.getLabel(a) ?? '').localeCompare(Entity.getLabel(b) ?? '')) .map((result) => { const target = types.find((type) => Type.getTypename(type) === Entity.getTypename(result)); return { id: Entity.getURI(result), label: Entity.getLabel(result) ?? result.id, ...(target && { icon: getTypeIcon(target)?.icon }), }; }), [], ); const useRefType = useCallback>( (_db, typeUri) => types.find((type) => String(typeUri).includes(Type.getTypename(type))) ?? recordType, [recordType], ); ``` On save, the form hands back its values, and the app writes them with the functions from [Mutating objects](/docs/echo/learn/mutating-objects/). ```ts const handleSave = useCallback( (next: Record) => { if (record) { updateRecord(record, next); onDone?.(); return; } // A reference field reports the URI of its choice; the record behind it is what the add functions take. const resolve = (value: unknown) => Ref.isRef(value) ? records.find((candidate) => Entity.getURI(candidate) === value.uri) : undefined; if (recordType === Person) { const employer = resolve(next.employer); addPerson(db, { name: String(next.name ?? ''), email: optional(next.email), employer: employer && Obj.instanceOf(Organization, employer) ? employer : undefined, }); } else if (recordType === Event) { addEvent(db, { title: String(next.title ?? ''), description: optional(next.description), attendees: (Array.isArray(next.attendees) ? next.attendees : []) .map(resolve) .filter((candidate) => candidate !== undefined) .filter((candidate) => Obj.instanceOf(Person, candidate)), }); } else { addOrganization(db, { name: String(next.name ?? ''), website: optional(next.website) }); } onDone?.(); }, [db, record, records, recordType, onDone], ); ``` ## Try it The app with every piece in place: lists, the table and the graph are all `useQuery`, and the form is `useObject` and `Form.Root`. ## Configure the provider ### Show a fallback `fallback` renders while the client initializes. ```ts const Loading = () => Loading…; const WithFallback = () => ( ); ``` ### Pass your own client Create the client yourself to use it outside React too. ```ts 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 [Configure the client](/docs/echo/learn/installation/#configure-the-client). ```ts 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. ## Next [Spaces and databases](/docs/echo/concepts/spaces/): what a space is, and how peers join one. --- # 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 | | [`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 | ## 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 const urgent = Filter.type(Task, { priority: Filter.gte(8) }); const untyped = Filter.props<{ priority: number }>({ priority: 8 }); ``` ## Compare a field Comparators go in the props shape, on the field they test. ```ts const midrange = Filter.type(Task, { priority: Filter.between(3, 7) }); const tagged = Filter.type(Task, { tags: Filter.contains('docs') }); ``` ## Combine filters ```ts const triage = Filter.and(Filter.type(Task), Filter.or(urgent, tagged), Filter.not(midrange)); const results = await db.query(Query.select(triage)).run(); ``` ## Search text `options.type` selects `full-text` or `vector`. ```ts const matches = Filter.text('conflict resolution', { 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 const recent = Filter.updated({ after: new Date('2026-11-01') }); ```