-
-
Notifications
You must be signed in to change notification settings - Fork 33.8k
Description
Refs: #54181
The SQLite session extension provides for conflict resolution when applying changesets. The way this works within sqlite3changeset_apply() is:
- on conflict, the user-provided
xConflictcallback is called with a conflict codeeConflict - the
xConflictcallback returns one of the resolution codesSQLITE_CHANGESET_OMIT,SQLITE_CHANGESET_ABORTorSQLITE_CHANGESET_REPLACE sqlite3changeset_apply()applies the requested resolution
To create an xConflict callback, the onConflict option passed to database.applyChangeset() is transformed into a simple lambda which ignores eConflict entirely and always returns the given resolution code:
Line 482 in 552a182
| conflictCallback = [conflictInt]() -> int { return conflictInt; }; |
This is mostly fine, except where SQLITE_CHANGESET_REPLACE is concerned.
As per the documentation, this code may only be returned where the eConflict passed to the callback is SQLITE_CHANGESET_DATA or SQLITE_CHANGESET_CONFLICT. The current implementation does not honour this, and so will result in the function aborting, rolling back and returning SQLITE_MISUSE if it is asked to deal with a different type of conflict. Minimal repro:
const { DatabaseSync, SQLITE_CHANGESET_REPLACE } = require('node:sqlite');
const [ source, target ] = Array.from({ length: 2 }, () => {
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
return db;
});
source.prepare('INSERT INTO data (key) VALUES (?)').run(1);
const session = source.createSession();
source.prepare('DELETE FROM data WHERE key = ?').run(1);
target.applyChangeset(session.changeset(), { onConflict: SQLITE_CHANGESET_REPLACE });
// Uncaught [Error: not an error] {
// code: 'ERR_SQLITE_ERROR',
// errcode: 0,
// errstr: 'not an error'
// }In this instance, the conflict code passed to xConflict is SQLITE_CHANGESET_NOTFOUND, and replying with SQLITE_CHANGESET_REPLACE is illegal. The function aborts and returns SQLITE_MISUSE, and this results in an error being thrown by DatabaseSync::ApplyChangeset().
In addition, there is an issue with using CHECK_ERROR_OR_THROW here, because aborted returns from sqlite3changeset_apply() that arise from within the changeset functions' internal logic (rather than as a result of an unsuccessful database operation) don't set an error code on the database handle.
This is the cause of the nonsensical error message in the example, as CHECK_ERROR_OR_THROW ends up setting the properties of the thrown error by interrogating the database handle, not by using the error code passed to the macro.
This is slightly small-print, but not all errors returned by the changeset functions are associated with database operations (eg. SQLITE_NOMEM). It's not clear to me how important it would be to handle these cases, or indeed whether similar cases arise in other areas of node_sqlite.
In terms of things to address:
- Conflict handling needs to handle gracefully the case where
options.onConflict == SQLITE_CHANGESET_REPLACEbut the conflict code isn't one that accepts this response.- Should the callback just return
SQLITE_CHANGESET_OMITinstead in these cases? - Whatever the resolution is, the documentation will need to be clear on the new behaviour.
- Should the callback just return
- May need to look into whether error handling via
CHECK_ERROR_OR_THROWneeds to account for errors which aren't associated with the database handle.