@dxos/echo-atom 0.8.4-main.bc674ce → 0.8.4-main.c351d160a8
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/{browser → neutral}/index.mjs +46 -12
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- 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/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -12
- package/src/atom.test.ts +114 -2
- package/src/atom.ts +91 -22
- package/src/query-atom.test.ts +137 -11
- package/src/query-atom.ts +5 -10
- package/src/reactivity.test.ts +27 -0
- package/src/ref-atom.test.ts +11 -11
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/node-esm/index.mjs +0 -179
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/echo-atom",
|
|
3
|
-
"version": "0.8.4-main.
|
|
3
|
+
"version": "0.8.4-main.c351d160a8",
|
|
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",
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
".": {
|
|
17
17
|
"source": "./src/index.ts",
|
|
18
18
|
"types": "./dist/types/src/index.d.ts",
|
|
19
|
-
"
|
|
20
|
-
"node": "./dist/lib/node-esm/index.mjs"
|
|
19
|
+
"default": "./dist/lib/neutral/index.mjs"
|
|
21
20
|
}
|
|
22
21
|
},
|
|
23
22
|
"types": "dist/types/src/index.d.ts",
|
|
@@ -29,17 +28,19 @@
|
|
|
29
28
|
"src"
|
|
30
29
|
],
|
|
31
30
|
"dependencies": {
|
|
32
|
-
"@effect-atom/atom": "^0.
|
|
33
|
-
"
|
|
34
|
-
"@dxos/echo": "0.8.4-main.
|
|
35
|
-
"@dxos/
|
|
36
|
-
"@dxos/util": "0.8.4-main.
|
|
37
|
-
"@dxos/invariant": "0.8.4-main.bc674ce"
|
|
31
|
+
"@effect-atom/atom": "^0.5.1",
|
|
32
|
+
"@dxos/echo": "0.8.4-main.c351d160a8",
|
|
33
|
+
"@dxos/echo-db": "0.8.4-main.c351d160a8",
|
|
34
|
+
"@dxos/invariant": "0.8.4-main.c351d160a8",
|
|
35
|
+
"@dxos/util": "0.8.4-main.c351d160a8"
|
|
38
36
|
},
|
|
39
37
|
"devDependencies": {
|
|
40
|
-
"
|
|
41
|
-
"@dxos/random": "0.8.4-main.
|
|
42
|
-
"@dxos/test-utils": "0.8.4-main.
|
|
38
|
+
"effect": "3.19.16",
|
|
39
|
+
"@dxos/random": "0.8.4-main.c351d160a8",
|
|
40
|
+
"@dxos/test-utils": "0.8.4-main.c351d160a8"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"effect": "3.19.16"
|
|
43
44
|
},
|
|
44
45
|
"publishConfig": {
|
|
45
46
|
"access": "public"
|
package/src/atom.test.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import * as Registry from '@effect-atom/atom/Registry';
|
|
6
|
-
import { describe, expect, test } from 'vitest';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
7
7
|
|
|
8
|
-
import { Obj } from '@dxos/echo';
|
|
8
|
+
import { Obj, Ref } from '@dxos/echo';
|
|
9
9
|
import { TestSchema } from '@dxos/echo/testing';
|
|
10
10
|
import { createObject } from '@dxos/echo-db';
|
|
11
|
+
import { EchoTestBuilder } from '@dxos/echo-db/testing';
|
|
11
12
|
|
|
12
13
|
import * as AtomObj from './atom';
|
|
13
14
|
|
|
@@ -283,4 +284,115 @@ describe('Echo Atom - Referential Equality', () => {
|
|
|
283
284
|
expect(updateCount).toBe(2);
|
|
284
285
|
expect(registry.get(atom1)).toBe('Updated');
|
|
285
286
|
});
|
|
287
|
+
|
|
288
|
+
test('AtomObj.make returns same atom instance for different ref instances with same DXN', ({ expect }) => {
|
|
289
|
+
const org = createObject(Obj.make(TestSchema.Organization, { name: 'DXOS' }));
|
|
290
|
+
const person = createObject(
|
|
291
|
+
Obj.make(TestSchema.Person, {
|
|
292
|
+
name: 'Test',
|
|
293
|
+
username: 'test',
|
|
294
|
+
email: 'test@example.com',
|
|
295
|
+
employer: Ref.make(org),
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// Each property access returns a new Ref instance from the ECHO proxy.
|
|
300
|
+
const ref1 = person.employer!;
|
|
301
|
+
const ref2 = person.employer!;
|
|
302
|
+
expect(ref1).not.toBe(ref2);
|
|
303
|
+
|
|
304
|
+
// Despite being different Ref instances, they should resolve to the same atom.
|
|
305
|
+
const atom1 = AtomObj.make(ref1);
|
|
306
|
+
const atom2 = AtomObj.make(ref2);
|
|
307
|
+
expect(atom1).toBe(atom2);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('AtomObj.makeWithReactive', () => {
|
|
312
|
+
test('returns object for direct obj input', () => {
|
|
313
|
+
const obj = createObject(
|
|
314
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const registry = Registry.make();
|
|
318
|
+
const atom = AtomObj.makeWithReactive(obj);
|
|
319
|
+
const result = registry.get(atom);
|
|
320
|
+
|
|
321
|
+
expect(result).toBe(obj);
|
|
322
|
+
expect(result?.name).toBe('Test');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('returns same atom for same object', () => {
|
|
326
|
+
const obj = createObject(
|
|
327
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const atom1 = AtomObj.makeWithReactive(obj);
|
|
331
|
+
const atom2 = AtomObj.makeWithReactive(obj);
|
|
332
|
+
|
|
333
|
+
expect(atom1).toBe(atom2);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('with ref input', () => {
|
|
337
|
+
let testBuilder: InstanceType<typeof EchoTestBuilder>;
|
|
338
|
+
|
|
339
|
+
beforeEach(async () => {
|
|
340
|
+
testBuilder = await new EchoTestBuilder().open();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
afterEach(async () => {
|
|
344
|
+
await testBuilder.close();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('returns resolved object for ref', async ({ expect }) => {
|
|
348
|
+
const { db } = await testBuilder.createDatabase({
|
|
349
|
+
types: [TestSchema.Person, TestSchema.Task],
|
|
350
|
+
});
|
|
351
|
+
const task = db.add(Obj.make(TestSchema.Task, { title: 'Task 1' }));
|
|
352
|
+
const person = db.add(
|
|
353
|
+
Obj.make(TestSchema.Person, {
|
|
354
|
+
name: 'Test',
|
|
355
|
+
username: 'test',
|
|
356
|
+
email: 'test@example.com',
|
|
357
|
+
tasks: [Ref.make(task)],
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
await db.flush();
|
|
361
|
+
|
|
362
|
+
const ref = person.tasks![0];
|
|
363
|
+
const registry = Registry.make();
|
|
364
|
+
const atom = AtomObj.makeWithReactive(ref);
|
|
365
|
+
const result = registry.get(atom);
|
|
366
|
+
|
|
367
|
+
expect(result).toBe(task);
|
|
368
|
+
expect(result?.title).toBe('Task 1');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('returns undefined when ref target was removed', async ({ expect }) => {
|
|
372
|
+
const { db } = await testBuilder.createDatabase({
|
|
373
|
+
types: [TestSchema.Person, TestSchema.Task],
|
|
374
|
+
});
|
|
375
|
+
const task = db.add(Obj.make(TestSchema.Task, { title: 'Task 1' }));
|
|
376
|
+
const person = db.add(
|
|
377
|
+
Obj.make(TestSchema.Person, {
|
|
378
|
+
name: 'Test',
|
|
379
|
+
username: 'test',
|
|
380
|
+
email: 'test@example.com',
|
|
381
|
+
tasks: [Ref.make(task)],
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
await db.flush();
|
|
385
|
+
|
|
386
|
+
const ref = person.tasks![0];
|
|
387
|
+
const registry = Registry.make();
|
|
388
|
+
const atom = AtomObj.makeWithReactive(ref);
|
|
389
|
+
|
|
390
|
+
expect(registry.get(atom)).toBe(task);
|
|
391
|
+
|
|
392
|
+
db.remove(task);
|
|
393
|
+
await db.flush();
|
|
394
|
+
|
|
395
|
+
expect(registry.get(atom)).toBeUndefined();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
286
398
|
});
|
package/src/atom.ts
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import * as Atom from '@effect-atom/atom/Atom';
|
|
6
|
-
import
|
|
6
|
+
import * as Result from '@effect-atom/atom/Result';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Function from 'effect/Function';
|
|
9
|
+
import * as Option from 'effect/Option';
|
|
7
10
|
|
|
8
|
-
import { Obj, Ref } from '@dxos/echo';
|
|
11
|
+
import { type Entity, Obj, Ref, Relation } from '@dxos/echo';
|
|
9
12
|
import { assertArgument } from '@dxos/invariant';
|
|
10
13
|
|
|
11
14
|
import { loadRefTarget } from './ref-utils';
|
|
@@ -23,13 +26,13 @@ const objectFamily = Atom.family(<T extends Obj.Unknown>(obj: T): Atom.Atom<Obj.
|
|
|
23
26
|
get.addFinalizer(() => unsubscribe());
|
|
24
27
|
|
|
25
28
|
return Obj.getSnapshot(obj);
|
|
26
|
-
});
|
|
29
|
+
}).pipe(Atom.keepAlive);
|
|
27
30
|
});
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
+
* Atom family for ECHO refs.
|
|
34
|
+
* RefImpl implements Effect's Hash/Equal traits using DXN, so different Ref instances
|
|
35
|
+
* pointing to the same object resolve to the same atom.
|
|
33
36
|
*/
|
|
34
37
|
const refFamily = Atom.family(<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<Obj.Snapshot<T> | undefined> => {
|
|
35
38
|
return Atom.make<Obj.Snapshot<T> | undefined>((get) => {
|
|
@@ -43,10 +46,12 @@ const refFamily = Atom.family(<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Ato
|
|
|
43
46
|
return Obj.getSnapshot(target);
|
|
44
47
|
};
|
|
45
48
|
|
|
46
|
-
get.addFinalizer(() =>
|
|
49
|
+
get.addFinalizer(() => {
|
|
50
|
+
unsubscribeTarget?.();
|
|
51
|
+
});
|
|
47
52
|
|
|
48
53
|
return loadRefTarget(ref, get, setupTargetSubscription);
|
|
49
|
-
});
|
|
54
|
+
}).pipe(Atom.keepAlive);
|
|
50
55
|
});
|
|
51
56
|
|
|
52
57
|
/**
|
|
@@ -78,7 +83,7 @@ const propertyFamily = Atom.family(<T extends Obj.Unknown>(obj: T) =>
|
|
|
78
83
|
|
|
79
84
|
const unsubscribe = Obj.subscribe(obj, () => {
|
|
80
85
|
const newValue = obj[key];
|
|
81
|
-
if (
|
|
86
|
+
if (previousSnapshot !== newValue) {
|
|
82
87
|
previousSnapshot = snapshotForComparison(newValue);
|
|
83
88
|
// Return a snapshot copy so React sees a new reference.
|
|
84
89
|
get.setSelf(snapshotForComparison(newValue));
|
|
@@ -89,43 +94,46 @@ const propertyFamily = Atom.family(<T extends Obj.Unknown>(obj: T) =>
|
|
|
89
94
|
|
|
90
95
|
// Return a snapshot copy so React sees a new reference.
|
|
91
96
|
return snapshotForComparison(obj[key]);
|
|
92
|
-
});
|
|
97
|
+
}).pipe(Atom.keepAlive);
|
|
93
98
|
}),
|
|
94
99
|
);
|
|
95
100
|
|
|
96
101
|
/**
|
|
97
|
-
* Create a read-only atom for a reactive object or ref.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
102
|
+
* Create a read-only atom for a single reactive object or ref.
|
|
103
|
+
* Returns {@link Obj.Snapshot} (immutable plain data), not the live reactive object.
|
|
104
|
+
* Use this when you need one object's data for display or React dependency tracking.
|
|
100
105
|
* The atom updates automatically when the object is mutated.
|
|
101
106
|
* For refs, automatically handles async loading.
|
|
102
|
-
* Uses Atom.family internally - same object/ref
|
|
107
|
+
* Uses Atom.family internally - same object/ref returns same atom instance.
|
|
103
108
|
*
|
|
104
109
|
* @param objOrRef - The reactive object or ref to create an atom for, or undefined.
|
|
105
|
-
* @returns An atom that returns the object snapshot. Returns undefined only for refs (async loading) or undefined input.
|
|
110
|
+
* @returns An atom that returns the object snapshot (plain data). Returns undefined only for refs (async loading) or undefined input.
|
|
106
111
|
*/
|
|
107
112
|
export function make<T extends Obj.Unknown>(obj: T): Atom.Atom<Obj.Snapshot<T>>;
|
|
113
|
+
export function make<T extends Relation.Unknown>(relation: T): Atom.Atom<Relation.Snapshot<T>>;
|
|
114
|
+
export function make<T extends Entity.Unknown>(entity: T): Atom.Atom<Entity.Snapshot>;
|
|
108
115
|
export function make<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<Obj.Snapshot<T> | undefined>;
|
|
109
116
|
export function make<T extends Obj.Unknown>(
|
|
110
117
|
objOrRef: T | Ref.Ref<T> | undefined,
|
|
111
118
|
): Atom.Atom<Obj.Snapshot<T> | undefined>;
|
|
112
|
-
export function make<T extends
|
|
119
|
+
export function make<T extends Entity.Unknown>(
|
|
113
120
|
objOrRef: T | Ref.Ref<T> | undefined,
|
|
114
|
-
): Atom.Atom<
|
|
121
|
+
): Atom.Atom<Entity.Snapshot | undefined> {
|
|
115
122
|
if (objOrRef === undefined) {
|
|
116
|
-
return Atom.make<
|
|
123
|
+
return Atom.make<Entity.Snapshot | undefined>(() => undefined);
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
// Handle Ref inputs.
|
|
120
127
|
if (Ref.isRef(objOrRef)) {
|
|
121
|
-
return refFamily(objOrRef as
|
|
128
|
+
return refFamily(objOrRef as any);
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
// At this point, objOrRef is definitely T (not a Ref).
|
|
125
132
|
const obj = objOrRef as T;
|
|
126
|
-
assertArgument(Obj.isObject(obj), 'obj', 'Object must be a reactive object');
|
|
133
|
+
assertArgument(Obj.isObject(obj) || Relation.isRelation(obj), 'obj', 'Object must be a reactive object');
|
|
127
134
|
|
|
128
|
-
|
|
135
|
+
// TODO(dmaretskyi): Fix echo types during review.
|
|
136
|
+
return objectFamily(obj as any);
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
/**
|
|
@@ -153,6 +161,67 @@ export function makeProperty<T extends Obj.Unknown, K extends keyof T>(
|
|
|
153
161
|
}
|
|
154
162
|
|
|
155
163
|
assertArgument(Obj.isObject(obj), 'obj', 'Object must be a reactive object');
|
|
156
|
-
assertArgument(key in obj, 'key', 'Property must exist on object');
|
|
157
164
|
return propertyFamily(obj)(key);
|
|
158
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Atom family for ECHO objects - returns the live object, not a snapshot.
|
|
169
|
+
* Same as objectFamily but returns T instead of Obj.Snapshot<T>.
|
|
170
|
+
*/
|
|
171
|
+
const objectWithReactiveFamily = Atom.family(<T extends Obj.Unknown>(obj: T): Atom.Atom<T> => {
|
|
172
|
+
return Atom.make<T>((get) => {
|
|
173
|
+
const unsubscribe = Obj.subscribe(obj, () => {
|
|
174
|
+
get.setSelf(obj);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
get.addFinalizer(() => unsubscribe());
|
|
178
|
+
|
|
179
|
+
return obj;
|
|
180
|
+
}).pipe(Atom.keepAlive);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Atom family for ECHO refs - returns the live reactive object, not a snapshot.
|
|
185
|
+
* Resolves the ref via the database; returns undefined while loading or if unresolved.
|
|
186
|
+
*/
|
|
187
|
+
const refWithReactiveFamily = Atom.family(<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<T | undefined> => {
|
|
188
|
+
const effect = (get: Atom.Context) =>
|
|
189
|
+
Effect.gen(function* () {
|
|
190
|
+
const snapshot = get(make(ref));
|
|
191
|
+
if (snapshot == null) return undefined;
|
|
192
|
+
const option = yield* Obj.getReactiveOption(snapshot);
|
|
193
|
+
return Option.getOrElse(option, () => undefined);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return Function.pipe(
|
|
197
|
+
Atom.make(effect),
|
|
198
|
+
Atom.map((result) => Result.getOrElse(result, () => undefined)),
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Like {@link make} but returns the live reactive object instead of a snapshot.
|
|
204
|
+
* Same input: Obj or Ref.Ref. Same output shape: Atom that updates when the object mutates.
|
|
205
|
+
* Prefer {@link make} (snapshot) unless you need the live Obj.Obj for generic mutations (e.g. Obj.change).
|
|
206
|
+
*
|
|
207
|
+
* @param objOrRef - The reactive object or ref.
|
|
208
|
+
* @returns An atom that returns the live object. Returns undefined for refs (async loading) or undefined input.
|
|
209
|
+
*/
|
|
210
|
+
export function makeWithReactive<T extends Obj.Unknown>(obj: T): Atom.Atom<T>;
|
|
211
|
+
export function makeWithReactive<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<T | undefined>;
|
|
212
|
+
export function makeWithReactive<T extends Obj.Unknown>(objOrRef: T | Ref.Ref<T> | undefined): Atom.Atom<T | undefined>;
|
|
213
|
+
export function makeWithReactive<T extends Obj.Unknown>(
|
|
214
|
+
objOrRef: T | Ref.Ref<T> | undefined,
|
|
215
|
+
): Atom.Atom<T | undefined> {
|
|
216
|
+
if (objOrRef === undefined) {
|
|
217
|
+
return Atom.make<T | undefined>(() => undefined);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (Ref.isRef(objOrRef)) {
|
|
221
|
+
return refWithReactiveFamily(objOrRef as Ref.Ref<T>);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const obj = objOrRef as T;
|
|
225
|
+
assertArgument(Obj.isObject(obj), 'obj', 'Object must be a reactive object');
|
|
226
|
+
return objectWithReactiveFamily(obj);
|
|
227
|
+
}
|
package/src/query-atom.test.ts
CHANGED
|
@@ -6,9 +6,11 @@ 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';
|
|
11
|
+
import { Filter, Query } from '@dxos/echo';
|
|
10
12
|
import { TestSchema } from '@dxos/echo/testing';
|
|
11
|
-
import { type EchoDatabase,
|
|
13
|
+
import { type EchoDatabase, RuntimeSchemaRegistry } from '@dxos/echo-db';
|
|
12
14
|
import { EchoTestBuilder } from '@dxos/echo-db/testing';
|
|
13
15
|
import { SpaceId } from '@dxos/keys';
|
|
14
16
|
|
|
@@ -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
|
@@ -8,6 +8,7 @@ import { describe, expect, test } from 'vitest';
|
|
|
8
8
|
import { Obj } from '@dxos/echo';
|
|
9
9
|
import { TestSchema } from '@dxos/echo/testing';
|
|
10
10
|
import { createObject } from '@dxos/echo-db';
|
|
11
|
+
import { arrayMove } from '@dxos/util';
|
|
11
12
|
|
|
12
13
|
import * as AtomObj from './atom';
|
|
13
14
|
|
|
@@ -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.change(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
|
});
|