New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Undo stack #1933
Comments
In dexie-cloud-addon we use a middleware to track all operations on the transaction and subscribe to the transaction's commmitted event to store the operations in a dedicated table for logging them. However, dexie-cloud-addon will cleanse out all old operations once they have been synced, so it would not work for an undo buffer. Moreover it doesn't keep the old value of the operation. Don't know if your undo buffer need to be persisted or not? In any case, I think it would be best to do something similar.
If the undo buffer should be persistent you would also need to:
|
Awesome, thanks a lot for the tricks, I'll see how far I can go. Would be great to have it natively in Dexie.js! |
It would be nice with an addon for it. I've got the same question before. |
In your case, would you prefer an in-memory undo buffer or a persistent one? |
In my case an in-memory undo buffer would be good enough I'd say. |
So I tried to start experimenting, and I got undo/redo for
Is it what you had in mind and correctly performed? There are multiple things I'm not sure how to deal with right now:
I'm also a bit worried about efficiency, like I don't know if But for this I have quite a few questions:
|
So I tried here to implement the
Next step: the |
Ok, I also did it for So now I have 2 questions left:
I still need to polish this a bit (e.g. maximum undo size) and add // Create an undo mechanism based on recommendations I got from https://github.com/dexie/Dexie.js/issues/1933
// This is a middleware as defined in https://dexie.org/docs/Dexie/Dexie.use()
import type { DBCore, DBCoreMutateRequest, DBCoreTransaction } from 'dexie';
import { PlanotoFileDexie } from '$lib/database/db';
// Used to specify the list of tables that the actions might touch (needed to create a transaction)
interface ITablesAndActions {
tables: string[];
actions: ((db: PlanotoFileDexie) => void)[]
}
type MyExtendedDBCoreTransaction = DBCoreTransaction & {
myUndoTransactionsToApply?: ITablesAndActions
};
export function undoMiddleware() {
let undoArray : MyExtendedDBCoreTransaction[] = [];
let redoArray : MyExtendedDBCoreTransaction[] = [];
// Needed so that undoing an operation does not create a new Undo entry.
// There is certainly a better way but not sure how.
let normalUndoOrRedo = 0; // 0, 1 or 2 depending on the case
return {
undo: async (db : PlanotoFileDexie) => {
let middlewareEnabled = false;
console.log("Length", undoArray.length, redoArray.length);
const x = undoArray.pop();
if(x && x.myUndoTransactionsToApply) {
await db.transaction("rw", x.myUndoTransactionsToApply.tables.flat(), async () => {
normalUndoOrRedo = 1;
if(x && x.myUndoTransactionsToApply) {
await Promise.all(x.myUndoTransactionsToApply.actions.map(action => action(db)));
}
normalUndoOrRedo = 0;
});
}
},
redo: async (db : PlanotoFileDexie) => {
console.log("Starting redo");
const x = redoArray.pop();
if(x && x.myUndoTransactionsToApply) {
await db.transaction("rw", x.myUndoTransactionsToApply.tables.flat(), async () => {
normalUndoOrRedo = 2;
if(x && x.myUndoTransactionsToApply) {
await Promise.all(x.myUndoTransactionsToApply.actions.map(action => action(db)));
}
normalUndoOrRedo = 0;
});
};
},
create: (downlevelDatabase: DBCore) => {
console.log("Euh I am a middleware");
// Return your own implementation of DBCore:
return {
// Copy default implementation.
...downlevelDatabase,
// Override table method
table (tableName: string) {
console.log("I am in my custom table method");
// Call default table method
const downlevelTable = downlevelDatabase.table(tableName);
// Derive your own table from it:
return {
// Copy default table implementation:
...downlevelTable,
// Override the mutate method:
mutate: async (req: DBCoreMutateRequest & { trans: MyExtendedDBCoreTransaction }) => {
// https://github.com/dexie/Dexie.js/blob/master/src/public/types/dbcore.d.ts
console.log("normalUndoOrRedo", normalUndoOrRedo);
console.log("req", req);
switch (normalUndoOrRedo) {
case 0:
// Normal mode: we add the operation to the undo list, and we clean the redo mode
redoArray = [];
if(undoArray[undoArray.length - 1] != req.trans) {
console.log("New transaction");
undoArray.push(req.trans);
};
break;
case 1:
// In undo mode: we add the operation to the redo list
if(redoArray[redoArray.length - 1] != req.trans) {
console.log("New transaction in undo mode");
redoArray.push(req.trans);
console.log("redoArray", redoArray.length, "undoArray", undoArray.length)
};
break;
case 2:
// In redo mode: we add the operation to the undo list
if(undoArray[undoArray.length - 1] != req.trans) {
console.log("New transaction");
undoArray.push(req.trans);
};
break;
}
// if(!req.myRedoTransactionsToApply) {
// req.myRedoTransactionsToApply = [];
// }
switch (req.type) {
case "delete":
console.log("We will delete items ", req.keys);
await Promise.all(req.keys.map(async (id) => {
if(!req.trans.myUndoTransactionsToApply) {
req.trans.myUndoTransactionsToApply = {tables: [tableName], actions: []};
}
console.log("start with ", id);
console.log("will get element", id);
const elmt = await downlevelTable.get({trans: req.trans, key: id});
console.log("foo", elmt);
console.log("We will push in the undo array add ", tableName, elmt);
req.trans.myUndoTransactionsToApply.actions.push(async (db) => await db.table(tableName).add(elmt));
//req.myRedoTransactionsToApply.push(db => db.table(tableName).delete(id));
}));
break;
// For add we need to wait for the query to finish to get the ID
case "put":
//TODO
console.log("I am in put", downlevelTable.schema.primaryKey.keyPath);
const ids = req.values.map(obj => PlanotoFileDexie.getByKeyPath(obj, downlevelTable.schema.primaryKey.keyPath));
console.log("Ids to copy", ids);
await Promise.all(ids.map(async (id) => {
console.log("doing it for", id);
const elmt = await downlevelTable.get({trans: req.trans, key: id});
console.log("The elemt is", elmt);
if(!req.trans.myUndoTransactionsToApply) {
req.trans.myUndoTransactionsToApply = {tables: [tableName], actions: []};
}
req.trans.myUndoTransactionsToApply.actions.push(async (db) => db.table(tableName).put(elmt));
console.log("Element added");
}));
console.log("Done");
break;
};
console.log("My middleware is called in a mutate query!", req);
const res = await downlevelTable.mutate(req);
// We first need to run the mutation to get the id of the new object to remove
console.log("After the mutation the result is ", res);
switch (req.type) {
case "add":
console.log("It created the ids", res.results);
if(res.results) {
res.results.forEach((id, i) => {
if(!req.trans.myUndoTransactionsToApply) {
req.trans.myUndoTransactionsToApply = {tables: [tableName], actions: []};
}
req.trans.myUndoTransactionsToApply.actions.push(async (db) => await db.table(tableName).delete(id));
//req.myRedoTransactionsToApply.push(db => db.table(tableName).add(req.values[i]));
});
}
break;
}
return res
}
}
}
}
}
}
} |
Let // create a weakmap to associate state with the transaction:
const wmNormalUndoOrRedo = new WeakMap<object, number>();
...
// in the undo / redo
await db.transaction("rw", x.myUndoTransactionsToApply.tables.flat(), async (tx) => {
wmNormalUndoOrRedo.set(tx.idbtrans, 1); // or 2 for redo
if(x && x.myUndoTransactionsToApply) {
await Promise.all(x.myUndoTransactionsToApply.actions.map(action => action(db)));
}
});
...
// in the middleware
switch (wmNormalUndoOrRedo.get(trans)) {
...
} Transactions can span over multiple operations. You need both to detect failing operations and aborted transactions. Instead of pushing the trans on every mutate, I think you should override the To detect whether a transaction aborts, listen to the "abort" and "error" events of the IDBTransaction. This event can be listened to in the When abort or error happens on any 'readwrite' transaction, try finding it in the undoBuffer and remove your aborted transaction from your undoBuffer. |
To detect failing operations, just catch the call to downlevelTable.mutate() and remove anything you've pushed to your undo buffer from it. Remember to re-throw the error also. The reason for both checking aborted/errored transactions and failed operations is that operations can succeed but a later operation fail and then abort earlier operations, while a failing operation itself doesn't nescessarily abort the transaction in case the user catches it and continues doing other operations within the same transaction. |
Thanks a lot for your precious advices. But I tried to apply both advices:
Unfortunately while 1 works well within the above code, if I tried to apply both 1 & 2 I have an issue: it seems that when the EDIT I'm thinking… instead of pushing the code in EDIT2 |
Ok, so continuing my tests… now I'm trying to make it deal with aborted queries. But I'm quite disturbed by this: if I do in
and if I create a query like:
then in the console I can see |
I would love to bring an undo/redo stack to a dexie.js based app. I saw that dexie.js implements transactions, would it be technically possible to use this to define a "commit" of the database, and provide functions to list and move between commits? I don't know the internals of Dexie.js, but I'm thinking that it might already provide the notion of commits to achieve synchronization between client and server?
One might also be interested by this (sadly abandoned) project https://gitlab.com/onezoomin/bygonz/bygonz/
The text was updated successfully, but these errors were encountered: