@dxos/echo-atom 0.8.4-main.ef1bc66f44 → 0.8.4-main.fcc0d83b33
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 +52 -11
- package/dist/lib/neutral/index.mjs.map +3 -3
- package/dist/lib/neutral/meta.json +1 -1
- package/dist/types/src/atom.d.ts +19 -6
- package/dist/types/src/atom.d.ts.map +1 -1
- package/dist/types/src/query-atom.d.ts +2 -2
- package/dist/types/src/query-atom.d.ts.map +1 -1
- package/dist/types/src/ref-utils.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -12
- package/src/atom.test.ts +106 -16
- package/src/atom.ts +87 -18
- package/src/batching.test.ts +24 -24
- package/src/query-atom.test.ts +138 -12
- package/src/query-atom.ts +5 -10
- package/src/reactivity.test.ts +51 -24
- package/src/ref-atom.test.ts +45 -11
- package/src/ref-utils.ts +17 -2
package/src/query-atom.test.ts
CHANGED
|
@@ -6,10 +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
|
-
import {
|
|
11
|
-
import { type EchoDatabase,
|
|
11
|
+
import { Filter, Query } from '@dxos/echo';
|
|
12
|
+
import { type EchoDatabase, RuntimeSchemaRegistry } from '@dxos/echo-db';
|
|
12
13
|
import { EchoTestBuilder } from '@dxos/echo-db/testing';
|
|
14
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
13
15
|
import { SpaceId } from '@dxos/keys';
|
|
14
16
|
|
|
15
17
|
import * as AtomQuery from './query-atom';
|
|
@@ -22,7 +24,7 @@ const TestItem = Schema.Struct({
|
|
|
22
24
|
value: Schema.Number,
|
|
23
25
|
}).pipe(
|
|
24
26
|
Type.object({
|
|
25
|
-
typename: 'example.
|
|
27
|
+
typename: 'com.example.type.test-item',
|
|
26
28
|
version: '0.1.0',
|
|
27
29
|
}),
|
|
28
30
|
);
|
|
@@ -47,7 +49,7 @@ describe('AtomQuery', () => {
|
|
|
47
49
|
test('creates atom with initial results', async () => {
|
|
48
50
|
db.add(Obj.make(TestItem, { name: 'Object 1', value: 100 }));
|
|
49
51
|
db.add(Obj.make(TestItem, { name: 'Object 2', value: 100 }));
|
|
50
|
-
await db.flush(
|
|
52
|
+
await db.flush();
|
|
51
53
|
|
|
52
54
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
53
55
|
Query.select(Filter.type(TestItem, { value: 100 })),
|
|
@@ -63,7 +65,7 @@ describe('AtomQuery', () => {
|
|
|
63
65
|
|
|
64
66
|
test('registry.subscribe fires on QueryResult changes', async () => {
|
|
65
67
|
db.add(Obj.make(TestItem, { name: 'Initial', value: 200 }));
|
|
66
|
-
await db.flush(
|
|
68
|
+
await db.flush();
|
|
67
69
|
|
|
68
70
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
69
71
|
Query.select(Filter.type(TestItem, { value: 200 })),
|
|
@@ -86,7 +88,7 @@ describe('AtomQuery', () => {
|
|
|
86
88
|
|
|
87
89
|
// Add a new object that matches the query.
|
|
88
90
|
db.add(Obj.make(TestItem, { name: 'New Object', value: 200 }));
|
|
89
|
-
await db.flush({
|
|
91
|
+
await db.flush({ updates: true });
|
|
90
92
|
|
|
91
93
|
// Subscription should have fired.
|
|
92
94
|
expect(updateCount).toBeGreaterThan(0);
|
|
@@ -96,7 +98,7 @@ describe('AtomQuery', () => {
|
|
|
96
98
|
test('registry.subscribe fires when objects are removed', async () => {
|
|
97
99
|
const obj1 = db.add(Obj.make(TestItem, { name: 'Object 1', value: 300 }));
|
|
98
100
|
db.add(Obj.make(TestItem, { name: 'Object 2', value: 300 }));
|
|
99
|
-
await db.flush(
|
|
101
|
+
await db.flush();
|
|
100
102
|
|
|
101
103
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
102
104
|
Query.select(Filter.type(TestItem, { value: 300 })),
|
|
@@ -119,7 +121,7 @@ describe('AtomQuery', () => {
|
|
|
119
121
|
|
|
120
122
|
// Remove an object.
|
|
121
123
|
db.remove(obj1);
|
|
122
|
-
await db.flush({
|
|
124
|
+
await db.flush({ updates: true });
|
|
123
125
|
|
|
124
126
|
// Subscription should have fired.
|
|
125
127
|
expect(updateCount).toBeGreaterThan(0);
|
|
@@ -129,7 +131,7 @@ describe('AtomQuery', () => {
|
|
|
129
131
|
|
|
130
132
|
test('unsubscribing from registry stops receiving updates', async () => {
|
|
131
133
|
db.add(Obj.make(TestItem, { name: 'Initial', value: 400 }));
|
|
132
|
-
await db.flush(
|
|
134
|
+
await db.flush();
|
|
133
135
|
|
|
134
136
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
135
137
|
Query.select(Filter.type(TestItem, { value: 400 })),
|
|
@@ -150,7 +152,7 @@ describe('AtomQuery', () => {
|
|
|
150
152
|
|
|
151
153
|
// Add object and verify subscription fires.
|
|
152
154
|
db.add(Obj.make(TestItem, { name: 'Object 2', value: 400 }));
|
|
153
|
-
await db.flush({
|
|
155
|
+
await db.flush({ updates: true });
|
|
154
156
|
const countAfterFirstAdd = updateCount;
|
|
155
157
|
expect(countAfterFirstAdd).toBeGreaterThan(0);
|
|
156
158
|
|
|
@@ -159,7 +161,7 @@ describe('AtomQuery', () => {
|
|
|
159
161
|
|
|
160
162
|
// Add another object.
|
|
161
163
|
db.add(Obj.make(TestItem, { name: 'Object 3', value: 400 }));
|
|
162
|
-
await db.flush({
|
|
164
|
+
await db.flush({ updates: true });
|
|
163
165
|
|
|
164
166
|
// Update count should not have changed after unsubscribe.
|
|
165
167
|
expect(updateCount).toBe(countAfterFirstAdd);
|
|
@@ -179,7 +181,7 @@ describe('AtomQuery', () => {
|
|
|
179
181
|
|
|
180
182
|
test('multiple atoms from same query share underlying subscription', async () => {
|
|
181
183
|
db.add(Obj.make(TestItem, { name: 'Object', value: 500 }));
|
|
182
|
-
await db.flush(
|
|
184
|
+
await db.flush();
|
|
183
185
|
|
|
184
186
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
185
187
|
Query.select(Filter.type(TestItem, { value: 500 })),
|
|
@@ -260,3 +262,127 @@ describe('AtomQuery with queues', () => {
|
|
|
260
262
|
expect(results[0].name).toEqual('jane');
|
|
261
263
|
});
|
|
262
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
|
|
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
|
|
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.
|
|
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).
|
|
@@ -37,7 +32,7 @@ const queryableRegistry = new WeakDictionary<string, Database.Queryable>();
|
|
|
37
32
|
// Key separator that won't appear in identifiers (DXN strings use colons).
|
|
38
33
|
const KEY_SEPARATOR = '~';
|
|
39
34
|
|
|
40
|
-
// Atom.family keyed by "identifier
|
|
35
|
+
// Atom.family keyed by "identifier~serializedAST".
|
|
41
36
|
const queryFamily = Atom.family((key: string) => {
|
|
42
37
|
// Parse key outside Atom.make - runs once per key.
|
|
43
38
|
const separatorIndex = key.indexOf(KEY_SEPARATOR);
|
package/src/reactivity.test.ts
CHANGED
|
@@ -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.
|
|
31
|
-
Obj.
|
|
32
|
-
|
|
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.
|
|
53
|
-
Obj.
|
|
54
|
-
|
|
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.
|
|
85
|
-
Obj.
|
|
86
|
-
|
|
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.
|
|
112
|
-
Obj.
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
143
|
-
Obj.
|
|
144
|
-
|
|
143
|
+
// Update object via Obj.update.
|
|
144
|
+
Obj.update(obj, (obj) => {
|
|
145
|
+
obj.name = 'Updated';
|
|
145
146
|
});
|
|
146
|
-
Obj.
|
|
147
|
-
|
|
147
|
+
Obj.update(obj, (obj) => {
|
|
148
|
+
obj.email = 'updated@example.com';
|
|
148
149
|
});
|
|
149
150
|
|
|
150
|
-
// Updates fire through Obj.subscribe (one per Obj.
|
|
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.
|
|
170
|
-
|
|
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.
|
|
194
|
-
|
|
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
|
});
|
package/src/ref-atom.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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.
|
|
64
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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;
|