@dxos/echo-doc 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@dxos/echo-doc",
3
+ "version": "0.0.0",
4
+ "description": "Opinionated Automerge document wrapper for ECHO.",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
12
+ "author": "DXOS.org",
13
+ "sideEffects": false,
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "source": "./src/index.ts",
18
+ "types": "./dist/types/src/index.d.ts",
19
+ "default": "./dist/lib/neutral/index.mjs"
20
+ }
21
+ },
22
+ "types": "dist/types/src/index.d.ts",
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "dependencies": {
28
+ "@automerge/automerge": "catalog:",
29
+ "@dxos/echo": "workspace:*",
30
+ "@dxos/echo-client": "workspace:*",
31
+ "@dxos/invariant": "workspace:*",
32
+ "@dxos/log": "workspace:*",
33
+ "@dxos/util": "workspace:*",
34
+ "effect": "catalog:"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }
@@ -0,0 +1,98 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { next as A } from '@automerge/automerge';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { Obj } from '@dxos/echo';
9
+ import { EchoTestBuilder } from '@dxos/echo-client/testing';
10
+ import { TestSchema } from '@dxos/echo/testing';
11
+
12
+ import * as Doc from './Doc';
13
+ import { applyEdits } from './edits';
14
+
15
+ // `Doc.createAccessor` resolves an accessor for either backend: a database-attached object (its core
16
+ // is the space document) or an in-memory `Obj.make` object (a local core is materialized on demand).
17
+
18
+ describe('Doc', () => {
19
+ describe('echo-db backend', () => {
20
+ test('reads and edits an attached object', async ({ expect }) => {
21
+ const builder = new EchoTestBuilder();
22
+ const { db, graph } = await builder.createDatabase();
23
+ graph.registry.add([TestSchema.Task]);
24
+
25
+ const obj = db.add(Obj.make(TestSchema.Task, { description: 'hello' }));
26
+ const accessor = Doc.createAccessor(obj, ['description']);
27
+ expect(Doc.getValue<string>(accessor)).toBe('hello');
28
+
29
+ accessor.handle.change((doc) => {
30
+ const path: A.Prop[] = [...accessor.path];
31
+ A.splice(doc, path, 5, 0, ' world');
32
+ });
33
+ expect(obj.description).toBe('hello world');
34
+ });
35
+ });
36
+
37
+ describe('in-memory backend', () => {
38
+ test('binds before db.add and edits survive attach', async ({ expect }) => {
39
+ const obj = Obj.make(TestSchema.Task, { description: 'hello' });
40
+
41
+ // No db: a local core is materialized so the accessor can bind.
42
+ const accessor = Doc.createAccessor(obj, ['description']);
43
+ expect(Doc.getValue<string>(accessor)).toBe('hello');
44
+
45
+ accessor.handle.change((doc) => {
46
+ const path: A.Prop[] = [...accessor.path];
47
+ A.splice(doc, path, 5, 0, ' world');
48
+ });
49
+ expect(Doc.getValue<string>(accessor)).toBe('hello world');
50
+
51
+ const builder = new EchoTestBuilder();
52
+ const { db, graph } = await builder.createDatabase();
53
+ graph.registry.add([TestSchema.Task]);
54
+ const added = db.add(obj);
55
+ expect(added).toBe(obj);
56
+ expect(added.description).toBe('hello world');
57
+ });
58
+ });
59
+
60
+ describe('applyEdits', () => {
61
+ test('applies edits over an attached object', async ({ expect }) => {
62
+ const builder = new EchoTestBuilder();
63
+ const { db, graph } = await builder.createDatabase();
64
+ graph.registry.add([TestSchema.Task]);
65
+
66
+ const obj = db.add(Obj.make(TestSchema.Task, { description: 'the cat sat' }));
67
+ const accessor = Doc.createAccessor(obj, ['description']);
68
+ expect(applyEdits(accessor, [{ oldString: 'cat', newString: 'dog' }])).toBe('the dog sat');
69
+ expect(obj.description).toBe('the dog sat');
70
+ });
71
+
72
+ test('applies edits over an in-memory object', ({ expect }) => {
73
+ const obj = Obj.make(TestSchema.Task, { description: 'the cat sat' });
74
+ const accessor = Doc.createAccessor(obj, ['description']);
75
+ expect(applyEdits(accessor, [{ oldString: 'cat', newString: 'dog' }])).toBe('the dog sat');
76
+ });
77
+
78
+ test('replaces all occurrences with replaceAll', ({ expect }) => {
79
+ const obj = Obj.make(TestSchema.Task, { description: 'the cat sat on the cat' });
80
+ const accessor = Doc.createAccessor(obj, ['description']);
81
+ expect(applyEdits(accessor, [{ oldString: 'cat', newString: 'dog', replaceAll: true }])).toBe(
82
+ 'the dog sat on the dog',
83
+ );
84
+ });
85
+
86
+ test('throws when oldString is not found', ({ expect }) => {
87
+ const obj = Obj.make(TestSchema.Task, { description: 'hello' });
88
+ const accessor = Doc.createAccessor(obj, ['description']);
89
+ expect(() => applyEdits(accessor, [{ oldString: 'xyz', newString: 'abc' }])).toThrow();
90
+ });
91
+
92
+ test('throws on an empty oldString (would otherwise loop forever with replaceAll)', ({ expect }) => {
93
+ const obj = Obj.make(TestSchema.Task, { description: 'hello' });
94
+ const accessor = Doc.createAccessor(obj, ['description']);
95
+ expect(() => applyEdits(accessor, [{ oldString: '', newString: 'x', replaceAll: true }])).toThrow();
96
+ });
97
+ });
98
+ });
package/src/Doc.ts ADDED
@@ -0,0 +1,58 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import { next as A } from '@automerge/automerge';
8
+
9
+ import { Doc, createObject, getObjectCore, isEchoObject } from '@dxos/echo-client';
10
+ import { type AnyProperties, isProxy } from '@dxos/echo/internal';
11
+ import { assertArgument } from '@dxos/invariant';
12
+
13
+ // TODO(burdon): Reduce the dependency on @dxos/echo-client (e.g. move ObjectCore lower or invert).
14
+
15
+ export type KeyPath = Doc.KeyPath;
16
+
17
+ export const isKeyPath = Doc.isKeyPath;
18
+
19
+ export type Handle<T = any> = Doc.Handle<T>;
20
+
21
+ export type Accessor<T = any> = Doc.Accessor<T>;
22
+
23
+ export const getValue = Doc.getValue;
24
+
25
+ /**
26
+ * Resolves an {@link Accessor} for a value within an object, agnostic to whether the object is
27
+ * in-memory or attached to a database. An `Obj.make` object has no `ObjectCore` until `db.add`,
28
+ * so an in-memory core is materialized (via `createObject`) to let the accessor bind before
29
+ * persistence; the object's edits then survive a later `db.add`.
30
+ */
31
+ export const createAccessor = <T extends AnyProperties>(
32
+ obj: T,
33
+ path: KeyPath | Extract<keyof T, string | number>,
34
+ ): Accessor<T> => {
35
+ const keyPath: KeyPath = Array.isArray(path) ? path : [path];
36
+ assertArgument(isProxy(obj), 'obj', 'expect obj to be a LiveObject');
37
+ assertArgument(isKeyPath(keyPath), 'path', 'expect path to be a valid key path');
38
+
39
+ const live: AnyProperties = isEchoObject(obj) ? obj : createObject(obj);
40
+ return getObjectCore(live).getDocAccessor(keyPath);
41
+ };
42
+
43
+ /**
44
+ * Sets the text value at the given path, applying the change as a minimal Automerge delta (via
45
+ * `A.updateText`) rather than replacing the whole string. This preserves cursors/anchors and merges
46
+ * cleanly with concurrent edits — use it to write an edited string back into a collaborative text field.
47
+ */
48
+ export const updateText = <T extends AnyProperties>(
49
+ obj: T,
50
+ path: KeyPath | Extract<keyof T, string | number>,
51
+ newText: string,
52
+ ): T => {
53
+ const accessor = createAccessor(obj, path);
54
+ accessor.handle.change((doc) => {
55
+ A.updateText(doc, accessor.path.slice(), newText);
56
+ });
57
+ return obj;
58
+ };
package/src/edits.ts ADDED
@@ -0,0 +1,60 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { next as A } from '@automerge/automerge';
6
+ import * as Schema from 'effect/Schema';
7
+
8
+ import * as Doc from './Doc';
9
+
10
+ /**
11
+ * A single find/replace edit applied to a text document. This is the structured form of the diff
12
+ * protocol described in document skills' instructions.
13
+ */
14
+ export const Edit = Schema.Struct({
15
+ oldString: Schema.String.annotations({
16
+ description: 'The exact text to find and replace.',
17
+ }),
18
+ newString: Schema.String.annotations({
19
+ description: 'The text to replace it with.',
20
+ }),
21
+ replaceAll: Schema.optional(Schema.Boolean).annotations({
22
+ description: 'Replace all occurrences of oldString (default: replace the first occurrence).',
23
+ }),
24
+ });
25
+
26
+ export interface Edit extends Schema.Schema.Type<typeof Edit> {}
27
+
28
+ /**
29
+ * Applies a sequence of find/replace {@link Edit}s to the string value an accessor points at,
30
+ * returning the resulting content. Throws if a non-`replaceAll` edit's `oldString` is not found.
31
+ */
32
+ export const applyEdits = (accessor: Doc.Accessor, edits: readonly Edit[]): string => {
33
+ for (const edit of edits) {
34
+ // `''.indexOf('')` is 0 (never -1), so a `replaceAll` with an empty match would loop forever.
35
+ if (edit.oldString.length === 0) {
36
+ throw new Error('Edit oldString must be non-empty.');
37
+ }
38
+
39
+ accessor.handle.change((doc) => {
40
+ const text = Doc.getValue<string>(accessor);
41
+ // Automerge's `splice` types the path as mutable `Prop[]`; our `KeyPath` is readonly and is not mutated here.
42
+ if (edit.replaceAll) {
43
+ let idx = text.indexOf(edit.oldString);
44
+ while (idx !== -1) {
45
+ A.splice(doc, accessor.path as A.Prop[], idx, edit.oldString.length, edit.newString);
46
+ const updated = Doc.getValue<string>(accessor);
47
+ idx = updated.indexOf(edit.oldString, idx + edit.newString.length);
48
+ }
49
+ } else {
50
+ const idx = text.indexOf(edit.oldString);
51
+ if (idx === -1) {
52
+ throw new Error(`Edit not found: ${JSON.stringify(edit.oldString)}`);
53
+ }
54
+ A.splice(doc, accessor.path as A.Prop[], idx, edit.oldString.length, edit.newString);
55
+ }
56
+ });
57
+ }
58
+
59
+ return Doc.getValue<string>(accessor);
60
+ };
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * as Doc from './Doc';
6
+ export { Edit, applyEdits } from './edits';
7
+ export {
8
+ AbstractStoreAdapter,
9
+ type BaseElement,
10
+ type Batch,
11
+ Modified,
12
+ type StoreAdapterOptions,
13
+ } from './store-adapter';
package/src/record.ts ADDED
@@ -0,0 +1,90 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { next as A } from '@automerge/automerge';
6
+
7
+ import { isNonNullable } from '@dxos/util';
8
+
9
+ import * as Doc from './Doc';
10
+
11
+ // Strings longer than this have collaborative editing disabled for performance reasons.
12
+ const STRING_CRDT_LIMIT = 300_000;
13
+
14
+ /**
15
+ * Default codec: encode a model value to an Automerge record.
16
+ */
17
+ export const encode = (value: any): any => {
18
+ if (Array.isArray(value)) {
19
+ return value.map(encode);
20
+ }
21
+ if (value instanceof A.RawString) {
22
+ throw new Error('Encode called on automerge data.');
23
+ }
24
+ if (typeof value === 'object' && value !== null) {
25
+ return Object.fromEntries(
26
+ Object.entries(value)
27
+ .map(([key, value]) => {
28
+ const encoded = encode(value);
29
+ if (encoded === undefined) {
30
+ return undefined;
31
+ }
32
+ return [key, encoded];
33
+ })
34
+ .filter(isNonNullable),
35
+ );
36
+ }
37
+ if (typeof value === 'string' && value.length > STRING_CRDT_LIMIT) {
38
+ return new A.RawString(value);
39
+ }
40
+
41
+ return value;
42
+ };
43
+
44
+ /**
45
+ * Default codec: decode an Automerge record to a model value.
46
+ */
47
+ export const decode = (value: any): any => {
48
+ if (Array.isArray(value)) {
49
+ return value.map(decode);
50
+ }
51
+ if (value instanceof A.RawString) {
52
+ return value.toString();
53
+ }
54
+ if (typeof value === 'object' && value !== null) {
55
+ return Object.fromEntries(Object.entries(value).map(([key, value]) => [key, decode(value)]));
56
+ }
57
+
58
+ return value;
59
+ };
60
+
61
+ /**
62
+ * Relative path from `base`, or `undefined` if `path` is not under `base`. Used to filter mutations.
63
+ */
64
+ export const rebasePath = (path: A.Prop[], base: Doc.KeyPath): A.Prop[] | undefined => {
65
+ if (path.length < base.length) {
66
+ return undefined;
67
+ }
68
+ for (let i = 0; i < base.length; ++i) {
69
+ if (path[i] !== base[i]) {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ return path.slice(base.length);
75
+ };
76
+
77
+ /**
78
+ * Read the value at `path`. When `init` is set, auto-vivifies intermediate records.
79
+ */
80
+ export const getDeep = (obj: any, path: Doc.KeyPath, init = false): any => {
81
+ let value = obj;
82
+ for (const key of path) {
83
+ if (init) {
84
+ value[key] ??= {};
85
+ }
86
+ value = value?.[key];
87
+ }
88
+
89
+ return value;
90
+ };
@@ -0,0 +1,100 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { DXN, Obj, Type } from '@dxos/echo';
9
+
10
+ import * as Doc from './Doc';
11
+ import { AbstractStoreAdapter, type Batch } from './store-adapter';
12
+
13
+ const Canvas = Type.makeObject(DXN.make('com.example.type.canvas', '0.1.0'))(
14
+ Schema.Struct({
15
+ content: Schema.optional(Schema.Any),
16
+ }),
17
+ );
18
+
19
+ type Element = { id: string; value: string };
20
+
21
+ // Reads/mutates the element map at the accessor's (namespaced) path.
22
+ const elementMap = (accessor: Doc.Accessor): Record<string, Element> => Doc.getValue<Record<string, Element>>(accessor);
23
+ const mutateMap = (accessor: Doc.Accessor, mutate: (map: Record<string, Element>) => void): void =>
24
+ accessor.handle.change((doc: any) => {
25
+ let map = doc;
26
+ for (const key of accessor.path) {
27
+ map = map[key];
28
+ }
29
+ mutate(map);
30
+ });
31
+
32
+ // Minimal adapter that mirrors the document map into a local `Map`.
33
+ class TestAdapter extends AbstractStoreAdapter<Element> {
34
+ readonly store = new Map<string, Element>();
35
+ readonly updates: Batch<Element>[] = [];
36
+
37
+ override getElements(): Element[] {
38
+ return [...this.store.values()];
39
+ }
40
+
41
+ protected override onUpdate(batch: Batch<Element>): void {
42
+ this.updates.push(batch);
43
+ [...(batch.added ?? []), ...(batch.updated ?? [])].forEach((element) => this.store.set(element.id, element));
44
+ (batch.deleted ?? []).forEach((id) => this.store.delete(id));
45
+ }
46
+
47
+ write(batch: Batch<Element>): void {
48
+ this.updateDatabase(batch);
49
+ }
50
+ }
51
+
52
+ describe('AbstractStoreAdapter', () => {
53
+ test('seeds an empty document from the store', ({ expect }) => {
54
+ const obj = Obj.make(Canvas, { content: {} });
55
+ const accessor = Doc.createAccessor(obj, ['content']);
56
+
57
+ const adapter = new TestAdapter();
58
+ adapter.store.set('a', { id: 'a', value: '1' });
59
+ const dispose = adapter.open(accessor);
60
+
61
+ expect(elementMap(accessor).a).toEqual({ id: 'a', value: '1' });
62
+ dispose();
63
+ });
64
+
65
+ test('hydrates the store from a populated document', ({ expect }) => {
66
+ const obj = Obj.make(Canvas, { content: { a: { id: 'a', value: '1' } } });
67
+ const accessor = Doc.createAccessor(obj, ['content']);
68
+
69
+ const adapter = new TestAdapter();
70
+ const dispose = adapter.open(accessor);
71
+
72
+ expect(adapter.store.get('a')).toEqual({ id: 'a', value: '1' });
73
+ dispose();
74
+ });
75
+
76
+ test('writes store changes to the document and reflects external changes back', ({ expect }) => {
77
+ const obj = Obj.make(Canvas, { content: {} });
78
+ const accessor = Doc.createAccessor(obj, ['content']);
79
+
80
+ const adapter = new TestAdapter();
81
+ const dispose = adapter.open(accessor);
82
+
83
+ // store -> doc
84
+ adapter.write({ added: [{ id: 'a', value: '1' }] });
85
+ expect(elementMap(accessor).a).toEqual({ id: 'a', value: '1' });
86
+
87
+ // doc -> store (external mutation)
88
+ mutateMap(accessor, (map) => {
89
+ map.b = { id: 'b', value: '2' };
90
+ });
91
+ expect(adapter.store.get('b')).toEqual({ id: 'b', value: '2' });
92
+
93
+ // dispose stops propagation
94
+ dispose();
95
+ mutateMap(accessor, (map) => {
96
+ map.c = { id: 'c', value: '3' };
97
+ });
98
+ expect(adapter.store.has('c')).toBe(false);
99
+ });
100
+ });
@@ -0,0 +1,237 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { next as A } from '@automerge/automerge';
6
+
7
+ import { invariant } from '@dxos/invariant';
8
+ import { log } from '@dxos/log';
9
+ import { isNonNullable } from '@dxos/util';
10
+
11
+ import * as Doc from './Doc';
12
+ import { decode as defaultDecode, encode as defaultEncode, getDeep, rebasePath } from './record';
13
+
14
+ export type BaseElement = { id: string };
15
+
16
+ /**
17
+ * Batch of element changes.
18
+ */
19
+ export type Batch<Element extends BaseElement> = {
20
+ added?: Element[];
21
+ updated?: Element[];
22
+ deleted?: Element['id'][];
23
+ };
24
+
25
+ /**
26
+ * Accumulates pending local mutations.
27
+ */
28
+ export class Modified<Element extends BaseElement> {
29
+ readonly added = new Map<Element['id'], Element>();
30
+ readonly updated = new Map<Element['id'], Element>();
31
+ readonly deleted = new Set<Element['id']>();
32
+
33
+ batch(): Batch<Element> {
34
+ return {
35
+ added: Array.from(this.added.values()),
36
+ updated: Array.from(this.updated.values()),
37
+ deleted: Array.from(this.deleted.values()),
38
+ };
39
+ }
40
+
41
+ clear(): void {
42
+ this.added.clear();
43
+ this.updated.clear();
44
+ this.deleted.clear();
45
+ }
46
+ }
47
+
48
+ export type StoreAdapterOptions = {
49
+ readonly?: boolean;
50
+ /** Encode a model value to an Automerge record (defaults to a structural codec). */
51
+ encode?: (value: any) => any;
52
+ /** Decode an Automerge record to a model value. */
53
+ decode?: (value: any) => any;
54
+ };
55
+
56
+ /**
57
+ * Two-way sync between an external store and an id-keyed `Record<string, Element>` held at an
58
+ * accessor's path within an Automerge document. Subclasses bind a concrete store (e.g. tldraw,
59
+ * excalidraw) by implementing {@link getElements} / {@link onUpdate} and registering store
60
+ * listeners in {@link onOpen}.
61
+ */
62
+ // TODO(burdon): Make element id key configurable.
63
+ export abstract class AbstractStoreAdapter<Element extends BaseElement> {
64
+ #accessor?: Doc.Accessor<any>;
65
+ #lastHeads?: A.Heads;
66
+ #cleanup?: () => void;
67
+ readonly #readonly: boolean;
68
+ readonly #encode: (value: any) => any;
69
+ readonly #decode: (value: any) => any;
70
+
71
+ constructor(options: StoreAdapterOptions = {}) {
72
+ this.#readonly = options.readonly ?? false;
73
+ this.#encode = options.encode ?? defaultEncode;
74
+ this.#decode = options.decode ?? defaultDecode;
75
+ }
76
+
77
+ get isOpen(): boolean {
78
+ return !!this.#accessor;
79
+ }
80
+
81
+ get readonly(): boolean {
82
+ return this.#readonly;
83
+ }
84
+
85
+ /**
86
+ * Binds the adapter to the element map at `accessor.path`. Returns a dispose function; calling it
87
+ * (or {@link close}) tears down the subscription and any listeners registered in {@link onOpen}.
88
+ */
89
+ open(accessor: Doc.Accessor<any>): () => void {
90
+ invariant(accessor.path.length);
91
+ if (this.isOpen) {
92
+ this.close();
93
+ }
94
+
95
+ log('opening...', { path: accessor.path });
96
+ this.#accessor = accessor;
97
+ const onOpenCleanup = this.onOpen();
98
+
99
+ // Seed the document from the store, or hydrate the store from the document.
100
+ {
101
+ const map: Record<string, Element> = getDeep(accessor.handle.doc(), accessor.path) ?? {};
102
+ const records = Object.values(map);
103
+ if (records.length === 0) {
104
+ accessor.handle.change((doc) => {
105
+ const map: Record<string, Element> = getDeep(doc, accessor.path, true);
106
+ for (const record of this.getElements()) {
107
+ map[record.id] = this.#encode(record);
108
+ }
109
+ });
110
+ } else {
111
+ this.onUpdate({ updated: records.map((record) => this.#decode(record)) });
112
+ }
113
+ }
114
+
115
+ // Baseline heads after seeding so the first change diffs only post-open mutations (not full history).
116
+ this.#lastHeads = A.getHeads(accessor.handle.doc());
117
+
118
+ // Propagate document mutations (local and remote) into the store.
119
+ const updateModel = () => {
120
+ const doc = accessor.handle.doc();
121
+ const map: Record<string, Element> = getDeep(doc, accessor.path);
122
+
123
+ const updated = new Set<Element['id']>();
124
+ const deleted = new Set<Element['id']>();
125
+
126
+ const currentHeads = A.getHeads(doc);
127
+ const diff = A.equals(this.#lastHeads, currentHeads) ? [] : A.diff(doc, this.#lastHeads ?? [], currentHeads);
128
+ diff.forEach((patch) => {
129
+ const relativePath = rebasePath(patch.path, accessor.path);
130
+ if (!relativePath) {
131
+ return;
132
+ }
133
+
134
+ // A patch on the map root (e.g. initial assignment) touches every element.
135
+ if (relativePath.length === 0) {
136
+ for (const id of Object.keys(map)) {
137
+ updated.add(id as Element['id']);
138
+ }
139
+ return;
140
+ }
141
+
142
+ switch (patch.action) {
143
+ case 'del': {
144
+ if (relativePath.length === 1) {
145
+ deleted.add(relativePath[0] as Element['id']);
146
+ break;
147
+ }
148
+ }
149
+ // eslint-disable-next-line no-fallthrough
150
+ case 'put':
151
+ case 'insert':
152
+ case 'inc':
153
+ case 'splice': {
154
+ updated.add(relativePath[0] as Element['id']);
155
+ break;
156
+ }
157
+ default:
158
+ log.warn('did not process patch', { patch, path: accessor.path });
159
+ }
160
+ });
161
+
162
+ if (updated.size || deleted.size) {
163
+ this.onUpdate({
164
+ updated: Array.from(updated)
165
+ .map((id) => this.#decode(map[id]))
166
+ .filter(isNonNullable), // Elements modified then eventually removed.
167
+ deleted: Array.from(deleted),
168
+ });
169
+ }
170
+
171
+ this.#lastHeads = currentHeads;
172
+ };
173
+
174
+ accessor.handle.addListener('change', updateModel);
175
+ this.#cleanup = () => {
176
+ accessor.handle.removeListener('change', updateModel);
177
+ onOpenCleanup?.();
178
+ };
179
+
180
+ log('open');
181
+ return () => this.close();
182
+ }
183
+
184
+ close(): void {
185
+ if (!this.isOpen) {
186
+ return;
187
+ }
188
+
189
+ log('closing...');
190
+ this.onClose();
191
+ this.#cleanup?.();
192
+ this.#cleanup = undefined;
193
+ this.#accessor = undefined;
194
+ this.#lastHeads = undefined;
195
+ log('closed');
196
+ }
197
+
198
+ /**
199
+ * Writes a batch of element changes to the document.
200
+ */
201
+ protected updateDatabase(batch: Batch<Element>): void {
202
+ invariant(this.isOpen);
203
+ if (this.#readonly) {
204
+ log.warn('attempting to update read-only store');
205
+ return;
206
+ }
207
+
208
+ const accessor = this.#accessor!;
209
+ accessor.handle.change((doc) => {
210
+ const map: Record<string, Element> = getDeep(doc, accessor.path, true);
211
+ this.#removeDeleted(batch, batch.added)?.forEach((element) => (map[element.id] = this.#encode(element)));
212
+ this.#removeDeleted(batch, batch.updated)?.forEach((element) => (map[element.id] = this.#encode(element)));
213
+ batch.deleted?.forEach((id) => delete map[id]);
214
+ });
215
+ }
216
+
217
+ #removeDeleted(batch: Batch<Element>, elements?: Element[]): Element[] | undefined {
218
+ return batch.deleted ? elements?.filter((element) => !batch.deleted!.includes(element.id)) : elements;
219
+ }
220
+
221
+ /**
222
+ * Returns all elements currently in the store.
223
+ */
224
+ abstract getElements(): readonly Element[];
225
+
226
+ /**
227
+ * Applies document changes to the store.
228
+ */
229
+ protected abstract onUpdate(batch: Batch<Element>): void;
230
+
231
+ /**
232
+ * Called when the adapter opens; return a cleanup function to run on close (e.g. to remove store listeners).
233
+ */
234
+ protected onOpen(): (() => void) | void {}
235
+
236
+ protected onClose(): void {}
237
+ }