@dxos/echo-atom 0.8.4-main.3eb6e50203 → 0.8.4-main.3fbcb4aa9b

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.
@@ -6,8 +6,9 @@ import * as Registry from '@effect-atom/atom/Registry';
6
6
  import { describe, expect, test } from 'vitest';
7
7
 
8
8
  import { Obj } from '@dxos/echo';
9
- import { TestSchema } from '@dxos/echo/testing';
10
9
  import { createObject } from '@dxos/echo-db';
10
+ import { TestSchema } from '@dxos/echo/testing';
11
+ import { arrayMove } from '@dxos/util';
11
12
 
12
13
  import * as AtomObj from './atom';
13
14
 
@@ -27,9 +28,9 @@ describe('Echo Atom - Reactivity', () => {
27
28
  // Subscribe to enable reactivity.
28
29
  registry.subscribe(atom, () => {});
29
30
 
30
- // Update the object via Obj.change.
31
- Obj.change(obj, (o) => {
32
- o.name = 'Updated';
31
+ // Update the object via Obj.update.
32
+ Obj.update(obj, (obj) => {
33
+ obj.name = 'Updated';
33
34
  });
34
35
 
35
36
  const updatedSnapshot = registry.get(atom);
@@ -49,9 +50,9 @@ describe('Echo Atom - Reactivity', () => {
49
50
  // Subscribe to enable reactivity.
50
51
  registry.subscribe(atom, () => {});
51
52
 
52
- // Update the property via Obj.change.
53
- Obj.change(obj, (o) => {
54
- o.name = 'Updated';
53
+ // Update the property via Obj.update.
54
+ Obj.update(obj, (obj) => {
55
+ obj.name = 'Updated';
55
56
  });
56
57
 
57
58
  expect(registry.get(atom)).toBe('Updated');
@@ -81,9 +82,9 @@ describe('Echo Atom - Reactivity', () => {
81
82
  emailUpdateCount++;
82
83
  });
83
84
 
84
- // Update only email property via Obj.change.
85
- Obj.change(obj, (o) => {
86
- o.email = 'updated@example.com';
85
+ // Update only email property via Obj.update.
86
+ Obj.update(obj, (obj) => {
87
+ obj.email = 'updated@example.com';
87
88
  });
88
89
 
89
90
  // Name atom should NOT have changed.
@@ -108,10 +109,10 @@ describe('Echo Atom - Reactivity', () => {
108
109
  registry.subscribe(nameAtom, () => {});
109
110
  registry.subscribe(emailAtom, () => {});
110
111
 
111
- // Update multiple properties via Obj.change.
112
- Obj.change(obj, (o) => {
113
- o.name = 'Updated';
114
- o.email = 'updated@example.com';
112
+ // Update multiple properties via Obj.update.
113
+ Obj.update(obj, (obj) => {
114
+ obj.name = 'Updated';
115
+ obj.email = 'updated@example.com';
115
116
  });
116
117
 
117
118
  expect(registry.get(nameAtom)).toBe('Updated');
@@ -139,15 +140,15 @@ describe('Echo Atom - Reactivity', () => {
139
140
  const initialCount = updateCount;
140
141
  expect(initialCount).toBe(1);
141
142
 
142
- // Update object via Obj.change.
143
- Obj.change(obj, (o) => {
144
- o.name = 'Updated';
143
+ // Update object via Obj.update.
144
+ Obj.update(obj, (obj) => {
145
+ obj.name = 'Updated';
145
146
  });
146
- Obj.change(obj, (o) => {
147
- o.email = 'updated@example.com';
147
+ Obj.update(obj, (obj) => {
148
+ obj.email = 'updated@example.com';
148
149
  });
149
150
 
150
- // Updates fire through Obj.subscribe (one per Obj.change call).
151
+ // Updates fire through Obj.subscribe (one per Obj.update call).
151
152
  expect(updateCount).toBe(initialCount + 2);
152
153
 
153
154
  // Verify final state - returns snapshot (plain object).
@@ -166,8 +167,8 @@ describe('Echo Atom - Reactivity', () => {
166
167
  });
167
168
 
168
169
  actions.push('before');
169
- Obj.change(obj, (o) => {
170
- o.name = 'Updated';
170
+ Obj.update(obj, (obj) => {
171
+ obj.name = 'Updated';
171
172
  });
172
173
  actions.push('after');
173
174
 
@@ -190,8 +191,8 @@ describe('Echo Atom - Reactivity', () => {
190
191
  });
191
192
 
192
193
  actions.push('before');
193
- Obj.change(obj, (o) => {
194
- o.stringArray!.splice(1, 1);
194
+ Obj.update(obj, (obj) => {
195
+ obj.stringArray!.splice(1, 1);
195
196
  });
196
197
  actions.push('after');
197
198
 
@@ -203,4 +204,30 @@ describe('Echo Atom - Reactivity', () => {
203
204
 
204
205
  unsubscribe();
205
206
  });
207
+
208
+ test('property atom for array property updates when array is reordered in place', () => {
209
+ // Verifies that makeProperty(obj, 'columns')-style atoms subscribe to in-place
210
+ // array mutations (e.g. arrayMove), so UI stays in sync after column reorder.
211
+ const obj = createObject(Obj.make(TestSchema.Example, { stringArray: ['a', 'b', 'c'] }));
212
+
213
+ const registry = Registry.make();
214
+ const atom = AtomObj.makeProperty(obj, 'stringArray');
215
+
216
+ const initial = registry.get(atom);
217
+ expect(initial).toEqual(['a', 'b', 'c']);
218
+
219
+ let updateCount = 0;
220
+ registry.subscribe(atom, () => {
221
+ updateCount++;
222
+ });
223
+
224
+ // Reorder in place (e.g. move first to last).
225
+ Obj.update(obj, (obj) => {
226
+ arrayMove(obj.stringArray!, 0, 2);
227
+ });
228
+
229
+ expect(updateCount).toBe(1);
230
+ const afterReorder = registry.get(atom);
231
+ expect(afterReorder).toEqual(['b', 'c', 'a']);
232
+ });
206
233
  });
@@ -5,11 +5,13 @@
5
5
  import * as Registry from '@effect-atom/atom/Registry';
6
6
  import { afterEach, beforeEach, describe, expect, test } from 'vitest';
7
7
 
8
- import { Obj, Ref } from '@dxos/echo';
9
- import { TestSchema } from '@dxos/echo/testing';
8
+ import { Filter, Obj, Ref } from '@dxos/echo';
10
9
  import { type EchoDatabase } from '@dxos/echo-db';
11
10
  import { EchoTestBuilder } from '@dxos/echo-db/testing';
11
+ import { TestSchema } from '@dxos/echo/testing';
12
+ import { PublicKey } from '@dxos/keys';
12
13
 
14
+ import * as AtomObj from './atom';
13
15
  import * as AtomRef from './ref-atom';
14
16
 
15
17
  describe('AtomRef - Basic Functionality', () => {
@@ -33,7 +35,7 @@ describe('AtomRef - Basic Functionality', () => {
33
35
 
34
36
  const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
35
37
  db.add(targetObj);
36
- await db.flush({ indexes: true });
38
+ await db.flush();
37
39
 
38
40
  const ref = Ref.make(targetObj);
39
41
  const atom = AtomRef.make(ref);
@@ -48,7 +50,7 @@ describe('AtomRef - Basic Functionality', () => {
48
50
 
49
51
  const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
50
52
  db.add(targetObj);
51
- await db.flush({ indexes: true });
53
+ await db.flush();
52
54
 
53
55
  const ref = Ref.make(targetObj);
54
56
  const atom = AtomRef.make(ref);
@@ -60,13 +62,45 @@ describe('AtomRef - Basic Functionality', () => {
60
62
  expect(updateCount).toBe(1);
61
63
 
62
64
  // Mutate target - ref atom does NOT react to this.
63
- Obj.change(targetObj, (o) => {
64
- o.name = 'Updated';
65
+ Obj.update(targetObj, (targetObj) => {
66
+ targetObj.name = 'Updated';
65
67
  });
66
68
 
67
69
  // Update count should still be 1 - ref atom doesn't subscribe to target changes.
68
70
  expect(updateCount).toBe(1);
69
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
+ });
70
104
  });
71
105
 
72
106
  describe('AtomRef - Referential Equality', () => {
@@ -90,7 +124,7 @@ describe('AtomRef - Referential Equality', () => {
90
124
 
91
125
  const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
92
126
  db.add(targetObj);
93
- await db.flush({ indexes: true });
127
+ await db.flush();
94
128
 
95
129
  const ref = Ref.make(targetObj);
96
130
 
@@ -116,7 +150,7 @@ describe('AtomRef - Referential Equality', () => {
116
150
  });
117
151
  db.add(targetObj1);
118
152
  db.add(targetObj2);
119
- await db.flush({ indexes: true });
153
+ await db.flush();
120
154
 
121
155
  const ref1 = Ref.make(targetObj1);
122
156
  const ref2 = Ref.make(targetObj2);
@@ -133,7 +167,7 @@ describe('AtomRef - Referential Equality', () => {
133
167
 
134
168
  const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
135
169
  db.add(targetObj);
136
- await db.flush({ indexes: true });
170
+ await db.flush();
137
171
 
138
172
  // Create two separate refs to the same target.
139
173
  const ref1 = Ref.make(targetObj);
@@ -158,7 +192,7 @@ describe('AtomRef - Referential Equality', () => {
158
192
 
159
193
  const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
160
194
  db.add(targetObj);
161
- await db.flush({ indexes: true });
195
+ await db.flush();
162
196
 
163
197
  const ref = Ref.make(targetObj);
164
198
 
@@ -197,7 +231,7 @@ describe('AtomRef - Expando Objects', () => {
197
231
  test('works with Expando objects', async () => {
198
232
  const targetObj = Obj.make(TestSchema.Expando, { name: 'Expando Target', value: 42 });
199
233
  db.add(targetObj);
200
- await db.flush({ indexes: true });
234
+ await db.flush();
201
235
 
202
236
  const ref = Ref.make(targetObj);
203
237
  const atom = AtomRef.make(ref);
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
- // Target not loaded yet - trigger async load.
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, keep target as undefined.
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;