@dxos/echo-atom 0.8.4-main.d05673bc65 → 0.8.4-main.dfabb4ec29

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/echo-atom",
3
- "version": "0.8.4-main.d05673bc65",
3
+ "version": "0.8.4-main.dfabb4ec29",
4
4
  "description": "Effect Atom wrappers for ECHO objects with explicit subscriptions.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -20,27 +20,25 @@
20
20
  }
21
21
  },
22
22
  "types": "dist/types/src/index.d.ts",
23
- "typesVersions": {
24
- "*": {}
25
- },
26
23
  "files": [
27
24
  "dist",
28
25
  "src"
29
26
  ],
30
27
  "dependencies": {
31
28
  "@effect-atom/atom": "^0.5.1",
32
- "@dxos/echo": "0.8.4-main.d05673bc65",
33
- "@dxos/util": "0.8.4-main.d05673bc65",
34
- "@dxos/echo-db": "0.8.4-main.d05673bc65",
35
- "@dxos/invariant": "0.8.4-main.d05673bc65"
29
+ "@dxos/echo": "0.8.4-main.dfabb4ec29",
30
+ "@dxos/util": "0.8.4-main.dfabb4ec29",
31
+ "@dxos/invariant": "0.8.4-main.dfabb4ec29",
32
+ "@dxos/echo-db": "0.8.4-main.dfabb4ec29"
36
33
  },
37
34
  "devDependencies": {
38
- "effect": "3.19.16",
39
- "@dxos/random": "0.8.4-main.d05673bc65",
40
- "@dxos/test-utils": "0.8.4-main.d05673bc65"
35
+ "effect": "3.20.0",
36
+ "@dxos/context": "0.8.4-main.dfabb4ec29",
37
+ "@dxos/random": "0.8.4-main.dfabb4ec29",
38
+ "@dxos/test-utils": "0.8.4-main.dfabb4ec29"
41
39
  },
42
40
  "peerDependencies": {
43
- "effect": "3.19.16"
41
+ "effect": "3.20.0"
44
42
  },
