@dxos/echo-atom 0.0.0 → 0.8.4-main.69d29f4

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.
Files changed (35) hide show
  1. package/dist/lib/browser/index.mjs +178 -0
  2. package/dist/lib/browser/index.mjs.map +7 -0
  3. package/dist/lib/browser/meta.json +1 -0
  4. package/dist/lib/node-esm/index.mjs +179 -0
  5. package/dist/lib/node-esm/index.mjs.map +7 -0
  6. package/dist/lib/node-esm/meta.json +1 -0
  7. package/dist/types/src/atom.d.ts +30 -0
  8. package/dist/types/src/atom.d.ts.map +1 -0
  9. package/dist/types/src/atom.test.d.ts +2 -0
  10. package/dist/types/src/atom.test.d.ts.map +1 -0
  11. package/dist/types/src/batching.test.d.ts +2 -0
  12. package/dist/types/src/batching.test.d.ts.map +1 -0
  13. package/dist/types/src/index.d.ts +4 -0
  14. package/dist/types/src/index.d.ts.map +1 -0
  15. package/dist/types/src/query-atom.d.ts +24 -0
  16. package/dist/types/src/query-atom.d.ts.map +1 -0
  17. package/dist/types/src/query-atom.test.d.ts +2 -0
  18. package/dist/types/src/query-atom.test.d.ts.map +1 -0
  19. package/dist/types/src/reactivity.test.d.ts +2 -0
  20. package/dist/types/src/reactivity.test.d.ts.map +1 -0
  21. package/dist/types/src/ref-atom.d.ts +13 -0
  22. package/dist/types/src/ref-atom.d.ts.map +1 -0
  23. package/dist/types/src/ref-atom.test.d.ts +2 -0
  24. package/dist/types/src/ref-atom.test.d.ts.map +1 -0
  25. package/dist/types/src/ref-utils.d.ts +14 -0
  26. package/dist/types/src/ref-utils.d.ts.map +1 -0
  27. package/dist/types/tsconfig.tsbuildinfo +1 -0
  28. package/package.json +12 -9
  29. package/src/atom.test.ts +135 -1
  30. package/src/atom.ts +99 -55
  31. package/src/query-atom.test.ts +63 -1
  32. package/src/query-atom.ts +7 -4
  33. package/src/reactivity.test.ts +48 -0
  34. package/src/ref-atom.test.ts +209 -0
  35. 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.0.0",
3
+ "version": "0.8.4-main.69d29f4",
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,
@@ -25,18 +29,17 @@
25
29
  "src"
26
30
  ],
27
31
  "dependencies": {
28
- "@effect-atom/atom": "^0.4.10",
32
+ "@effect-atom/atom": "^0.4.13",
29
33
  "lodash.isequal": "^4.5.0",
30
- "@dxos/echo-db": "0.8.3",
31
- "@dxos/live-object": "0.8.3",
32
- "@dxos/echo": "0.8.3",
33
- "@dxos/invariant": "0.8.3",
34
- "@dxos/util": "0.8.3"
34
+ "@dxos/echo-db": "0.8.4-main.69d29f4",
35
+ "@dxos/invariant": "0.8.4-main.69d29f4",
36
+ "@dxos/util": "0.8.4-main.69d29f4",
37
+ "@dxos/echo": "0.8.4-main.69d29f4"
35
38
  },
36
39
  "devDependencies": {
37
40
  "@types/lodash.isequal": "^4.5.0",
38
- "@dxos/test-utils": "0.8.3",
39
- "@dxos/random": "0.8.3"
41
+ "@dxos/test-utils": "0.8.4-main.69d29f4",
42
+ "@dxos/random": "0.8.4-main.69d29f4"
40
43
  },
