@abloatai/humans 0.66.1 → 0.66.3

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.
@@ -16,7 +16,9 @@
16
16
  * counterparts (`paired`), which carry the value each operation established: on
17
17
  * undo the forwards say what you set, and on redo the inverses say what undo
18
18
  * restored. For `update` and `updateMany` operations it drops any field whose
19
- * live value no longer matches that established value. The `create` and
19
+ * live value no longer matches that established value. Plain JSON objects are
20
+ * compared recursively, so independent nested changes can still be undone.
21
+ * Arrays and object/type replacements remain atomic. The `create` and
20
22
  * `delete` families are structural and always applied — undoing a create
21
23
  * removes the row you added, and undoing a delete restores it.
22
24
  *
@@ -30,7 +32,7 @@ import { deepEqual } from '@abloatai/transaction/utils/json';
30
32
  * How undo and redo treat a field that a collaborator changed after your
31
33
  * operation.
32
34
  *
33
- * - `skip-stale` (the default): leave the field alone. Your change has
35
+ * - `skip-stale` (the default): leave superseded fields/JSON leaves alone. Your change has
34
36
  * already been superseded, so reverting it would overwrite the
35
37
  * collaborator's value. This is what keeps undo scoped to your own edits.
36
38
  * - `last-writer-wins`: apply the operation verbatim, so your undo overwrites
@@ -16,7 +16,9 @@
16
16
  * counterparts (`paired`), which carry the value each operation established: on
17
17
  * undo the forwards say what you set, and on redo the inverses say what undo
18
18
  * restored. For `update` and `updateMany` operations it drops any field whose
19
- * live value no longer matches that established value. The `create` and
19
+ * live value no longer matches that established value. Plain JSON objects are
20
+ * compared recursively, so independent nested changes can still be undone.
21
+ * Arrays and object/type replacements remain atomic. The `create` and
20
22
  * `delete` families are structural and always applied — undoing a create
21
23
  * removes the row you added, and undoing a delete restores it.
22
24
  *
@@ -56,10 +58,34 @@ function readCurrentField(store, id, field) {
56
58
  const json = model.toJSON?.();
57
59
  return json ? json[field] : undefined;
58
60
  }
61
+ // Missing object properties differ from properties whose value is undefined.
62
+ const MISSING = Symbol('missing undo property');
63
+ const ownValue = (value, key) => Object.hasOwn(value, key) ? value[key] : MISSING;
64
+ function isPlainObject(value) {
65
+ if (value === null || typeof value !== 'object')
66
+ return false;
67
+ const prototype = Object.getPrototypeOf(value);
68
+ return prototype === Object.prototype || prototype === null;
69
+ }
70
+ /** Replay changed JSON leaves; arrays and replacements remain atomic. */
71
+ function replayValue(value, established, current) {
72
+ if (deepEqual(value, established))
73
+ return current;
74
+ if (deepEqual(current, established))
75
+ return value;
76
+ if (isPlainObject(value) && isPlainObject(established) && isPlainObject(current)) {
77
+ const keys = new Set([...Object.keys(current), ...Object.keys(established), ...Object.keys(value)]);
78
+ // fromEntries keeps keys such as __proto__ as ordinary own properties.
79
+ return Object.fromEntries([...keys].flatMap(key => {
80
+ const next = replayValue(ownValue(value, key), ownValue(established, key), ownValue(current, key));
81
+ return next === MISSING ? [] : [[key, next]];
82
+ }));
83
+ }
84
+ return current;
85
+ }
59
86
  /**
60
- * Keep only the fields whose live value still equals what this op established
61
- * (`established[field]`). Returns `null` if nothing survives (the whole op is a
62
- * no-op — every field was superseded by a collaborator).
87
+ * Replay only changes that still stand, retaining collaborators' JSON leaves.
88
+ * Returns `null` when no effective change survives.
63
89
  */
