@dxos/echo-atom 0.0.0 → 0.8.4-main.03d5cd7b56
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 +221 -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 +43 -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 +18 -16
- package/src/atom.test.ts +258 -12
- package/src/atom.ts +171 -56
- package/src/batching.test.ts +24 -24
- package/src/query-atom.test.ts +200 -12
- package/src/query-atom.ts +11 -13
- package/src/reactivity.test.ts +95 -20
- package/src/ref-atom.test.ts +243 -0
- package/src/ref-atom.ts +17 -9
- package/src/ref-utils.ts +17 -2
package/src/batching.test.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
37
|
-
Obj.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
75
|
-
Obj.
|
|
76
|
-
|
|
74
|
+
// Make multiple separate Obj.update calls.
|
|
75
|
+
Obj.update(obj, (obj) => {
|
|
76
|
+
obj.name = 'Updated1';
|
|
77
77
|
});
|
|
78
|
-
Obj.
|
|
79
|
-
|
|
78
|
+
Obj.update(obj, (obj) => {
|
|
79
|
+
obj.email = 'updated@example.com';
|
|
80
80
|
});
|
|
81
|
-
Obj.
|
|
82
|
-
|
|
81
|
+
Obj.update(obj, (obj) => {
|
|
82
|
+
obj.username = 'updated';
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
// Should have fired once for initial + once per Obj.
|
|
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.
|
|
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.
|
|
117
|
-
Obj.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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.
|
package/src/query-atom.test.ts
CHANGED
|
@@ -6,9 +6,13 @@ 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 { Filter, Query } from '@dxos/echo';
|
|
12
|
+
import { type EchoDatabase, RuntimeSchemaRegistry } from '@dxos/echo-db';
|
|
11
13
|
import { EchoTestBuilder } from '@dxos/echo-db/testing';
|
|
14
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
15
|
+
import { SpaceId } from '@dxos/keys';
|
|
12
16
|
|
|
13
17
|
import * as AtomQuery from './query-atom';
|
|
14
18
|
|
|
@@ -19,8 +23,8 @@ const TestItem = Schema.Struct({
|
|
|
19
23
|
name: Schema.String,
|
|
20
24
|
value: Schema.Number,
|
|
21
25
|
}).pipe(
|
|
22
|
-
Type.
|
|
23
|
-
typename: 'example.
|
|
26
|
+
Type.object({
|
|
27
|
+
typename: 'com.example.type.test-item',
|
|
24
28
|
version: '0.1.0',
|
|
25
29
|
}),
|
|
26
30
|
);
|
|
@@ -45,7 +49,7 @@ describe('AtomQuery', () => {
|
|
|
45
49
|
test('creates atom with initial results', async () => {
|
|
46
50
|
db.add(Obj.make(TestItem, { name: 'Object 1', value: 100 }));
|
|
47
51
|
db.add(Obj.make(TestItem, { name: 'Object 2', value: 100 }));
|
|
48
|
-
await db.flush(
|
|
52
|
+
await db.flush();
|
|
49
53
|
|
|
50
54
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
51
55
|
Query.select(Filter.type(TestItem, { value: 100 })),
|
|
@@ -61,7 +65,7 @@ describe('AtomQuery', () => {
|
|
|
61
65
|
|
|
62
66
|
test('registry.subscribe fires on QueryResult changes', async () => {
|
|
63
67
|
db.add(Obj.make(TestItem, { name: 'Initial', value: 200 }));
|
|
64
|
-
await db.flush(
|
|
68
|
+
await db.flush();
|
|
65
69
|
|
|
66
70
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
67
71
|
Query.select(Filter.type(TestItem, { value: 200 })),
|
|
@@ -84,7 +88,7 @@ describe('AtomQuery', () => {
|
|
|
84
88
|
|
|
85
89
|
// Add a new object that matches the query.
|
|
86
90
|
db.add(Obj.make(TestItem, { name: 'New Object', value: 200 }));
|
|
87
|
-
await db.flush({
|
|
91
|
+
await db.flush({ updates: true });
|
|
88
92
|
|
|
89
93
|
// Subscription should have fired.
|
|
90
94
|
expect(updateCount).toBeGreaterThan(0);
|
|
@@ -94,7 +98,7 @@ describe('AtomQuery', () => {
|
|
|
94
98
|
test('registry.subscribe fires when objects are removed', async () => {
|
|
95
99
|
const obj1 = db.add(Obj.make(TestItem, { name: 'Object 1', value: 300 }));
|
|
96
100
|
db.add(Obj.make(TestItem, { name: 'Object 2', value: 300 }));
|
|
97
|
-
await db.flush(
|
|
101
|
+
await db.flush();
|
|
98
102
|
|
|
99
103
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
100
104
|
Query.select(Filter.type(TestItem, { value: 300 })),
|
|
@@ -117,7 +121,7 @@ describe('AtomQuery', () => {
|
|
|
117
121
|
|
|
118
122
|
// Remove an object.
|
|
119
123
|
db.remove(obj1);
|
|
120
|
-
await db.flush({
|
|
124
|
+
await db.flush({ updates: true });
|
|
121
125
|
|
|
122
126
|
// Subscription should have fired.
|
|
123
127
|
expect(updateCount).toBeGreaterThan(0);
|
|
@@ -127,7 +131,7 @@ describe('AtomQuery', () => {
|
|
|
127
131
|
|
|
128
132
|
test('unsubscribing from registry stops receiving updates', async () => {
|
|
129
133
|
db.add(Obj.make(TestItem, { name: 'Initial', value: 400 }));
|
|
130
|
-
await db.flush(
|
|
134
|
+
await db.flush();
|
|
131
135
|
|
|
132
136
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
133
137
|
Query.select(Filter.type(TestItem, { value: 400 })),
|
|
@@ -148,7 +152,7 @@ describe('AtomQuery', () => {
|
|
|
148
152
|
|
|
149
153
|
// Add object and verify subscription fires.
|
|
150
154
|
db.add(Obj.make(TestItem, { name: 'Object 2', value: 400 }));
|
|
151
|
-
await db.flush({
|
|
155
|
+
await db.flush({ updates: true });
|
|
152
156
|
const countAfterFirstAdd = updateCount;
|
|
153
157
|
expect(countAfterFirstAdd).toBeGreaterThan(0);
|
|
154
158
|
|
|
@@ -157,7 +161,7 @@ describe('AtomQuery', () => {
|
|
|
157
161
|
|
|
158
162
|
// Add another object.
|
|
159
163
|
db.add(Obj.make(TestItem, { name: 'Object 3', value: 400 }));
|
|
160
|
-
await db.flush({
|
|
164
|
+
await db.flush({ updates: true });
|
|
161
165
|
|
|
162
166
|
// Update count should not have changed after unsubscribe.
|
|
163
167
|
expect(updateCount).toBe(countAfterFirstAdd);
|
|
@@ -177,7 +181,7 @@ describe('AtomQuery', () => {
|
|
|
177
181
|
|
|
178
182
|
test('multiple atoms from same query share underlying subscription', async () => {
|
|
179
183
|
db.add(Obj.make(TestItem, { name: 'Object', value: 500 }));
|
|
180
|
-
await db.flush(
|
|
184
|
+
await db.flush();
|
|
181
185
|
|
|
182
186
|
const queryResult: QueryResult.QueryResult<TestItem> = db.query(
|
|
183
187
|
Query.select(Filter.type(TestItem, { value: 500 })),
|
|
@@ -198,3 +202,187 @@ describe('AtomQuery', () => {
|
|
|
198
202
|
expect(results2[0].name).toBe('Object');
|
|
199
203
|
});
|
|
200
204
|
});
|
|
205
|
+
|
|
206
|
+
describe('AtomQuery with queues', () => {
|
|
207
|
+
let testBuilder: EchoTestBuilder;
|
|
208
|
+
let registry: Registry.Registry;
|
|
209
|
+
|
|
210
|
+
beforeEach(async () => {
|
|
211
|
+
testBuilder = await new EchoTestBuilder().open();
|
|
212
|
+
registry = Registry.make();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterEach(async () => {
|
|
216
|
+
await testBuilder.close();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('AtomQuery.make with Filter.type on queue', async () => {
|
|
220
|
+
const peer = await testBuilder.createPeer({ types: [TestSchema.Person] });
|
|
221
|
+
const spaceId = SpaceId.random();
|
|
222
|
+
const queues = peer.client.constructQueueFactory(spaceId);
|
|
223
|
+
const queue = queues.create();
|
|
224
|
+
|
|
225
|
+
const john = Obj.make(TestSchema.Person, { name: 'john' });
|
|
226
|
+
const jane = Obj.make(TestSchema.Person, { name: 'jane' });
|
|
227
|
+
await queue.append([john, jane]);
|
|
228
|
+
|
|
229
|
+
// Verify queue.query works directly (sanity check).
|
|
230
|
+
const directResult = await queue.query(Query.select(Filter.type(TestSchema.Person))).run();
|
|
231
|
+
expect(directResult).toHaveLength(2);
|
|
232
|
+
|
|
233
|
+
// Now test AtomQuery.make.
|
|
234
|
+
const atom = AtomQuery.make<TestSchema.Person>(queue, Filter.type(TestSchema.Person));
|
|
235
|
+
const results = registry.get(atom);
|
|
236
|
+
|
|
237
|
+
expect(results).toHaveLength(2);
|
|
238
|
+
expect(results.map((r) => r.name).sort()).toEqual(['jane', 'john']);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('AtomQuery.make with Filter.id on queue', async () => {
|
|
242
|
+
const peer = await testBuilder.createPeer({ types: [TestSchema.Person] });
|
|
243
|
+
const spaceId = SpaceId.random();
|
|
244
|
+
const queues = peer.client.constructQueueFactory(spaceId);
|
|
245
|
+
const queue = queues.create();
|
|
246
|
+
|
|
247
|
+
const john = Obj.make(TestSchema.Person, { name: 'john' });
|
|
248
|
+
const jane = Obj.make(TestSchema.Person, { name: 'jane' });
|
|
249
|
+
const alice = Obj.make(TestSchema.Person, { name: 'alice' });
|
|
250
|
+
await queue.append([john, jane, alice]);
|
|
251
|
+
|
|
252
|
+
// Verify queue.query works directly (sanity check).
|
|
253
|
+
const directResult = await queue.query(Query.select(Filter.id(jane.id))).run();
|
|
254
|
+
expect(directResult).toHaveLength(1);
|
|
255
|
+
|
|
256
|
+
// Use AtomQuery.make with Filter.id - this is what app-graph-builder uses.
|
|
257
|
+
const atom = AtomQuery.make<TestSchema.Person>(queue, Filter.id(jane.id));
|
|
258
|
+
const results = registry.get(atom);
|
|
259
|
+
|
|
260
|
+
expect(results).toHaveLength(1);
|
|
261
|
+
expect(results[0].id).toEqual(jane.id);
|
|
262
|
+
expect(results[0].name).toEqual('jane');
|
|
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
|
|
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,27 +17,25 @@ 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).
|
|
35
30
|
const queryableRegistry = new WeakDictionary<string, Database.Queryable>();
|
|
36
31
|
|
|
37
|
-
//
|
|
32
|
+
// Key separator that won't appear in identifiers (DXN strings use colons).
|
|
33
|
+
const KEY_SEPARATOR = '~';
|
|
34
|
+
|
|
35
|
+
// Atom.family keyed by "identifier~serializedAST".
|
|
38
36
|
const queryFamily = Atom.family((key: string) => {
|
|
39
37
|
// Parse key outside Atom.make - runs once per key.
|
|
40
|
-
const separatorIndex = key.indexOf(
|
|
38
|
+
const separatorIndex = key.indexOf(KEY_SEPARATOR);
|
|
41
39
|
const identifier = key.slice(0, separatorIndex);
|
|
42
40
|
const serializedAst = key.slice(separatorIndex + 1);
|
|
43
41
|
|
|
@@ -114,8 +112,8 @@ const fromQueryable = <T extends Entity.Unknown>(
|
|
|
114
112
|
? queryOrFilter
|
|
115
113
|
: Query.select(queryOrFilter as Filter.Filter<T>);
|
|
116
114
|
|
|
117
|
-
// Build key: identifier
|
|
118
|
-
const key = `${identifier}
|
|
115
|
+
// Build key: identifier\0serializedAST (using null char as separator to avoid DXN colon conflicts).
|
|
116
|
+
const key = `${identifier}${KEY_SEPARATOR}${JSON.stringify(normalizedQuery.ast)}`;
|
|
119
117
|
|
|
120
118
|
return queryFamily(key) as Atom.Atom<T[]>;
|
|
121
119
|
};
|
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).
|
|
@@ -155,4 +156,78 @@ describe('Echo Atom - Reactivity', () => {
|
|
|
155
156
|
expect(finalSnapshot.name).toBe('Updated');
|
|
156
157
|
expect(finalSnapshot.email).toBe('updated@example.com');
|
|
157
158
|
});
|
|
159
|
+
|
|
160
|
+
test('property mutation on standalone Obj.make object is synchronous', () => {
|
|
161
|
+
// Test objects created with just Obj.make() - no createObject/database.
|
|
162
|
+
const obj = Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' });
|
|
163
|
+
|
|
164
|
+
const actions: string[] = [];
|
|
165
|
+
const unsubscribe = Obj.subscribe(obj, () => {
|
|
166
|
+
actions.push('update');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
actions.push('before');
|
|
170
|
+
Obj.update(obj, (obj) => {
|
|
171
|
+
obj.name = 'Updated';
|
|
172
|
+
});
|
|
173
|
+
actions.push('after');
|
|
174
|
+
|
|
175
|
+
// Updates must be synchronous: before -> update -> after.
|
|
176
|
+
expect(actions).toEqual(['before', 'update', 'after']);
|
|
177
|
+
|
|
178
|
+
// Verify the property was modified.
|
|
179
|
+
expect(obj.name).toBe('Updated');
|
|
180
|
+
|
|
181
|
+
unsubscribe();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('array splice on standalone Obj.make object is synchronous', () => {
|
|
185
|
+
// Test objects created with just Obj.make() - no createObject/database.
|
|
186
|
+
const obj = Obj.make(TestSchema.Example, { stringArray: ['a', 'b', 'c', 'd'] });
|
|
187
|
+
|
|
188
|
+
const actions: string[] = [];
|
|
189
|
+
const unsubscribe = Obj.subscribe(obj, () => {
|
|
190
|
+
actions.push('update');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
actions.push('before');
|
|
194
|
+
Obj.update(obj, (obj) => {
|
|
195
|
+
obj.stringArray!.splice(1, 1);
|
|
196
|
+
});
|
|
197
|
+
actions.push('after');
|
|
198
|
+
|
|
199
|
+
// Updates must be synchronous: before -> update -> after.
|
|
200
|
+
expect(actions).toEqual(['before', 'update', 'after']);
|
|
201
|
+
|
|
202
|
+
// Verify the array was modified.
|
|
203
|
+
expect(obj.stringArray).toEqual(['a', 'c', 'd']);
|
|
204
|
+
|
|
205
|
+
unsubscribe();
|
|
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
|
+
});
|
|
158
233
|
});
|