41
44
  "publishConfig": {
42
45
  "access": "public"
package/src/atom.test.ts CHANGED
@@ -139,7 +139,9 @@ describe('Echo Atom - Basic Functionality', () => {
139
139
  expect(propertyUpdateCount).toBe(1);
140
140
 
141
141
  // Mutate the standalone object.
142
- obj.name = 'Updated Standalone';
142
+ Obj.change(obj, (o) => {
143
+ o.name = 'Updated Standalone';
144
+ });
143
145
 
144
146
  // Both atoms should have received updates.
145
147
  expect(objectUpdateCount).toBe(2);
@@ -150,3 +152,135 @@ describe('Echo Atom - Basic Functionality', () => {
150
152
  expect(registry.get(propertyAtom)).toBe('Updated Standalone');
151
153
  });
152
154
  });
155
+
156
+ describe('Echo Atom - Referential Equality', () => {
157
+ test('AtomObj.make returns same atom instance for same object', () => {
158
+ const obj = createObject(
159
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
160
+ );
161
+
162
+ const atom1 = AtomObj.make(obj);
163
+ const atom2 = AtomObj.make(obj);
164
+
165
+ // Same object should return the exact same atom instance.
166
+ expect(atom1).toBe(atom2);
167
+ });
168
+
169
+ test('AtomObj.make returns different atom instances for different objects', () => {
170
+ const obj1 = createObject(
171
+ Obj.make(TestSchema.Person, { name: 'Test1', username: 'test1', email: 'test1@example.com' }),
172
+ );
173
+ const obj2 = createObject(
174
+ Obj.make(TestSchema.Person, { name: 'Test2', username: 'test2', email: 'test2@example.com' }),
175
+ );
176
+
177
+ const atom1 = AtomObj.make(obj1);
178
+ const atom2 = AtomObj.make(obj2);
179
+
180
+ // Different objects should return different atom instances.
181
+ expect(atom1).not.toBe(atom2);
182
+ });
183
+
184
+ test('AtomObj.makeProperty returns same atom instance for same object and key', () => {
185
+ const obj = createObject(
186
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
187
+ );
188
+
189
+ const atom1 = AtomObj.makeProperty(obj, 'name');
190
+ const atom2 = AtomObj.makeProperty(obj, 'name');
191
+
192
+ // Same object and key should return the exact same atom instance.
193
+ expect(atom1).toBe(atom2);
194
+ });
195
+
196
+ test('AtomObj.makeProperty returns different atom instances for same object but different keys', () => {
197
+ const obj = createObject(
198
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
199
+ );
200
+
201
+ const nameAtom = AtomObj.makeProperty(obj, 'name');
202
+ const emailAtom = AtomObj.makeProperty(obj, 'email');
203
+
204
+ // Same object but different keys should return different atom instances.
205
+ expect(nameAtom).not.toBe(emailAtom);
206
+ });
207
+
208
+ test('AtomObj.makeProperty returns different atom instances for different objects with same key', () => {
209
+ const obj1 = createObject(
210
+ Obj.make(TestSchema.Person, { name: 'Test1', username: 'test1', email: 'test1@example.com' }),
211
+ );
212
+ const obj2 = createObject(
213
+ Obj.make(TestSchema.Person, { name: 'Test2', username: 'test2', email: 'test2@example.com' }),
214
+ );
215
+
216
+ const atom1 = AtomObj.makeProperty(obj1, 'name');
217
+ const atom2 = AtomObj.makeProperty(obj2, 'name');
218
+
219
+ // Different objects should return different atom instances even for same key.
220
+ expect(atom1).not.toBe(atom2);
221
+ });
222
+
223
+ test('cached atoms remain reactive after multiple retrievals', () => {
224
+ const obj = createObject(
225
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
226
+ );
227
+
228
+ const registry = Registry.make();
229
+
230
+ // Get the same atom multiple times.
231
+ const atom1 = AtomObj.make(obj);
232
+ const atom2 = AtomObj.make(obj);
233
+ const atom3 = AtomObj.make(obj);
234
+
235
+ // All should be the same instance.
236
+ expect(atom1).toBe(atom2);
237
+ expect(atom2).toBe(atom3);
238
+
239
+ // Subscribe to the atom.
240
+ let updateCount = 0;
241
+ registry.subscribe(atom1, () => updateCount++, { immediate: true });
242
+
243
+ expect(updateCount).toBe(1);
244
+
245
+ // Mutate the object.
246
+ Obj.change(obj, (o) => {
247
+ o.name = 'Updated';
248
+ });
249
+
250
+ // The subscription should still work.
251
+ expect(updateCount).toBe(2);
252
+ expect(registry.get(atom1).name).toBe('Updated');
253
+ });
254
+
255
+ test('cached property atoms remain reactive after multiple retrievals', () => {
256
+ const obj = createObject(
257
+ Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
258
+ );
259
+
260
+ const registry = Registry.make();
261
+
262
+ // Get the same property atom multiple times.
263
+ const atom1 = AtomObj.makeProperty(obj, 'name');
264
+ const atom2 = AtomObj.makeProperty(obj, 'name');
265
+ const atom3 = AtomObj.makeProperty(obj, 'name');
266
+
267
+ // All should be the same instance.
268
+ expect(atom1).toBe(atom2);
269
+ expect(atom2).toBe(atom3);
270
+
271
+ // Subscribe to the atom.
272
+ let updateCount = 0;
273
+ registry.subscribe(atom1, () => updateCount++, { immediate: true });
274
+
275
+ expect(updateCount).toBe(1);
276
+
277
+ // Mutate the specific property.
278
+ Obj.change(obj, (o) => {
279
+ o.name = 'Updated';
280
+ });
281
+
282
+ // The subscription should still work.
283
+ expect(updateCount).toBe(2);
284
+ expect(registry.get(atom1)).toBe('Updated');
285
+ });
286
+ });
package/src/atom.ts CHANGED
@@ -5,87 +5,146 @@
5
5
  import * as Atom from '@effect-atom/atom/Atom';
6
6
  import isEqual from 'lodash.isequal';
7
7
 
8
- import { type Entity, Obj, Ref } from '@dxos/echo';
8
+ import { Obj, Ref } from '@dxos/echo';
9
9
  import { assertArgument } from '@dxos/invariant';
10
- import { getSnapshot, isLiveObject } from '@dxos/live-object';
11
10
 
12
11
  import { loadRefTarget } from './ref-utils';
13
12
 
14
13
  /**
15
- * Create a read-only atom for a reactive object or ref.
16
- * Works with Echo objects, plain live objects (from Obj.make), and Refs.
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.
14
+ * Atom family for ECHO objects.
15
+ * Uses object reference as key - same object returns same atom.
23
16
  */
24
- export function make<T extends Entity.Unknown>(objOrRef: T | Ref.Ref<T>): Atom.Atom<T | undefined>;
25
- export function make<T extends Entity.Unknown>(objOrRef: T | Ref.Ref<T> | undefined): Atom.Atom<T | undefined>;
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) => {
17
+ const objectFamily = Atom.family(<T extends Obj.Unknown>(obj: T): Atom.Atom<Obj.Snapshot<T>> => {
18
+ return Atom.make<Obj.Snapshot<T>>((get) => {
41
19
  const unsubscribe = Obj.subscribe(obj, () => {
42
- get.setSelf(getSnapshot(obj) as T);
20
+ get.setSelf(Obj.getSnapshot(obj));
43
21
  });
44
22
 
45
23
  get.addFinalizer(() => unsubscribe());
46
24
 
47
- return getSnapshot(obj) as T;
25
+ return Obj.getSnapshot(obj);
48
26
  });
49
- }
27
+ });
50
28
 
