@dxos/echo-atom 0.0.0 → 0.8.4-main.1068cf700f
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/dist/lib/neutral/index.mjs +214 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/types/src/atom.d.ts +41 -0
- package/dist/types/src/atom.d.ts.map +1 -0
- package/dist/types/src/atom.test.d.ts +2 -0
- package/dist/types/src/atom.test.d.ts.map +1 -0
- package/dist/types/src/batching.test.d.ts +2 -0
- package/dist/types/src/batching.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/query-atom.d.ts +24 -0
- package/dist/types/src/query-atom.d.ts.map +1 -0
- package/dist/types/src/query-atom.test.d.ts +2 -0
- package/dist/types/src/query-atom.test.d.ts.map +1 -0
- package/dist/types/src/reactivity.test.d.ts +2 -0
- package/dist/types/src/reactivity.test.d.ts.map +1 -0
- package/dist/types/src/ref-atom.d.ts +13 -0
- package/dist/types/src/ref-atom.d.ts.map +1 -0
- package/dist/types/src/ref-atom.test.d.ts +2 -0
- package/dist/types/src/ref-atom.test.d.ts.map +1 -0
- package/dist/types/src/ref-utils.d.ts +14 -0
- package/dist/types/src/ref-utils.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +17 -11
- package/src/atom.test.ts +249 -3
- package/src/atom.ts +163 -51
- package/src/query-atom.test.ts +63 -1
- package/src/query-atom.ts +7 -4
- package/src/reactivity.test.ts +48 -0
- package/src/ref-atom.test.ts +209 -0
- package/src/ref-atom.ts +17 -9
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/echo-atom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.4-main.1068cf700f",
|
|
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",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dxos/dxos"
|
|
10
|
+
},
|
|
7
11
|
"license": "MIT",
|
|
8
12
|
"author": "DXOS.org",
|
|
9
13
|
"sideEffects": false,
|
|
@@ -12,8 +16,7 @@
|
|
|
12
16
|
".": {
|
|
13
17
|
"source": "./src/index.ts",
|
|
14
18
|
"types": "./dist/types/src/index.d.ts",
|
|
15
|
-
"
|
|
16
|
-
"node": "./dist/lib/node-esm/index.mjs"
|
|
19
|
+
"default": "./dist/lib/neutral/index.mjs"
|
|
17
20
|
}
|
|
18
21
|
},
|
|
19
22
|
"types": "dist/types/src/index.d.ts",
|
|
@@ -25,18 +28,21 @@
|
|
|
25
28
|
"src"
|
|
26
29
|
],
|
|
27
30
|
"dependencies": {
|
|
28
|
-
"@effect-atom/atom": "^0.
|
|
31
|
+
"@effect-atom/atom": "^0.5.1",
|
|
29
32
|
"lodash.isequal": "^4.5.0",
|
|
30
|
-
"@dxos/echo-db": "0.8.
|
|
31
|
-
"@dxos/
|
|
32
|
-
"@dxos/
|
|
33
|
-
"@dxos/invariant": "0.8.
|
|
34
|
-
"@dxos/util": "0.8.3"
|
|
33
|
+
"@dxos/echo-db": "0.8.4-main.1068cf700f",
|
|
34
|
+
"@dxos/echo": "0.8.4-main.1068cf700f",
|
|
35
|
+
"@dxos/util": "0.8.4-main.1068cf700f",
|
|
36
|
+
"@dxos/invariant": "0.8.4-main.1068cf700f"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@types/lodash.isequal": "^4.5.0",
|
|
38
|
-
"
|
|
39
|
-
"@dxos/random": "0.8.
|
|
40
|
+
"effect": "3.19.16",
|
|
41
|
+
"@dxos/random": "0.8.4-main.1068cf700f",
|
|
42
|
+
"@dxos/test-utils": "0.8.4-main.1068cf700f"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"effect": "3.19.16"
|
|
40
46
|
},
|
|
41
47
|
"publishConfig": {
|
|
42
48
|
"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
|
|
|
@@ -139,7 +140,9 @@ describe('Echo Atom - Basic Functionality', () => {
|
|
|
139
140
|
expect(propertyUpdateCount).toBe(1);
|
|
140
141
|
|
|
141
142
|
// Mutate the standalone object.
|
|
142
|
-
obj
|
|
143
|
+
Obj.change(obj, (o) => {
|
|
144
|
+
o.name = 'Updated Standalone';
|
|
145
|
+
});
|
|
143
146
|
|
|
144
147
|
// Both atoms should have received updates.
|
|
145
148
|
expect(objectUpdateCount).toBe(2);
|
|
@@ -150,3 +153,246 @@ describe('Echo Atom - Basic Functionality', () => {
|
|
|
150
153
|
expect(registry.get(propertyAtom)).toBe('Updated Standalone');
|
|
151
154
|
});
|
|
152
155
|
});
|
|
156
|
+
|
|
157
|
+
describe('Echo Atom - Referential Equality', () => {
|
|
158
|
+
test('AtomObj.make returns same atom instance for same object', () => {
|
|
159
|
+
const obj = createObject(
|
|
160
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const atom1 = AtomObj.make(obj);
|
|
164
|
+
const atom2 = AtomObj.make(obj);
|
|
165
|
+
|
|
166
|
+
// Same object should return the exact same atom instance.
|
|
167
|
+
expect(atom1).toBe(atom2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('AtomObj.make returns different atom instances for different objects', () => {
|
|
171
|
+
const obj1 = createObject(
|
|
172
|
+
Obj.make(TestSchema.Person, { name: 'Test1', username: 'test1', email: 'test1@example.com' }),
|
|
173
|
+
);
|
|
174
|
+
const obj2 = createObject(
|
|
175
|
+
Obj.make(TestSchema.Person, { name: 'Test2', username: 'test2', email: 'test2@example.com' }),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const atom1 = AtomObj.make(obj1);
|
|
179
|
+
const atom2 = AtomObj.make(obj2);
|
|
180
|
+
|
|
181
|
+
// Different objects should return different atom instances.
|
|
182
|
+
expect(atom1).not.toBe(atom2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('AtomObj.makeProperty returns same atom instance for same object and key', () => {
|
|
186
|
+
const obj = createObject(
|
|
187
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const atom1 = AtomObj.makeProperty(obj, 'name');
|
|
191
|
+
const atom2 = AtomObj.makeProperty(obj, 'name');
|
|
192
|
+
|
|
193
|
+
// Same object and key should return the exact same atom instance.
|
|
194
|
+
expect(atom1).toBe(atom2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('AtomObj.makeProperty returns different atom instances for same object but different keys', () => {
|
|
198
|
+
const obj = createObject(
|
|
199
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const nameAtom = AtomObj.makeProperty(obj, 'name');
|
|
203
|
+
const emailAtom = AtomObj.makeProperty(obj, 'email');
|
|
204
|
+
|
|
205
|
+
// Same object but different keys should return different atom instances.
|
|
206
|
+
expect(nameAtom).not.toBe(emailAtom);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('AtomObj.makeProperty returns different atom instances for different objects with same key', () => {
|
|
210
|
+
const obj1 = createObject(
|
|
211
|
+
Obj.make(TestSchema.Person, { name: 'Test1', username: 'test1', email: 'test1@example.com' }),
|
|
212
|
+
);
|
|
213
|
+
const obj2 = createObject(
|
|
214
|
+
Obj.make(TestSchema.Person, { name: 'Test2', username: 'test2', email: 'test2@example.com' }),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const atom1 = AtomObj.makeProperty(obj1, 'name');
|
|
218
|
+
const atom2 = AtomObj.makeProperty(obj2, 'name');
|
|
219
|
+
|
|
220
|
+
// Different objects should return different atom instances even for same key.
|
|
221
|
+
expect(atom1).not.toBe(atom2);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('cached atoms remain reactive after multiple retrievals', () => {
|
|
225
|
+
const obj = createObject(
|
|
226
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const registry = Registry.make();
|
|
230
|
+
|
|
231
|
+
// Get the same atom multiple times.
|
|
232
|
+
const atom1 = AtomObj.make(obj);
|
|
233
|
+
const atom2 = AtomObj.make(obj);
|
|
234
|
+
const atom3 = AtomObj.make(obj);
|
|
235
|
+
|
|
236
|
+
// All should be the same instance.
|
|
237
|
+
expect(atom1).toBe(atom2);
|
|
238
|
+
expect(atom2).toBe(atom3);
|
|
239
|
+
|
|
240
|
+
// Subscribe to the atom.
|
|
241
|
+
let updateCount = 0;
|
|
242
|
+
registry.subscribe(atom1, () => updateCount++, { immediate: true });
|
|
243
|
+
|
|
244
|
+
expect(updateCount).toBe(1);
|
|
245
|
+
|
|
246
|
+
// Mutate the object.
|
|
247
|
+
Obj.change(obj, (o) => {
|
|
248
|
+
o.name = 'Updated';
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// The subscription should still work.
|
|
252
|
+
expect(updateCount).toBe(2);
|
|
253
|
+
expect(registry.get(atom1).name).toBe('Updated');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('cached property atoms remain reactive after multiple retrievals', () => {
|
|
257
|
+
const obj = createObject(
|
|
258
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const registry = Registry.make();
|
|
262
|
+
|
|
263
|
+
// Get the same property atom multiple times.
|
|
264
|
+
const atom1 = AtomObj.makeProperty(obj, 'name');
|
|
265
|
+
const atom2 = AtomObj.makeProperty(obj, 'name');
|
|
266
|
+
const atom3 = AtomObj.makeProperty(obj, 'name');
|
|
267
|
+
|
|
268
|
+
// All should be the same instance.
|
|
269
|
+
expect(atom1).toBe(atom2);
|
|
270
|
+
expect(atom2).toBe(atom3);
|
|
271
|
+
|
|
272
|
+
// Subscribe to the atom.
|
|
273
|
+
let updateCount = 0;
|
|
274
|
+
registry.subscribe(atom1, () => updateCount++, { immediate: true });
|
|
275
|
+
|
|
276
|
+
expect(updateCount).toBe(1);
|
|
277
|
+
|
|
278
|
+
// Mutate the specific property.
|
|
279
|
+
Obj.change(obj, (o) => {
|
|
280
|
+
o.name = 'Updated';
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// The subscription should still work.
|
|
284
|
+
expect(updateCount).toBe(2);
|
|
285
|
+
expect(registry.get(atom1)).toBe('Updated');
|
|
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({ indexes: true });
|
|
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({ indexes: true });
|
|
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({ indexes: true });
|
|
394
|
+
|
|
395
|
+
expect(registry.get(atom)).toBeUndefined();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|
package/src/atom.ts
CHANGED
|
@@ -3,89 +3,154 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import * as Atom from '@effect-atom/atom/Atom';
|
|
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';
|
|
6
10
|
import isEqual from 'lodash.isequal';
|
|
7
11
|
|
|
8
|
-
import {
|
|
12
|
+
import { Obj, Ref } from '@dxos/echo';
|
|
9
13
|
import { assertArgument } from '@dxos/invariant';
|
|
10
|
-
import { getSnapshot, isLiveObject } from '@dxos/live-object';
|
|
11
14
|
|
|
12
15
|
import { loadRefTarget } from './ref-utils';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Returns immutable snapshots of the object data.
|
|
18
|
-
* The atom updates automatically when the object is mutated.
|
|
19
|
-
* For refs, automatically handles async loading.
|
|
20
|
-
*
|
|
21
|
-
* @param objOrRef - The reactive object or ref to create an atom for, or undefined.
|
|
22
|
-
* @returns An atom that returns the object snapshot, or undefined if not loaded/undefined.
|
|
18
|
+
* Atom family for ECHO objects.
|
|
19
|
+
* Uses object reference as key - same object returns same atom.
|
|
23
20
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export function make<T extends Entity.Unknown>(objOrRef: T | Ref.Ref<T> | undefined): Atom.Atom<T | undefined> {
|
|
27
|
-
if (objOrRef === undefined) {
|
|
28
|
-
return Atom.make<T | undefined>(() => undefined);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Handle Ref inputs.
|
|
32
|
-
if (Ref.isRef(objOrRef)) {
|
|
33
|
-
return makeFromRef(objOrRef as Ref.Ref<T>);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// At this point, objOrRef is definitely T (not a Ref).
|
|
37
|
-
const obj = objOrRef as T;
|
|
38
|
-
assertArgument(isLiveObject(obj), 'obj', 'Object must be a reactive object');
|
|
39
|
-
|
|
40
|
-
return Atom.make<T | undefined>((get) => {
|
|
21
|
+
const objectFamily = Atom.family(<T extends Obj.Unknown>(obj: T): Atom.Atom<Obj.Snapshot<T>> => {
|
|
22
|
+
return Atom.make<Obj.Snapshot<T>>((get) => {
|
|
41
23
|
const unsubscribe = Obj.subscribe(obj, () => {
|
|
42
|
-
get.setSelf(getSnapshot(obj)
|
|
24
|
+
get.setSelf(Obj.getSnapshot(obj));
|
|
43
25
|
});
|
|
44
26
|
|
|
45
27
|
get.addFinalizer(() => unsubscribe());
|
|
46
28
|
|
|
47
|
-
return getSnapshot(obj)
|
|
29
|
+
return Obj.getSnapshot(obj);
|
|
48
30
|
});
|
|
49
|
-
}
|
|
31
|
+
});
|
|
50
32
|
|
|
51
33
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
34
|
+
* Atom family for ECHO refs.
|
|
35
|
+
* RefImpl implements Effect's Hash/Equal traits using DXN, so different Ref instances
|
|
36
|
+
* pointing to the same object resolve to the same atom.
|
|
54
37
|
*/
|
|
55
|
-
const
|
|
56
|
-
return Atom.make<T | undefined>((get) => {
|
|
38
|
+
const refFamily = Atom.family(<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<Obj.Snapshot<T> | undefined> => {
|
|
39
|
+
return Atom.make<Obj.Snapshot<T> | undefined>((get) => {
|
|
57
40
|
let unsubscribeTarget: (() => void) | undefined;
|
|
58
41
|
|
|
59
|
-
const setupTargetSubscription = (target: T): T => {
|
|
42
|
+
const setupTargetSubscription = (target: T): Obj.Snapshot<T> => {
|
|
60
43
|
unsubscribeTarget?.();
|
|
61
44
|
unsubscribeTarget = Obj.subscribe(target, () => {
|
|
62
|
-
get.setSelf(getSnapshot(target)
|
|
45
|
+
get.setSelf(Obj.getSnapshot(target));
|
|
63
46
|
});
|
|
64
|
-
return getSnapshot(target)
|
|
47
|
+
return Obj.getSnapshot(target);
|
|
65
48
|
};
|
|
66
49
|
|
|
67
|
-
get.addFinalizer(() =>
|
|
50
|
+
get.addFinalizer(() => {
|
|
51
|
+
unsubscribeTarget?.();
|
|
52
|
+
});
|
|
68
53
|
|
|
69
54
|
return loadRefTarget(ref, get, setupTargetSubscription);
|
|
70
55
|
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Snapshot a value to create a new reference for comparison and React dependency tracking.
|
|
60
|
+
* Arrays and plain objects are shallow-copied so that:
|
|
61
|
+
* 1. The snapshot is isolated from mutations to the original value.
|
|
62
|
+
* 2. React's shallow comparison (Object.is) detects changes via new reference identity.
|
|
63
|
+
*/
|
|
64
|
+
const snapshotForComparison = <V>(value: V): V => {
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
return [...value] as V;
|
|
67
|
+
}
|
|
68
|
+
if (value !== null && typeof value === 'object') {
|
|
69
|
+
return { ...value } as V;
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
71
72
|
};
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Atom family for ECHO object properties.
|
|
76
|
+
* Uses nested families: outer keyed by object, inner keyed by property key.
|
|
77
|
+
* Same object+key combination returns same atom instance.
|
|
78
|
+
*/
|
|
79
|
+
const propertyFamily = Atom.family(<T extends Obj.Unknown>(obj: T) =>
|
|
80
|
+
Atom.family(<K extends keyof T>(key: K): Atom.Atom<T[K]> => {
|
|
81
|
+
return Atom.make<T[K]>((get) => {
|
|
82
|
+
// Snapshot the initial value for comparison (arrays/objects need copying).
|
|
83
|
+
let previousSnapshot = snapshotForComparison(obj[key]);
|
|
84
|
+
|
|
85
|
+
const unsubscribe = Obj.subscribe(obj, () => {
|
|
86
|
+
const newValue = obj[key];
|
|
87
|
+
if (!isEqual(previousSnapshot, newValue)) {
|
|
88
|
+
previousSnapshot = snapshotForComparison(newValue);
|
|
89
|
+
// Return a snapshot copy so React sees a new reference.
|
|
90
|
+
get.setSelf(snapshotForComparison(newValue));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
get.addFinalizer(() => unsubscribe());
|
|
95
|
+
|
|
96
|
+
// Return a snapshot copy so React sees a new reference.
|
|
97
|
+
return snapshotForComparison(obj[key]);
|
|
98
|
+
});
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create a read-only atom for a single reactive object or ref.
|
|
104
|
+
* Returns {@link Obj.Snapshot} (immutable plain data), not the live reactive object.
|
|
105
|
+
* Use this when you need one object's data for display or React dependency tracking.
|
|
106
|
+
* The atom updates automatically when the object is mutated.
|
|
107
|
+
* For refs, automatically handles async loading.
|
|
108
|
+
* Uses Atom.family internally - same object/ref returns same atom instance.
|
|
109
|
+
*
|
|
110
|
+
* @param objOrRef - The reactive object or ref to create an atom for, or undefined.
|
|
111
|
+
* @returns An atom that returns the object snapshot (plain data). Returns undefined only for refs (async loading) or undefined input.
|
|
112
|
+
*/
|
|
113
|
+
export function make<T extends Obj.Unknown>(obj: T): Atom.Atom<Obj.Snapshot<T>>;
|
|
114
|
+
export function make<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<Obj.Snapshot<T> | undefined>;
|
|
115
|
+
export function make<T extends Obj.Unknown>(
|
|
116
|
+
objOrRef: T | Ref.Ref<T> | undefined,
|
|
117
|
+
): Atom.Atom<Obj.Snapshot<T> | undefined>;
|
|
118
|
+
export function make<T extends Obj.Unknown>(
|
|
119
|
+
objOrRef: T | Ref.Ref<T> | undefined,
|
|
120
|
+
): Atom.Atom<Obj.Snapshot<T> | undefined> {
|
|
121
|
+
if (objOrRef === undefined) {
|
|
122
|
+
return Atom.make<Obj.Snapshot<T> | undefined>(() => undefined);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Handle Ref inputs.
|
|
126
|
+
if (Ref.isRef(objOrRef)) {
|
|
127
|
+
return refFamily(objOrRef as Ref.Ref<T>);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// At this point, objOrRef is definitely T (not a Ref).
|
|
131
|
+
const obj = objOrRef as T;
|
|
132
|
+
assertArgument(Obj.isObject(obj), 'obj', 'Object must be a reactive object');
|
|
133
|
+
|
|
134
|
+
return objectFamily(obj);
|
|
135
|
+
}
|
|
136
|
+
|
|
73
137
|
/**
|
|
74
138
|
* Create a read-only atom for a specific property of a reactive object.
|
|
75
139
|
* Works with both Echo objects (from createObject) and plain live objects (from Obj.make).
|
|
76
140
|
* The atom updates automatically when the property is mutated.
|
|
77
141
|
* Only fires updates when the property value actually changes.
|
|
142
|
+
* Uses Atom.family internally - same object+key combination returns same atom instance.
|
|
78
143
|
*
|
|
79
144
|
* @param obj - The reactive object to create an atom for, or undefined.
|
|
80
145
|
* @param key - The property key to subscribe to.
|
|
81
146
|
* @returns An atom that returns the property value, or undefined if obj is undefined.
|
|
82
147
|
*/
|
|
83
|
-
export function makeProperty<T extends
|
|
84
|
-
export function makeProperty<T extends
|
|
148
|
+
export function makeProperty<T extends Obj.Unknown, K extends keyof T>(obj: T, key: K): Atom.Atom<T[K]>;
|
|
149
|
+
export function makeProperty<T extends Obj.Unknown, K extends keyof T>(
|
|
85
150
|
obj: T | undefined,
|
|
86
151
|
key: K,
|
|
87
152
|
): Atom.Atom<T[K] | undefined>;
|
|
88
|
-
export function makeProperty<T extends
|
|
153
|
+
export function makeProperty<T extends Obj.Unknown, K extends keyof T>(
|
|
89
154
|
obj: T | undefined,
|
|
90
155
|
key: K,
|
|
91
156
|
): Atom.Atom<T[K] | undefined> {
|
|
@@ -93,22 +158,69 @@ export function makeProperty<T extends Entity.Unknown, K extends keyof T>(
|
|
|
93
158
|
return Atom.make<T[K] | undefined>(() => undefined);
|
|
94
159
|
}
|
|
95
160
|
|
|
96
|
-
assertArgument(
|
|
161
|
+
assertArgument(Obj.isObject(obj), 'obj', 'Object must be a reactive object');
|
|
97
162
|
assertArgument(key in obj, 'key', 'Property must exist on object');
|
|
163
|
+
return propertyFamily(obj)(key);
|
|
164
|
+
}
|
|
98
165
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Atom family for ECHO objects - returns the live object, not a snapshot.
|
|
168
|
+
* Same as objectFamily but returns T instead of Obj.Snapshot<T>.
|
|
169
|
+
*/
|
|
170
|
+
const objectWithReactiveFamily = Atom.family(<T extends Obj.Unknown>(obj: T): Atom.Atom<T> => {
|
|
171
|
+
return Atom.make<T>((get) => {
|
|
102
172
|
const unsubscribe = Obj.subscribe(obj, () => {
|
|
103
|
-
|
|
104
|
-
if (!isEqual(previousValue, newValue)) {
|
|
105
|
-
previousValue = newValue;
|
|
106
|
-
get.setSelf(newValue);
|
|
107
|
-
}
|
|
173
|
+
get.setSelf(obj);
|
|
108
174
|
});
|
|
109
175
|
|
|
110
176
|
get.addFinalizer(() => unsubscribe());
|
|
111
177
|
|
|
112
|
-
return obj
|
|
178
|
+
return obj;
|
|
113
179
|
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Atom family for ECHO refs - returns the live reactive object, not a snapshot.
|
|
184
|
+
* Resolves the ref via the database; returns undefined while loading or if unresolved.
|
|
185
|
+
*/
|
|
186
|
+
const refWithReactiveFamily = Atom.family(<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<T | undefined> => {
|
|
187
|
+
const effect = (get: Atom.Context) =>
|
|
188
|
+
Effect.gen(function* () {
|
|
189
|
+
const snapshot = get(make(ref));
|
|
190
|
+
if (snapshot == null) return undefined;
|
|
191
|
+
const option = yield* Obj.getReactiveOption(snapshot);
|
|
192
|
+
return Option.getOrElse(option, () => undefined);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return Function.pipe(
|
|
196
|
+
Atom.make(effect),
|
|
197
|
+
Atom.map((result) => Result.getOrElse(result, () => undefined)),
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Like {@link make} but returns the live reactive object instead of a snapshot.
|
|
203
|
+
* Same input: Obj or Ref.Ref. Same output shape: Atom that updates when the object mutates.
|
|
204
|
+
* Prefer {@link make} (snapshot) unless you need the live Obj.Obj for generic mutations (e.g. Obj.change).
|
|
205
|
+
*
|
|
206
|
+
* @param objOrRef - The reactive object or ref.
|
|
207
|
+
* @returns An atom that returns the live object. Returns undefined for refs (async loading) or undefined input.
|
|
208
|
+
*/
|
|
209
|
+
export function makeWithReactive<T extends Obj.Unknown>(obj: T): Atom.Atom<T>;
|
|
210
|
+
export function makeWithReactive<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<T | undefined>;
|
|
211
|
+
export function makeWithReactive<T extends Obj.Unknown>(objOrRef: T | Ref.Ref<T> | undefined): Atom.Atom<T | undefined>;
|
|
212
|
+
export function makeWithReactive<T extends Obj.Unknown>(
|
|
213
|
+
objOrRef: T | Ref.Ref<T> | undefined,
|
|
214
|
+
): Atom.Atom<T | undefined> {
|
|
215
|
+
if (objOrRef === undefined) {
|
|
216
|
+
return Atom.make<T | undefined>(() => undefined);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (Ref.isRef(objOrRef)) {
|
|
220
|
+
return refWithReactiveFamily(objOrRef as Ref.Ref<T>);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const obj = objOrRef as T;
|
|
224
|
+
assertArgument(Obj.isObject(obj), 'obj', 'Object must be a reactive object');
|
|
225
|
+
return objectWithReactiveFamily(obj);
|
|
114
226
|
}
|
package/src/query-atom.test.ts
CHANGED
|
@@ -7,8 +7,10 @@ import * as Schema from 'effect/Schema';
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
8
8
|
|
|
9
9
|
import { Obj, type QueryResult, Type } from '@dxos/echo';
|
|
10
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
10
11
|
import { type EchoDatabase, Filter, Query } from '@dxos/echo-db';
|
|
11
12
|
import { EchoTestBuilder } from '@dxos/echo-db/testing';
|
|
13
|
+
import { SpaceId } from '@dxos/keys';
|
|
12
14
|
|
|
13
15
|
import * as AtomQuery from './query-atom';
|
|
14
16
|
|
|
@@ -19,7 +21,7 @@ const TestItem = Schema.Struct({
|
|
|
19
21
|
name: Schema.String,
|
|
20
22
|
value: Schema.Number,
|
|
21
23
|
}).pipe(
|
|
22
|
-
Type.
|
|
24
|
+
Type.object({
|
|
23
25
|
typename: 'example.com/type/TestItem',
|
|
24
26
|
version: '0.1.0',
|
|
25
27
|
}),
|
|
@@ -198,3 +200,63 @@ describe('AtomQuery', () => {
|
|
|
198
200
|
expect(results2[0].name).toBe('Object');
|
|
199
201
|
});
|
|
200
202
|
});
|
|
203
|
+
|
|
204
|
+
describe('AtomQuery with queues', () => {
|
|
205
|
+
let testBuilder: EchoTestBuilder;
|
|
206
|
+
let registry: Registry.Registry;
|
|
207
|
+
|
|
208
|
+
beforeEach(async () => {
|
|
209
|
+
testBuilder = await new EchoTestBuilder().open();
|
|
210
|
+
registry = Registry.make();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
afterEach(async () => {
|
|
214
|
+
await testBuilder.close();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('AtomQuery.make with Filter.type on queue', async () => {
|
|
218
|
+
const peer = await testBuilder.createPeer({ types: [TestSchema.Person] });
|
|
219
|
+
const spaceId = SpaceId.random();
|
|
220
|
+
const queues = peer.client.constructQueueFactory(spaceId);
|
|
221
|
+
const queue = queues.create();
|
|
222
|
+
|
|
223
|
+
const john = Obj.make(TestSchema.Person, { name: 'john' });
|
|
224
|
+
const jane = Obj.make(TestSchema.Person, { name: 'jane' });
|
|
225
|
+
await queue.append([john, jane]);
|
|
226
|
+
|
|
227
|
+
// Verify queue.query works directly (sanity check).
|
|
228
|
+
const directResult = await queue.query(Query.select(Filter.type(TestSchema.Person))).run();
|
|
229
|
+
expect(directResult).toHaveLength(2);
|
|
230
|
+
|
|
231
|
+
// Now test AtomQuery.make.
|
|
232
|
+
const atom = AtomQuery.make<TestSchema.Person>(queue, Filter.type(TestSchema.Person));
|
|
233
|
+
const results = registry.get(atom);
|
|
234
|
+
|
|
235
|
+
expect(results).toHaveLength(2);
|
|
236
|
+
expect(results.map((r) => r.name).sort()).toEqual(['jane', 'john']);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('AtomQuery.make with Filter.id on queue', async () => {
|
|
240
|
+
const peer = await testBuilder.createPeer({ types: [TestSchema.Person] });
|
|
241
|
+
const spaceId = SpaceId.random();
|
|
242
|
+
const queues = peer.client.constructQueueFactory(spaceId);
|
|
243
|
+
const queue = queues.create();
|
|
244
|
+
|
|
245
|
+
const john = Obj.make(TestSchema.Person, { name: 'john' });
|
|
246
|
+
const jane = Obj.make(TestSchema.Person, { name: 'jane' });
|
|
247
|
+
const alice = Obj.make(TestSchema.Person, { name: 'alice' });
|
|
248
|
+
await queue.append([john, jane, alice]);
|
|
249
|
+
|
|
250
|
+
// Verify queue.query works directly (sanity check).
|
|
251
|
+
const directResult = await queue.query(Query.select(Filter.id(jane.id))).run();
|
|
252
|
+
expect(directResult).toHaveLength(1);
|
|
253
|
+
|
|
254
|
+
// Use AtomQuery.make with Filter.id - this is what app-graph-builder uses.
|
|
255
|
+
const atom = AtomQuery.make<TestSchema.Person>(queue, Filter.id(jane.id));
|
|
256
|
+
const results = registry.get(atom);
|
|
257
|
+
|
|
258
|
+
expect(results).toHaveLength(1);
|
|
259
|
+
expect(results[0].id).toEqual(jane.id);
|
|
260
|
+
expect(results[0].name).toEqual('jane');
|
|
261
|
+
});
|
|
262
|
+
});
|