@dxos/echo-atom 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/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ MIT License
2
+ Copyright (c) 2025 DXOS
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+
6
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # @dxos/echo-atom
2
+
3
+ Effect Atom wrappers for ECHO objects with automatic subscriptions. Provides object reactivity without signals by integrating ECHO objects with the Effect Atom system.
4
+
5
+ ## Overview
6
+
7
+ `echo-atom` bridges ECHO objects and Effect Atoms, enabling reactive programming patterns without relying on signals. It provides:
8
+
9
+ - **Object-level reactivity**: Subscribe to entire Echo objects or specific properties
10
+ - **Automatic subscriptions**: Direct integration with Echo's `Obj.subscribe` system
11
+ - **Type-safe APIs**: Full TypeScript support with proper inference
12
+ - **React integration**: See `@dxos/echo-react` for React hooks
13
+ - **SolidJS integration**: See `@dxos/echo-solid` for SolidJS hooks
14
+
15
+ ## Design Goals
16
+
17
+ 1. **No Signals Dependency**: Provides reactivity without requiring signal-based reactivity systems
18
+ 2. **Automatic Subscriptions**: Atoms automatically subscribe to Echo object changes via `Obj.subscribe`
19
+ 3. **Granular Updates**: Subscribe to entire objects or specific properties for efficient re-renders
20
+ 4. **Type Safety**: Full TypeScript support with proper generic constraints
21
+ 5. **Framework Agnostic**: Core functionality works with any framework; React and SolidJS hooks provided separately
22
+
23
+ ## Core Concepts
24
+
25
+ ### Atoms
26
+
27
+ Atoms wrap Echo objects and their properties, storing metadata about the object and path being watched:
28
+
29
+ ```typescript
30
+ interface AtomValue<T> {
31
+ readonly obj: Entity.Unknown; // The Echo object being watched
32
+ readonly path: KeyPath; // Property path (empty for entire object)
33
+ readonly value: T; // Current value
34
+ }
35
+ ```
36
+
37
+ ### Registry
38
+
39
+ The Effect Atom Registry manages atom lifecycle and subscriptions. Each registry instance maintains its own subscription state.
40
+
41
+ ### Reactive Updates
42
+
43
+ When you mutate an Echo object directly, the changes automatically flow through `Obj.subscribe` to update the atom and notify all subscribers.
44
+
45
+ ## Usage
46
+
47
+ ### Basic Usage
48
+
49
+ ```typescript
50
+ import * as Registry from '@effect-atom/atom/Registry';
51
+ import { AtomObj } from '@dxos/echo-atom';
52
+ import { Obj } from '@dxos/echo';
53
+ import { TestSchema } from '@dxos/echo/testing';
54
+
55
+ // Create a registry
56
+ const registry = Registry.make();
57
+
58
+ // Create an Echo object
59
+ const person = Obj.make(TestSchema.Person, {
60
+ name: 'Alice',
61
+ email: 'alice@example.com'
62
+ });
63
+
64
+ // Create an atom for the entire object
65
+ const personAtom = AtomObj.make(person);
66
+
67
+ // Create an atom for a specific property
68
+ const nameAtom = AtomObj.makeProperty(person, 'name');
69
+
70
+ // Get current values from the registry
71
+ const currentPerson = registry.get(personAtom).value;
72
+ const currentName = registry.get(nameAtom).value;
73
+
74
+ // Subscribe to updates using the registry
75
+ const unsubscribe = registry.subscribe(nameAtom, () => {
76
+ const newName = registry.get(nameAtom).value;
77
+ console.log('Name changed:', newName);
78
+ }, { immediate: true });
79
+
80
+ // Update the object directly - the atom will automatically update
81
+ person.name = 'Bob';
82
+ person.email = 'bob@example.com';
83
+
84
+ // Clean up
85
+ unsubscribe();
86
+ ```
87
+
88
+ ### React Integration
89
+
90
+ For React applications, use the hooks from `@dxos/echo-react`:
91
+
92
+ ```tsx
93
+ import { useObject } from '@dxos/echo-react';
94
+ import { RegistryContext } from '@effect-atom/atom-react';
95
+ import * as Registry from '@effect-atom/atom/Registry';
96
+
97
+ // Wrap your app with RegistryContext
98
+ function App() {
99
+ const registry = useMemo(() => Registry.make(), []);
100
+
101
+ return (
102
+ <RegistryContext.Provider value={registry}>
103
+ <YourComponents />
104
+ </RegistryContext.Provider>
105
+ );
106
+ }
107
+
108
+ // In your components
109
+ function PersonView({ person }: { person: Person }) {
110
+ // Subscribe to entire object (returns [value, updateCallback])
111
+ const [currentPerson, updatePerson] = useObject(person);
112
+
113
+ // Subscribe to specific property (returns [value, updateCallback])
114
+ const [name, updateName] = useObject(person, 'name');
115
+
116
+ return (
117
+ <div>
118
+ <input
119
+ value={name}
120
+ onChange={(e) => updateName(e.target.value)}
121
+ />
122
+ <button onClick={() => updatePerson(p => { p.email = 'new@example.com'; })}>
123
+ Update Email
124
+ </button>
125
+ </div>
126
+ );
127
+ }
128
+ ```
129
+
130
+ ### SolidJS Integration
131
+
132
+ For SolidJS applications, use the hooks from `@dxos/echo-solid`:
133
+
134
+ ```tsx
135
+ import { useObject } from '@dxos/echo-solid';
136
+ import { RegistryContext } from '@dxos/effect-atom-solid';
137
+ import * as Registry from '@effect-atom/atom/Registry';
138
+
139
+ // Wrap your app with RegistryContext
140
+ function App() {
141
+ const registry = Registry.make();
142
+
143
+ return (
144
+ <RegistryContext.Provider value={registry}>
145
+ <YourComponents />
146
+ </RegistryContext.Provider>
147
+ );
148
+ }
149
+
150
+ // In your components
151
+ function PersonView(props: { person: Person }) {
152
+ // Subscribe to entire object (returns [accessor, updateCallback])
153
+ const [currentPerson, updatePerson] = useObject(() => props.person);
154
+
155
+ // Subscribe to specific property (returns [accessor, updateCallback])
156
+ const [name, updateName] = useObject(() => props.person, 'name');
157
+
158
+ return (
159
+ <div>
160
+ <input
161
+ value={name()}
162
+ onInput={(e) => updateName(e.target.value)}
163
+ />
164
+ <button onClick={() => updatePerson(p => { p.email = 'new@example.com'; })}>
165
+ Update Email
166
+ </button>
167
+ </div>
168
+ );
169
+ }
170
+ ```
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@dxos/echo-atom",
3
+ "version": "0.0.0",
4
+ "description": "Effect Atom wrappers for ECHO objects with explicit subscriptions.",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "license": "MIT",
8
+ "author": "DXOS.org",
9
+ "sideEffects": false,
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "source": "./src/index.ts",
14
+ "types": "./dist/types/src/index.d.ts",
15
+ "browser": "./dist/lib/browser/index.mjs",
16
+ "node": "./dist/lib/node-esm/index.mjs"
17
+ }
18
+ },
19
+ "types": "dist/types/src/index.d.ts",
20
+ "typesVersions": {
21
+ "*": {}
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "dependencies": {
28
+ "@effect-atom/atom": "^0.4.10",
29
+ "lodash.isequal": "^4.5.0",
30
+ "@dxos/echo-db": "0.8.3",
31
+ "@dxos/live-object": "0.8.3",
32
+ "@dxos/echo": "0.8.3",
33
+ "@dxos/invariant": "0.8.3",
34
+ "@dxos/util": "0.8.3"
35
+ },
36
+ "devDependencies": {
37
+ "@types/lodash.isequal": "^4.5.0",
38
+ "@dxos/test-utils": "0.8.3",
39
+ "@dxos/random": "0.8.3"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
@@ -0,0 +1,152 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Registry from '@effect-atom/atom/Registry';
6
+ import { describe, expect, test } from 'vitest';
7
+
8
+ import { Obj } from '@dxos/echo';
9
+ import { TestSchema } from '@dxos/echo/testing';
10
+ import { createObject } from '@dxos/echo-db';
11
+
12
+ import * as AtomObj from './atom';
13
+
14
+ describe('Echo Atom - Basic Functionality', () => {
15
+ test('AtomObj.make creates atom for entire object', () => {
16
+ const obj = createObject(
17
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
18
+ );
19
+
20
+ const registry = Registry.make();
21
+ const atom = AtomObj.make(obj);
22
+
23
+ // Returns a snapshot (plain object), not the Echo object itself.
24
+ const snapshot = registry.get(atom);
25
+ expect(snapshot).not.toBe(obj);
26
+ expect(snapshot.name).toBe('Test');
27
+ expect(snapshot.username).toBe('test');
28
+ expect(snapshot.email).toBe('test@example.com');
29
+ });
30
+
31
+ test('AtomObj.makeProperty creates atom for specific property', () => {
32
+ const obj = createObject(
33
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
34
+ );
35
+
36
+ const registry = Registry.make();
37
+ const atom = AtomObj.makeProperty(obj, 'name');
38
+
39
+ const atomValue = registry.get(atom);
40
+ expect(atomValue).toBe('Test');
41
+ });
42
+
43
+ test('AtomObj.makeProperty is type-safe', () => {
44
+ const obj = createObject(
45
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
46
+ );
47
+
48
+ const registry = Registry.make();
49
+ // This should compile and work.
50
+ const nameAtom = AtomObj.makeProperty(obj, 'name');
51
+ const emailAtom = AtomObj.makeProperty(obj, 'email');
52
+
53
+ expect(registry.get(nameAtom)).toBe('Test');
54
+ expect(registry.get(emailAtom)).toBe('test@example.com');
55
+ });
56
+
57
+ test('atom updates when object is mutated via Obj.change', () => {
58
+ const obj = createObject(
59
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
60
+ );
61
+
62
+ const registry = Registry.make();
63
+ const atom = AtomObj.makeProperty(obj, 'name');
64
+
65
+ let updateCount = 0;
66
+ registry.subscribe(
67
+ atom,
68
+ () => {
69
+ updateCount++;
70
+ },
71
+ { immediate: true },
72
+ );
73
+
74
+ // Mutate object via Obj.change.
75
+ Obj.change(obj, (o) => {
76
+ o.name = 'Updated';
77
+ });
78
+
79
+ // Subscription should have fired: immediate + update.
80
+ expect(updateCount).toBe(2);
81
+
82
+ // Atom should reflect the change.
83
+ expect(registry.get(atom)).toBe('Updated');
84
+ expect(obj.name).toBe('Updated');
85
+ });
86
+
87
+ test('property atom supports updater pattern via Obj.change', () => {
88
+ const obj = createObject(
89
+ Obj.make(TestSchema.Task, {
90
+ title: 'Task',
91
+ }),
92
+ );
93
+
94
+ const registry = Registry.make();
95
+ const atom = AtomObj.makeProperty(obj, 'title');
96
+
97
+ let updateCount = 0;
98
+ registry.subscribe(
99
+ atom,
100
+ () => {
101
+ updateCount++;
102
+ },
103
+ { immediate: true },
104
+ );
105
+
106
+ // Update through Obj.change.
107
+ Obj.change(obj, (o) => {
108
+ o.title = (o.title ?? '') + ' Updated';
109
+ });
110
+
111
+ // Subscription should have fired: immediate + update.
112
+ expect(updateCount).toBe(2);
113
+
114
+ // Atom should reflect the change.
115
+ expect(registry.get(atom)).toBe('Task Updated');
116
+ expect(obj.title).toBe('Task Updated');
117
+ });
118
+
119
+ test('atoms work for plain live objects (Obj.make without createObject)', () => {
120
+ // This test explicitly verifies that reactivity works for plain live objects
121
+ // created with just Obj.make() - no createObject() or database required.
122
+ // This is the simplest form of reactive object.
123
+ const obj = Obj.make(TestSchema.Person, { name: 'Standalone', username: 'test', email: 'test@example.com' });
124
+
125
+ // Verify object has an id (Obj.make generates one).
126
+ expect(obj.id).toBeDefined();
127
+
128
+ const registry = Registry.make();
129
+ const objectAtom = AtomObj.make(obj);
130
+ const propertyAtom = AtomObj.makeProperty(obj, 'name');
131
+
132
+ let objectUpdateCount = 0;
133
+ let propertyUpdateCount = 0;
134
+
135
+ registry.subscribe(objectAtom, () => objectUpdateCount++, { immediate: true });
136
+ registry.subscribe(propertyAtom, () => propertyUpdateCount++, { immediate: true });
137
+
138
+ expect(objectUpdateCount).toBe(1);
139
+ expect(propertyUpdateCount).toBe(1);
140
+
141
+ // Mutate the standalone object.
142
+ obj.name = 'Updated Standalone';
143
+
144
+ // Both atoms should have received updates.
145
+ expect(objectUpdateCount).toBe(2);
146
+ expect(propertyUpdateCount).toBe(2);
147
+
148
+ // Verify values are correct.
149
+ expect(registry.get(objectAtom)!.name).toBe('Updated Standalone');
150
+ expect(registry.get(propertyAtom)).toBe('Updated Standalone');
151
+ });
152
+ });
package/src/atom.ts ADDED
@@ -0,0 +1,114 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Atom from '@effect-atom/atom/Atom';
6
+ import isEqual from 'lodash.isequal';
7
+
8
+ import { type Entity, Obj, Ref } from '@dxos/echo';
9
+ import { assertArgument } from '@dxos/invariant';
10
+ import { getSnapshot, isLiveObject } from '@dxos/live-object';
11
+
12
+ import { loadRefTarget } from './ref-utils';
13
+
14
+ /**
15
+ * Create a read-only atom for a reactive object or ref.
16
+ * Works with Echo objects, plain live objects (from Obj.make), and Refs.
17
+ * Returns immutable snapshots of the object data.
18
+ * The atom updates automatically when the object is mutated.
19
+ * For refs, automatically handles async loading.
20
+ *
21
+ * @param objOrRef - The reactive object or ref to create an atom for, or undefined.
22
+ * @returns An atom that returns the object snapshot, or undefined if not loaded/undefined.
23
+ */
24
+ export function make<T extends Entity.Unknown>(objOrRef: T | Ref.Ref<T>): Atom.Atom<T | undefined>;
25
+ export function make<T extends Entity.Unknown>(objOrRef: T | Ref.Ref<T> | undefined): Atom.Atom<T | undefined>;
26
+ export function make<T extends Entity.Unknown>(objOrRef: T | Ref.Ref<T> | undefined): Atom.Atom<T | undefined> {
27
+ if (objOrRef === undefined) {
28
+ return Atom.make<T | undefined>(() => undefined);
29
+ }
30
+
31
+ // Handle Ref inputs.
32
+ if (Ref.isRef(objOrRef)) {
33
+ return makeFromRef(objOrRef as Ref.Ref<T>);
34
+ }
35
+
36
+ // At this point, objOrRef is definitely T (not a Ref).
37
+ const obj = objOrRef as T;
38
+ assertArgument(isLiveObject(obj), 'obj', 'Object must be a reactive object');
39
+
40
+ return Atom.make<T | undefined>((get) => {
41
+ const unsubscribe = Obj.subscribe(obj, () => {
42
+ get.setSelf(getSnapshot(obj) as T);
43
+ });
44
+
45
+ get.addFinalizer(() => unsubscribe());
46
+
47
+ return getSnapshot(obj) as T;
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Internal helper to create an atom from a Ref.
53
+ * Handles async loading and subscribes to the target for reactive updates.
54
+ */
55
+ const makeFromRef = <T extends Entity.Unknown>(ref: Ref.Ref<T>): Atom.Atom<T | undefined> => {
56
+ return Atom.make<T | undefined>((get) => {
57
+ let unsubscribeTarget: (() => void) | undefined;
58
+
59
+ const setupTargetSubscription = (target: T): T => {
60
+ unsubscribeTarget?.();
61
+ unsubscribeTarget = Obj.subscribe(target, () => {
62
+ get.setSelf(getSnapshot(target) as T);
63
+ });
64
+ return getSnapshot(target) as T;
65
+ };
66
+
67
+ get.addFinalizer(() => unsubscribeTarget?.());
68
+
69
+ return loadRefTarget(ref, get, setupTargetSubscription);
70
+ });
71
+ };
72
+
73
+ /**
74
+ * Create a read-only atom for a specific property of a reactive object.
75
+ * Works with both Echo objects (from createObject) and plain live objects (from Obj.make).
76
+ * The atom updates automatically when the property is mutated.
77
+ * Only fires updates when the property value actually changes.
78
+ *
79
+ * @param obj - The reactive object to create an atom for, or undefined.
80
+ * @param key - The property key to subscribe to.
81
+ * @returns An atom that returns the property value, or undefined if obj is undefined.
82
+ */
83
+ export function makeProperty<T extends Entity.Unknown, K extends keyof T>(obj: T, key: K): Atom.Atom<T[K]>;
84
+ export function makeProperty<T extends Entity.Unknown, K extends keyof T>(
85
+ obj: T | undefined,
86
+ key: K,
87
+ ): Atom.Atom<T[K] | undefined>;
88
+ export function makeProperty<T extends Entity.Unknown, K extends keyof T>(
89
+ obj: T | undefined,
90
+ key: K,
91
+ ): Atom.Atom<T[K] | undefined> {
92
+ if (obj === undefined) {
93
+ return Atom.make<T[K] | undefined>(() => undefined);
94
+ }
95
+
96
+ assertArgument(isLiveObject(obj), 'obj', 'Object must be a reactive object');
97
+ assertArgument(key in obj, 'key', 'Property must exist on object');
98
+
99
+ return Atom.make<T[K] | undefined>((get) => {
100
+ let previousValue = obj[key];
101
+
102
+ const unsubscribe = Obj.subscribe(obj, () => {
103
+ const newValue = obj[key];
104
+ if (!isEqual(previousValue, newValue)) {
105
+ previousValue = newValue;
106
+ get.setSelf(newValue);
107
+ }
108
+ });
109
+
110
+ get.addFinalizer(() => unsubscribe());
111
+
112
+ return obj[key];
113
+ });
114
+ }
@@ -0,0 +1,129 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Registry from '@effect-atom/atom/Registry';
6
+ import { describe, expect, test } from 'vitest';
7
+
8
+ import { Obj } from '@dxos/echo';
9
+ import { TestSchema } from '@dxos/echo/testing';
10
+ import { createObject } from '@dxos/echo-db';
11
+
12
+ import * as AtomObj from './atom';
13
+
14
+ describe('Echo Atom - Batch Updates', () => {
15
+ test('multiple updates to same object atom in single Obj.change fire single update', () => {
16
+ const obj = createObject(
17
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
18
+ );
19
+
20
+ const registry = Registry.make();
21
+ const atom = AtomObj.make(obj);
22
+
23
+ let updateCount = 0;
24
+ registry.subscribe(
25
+ atom,
26
+ () => {
27
+ updateCount++;
28
+ },
29
+ { immediate: true },
30
+ );
31
+
32
+ // Get initial count (immediate: true causes initial update).
33
+ const initialCount = updateCount;
34
+ expect(initialCount).toBe(1); // Verify immediate update fired.
35
+
36
+ // Make multiple updates to the same object in a single Obj.change call.
37
+ Obj.change(obj, (o) => {
38
+ o.name = 'Updated1';
39
+ o.email = 'updated@example.com';
40
+ o.username = 'updated';
41
+ });
42
+
43
+ // Should have fired once for initial + once for the Obj.change (not once per property update).
44
+ expect(updateCount).toBe(2);
45
+
46
+ // Verify final state.
47
+ const finalValue = registry.get(atom);
48
+ expect(finalValue.name).toBe('Updated1');
49
+ expect(finalValue.email).toBe('updated@example.com');
50
+ expect(finalValue.username).toBe('updated');
51
+ });
52
+
53
+ test('multiple separate Obj.change calls fire separate updates', () => {
54
+ const obj = createObject(
55
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
56
+ );
57
+
58
+ const registry = Registry.make();
59
+ const atom = AtomObj.make(obj);
60
+
61
+ let updateCount = 0;
62
+ registry.subscribe(
63
+ atom,
64
+ () => {
65
+ updateCount++;
66
+ },
67
+ { immediate: true },
68
+ );
69
+
70
+ // Get initial count (immediate: true causes initial update).
71
+ const initialCount = updateCount;
72
+ expect(initialCount).toBe(1);
73
+
74
+ // Make multiple separate Obj.change calls.
75
+ Obj.change(obj, (o) => {
76
+ o.name = 'Updated1';
77
+ });
78
+ Obj.change(obj, (o) => {
79
+ o.email = 'updated@example.com';
80
+ });
81
+ Obj.change(obj, (o) => {
82
+ o.username = 'updated';
83
+ });
84
+
85
+ // Should have fired once for initial + once per Obj.change call.
86
+ expect(updateCount).toBe(4);
87
+
88
+ // Verify final state.
89
+ const finalValue = registry.get(atom);
90
+ expect(finalValue.name).toBe('Updated1');
91
+ expect(finalValue.email).toBe('updated@example.com');
92
+ expect(finalValue.username).toBe('updated');
93
+ });
94
+
95
+ test('multiple updates to same property atom in single Obj.change fire single update', () => {
96
+ const obj = createObject(
97
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
98
+ );
99
+
100
+ const registry = Registry.make();
101
+ const atom = AtomObj.makeProperty(obj, 'name');
102
+
103
+ let updateCount = 0;
104
+ registry.subscribe(
105
+ atom,
106
+ () => {
107
+ updateCount++;
108
+ },
109
+ { immediate: true },
110
+ );
111
+
112
+ // Get initial count (immediate: true causes initial update).
113
+ const initialCount = updateCount;
114
+ expect(initialCount).toBe(1);
115
+
116
+ // Make multiple updates to the same property in a single Obj.change call.
117
+ Obj.change(obj, (o) => {
118
+ o.name = 'Updated1';
119
+ o.name = 'Updated2';
120
+ o.name = 'Updated3';
121
+ });
122
+
123
+ // Should have fired once for initial + once for the Obj.change (not once per assignment).
124
+ expect(updateCount).toBe(2);
125
+
126
+ // Verify final state.
127
+ expect(registry.get(atom)).toBe('Updated3');
128
+ });
129
+ });
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * as AtomObj from './atom';
6
+ export * as AtomQuery from './query-atom';
7
+ export * as AtomRef from './ref-atom';
@@ -0,0 +1,200 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Registry from '@effect-atom/atom/Registry';
6
+ import * as Schema from 'effect/Schema';
7
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest';
8
+
9
+ import { Obj, type QueryResult, Type } from '@dxos/echo';
10
+ import { type EchoDatabase, Filter, Query } from '@dxos/echo-db';
11
+ import { EchoTestBuilder } from '@dxos/echo-db/testing';
12
+
13
+ import * as AtomQuery from './query-atom';
14
+
15
+ /**
16
+ * Test schema for query-atom tests.
17
+ */
18
+ const TestItem = Schema.Struct({
19
+ name: Schema.String,
20
+ value: Schema.Number,
21
+ }).pipe(
22
+ Type.Obj({
23
+ typename: 'example.com/type/TestItem',
24
+ version: '0.1.0',
25
+ }),
26
+ );
27
+ type TestItem = Schema.Schema.Type<typeof TestItem>;
28
+
29
+ describe('AtomQuery', () => {
30
+ let testBuilder: EchoTestBuilder;
31
+ let db: EchoDatabase;
32
+ let registry: Registry.Registry;
33
+
34
+ beforeEach(async () => {
35
+ testBuilder = await new EchoTestBuilder().open();
36
+ const { db: database } = await testBuilder.createDatabase({ types: [TestItem] });
37
+ db = database;
38
+ registry = Registry.make();
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await testBuilder.close();
43
+ });
44
+
45
+ test('creates atom with initial results', async () => {
46
+ db.add(Obj.make(TestItem, { name: 'Object 1', value: 100 }));
47
+ db.add(Obj.make(TestItem, { name: 'Object 2', value: 100 }));
48
+ await db.flush({ indexes: true });
49
+
50
+ const queryResult: QueryResult.QueryResult<TestItem> = db.query(
51
+ Query.select(Filter.type(TestItem, { value: 100 })),
52
+ );
53
+ await queryResult.run();
54
+
55
+ const atom = AtomQuery.fromQuery(queryResult);
56
+ const results = registry.get(atom);
57
+
58
+ expect(results).toHaveLength(2);
59
+ expect(results.map((r) => r.name).sort()).toEqual(['Object 1', 'Object 2']);
60
+ });
61
+
62
+ test('registry.subscribe fires on QueryResult changes', async () => {
63
+ db.add(Obj.make(TestItem, { name: 'Initial', value: 200 }));
64
+ await db.flush({ indexes: true });
65
+
66
+ const queryResult: QueryResult.QueryResult<TestItem> = db.query(
67
+ Query.select(Filter.type(TestItem, { value: 200 })),
68
+ );
69
+ await queryResult.run();
70
+
71
+ const atom = AtomQuery.fromQuery(queryResult);
72
+
73
+ // Get initial results.
74
+ const initialResults = registry.get(atom);
75
+ expect(initialResults).toHaveLength(1);
76
+
77
+ // Subscribe to atom updates.
78
+ let updateCount = 0;
79
+ let latestResults: TestItem[] = [];
80
+ registry.subscribe(atom, () => {
81
+ updateCount++;
82
+ latestResults = registry.get(atom);
83
+ });
84
+
85
+ // Add a new object that matches the query.
86
+ db.add(Obj.make(TestItem, { name: 'New Object', value: 200 }));
87
+ await db.flush({ indexes: true, updates: true });
88
+
89
+ // Subscription should have fired.
90
+ expect(updateCount).toBeGreaterThan(0);
91
+ expect(latestResults).toHaveLength(2);
92
+ });
93
+
94
+ test('registry.subscribe fires when objects are removed', async () => {
95
+ const obj1 = db.add(Obj.make(TestItem, { name: 'Object 1', value: 300 }));
96
+ db.add(Obj.make(TestItem, { name: 'Object 2', value: 300 }));
97
+ await db.flush({ indexes: true });
98
+
99
+ const queryResult: QueryResult.QueryResult<TestItem> = db.query(
100
+ Query.select(Filter.type(TestItem, { value: 300 })),
101
+ );
102
+ await queryResult.run();
103
+
104
+ const atom = AtomQuery.fromQuery(queryResult);
105
+
106
+ // Get initial results.
107
+ const initialResults = registry.get(atom);
108
+ expect(initialResults).toHaveLength(2);
109
+
110
+ // Subscribe to atom updates.
111
+ let updateCount = 0;
112
+ let latestResults: TestItem[] = [];
113
+ registry.subscribe(atom, () => {
114
+ updateCount++;
115
+ latestResults = registry.get(atom);
116
+ });
117
+
118
+ // Remove an object.
119
+ db.remove(obj1);
120
+ await db.flush({ indexes: true, updates: true });
121
+
122
+ // Subscription should have fired.
123
+ expect(updateCount).toBeGreaterThan(0);
124
+ expect(latestResults).toHaveLength(1);
125
+ expect(latestResults[0].name).toBe('Object 2');
126
+ });
127
+
128
+ test('unsubscribing from registry stops receiving updates', async () => {
129
+ db.add(Obj.make(TestItem, { name: 'Initial', value: 400 }));
130
+ await db.flush({ indexes: true });
131
+
132
+ const queryResult: QueryResult.QueryResult<TestItem> = db.query(
133
+ Query.select(Filter.type(TestItem, { value: 400 })),
134
+ );
135
+ await queryResult.run();
136
+
137
+ const atom = AtomQuery.fromQuery(queryResult);
138
+
139
+ // Initialize the atom by getting its value first.
140
+ const initialResults = registry.get(atom);
141
+ expect(initialResults).toHaveLength(1);
142
+
143
+ // Subscribe to atom updates.
144
+ let updateCount = 0;
145
+ const unsubscribe = registry.subscribe(atom, () => {
146
+ updateCount++;
147
+ });
148
+
149
+ // Add object and verify subscription fires.
150
+ db.add(Obj.make(TestItem, { name: 'Object 2', value: 400 }));
151
+ await db.flush({ indexes: true, updates: true });
152
+ const countAfterFirstAdd = updateCount;
153
+ expect(countAfterFirstAdd).toBeGreaterThan(0);
154
+
155
+ // Unsubscribe.
156
+ unsubscribe();
157
+
158
+ // Add another object.
159
+ db.add(Obj.make(TestItem, { name: 'Object 3', value: 400 }));
160
+ await db.flush({ indexes: true, updates: true });
161
+
162
+ // Update count should not have changed after unsubscribe.
163
+ expect(updateCount).toBe(countAfterFirstAdd);
164
+ });
165
+
166
+ test('works with empty query results', async () => {
167
+ const queryResult: QueryResult.QueryResult<TestItem> = db.query(
168
+ Query.select(Filter.type(TestItem, { value: 999 })),
169
+ );
170
+ await queryResult.run();
171
+
172
+ const atom = AtomQuery.fromQuery(queryResult);
173
+ const results = registry.get(atom);
174
+
175
+ expect(results).toHaveLength(0);
176
+ });
177
+
178
+ test('multiple atoms from same query share underlying subscription', async () => {
179
+ db.add(Obj.make(TestItem, { name: 'Object', value: 500 }));
180
+ await db.flush({ indexes: true });
181
+
182
+ const queryResult: QueryResult.QueryResult<TestItem> = db.query(
183
+ Query.select(Filter.type(TestItem, { value: 500 })),
184
+ );
185
+ await queryResult.run();
186
+
187
+ // Create two atoms from the same query result.
188
+ const atom1 = AtomQuery.fromQuery(queryResult);
189
+ const atom2 = AtomQuery.fromQuery(queryResult);
190
+
191
+ // Both should return the same results.
192
+ const results1 = registry.get(atom1);
193
+ const results2 = registry.get(atom2);
194
+
195
+ expect(results1).toHaveLength(1);
196
+ expect(results2).toHaveLength(1);
197
+ expect(results1[0].name).toBe('Object');
198
+ expect(results2[0].name).toBe('Object');
199
+ });
200
+ });
@@ -0,0 +1,121 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Atom } from '@effect-atom/atom';
6
+
7
+ import { DXN, Database, type Entity, type Filter, Query, type QueryResult } from '@dxos/echo';
8
+ import { WeakDictionary } from '@dxos/util';
9
+
10
+ /**
11
+ * Create a self-updating atom from an existing QueryResult.
12
+ * Internally subscribes to queryResult and uses get.setSelf to update.
13
+ * Cleanup is handled via get.addFinalizer.
14
+ *
15
+ * Note: This creates a new atom each time. For memoization, use `make` instead.
16
+ *
17
+ * @param queryResult - The QueryResult to wrap.
18
+ * @returns An atom that automatically updates when query results change.
19
+ */
20
+ export const fromQuery = <T extends Entity.Unknown>(queryResult: QueryResult.QueryResult<T>): Atom.Atom<T[]> =>
21
+ Atom.make((get) => {
22
+ // TODO(wittjosiah): Consider subscribing to individual objects here as well, and grabbing their snapshots.
23
+ // Subscribe to QueryResult changes.
24
+ const unsubscribe = queryResult.subscribe(() => {
25
+ get.setSelf(queryResult.results);
26
+ });
27
+
28
+ // Register cleanup for when atom is no longer used.
29
+ get.addFinalizer(unsubscribe);
30
+
31
+ return queryResult.results;
32
+ });
33
+
34
+ // Registry: key → Queryable (WeakRef with auto-cleanup when GC'd).
35
+ const queryableRegistry = new WeakDictionary<string, Database.Queryable>();
36
+
37
+ // Atom.family keyed by "identifier:serializedAST".
38
+ const queryFamily = Atom.family((key: string) => {
39
+ // Parse key outside Atom.make - runs once per key.
40
+ const separatorIndex = key.indexOf(':');
41
+ const identifier = key.slice(0, separatorIndex);
42
+ const serializedAst = key.slice(separatorIndex + 1);
43
+
44
+ // Get queryable outside Atom.make - keeps Queryable alive via closure.
45
+ const queryable = queryableRegistry.get(identifier);
46
+ if (!queryable) {
47
+ return Atom.make(() => [] as Entity.Unknown[]);
48
+ }
49
+
50
+ // Create query outside Atom.make - runs once, not on every recompute.
51
+ const ast = JSON.parse(serializedAst);
52
+ const queryResult = queryable.query(Query.fromAst(ast)) as QueryResult.QueryResult<Entity.Unknown>;
53
+
54
+ return Atom.make((get) => {
55
+ const unsubscribe = queryResult.subscribe(() => {
56
+ get.setSelf(queryResult.results);
57
+ });
58
+ get.addFinalizer(unsubscribe);
59
+
60
+ return queryResult.results;
61
+ });
62
+ });
63
+
64
+ /**
65
+ * Derive a stable identifier from a Queryable.
66
+ * Supports Database (spaceId), Queue (dxn), and objects with id.
67
+ */
68
+ const getQueryableIdentifier = (queryable: Database.Queryable): string => {
69
+ // Database: use spaceId.
70
+ if (Database.isDatabase(queryable)) {
71
+ return queryable.spaceId;
72
+ }
73
+ // Queue or similar: use dxn if it's a DXN instance.
74
+ if ('dxn' in queryable && queryable.dxn instanceof DXN) {
75
+ return queryable.dxn.toString();
76
+ }
77
+ // Fallback: use id if it's a string.
78
+ if ('id' in queryable && typeof queryable.id === 'string') {
79
+ return queryable.id;
80
+ }
81
+ throw new Error('Unable to derive identifier from queryable.');
82
+ };
83
+
84
+ /**
85
+ * Get a memoized query atom for any Queryable (Database, Queue, etc.).
86
+ * Uses a single Atom.family keyed by queryable identifier + serialized query AST.
87
+ * Same queryable + query/filter = same atom instance (proper memoization).
88
+ *
89
+ * @param queryable - The queryable to query (Database, Queue, etc.).
90
+ * @param queryOrFilter - A Query or Filter to execute.
91
+ * @returns A memoized atom that updates when query results change.
92
+ */
93
+ export const make = <T extends Entity.Unknown>(
94
+ queryable: Database.Queryable,
95
+ queryOrFilter: Query.Query<T> | Filter.Filter<T>,
96
+ ): Atom.Atom<T[]> => {
97
+ const identifier = getQueryableIdentifier(queryable);
98
+ return fromQueryable(queryable, identifier, queryOrFilter);
99
+ };
100
+
101
+ /**
102
+ * Internal: Get a memoized query atom for any Queryable with a custom identifier.
103
+ */
104
+ const fromQueryable = <T extends Entity.Unknown>(
105
+ queryable: Database.Queryable,
106
+ identifier: string,
107
+ queryOrFilter: Query.Query<T> | Filter.Filter<T>,
108
+ ): Atom.Atom<T[]> => {
109
+ // Register queryable in registry (WeakDictionary handles cleanup automatically).
110
+ queryableRegistry.set(identifier, queryable);
111
+
112
+ // Normalize to Query.
113
+ const normalizedQuery: Query.Any = Query.is(queryOrFilter)
114
+ ? queryOrFilter
115
+ : Query.select(queryOrFilter as Filter.Filter<T>);
116
+
117
+ // Build key: identifier:serializedAST.
118
+ const key = `${identifier}:${JSON.stringify(normalizedQuery.ast)}`;
119
+
120
+ return queryFamily(key) as Atom.Atom<T[]>;
121
+ };
@@ -0,0 +1,158 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Registry from '@effect-atom/atom/Registry';
6
+ import { describe, expect, test } from 'vitest';
7
+
8
+ import { Obj } from '@dxos/echo';
9
+ import { TestSchema } from '@dxos/echo/testing';
10
+ import { createObject } from '@dxos/echo-db';
11
+
12
+ import * as AtomObj from './atom';
13
+
14
+ describe('Echo Atom - Reactivity', () => {
15
+ test('atom updates when Echo object is mutated', () => {
16
+ const obj = createObject(
17
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
18
+ );
19
+
20
+ const registry = Registry.make();
21
+ const atom = AtomObj.make(obj);
22
+
23
+ // Returns a snapshot (plain object), not the Echo object itself.
24
+ const initialSnapshot = registry.get(atom);
25
+ expect(initialSnapshot.name).toBe('Test');
26
+
27
+ // Subscribe to enable reactivity.
28
+ registry.subscribe(atom, () => {});
29
+
30
+ // Update the object via Obj.change.
31
+ Obj.change(obj, (o) => {
32
+ o.name = 'Updated';
33
+ });
34
+
35
+ const updatedSnapshot = registry.get(atom);
36
+ expect(updatedSnapshot.name).toBe('Updated');
37
+ });
38
+
39
+ test('property atom updates when its property is mutated', () => {
40
+ const obj = createObject(
41
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
42
+ );
43
+
44
+ const registry = Registry.make();
45
+ const atom = AtomObj.makeProperty(obj, 'name');
46
+
47
+ expect(registry.get(atom)).toBe('Test');
48
+
49
+ // Subscribe to enable reactivity.
50
+ registry.subscribe(atom, () => {});
51
+
52
+ // Update the property via Obj.change.
53
+ Obj.change(obj, (o) => {
54
+ o.name = 'Updated';
55
+ });
56
+
57
+ expect(registry.get(atom)).toBe('Updated');
58
+ });
59
+
60
+ test('property atom does NOT update when other properties change', () => {
61
+ const obj = createObject(
62
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
63
+ );
64
+
65
+ const registry = Registry.make();
66
+ const nameAtom = AtomObj.makeProperty(obj, 'name');
67
+ const emailAtom = AtomObj.makeProperty(obj, 'email');
68
+
69
+ const initialName = registry.get(nameAtom);
70
+ const initialEmail = registry.get(emailAtom);
71
+ expect(initialName).toBe('Test');
72
+ expect(initialEmail).toBe('test@example.com');
73
+
74
+ // Subscribe to enable reactivity.
75
+ let nameUpdateCount = 0;
76
+ let emailUpdateCount = 0;
77
+ registry.subscribe(nameAtom, () => {
78
+ nameUpdateCount++;
79
+ });
80
+ registry.subscribe(emailAtom, () => {
81
+ emailUpdateCount++;
82
+ });
83
+
84
+ // Update only email property via Obj.change.
85
+ Obj.change(obj, (o) => {
86
+ o.email = 'updated@example.com';
87
+ });
88
+
89
+ // Name atom should NOT have changed.
90
+ expect(registry.get(nameAtom)).toBe('Test');
91
+ expect(nameUpdateCount).toBe(0);
92
+
93
+ // Email atom should have changed.
94
+ expect(registry.get(emailAtom)).toBe('updated@example.com');
95
+ expect(emailUpdateCount).toBe(1);
96
+ });
97
+
98
+ test('multiple property updates on same object update respective atoms', () => {
99
+ const obj = createObject(
100
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
101
+ );
102
+
103
+ const registry = Registry.make();
104
+ const nameAtom = AtomObj.makeProperty(obj, 'name');
105
+ const emailAtom = AtomObj.makeProperty(obj, 'email');
106
+
107
+ // Subscribe to enable reactivity.
108
+ registry.subscribe(nameAtom, () => {});
109
+ registry.subscribe(emailAtom, () => {});
110
+
111
+ // Update multiple properties via Obj.change.
112
+ Obj.change(obj, (o) => {
113
+ o.name = 'Updated';
114
+ o.email = 'updated@example.com';
115
+ });
116
+
117
+ expect(registry.get(nameAtom)).toBe('Updated');
118
+ expect(registry.get(emailAtom)).toBe('updated@example.com');
119
+ });
120
+
121
+ test('direct object mutations flow through to atoms', () => {
122
+ const obj = createObject(
123
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
124
+ );
125
+
126
+ const registry = Registry.make();
127
+ const atom = AtomObj.make(obj);
128
+
129
+ let updateCount = 0;
130
+ registry.subscribe(
131
+ atom,
132
+ () => {
133
+ updateCount++;
134
+ },
135
+ { immediate: true },
136
+ );
137
+
138
+ // Get initial count (immediate: true causes initial update).
139
+ const initialCount = updateCount;
140
+ expect(initialCount).toBe(1);
141
+
142
+ // Update object via Obj.change.
143
+ Obj.change(obj, (o) => {
144
+ o.name = 'Updated';
145
+ });
146
+ Obj.change(obj, (o) => {
147
+ o.email = 'updated@example.com';
148
+ });
149
+
150
+ // Updates fire through Obj.subscribe (one per Obj.change call).
151
+ expect(updateCount).toBe(initialCount + 2);
152
+
153
+ // Verify final state - returns snapshot (plain object).
154
+ const finalSnapshot = registry.get(atom);
155
+ expect(finalSnapshot.name).toBe('Updated');
156
+ expect(finalSnapshot.email).toBe('updated@example.com');
157
+ });
158
+ });
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Atom from '@effect-atom/atom/Atom';
6
+
7
+ import { type Entity, type Ref } from '@dxos/echo';
8
+
9
+ import { loadRefTarget } from './ref-utils';
10
+
11
+ /**
12
+ * Create an atom for a reference target that returns the live object when loaded.
13
+ * This atom only updates once when the ref loads - it does not subscribe to object changes.
14
+ * Use AtomObj.make with a ref if you need reactive snapshots.
15
+ */
16
+ export function make<T extends Entity.Unknown>(ref: Ref.Ref<T> | undefined): Atom.Atom<T | undefined> {
17
+ if (!ref) {
18
+ return Atom.make<T | undefined>(() => undefined);
19
+ }
20
+
21
+ return Atom.make<T | undefined>((get) => {
22
+ return loadRefTarget(ref, get, (target) => target);
23
+ });
24
+ }
@@ -0,0 +1,40 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import type * as Atom from '@effect-atom/atom/Atom';
6
+
7
+ import type { Ref } from '@dxos/echo';
8
+
9
+ /**
10
+ * Internal helper for loading ref targets in atoms.
11
+ * Handles the common pattern of checking for loaded target and triggering async load.
12
+ *
13
+ * @param ref - The ref to load.
14
+ * @param get - The atom context for setSelf.
15
+ * @param onTargetAvailable - Callback invoked when target is available (sync or async).
16
+ * Should return the value to use for the atom.
17
+ * @returns The result of onTargetAvailable if target is already loaded, undefined otherwise.
18
+ */
19
+ export const loadRefTarget = <T, R>(
20
+ ref: Ref.Ref<T>,
21
+ get: Atom.Context,
22
+ onTargetAvailable: (target: T) => R,
23
+ ): R | undefined => {
24
+ const currentTarget = ref.target;
25
+ if (currentTarget) {
26
+ return onTargetAvailable(currentTarget);
27
+ }
28
+
29
+ // Target not loaded yet - trigger async load.
30
+ void ref
31
+ .load()
32
+ .then((loadedTarget) => {
33
+ get.setSelf(onTargetAvailable(loadedTarget));
34
+ })
35
+ .catch(() => {
36
+ // Loading failed, keep target as undefined.
37
+ });
38
+
39
+ return undefined;
40
+ };