51
29
  /**
52
30
  * Internal helper to create an atom from a Ref.
53
31
  * Handles async loading and subscribes to the target for reactive updates.
32
+ * Uses Atom.family internally - same ref reference returns same atom instance.
54
33
  */
55
- const makeFromRef = <T extends Entity.Unknown>(ref: Ref.Ref<T>): Atom.Atom<T | undefined> => {
56
- return Atom.make<T | undefined>((get) => {
34
+ const refFamily = Atom.family(<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<Obj.Snapshot<T> | undefined> => {
35
+ return Atom.make<Obj.Snapshot<T> | undefined>((get) => {
57
36
  let unsubscribeTarget: (() => void) | undefined;
58
37
 
59
- const setupTargetSubscription = (target: T): T => {
38
+ const setupTargetSubscription = (target: T): Obj.Snapshot<T> => {
60
39
  unsubscribeTarget?.();
61
40
  unsubscribeTarget = Obj.subscribe(target, () => {
62
- get.setSelf(getSnapshot(target) as T);
41
+ get.setSelf(Obj.getSnapshot(target));
63
42
  });
64
- return getSnapshot(target) as T;
43
+ return Obj.getSnapshot(target);
65
44
  };
66
45
 
67
46
  get.addFinalizer(() => unsubscribeTarget?.());
68
47
 
69
48
  return loadRefTarget(ref, get, setupTargetSubscription);
70
49
  });
50
+ });
51
+
52
+ /**
53
+ * Snapshot a value to create a new reference for comparison and React dependency tracking.
54
+ * Arrays and plain objects are shallow-copied so that:
55
+ * 1. The snapshot is isolated from mutations to the original value.
56
+ * 2. React's shallow comparison (Object.is) detects changes via new reference identity.
57
+ */
58
+ const snapshotForComparison = <V>(value: V): V => {
59
+ if (Array.isArray(value)) {
60
+ return [...value] as V;
61
+ }
62
+ if (value !== null && typeof value === 'object') {
63
+ return { ...value } as V;
64
+ }
65
+ return value;
71
66
  };
72
67
 
68
+ /**
69
+ * Atom family for ECHO object properties.
70
+ * Uses nested families: outer keyed by object, inner keyed by property key.
71
+ * Same object+key combination returns same atom instance.
72
+ */
73
+ const propertyFamily = Atom.family(<T extends Obj.Unknown>(obj: T) =>
74
+ Atom.family(<K extends keyof T>(key: K): Atom.Atom<T[K]> => {
75
+ return Atom.make<T[K]>((get) => {
76
+ // Snapshot the initial value for comparison (arrays/objects need copying).
77
+ let previousSnapshot = snapshotForComparison(obj[key]);
78
+
79
+ const unsubscribe = Obj.subscribe(obj, () => {
80
+ const newValue = obj[key];
81
+ if (!isEqual(previousSnapshot, newValue)) {
82
+ previousSnapshot = snapshotForComparison(newValue);
83
+ // Return a snapshot copy so React sees a new reference.
84
+ get.setSelf(snapshotForComparison(newValue));
85
+ }
86
+ });
87
+
88
+ get.addFinalizer(() => unsubscribe());
89
+
90
+ // Return a snapshot copy so React sees a new reference.
91
+ return snapshotForComparison(obj[key]);
92
+ });
93
+ }),
94
+ );
95
+
96
+ /**
97
+ * Create a read-only atom for a reactive object or ref.
98
+ * Works with Echo objects, plain reactive objects (from Obj.make), and Refs.
99
+ * Returns immutable snapshots of the object data (branded with SnapshotKindId).
100
+ * The atom updates automatically when the object is mutated.
101
+ * For refs, automatically handles async loading.
102
+ * Uses Atom.family internally - same object/ref reference returns same atom instance.
103
+ *
104
+ * @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.
106
+ */
107
+ export function make<T extends Obj.Unknown>(obj: T): Atom.Atom<Obj.Snapshot<T>>;
108
+ export function make<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Atom<Obj.Snapshot<T> | undefined>;
109
+ export function make<T extends Obj.Unknown>(
110
+ objOrRef: T | Ref.Ref<T> | undefined,
111
+ ): Atom.Atom<Obj.Snapshot<T> | undefined>;
112
+ export function make<T extends Obj.Unknown>(
113
+ objOrRef: T | Ref.Ref<T> | undefined,
114
+ ): Atom.Atom<Obj.Snapshot<T> | undefined> {
115
+ if (objOrRef === undefined) {
116
+ return Atom.make<Obj.Snapshot<T> | undefined>(() => undefined);
117
+ }
118
+
119
+ // Handle Ref inputs.
120
+ if (Ref.isRef(objOrRef)) {
121
+ return refFamily(objOrRef as Ref.Ref<T>);
122
+ }
123
+
124
+ // At this point, objOrRef is definitely T (not a Ref).
125
+ const obj = objOrRef as T;
126
+ assertArgument(Obj.isObject(obj), 'obj', 'Object must be a reactive object');
127
+
128
+ return objectFamily(obj);
129
+ }
130
+
73
131
  /**
74
132
  * Create a read-only atom for a specific property of a reactive object.
75
133
  * Works with both Echo objects (from createObject) and plain live objects (from Obj.make).
76
134
  * The atom updates automatically when the property is mutated.
77
135
  * Only fires updates when the property value actually changes.
136
+ * Uses Atom.family internally - same object+key combination returns same atom instance.
78
137
  *
79
138
  * @param obj - The reactive object to create an atom for, or undefined.
80
139
  * @param key - The property key to subscribe to.
81
140
  * @returns An atom that returns the property value, or undefined if obj is undefined.
82
141
  */
83
- export function makeProperty<T extends Entity.Unknown, K extends keyof T>(obj: T, key: K): Atom.Atom<T[K]>;
84
- export function makeProperty<T extends Entity.Unknown, K extends keyof T>(
142
+ export function makeProperty<T extends Obj.Unknown, K extends keyof T>(obj: T, key: K): Atom.Atom<T[K]>;
143
+ export function makeProperty<T extends Obj.Unknown, K extends keyof T>(
85
144
  obj: T | undefined,
86
145
  key: K,
87
146
  ): Atom.Atom<T[K] | undefined>;
88
- export function makeProperty<T extends Entity.Unknown, K extends keyof T>(
147
+ export function makeProperty<T extends Obj.Unknown, K extends keyof T>(
89
148
  obj: T | undefined,
90
149
  key: K,
91
150
  ): Atom.Atom<T[K] | undefined> {
@@ -93,22 +152,7 @@ export function makeProperty<T extends Entity.Unknown, K extends keyof T>(
93
152
  return Atom.make<T[K] | undefined>(() => undefined);
94
153
  }
95
154
 
96
- assertArgument(isLiveObject(obj), 'obj', 'Object must be a reactive object');
155
+ assertArgument(Obj.isObject(obj), 'obj', 'Object must be a reactive object');
97
156
  assertArgument(key in obj, 'key', 'Property must exist on object');
98
-
99
- return Atom.make<T[K] | undefined>((get) => {
100
- let previousValue = obj[key];
101
-
102
- const unsubscribe = Obj.subscribe(obj, () => {
103
- const newValue = obj[key];
104
- if (!isEqual(previousValue, newValue)) {
105
- previousValue = newValue;
106
- get.setSelf(newValue);
107
- }
108
- });
109
-
110
- get.addFinalizer(() => unsubscribe());
111
-
112
- return obj[key];
113
- });
157
+ return propertyFamily(obj)(key);
114
158
  }
@@ -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.Obj({
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
+ });
package/src/query-atom.ts CHANGED
@@ -34,10 +34,13 @@ export const fromQuery = <T extends Entity.Unknown>(queryResult: QueryResult.Que
34
34
  // Registry: key → Queryable (WeakRef with auto-cleanup when GC'd).
35
35
  const queryableRegistry = new WeakDictionary<string, Database.Queryable>();
36
36
 
37
- // Atom.family keyed by "identifier:serializedAST".
37
+ // Key separator that won't appear in identifiers (DXN strings use colons).
38
+ const KEY_SEPARATOR = '~';
39
+
40
+ // Atom.family keyed by "identifier\0serializedAST".
38
41
  const queryFamily = Atom.family((key: string) => {
39
42
  // Parse key outside Atom.make - runs once per key.
40
- const separatorIndex = key.indexOf(':');
43
+ const separatorIndex = key.indexOf(KEY_SEPARATOR);
41
44
  const identifier = key.slice(0, separatorIndex);
42
45
  const serializedAst = key.slice(separatorIndex + 1);
43
46
 
@@ -114,8 +117,8 @@ const fromQueryable = <T extends Entity.Unknown>(
114
117
  ? queryOrFilter
115
118
  : Query.select(queryOrFilter as Filter.Filter<T>);
116
119
 
117
- // Build key: identifier:serializedAST.
118
- const key = `${identifier}:${JSON.stringify(normalizedQuery.ast)}`;
120
+ // Build key: identifier\0serializedAST (using null char as separator to avoid DXN colon conflicts).
121
+ const key = `${identifier}${KEY_SEPARATOR}${JSON.stringify(normalizedQuery.ast)}`;
119
122
 
120
123
  return queryFamily(key) as Atom.Atom<T[]>;
121
124
  };
@@ -155,4 +155,52 @@ describe('Echo Atom - Reactivity', () => {
155
155
  expect(finalSnapshot.name).toBe('Updated');
156
156
  expect(finalSnapshot.email).toBe('updated@example.com');
157
157
  });
158
+
159
+ test('property mutation on standalone Obj.make object is synchronous', () => {
160
+ // Test objects created with just Obj.make() - no createObject/database.
161
+ const obj = Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' });
162
+
163
+ const actions: string[] = [];
164
+ const unsubscribe = Obj.subscribe(obj, () => {
165
+ actions.push('update');
166
+ });
167
+
168
+ actions.push('before');
169
+ Obj.change(obj, (o) => {
170
+ o.name = 'Updated';
171
+ });
172
+ actions.push('after');
173
+
174
+ // Updates must be synchronous: before -> update -> after.
175
+ expect(actions).toEqual(['before', 'update', 'after']);
176
+
177
+ // Verify the property was modified.
178
+ expect(obj.name).toBe('Updated');
179
+
180
+ unsubscribe();
181
+ });
182
+
183
+ test('array splice on standalone Obj.make object is synchronous', () => {
184
+ // Test objects created with just Obj.make() - no createObject/database.
185
+ const obj = Obj.make(TestSchema.Example, { stringArray: ['a', 'b', 'c', 'd'] });
186
+
187
+ const actions: string[] = [];
188
+ const unsubscribe = Obj.subscribe(obj, () => {
189
+ actions.push('update');
190
+ });
191
+
192
+ actions.push('before');
193
+ Obj.change(obj, (o) => {
194
+ o.stringArray!.splice(1, 1);
195
+ });
196
+ actions.push('after');
197
+
198
+ // Updates must be synchronous: before -> update -> after.
199
+ expect(actions).toEqual(['before', 'update', 'after']);
200
+
201
+ // Verify the array was modified.
202
+ expect(obj.stringArray).toEqual(['a', 'c', 'd']);
203
+
204
+ unsubscribe();
205
+ });
158
206
  });