@dxos/echo-atom 0.0.0 → 0.8.4-main.03d5cd7b56
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/README.md +28 -18
- package/dist/lib/neutral/index.mjs +221 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/types/src/atom.d.ts +43 -0
- package/dist/types/src/atom.d.ts.map +1 -0
- package/dist/types/src/atom.test.d.ts +2 -0
- package/dist/types/src/atom.test.d.ts.map +1 -0
- package/dist/types/src/batching.test.d.ts +2 -0
- package/dist/types/src/batching.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/query-atom.d.ts +24 -0
- package/dist/types/src/query-atom.d.ts.map +1 -0
- package/dist/types/src/query-atom.test.d.ts +2 -0
- package/dist/types/src/query-atom.test.d.ts.map +1 -0
- package/dist/types/src/reactivity.test.d.ts +2 -0
- package/dist/types/src/reactivity.test.d.ts.map +1 -0
- package/dist/types/src/ref-atom.d.ts +13 -0
- package/dist/types/src/ref-atom.d.ts.map +1 -0
- package/dist/types/src/ref-atom.test.d.ts +2 -0
- package/dist/types/src/ref-atom.test.d.ts.map +1 -0
- package/dist/types/src/ref-utils.d.ts +14 -0
- package/dist/types/src/ref-utils.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +18 -16
- package/src/atom.test.ts +258 -12
- package/src/atom.ts +171 -56
- package/src/batching.test.ts +24 -24
- package/src/query-atom.test.ts +200 -12
- package/src/query-atom.ts +11 -13
- package/src/reactivity.test.ts +95 -20
- package/src/ref-atom.test.ts +243 -0
- package/src/ref-atom.ts +17 -9
- package/src/ref-utils.ts +17 -2
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Registry from '@effect-atom/atom/Registry';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { Filter, Obj, Ref } from '@dxos/echo';
|
|
9
|
+
import { type EchoDatabase } from '@dxos/echo-db';
|
|
10
|
+
import { EchoTestBuilder } from '@dxos/echo-db/testing';
|
|
11
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
12
|
+
import { PublicKey } from '@dxos/keys';
|
|
13
|
+
|
|
14
|
+
import * as AtomObj from './atom';
|
|
15
|
+
import * as AtomRef from './ref-atom';
|
|
16
|
+
|
|
17
|
+
describe('AtomRef - Basic Functionality', () => {
|
|
18
|
+
let testBuilder: EchoTestBuilder;
|
|
19
|
+
let db: EchoDatabase;
|
|
20
|
+
let registry: Registry.Registry;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
testBuilder = await new EchoTestBuilder().open();
|
|
24
|
+
const { db: database } = await testBuilder.createDatabase();
|
|
25
|
+
db = database;
|
|
26
|
+
registry = Registry.make();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await testBuilder.close();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('AtomRef.make returns target when ref is loaded', async () => {
|
|
34
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
35
|
+
|
|
36
|
+
const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
|
|
37
|
+
db.add(targetObj);
|
|
38
|
+
await db.flush();
|
|
39
|
+
|
|
40
|
+
const ref = Ref.make(targetObj);
|
|
41
|
+
const atom = AtomRef.make(ref);
|
|
42
|
+
|
|
43
|
+
// Should return the target object (as a snapshot).
|
|
44
|
+
const result = registry.get(atom);
|
|
45
|
+
expect(result?.name).toBe('Target');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('AtomRef.make does not subscribe to target changes (use AtomObj for reactive snapshots)', async () => {
|
|
49
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
50
|
+
|
|
51
|
+
const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
|
|
52
|
+
db.add(targetObj);
|
|
53
|
+
await db.flush();
|
|
54
|
+
|
|
55
|
+
const ref = Ref.make(targetObj);
|
|
56
|
+
const atom = AtomRef.make(ref);
|
|
57
|
+
|
|
58
|
+
// Subscribe to updates.
|
|
59
|
+
let updateCount = 0;
|
|
60
|
+
registry.subscribe(atom, () => updateCount++, { immediate: true });
|
|
61
|
+
|
|
62
|
+
expect(updateCount).toBe(1);
|
|
63
|
+
|
|
64
|
+
// Mutate target - ref atom does NOT react to this.
|
|
65
|
+
Obj.update(targetObj, (targetObj) => {
|
|
66
|
+
targetObj.name = 'Updated';
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Update count should still be 1 - ref atom doesn't subscribe to target changes.
|
|
70
|
+
expect(updateCount).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Sibling client (sharing services) creates a brand-new ref target. The atom
|
|
74
|
+
// must update once the target's document propagates and resolves.
|
|
75
|
+
test('atom resolves target created by sibling client', async () => {
|
|
76
|
+
const [spaceKey] = PublicKey.randomSequence();
|
|
77
|
+
await using peer = await testBuilder.createPeer({
|
|
78
|
+
types: [TestSchema.Person, TestSchema.Container],
|
|
79
|
+
});
|
|
80
|
+
await using db1 = await peer.createDatabase(spaceKey);
|
|
81
|
+
const parent1 = db1.add(Obj.make(TestSchema.Container, { objects: [] }));
|
|
82
|
+
await db1.flush();
|
|
83
|
+
|
|
84
|
+
await using client2 = await peer.createClient();
|
|
85
|
+
await using db2 = await peer.openDatabase(spaceKey, db1.rootUrl!, { client: client2 });
|
|
86
|
+
const [parent2] = await db2.query(Filter.id(parent1.id)).run();
|
|
87
|
+
|
|
88
|
+
const newPerson = db2.add(
|
|
89
|
+
Obj.make(TestSchema.Person, { name: 'Alice', username: 'alice', email: 'alice@example.com' }),
|
|
90
|
+
);
|
|
91
|
+
Obj.update(parent2, (parent2) => {
|
|
92
|
+
parent2.objects = [...(parent2.objects ?? []), Ref.make(newPerson)];
|
|
93
|
+
});
|
|
94
|
+
await db2.flush();
|
|
95
|
+
|
|
96
|
+
await expect.poll(() => (parent1.objects ?? []).length).toBeGreaterThan(0);
|
|
97
|
+
const atom = AtomObj.make(parent1.objects![0]);
|
|
98
|
+
|
|
99
|
+
let lastValue: any;
|
|
100
|
+
registry.subscribe(atom, (value) => (lastValue = value), { immediate: true });
|
|
101
|
+
|
|
102
|
+
await expect.poll(() => lastValue?.name).toBe('Alice');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('AtomRef - Referential Equality', () => {
|
|
107
|
+
let testBuilder: EchoTestBuilder;
|
|
108
|
+
let db: EchoDatabase;
|
|
109
|
+
let registry: Registry.Registry;
|
|
110
|
+
|
|
111
|
+
beforeEach(async () => {
|
|
112
|
+
testBuilder = await new EchoTestBuilder().open();
|
|
113
|
+
const { db: database } = await testBuilder.createDatabase();
|
|
114
|
+
db = database;
|
|
115
|
+
registry = Registry.make();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
await testBuilder.close();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('AtomRef.make returns same atom instance for same ref', async () => {
|
|
123
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
124
|
+
|
|
125
|
+
const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
|
|
126
|
+
db.add(targetObj);
|
|
127
|
+
await db.flush();
|
|
128
|
+
|
|
129
|
+
const ref = Ref.make(targetObj);
|
|
130
|
+
|
|
131
|
+
const atom1 = AtomRef.make(ref);
|
|
132
|
+
const atom2 = AtomRef.make(ref);
|
|
133
|
+
|
|
134
|
+
// Same ref should return the exact same atom instance.
|
|
135
|
+
expect(atom1).toBe(atom2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('AtomRef.make returns different atom instances for different refs', async () => {
|
|
139
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
140
|
+
|
|
141
|
+
const targetObj1 = Obj.make(TestSchema.Person, {
|
|
142
|
+
name: 'Target1',
|
|
143
|
+
username: 'target1',
|
|
144
|
+
email: 'target1@example.com',
|
|
145
|
+
});
|
|
146
|
+
const targetObj2 = Obj.make(TestSchema.Person, {
|
|
147
|
+
name: 'Target2',
|
|
148
|
+
username: 'target2',
|
|
149
|
+
email: 'target2@example.com',
|
|
150
|
+
});
|
|
151
|
+
db.add(targetObj1);
|
|
152
|
+
db.add(targetObj2);
|
|
153
|
+
await db.flush();
|
|
154
|
+
|
|
155
|
+
const ref1 = Ref.make(targetObj1);
|
|
156
|
+
const ref2 = Ref.make(targetObj2);
|
|
157
|
+
|
|
158
|
+
const atom1 = AtomRef.make(ref1);
|
|
159
|
+
const atom2 = AtomRef.make(ref2);
|
|
160
|
+
|
|
161
|
+
// Different refs should return different atom instances.
|
|
162
|
+
expect(atom1).not.toBe(atom2);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('AtomRef.make returns same atom for refs created separately to same target', async () => {
|
|
166
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
167
|
+
|
|
168
|
+
const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
|
|
169
|
+
db.add(targetObj);
|
|
170
|
+
await db.flush();
|
|
171
|
+
|
|
172
|
+
// Create two separate refs to the same target.
|
|
173
|
+
const ref1 = Ref.make(targetObj);
|
|
174
|
+
const ref2 = Ref.make(targetObj);
|
|
175
|
+
|
|
176
|
+
// Refs are different objects (not referentially equal).
|
|
177
|
+
expect(ref1).not.toBe(ref2);
|
|
178
|
+
|
|
179
|
+
const atom1 = AtomRef.make(ref1);
|
|
180
|
+
const atom2 = AtomRef.make(ref2);
|
|
181
|
+
|
|
182
|
+
// Refs with the same DXN resolve to the same atom via Hash/Equal traits.
|
|
183
|
+
expect(atom1).toBe(atom2);
|
|
184
|
+
|
|
185
|
+
// Both atoms should return the same target data.
|
|
186
|
+
expect(registry.get(atom1)?.name).toBe('Target');
|
|
187
|
+
expect(registry.get(atom2)?.name).toBe('Target');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('cached ref atoms return same instance after multiple retrievals', async () => {
|
|
191
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
192
|
+
|
|
193
|
+
const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
|
|
194
|
+
db.add(targetObj);
|
|
195
|
+
await db.flush();
|
|
196
|
+
|
|
197
|
+
const ref = Ref.make(targetObj);
|
|
198
|
+
|
|
199
|
+
// Get the same atom multiple times.
|
|
200
|
+
const atom1 = AtomRef.make(ref);
|
|
201
|
+
const atom2 = AtomRef.make(ref);
|
|
202
|
+
const atom3 = AtomRef.make(ref);
|
|
203
|
+
|
|
204
|
+
// All should be the same instance.
|
|
205
|
+
expect(atom1).toBe(atom2);
|
|
206
|
+
expect(atom2).toBe(atom3);
|
|
207
|
+
|
|
208
|
+
// All should return the same target value.
|
|
209
|
+
expect(registry.get(atom1)?.name).toBe('Target');
|
|
210
|
+
expect(registry.get(atom2)?.name).toBe('Target');
|
|
211
|
+
expect(registry.get(atom3)?.name).toBe('Target');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('AtomRef - Expando Objects', () => {
|
|
216
|
+
let testBuilder: EchoTestBuilder;
|
|
217
|
+
let db: EchoDatabase;
|
|
218
|
+
let registry: Registry.Registry;
|
|
219
|
+
|
|
220
|
+
beforeEach(async () => {
|
|
221
|
+
testBuilder = await new EchoTestBuilder().open();
|
|
222
|
+
const { db: database } = await testBuilder.createDatabase();
|
|
223
|
+
db = database;
|
|
224
|
+
registry = Registry.make();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
afterEach(async () => {
|
|
228
|
+
await testBuilder.close();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('works with Expando objects', async () => {
|
|
232
|
+
const targetObj = Obj.make(TestSchema.Expando, { name: 'Expando Target', value: 42 });
|
|
233
|
+
db.add(targetObj);
|
|
234
|
+
await db.flush();
|
|
235
|
+
|
|
236
|
+
const ref = Ref.make(targetObj);
|
|
237
|
+
const atom = AtomRef.make(ref);
|
|
238
|
+
|
|
239
|
+
const result = registry.get(atom);
|
|
240
|
+
expect(result?.name).toBe('Expando Target');
|
|
241
|
+
expect(result?.value).toBe(42);
|
|
242
|
+
});
|
|
243
|
+
});
|
package/src/ref-atom.ts
CHANGED
|
@@ -4,21 +4,29 @@
|
|
|
4
4
|
|
|
5
5
|
import * as Atom from '@effect-atom/atom/Atom';
|
|
6
6
|
|
|
7
|
-
import { type
|
|
7
|
+
import { type Ref } from '@dxos/echo';
|
|
8
8
|
|
|
9
9
|
import { loadRefTarget } from './ref-utils';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Atom family for ECHO refs.
|
|
13
|
+
* Uses ref reference as key - same ref returns same atom.
|
|
13
14
|
* 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
|
+
* Use AtomObj.make with a ref if you need reactive snapshots of ECHO objects.
|
|
15
16
|
*/
|
|
16
|
-
|
|
17
|
-
if (!ref) {
|
|
18
|
-
return Atom.make<T | undefined>(() => undefined);
|
|
19
|
-
}
|
|
20
|
-
|
|
17
|
+
const refFamily = Atom.family(<T>(ref: Ref.Ref<T>): Atom.Atom<T | undefined> => {
|
|
21
18
|
return Atom.make<T | undefined>((get) => {
|
|
22
19
|
return loadRefTarget(ref, get, (target) => target);
|
|
23
20
|
});
|
|
24
|
-
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a read-only atom for a reference target.
|
|
25
|
+
* Returns undefined if the target hasn't loaded yet.
|
|
26
|
+
* Updates when the ref loads but does NOT subscribe to target object changes.
|
|
27
|
+
* Use AtomObj.make with a ref if you need reactive snapshots of ECHO objects.
|
|
28
|
+
* Uses Atom.family internally - same ref reference returns same atom instance.
|
|
29
|
+
*
|
|
30
|
+
* Supports refs to any target type including ECHO objects and Queues.
|
|
31
|
+
*/
|
|
32
|
+
export const make = refFamily;
|
package/src/ref-utils.ts
CHANGED
|
@@ -21,19 +21,34 @@ export const loadRefTarget = <T, R>(
|
|
|
21
21
|
get: Atom.Context,
|
|
22
22
|
onTargetAvailable: (target: T) => R,
|
|
23
23
|
): R | undefined => {
|
|
24
|
+
// Accessing `ref.target` registers a resolution callback when the target is
|
|
25
|
+
// not yet loaded, so resolution can be observed via `ref.onResolved` below.
|
|
24
26
|
const currentTarget = ref.target;
|
|
25
27
|
if (currentTarget) {
|
|
26
28
|
return onTargetAvailable(currentTarget);
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
//
|
|
31
|
+
// Subscribe to the ref's resolution event in case the target loads later
|
|
32
|
+
// (e.g. when a sibling client creates the linked object). Without this,
|
|
33
|
+
// a one-shot async load that fails because the document hasn't propagated
|
|
34
|
+
// would leave the atom permanently undefined.
|
|
35
|
+
const unsubscribe = ref.onResolved(() => {
|
|
36
|
+
const target = ref.target;
|
|
37
|
+
if (target) {
|
|
38
|
+
get.setSelf(onTargetAvailable(target));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
get.addFinalizer(unsubscribe);
|
|
42
|
+
|
|
43
|
+
// Also try async load (e.g. for objects that need disk loading).
|
|
30
44
|
void ref
|
|
31
45
|
.load()
|
|
32
46
|
.then((loadedTarget) => {
|
|
33
47
|
get.setSelf(onTargetAvailable(loadedTarget));
|
|
34
48
|
})
|
|
35
49
|
.catch(() => {
|
|
36
|
-
// Loading failed
|
|
50
|
+
// Loading failed; the resolution subscription above will pick up
|
|
51
|
+
// cross-client updates when they arrive.
|
|
37
52
|
});
|
|
38
53
|
|
|
39
54
|
return undefined;
|