Skip to content

Changes and branches

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.

Each object lives in an Automerge 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.

import {
import Obj
Obj
,
import Text
Text
} from '@dxos/echo';
import Obj
Obj
.
const update: <BaseObj & Event & OfKind<EntityKind.Object>>(obj: BaseObj & Event & OfKind<EntityKind.Object>, callback: ChangeCallback<BaseObj & Event & OfKind<EntityKind.Object>>) => BaseObj & Event & OfKind<EntityKind.Object>

Perform mutations on an echo object within a controlled context.

All mutations within the callback are batched and trigger a single notification when the callback completes. Direct mutations outside of Obj.update will throw an error for echo objects.

This function also works with nested objects within echo objects (e.g., Template structs) that are reactive at runtime.

@param ― obj - The echo object to mutate. Use Relation.update for relations.

@param ― callback - The callback that performs mutations on the object.

@example

const person = Obj.make(Person, { name: 'John', age: 25 });
// Mutate within Obj.update
Obj.update(person, (obj) => {
obj.name = 'Jane';
obj.age = 30;
});
// ONE notification fires here
// Direct mutation throws
person.name = 'Bob'; // Error: Cannot modify outside Obj.update()

Note: Only accepts objects. Use Relation.update for relations.

@performance ― Synchronous; costs the mutations made in the callback plus one batched notification and an O(owning fields) parent pass.

update
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
, (
launch: {
id: EntityId;
"~@dxos/echo/Kind": EntityKind.Object;
title: string;
description?: string | undefined;
attendees?: Ref<Person & OfKind<EntityKind.Object> & Obj.Unknown>[] | undefined;
}
launch
) => {
// replaces the whole string: a concurrent edit to the title loses
launch: {
id: EntityId;
"~@dxos/echo/Kind": EntityKind.Object;
title: string;
description?: string | undefined;
attendees?: Ref<Person & OfKind<EntityKind.Object> & Obj.Unknown>[] | undefined;
}
launch
.
title: string
title
= 'Product launch';
// inserts characters: concurrent edits interleave
import Text
Text
.
const splice: (obj: Obj.Unknown, path: Text.KeyPath | string | number, start: number, deleteCount: number, insert?: string) => string

Splice the string CRDT value at path by character index, mirroring Array.prototype.splice: remove deleteCount characters at start and insert insert. Returns the removed substring.

Must be called inside Obj.update(obj, () => { ... }).

@performance ― O(delete count + insert length) CRDT splice.

splice
(
launch: {
id: EntityId;
"~@dxos/echo/Kind": EntityKind.Object;
title: string;
description?: string | undefined;
attendees?: Ref<Person & OfKind<EntityKind.Object> & Obj.Unknown>[] | undefined;
}
launch
, 'description', 8, 0, 'demo, questions. ');
});
  • 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.
