(For some context, I'm very experienced with Apple platforms, GCD etc; fairly experienced with Swift; fairly new to SwiftUI, and brand new to Swift Concurrency.)
I have just refactored my app to do all its work/business logic inside a custom actor. Since this work is mainly done in SQL queries, I've encapsulated the sqlite database inside this actor, and given it a custom serial queue on a custom executor. Then, in all of my UI code, I've restructured things to use async calls to everywhere interacting with the database/business logic (both fetching and mutating), enforced by my custom actor. This is working great!
(Note that I have not used a custom global actor, since in theory in the future I might want to open two databases at once, and I feel they should have an actor each.)
The one thing I've not solved yet is undo. Previously, I had an undo manager that my database object (now my database actor) held onto. I also have a sqlite table that, through use of sqlite triggers, stores SQL which will do the inverse of any database operation. (This is the pattern recommended in the sqlite manual.) Immediately before each database mutation I get the current max rowID of that table, and I do that again after the mutation; I then create a closure for the undo manager to fetch and execute the sql for each row between those IDs. It worked well.
Now, I have a couple of problems. We'll start by assuming that in the new concurrent world, the undomanager will be isolated within my actor. These problems are probably related to some poor understanding of Swift Concurrency on my part:
Problem 1: how to get the undo manager's closure to run synchronously on my actor, in order to register the redo operation at the correct time
As far as I understand, to achieve a redo operation with NSUndoManager, one has to register an undo operation when one is already inside an undo operation. With the closure syntax of NSUndoManager (which is the only one usable when not working with ObjC compatible objects), I take this to mean before the undo closure returns.
Inside the undo closure, I therefore need to do some database fetches to be able to pull the relevant information needed to register the redo action, as well as to perform the database mutation for the undo action. The latter could be done async, but the former couldn't, because the redo operation has to be registered before the undo closure returns. I need to base the redo operation on the state of the database now, not on the state of the database in the future.
Thus, I cannot wrap the contents of my undo closure in a Task, or my redo operation would be registered too late. It only turns to a redo operation if it's registered inside the undo closure, otherwise it's just another undo operation.
What am I missing? Is there a way to make sure the undo closure is isolated to my actor, or else to block from within it until a Task has completed? Does the fact that my actor has its own serial queue help here?
Problem 2: tying in with SwiftUI's UndoManager (aka I really wish NSUndoManager was sendable)
If the undo manager is entirely encapsulated inside my actor, then nothing outside of my actor can talk to it.
Is the recommended pattern here to register undo actions with SwiftUI's undo manager that simply call an async function on my actor to tell its internal undo manager to undo or redo?
If so, for the registration of those undo/redo actions with the outer undo manager, will I run into the same issue that redo actions have to be registered synchronously inside the undo closure?
Surely I can't be the only one running into this? If UndoManager could take an async closure, and ensure that things counted as redo if they were registered within this async closure, it would solve things. But that'd mean the undomanager needed to manage its own serial queue too I guess.
Should I just abandon NSUndoManager and hook my own system up to the menu bar etc?
What would I lose by doing so? I fear that the system created undo managers for things like text editing might not play nice in that situation…