64
90
  function filterStalePatch(store, patch, established) {
65
91
  const out = { id: patch.id };
@@ -68,11 +94,10 @@ function filterStalePatch(store, patch, established) {
68
94
  if (field === 'id')
69
95
  continue;
70
96
  if (established && field in established) {
71
- // Apply only if the field still holds the value we established, meaning no
72
- // collaborator has overwritten it since. Otherwise skip it, so the
73
- // collaborator's change is left intact.
74
- if (deepEqual(readCurrentField(store, patch.id, field), established[field])) {
75
- out[field] = patch[field];
97
+ const current = readCurrentField(store, patch.id, field);
98
+ const next = replayValue(patch[field], established[field], current);
99
+ if (!deepEqual(next, current)) {
100
+ out[field] = next;
76
101
  kept++;
77
102
  }
78
103
  }
@@ -137,12 +137,12 @@ export declare class SubscriptionManager {
137
137
  /**
138
138
  * Re-asserts the full desired set against the transport, forgetting what was
139
139
  * previously confirmed. Call this after a reconnect: a fresh
140
- * {@link SyncWebSocket} starts from the sync groups named in the connect-time
141
- * URL, so the manager's diff baseline no longer reflects the new socket.
140
+ * {@link SyncWebSocket} starts from the groups confirmed during connection,
141
+ * so the manager's diff baseline no longer reflects the new socket.
142
142
  * Clearing that baseline makes the next reconcile push one
143
143
  * `update_subscription` frame that re-establishes the current interest —
144
144
  * including any warm or pinned groups that drifted while the connection was
145
- * down. The connect-time URL already carries the last-acknowledged set, so
145
+ * down. The connection already carries the last-acknowledged set, so
146
146
  * this is a correction, not the primary mechanism.
147
147
  */
148
148
  resync(): Promise<void>;
@@ -174,12 +174,12 @@ export class SubscriptionManager {
174
174
  /**
175
175
  * Re-asserts the full desired set against the transport, forgetting what was
176
176
  * previously confirmed. Call this after a reconnect: a fresh
177
- * {@link SyncWebSocket} starts from the sync groups named in the connect-time
178
- * URL, so the manager's diff baseline no longer reflects the new socket.
177
+ * {@link SyncWebSocket} starts from the groups confirmed during connection,
178
+ * so the manager's diff baseline no longer reflects the new socket.
179
179
  * Clearing that baseline makes the next reconcile push one
180
180
  * `update_subscription` frame that re-establishes the current interest —
181
181
  * including any warm or pinned groups that drifted while the connection was
182
- * down. The connect-time URL already carries the last-acknowledged set, so
182
+ * down. The connection already carries the last-acknowledged set, so
183
183
  * this is a correction, not the primary mechanism.
184
184
  */
185
185
  resync() {
@@ -11,9 +11,9 @@ export function wireSocketEvents(deps) {
11
11
  deps.updateSyncStatus({ offlineSince: undefined });
12
12
  }
13
13
  // Re-assert read interest on every (re)connect. After a transient
14
- // reconnect the socket re-sends its URL groups, but interest may have
15
- // changed while offline; after a full reconnect the new socket's URL
16
- // carries only base groups. `resync` re-pushes the current desired set
14
+ // reconnect the socket restores its last confirmed groups, but interest
15
+ // may have changed while offline; after a full reconnect the new socket
16
+ // starts with only base groups. `resync` re-pushes the current desired set
17
17
  // so the server-side index matches what the user is actually viewing.
18
18
  void deps.areaOfInterest.resync();
19
19
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.66.1",
3
+ "version": "0.66.3",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -85,7 +85,7 @@
85
85
  "directory": "packages/humans"
86
86
  },
87
87
  "dependencies": {
88
- "@abloatai/transaction": "0.66.1",
88
+ "@abloatai/transaction": "0.66.3",
89
89
  "events": "^3.3.0",
90
90
  "mobx": "^6.13.7",
91
91
  "uuid": "^11.1.0",
@@ -16,7 +16,9 @@
16
16
  * counterparts (`paired`), which carry the value each operation established: on
17
17
  * undo the forwards say what you set, and on redo the inverses say what undo
18
18
  * restored. For `update` and `updateMany` operations it drops any field whose
19
- * live value no longer matches that established value. The `create` and
19
+ * live value no longer matches that established value. Plain JSON objects are
20
+ * compared recursively, so independent nested changes can still be undone.
21
+ * Arrays and object/type replacements remain atomic. The `create` and
20
22
  * `delete` families are structural and always applied — undoing a create
21
23
  * removes the row you added, and undoing a delete restores it.
22
24
  *
@@ -32,7 +34,7 @@ import { deepEqual } from '@abloatai/transaction/utils/json';
32
34
  * How undo and redo treat a field that a collaborator changed after your
33
35
  * operation.
34
36
  *
35
- * - `skip-stale` (the default): leave the field alone. Your change has
37
+ * - `skip-stale` (the default): leave superseded fields/JSON leaves alone. Your change has
36
38
  * already been superseded, so reverting it would overwrite the
37
39
  * collaborator's value. This is what keeps undo scoped to your own edits.
38
40
  * - `last-writer-wins`: apply the operation verbatim, so your undo overwrites
@@ -75,10 +77,35 @@ function readCurrentField(store: SyncStoreContract, id: string, field: string):
75
77
 
76
78
  type Patch = { id: string } & Record<string, unknown>;
77
79
 
80
+ // Missing object properties differ from properties whose value is undefined.
81
+ const MISSING = Symbol('missing undo property');
82
+ const ownValue = (value: Record<string, unknown>, key: string): unknown =>
83
+ Object.hasOwn(value, key) ? value[key] : MISSING;
84
+
85
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
86
+ if (value === null || typeof value !== 'object') return false;
87
+ const prototype: unknown = Object.getPrototypeOf(value);
88
+ return prototype === Object.prototype || prototype === null;
89
+ }
90
+
91
+ /** Replay changed JSON leaves; arrays and replacements remain atomic. */
92
+ function replayValue(value: unknown, established: unknown, current: unknown): unknown {
93
+ if (deepEqual(value, established)) return current;
94
+ if (deepEqual(current, established)) return value;
95
+ if (isPlainObject(value) && isPlainObject(established) && isPlainObject(current)) {
96
+ const keys = new Set([...Object.keys(current), ...Object.keys(established), ...Object.keys(value)]);
97
+ // fromEntries keeps keys such as __proto__ as ordinary own properties.
98
+ return Object.fromEntries([...keys].flatMap(key => {
99
+ const next = replayValue(ownValue(value, key), ownValue(established, key), ownValue(current, key));
100
+ return next === MISSING ? [] : [[key, next]];
101
+ }));
102
+ }
103
+ return current;
104
+ }
105
+
78
106
  /**
79
- * Keep only the fields whose live value still equals what this op established
80
- * (`established[field]`). Returns `null` if nothing survives (the whole op is a
81
- * no-op — every field was superseded by a collaborator).
107
+ * Replay only changes that still stand, retaining collaborators' JSON leaves.
108
+ * Returns `null` when no effective change survives.
82
109
  */
83
110
  function filterStalePatch(
84
111
  store: SyncStoreContract,
@@ -90,11 +117,10 @@ function filterStalePatch(
90
117
  for (const field of Object.keys(patch)) {
91
118
  if (field === 'id') continue;
92
119
  if (established && field in established) {
93
- // Apply only if the field still holds the value we established, meaning no
94
- // collaborator has overwritten it since. Otherwise skip it, so the
95
- // collaborator's change is left intact.
96
- if (deepEqual(readCurrentField(store, patch.id, field), established[field])) {
97
- out[field] = patch[field];
120
+ const current = readCurrentField(store, patch.id, field);
121
+ const next = replayValue(patch[field], established[field], current);
122
+ if (!deepEqual(next, current)) {
123
+ out[field] = next;
98
124
  kept++;
99
125
  }
100
126
  } else {
@@ -232,12 +232,12 @@ export class SubscriptionManager {
232
232
  /**
233
233
  * Re-asserts the full desired set against the transport, forgetting what was
234
234
  * previously confirmed. Call this after a reconnect: a fresh
235
- * {@link SyncWebSocket} starts from the sync groups named in the connect-time
236
- * URL, so the manager's diff baseline no longer reflects the new socket.
235
+ * {@link SyncWebSocket} starts from the groups confirmed during connection,
236
+ * so the manager's diff baseline no longer reflects the new socket.
237
237
  * Clearing that baseline makes the next reconcile push one
238
238
  * `update_subscription` frame that re-establishes the current interest —
239
239
  * including any warm or pinned groups that drifted while the connection was
240
- * down. The connect-time URL already carries the last-acknowledged set, so
240
+ * down. The connection already carries the last-acknowledged set, so
241
241
  * this is a correction, not the primary mechanism.
242
242
  */
243
243
  resync(): Promise<void> {
@@ -49,9 +49,9 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
49
49
  deps.updateSyncStatus({ offlineSince: undefined });
50
50
  }
51
51
  // Re-assert read interest on every (re)connect. After a transient
52
- // reconnect the socket re-sends its URL groups, but interest may have
53
- // changed while offline; after a full reconnect the new socket's URL
54
- // carries only base groups. `resync` re-pushes the current desired set
52
+ // reconnect the socket restores its last confirmed groups, but interest
53
+ // may have changed while offline; after a full reconnect the new socket
54
+ // starts with only base groups. `resync` re-pushes the current desired set
55
55
  // so the server-side index matches what the user is actually viewing.
56
56
  void deps.areaOfInterest.resync();
57
57
  });