const
const writers: Set<string>
writers
= new
var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set
(
const changes: ValueChange<string>[]
changes
.
Array<ValueChange<string>>.map<string>(callbackfn: (value: ValueChange<string>, index: number, array: ValueChange<string>[]) => string, thisArg?: any): string[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
((
change: ValueChange<string>
change
) =>
change: ValueChange<string>
change
.
actor: string

Writer that made the change; for a document change, an Automerge actor, which is a device, not a member.

actor
));
if (
const writers: Set<string>
writers
.
Set<string>.size: number

@returns ― the number of (unique) elements in Set.

size
> 1) {
// Several devices set the title; show the history so a person can confirm
// the result.
}

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.

import {
import Aggregate
Aggregate
,
import Filter
Filter
,
import Obj
Obj
,
import Order
Order
,
import Query
Query
} from '@dxos/echo';
const
const changes: ValueChange<string>[]
changes
=
import Obj
Obj
.
function getChanges<BaseObj & Event & OfKind<EntityKind.Object>, "title">(obj: BaseObj & Event & OfKind<EntityKind.Object>, opts: Obj.GetChangesOptions<"title"> & {
property: "title";
}): ValueChange<string>[] (+1 overload)

The object's edit history, oldest first: one

Change.Change

per document change that touched the object, carrying its snapshot before and after. Given property, only the changes that touched that property, carrying its value. Pass an entry's heads to

getVersion

to read the whole object as that change left it.

Changes belong to the object's Automerge document, so time, actor and ops describe the whole change, which may also have written other objects in the same document. Edits to the content of a referenced object (e.g. a Ref<Text>) are that object's history, not this one's.

@example

for (const { time, before, after } of Obj.getChanges(task, { property: 'status' })) {
console.log(new Date(time), before, '→', after);
}

@performance ― O(document history): diffs every change of the Automerge document and snapshots each match; not cached.

getChanges
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
, {
property: "title"

Narrow the history to the changes that touched this property; before/after then hold its value.

property
: 'title' });
for (const {
const actor: string

Writer that made the change; for a document change, an Automerge actor, which is a device, not a member.

actor
,
const time: number

The author's clock when the change was made, unix ms.

time
,
const before: string | undefined

The value before the change; undefined where the object (or property) did not exist yet.

before
,
const after: string | undefined

The value after the change; undefined where the change removed the property.

after
} of
const changes: ValueChange<string>[]
changes
) {
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@see ― source

console
.
Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@since ― v0.1.100

log
(
const actor: string

Writer that made the change; for a document change, an Automerge actor, which is a device, not a member.

actor
,
const time: number

The author's clock when the change was made, unix ms.

time
,
const before: string | undefined

The value before the change; undefined where the object (or property) did not exist yet.

before
, '→',
const after: string | undefined

The value after the change; undefined where the change removed the property.

after
);
}
// The event as it was after the first change.
const
const first: Obj.Snapshot<BaseObj & Event & OfKind<EntityKind.Object>>
first
=
import Obj
Obj
.
const getVersion: <BaseObj & Event & OfKind<EntityKind.Object>>(obj: BaseObj & Event & OfKind<EntityKind.Object>, heads: readonly string[]) => Obj.Snapshot<BaseObj & Event & OfKind<EntityKind.Object>>

Get an immutable snapshot of the object at the given historical heads — a detached instance, not a pin on the live object. Only the surface that asks for it sees the historical value; the live object and every other surface are unaffected. The functional alternative to a read-time-travel pin.

@performance ― O(object size) plus the Automerge cost of viewing the document at heads; nothing is cached.

getVersion
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
,
const changes: ValueChange<string>[]
changes
[0].
ValueChange<string>.heads: readonly string[]

The document's frontier once the change applied; Obj.getVersion(obj, heads) reads the object as it left it.

