Just repeating something here that I posted to Mastodon for completeness:
In my experience, and I have quite a lot with Core Data and sync in particular (https://ensembles.io), you are asking the wrong questions. Or at least, the architecture you are using is the problem. IMHO undo should never be a DB level operation. It should happen up in your business logic, where you can make high level decisions.
I have hit this many times in apps I have worked on for undo (eg Sketch, Agenda). When you take advantage of auto-undo, like what you get in Core Data, or your own registration of SQLite logs, you are taking on a technical debt, and the more complex your app gets, the more troublesome it becomes.
In a nutshell, the DB has no concept of user actions, which is what undo is about. For the DB, undo is just a collection of data changes which can be inverted. Things start to get messy when changes in the DB are not solely due to user actions. Think about some changes syncing in from a different device, or a background import. These clearly should not be undoable by the current user. So what happens if the current user does undo something, but the data was changed by the sync in the meantime? You end up in some possibly invalid, undefined state. Just mixing together two streams of concurrent data changes is too low level for this.
It also explains why you are struggling with async. Undo should never be async, because the user action is clear from the beginning. The data changes may take time, but that has nothing to do with the user action. So record the user action, rather than trying to wrangle undo into the async DB action.
My advice is to ditch DB level undo, and instead simply define an enum or similar at the business logic level for things the user can do that should be undoable. For a note taking app, for example, it might look like this
enum UndoableOperation {
case addNote(Note.ID)
case deleteNote(Note.ID)
case moveNote(Note.ID, from: Project.ID, to: Project.ID)
var inverse: UndoableOperation { ... }
}
Now you simply record these in the undo manager at an appropriate time, corresponding to what the user would expect.
When the user undoes, you apply the inverse of the original action, if possible. The nice aspect here is you can validate whether it is possible or not. If a sync has changed the DB in the meantime, it's doesn't matter. Eg. if you undo an "add note", you first check if the note is still there, and if it is, you delete it.
(I was inspired years ago by a Wil Shipley post on this with regard to Delicious Library. He basically ditched the Core Data "magic" undo, and did something like what I mention above. It is much easier than it seems. The magic undo always hurts you in the long term, as you have to disengage during imports, handle invalid data, etc It is a short term gain for long term pain.)