@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.
- package/dist/local/mutators/undoApply.d.ts +4 -2
- package/dist/local/mutators/undoApply.js +34 -9
- package/dist/local/sync/SubscriptionManager.d.ts +3 -3
- package/dist/local/sync/SubscriptionManager.js +3 -3
- package/dist/local/sync/socketEventWiring.js +3 -3
- package/package.json +2 -2
- package/src/local/mutators/undoApply.ts +36 -10
- package/src/local/sync/SubscriptionManager.ts +3 -3
- package/src/local/sync/socketEventWiring.ts +3 -3
|
@@ -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.
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
141
|
-
*
|
|
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
|
|
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
|
|
178
|
-
*
|
|
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
|
|
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
|
|
15
|
-
// changed while offline; after a full reconnect the new socket
|
|
16
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
236
|
-
*
|
|
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
|
|
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
|
|
53
|
-
// changed while offline; after a full reconnect the new socket
|
|
54
|
-
//
|
|
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
|
});
|