heads
);
// Changes per day across the space, newest first.
const
const perDay: {
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}[]
perDay
= await
const db: Database.Database
db
.
Database.query: Database.QueryFn
<Query.Query<{
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}>>(query: Query.Query<{
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}>) => QueryResult<{
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}> (+1 overload)
query
(
import Query
Query
.
const select: <Filter.Filter<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult>>(filter: Filter.Filter<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult>) => Query.Query<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult>

Select objects based on a filter.

@param ― filter - Filter to select the objects.

@returns ― Query for the selected objects.

@performance ― O(1); wraps the current AST in a new immutable node without executing anything.

select
(
import Filter
Filter
.
const changes: (targets?: Obj.Unknown | Unknown | readonly (Obj.Unknown | Unknown)[]) => Filter.Filter<Change>

Select Automerge changes instead of objects: one

Change.Change

per change to the documents holding targets, or to every document in the space when called with no argument.

Changes belong to documents, not entities. Entities that share a document share its changes, and content held by another entity (a document's Text) needs its own target. Only the host answers these queries; a space-wide query must aggregate, and the index answers it only at hour granularity (see Aggregate.time).

@example

Query.select(Filter.changes()).aggregate({ day: Aggregate.time('time', 'day'), changes: Aggregate.count() });
Query.select(Filter.changes([doc, doc.content])).orderBy(Order.property('time', 'desc')).limit(50);

@performance ― O(k) in the number of targets; refs are read by URI without loading.

changes
())
.
Query<{ readonly key: string; readonly source: "document" | "feed"; readonly time: number; readonly actor: string; readonly seq: number; readonly ops: number; } & RecordResult>.aggregate<{
readonly day: Aggregate.Aggregate<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult, number | null>;
readonly changes: Aggregate.Aggregate<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult, number>;
}>(aggregates: {
readonly day: Aggregate.Aggregate<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult, number | null>;
readonly changes: Aggregate.Aggregate<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult, number>;
}): Query.Query<...>

Aggregate the query results into flat records.

Aggregate.group

entries partition the results into contiguous groups (one record each), keyed by the record field the group is named after; with no group entries the entire input aggregates into a single record. Each declared aggregate becomes a top-level field and can be ordered by with a following

orderBy

using

Order.property

.

Groups are ordered by the first occurrence of their key in the incoming stream, so a preceding orderBy controls group order in the absence of a following one.

Aggregate.items

's own order option is a separate, explicit per-group member ordering — pass it there rather than relying on a preceding orderBy, whose only well-defined job once aggregate follows is establishing initial group order. For example, message threads ordered by their most recent message, each retaining up to 20 members newest-first:

Query.type(Message)
.aggregate({
threadId: Aggregate.group('threadId'),
lastMessageAt: Aggregate.max('created'),
items: Aggregate.items({ limit: 20, order: [Order.property('created', 'desc')] }),
})
.orderBy(Order.property('lastMessageAt', 'desc'));

Must be the last data-selecting clause in the chain — only from/options/orderBy/limit/ skip may follow.

@param ― aggregates - Record of aggregate declarations keyed by result field name.

@returns ― Query whose flat result records carry the named aggregates as fields.

@performance ― O(k) in the number of aggregates; builds the AST without executing anything.

aggregate
({
day: Aggregate.Aggregate<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult, number | null>
day
:
import Aggregate
Aggregate
.
const time: <{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult, "time">(property: "time", unit: Aggregate.TimeUnit) => Aggregate.Aggregate<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult, number | null>

Group members by the UTC hour or day a unix-ms property falls in, like

updated

over a property instead of a system timestamp. The field carries the start of that interval in unix ms, or null when the property is not a number.

@performance ― O(1); builds an aggregate spec without evaluating anything.

time
('time', 'day'),
changes: Aggregate.Aggregate<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult, number>
changes
:
import Aggregate
Aggregate
.
const count: <{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult>() => Aggregate.Aggregate<{
readonly key: string;
readonly source: "document" | "feed";
readonly time: number;
readonly actor: string;
readonly seq: number;
readonly ops: number;
} & Query.RecordResult, number>

Count the group's members. Opt-in — groups carry no count unless this aggregate is declared.

@performance ― O(1); builds an aggregate spec without evaluating anything.

count
(),
})
.
Query<{ readonly '~@dxos/echo/Query.RecordResult': true; readonly day: number | null; readonly changes: number; }>.orderBy(order_0: Order.Order<{
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}>, ...order: Order.Order<{
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}>[]): Query.Query<{
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}>

Order the query results. Orders are specified in priority order. The first order will be applied first, etc.

Order.property orders by the current result shape's fields, so it works both before and after an

aggregate

: before, by member (row) properties; after, by the flat record's fields (any group or aggregate field — e.g. Order.property('lastMessageAt') reorders the groups by that aggregate).

@param ― order - Order to sort the results.

@returns ― Query for the ordered results.

@performance ― O(k) in the number of orders; builds the AST without executing anything.

orderBy
(
import Order
Order
.
const property: <{
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}>(property: "day" | "~@dxos/echo/Query.RecordResult" | "changes", direction: OrderDirection) => Order.Order<{
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}>

Order by a property of the current result shape.

@performance ― O(1); builds an order AST node without sorting anything.

property
('day', 'desc')),
)
.
QueryResult<{ readonly '~@dxos/echo/Query.RecordResult': true; readonly day: number | null; readonly changes: number; }>.run(opts?: RunOptions): Promise<{
readonly '~@dxos/echo/Query.RecordResult': true;
readonly day: number | null;
readonly changes: number;
}[]>

Returns all known results.

@performance ― Async; executes the full query (index plus working set) on every call, not cached; O(n) in the data the executor examines, not in the result count.

run
();

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.
import {
import Obj
Obj
} from '@dxos/echo';
// A draft of the event and everything it references.
await
const db: Database.Database
db
.
Database.createBranch(rootObjectId: string, name: string, opts?: {
fromHeads?: readonly string[] | Record<string, readonly string[]>;
}): Promise<void>

Fork the object and its referenced subtree into a new branch (does not switch to it).

@param ― opts.fromHeads Fork from a historical frontier instead of the tip (a bare heads array applies to the root only; a map forks each member from its own frontier).

@performance ― Async; O(subtree size), each member document forked as a full history copy.

createBranch
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
.
id: string
id
, 'draft');
await
const db: Database.Database
db
.
Database.switchBranch(rootObjectId: string, name: string): Promise<void>

Switch the object's subtree to a branch (or back to 'main'). Device-local; cascades to children.

@performance ― Async; O(subtree size) rebinds, serialized with other branch operations.

switchBranch
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
.
id: string
id
, 'draft');
import Obj
Obj
.
const update: <BaseObj & Event & OfKind<EntityKind.Object>>(obj: BaseObj & Event & OfKind<EntityKind.Object>, callback: ChangeCallback<BaseObj & Event & OfKind<EntityKind.Object>>) => BaseObj & Event & OfKind<EntityKind.Object>

Perform mutations on an echo object within a controlled context.

All mutations within the callback are batched and trigger a single notification when the callback completes. Direct mutations outside of Obj.update will throw an error for echo objects.

This function also works with nested objects within echo objects (e.g., Template structs) that are reactive at runtime.

@param ― obj - The echo object to mutate. Use Relation.update for relations.

@param ― callback - The callback that performs mutations on the object.

@example

const person = Obj.make(Person, { name: 'John', age: 25 });
// Mutate within Obj.update
Obj.update(person, (obj) => {
obj.name = 'Jane';
obj.age = 30;
});
// ONE notification fires here
// Direct mutation throws
person.name = 'Bob'; // Error: Cannot modify outside Obj.update()

Note: Only accepts objects. Use Relation.update for relations.

@performance ― Synchronous; costs the mutations made in the callback plus one batched notification and an O(owning fields) parent pass.

update
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
, (
launch: {
id: EntityId;
"~@dxos/echo/Kind": EntityKind.Object;
title: string;
description?: string | undefined;
attendees?: Ref<Person & OfKind<EntityKind.Object> & Obj.Unknown>[] | undefined;
}
launch
) => {
launch: {
id: EntityId;
"~@dxos/echo/Kind": EntityKind.Object;
title: string;
description?: string | undefined;
attendees?: Ref<Person & OfKind<EntityKind.Object> & Obj.Unknown>[] | undefined;
}
launch
.
title: string
title
= 'Launch, moved to Friday';
});
// Back to main, then merge the draft into it.
await
const db: Database.Database
db
.
Database.switchBranch(rootObjectId: string, name: string): Promise<void>