45
43
  "publishConfig": {
46
44
  "access": "public"
package/src/atom.test.ts CHANGED
@@ -6,9 +6,9 @@ import * as Registry from '@effect-atom/atom/Registry';
6
6
  import { afterEach, beforeEach, describe, expect, test } from 'vitest';
7
7
 
8
8
  import { Obj, Ref } from '@dxos/echo';
9
- import { TestSchema } from '@dxos/echo/testing';
10
9
  import { createObject } from '@dxos/echo-db';
11
10
  import { EchoTestBuilder } from '@dxos/echo-db/testing';
11
+ import { TestSchema } from '@dxos/echo/testing';
12
12
 
13
13
  import * as AtomObj from './atom';
14
14
 
@@ -55,7 +55,7 @@ describe('Echo Atom - Basic Functionality', () => {
55
55
  expect(registry.get(emailAtom)).toBe('test@example.com');
56
56
  });
57
57
 
58
- test('atom updates when object is mutated via Obj.change', () => {
58
+ test('atom updates when object is mutated via Obj.update', () => {
59
59
  const obj = createObject(
60
60
  Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
61
61
  );
@@ -72,9 +72,9 @@ describe('Echo Atom - Basic Functionality', () => {
72
72
  { immediate: true },
73
73
  );
74
74
 
75
- // Mutate object via Obj.change.
76
- Obj.change(obj, (o) => {
77
- o.name = 'Updated';
75
+ // Mutate object via Obj.update.
76
+ Obj.update(obj, (obj) => {
77
+ obj.name = 'Updated';
78
78
  });
79
79
 
80
80
  // Subscription should have fired: immediate + update.
@@ -85,7 +85,7 @@ describe('Echo Atom - Basic Functionality', () => {
85
85
  expect(obj.name).toBe('Updated');
86
86
  });
87
87
 
88
- test('property atom supports updater pattern via Obj.change', () => {
88
+ test('property atom supports updater pattern via Obj.update', () => {
89
89
  const obj = createObject(
90
90
  Obj.make(TestSchema.Task, {
91
91
  title: 'Task',
@@ -104,9 +104,9 @@ describe('Echo Atom - Basic Functionality', () => {
104
104
  { immediate: true },
105
105
  );
106
106
 
107
- // Update through Obj.change.
108
- Obj.change(obj, (o) => {
109
- o.title = (o.title ?? '') + ' Updated';
107
+ // Update through Obj.update.
108
+ Obj.update(obj, (obj) => {
109
+ obj.title = (obj.title ?? '') + ' Updated';
110
110
  });
111
111
 
112
112
  // Subscription should have fired: immediate + update.
@@ -140,8 +140,8 @@ describe('Echo Atom - Basic Functionality', () => {
140
140
  expect(propertyUpdateCount).toBe(1);
141
141
 
142
142
  // Mutate the standalone object.
143
- Obj.change(obj, (o) => {
144
- o.name = 'Updated Standalone';
143
+ Obj.update(obj, (obj) => {
144
+ obj.name = 'Updated Standalone';
145
145
  });
146
146
 
147
147
  // Both atoms should have received updates.
@@ -244,8 +244,8 @@ describe('Echo Atom - Referential Equality', () => {
244
244
  expect(updateCount).toBe(1);
245
245
 
246
246
  // Mutate the object.
247
- Obj.change(obj, (o) => {
248
- o.name = 'Updated';
247
+ Obj.update(obj, (obj) => {
248
+ obj.name = 'Updated';
249
249
  });
250
250
 
251
251
  // The subscription should still work.
@@ -276,8 +276,8 @@ describe('Echo Atom - Referential Equality', () => {
276
276
  expect(updateCount).toBe(1);
277
277
 
278
278
  // Mutate the specific property.
279
- Obj.change(obj, (o) => {
280
- o.name = 'Updated';
279
+ Obj.update(obj, (obj) => {
280
+ obj.name = 'Updated';
281
281
  });
282
282
 
283
283
  // The subscription should still work.
package/src/atom.ts CHANGED
@@ -161,7 +161,6 @@ export function makeProperty<T extends Obj.Unknown, K extends keyof T>(
161
161
  }
162
162
 
163
163
  assertArgument(Obj.isObject(obj), 'obj', 'Object must be a reactive object');
164
- assertArgument(key in obj, 'key', 'Property must exist on object');
165
164
  return propertyFamily(obj)(key);
166
165
  }
167
166
 
@@ -189,7 +188,9 @@ const refWithReactiveFamily = Atom.family(<T extends Obj.Unknown>(ref: Ref.Ref<T
189
188
  const effect = (get: Atom.Context) =>
190
189
  Effect.gen(function* () {
191
190
  const snapshot = get(make(ref));
192
- if (snapshot == null) return undefined;
191
+ if (snapshot == null) {
192
+ return undefined;
193
+ }
193
194
  const option = yield* Obj.getReactiveOption(snapshot);
194
195
  return Option.getOrElse(option, () => undefined);
195
196
  });
@@ -203,7 +204,7 @@ const refWithReactiveFamily = Atom.family(<T extends Obj.Unknown>(ref: Ref.Ref<T
203
204
  /**
204
205
  * Like {@link make} but returns the live reactive object instead of a snapshot.
205
206
  * Same input: Obj or Ref.Ref. Same output shape: Atom that updates when the object mutates.
206
- * Prefer {@link make} (snapshot) unless you need the live Obj.Obj for generic mutations (e.g. Obj.change).
207
+ * Prefer {@link make} (snapshot) unless you need the live Obj.Obj for generic mutations (e.g. Obj.update).
207
208
  *
208
209
  * @param objOrRef - The reactive object or ref.
209
210
  * @returns An atom that returns the live object. Returns undefined for refs (async loading) or undefined input.
@@ -6,13 +6,13 @@ 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
11
 
12
12
  import * as AtomObj from './atom';
13
13
 
14
14
  describe('Echo Atom - Batch Updates', () => {
15
- test('multiple updates to same object atom in single Obj.change fire single update', () => {
15
+ test('multiple updates to same object atom in single Obj.update fire single update', () => {
16
16
  const obj = createObject(
17
17
  Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
18
18
  );
@@ -33,14 +33,14 @@ describe('Echo Atom - Batch Updates', () => {
33
33
  const initialCount = updateCount;
34
34
  expect(initialCount).toBe(1); // Verify immediate update fired.
35
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';
36
+ // Make multiple updates to the same object in a single Obj.update call.
37
+ Obj.update(obj, (obj) => {
38
+ obj.name = 'Updated1';
39
+ obj.email = 'updated@example.com';
40
+ obj.username = 'updated';
41
41
  });
42
42
 
43
- // Should have fired once for initial + once for the Obj.change (not once per property update).
43
+ // Should have fired once for initial + once for the Obj.update (not once per property update).
44
44
  expect(updateCount).toBe(2);
45
45
 
46
46
  // Verify final state.
@@ -50,7 +50,7 @@ describe('Echo Atom - Batch Updates', () => {
50
50
  expect(finalValue.username).toBe('updated');
51
51
  });
52
52
 
53
- test('multiple separate Obj.change calls fire separate updates', () => {
53
+ test('multiple separate Obj.update calls fire separate updates', () => {
54
54
  const obj = createObject(
55
55
  Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
56
56
  );
@@ -71,18 +71,18 @@ describe('Echo Atom - Batch Updates', () => {
71
71
  const initialCount = updateCount;
72
72
  expect(initialCount).toBe(1);
73
73
 
74
- // Make multiple separate Obj.change calls.
75
- Obj.change(obj, (o) => {
76
- o.name = 'Updated1';
74
+ // Make multiple separate Obj.update calls.
75
+ Obj.update(obj, (obj) => {
76
+ obj.name = 'Updated1';
77
77
  });
78
- Obj.change(obj, (o) => {
79
- o.email = 'updated@example.com';
78
+ Obj.update(obj, (obj) => {
79
+ obj.email = 'updated@example.com';
80
80
  });
81
- Obj.change(obj, (o) => {
82
- o.username = 'updated';
81
+ Obj.update(obj, (obj) => {
82
+ obj.username = 'updated';
83
83
  });
84
84
 
85
- // Should have fired once for initial + once per Obj.change call.
85
+ // Should have fired once for initial + once per Obj.update call.
86
86
  expect(updateCount).toBe(4);
87
87
 
88
88
  // Verify final state.
@@ -92,7 +92,7 @@ describe('Echo Atom - Batch Updates', () => {
92
92
  expect(finalValue.username).toBe('updated');
93
93
  });
94
94
 
95
- test('multiple updates to same property atom in single Obj.change fire single update', () => {
95
+ test('multiple updates to same property atom in single Obj.update fire single update', () => {
96
96
  const obj = createObject(
97
97
  Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
98
98
  );
@@ -113,14 +113,14 @@ describe('Echo Atom - Batch Updates', () => {
113
113
  const initialCount = updateCount;
114
114
  expect(initialCount).toBe(1);
115
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';
116
+ // Make multiple updates to the same property in a single Obj.update call.
117
+ Obj.update(obj, (obj) => {
118
+ obj.name = 'Updated1';
119
+ obj.name = 'Updated2';
120
+ obj.name = 'Updated3';
121
121
  });
122
122
 
123
- // Should have fired once for initial + once for the Obj.change (not once per assignment).
123
+ // Should have fired once for initial + once for the Obj.update (not once per assignment).
124
124
  expect(updateCount).toBe(2);
125
125
 
126
126
  // Verify final state.
@@ -6,11 +6,12 @@ import * as Registry from '@effect-atom/atom/Registry';
6
6
  import * as Schema from 'effect/Schema';
7
7
  import { afterEach, beforeEach, describe, expect, test } from 'vitest';
8
8
 
9
+ import { sleep } from '@dxos/async';
9
10
  import { Obj, type QueryResult, Type } from '@dxos/echo';
10
11
  import { Filter, Query } from '@dxos/echo';
11
- import { TestSchema } from '@dxos/echo/testing';
12
- import { type EchoDatabase } from '@dxos/echo-db';
12
+ import { type EchoDatabase, RuntimeSchemaRegistry } from '@dxos/echo-db';
13
13
  import { EchoTestBuilder } from '@dxos/echo-db/testing';
14
+ import { TestSchema } from '@dxos/echo/testing';
14
15
  import { SpaceId } from '@dxos/keys';
15
16
 
16
17
  import * as AtomQuery from './query-atom';
@@ -261,3 +262,127 @@ describe('AtomQuery with queues', () => {
261
262
  expect(results[0].name).toEqual('jane');
262
263
  });
263
264
  });
265
+
266
+ const SchemaA = Schema.Struct({
267
+ name: Schema.String,
268
+ }).pipe(
269
+ Type.object({
270
+ typename: 'com.example.type.a',
271
+ version: '0.1.0',
272
+ }),
273
+ );
274
+
275
+ const SchemaB = Schema.Struct({
276
+ value: Schema.Number,
277
+ }).pipe(
278
+ Type.object({
279
+ typename: 'com.example.type.b',
280
+ version: '0.1.0',
281
+ }),
282
+ );
283
+
284
+ describe('AtomQuery.fromQuery with schema registry', () => {
285
+ let schemaRegistry: RuntimeSchemaRegistry;
286
+ let registry: Registry.Registry;
287
+
288
+ beforeEach(() => {
289
+ schemaRegistry = new RuntimeSchemaRegistry([]);
290
+ registry = Registry.make();
291
+ });
292
+
293
+ test('creates atom with initial results from schema query', async ({ expect }) => {
294
+ await schemaRegistry.register([SchemaA]);
295
+
296
+ const queryResult = schemaRegistry.query();
297
+ const atom = AtomQuery.fromQuery(queryResult);
298
+ const results = registry.get(atom);
299
+
300
+ expect(results).toHaveLength(1);
301
+ expect(Type.getTypename(results[0])).toBe('com.example.type.a');
302
+ });
303
+
304
+ test('atom updates when new schemas are registered', async ({ expect }) => {
305
+ await schemaRegistry.register([SchemaA]);
306
+
307
+ const queryResult = schemaRegistry.query();
308
+ const atom = AtomQuery.fromQuery(queryResult);
309
+
310
+ // Get initial results and subscribe.
311
+ const initialResults = registry.get(atom);
312
+ expect(initialResults).toHaveLength(1);
313
+
314
+ let updateCount = 0;
315
+ let latestResults: Type.AnyEntity[] = [];
316
+ registry.subscribe(atom, () => {
317
+ updateCount++;
318
+ latestResults = registry.get(atom);
319
+ });
320
+
321
+ // Allow reactive query to start (deferred via queueMicrotask).
322
+ await sleep(10);
323
+
324
+ // Register a new schema.
325
+ await schemaRegistry.register([SchemaB]);
326
+
327
+ expect(updateCount).toBeGreaterThan(0);
328
+ expect(latestResults).toHaveLength(2);
329
+ expect(latestResults.map(Type.getTypename).sort()).toEqual(['com.example.type.a', 'com.example.type.b']);
330
+ });
331
+
332
+ test('atom works with empty initial results', ({ expect }) => {
333
+ const queryResult = schemaRegistry.query();
334
+ const atom = AtomQuery.fromQuery(queryResult);
335
+ const results = registry.get(atom);
336
+
337
+ expect(results).toHaveLength(0);
338
+ });
339
+
340
+ test('atom with filtered query only reflects matching schemas', async ({ expect }) => {
341
+ const queryResult = schemaRegistry.query({ typename: 'com.example.type.a' });
342
+ const atom = AtomQuery.fromQuery(queryResult);
343
+
344
+ // Get initial (empty) results and subscribe.
345
+ const initialResults = registry.get(atom);
346
+ expect(initialResults).toHaveLength(0);
347
+
348
+ let latestResults: Type.AnyEntity[] = [];
349
+ registry.subscribe(atom, () => {
350
+ latestResults = registry.get(atom);
351
+ });
352
+
353
+ await sleep(10);
354
+
355
+ // Register non-matching schema.
356
+ await schemaRegistry.register([SchemaB]);
357
+ // Results updated but still empty for this filter.
358
+ expect(latestResults).toHaveLength(0);
359
+
360
+ // Register matching schema.
361
+ await schemaRegistry.register([SchemaA]);
362
+ expect(latestResults).toHaveLength(1);
363
+ expect(Type.getTypename(latestResults[0])).toBe('com.example.type.a');
364
+ });
365
+
366
+ test('unsubscribing from atom stops updates', async ({ expect }) => {
367
+ const queryResult = schemaRegistry.query();
368
+ const atom = AtomQuery.fromQuery(queryResult);
369
+
370
+ registry.get(atom);
371
+
372
+ let updateCount = 0;
373
+ const unsubscribe = registry.subscribe(atom, () => {
374
+ updateCount++;
375
+ });
376
+
377
+ await sleep(10);
378
+
379
+ await schemaRegistry.register([SchemaA]);
380
+ const countAfterFirst = updateCount;
381
+ expect(countAfterFirst).toBeGreaterThan(0);
382
+
383
+ unsubscribe();
384
+
385
+ await schemaRegistry.register([SchemaB]);
386
+ expect(updateCount).toBe(countAfterFirst);
387
+ });
388
+ });
package/src/query-atom.ts CHANGED
@@ -8,7 +8,7 @@ import { DXN, Database, type Entity, type Filter, Query, type QueryResult } from
8
8
  import { WeakDictionary } from '@dxos/util';
9
9
 
10
10
  /**
11
- * Create a self-updating atom from an existing QueryResult.
11
+ * Create a self-updating atom from any QueryResult (e.g. schema registry queries).
12
12
  * Internally subscribes to queryResult and uses get.setSelf to update.
13
13
  * Cleanup is handled via get.addFinalizer.
14
14
  *
@@ -17,18 +17,13 @@ import { WeakDictionary } from '@dxos/util';
17
17
  * @param queryResult - The QueryResult to wrap.
18
18
  * @returns An atom that automatically updates when query results change.
19
19
  */
20
- export const fromQuery = <T extends Entity.Unknown>(queryResult: QueryResult.QueryResult<T>): Atom.Atom<T[]> =>
20
+ export const fromQuery = <T>(queryResult: QueryResult.QueryResult<T>): Atom.Atom<T[]> =>
21
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
22
  const unsubscribe = queryResult.subscribe(() => {
25
- get.setSelf(queryResult.results);
23
+ get.setSelf(queryResult.runSync());
26
24
  });
27
-
28
- // Register cleanup for when atom is no longer used.
29
25
  get.addFinalizer(unsubscribe);
30
-
31
- return queryResult.results;
26
+ return queryResult.runSync();
32
27
  });
33
28
 
34
29
  // Registry: key → Queryable (WeakRef with auto-cleanup when GC'd).
@@ -6,8 +6,8 @@ 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
11
  import { arrayMove } from '@dxos/util';
12
12
 
13
13
  import * as AtomObj from './atom';
@@ -28,9 +28,9 @@ describe('Echo Atom - Reactivity', () => {
28
28
  // Subscribe to enable reactivity.
29
29
  registry.subscribe(atom, () => {});
30
30
 
31
- // Update the object via Obj.change.
32
- Obj.change(obj, (o) => {
33
- o.name = 'Updated';
31
+ // Update the object via Obj.update.
32
+ Obj.update(obj, (obj) => {
33
+ obj.name = 'Updated';
34
34
  });
35
35
 
36
36
  const updatedSnapshot = registry.get(atom);
@@ -50,9 +50,9 @@ describe('Echo Atom - Reactivity', () => {
50
50
  // Subscribe to enable reactivity.
51
51
  registry.subscribe(atom, () => {});
52
52
 
53
- // Update the property via Obj.change.
54
- Obj.change(obj, (o) => {
55
- o.name = 'Updated';
53
+ // Update the property via Obj.update.
54
+ Obj.update(obj, (obj) => {
55
+ obj.name = 'Updated';
56
56
  });
57
57
 
58
58
  expect(registry.get(atom)).toBe('Updated');
@@ -82,9 +82,9 @@ describe('Echo Atom - Reactivity', () => {
82
82
  emailUpdateCount++;
83
83
  });
84
84
 
85
- // Update only email property via Obj.change.
86
- Obj.change(obj, (o) => {
87
- o.email = 'updated@example.com';
85
+ // Update only email property via Obj.update.
86
+ Obj.update(obj, (obj) => {
87
+ obj.email = 'updated@example.com';
88
88
  });
89
89
 
90
90
  // Name atom should NOT have changed.
@@ -109,10 +109,10 @@ describe('Echo Atom - Reactivity', () => {
109
109
  registry.subscribe(nameAtom, () => {});
110
110
  registry.subscribe(emailAtom, () => {});
111
111
 
112
- // Update multiple properties via Obj.change.
113
- Obj.change(obj, (o) => {
114
- o.name = 'Updated';
115
- 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';
116
116
  });
117
117
 
118
118
  expect(registry.get(nameAtom)).toBe('Updated');
@@ -140,15 +140,15 @@ describe('Echo Atom - Reactivity', () => {
140
140
  const initialCount = updateCount;
141
141
  expect(initialCount).toBe(1);
142
142
 
143
- // Update object via Obj.change.
144
- Obj.change(obj, (o) => {
145
- o.name = 'Updated';
143
+ // Update object via Obj.update.
144
+ Obj.update(obj, (obj) => {
145
+ obj.name = 'Updated';
146
146
  });
147
- Obj.change(obj, (o) => {
148
- o.email = 'updated@example.com';
147
+ Obj.update(obj, (obj) => {
148
+ obj.email = 'updated@example.com';
149
149
  });
150
150
 
151
- // Updates fire through Obj.subscribe (one per Obj.change call).
151
+ // Updates fire through Obj.subscribe (one per Obj.update call).
152
152
  expect(updateCount).toBe(initialCount + 2);
153
153
 
154
154
  // Verify final state - returns snapshot (plain object).
@@ -167,8 +167,8 @@ describe('Echo Atom - Reactivity', () => {
167
167
  });
168
168
 
169
169
  actions.push('before');
170
- Obj.change(obj, (o) => {
171
- o.name = 'Updated';
170
+ Obj.update(obj, (obj) => {
171
+ obj.name = 'Updated';
172
172
  });
173
173
  actions.push('after');
174
174
 
@@ -191,8 +191,8 @@ describe('Echo Atom - Reactivity', () => {
191
191
  });
192
192
 
193
193
  actions.push('before');
194
- Obj.change(obj, (o) => {
195
- o.stringArray!.splice(1, 1);
194
+ Obj.update(obj, (obj) => {
195
+ obj.stringArray!.splice(1, 1);
196
196
  });
197
197
  actions.push('after');
198
198
 
@@ -222,7 +222,7 @@ describe('Echo Atom - Reactivity', () => {
222
222
  });
223
223
 
224
224
  // Reorder in place (e.g. move first to last).
225
- Obj.change(obj, (obj) => {
225
+ Obj.update(obj, (obj) => {
226
226
  arrayMove(obj.stringArray!, 0, 2);
227
227
  });
228
228
 
@@ -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', () => {
@@ -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', () => {
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;