Switch the object's subtree to a branch (or back to 'main'). Device-local; cascades to children.

@performance ― Async; O(subtree size) rebinds, serialized with other branch operations.

switchBranch
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
.
id: string
id
, 'main');
await
const db: Database.Database
db
.
Database.mergeBranch(rootObjectId: string, name: string, opts?: {
deleteAfter?: boolean;
}): Promise<void>

Merge a branch back into main across the subtree, then switch back to main.

@performance ― Async; O(subtree size) Automerge merges followed by a switch back to main.

mergeBranch
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
.
id: string
id
, 'draft', {
deleteAfter?: boolean | undefined
deleteAfter
: true });
import {
import Obj
Obj
} from '@dxos/echo';
// A second live copy bound to the branch, without switching this device.
await
const db: Database.Database
db
.
Database.createBranch(rootObjectId: string, name: string, opts?: {
fromHeads?: readonly string[] | Record<string, readonly string[]>;
}): Promise<void>

Fork the object and its referenced subtree into a new branch (does not switch to it).

@param ― opts.fromHeads Fork from a historical frontier instead of the tip (a bare heads array applies to the root only; a map forks each member from its own frontier).

@performance ― Async; O(subtree size), each member document forked as a full history copy.

createBranch
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
.
id: string
id
, 'proposal');
const
const proposal: Database.BranchBinding<BaseObj & Event & OfKind<EntityKind.Object>>
proposal
= await
const db: Database.Database
db
.
Database.branch<BaseObj & Event & OfKind<EntityKind.Object>>(obj: BaseObj & Event & OfKind<EntityKind.Object>, name: string): Promise<Database.BranchBinding<BaseObj & Event & OfKind<EntityKind.Object>>>

Create a caller-owned, writable binding to one branch of one object — a live object whose reads resolve the branch document and whose writes land on the branch document only. Multiple bindings to different branches of the same object may coexist; the device-global current branch and other bindings are unaffected. Binding to 'main' returns the canonical live object. Bindings are ephemeral and never persisted — the caller must dispose().

@performance ― Async; O(1) for main, otherwise loads and binds the branch document of this one object.

branch
(
const launch: BaseObj & Event & OfKind<EntityKind.Object>
launch
, 'proposal');
import Obj
Obj
.
const update: <BaseObj & Event & OfKind<EntityKind.Object>>(obj: BaseObj & Event & OfKind<EntityKind.Object>, callback: ChangeCallback<BaseObj & Event & OfKind<EntityKind.Object>>) => BaseObj & Event & OfKind<EntityKind.Object>

Perform mutations on an echo object within a controlled context.

All mutations within the callback are batched and trigger a single notification when the callback completes. Direct mutations outside of Obj.update will throw an error for echo objects.

This function also works with nested objects within echo objects (e.g., Template structs) that are reactive at runtime.

@param ― obj - The echo object to mutate. Use Relation.update for relations.

@param ― callback - The callback that performs mutations on the object.

@example

const person = Obj.make(Person, { name: 'John', age: 25 });
// Mutate within Obj.update
Obj.update(person, (obj) => {
obj.name = 'Jane';
obj.age = 30;
});
// ONE notification fires here
// Direct mutation throws
person.name = 'Bob'; // Error: Cannot modify outside Obj.update()

Note: Only accepts objects. Use Relation.update for relations.

@performance ― Synchronous; costs the mutations made in the callback plus one batched notification and an O(owning fields) parent pass.

update
(
const proposal: Database.BranchBinding<BaseObj & Event & OfKind<EntityKind.Object>>
proposal
.
object: BaseObj & Event & OfKind<EntityKind.Object>

Live object bound to the branch document ('main' -> the canonical live object).

object
, (
event: {
id: EntityId;
"~@dxos/echo/Kind": EntityKind.Object;
title: string;
description?: string | undefined;
attendees?: Ref<Person & OfKind<EntityKind.Object> & Obj.Unknown>[] | undefined;
}
event
) => {
event: {
id: EntityId;
"~@dxos/echo/Kind": EntityKind.Object;
title: string;
description?: string | undefined;
attendees?: Ref<Person & OfKind<EntityKind.Object> & Obj.Unknown>[] | undefined;
}
event
.
title: string
title
= 'Launch, online only';
});
// launch.title is unchanged; show the two side by side.
const proposal: Database.BranchBinding<BaseObj & Event & OfKind<EntityKind.Object>>
proposal
.
function dispose(): void

Release the binding (drops the doc-handle listener; never deletes the branch document).

dispose
();

A feed has branches of its own, for items appended to a log. Branches of objects are for objects that people edit in place.

  • 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 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.
  • A switch to a branch is local to the device. Another device of the same person still shows main until it switches too.